diff --git a/app/controllers/display_ads_controller.rb b/app/controllers/display_ads_controller.rb index 2857e2129..c685ec129 100644 --- a/app/controllers/display_ads_controller.rb +++ b/app/controllers/display_ads_controller.rb @@ -15,7 +15,7 @@ class DisplayAdsController < ApplicationController area: params[:placement_area], user_signed_in: user_signed_in?, user_id: current_user&.id, - article: ArticleDecorator.new(@article), + article: @article ? ArticleDecorator.new(@article) : nil, ) if @display_ad && !session_current_user_id diff --git a/app/javascript/articles/Feed.jsx b/app/javascript/articles/Feed.jsx index a29e2086f..060599966 100644 --- a/app/javascript/articles/Feed.jsx +++ b/app/javascript/articles/Feed.jsx @@ -3,92 +3,248 @@ import { useEffect, useState } from 'preact/hooks'; import PropTypes from 'prop-types'; import { useListNavigation } from '../shared/components/useListNavigation'; import { useKeyboardShortcuts } from '../shared/components/useKeyboardShortcuts'; +import { insertInArrayIf } from '../../javascript/utilities/insertInArrayIf'; /* global userData sendHapticMessage showLoginModal buttonFormData renderNewSidebarCount */ -export const Feed = ({ timeFrame, renderFeed }) => { +export const Feed = ({ timeFrame, renderFeed, afterRender }) => { const { reading_list_ids = [] } = userData(); // eslint-disable-line camelcase const [bookmarkedFeedItems, setBookmarkedFeedItems] = useState( new Set(reading_list_ids), ); - const [pinnedArticle, setPinnedArticle] = useState(null); + const [pinnedItem, setPinnedItem] = useState(null); + const [imageItem, setimageItem] = useState(null); const [feedItems, setFeedItems] = useState([]); - const [podcastEpisodes, setPodcastEpisodes] = useState([]); const [onError, setOnError] = useState(false); useEffect(() => { - setPodcastEpisodes(getPodcastEpisodes()); - }, []); - - useEffect(() => { - const fetchFeedItems = async () => { + const organizeFeedItems = async () => { try { if (onError) setOnError(false); - let feedItems = await getFeedItems(timeFrame); + fetchFeedItems(timeFrame).then( + ([ + feedPosts, + feedFirstBillboard, + feedSecondBillboard, + feedThirdBillboard, + ]) => { + const imagePost = getImagePost(feedPosts); + const pinnedPost = getPinnedPost(feedPosts); + const podcastPost = getPodcastEpisodes(); - // Here we extract from the feed two special items: pinned and featured + const hasSetPinnedPost = setPinnedPostItem(pinnedPost, imagePost); + const hasSetImagePostItem = setImagePostItem(imagePost); - const pinnedArticle = feedItems.find((story) => story.pinned === true); + const updatedFeedPosts = updateFeedPosts( + feedPosts, + imagePost, + pinnedPost, + ); - // Ensure first article is one with a main_image - // This is important because the featuredStory will - // appear at the top of the feed, with a larger - // main_image than any of the stories or feed elements. - const featuredStory = feedItems.find( - (story) => story.main_image !== null, + // We implement the following organization for the feed: + // 1. Place the pinned post first (if the timeframe is relevant) + // 2. Place the image post next + // 3. If you follow podcasts, place the podcast episodes that are + // published today (this is an array) + // 4. Place the rest of the stories for the feed + // 5. Insert the billboards in that array accordingly + // - feed_first: Before all home page posts + // - feed_second: Between 2nd and 3rd posts in the feed + // - feed_third: Between 7th and 8th posts in the feed + + const organizedFeedItems = [ + ...insertInArrayIf(hasSetPinnedPost, pinnedPost), + ...insertInArrayIf(hasSetImagePostItem, imagePost), + ...insertInArrayIf(podcastPost.length > 0, podcastPost), + ...updatedFeedPosts, + ]; + + const organizedFeedItemsWithBillboards = insertBillboardsInFeed( + organizedFeedItems, + feedFirstBillboard, + feedSecondBillboard, + feedThirdBillboard, + ); + + setFeedItems(organizedFeedItemsWithBillboards); + }, ); - - // If pinned and featured article aren't the same, - // (either because featuredStory is missing or because they represent two different articles), - // we set the pinnedArticle and remove it from feedItems. - // If pinned and featured are the same, we just remove it from feedItems without setting it as state. - // NB: We only show the pinned post on the "Relevant" feed (when there is no 'timeFrame' selected) - if (pinnedArticle && timeFrame === '') { - feedItems = feedItems.filter((item) => item.id !== pinnedArticle.id); - - if (pinnedArticle.id !== featuredStory?.id) { - setPinnedArticle(pinnedArticle); - } - } - - // Remove that first story from the array to - // prevent it from rendering twice in the feed. - const featuredIndex = feedItems.indexOf(featuredStory); - if (featuredStory) { - feedItems.splice(featuredIndex, 1); - } - const organizedFeedItems = [featuredStory, feedItems].flat(); - - setFeedItems(organizedFeedItems); } catch { if (!onError) setOnError(true); } }; - - fetchFeedItems(); + organizeFeedItems(); }, [timeFrame, onError]); - /** - * Retrieves feed data. - * - * @param {number} [page=1] Page of feed data to retrieve - * - * @returns {Promise} A promise containing the JSON response for the feed data. - */ - async function getFeedItems(timeFrame = '', page = 1) { - const response = await fetch(`/stories/feed/${timeFrame}?page=${page}`, { - method: 'GET', - headers: { - Accept: 'application/json', - 'X-CSRF-Token': window.csrfToken, - 'Content-Type': 'application/json', - }, - credentials: 'same-origin', - }); - return await response.json(); + useEffect(() => { + if (feedItems.length > 0) { + afterRender(); + } + }, [feedItems.length]); + // /** + // * Retrieves the imagePost which will later appear at the top of the feed, + // * with a larger main_image than any of the stories or feed elements. + // * + // * @param {Array} The original feed posts that are retrieved from the endpoint. + // * + // * @returns {Object} The first post with a main_image + // */ + function getImagePost(feedPosts) { + return feedPosts.find((post) => post.main_image !== null); } + // /** + // * Retrieves the pinnedPost which will later appear at the top the feed with a pin. + // * + // * @param {Array} The original feed posts that are retrieved from the endpoint. + // * + // * @returns {Object} The first post that has pinned set to true + // */ + function getPinnedPost(feedPosts) { + return feedPosts.find((post) => post.pinned === true); + } + + // /** + // * Sets the Pinned Item into state. + // * + // * @param {Object} The pinnedPost + // * @param {Object} The imagePost + // * + // * @returns {boolean} If we set the pinned post we return true else we return false + // */ + function setPinnedPostItem(pinnedPost, imagePost) { + // We only show the pinned post on the "Relevant" feed (when there is no 'timeFrame' selected) + if (!pinnedPost || timeFrame !== '') return false; + + // If the pinned and the image post aren't the same, (either because imagePost is missing or + // because they represent two different posts), we set the pinnedPost + if (pinnedPost.id !== imagePost?.id) { + setPinnedItem(pinnedPost); + return true; + } + + return false; + } + + // /** + // * Sets the Image Item into state. + // * + // * @param {Object} The imagePost + // * + // * @returns {boolean} If we set the pinned post we return true + // */ + function setImagePostItem(imagePost) { + if (imagePost) { + setimageItem(imagePost); + return true; + } + } + + // /** + // * Updates the feedPosts to remove the relevant items like the pinned + // * post and the image post that will be added to the top of final organized feed + // * items separately. We do not want duplication. + // * + // * @param {Array} The original feed posts that are retrieved from the endpoint. + // * @param {Object} The imagePost + // * @param {Object} The pinnedPost + // * + // * @returns {Array} We return the new array that no longer contains the pinned post or the image post. + // */ + function updateFeedPosts(feedPosts, imagePost, pinnedPost) { + let filteredFeedPost = feedPosts; + if (pinnedPost) { + filteredFeedPost = feedPosts.filter((item) => item.id !== pinnedPost.id); + } + + if (imagePost) { + const imagePostIndex = filteredFeedPost.indexOf(imagePost); + filteredFeedPost.splice(imagePostIndex, 1); + } + + return filteredFeedPost; + } + + // /** + // * Inserts the billboards (if they exist) into the feed. + // * + // * @param {organizedFeedItems} The partially organized feed items. + // * @param {String} feedFirstBillboard is the feed_first billboard retrieved from an endpoint. + // * @param {String} feedSecondBillboard is the feed_second billboard retrieved from an endpoint. + // * @param {String} feedThirdBillboard is the feed_third billboard retrieved from an endpoint. + // * + // * @returns {Array} We return the array containing the billboards slotted into the correct positions. + // */ + function insertBillboardsInFeed( + organizedFeedItems, + feedFirstBillboard, + feedSecondBillboard, + feedThirdBillboard, + ) { + if (organizedFeedItems.length >= 9 && feedThirdBillboard) { + organizedFeedItems.splice(7, 0, feedThirdBillboard); + } + + if (organizedFeedItems.length >= 3 && feedSecondBillboard) { + organizedFeedItems.splice(2, 0, feedSecondBillboard); + } + + if (organizedFeedItems.length >= 0 && feedFirstBillboard) { + organizedFeedItems.splice(0, 0, feedFirstBillboard); + } + + return organizedFeedItems; + } + + // /** + // * Retrieves data for the feed. The data will include articles and billboards. + // * + // * @param {number} [page=1] Page of feed data to retrieve + // * @param {string} The time frame of feed data to retrieve + // * + // * @returns {Promise} A promise containing the JSON response for the feed data. + // */ + async function fetchFeedItems(timeFrame = '', page = 1) { + const [ + feedPostsResponse, + feedFirstBillboardResponse, + feedSecondBillboardResponse, + feedThirdBillboardResponse, + ] = await Promise.all([ + fetch(`/stories/feed/${timeFrame}?page=${page}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'X-CSRF-Token': window.csrfToken, + 'Content-Type': 'application/json', + }, + credentials: 'same-origin', + }), + fetch(`/display_ads/feed_first`), + fetch(`/display_ads/feed_second`), + fetch(`/display_ads/feed_third`), + ]); + + const feedPosts = await feedPostsResponse.json(); + const feedFirstBillboard = await feedFirstBillboardResponse.text(); + const feedSecondBillboard = await feedSecondBillboardResponse.text(); + const feedThirdBillboard = await feedThirdBillboardResponse.text(); + + return [ + feedPosts, + feedFirstBillboard, + feedSecondBillboard, + feedThirdBillboard, + ]; + } + + // /** + // * Retrieves the podcasts for the feed from the user data and the `followed-podcasts` + // * div item. + // * + // * @returns {Object} An Object containing today's podcast episodes for the podcasts found in followed_podcast_ids. + // */ function getPodcastEpisodes() { const el = document.getElementById('followed-podcasts'); const user = userData(); // Global @@ -109,7 +265,8 @@ export const Feed = ({ timeFrame, renderFeed }) => { } /** - * Dispatches a click event to bookmark/unbookmark an article. + * Dispatches a click event to bookmark/unbookmark an article and sets the ID's of the + * updated bookmark feed items. * * @param {Event} event */ @@ -182,9 +339,9 @@ export const Feed = ({ timeFrame, renderFeed }) => { ) : ( renderFeed({ - pinnedArticle, + pinnedItem, + imageItem, feedItems, - podcastEpisodes, bookmarkedFeedItems, bookmarkClick, }) diff --git a/app/javascript/articles/__tests__/Feed.test.jsx b/app/javascript/articles/__tests__/Feed.test.jsx new file mode 100644 index 000000000..697c2a6be --- /dev/null +++ b/app/javascript/articles/__tests__/Feed.test.jsx @@ -0,0 +1,226 @@ +/* eslint-disable no-irregular-whitespace */ +import { h } from 'preact'; +import { render, waitFor } from '@testing-library/preact'; +import fetch from 'jest-fetch-mock'; +import '@testing-library/jest-dom'; +import { Feed } from '../Feed'; +import { + feedPosts, + feedPostsWherePinnedAndImagePostsSame, + firstBillboard, + secondBillboard, + thirdBillboard, + podcastEpisodes, +} from './utilities/feedUtilities'; +import '../../../assets/javascripts/lib/xss'; +import '../../../assets/javascripts/utilities/timeAgo'; + +global.fetch = fetch; + +describe(' component', () => { + const getUserData = () => { + return { + followed_tag_names: ['javascript'], + followed_podcast_ids: [1], //should we make this dynamic + profile_image_90: 'mock_url_link', + name: 'firstname lastname', + username: 'username', + reading_list_ids: [], + }; + }; + + beforeAll(() => { + global.userData = jest.fn(() => getUserData()); + document.body.setAttribute('data-user', JSON.stringify(getUserData())); + + const node = document.createElement('div'); + node.setAttribute('id', 'followed-podcasts'); + node.setAttribute('data-episodes', JSON.stringify(podcastEpisodes)); + document.body.appendChild(node); + }); + + describe('feedItem organization', () => { + let callback; + beforeAll(() => { + fetch.mockResponseOnce(JSON.stringify(feedPosts)); + fetch.mockResponseOnce(firstBillboard); + fetch.mockResponseOnce(secondBillboard); + fetch.mockResponseOnce(thirdBillboard); + + callback = jest.fn(); + render(); + }); + + it('should return the correct length of feedItems', async () => { + await waitFor(() => { + const lastCallbackResult = + callback.mock.calls[callback.mock.calls.length - 1][0]; + expect(lastCallbackResult.feedItems.length).toEqual(14); + }); + }); + + it('should set the pinnedItem and place it correctly in the feed', async () => { + await waitFor(() => { + const lastCallbackResult = + callback.mock.calls[callback.mock.calls.length - 1][0]; + const firstPinnedItem = feedPosts.find((o) => o.pinned === true); + expect(lastCallbackResult.pinnedItem).toEqual(firstPinnedItem); + expect(lastCallbackResult.feedItems[1]).toEqual(firstPinnedItem); + }); + }); + + it('should set the imageItem and place it correctly in the feed', async () => { + await waitFor(() => { + const lastCallbackResult = + callback.mock.calls[callback.mock.calls.length - 1][0]; + const firstImageItem = feedPosts.find( + (post) => post.main_image !== null, + ); + expect(lastCallbackResult.imageItem).toEqual(firstImageItem); + expect(lastCallbackResult.feedItems[2]).toEqual(firstImageItem); + }); + }); + + it('should place the billboards correctly within the feedItems', async () => { + await waitFor(() => { + const lastCallbackResult = + callback.mock.calls[callback.mock.calls.length - 1][0]; + expect(lastCallbackResult.feedItems[0]).toEqual(firstBillboard); + expect(lastCallbackResult.feedItems[3]).toEqual(secondBillboard); + expect(lastCallbackResult.feedItems[9]).toEqual(thirdBillboard); + }); + }); + + it('should place the podcasts correctly within feedItems', async () => { + await waitFor(() => { + const lastCallbackResult = + callback.mock.calls[callback.mock.calls.length - 1][0]; + expect(lastCallbackResult.feedItems[4]).toEqual(podcastEpisodes); + }); + }); + }); + + describe('when pinned and image posts are the same', () => { + let callback; + beforeAll(() => { + fetch.mockResponseOnce( + JSON.stringify(feedPostsWherePinnedAndImagePostsSame), + ); + fetch.mockResponseOnce(firstBillboard); + fetch.mockResponseOnce(secondBillboard); + fetch.mockResponseOnce(thirdBillboard); + + callback = jest.fn(); + render(); + }); + + it('should not set a pinned item', async () => { + const lastCallbackResult = + callback.mock.calls[callback.mock.calls.length - 1][0]; + const postAndImageItem = feedPostsWherePinnedAndImagePostsSame.find( + (post) => post.main_image !== null && post.pinned === true, + ); + + expect(lastCallbackResult.pinnedItem).toEqual(null); + expect(lastCallbackResult.imageItem).toEqual(postAndImageItem); + expect(lastCallbackResult.feedItems[1]).toEqual(postAndImageItem); + expect(lastCallbackResult.feedItems[2]).not.toEqual(postAndImageItem); + }); + }); + + describe("when the timeframe prop is 'latest'", () => { + let callback; + beforeAll(() => { + fetch.mockResponseOnce(JSON.stringify(feedPosts)); + fetch.mockResponseOnce(firstBillboard); + fetch.mockResponseOnce(secondBillboard); + fetch.mockResponseOnce(thirdBillboard); + + callback = jest.fn(); + render(); + }); + + it('should not set the pinned items', async () => { + const lastCallbackResult = + callback.mock.calls[callback.mock.calls.length - 1][0]; + expect(lastCallbackResult.pinnedItem).toEqual(null); + }); + + it('should return the correct length of feedItems (by excluding pinned item)', async () => { + await waitFor(() => { + const lastCallbackResult = + callback.mock.calls[callback.mock.calls.length - 1][0]; + expect(lastCallbackResult.feedItems.length).toEqual(13); + }); + }); + }); + + describe("when we there isn't all three billboards on the home feed", () => { + describe("when there isn't a feed_second billboard", () => { + let callback; + beforeAll(() => { + fetch.mockResponseOnce(JSON.stringify(feedPosts)); + fetch.mockResponseOnce(firstBillboard); + fetch.mockResponseOnce(undefined); + fetch.mockResponseOnce(thirdBillboard); + + callback = jest.fn(); + render(); + }); + + it('should return the correct length of feedItems', async () => { + await waitFor(() => { + const lastCallbackResult = + callback.mock.calls[callback.mock.calls.length - 1][0]; + expect(lastCallbackResult.feedItems.length).toEqual(13); + }); + }); + + it('should still amend the organization of the feedItems correctly', async () => { + await waitFor(() => { + const lastCallbackResult = + callback.mock.calls[callback.mock.calls.length - 1][0]; + expect(lastCallbackResult.feedItems[0]).toEqual(firstBillboard); + // there is no second bilboard so podcasts get rendered in 4th place + expect(lastCallbackResult.feedItems[3]).toEqual(podcastEpisodes); + expect(lastCallbackResult.feedItems[8]).toEqual(thirdBillboard); + }); + }); + }); + + describe("when there isn't a feed_first or feed_second billboard", () => { + let callback; + beforeAll(() => { + fetch.mockResponseOnce(JSON.stringify(feedPosts)); + fetch.mockResponseOnce(undefined); + fetch.mockResponseOnce(undefined); + fetch.mockResponseOnce(thirdBillboard); + + callback = jest.fn(); + render(); + }); + + it('should return the correct length of feedItems', async () => { + await waitFor(() => { + const lastCallbackResult = + callback.mock.calls[callback.mock.calls.length - 1][0]; + expect(lastCallbackResult.feedItems.length).toEqual(12); + }); + }); + + it('should still amend the organization of the feedItems correctly', async () => { + await waitFor(() => { + const lastCallbackResult = + callback.mock.calls[callback.mock.calls.length - 1][0]; + + const pinnedItem = feedPosts.find((o) => o.pinned === true); + // there is no first billboard + expect(lastCallbackResult.feedItems[0]).toEqual(pinnedItem); + // there is no second bilboard so podcsats get rendered in 3rd place + expect(lastCallbackResult.feedItems[2]).toEqual(podcastEpisodes); + expect(lastCallbackResult.feedItems[7]).toEqual(thirdBillboard); + }); + }); + }); + }); +}); diff --git a/app/javascript/articles/__tests__/utilities/feedUtilities.js b/app/javascript/articles/__tests__/utilities/feedUtilities.js new file mode 100644 index 000000000..32013d692 --- /dev/null +++ b/app/javascript/articles/__tests__/utilities/feedUtilities.js @@ -0,0 +1,685 @@ +export const assetPath = (relativeUrl) => `/images/${relativeUrl}`; + +export const feedPosts = [ + { + title: 'That Good Night Et molestias', + path: '/douglas_esmeralda/that-good-night-et-molestias-5fak', + id: 85, + user_id: 9, + comments_count: 0, + public_reactions_count: 3, + organization_id: null, + reading_time: 1, + video_thumbnail_url: null, + video: null, + video_duration_in_minutes: '00:00', + experience_level_rating: 5, + experience_level_rating_distribution: 5, + user: { + name: 'Esmeralda "The Esmeralda" Douglas \\:/', + username: 'douglas_esmeralda', + slug: 'douglas_esmeralda', + profile_image_90: + '/uploads/user/profile_image/9/a80aa112-0bbc-4c25-9535-3a21d0cd56a0.png', + profile_image_url: + '/uploads/user/profile_image/9/a80aa112-0bbc-4c25-9535-3a21d0cd56a0.png', + }, + pinned: false, + main_image: 'https://pigment.github.io/fake-logos/logos/medium/color/1.png', + tag_list: ['performance', 'be', 'javascript'], + readable_publish_date: 'May 15', + flare_tag: null, + class_name: 'Article', + cloudinary_video_url: null, + published_at_int: 1684156003, + published_timestamp: '2023-05-15T13:06:43Z', + main_image_background_hex_color: '#dddddd', + public_reaction_categories: [ + { + slug: 'like', + name: 'Like', + icon: 'sparkle-heart', + position: 1, + }, + { + slug: 'unicorn', + name: 'Unicorn', + icon: 'multi-unicorn', + position: 2, + }, + ], + top_comments: [], + }, + { + title: 'After Many a Summer Dies the Swan Qui pariatur', + path: '/tyler_blanda/after-many-a-summer-dies-the-swan-qui-pariatur-3o0p', + id: 142, + user_id: 6, + comments_count: 0, + public_reactions_count: 3, + organization_id: null, + reading_time: 1, + video_thumbnail_url: null, + video: null, + video_duration_in_minutes: '00:00', + experience_level_rating: 5, + experience_level_rating_distribution: 5, + user: { + name: 'Tyler "The Tyler" Blanda \\:/', + username: 'tyler_blanda', + slug: 'tyler_blanda', + profile_image_90: + '/uploads/user/profile_image/6/3cc1898b-cd2a-4ec2-a379-e0c9102a2262.png', + profile_image_url: + '/uploads/user/profile_image/6/3cc1898b-cd2a-4ec2-a379-e0c9102a2262.png', + }, + pinned: false, + main_image: + 'https://pigment.github.io/fake-logos/logos/medium/color/12.png', + tag_list: [], + readable_publish_date: 'May 15', + flare_tag: null, + class_name: 'Article', + cloudinary_video_url: null, + published_at_int: 1684156197, + published_timestamp: '2023-05-15T13:09:57Z', + main_image_background_hex_color: '#dddddd', + public_reaction_categories: [ + { + slug: 'like', + name: 'Like', + icon: 'sparkle-heart', + position: 1, + }, + { + slug: 'unicorn', + name: 'Unicorn', + icon: 'multi-unicorn', + position: 2, + }, + ], + top_comments: [], + }, + { + title: 'Number the Stars Et ab', + path: '/schroeder_irvin/number-the-stars-et-ab-18p4', + id: 143, + user_id: 2, + comments_count: 0, + public_reactions_count: 4, + organization_id: null, + reading_time: 1, + video_thumbnail_url: null, + video: null, + video_duration_in_minutes: '00:00', + experience_level_rating: 5, + experience_level_rating_distribution: 5, + user: { + name: 'Irvin "The Irvin" Schroeder \\:/', + username: 'schroeder_irvin', + slug: 'schroeder_irvin', + profile_image_90: + '/uploads/user/profile_image/2/8eb14853-857f-4502-a9fd-507b27c6c300.png', + profile_image_url: + '/uploads/user/profile_image/2/8eb14853-857f-4502-a9fd-507b27c6c300.png', + }, + pinned: false, + main_image: 'https://pigment.github.io/fake-logos/logos/medium/color/7.png', + tag_list: [], + readable_publish_date: 'May 15', + flare_tag: null, + class_name: 'Article', + cloudinary_video_url: null, + published_at_int: 1684156197, + published_timestamp: '2023-05-15T13:09:57Z', + main_image_background_hex_color: '#dddddd', + public_reaction_categories: [ + { + slug: 'like', + name: 'Like', + icon: 'sparkle-heart', + position: 1, + }, + { + slug: 'unicorn', + name: 'Unicorn', + icon: 'multi-unicorn', + position: 2, + }, + ], + top_comments: [], + }, + { + title: 'Now Sleeps the Crimson Petal Nihil aut', + path: '/schroeder_irvin/now-sleeps-the-crimson-petal-nihil-aut-a8o', + id: 141, + user_id: 2, + comments_count: 0, + public_reactions_count: 4, + organization_id: null, + reading_time: 1, + video_thumbnail_url: null, + video: null, + video_duration_in_minutes: '00:00', + experience_level_rating: 5, + experience_level_rating_distribution: 5, + user: { + name: 'Irvin "The Irvin" Schroeder \\:/', + username: 'schroeder_irvin', + slug: 'schroeder_irvin', + profile_image_90: + '/uploads/user/profile_image/2/8eb14853-857f-4502-a9fd-507b27c6c300.png', + profile_image_url: + '/uploads/user/profile_image/2/8eb14853-857f-4502-a9fd-507b27c6c300.png', + }, + pinned: true, + main_image: 'https://pigment.github.io/fake-logos/logos/medium/color/6.png', + tag_list: [], + readable_publish_date: 'May 15', + flare_tag: null, + class_name: 'Article', + cloudinary_video_url: null, + published_at_int: 1684156197, + published_timestamp: '2023-05-15T13:09:57Z', + main_image_background_hex_color: '#dddddd', + public_reaction_categories: [ + { + slug: 'like', + name: 'Like', + icon: 'sparkle-heart', + position: 1, + }, + { + slug: 'unicorn', + name: 'Unicorn', + icon: 'multi-unicorn', + position: 2, + }, + ], + top_comments: [], + }, + { + title: 'Dying of the Light Et libero', + path: '/thiel_erminia/dying-of-the-light-et-libero-2b70', + id: 151, + user_id: 68, + comments_count: 0, + public_reactions_count: 3, + organization_id: null, + reading_time: 1, + video_thumbnail_url: null, + video: null, + video_duration_in_minutes: '00:00', + experience_level_rating: 5, + experience_level_rating_distribution: 5, + user: { + name: 'Erminia "The Erminia" Thiel \\:/', + username: 'thiel_erminia', + slug: 'thiel_erminia', + profile_image_90: + '/uploads/user/profile_image/68/bb57f764-9b55-475f-b54e-4135bff64379.png', + profile_image_url: + '/uploads/user/profile_image/68/bb57f764-9b55-475f-b54e-4135bff64379.png', + }, + pinned: false, + main_image: 'https://pigment.github.io/fake-logos/logos/medium/color/6.png', + tag_list: [], + readable_publish_date: 'May 15', + flare_tag: null, + class_name: 'Article', + cloudinary_video_url: null, + published_at_int: 1684156198, + published_timestamp: '2023-05-15T13:09:58Z', + main_image_background_hex_color: '#dddddd', + public_reaction_categories: [ + { + slug: 'like', + name: 'Like', + icon: 'sparkle-heart', + position: 1, + }, + ], + top_comments: [], + }, + { + title: 'A Confederacy of Dunces Ut et', + path: '/macgyver_anamaria/a-confederacy-of-dunces-ut-et-5618', + id: 107, + user_id: 69, + comments_count: 0, + public_reactions_count: 4, + organization_id: null, + reading_time: 1, + video_thumbnail_url: null, + video: null, + video_duration_in_minutes: '00:00', + experience_level_rating: 5, + experience_level_rating_distribution: 5, + user: { + name: 'Anamaria "The Anamaria" MacGyver \\:/', + username: 'macgyver_anamaria', + slug: 'macgyver_anamaria', + profile_image_90: + '/uploads/user/profile_image/69/10f094e2-707a-483a-9e6f-ca2ca59c5ac3.png', + profile_image_url: + '/uploads/user/profile_image/69/10f094e2-707a-483a-9e6f-ca2ca59c5ac3.png', + }, + pinned: false, + main_image: 'https://pigment.github.io/fake-logos/logos/medium/color/5.png', + tag_list: [], + readable_publish_date: 'May 15', + flare_tag: null, + class_name: 'Article', + cloudinary_video_url: null, + published_at_int: 1684156194, + published_timestamp: '2023-05-15T13:09:54Z', + main_image_background_hex_color: '#dddddd', + public_reaction_categories: [ + { + slug: 'like', + name: 'Like', + icon: 'sparkle-heart', + position: 1, + }, + { + slug: 'unicorn', + name: 'Unicorn', + icon: 'multi-unicorn', + position: 2, + }, + ], + top_comments: [], + }, + { + title: 'The Torment of Others Quia recusandae', + path: '/douglas_esmeralda/the-torment-of-others-quia-recusandae-4jmh', + id: 126, + user_id: 9, + comments_count: 0, + public_reactions_count: 2, + organization_id: null, + reading_time: 1, + video_thumbnail_url: null, + video: null, + video_duration_in_minutes: '00:00', + experience_level_rating: 5, + experience_level_rating_distribution: 5, + user: { + name: 'Esmeralda "The Esmeralda" Douglas \\:/', + username: 'douglas_esmeralda', + slug: 'douglas_esmeralda', + profile_image_90: + '/uploads/user/profile_image/9/a80aa112-0bbc-4c25-9535-3a21d0cd56a0.png', + profile_image_url: + '/uploads/user/profile_image/9/a80aa112-0bbc-4c25-9535-3a21d0cd56a0.png', + }, + pinned: false, + main_image: + 'https://pigment.github.io/fake-logos/logos/medium/color/13.png', + tag_list: [], + readable_publish_date: 'May 15', + flare_tag: null, + class_name: 'Article', + cloudinary_video_url: null, + published_at_int: 1684156195, + published_timestamp: '2023-05-15T13:09:55Z', + main_image_background_hex_color: '#dddddd', + public_reaction_categories: [ + { + slug: 'unicorn', + name: 'Unicorn', + icon: 'multi-unicorn', + position: 2, + }, + ], + top_comments: [], + }, + { + title: 'Ah, Wilderness! Ad consectetur', + path: '/tyler_blanda/ah-wilderness-ad-consectetur-2cj9', + id: 146, + user_id: 6, + comments_count: 0, + public_reactions_count: 2, + organization_id: null, + reading_time: 1, + video_thumbnail_url: null, + video: null, + video_duration_in_minutes: '00:00', + experience_level_rating: 5, + experience_level_rating_distribution: 5, + user: { + name: 'Tyler "The Tyler" Blanda \\:/', + username: 'tyler_blanda', + slug: 'tyler_blanda', + profile_image_90: + '/uploads/user/profile_image/6/3cc1898b-cd2a-4ec2-a379-e0c9102a2262.png', + profile_image_url: + '/uploads/user/profile_image/6/3cc1898b-cd2a-4ec2-a379-e0c9102a2262.png', + }, + pinned: false, + main_image: + 'https://pigment.github.io/fake-logos/logos/medium/color/13.png', + tag_list: [], + readable_publish_date: 'May 15', + flare_tag: null, + class_name: 'Article', + cloudinary_video_url: null, + published_at_int: 1684156197, + published_timestamp: '2023-05-15T13:09:57Z', + main_image_background_hex_color: '#dddddd', + public_reaction_categories: [ + { + slug: 'unicorn', + name: 'Unicorn', + icon: 'multi-unicorn', + position: 2, + }, + ], + top_comments: [], + }, + { + title: 'Nectar in a Sieve Voluptas iusto', + path: '/o_hara_aaron/nectar-in-a-sieve-voluptas-iusto-2937', + id: 109, + user_id: 5, + comments_count: 0, + public_reactions_count: 3, + organization_id: null, + reading_time: 1, + video_thumbnail_url: null, + video: null, + video_duration_in_minutes: '00:00', + experience_level_rating: 5, + experience_level_rating_distribution: 5, + user: { + name: 'Aaron "The Aaron" O\'Hara \\:/', + username: 'o_hara_aaron', + slug: 'o_hara_aaron', + profile_image_90: + '/uploads/user/profile_image/5/e7705afd-19be-49a9-9a77-c20c061d3248.png', + profile_image_url: + '/uploads/user/profile_image/5/e7705afd-19be-49a9-9a77-c20c061d3248.png', + }, + pinned: false, + main_image: + 'https://pigment.github.io/fake-logos/logos/medium/color/10.png', + tag_list: [], + readable_publish_date: 'May 15', + flare_tag: null, + class_name: 'Article', + cloudinary_video_url: null, + published_at_int: 1684156194, + published_timestamp: '2023-05-15T13:09:54Z', + main_image_background_hex_color: '#dddddd', + public_reaction_categories: [ + { + slug: 'like', + name: 'Like', + icon: 'sparkle-heart', + position: 1, + }, + { + slug: 'unicorn', + name: 'Unicorn', + icon: 'multi-unicorn', + position: 2, + }, + ], + top_comments: [], + }, + { + title: 'The Golden Bowl Unde velit', + path: '/o_hara_aaron/the-golden-bowl-unde-velit-2d8e', + id: 106, + user_id: 5, + comments_count: 0, + public_reactions_count: 2, + organization_id: null, + reading_time: 1, + video_thumbnail_url: null, + video: null, + video_duration_in_minutes: '00:00', + experience_level_rating: 5, + experience_level_rating_distribution: 5, + user: { + name: 'Aaron "The Aaron" O\'Hara \\:/', + username: 'o_hara_aaron', + slug: 'o_hara_aaron', + profile_image_90: + '/uploads/user/profile_image/5/e7705afd-19be-49a9-9a77-c20c061d3248.png', + profile_image_url: + '/uploads/user/profile_image/5/e7705afd-19be-49a9-9a77-c20c061d3248.png', + }, + pinned: false, + main_image: + 'https://pigment.github.io/fake-logos/logos/medium/color/10.png', + tag_list: [], + readable_publish_date: 'May 15', + flare_tag: null, + class_name: 'Article', + cloudinary_video_url: null, + published_at_int: 1684156193, + published_timestamp: '2023-05-15T13:09:53Z', + main_image_background_hex_color: '#dddddd', + public_reaction_categories: [ + { + slug: 'like', + name: 'Like', + icon: 'sparkle-heart', + position: 1, + }, + { + slug: 'unicorn', + name: 'Unicorn', + icon: 'multi-unicorn', + position: 2, + }, + ], + top_comments: [], + }, +]; + +export const feedPostsWherePinnedAndImagePostsSame = [ + { + title: 'That Good Night Et molestias', + path: '/douglas_esmeralda/that-good-night-et-molestias-5fak', + id: 85, + user_id: 9, + comments_count: 0, + public_reactions_count: 3, + organization_id: null, + reading_time: 1, + video_thumbnail_url: null, + video: null, + video_duration_in_minutes: '00:00', + experience_level_rating: 5, + experience_level_rating_distribution: 5, + user: { + name: 'Esmeralda "The Esmeralda" Douglas \\:/', + username: 'douglas_esmeralda', + slug: 'douglas_esmeralda', + profile_image_90: + '/uploads/user/profile_image/9/a80aa112-0bbc-4c25-9535-3a21d0cd56a0.png', + profile_image_url: + '/uploads/user/profile_image/9/a80aa112-0bbc-4c25-9535-3a21d0cd56a0.png', + }, + pinned: true, + main_image: 'https://pigment.github.io/fake-logos/logos/medium/color/1.png', + tag_list: ['performance', 'be', 'javascript'], + readable_publish_date: 'May 15', + flare_tag: null, + class_name: 'Article', + cloudinary_video_url: null, + published_at_int: 1684156003, + published_timestamp: '2023-05-15T13:06:43Z', + main_image_background_hex_color: '#dddddd', + public_reaction_categories: [ + { + slug: 'like', + name: 'Like', + icon: 'sparkle-heart', + position: 1, + }, + { + slug: 'unicorn', + name: 'Unicorn', + icon: 'multi-unicorn', + position: 2, + }, + ], + top_comments: [], + }, + { + title: 'After Many a Summer Dies the Swan Qui pariatur', + path: '/tyler_blanda/after-many-a-summer-dies-the-swan-qui-pariatur-3o0p', + id: 142, + user_id: 6, + comments_count: 0, + public_reactions_count: 3, + organization_id: null, + reading_time: 1, + video_thumbnail_url: null, + video: null, + video_duration_in_minutes: '00:00', + experience_level_rating: 5, + experience_level_rating_distribution: 5, + user: { + name: 'Tyler "The Tyler" Blanda \\:/', + username: 'tyler_blanda', + slug: 'tyler_blanda', + profile_image_90: + '/uploads/user/profile_image/6/3cc1898b-cd2a-4ec2-a379-e0c9102a2262.png', + profile_image_url: + '/uploads/user/profile_image/6/3cc1898b-cd2a-4ec2-a379-e0c9102a2262.png', + }, + pinned: false, + main_image: + 'https://pigment.github.io/fake-logos/logos/medium/color/12.png', + tag_list: [], + readable_publish_date: 'May 15', + flare_tag: null, + class_name: 'Article', + cloudinary_video_url: null, + published_at_int: 1684156197, + published_timestamp: '2023-05-15T13:09:57Z', + main_image_background_hex_color: '#dddddd', + public_reaction_categories: [ + { + slug: 'like', + name: 'Like', + icon: 'sparkle-heart', + position: 1, + }, + { + slug: 'unicorn', + name: 'Unicorn', + icon: 'multi-unicorn', + position: 2, + }, + ], + top_comments: [], + }, + { + title: 'Number the Stars Et ab', + path: '/schroeder_irvin/number-the-stars-et-ab-18p4', + id: 143, + user_id: 2, + comments_count: 0, + public_reactions_count: 4, + organization_id: null, + reading_time: 1, + video_thumbnail_url: null, + video: null, + video_duration_in_minutes: '00:00', + experience_level_rating: 5, + experience_level_rating_distribution: 5, + user: { + name: 'Irvin "The Irvin" Schroeder \\:/', + username: 'schroeder_irvin', + slug: 'schroeder_irvin', + profile_image_90: + '/uploads/user/profile_image/2/8eb14853-857f-4502-a9fd-507b27c6c300.png', + profile_image_url: + '/uploads/user/profile_image/2/8eb14853-857f-4502-a9fd-507b27c6c300.png', + }, + pinned: false, + main_image: 'https://pigment.github.io/fake-logos/logos/medium/color/7.png', + tag_list: [], + readable_publish_date: 'May 15', + flare_tag: null, + class_name: 'Article', + cloudinary_video_url: null, + published_at_int: 1684156197, + published_timestamp: '2023-05-15T13:09:57Z', + main_image_background_hex_color: '#dddddd', + public_reaction_categories: [ + { + slug: 'like', + name: 'Like', + icon: 'sparkle-heart', + position: 1, + }, + { + slug: 'unicorn', + name: 'Unicorn', + icon: 'multi-unicorn', + position: 2, + }, + ], + top_comments: [], + }, +]; + +export const podcastEpisodes = [ + { + slug: 's22e8-from-opera-to-code-anna-mcdougall', + title: 'S22:E8 - From Opera to Code (Anna McDougall)', + podcast_id: 1, + image: { url: null }, + id: null, + tag_list: [], + podcast: { + id: 1, + title: 'CodeNewbie', + slug: 'codenewbie', + image_90: + '/uploads/podcast/image/1/3f7dca78-b9fb-4b7f-b985-49b34417d37b.jpeg', + }, + }, + { + slug: 's22e2-building-the-bridge-across-the-tech-gap-michelle-glauser', + title: + 'S22:E2 - Building the bridge across the tech gap (Michelle Glauser)', + podcast_id: 1, + image: { url: null }, + id: null, + tag_list: [], + podcast: { + id: 1, + title: 'CodeNewbie', + slug: 'codenewbie', + image_90: + '/uploads/podcast/image/1/3f7dca78-b9fb-4b7f-b985-49b34417d37b.jpeg', + }, + }, + { + slug: 's22e7-starting-out-in-open-source-brian-douglas', + title: 'S22:E7 - Starting out in Open Source (Brian Douglas)', + podcast_id: 1, + image: { url: null }, + id: null, + tag_list: [], + podcast: { + id: 1, + title: 'CodeNewbie', + slug: 'codenewbie', + image_90: + '/uploads/podcast/image/1/3f7dca78-b9fb-4b7f-b985-49b34417d37b.jpeg', + }, + }, +]; + +export const firstBillboard = 'billboard one'; +export const secondBillboard = 'billboard two'; +export const thirdBillboard = 'billboard three'; diff --git a/app/javascript/packs/billboardAfterRenderActions.js b/app/javascript/packs/billboardAfterRenderActions.js new file mode 100644 index 000000000..4c6d73ef1 --- /dev/null +++ b/app/javascript/packs/billboardAfterRenderActions.js @@ -0,0 +1,117 @@ +/* global userData */ +// This is currently a duplicate of app/assets/javascript/initializers/initializeDisplayAdVisibility. +export function initializeDisplayAdVisibility() { + const displayAds = document.querySelectorAll('[data-display-unit]'); + + if (displayAds && displayAds.length == 0) { + return; + } + + const user = userData(); + + displayAds.forEach((ad) => { + if (user && !user.display_sponsors && ad.dataset['typeOf'] == 'external') { + ad.classList.add('hidden'); + } else { + ad.classList.remove('hidden'); + } + }); +} + +export function observeDisplayAds() { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + const elem = entry.target; + if (entry.intersectionRatio >= 0.25) { + setTimeout(() => { + trackAdImpression(elem); + }, 1); + } + } + }); + }, + { + root: null, // defaults to browser viewport + rootMargin: '0px', + threshold: 0.25, + }, + ); + + document.querySelectorAll('[data-display-unit]').forEach((ad) => { + observer.observe(ad); + ad.removeEventListener('click', trackAdClick, false); + ad.addEventListener('click', () => trackAdClick(ad)); + }); +} + +function trackAdImpression(adBox) { + const isBot = + /bot|google|baidu|bing|msn|duckduckbot|teoma|slurp|yandex/i.test( + navigator.userAgent, + ); // is crawler + const adSeen = adBox.dataset.impressionRecorded; + if (isBot || adSeen) { + return; + } + + const tokenMeta = document.querySelector("meta[name='csrf-token']"); + const csrfToken = tokenMeta && tokenMeta.getAttribute('content'); + + const dataBody = { + display_ad_event: { + display_ad_id: adBox.dataset.id, + context_type: adBox.dataset.contextType, + category: adBox.dataset.categoryImpression, + }, + }; + + window + .fetch('/display_ad_events', { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(dataBody), + credentials: 'same-origin', + }) + .catch((error) => console.error(error)); + + adBox.dataset.impressionRecorded = true; +} + +function trackAdClick(adBox) { + const isBot = + /bot|google|baidu|bing|msn|duckduckbot|teoma|slurp|yandex/i.test( + navigator.userAgent, + ); // is crawler + const adClicked = adBox.dataset.clickRecorded; + if (isBot || adClicked) { + return; + } + + const tokenMeta = document.querySelector("meta[name='csrf-token']"); + const csrfToken = tokenMeta && tokenMeta.getAttribute('content'); + + const dataBody = { + display_ad_event: { + display_ad_id: adBox.dataset.id, + context_type: adBox.dataset.contextType, + category: adBox.dataset.categoryClick, + }, + }; + + window.fetch('/display_ad_events', { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(dataBody), + credentials: 'same-origin', + }); + + adBox.dataset.clickRecorded = true; +} diff --git a/app/javascript/packs/homePage.jsx b/app/javascript/packs/homePage.jsx index 165f448c2..d77806b9e 100644 --- a/app/javascript/packs/homePage.jsx +++ b/app/javascript/packs/homePage.jsx @@ -1,6 +1,10 @@ import { h, render } from 'preact'; import ahoy from 'ahoy.js'; import { TagsFollowed } from '../leftSidebar/TagsFollowed'; +import { + observeDisplayAds, + initializeDisplayAdVisibility, +} from '../packs/billboardAfterRenderActions'; import { setupDisplayAdDropdown } from '@utilities/displayAdDropdown'; import { trackCreateAccountClicks } from '@utilities/ahoy/trackEvents'; @@ -93,8 +97,12 @@ if (!document.getElementById('featured-story-marker')) { return; } import('./homePageFeed').then(({ renderFeed }) => { - // We have user data, render followed tags. - renderFeed(feedTimeFrame); + const callback = () => { + initializeDisplayAdVisibility(); + observeDisplayAds(); + }; + + renderFeed(feedTimeFrame, callback); InstantClick.on('change', () => { const { userStatus: currentUserStatus } = document.body.dataset; @@ -110,7 +118,12 @@ if (!document.getElementById('featured-story-marker')) { return; } - renderFeed(changedFeedTimeFrame); + const callback = () => { + initializeDisplayAdVisibility(); + observeDisplayAds(); + }; + + renderFeed(changedFeedTimeFrame, callback); }); }); diff --git a/app/javascript/packs/homePageFeed.jsx b/app/javascript/packs/homePageFeed.jsx index 30326ecc2..b7d5634b1 100644 --- a/app/javascript/packs/homePageFeed.jsx +++ b/app/javascript/packs/homePageFeed.jsx @@ -43,6 +43,92 @@ function sendFeaturedArticleAnalyticsGA4(articleId) { })(); } +function feedConstruct( + pinnedItem, + imageItem, + feedItems, + bookmarkedFeedItems, + bookmarkClick, + currentUserId, + timeFrame, +) { + const commonProps = { + bookmarkClick, + }; + + const feedStyle = JSON.parse(document.body.dataset.user).feed_style; + + if (imageItem) { + sendFeaturedArticleGoogleAnalytics(imageItem.id); + sendFeaturedArticleAnalyticsGA4(imageItem.id); + } + + return feedItems.map((item) => { + // billboard is an html string + if (typeof item === 'string') { + return ( +
+ ); + } + + if (Array.isArray(item) && item[0].podcast) { + return ; + } + + if (typeof item === 'object') { + // For "saveable" props, "!=" is used instead of "!==" to compare user_id + // and currentUserId because currentUserId is a String while user_id is an Integer + + if (item.id === pinnedItem?.id && timeFrame === '') { + return ( +
+ ); + } + + if (item.id === imageItem?.id) { + return ( +
+ ); + } + + if (item.class_name === 'Article') { + return ( +
+ ); + } + } + }); +} + const FeedLoading = () => (
@@ -74,83 +160,44 @@ PodcastEpisodes.propTypes = { /** * Renders the main feed. */ -export const renderFeed = async (timeFrame) => { +export const renderFeed = async (timeFrame, afterRender) => { const feedContainer = document.getElementById('homepage-feed'); const { currentUser } = await getUserDataAndCsrfToken(); const currentUserId = currentUser && currentUser.id; + const callback = ({ + pinnedItem, + imageItem, + feedItems, + bookmarkedFeedItems, + bookmarkClick, + }) => { + if (feedItems.length === 0) { + // Fancy loading ✨ + return ; + } + + return ( +
+ {feedConstruct( + pinnedItem, + imageItem, + feedItems, + bookmarkedFeedItems, + bookmarkClick, + currentUserId, + timeFrame, + )} +
+ ); + }; + render( { - if (feedItems.length === 0) { - // Fancy loading ✨ - return ; - } - - const commonProps = { - bookmarkClick, - }; - - const feedStyle = JSON.parse(document.body.dataset.user).feed_style; - - const [featuredStory, ...subStories] = feedItems; - if (featuredStory) { - sendFeaturedArticleGoogleAnalytics(featuredStory.id); - sendFeaturedArticleAnalyticsGA4(featuredStory.id); - } - - // 1. Show the pinned article first - // 2. Show the featured story next - // 3. Podcast episodes out today - // 4. Rest of the stories for the feed - // For "saveable", "!=" is used instead of "!==" to compare user_id - // and currentUserId because currentUserId is a String while user_id is an Integer - return ( -
- {timeFrame === '' && pinnedArticle && ( -
- )} - {featuredStory && ( -
- )} - {podcastEpisodes.length > 0 && ( - - )} - {(subStories || []).map((story) => ( -
- ))} -
- ); - }} + renderFeed={callback} + afterRender={afterRender} />, feedContainer, feedContainer.firstElementChild, diff --git a/app/javascript/utilities/__tests__/insertInArrayIf.test.js b/app/javascript/utilities/__tests__/insertInArrayIf.test.js new file mode 100644 index 000000000..c01a62c88 --- /dev/null +++ b/app/javascript/utilities/__tests__/insertInArrayIf.test.js @@ -0,0 +1,12 @@ +import { insertInArrayIf } from '@utilities/insertInArrayIf'; + +describe('insertInArrayIf Utility', () => { + it('should return insert into the array based on what the condition evaluates to', () => { + const trueCondition = true; + const falseCondition = false; + const object = { a: 1, b: 1 }; + + expect(insertInArrayIf(trueCondition, object)).toEqual([object]); + expect(insertInArrayIf(falseCondition, object)).toEqual([]); + }); +}); diff --git a/app/javascript/utilities/insertInArrayIf.jsx b/app/javascript/utilities/insertInArrayIf.jsx new file mode 100644 index 000000000..3f9c527eb --- /dev/null +++ b/app/javascript/utilities/insertInArrayIf.jsx @@ -0,0 +1,3 @@ +export function insertInArrayIf(condition, ...elements) { + return condition ? elements : []; +} diff --git a/config/routes.rb b/config/routes.rb index 8682e08d8..6110d0306 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -202,6 +202,7 @@ Rails.application.routes.draw do scope "/:username/:slug" do get "/display_ads/:placement_area", to: "display_ads#show", as: :article_display_ad end + get "/display_ads/:placement_area", to: "display_ads#show" # Settings post "users/join_org", to: "users#join_org" diff --git a/yarn.lock b/yarn.lock index 29ccf7cd1..45d7ed1b7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5505,9 +5505,9 @@ caniuse-api@^3.0.0: lodash.uniq "^4.5.0" caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001304, caniuse-lite@^1.0.30001400, caniuse-lite@^1.0.30001426: - version "1.0.30001474" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001474.tgz#13b6fe301a831fe666cce8ca4ef89352334133d5" - integrity sha512-iaIZ8gVrWfemh5DG3T9/YqarVZoYf0r188IjaGwx68j4Pf0SGY6CQkmJUIE+NZHkkecQGohzXmBGEwWDr9aM3Q== + version "1.0.30001489" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001489.tgz" + integrity sha512-x1mgZEXK8jHIfAxm+xgdpHpk50IN3z3q3zP261/WS+uvePxW8izXuCu6AHz0lkuYTlATDehiZ/tNyYBdSQsOUQ== canvas@^2.11.0: version "2.11.2"