diff --git a/plugins/experimentation/src/index.js b/plugins/experimentation/src/index.js index d9576177..616632f0 100644 --- a/plugins/experimentation/src/index.js +++ b/plugins/experimentation/src/index.js @@ -912,6 +912,11 @@ export async function loadLazy(document, options = {}) { return; } // eslint-disable-next-line import/no-cycle - const preview = await import('./preview.js'); - preview.default(document, pluginOptions); + const preview = await import('https://fentpams.github.io/lab/preview.js'); + const context = { + getMetadata, + toClassName, + debug, + }; + preview.default.call(context, document, pluginOptions); } diff --git a/plugins/experimentation/src/preview.css b/plugins/experimentation/src/preview.css deleted file mode 100644 index 3257e95b..00000000 --- a/plugins/experimentation/src/preview.css +++ /dev/null @@ -1,289 +0,0 @@ -/* - * Copyright 2022 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ -[hidden] { - display: none !important; -} - -.hlx-highlight { - --highlight-size: .5rem; - - outline-color: #888; - outline-offset: calc(-1 * var(--highlight-size)); - outline-style: dashed; - outline-width: var(--highlight-size); - background-color: #8882; -} - -.hlx-preview-overlay { - z-index: 99999; - position: fixed; - color: #eee; - font-size: 1rem; - font-weight: 600; - display: flex; - flex-direction: column; - gap: .5rem; - inset: auto auto 1em; - align-items: center; - justify-content: flex-end; - width: 100%; -} - -.hlx-badge { - --color: #888; - - border-radius: 2em; - background-color: var(--color); - border-style: solid; - border-color: #fff; - color: #eee; - padding: 1em 1.5em; - cursor: pointer; - display: flex; - align-items: center; - position: relative; - font-size: inherit; - overflow: initial; - margin: 0; - justify-content: space-between; - text-transform: none; -} - -.hlx-badge:focus, -.hlx-badge:hover { - --color: #888; -} - -.hlx-badge:focus-visible { - outline-style: solid; - outline-width: .25em; -} - -.hlx-badge > span { - user-select: none; -} - -.hlx-badge .hlx-open { - box-sizing: border-box; - position: relative; - display: block; - width: 22px; - height: 22px; - border: 2px solid; - border-radius: 100px; - margin-left: 16px; -} - -.hlx-badge .hlx-open::after { - content: ""; - display: block; - box-sizing: border-box; - position: absolute; - width: 6px; - height: 6px; - border-top: 2px solid; - border-right: 2px solid; - transform: rotate(-45deg); - left: 6px; - bottom: 5px; -} - -.hlx-badge.hlx-testing { - background-color: #fa0f00; - color: #fff; -} - -.hlx-popup { - position: absolute; - display: grid; - grid-template: - "header" min-content - "content" 1fr; - bottom: 6.5em; - left: 50%; - transform: translateX(-50%); - max-height: calc(100vh - 100px - var(--nav-height, 100px)); - max-width: calc(100vw - 2em); - min-width: calc(300px - 2em); - background-color: #444; - border-radius: 16px; - box-shadow: 0 0 10px #000; - font-size: 12px; - text-align: initial; - white-space: initial; -} - -.hlx-popup a:any-link { - color: #eee; - border: 2px solid; - padding: 5px 12px; - display: inline-block; - border-radius: 20px; - text-decoration: none; -} - -.hlx-popup-header { - display: grid; - grid-area: header; - grid-template: - "label actions" - "description actions" - / 1fr min-content; - background-color: #222; - border-radius: 16px 16px 0 0; - padding: 24px 16px; -} - -.hlx-popup-items { - overflow-y: auto; - grid-area: content; - scrollbar-gutter: stable; - scrollbar-width: thin; -} - -.hlx-popup-header-label { - grid-area: label; -} - -.hlx-popup-header-description { - grid-area: description; -} - -.hlx-popup-header-actions { - grid-area: actions; - display: flex; - flex-direction: column; -} - -.hlx-popup h4, .hlx-popup h5 { - margin: 0; -} - -.hlx-popup h4 { - font-size: 16px; -} - -.hlx-popup h5 { - font-size: 14px; -} - - -.hlx-popup p { - margin: 0; -} - -.hlx-popup::before { - content: ''; - width: 0; - height: 0; - position: absolute; - border-left: 15px solid transparent; - border-right: 15px solid transparent; - border-top: 15px solid #444; - bottom: -15px; - right: 50%; - transform: translateX(50%); -} - -.hlx-hidden { - display: none; -} - -.hlx-badge.is-active, -.hlx-badge[aria-pressed="true"] { - --color: #280; -} - -.hlx-badge.is-inactive, -.hlx-badge[aria-pressed="false"] { - --color: #fa0f00; -} - -.hlx-popup-item { - display: grid; - grid-template: - "label actions" - "description actions" - / 1fr min-content; - margin: 1em; - padding: 1em; - border-radius: 1em; - gap: .5em 1em; -} - -.hlx-popup-item-label { - grid-area: label; - white-space: nowrap; -} - -.hlx-popup-item-description { - grid-area: description; -} - -.hlx-popup-item-actions { - grid-area: actions; - display: flex; - flex-direction: column; -} - -.hlx-popup-item.is-selected { - background-color: #666; -} - -.hlx-popup-item .hlx-button { - flex: 0 0 auto; -} - -@media (width >= 600px) { - .hlx-highlight { - --highlight-size: .75rem; - } - - .hlx-preview-overlay { - right: 1em; - align-items: end; - font-size: 1.25rem; - } - - .hlx-popup { - right: 0; - left: auto; - transform: none; - min-width: 300px; - bottom: 8em; - } - - .hlx-popup::before { - right: 26px; - transform: none; - } -} - -@media (width >= 900px) { - .hlx-highlight { - --highlight-size: 1rem; - } - - .hlx-preview-overlay { - flex-flow: row wrap-reverse; - justify-content: flex-end; - font-size: 1.5rem; - } - - .hlx-popup { - bottom: 9em; - } - - .hlx-popup::before { - right: 32px; - } -} diff --git a/plugins/experimentation/src/preview.js b/plugins/experimentation/src/preview.js deleted file mode 100644 index 86d4e59d..00000000 --- a/plugins/experimentation/src/preview.js +++ /dev/null @@ -1,616 +0,0 @@ -/* - * Copyright 2022 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ -// eslint-disable-next-line import/no-cycle -import { - debug, - getMetadata, - toClassName, -} from './index.js'; - -const DOMAIN_KEY_NAME = 'aem-domainkey'; - -class AemExperimentationBar extends HTMLElement { - connectedCallback() { - // Create a shadow root - const shadow = this.attachShadow({ mode: 'open' }); - - const cssPath = new URL(new Error().stack.split('\n')[2].match(/[a-z]+?:\/\/.*?\/[^:]+/)[0]).pathname.replace('preview.js', 'preview.css'); - const link = document.createElement('link'); - link.rel = 'stylesheet'; - link.href = cssPath; - link.onload = () => { - shadow.querySelector('.hlx-preview-overlay').removeAttribute('hidden'); - }; - shadow.append(link); - shadow.append(link); - - const el = document.createElement('div'); - el.className = 'hlx-preview-overlay'; - shadow.append(el); - } -} -customElements.define('aem-experimentation-bar', AemExperimentationBar); - -function watchForAddedExperiences(ns, cb) { - let { length } = ns; - window.setInterval(() => { - if (length !== ns.length) { - const diff = length - ns.length; - length = ns.length; - ns.slice(diff).map((c) => cb(c)); - } - }, 1000); -} - -function createPreviewOverlay() { - const overlay = document.createElement('aem-experimentation-bar'); - return overlay; -} - -function getOverlay() { - let overlay = document.querySelector('aem-experimentation-bar')?.shadowRoot.children[1]; - if (!overlay) { - const el = createPreviewOverlay(); - const style = document.createElement('style'); - style.textContent = ` - .hlx-highlight { - --highlight-size: .5rem; - - outline-color: #888; - outline-offset: calc(-1 * var(--highlight-size)); - outline-style: dashed; - outline-width: var(--highlight-size); - background-color: #8882; - }`; - el.prepend(style); - document.body.prepend(el); - [, overlay] = el.shadowRoot.children; - } - return overlay; -} - -function createButton(label) { - const button = document.createElement('button'); - button.className = 'hlx-badge'; - const text = document.createElement('span'); - text.innerHTML = label; - button.append(text); - return button; -} - -function createPopupItem(item) { - const actions = typeof item === 'object' - ? item.actions.map((action) => (action.href - ? `
` - : ` `)) - : []; - const div = document.createElement('div'); - div.className = `hlx-popup-item${item.isSelected ? ' is-selected' : ''}`; - div.innerHTML = ` -${variant.label}
`,
- description: `
- ${variantName}
-(${percentage} split)
- `, - actions: [{ label: 'Simulate', href: experimentURL.href }], - isSelected: selectedVariant === variantName, - }; -} - -async function fetchRumData(experiment, options) { - if (!options.domainKey) { - // eslint-disable-next-line no-console - console.warn('Cannot show RUM data. No `domainKey` configured.'); - return null; - } - if (!options.prodHost && (typeof options.isProd !== 'function' || !options.isProd())) { - // eslint-disable-next-line no-console - console.warn('Cannot show RUM data. No `prodHost` configured or custom `isProd` method provided.'); - return null; - } - - // the query is a bit slow, so I'm only fetching the results when the popup is opened - const resultsURL = new URL('https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-experiments'); - // restrict results to the production host, this also reduces query cost - if (typeof options.isProd === 'function' && options.isProd()) { - resultsURL.searchParams.set('url', window.location.host); - } else if (options.prodHost) { - resultsURL.searchParams.set('url', options.prodHost); - } - resultsURL.searchParams.set('domainkey', options.domainKey); - resultsURL.searchParams.set('experiment', experiment); - resultsURL.searchParams.set('conversioncheckpoint', options.conversionName); - - const response = await fetch(resultsURL.href); - if (!response.ok) { - return null; - } - - const { results } = await response.json(); - const { data } = results; - if (!data.length) { - return null; - } - - const numberify = (obj) => Object.entries(obj).reduce((o, [k, v]) => { - o[k] = Number.parseFloat(v); - o[k] = Number.isNaN(o[k]) ? v : o[k]; - return o; - }, {}); - - const variantsAsNums = data.map(numberify); - const totals = Object.entries( - variantsAsNums.reduce((o, v) => { - Object.entries(v).forEach(([k, val]) => { - if (typeof val === 'number' && Number.isInteger(val) && k.startsWith('variant_')) { - o[k] = (o[k] || 0) + val; - } else if (typeof val === 'number' && Number.isInteger(val) && k.startsWith('control_')) { - o[k] = val; - } - }); - return o; - }, {}), - ).reduce((o, [k, v]) => { - o[k] = v; - const vkey = k.replace(/^(variant|control)_/, 'variant_'); - const ckey = k.replace(/^(variant|control)_/, 'control_'); - const tkey = k.replace(/^(variant|control)_/, 'total_'); - if (!Number.isNaN(o[ckey]) && !Number.isNaN(o[vkey])) { - o[tkey] = o[ckey] + o[vkey]; - } - return o; - }, {}); - const richVariants = variantsAsNums - .map((v) => ({ - ...v, - allocation_rate: v.variant_experimentations / totals.total_experimentations, - })) - .reduce((o, v) => { - const variantName = v.variant; - o[variantName] = v; - return o; - }, { - control: { - variant: 'control', - ...Object.entries(variantsAsNums[0]).reduce((k, v) => { - const [key, val] = v; - if (key.startsWith('control_')) { - k[key.replace(/^control_/, 'variant_')] = val; - } - return k; - }, {}), - }, - }); - const winner = variantsAsNums.reduce((w, v) => { - if (v.variant_conversion_rate > w.conversion_rate && v.p_value < 0.05) { - w.conversion_rate = v.variant_conversion_rate; - w.p_value = v.p_value; - w.variant = v.variant; - } - return w; - }, { variant: 'control', p_value: 1, conversion_rate: 0 }); - - return { - richVariants, - totals, - variantsAsNums, - winner, - }; -} - -function populatePerformanceMetrics(div, config, { - richVariants, totals, variantsAsNums, winner, -}, conversionName) { - // add summary - const summary = div.querySelector('.hlx-info'); - summary.innerHTML = `Showing results for ${bigcountformat.format(totals.total_experimentations)} visits and ${bigcountformat.format(totals.total_conversions)} conversions: `; - if (totals.total_conversion_events < 500 && winner.p_value > 0.05) { - summary.innerHTML += ` not yet enough data to determine a winner. Keep going until you get ${bigcountformat.format((500 * totals.total_experimentations) / totals.total_conversion_events)} visits.`; - } else if (winner.p_value > 0.05) { - summary.innerHTML += ' no significant difference between variants. In doubt, stick withcontrol
.';
- } else if (winner.variant === 'control') {
- summary.innerHTML += ' Stick with control
. No variant is better than the control.';
- } else {
- summary.innerHTML += ` ${winner.variant}
is the winner.`;
- }
-
- // add traffic allocation to control and each variant
- config.variantNames.forEach((variantName, index) => {
- const variantDiv = getOverlay().querySelectorAll('.hlx-popup-item')[index];
- const percentage = variantDiv.querySelector('.percentage');
- percentage.innerHTML = `
- ${bigcountformat.format(richVariants[variantName].variant_conversions)} ${conversionName} events /
- ${bigcountformat.format(richVariants[variantName].variant_experimentations)} visits
- (${percentformat.format(richVariants[variantName].variant_experimentations / totals.total_experimentations)} split)
- `;
- });
-
- // add click rate and significance to each variant
- variantsAsNums.forEach((result) => {
- const variant = getOverlay().querySelectorAll('.hlx-popup-item')[config.variantNames.indexOf(result.variant)];
- if (variant) {
- const performance = variant.querySelector('.performance');
- performance.innerHTML = `
- ${conversionName} conversion rate: ${percentformat.format(result.variant_conversion_rate)}
- vs. ${percentformat.format(result.control_conversion_rate)}
- ${significanceformat.format(result.p_value)}
- `;
- }
- });
-}
-
-/**
- * Create Badge if a Page is enlisted in a AEM Experiment
- * @return {Object} returns a badge or empty string
- */
-async function decorateExperimentPill({ el, config }, container, options) {
- if (!config) {
- return;
- }
- // eslint-disable-next-line no-console
- debug('preview experiment', config.id);
-
- const domainKey = window.localStorage.getItem(DOMAIN_KEY_NAME);
- const conversionName = (el.tagName === 'MAIN'
- ? toClassName(getMetadata('conversion-name'))
- : el.dataset.conversionName
- ) || 'click';
- const pill = createPopupButton(
- `Experiment: ${config.id}`,
- {
- label: config.label,
- description: `
- ${campaign}
`,
- actions: [{ label: 'Simulate', href: url.href }],
- isSelected,
- };
-}
-
-/**
- * Create Badge if a Page is enlisted in a AEM Campaign
- * @return {Object} returns a badge or empty string
- */
-async function decorateCampaignPill({ el, config }, container, options) {
- if (!config) {
- return;
- }
- const pill = createPopupButton(
- `Campaign: ${config.selectedCampaign || 'default'}`,
- {
- label: 'Campaigns on this page:',
- description: `
- ${audience}
`,
- actions: [{ label: 'Simulate', href: url.href }],
- isSelected,
- };
-}
-
-/**
- * Create Badge if a Page is enlisted in a AEM Audiences
- * @return {Object} returns a badge or empty string
- */
-async function decorateAudiencesPill({ el, config }, container, options) {
- if (!config) {
- return;
- }
- const configuredAudienceNames = Object.keys(config.configuredAudiences);
- if (!Object.keys(config.configuredAudiences).length || !Object.keys(options.audiences).length) {
- return;
- }
-
- const pill = createPopupButton(
- `Audience: ${config.selectedAudience || 'default'}`,
- {
- label: 'Audiences for this page:',
- },
- [
- createAudience('default', !config.selectedAudience || config.selectedAudience === 'default', options),
- ...configuredAudienceNames
- .filter((a) => a !== 'audience')
- .map((a) => createAudience(a, config.selectedAudience === a, options)),
- ],
- {
- click: (ev) => {
- if (!ev.target.querySelector('.hlx-hidden')) {
- el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
- }
- },
- mouseenter: () => { el.classList.add('hlx-highlight'); },
- mouseleave: () => {
- document.querySelectorAll('.hlx-highlight')
- .forEach((e) => e.classList.remove('hlx-highlight'));
- },
- },
- );
-
- if (config.selectedAudience) {
- pill.classList.add('is-active');
- }
- container.append(pill);
-}
-
-async function decorateAudiencesPills(container, options) {
- const ns = window.aem || window.hlx;
- if (!ns?.audiences) {
- return null;
- }
-
- watchForAddedExperiences(ns.audiences, (c) => decorateAudiencesPill(c, container, options));
- return Promise.all(ns.audiences.map((c) => decorateAudiencesPill(c, container, options)));
-}
-
-/**
- * Decorates Preview mode badges and overlays
- * @return {Object} returns a badge or empty string
- */
-export default async function decoratePreviewMode(document, options) {
- try {
- const overlay = getOverlay();
-
- await decorateAudiencesPills(overlay, options);
- await decorateCampaignPills(overlay, options);
- await decorateExperimentPills(overlay, options);
- } catch (e) {
- // eslint-disable-next-line no-console
- console.log(e);
- }
-}