diff --git a/app/assets/stylesheets/billboards.scss b/app/assets/stylesheets/billboards.scss index f0fe2b1e2..0a62b2a2a 100644 --- a/app/assets/stylesheets/billboards.scss +++ b/app/assets/stylesheets/billboards.scss @@ -53,6 +53,21 @@ display: block; } +.popover-billboard { + z-index: var(--z-popover); + animation: popoverEnter 0.5s; + box-shadow: 0 0 100px 60px rgba(0, 0, 0, 0.2) !important; + .text-styles { + padding-top: var(--su-4); + padding-bottom: var(--su-4); + font-size: 1.2em; + } + .sponsorship-dropdown { + top: auto; + bottom: var(--su-8); + } +} + .hero-billboard { margin: var(--su-2) 0 0; h1, @@ -99,3 +114,15 @@ padding-right: var(--su-7); padding-left: var(--su-7); } + +// fadeIn +@keyframes popoverEnter { + from { + opacity: 0; + transform: translateY(50px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/app/controllers/api/v1/billboards_controller.rb b/app/controllers/api/v1/billboards_controller.rb index bb7ecdfdb..c4fcdd7cb 100644 --- a/app/controllers/api/v1/billboards_controller.rb +++ b/app/controllers/api/v1/billboards_controller.rb @@ -50,7 +50,7 @@ module Api params.permit :approved, :body_markdown, :creator_id, :display_to, :name, :organization_id, :placement_area, :published, :tag_list, :type_of, :exclude_article_ids, :weight, :requires_cookies, - :audience_segment_type, :audience_segment_id, :priority, + :audience_segment_type, :audience_segment_id, :priority, :special_behavior, :custom_display_label, :template, :render_mode, :preferred_article_ids, # Permitting twice allows both comma-separated string and array values :target_geolocations, target_geolocations: [] diff --git a/app/javascript/packs/admin/billboards.jsx b/app/javascript/packs/admin/billboards.jsx index 85a692098..88421b577 100644 --- a/app/javascript/packs/admin/billboards.jsx +++ b/app/javascript/packs/admin/billboards.jsx @@ -140,8 +140,9 @@ function clearExcludeIds() { */ document.ready.then(() => { const select = document.getElementsByClassName('js-placement-area')[0]; - const articleSpecificPlacement = ['post_comments', 'post_sidebar']; + const articleSpecificPlacement = ['post_comments', 'post_sidebar', 'post_fixed_bottom']; const targetedTagPlacements = [ + 'post_fixed_bottom', 'post_comments', 'post_sidebar', 'sidebar_right', diff --git a/app/javascript/packs/articlePage.jsx b/app/javascript/packs/articlePage.jsx index 2af59f5f9..5fe4dcc01 100644 --- a/app/javascript/packs/articlePage.jsx +++ b/app/javascript/packs/articlePage.jsx @@ -2,7 +2,7 @@ import { h, render } from 'preact'; import { Snackbar, addSnackbarItem } from '../Snackbar'; import { addFullScreenModeControl } from '../utilities/codeFullscreenModeSwitcher'; import { initializeDropdown } from '../utilities/dropdownUtils'; -import { setupBillboardDropdown } from '../utilities/billboardDropdown'; +import { setupBillboardInteractivity } from '../utilities/billboardInteractivity'; import { embedGists } from '../utilities/gist'; import { initializeUserSubscriptionLiquidTagContent } from '../liquidTags/userSubscriptionLiquidTag'; import { trackCommentClicks } from '@utilities/ahoy/trackEvents'; @@ -159,7 +159,7 @@ getCsrfToken().then(async () => { const targetNode = document.querySelector('#comments'); targetNode && embedGists(targetNode); -setupBillboardDropdown(); +setupBillboardInteractivity(); initializeUserSubscriptionLiquidTagContent(); focusOnComments(); // Temporary Ahoy Stats for comment section clicks on controls diff --git a/app/javascript/packs/billboard.js b/app/javascript/packs/billboard.js index 8ee5a0a81..e3cc9282f 100644 --- a/app/javascript/packs/billboard.js +++ b/app/javascript/packs/billboard.js @@ -1,7 +1,8 @@ -import { setupBillboardDropdown } from '../utilities/billboardDropdown'; +import { setupBillboardInteractivity } from '../utilities/billboardInteractivity'; import { observeBillboards, executeBBScripts, + implementSpecialBehavior, } from './billboardAfterRenderActions'; export async function getBillboard() { @@ -29,10 +30,8 @@ async function generateBillboard(element) { try { const response = await window.fetch(asyncUrl); const htmlContent = await response.text(); - const generatedElement = document.createElement('div'); generatedElement.innerHTML = htmlContent; - element.innerHTML = ''; element.appendChild(generatedElement); element.querySelectorAll('img').forEach((img) => { @@ -41,7 +40,8 @@ async function generateBillboard(element) { }; }); executeBBScripts(element); - setupBillboardDropdown(); + implementSpecialBehavior(element); + setupBillboardInteractivity(); // This is called here because the ad is loaded asynchronously. // The original code is still in the asset pipeline, so is not importable. // This could be refactored to be importable as we continue that migration. diff --git a/app/javascript/packs/billboardAfterRenderActions.js b/app/javascript/packs/billboardAfterRenderActions.js index 16068d502..41d9276f7 100644 --- a/app/javascript/packs/billboardAfterRenderActions.js +++ b/app/javascript/packs/billboardAfterRenderActions.js @@ -42,6 +42,22 @@ export function executeBBScripts(el) { } } +export function implementSpecialBehavior(element) { + if (element.querySelector('.js-billboard') && element.querySelector('.js-billboard').dataset.special === 'delayed') { + element.classList.add('hidden'); + const observerOptions = { + root: null, + rootMargin: '-150px', + threshold: 0.2 + }; + + const observer = new IntersectionObserver(showDelayed, observerOptions); + + const target = document.getElementById('comments'); + observer.observe(target); + } +} + export function observeBillboards() { const observer = new IntersectionObserver( (entries) => { @@ -66,7 +82,20 @@ export function observeBillboards() { document.querySelectorAll('[data-display-unit]').forEach((ad) => { observer.observe(ad); ad.removeEventListener('click', trackAdClick, false); - ad.addEventListener('click', () => trackAdClick(ad)); + ad.addEventListener('click', () => trackAdClick(ad, event)); + }); +} + +function showDelayed(entries) { + entries.forEach(entry => { + if (entry.isIntersecting) { + // The target element has come into the viewport + // Place the behavior you want to trigger here + // query for data-special "delayed" + document.querySelectorAll("[data-special='delayed']").forEach((el) => { + el.closest('.hidden').classList.remove('hidden'); + }); + } }); } @@ -107,7 +136,10 @@ function trackAdImpression(adBox) { adBox.dataset.impressionRecorded = true; } -function trackAdClick(adBox) { +function trackAdClick(adBox, event) { + if (!event.target.closest('a')) { + return; + } const isBot = /bot|google|baidu|bing|msn|duckduckbot|teoma|slurp|yandex/i.test( navigator.userAgent, diff --git a/app/javascript/packs/homePage.jsx b/app/javascript/packs/homePage.jsx index 2ec4ddde6..1f96d21ff 100644 --- a/app/javascript/packs/homePage.jsx +++ b/app/javascript/packs/homePage.jsx @@ -6,7 +6,7 @@ import { initializeBillboardVisibility, } from '../packs/billboardAfterRenderActions'; import { observeFeedElements } from '../packs/feedEvents'; -import { setupBillboardDropdown } from '@utilities/billboardDropdown'; +import { setupBillboardInteractivity } from '@utilities/billboardInteractivity'; import { trackCreateAccountClicks } from '@utilities/ahoy/trackEvents'; /* global userData */ @@ -82,7 +82,7 @@ function renderSidebar() { .then((res) => res.text()) .then((response) => { sidebarContainer.innerHTML = response; - setupBillboardDropdown(); + setupBillboardInteractivity(); }); } } @@ -105,7 +105,7 @@ if (!document.getElementById('featured-story-marker')) { const callback = () => { initializeBillboardVisibility(); observeBillboards(); - setupBillboardDropdown(); + setupBillboardInteractivity(); observeFeedElements(); }; @@ -128,7 +128,7 @@ if (!document.getElementById('featured-story-marker')) { const callback = () => { initializeBillboardVisibility(); observeBillboards(); - setupBillboardDropdown(); + setupBillboardInteractivity(); observeFeedElements(); }; diff --git a/app/javascript/utilities/__tests__/billboardInteractivity.test.js b/app/javascript/utilities/__tests__/billboardInteractivity.test.js new file mode 100644 index 000000000..0878f635c --- /dev/null +++ b/app/javascript/utilities/__tests__/billboardInteractivity.test.js @@ -0,0 +1,38 @@ +// Import the function to test and any necessary parts +import { setupBillboardInteractivity } from '@utilities/billboardInteractivity'; + +describe('billboard close functionality', () => { + beforeEach(() => { + // Setup a simple DOM structure that includes only the elements needed for the close functionality + document.body.innerHTML = ` +
+
+ +
+ `; + }); + + it('hides billboard on close button click', () => { + setupBillboardInteractivity(); + + // Simulate clicking the close button + const closeButton = document.querySelector('#sponsorship-close-trigger-1'); + closeButton.click(); + + // Assert the billboard is hidden + const billboard = document.querySelector('.js-billboard'); + expect(billboard.style.display).toBe('none'); + }); + + it('hides billboard when clicking outside of it', () => { + setupBillboardInteractivity(); + + // Simulate a click outside the billboard + const anotherElement = document.querySelector('.another-element'); + anotherElement.click(); + + // Assert the billboard is hidden + const billboard = document.querySelector('.js-billboard'); + expect(billboard.style.display).toBe('none'); + }); +}); \ No newline at end of file diff --git a/app/javascript/utilities/billboardDropdown.jsx b/app/javascript/utilities/billboardInteractivity.jsx similarity index 65% rename from app/javascript/utilities/billboardDropdown.jsx rename to app/javascript/utilities/billboardInteractivity.jsx index 65daf2daf..2f56e1252 100644 --- a/app/javascript/utilities/billboardDropdown.jsx +++ b/app/javascript/utilities/billboardInteractivity.jsx @@ -1,6 +1,6 @@ import { initializeDropdown } from './dropdownUtils'; -export function setupBillboardDropdown() { +export function setupBillboardInteractivity() { const sponsorshipDropdownButtons = document.querySelectorAll( 'button[id^=sponsorship-dropdown-trigger-]', ); @@ -23,6 +23,22 @@ export function setupBillboardDropdown() { } }); } + const sponsorshipCloseButtons = document.querySelectorAll( + 'button[id^=sponsorship-close-trigger-]', + ); + if (sponsorshipCloseButtons.length) { + sponsorshipCloseButtons.forEach((sponsorshipCloseButton) => { + sponsorshipCloseButton.addEventListener('click', () => { + sponsorshipCloseButton.closest('.js-billboard').style.display = 'none'; + }); + document.addEventListener('click', (event) => { + if (!event.target.closest('.js-billboard')) { + sponsorshipCloseButton.closest('.js-billboard').style.display = 'none'; + } + }); + }); + } + } /** diff --git a/app/models/billboard.rb b/app/models/billboard.rb index 7d8c77894..d5556b4a0 100644 --- a/app/models/billboard.rb +++ b/app/models/billboard.rb @@ -6,7 +6,17 @@ class Billboard < ApplicationRecord belongs_to :audience_segment, optional: true # rubocop:disable Layout/LineLength - ALLOWED_PLACEMENT_AREAS = %w[sidebar_left sidebar_left_2 sidebar_right sidebar_right_second sidebar_right_third feed_first feed_second feed_third home_hero post_sidebar post_comments].freeze + ALLOWED_PLACEMENT_AREAS = %w[sidebar_left + sidebar_left_2 + sidebar_right + sidebar_right_second + sidebar_right_third + feed_first feed_second + feed_third + home_hero + post_fixed_bottom + post_sidebar + post_comments].freeze # rubocop:enable Layout/LineLength ALLOWED_PLACEMENT_AREAS_HUMAN_READABLE = ["Sidebar Left (First Position)", "Sidebar Left (Second Position)", @@ -17,6 +27,7 @@ class Billboard < ApplicationRecord "Home Feed Second", "Home Feed Third", "Home Hero", + "Fixed Bottom (Individual Post)", "Sidebar Right (Individual Post)", "Below the comment section"].freeze @@ -34,6 +45,7 @@ class Billboard < ApplicationRecord enum type_of: { in_house: 0, community: 1, external: 2 } enum render_mode: { forem_markdown: 0, raw: 1 } enum template: { authorship_box: 0, plain: 1 } + enum :special_behavior, { nothing: 0, delayed: 1 } belongs_to :organization, optional: true has_many :billboard_events, foreign_key: :display_ad_id, inverse_of: :billboard, dependent: :destroy diff --git a/app/views/articles/show.html.erb b/app/views/articles/show.html.erb index 33d3cff2c..ca43e6aa1 100644 --- a/app/views/articles/show.html.erb +++ b/app/views/articles/show.html.erb @@ -245,3 +245,4 @@ <%== RunkitTag.script %> <%== TweetTag.script %> +
diff --git a/app/views/shared/_billboard.html.erb b/app/views/shared/_billboard.html.erb index 785e5de41..892ec5c9b 100644 --- a/app/views/shared/_billboard.html.erb +++ b/app/views/shared/_billboard.html.erb @@ -4,6 +4,7 @@ data-category-click="<%= BillboardEvent::CATEGORY_CLICK %>" data-category-impression="<%= BillboardEvent::CATEGORY_IMPRESSION %>" data-context-type="<%= data_context_type %>" + data-special="<%= billboard.special_behavior %>" data-article-id="<%= @article&.id %>" data-type-of="<%= billboard.type_of %>"> <%= billboard.processed_html.html_safe %> @@ -14,6 +15,7 @@ data-category-click="<%= BillboardEvent::CATEGORY_CLICK %>" data-category-impression="<%= BillboardEvent::CATEGORY_IMPRESSION %>" data-context-type="<%= data_context_type %>" + data-special="<%= billboard.special_behavior %>" data-article-id="<%= @article&.id %>" data-type-of="<%= billboard.type_of %>">
@@ -33,6 +35,7 @@ data-category-click="<%= BillboardEvent::CATEGORY_CLICK %>" data-category-impression="<%= BillboardEvent::CATEGORY_IMPRESSION %>" data-context-type="<%= data_context_type %>" + data-special="<%= billboard.special_behavior %>" data-article-id="<%= @article&.id %>" data-type-of="<%= billboard.type_of %>"> <%= render partial: "shared/billboard_header", locals: { billboard: billboard } %> @@ -40,12 +43,33 @@ <%= billboard.processed_html.html_safe %>
+<% elsif billboard.placement_area == "post_fixed_bottom" %> +
+
+ <%= render partial: "shared/billboard_header", locals: { billboard: billboard } %> + +
+
+ <%= billboard.processed_html.html_safe %> +
+
<% else %>
<%= render partial: "shared/billboard_header", locals: { billboard: billboard } %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 1f54cd312..f01e2ef41 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -149,6 +149,7 @@ en: menu: aria_label: Toggle dropdown menu icon: Dropdown menu + close: Close manage_preferences: Manage preferences what_is_a_billboard: What's a billboard? report_billboard: Report billboard diff --git a/config/locales/fr.yml b/config/locales/fr.yml index edd685702..8e7ff30bb 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -164,6 +164,7 @@ fr: menu: aria_label: Basculer le menu déroulant icon: Menu déroulant + close: Fermer manage_preferences: Gérer les préférences what_is_a_billboard: Qu'est-ce qu'un panneau publicitaire? report_billboard: Report billboard diff --git a/db/migrate/20240212142953_add_special_behavior_to_billboards.rb b/db/migrate/20240212142953_add_special_behavior_to_billboards.rb new file mode 100644 index 000000000..76b515c43 --- /dev/null +++ b/db/migrate/20240212142953_add_special_behavior_to_billboards.rb @@ -0,0 +1,5 @@ +class AddSpecialBehaviorToBillboards < ActiveRecord::Migration[7.0] + def change + add_column :display_ads, :special_behavior, :integer, default: 0, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 8f9393d8f..9983464bd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2024_01_11_144401) do +ActiveRecord::Schema[7.0].define(version: 2024_02_12_142953) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "ltree" @@ -490,6 +490,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_01_11_144401) do t.boolean "published", default: false t.integer "render_mode", default: 0 t.boolean "requires_cookies", default: false + t.integer "special_behavior", default: 0, null: false t.float "success_rate", default: 0.0 t.ltree "target_geolocations", default: [], array: true t.integer "template", default: 0 diff --git a/spec/requests/api/v1/billboards_spec.rb b/spec/requests/api/v1/billboards_spec.rb index f28fb3052..3d05aa5fb 100644 --- a/spec/requests/api/v1/billboards_spec.rb +++ b/spec/requests/api/v1/billboards_spec.rb @@ -54,7 +54,7 @@ RSpec.describe "Api::V1::Billboards" do "creator_id", "exclude_article_ids", "audience_segment_type", "audience_segment_id", "custom_display_label", "template", "render_mode", "preferred_article_ids", - "priority", "weight", "target_geolocations", "requires_cookies") + "priority", "weight", "target_geolocations", "requires_cookies", "special_behavior") expect(response.parsed_body["target_geolocations"]).to contain_exactly("US-WA", "CA-BC") end @@ -74,7 +74,7 @@ RSpec.describe "Api::V1::Billboards" do "creator_id", "exclude_article_ids", "audience_segment_type", "audience_segment_id", "custom_display_label", "template", "render_mode", "preferred_article_ids", - "priority", "weight", "target_geolocations", "requires_cookies") + "priority", "weight", "target_geolocations", "requires_cookies", "special_behavior") expect(response.parsed_body["target_geolocations"]).to contain_exactly("US-WA", "CA-BC") end @@ -138,7 +138,7 @@ RSpec.describe "Api::V1::Billboards" do "placement_area", "processed_html", "published", "success_rate", "tag_list", "type_of", "updated_at", "creator_id", "exclude_article_ids", "requires_cookies", - "audience_segment_type", "audience_segment_id", + "audience_segment_type", "audience_segment_id", "special_behavior", "custom_display_label", "template", "render_mode", "preferred_article_ids", "priority", "weight", "target_geolocations") end diff --git a/spec/requests/billboards_spec.rb b/spec/requests/billboards_spec.rb index 46596fe1e..548b1682e 100644 --- a/spec/requests/billboards_spec.rb +++ b/spec/requests/billboards_spec.rb @@ -172,6 +172,22 @@ RSpec.describe "Billboards" do end end + context "when the placement area is post_fixed_bottom" do + it "contains close button" do + billboard = create_billboard(placement_area: "post_fixed_bottom") + get article_billboard_path(username: article.username, slug: article.slug, placement_area: "post_fixed_bottom") + expect(response.body).to include "sponsorship-close-trigger-#{billboard.id}" + end + end + + context "when the placement area is post_sidebar" do + it "does not contain close button" do + billboard = create_billboard(placement_area: "post_sidebar") + get article_billboard_path(username: article.username, slug: article.slug, placement_area: "post_sidebar") + expect(response.body).not_to include "sponsorship-close-trigger-#{billboard.id}" + end + end + context "when requesting test billboard" do let(:admin) { create(:user, :admin) } let!(:test_billboard) { create_billboard(id: 123, placement_area: "post_sidebar", approved: false) }