Add billboard for bottom of page with allowance to delay until after reading is finished. (#20616)

* Add delayed behavior

* Add special behavior to billboards

* Fix order for test

* Test clicking outside element when close event is present

* Update app/javascript/packs/billboardAfterRenderActions.js
This commit is contained in:
Ben Halpern 2024-02-13 11:56:32 -05:00 committed by GitHub
parent 8f4a6b832b
commit 205aee8c78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 195 additions and 20 deletions

View file

@ -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);
}
}

View file

@ -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: []

View file

@ -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',

View file

@ -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

View file

@ -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.

View file

@ -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,

View file

@ -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();
};

View file

@ -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 = `
<div class="another-element"></div>
<div class="js-billboard" style="display: block;">
<button id="sponsorship-close-trigger-1"></button>
</div>
`;
});
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');
});
});

View file

@ -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';
}
});
});
}
}
/**

View file

@ -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

View file

@ -245,3 +245,4 @@
<%== RunkitTag.script %>
<%== TweetTag.script %>
</script>
<div class="js-billboard-container pb-4 crayons-layout__comments-billboard" data-async-url="<%= article_billboard_path(username: @article.username, slug: @article.slug, placement_area: :post_fixed_bottom) %>"></div>

View file

@ -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 %>">
<div class="crayons-story__body">
@ -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 %>
</div>
</div>
<% elsif billboard.placement_area == "post_fixed_bottom" %>
<div class="crayons-card crayons-card--secondary crayons-sponsorship popover-billboard crayons-story__billboard js-billboard fixed bottom-0 left-0 right-0"
data-display-unit data-id="<%= billboard.id %>"
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 %>">
<div class="flex">
<%= render partial: "shared/billboard_header", locals: { billboard: billboard } %>
<button id="sponsorship-close-trigger-<%= billboard.id %>" aria-controls="sponsorship-close-<%= billboard.id %>"
class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon">
<%= crayons_icon_tag("x", class: "pointer-events-none", title: t("billboard.menu.close")) %>
</button>
</div>
<div class="p-1 text-styles text-styles--billboard">
<%= billboard.processed_html.html_safe %>
</div>
</div>
<% else %>
<div class="crayons-card crayons-card--secondary crayons-sponsorship billboard js-billboard"
data-display-unit data-id="<%= billboard.id %>"
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 } %>

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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) }