From 446191500078b90ee4f9549ab5dff4b39f13f26a Mon Sep 17 00:00:00 2001 From: John Pettitt Date: Wed, 22 Jun 2022 19:29:28 +0000 Subject: [PATCH 1/4] Preview Mode Initial Commits - WIP --- src/model/client-config.js | 10 + src/runtime/client-config-manager.js | 8 +- src/runtime/entitlements-manager.js | 12 + src/runtime/pay-flow.js | 15 +- src/runtime/preview-frame.js | 118 ++++++++ src/runtime/preview-mode.js | 430 +++++++++++++++++++++++++++ src/runtime/runtime.js | 3 + 7 files changed, 594 insertions(+), 2 deletions(-) create mode 100644 src/runtime/preview-frame.js create mode 100644 src/runtime/preview-mode.js diff --git a/src/model/client-config.js b/src/model/client-config.js index d7f73ad982..a4b23813a4 100644 --- a/src/model/client-config.js +++ b/src/model/client-config.js @@ -14,6 +14,8 @@ * limitations under the License. */ +import {PreviewManager} from '../runtime/preview-mode'; + /** * Client configuration options. * @@ -40,6 +42,7 @@ export class ClientConfig { attributionParams, autoPromptConfig, paySwgVersion, + previewAvailable, uiPredicates, usePrefixedHostPath, useUpdatedOfferFlows, @@ -51,6 +54,9 @@ export class ClientConfig { /** @const {string|undefined} */ this.paySwgVersion = paySwgVersion; + /** @const {string|undefined} */ + this.previewAvailable = previewAvailable; + /** @const {boolean} */ this.usePrefixedHostPath = usePrefixedHostPath || false; @@ -65,5 +71,9 @@ export class ClientConfig { /** @const {./attribution-params.AttributionParams|undefined} */ this.attributionParams = attributionParams; + + if (PreviewManager.isPreviewEnabled()) { + PreviewManager.getPreviewManager().setClientConfig(this); + } } } diff --git a/src/runtime/client-config-manager.js b/src/runtime/client-config-manager.js index bcdba480c4..a76d812abe 100644 --- a/src/runtime/client-config-manager.js +++ b/src/runtime/client-config-manager.js @@ -18,7 +18,9 @@ import {AttributionParams} from '../model/attribution-params'; import {AutoPromptConfig} from '../model/auto-prompt-config'; import {ClientConfig} from '../model/client-config'; import {ClientTheme} from '../api/basic-subscriptions'; +import {PreviewManager} from './preview-mode'; import {UiPredicates} from '../model/auto-prompt-config'; +import {addQueryParam} from '../utils/url'; import {serviceUrl} from './services'; import {warn} from '../utils/log'; @@ -165,11 +167,15 @@ export class ClientConfigManager { } else { // If there was no article from the entitlement manager, we need // to fetch our own using the internal version. - const url = serviceUrl( + let url = serviceUrl( '/publication/' + encodeURIComponent(this.publicationId_) + '/clientconfiguration' ); + if (PreviewManager.isPreviewEnabled()) { + url = addQueryParam(url, 'previewRequested', true); + } + return this.fetcher_.fetchCredentialedJson(url).then((json) => { if (json.errorMessages && json.errorMessages.length > 0) { for (const errorMessage of json.errorMessages) { diff --git a/src/runtime/entitlements-manager.js b/src/runtime/entitlements-manager.js index 2d1bddc8da..4c2ecb3e42 100644 --- a/src/runtime/entitlements-manager.js +++ b/src/runtime/entitlements-manager.js @@ -37,6 +37,7 @@ import { import {JwtHelper} from '../utils/jwt'; import {MeterClientTypes} from '../api/metering'; import {MeterToastApi} from './meter-toast-api'; +import {PreviewManager} from './preview-mode'; import {Toast} from '../ui/toast'; import {addQueryParam, getCanonicalUrl, parseQueryString} from '../utils/url'; import {analyticsEventToEntitlementResult} from './event-type-mapping'; @@ -369,6 +370,9 @@ export class EntitlementsManager { let url = '/publication/' + encodeURIComponent(this.publicationId_) + this.action_; url = addDevModeParamsToUrl(this.win_.location, url); + if (PreviewManager.isPreviewEnabled()) { + url = addQueryParam(url, 'previewRequested', true); + } // Promise that sets this.encodedParams_ when it resolves. const encodedParamsPromise = this.encodedParams_ @@ -414,6 +418,9 @@ export class EntitlementsManager { */ getEntitlementsFlow_(params) { return this.fetchEntitlementsWithCaching_(params).then((entitlements) => { + if (PreviewManager.isPreviewEnabled()) { + PreviewManager.getPreviewManager().setEntitlements(entitlements); + } this.onEntitlementsFetched_(entitlements); return entitlements; }); @@ -820,6 +827,11 @@ export class EntitlementsManager { url = addDevModeParamsToUrl(this.win_.location, url); + // Preview mode request? + if (PreviewManager.isPreviewEnabled()) { + url = addQueryParam(url, 'previewRequested', true); + } + // Add encryption param. if (params?.encryption) { url = addQueryParam( diff --git a/src/runtime/pay-flow.js b/src/runtime/pay-flow.js index 39b0601b86..817366f94d 100644 --- a/src/runtime/pay-flow.js +++ b/src/runtime/pay-flow.js @@ -30,6 +30,7 @@ import {ActivityIframeView} from '../ui/activity-iframe-view'; import {AnalyticsEvent, EventParams} from '../proto/api_messages'; import {Constants} from '../utils/constants'; import {JwtHelper} from '../utils/jwt'; +import {PreviewManager} from './preview-mode'; import { ProductType, SubscriptionFlows, @@ -98,6 +99,9 @@ export class PayStartFlow { /** @private @const {!./deps.DepsDef} */ this.deps_ = deps; + /** @private @const {!Window} */ + this.win_ = deps.win(); + /** @private @const {!./pay-client.PayClient} */ this.payClient_ = deps.payClient(); @@ -131,7 +135,16 @@ export class PayStartFlow { // Get the paySwgVersion for buyflow. const promise = this.clientConfigManager_.getClientConfig(); return promise.then((clientConfig) => { - this.start_(clientConfig.paySwgVersion); + if (PreviewManager.isPreviewEnabled() /*&& clientConfig.previewAvailable*/) { + PreviewManager.getPreviewManager().showPreviewResult( + clientConfig, + this.subscriptionRequest_, + this.productType_ + ); + return Promise.resolve(); + } else { + this.start_(clientConfig.paySwgVersion); + } }); } diff --git a/src/runtime/preview-frame.js b/src/runtime/preview-frame.js new file mode 100644 index 0000000000..f67a11837e --- /dev/null +++ b/src/runtime/preview-frame.js @@ -0,0 +1,118 @@ +/** + * Copyright 2022 The Subscribe with Google Authors. All Rights Reserved. + * + * Licensed 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 CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const PREVIEW_FRAME_JS = ` + const SENTINEL = "SWGPREV" + function clickHandler(event) { + if (!event.target.id) { + return; + } + + let message = { + sentinel: SENTINEL, + type: event.type, + target: event.target.id, + }; + console.log(message); + window.parent.postMessage(message, '*'); + } + window.addEventListener('click',clickHandler); +`; + +export const PREVIEW_FRAME_STYLE = ` +body { + padding: 5px 0 5px; + font-family: sans-serif; + line-height: 1.3; + max-height: 100vh; + font-size: 14px; +} +.menu {margin: 5px 5px 0 5px } +.header { text-align: center; font-weight: bold; padding: 0 5px 0 5px; color: #444 } +.clickable { cursor: pointer; } +.menuItem { + padding: 3px 8px 2px 8px; + color: #444; + margin: 0 3px 0 3px; + background: #fff; + border: none; + border-bottom: 2px transparent; + +} +.menuItem:hover { + background: #eee; + border-bottom: 2px solid #eee; +} +.active, .active:hover { + color: #000; + border-bottom: 2px solid blue; +} +.hidden { display: none } +.expand #tidy, .show { display: block } +#dataPane { border-top: 1px solid #ccc; overflow: scroll; position: fixed; top: 53px; bottom: 0; +width: 100%; } +#dataPane > div { padding: 5px 0 0 5px } +#tidy { + position: fixed; + top: 25px; + right: 3px; + font-size: 36px; + height: 25px; + display: none; + } + #close { float: right; padding-right 5px} +.placeholder { margin: 15px 0 15px; text-align: center; font-weight: bold;} +pre { margin: 0.5em 0; font-size: 12px }; + +`; + +export const PREVIEW_FRAME_HTML = ` +
+ Reader Revenue Dev Tools + +
+ +
+ + + +
˄
+
+`; diff --git a/src/runtime/preview-mode.js b/src/runtime/preview-mode.js new file mode 100644 index 0000000000..a537e11697 --- /dev/null +++ b/src/runtime/preview-mode.js @@ -0,0 +1,430 @@ +/** + * Copyright 2022 The Subscribe with Google Authors. All Rights Reserved. + * + * Licensed 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 CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Preview mode manager + */ +import {Dialog} from '../components/dialog'; +import {ErrorUtils} from '../utils/errors'; +import { + PREVIEW_FRAME_HTML, + PREVIEW_FRAME_JS, + PREVIEW_FRAME_STYLE, +} from './preview-frame'; +import {parseQueryString} from '../utils/url'; +import {removeElement} from '../utils/dom'; +import {setImportantStyles} from '../utils/style'; + +// Singleton +let swgPreviewManager = null; + +// Constants +const QUERY_KEY = 'swg.preview'; +const SENTINEL = 'SWGPREV'; +const MENU_HEIGHT = '56px'; +const MAX_Z_INDEX = 2147483647; + +const IMPORTANT_STYLES = { + 'opacity': '1', + 'position': 'fixed', + 'background': 'white', + 'color': 'black', + 'inset': '0 0 auto auto', + 'top': '0', + 'left': 'auto', + 'bottom': 'auto', + 'height': MENU_HEIGHT, + 'max-height': '70vh', + 'right': '0', + 'min-width': '320px', + 'width': '70vw', + 'z-index': MAX_Z_INDEX, + 'border': '1px solid black', + 'visibility': 'visible', +}; + +const DIALOG_CONFIG = { + iframeCssClassOverride: 'swg-preview-dialog', + isCenterPositioned: false, +}; + +// Keys to skip when stringifying +const SKIPPED_KEYS = [ + 'isReadyToPay', + 'raw', + 'usePrefixedHostPath', + 'useUpdatedOfferFlows', + 'product_', +]; + +// Period units +const UNITS = { + 'D': 'day', + 'M': 'month', + 'Y': 'year', +}; + +/** + * Singleton class to manage preview/debug mode + * dialog state and avoid having to pass deps with every call + */ +export class PreviewManager { + /** + * initialize the singlton preview manager + * @param {!../runtime/deps.DepsDef} deps + */ + static init(deps) { + // Check hash, preview mode requested + + try { + const query = parseQueryString(deps.win().location.hash); + if (!query[QUERY_KEY]) { + return; // If preview is not enabled bail + } + // Create the singleton if needed + if (!swgPreviewManager) { + swgPreviewManager = new PreviewManager(deps, query[QUERY_KEY]); + } + } catch (e) { + // Ignore: query parsing cannot block runtime. + ErrorUtils.throwAsync(e); + return; + } + } + + /** + * isPreviewEnabled + * @public + * @returns {boolean} + */ + static isPreviewEnabled() { + return !!swgPreviewManager; + } + + /** + * getPreviewManager + * @returns{!PreviewManager} + */ + static getPreviewManager() { + return swgPreviewManager; + } + + /** + * @param {!../runtime/deps.DepsDef} deps + * @param {string} level + */ + constructor(deps, level) { + /** @private @const {!../deps.DepsDef} */ + this.deps_ = deps; + + /** @private @const {string} */ + this.level_ = level; + + /** @private @const */ + this.globalDoc_ = deps.doc(); + /** @private @const */ + this.doc_ = this.globalDoc_.getRootNode(); + + /** @private {?Promise} */ + this.openPromise_ = null; + + /** @private {?Document} */ + this.frameDoc_ = null; + + /** @private {?Element} */ + this.frameElement_ = null; + + /** @private {!Array} */ + this.errorsDetected = []; + + this.dialog_ = new Dialog( + this.globalDoc_, + IMPORTANT_STYLES, + /* styles */ {}, + DIALOG_CONFIG + ); + + /** + * available preview functions + * @private {!Array<{name: string, entry: function, params: *}>} + */ + this.availablePreviews_ = []; + + /** + * Processed offers + * @private {!Promise} + */ + + // Init our listner and window + addEventListener('message', (e) => this.messageHandler_(e), false); + + this.openPromise_ = this.dialog_.open(true); + this.openPromise_.then((previewDialog) => { + this.frameElement_ = previewDialog.getElement(); + setImportantStyles(this.frameElement_, IMPORTANT_STYLES); + this.frameDoc_ = this.frameElement_.contentWindow.document; + + // Add some Styles + const previewStyle = this.frameDoc_.createElement('style'); + previewStyle.textContent = PREVIEW_FRAME_STYLE; + this.frameDoc_.head.appendChild(previewStyle); + + // and our minimal script + const previewScript = this.frameDoc_.createElement('script'); + previewScript.textContent = PREVIEW_FRAME_JS; + this.frameDoc_.head.appendChild(previewScript); + + previewDialog.getContainer().innerHTML = PREVIEW_FRAME_HTML; + + this.setPageConfig(); + + this.tidy_(); + }); + } + + /** + * messaheHandler + * @param {!Event} messsge + * @private + */ + messageHandler_(message) { + if (message.data.sentinel != SENTINEL || !message.data.target) { + return; + } + + switch (message.data.target) { + case 'close': + this.exit_(); + break; + case 'ents': + case 'conf': + case 'prev': + this.show_(message.data.target); + break; + case 'tidy': + this.tidy_(); + break; + case 'subscription': + this.tidy_(); // hide the menu + //this.deps_.showSubscribeOption(); + this.deps_.showOffers(); + break; + case 'contribution': + this.tidy_(); + this.deps_.showContributionOptions(); + break; + } + } + + /** + * exit preview mode, delete our frame and remove the manager instance. + */ + exit_() { + removeElement(this.frameElement_); + swgPreviewManager = null; + } + + /** + * resize the preview frame + * @param {string} pane; + */ + expand_(pane) { + const elementHeight = this.frameDoc_.getElementById( + `${pane}Data` + ).scrollHeight; + this.frameDoc_.body.classList.add('expand'); + this.frameElement_.style.setProperty( + 'height', + `${elementHeight + 100}px`, + 'important' + ); + } + + /** + * show a pane + * @param {string} pane + */ + show_(pane) { + this.hideData_(); + this.frameDoc_.getElementById(`${pane}`).classList.add('active'); + this.frameDoc_.getElementById(`${pane}Data`).classList.add('show'); + this.expand_(pane); + } + + /** + * tidy - hide data panes and go back to small menu + * @private + */ + tidy_() { + // Restore the z-index of swg-dialogs + Array.from(this.doc_.getElementsByClassName('swg-dialog')).forEach( + (dialog) => setImportantStyles(dialog, {'z-index': MAX_Z_INDEX}) + ); + this.frameElement_.style.setProperty('height', MENU_HEIGHT, 'important'); + this.frameDoc_.body.classList.remove('expand'); + this.hideData_(); + } + + /** + * Hide the data elements + * @private + */ + hideData_() { + const dataElements = Array.from( + this.frameDoc_.getElementsByClassName('show') + ); + dataElements.forEach((el) => el.classList.remove('show')); + const menuElements = Array.from( + this.frameDoc_.getElementsByClassName('active') + ); + menuElements.forEach((el) => el.classList.remove('active')); + } + + /** + * build the preview menu + * @param {ClientConfig} clientConfig; + * @private + */ + buildPreviewMenu_(clientConfig) { + if (clientConfig.previewAvailable) { + /* TODO:(jpettitt) customized the menu based on config */ + } + } + + /** + * setPageConfig + * @public + */ + setPageConfig() { + this.openPromise_.then(() => { + this.frameDoc_.getElementById('pageConfig').innerText = JSON.stringify( + this.deps_.pageConfig(), + (key, value) => this.replacer_(key, value), + /* spaces */ 2 + ); + }); + } + + /** + * setClientConfig + * @param {!../model/client-config.ClientConfig} clientConfig + * @public + */ + setClientConfig(clientConfig) { + this.openPromise_ + .then(() => { + this.frameDoc_.getElementById('clientConfig').innerText = + JSON.stringify( + clientConfig, + (key, value) => this.replacer_(key, value), + /* spaces */ 2 + ); + if (clientConfig.previewAvailable || this.level_ == 'debug') { + this.buildPreviewMenu_(clientConfig); + return 'prev'; + } else { + this.frameDoc_.getElementById('prev').remove(); + return 'conf'; + } + }) + .then((pane) => { + this.show_(pane); + }); + } + + /** + * setEntitlements + * @param {!../amo/entitlments.Entitlements} entitlements + * @public + */ + setEntitlements(entitlments) { + this.openPromise_.then(() => { + this.frameDoc_.getElementById('entitmentDetail').innerText = + JSON.stringify( + entitlments, + (key, value) => this.replacer_(key, value), + /* spaces */ 2 + ); + }); + } + + /** + * replacer - sugar to remove keys from stringify + * @param {string} key + * @param {string} value + * @returns {*} + * @private + */ + replacer_(key, value) { + // Expand json subscription tokens + if (key == 'subscriptionToken' && /^{\"/.test(value)) { + try { + value = JSON.parse(value.replace('\\"', '"')); + } catch (e) { + // ignore parse failures + } + } + // If we're in debug mode show all the keys + // Otherwsie skip the ones in the SKIPPED_KEYS array + if (this.level_ != 'debug' && SKIPPED_KEYS.indexOf(key) != -1) { + return undefined; + } + // Expand Periods + if (key && /Period$/.test(key)) { + const [val, period, unit] = value.match(/P(\d+)(.)$/); + return `${val} (per ${period} ${ + period == 1 ? UNITS[unit] : UNITS[unit] + 's' + })`; + } + return value; + } + + /** + * showPreviewResult + * @public + * @param {!../model/client-config.ClientConfig} clientConfig + * @param {!../api/subscriptions.SubscriptionRequest} subscriptionRequest + * @param {!../api/subscriptions.ProductType} productType + */ + showPreviewResult(clientConfig, subscriptionRequest, productType) { + // Move the swGFrame down one layer so we overlay it. + Array.from(this.doc_.getElementsByClassName('swg-dialog')).forEach( + (dialog) => setImportantStyles(dialog, {'z-index': MAX_Z_INDEX - 1}) + ); + // Get the offers now becasue we need to page + // config to be valid before we can do it + this.deps_.getOffers().then((offers) => { + // Find the offer that matches this sku + const [selectedOffer] = offers.filter((offer) => { + return offer.skuId == subscriptionRequest.skuId; + }); + // Show what the user clicked + const prodInfo = JSON.stringify( + selectedOffer, + (key, value) => this.replacer_(key, value), + /* spaces */ 2 + ); + this.frameDoc_.getElementById( + 'previewResultData' + ).innerText = `Product type: ${productType.replace( + 'UI_', + '' + )}\n${prodInfo}`; + this.frameDoc_.getElementById('previewResult').classList.remove('hidden'); + this.show_('prev'); + }); + } +} diff --git a/src/runtime/runtime.js b/src/runtime/runtime.js index 96ef2dd66b..68683930c9 100644 --- a/src/runtime/runtime.js +++ b/src/runtime/runtime.js @@ -54,6 +54,7 @@ import { import {PayClient} from './pay-client'; import {PayCompleteFlow, PayStartFlow} from './pay-flow'; import {Preconnect} from '../utils/preconnect'; +import {PreviewManager} from './preview-mode'; import { ProductType, Subscriptions as SubscriptionsInterface, @@ -641,6 +642,8 @@ export class ConfiguredRuntime { integr.useArticleEndpoint || false ); + PreviewManager.init(this); + /** @private @const {!ClientConfigManager} */ this.clientConfigManager_ = new ClientConfigManager( this, // See note about 'this' above From a7cc329d4541857eed7c730391d885b61521dfd7 Mon Sep 17 00:00:00 2001 From: John Pettitt Date: Wed, 22 Jun 2022 19:48:13 +0000 Subject: [PATCH 2/4] lint --- src/runtime/pay-flow.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/runtime/pay-flow.js b/src/runtime/pay-flow.js index a4dfaf043f..f1f5fbb5bb 100644 --- a/src/runtime/pay-flow.js +++ b/src/runtime/pay-flow.js @@ -135,7 +135,9 @@ export class PayStartFlow { // Get the paySwgVersion for buyflow. const promise = this.clientConfigManager_.getClientConfig(); return promise.then((clientConfig) => { - if (PreviewManager.isPreviewEnabled() /*&& clientConfig.previewAvailable*/) { + if ( + PreviewManager.isPreviewEnabled() /*&& clientConfig.previewAvailable*/ + ) { PreviewManager.getPreviewManager().showPreviewResult( clientConfig, this.subscriptionRequest_, From 20af151abc8fbf4f85a0e543694762dfb51274c1 Mon Sep 17 00:00:00 2001 From: John Pettitt Date: Mon, 29 Aug 2022 22:31:28 +0000 Subject: [PATCH 3/4] typdefs --- src/runtime/client-config-manager.js | 2 +- src/runtime/entitlements-manager.js | 4 +- src/runtime/preview-frame.js | 3 +- src/runtime/preview-mode.js | 96 ++++++++++++++++------------ 4 files changed, 60 insertions(+), 45 deletions(-) diff --git a/src/runtime/client-config-manager.js b/src/runtime/client-config-manager.js index 1e21a1640b..6f7e7bef2b 100644 --- a/src/runtime/client-config-manager.js +++ b/src/runtime/client-config-manager.js @@ -172,7 +172,7 @@ export class ClientConfigManager { '/clientconfiguration' ); if (PreviewManager.isPreviewEnabled()) { - url = addQueryParam(url, 'previewRequested', true); + url = addQueryParam(url, 'previewRequested', '1'); } return this.fetcher_.fetchCredentialedJson(url).then((json) => { diff --git a/src/runtime/entitlements-manager.js b/src/runtime/entitlements-manager.js index 3bf3d1bc4a..f3177ef724 100644 --- a/src/runtime/entitlements-manager.js +++ b/src/runtime/entitlements-manager.js @@ -383,7 +383,7 @@ export class EntitlementsManager { '/publication/' + encodeURIComponent(this.publicationId_) + this.action_; url = addDevModeParamsToUrl(this.win_.location, url); if (PreviewManager.isPreviewEnabled()) { - url = addQueryParam(url, 'previewRequested', true); + url = addQueryParam(url, 'previewRequested', '1'); } // Promise that sets this.encodedParams_ when it resolves. @@ -849,7 +849,7 @@ export class EntitlementsManager { // Preview mode request? if (PreviewManager.isPreviewEnabled()) { - url = addQueryParam(url, 'previewRequested', true); + url = addQueryParam(url, 'previewRequested', 'true'); } // Add encryption param. diff --git a/src/runtime/preview-frame.js b/src/runtime/preview-frame.js index f67a11837e..abf68fe98b 100644 --- a/src/runtime/preview-frame.js +++ b/src/runtime/preview-frame.js @@ -60,6 +60,7 @@ body { color: #000; border-bottom: 2px solid blue; } +.prevMenu li { margin: 2px 0 2px 0} .hidden { display: none } .expand #tidy, .show { display: block } #dataPane { border-top: 1px solid #ccc; overflow: scroll; position: fixed; top: 53px; bottom: 0; @@ -100,7 +101,7 @@ export const PREVIEW_FRAME_HTML = `