diff --git a/src/model/client-config.js b/src/model/client-config.js
index a1b54cbb45..185d8cd581 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,6 +71,10 @@ 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 f70e750fa5..6f7e7bef2b 100644
--- a/src/runtime/client-config-manager.js
+++ b/src/runtime/client-config-manager.js
@@ -18,6 +18,8 @@ import {AttributionParams} from '../model/attribution-params';
import {AutoPromptConfig} from '../model/auto-prompt-config';
import {ClientConfig, UiPredicates} from '../model/client-config';
import {ClientTheme} from '../api/basic-subscriptions';
+import {PreviewManager} from './preview-mode';
+import {addQueryParam} from '../utils/url';
import {serviceUrl} from './services';
import {warn} from '../utils/log';
@@ -164,11 +166,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', '1');
+ }
+
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 3cc292fe93..f3177ef724 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';
@@ -381,6 +382,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', '1');
+ }
// Promise that sets this.encodedParams_ when it resolves.
const encodedParamsPromise = this.encodedParams_
@@ -426,6 +430,9 @@ export class EntitlementsManager {
*/
getEntitlementsFlow_(params) {
return this.fetchEntitlementsWithCaching_(params).then((entitlements) => {
+ if (PreviewManager.isPreviewEnabled()) {
+ PreviewManager.getPreviewManager().setEntitlements(entitlements);
+ }
this.onEntitlementsFetched_(entitlements);
return entitlements;
});
@@ -840,6 +847,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 7352e94eac..f1f5fbb5bb 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,18 @@ 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..a3d2f08a78
--- /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,
+ };
+ 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;
+}
+.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;
+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 = `
+
+
+
+
+
On page config:
+
+
+
+
Config from server:
+
Loading ...
+
+
+
Entitlement response from server:
+
Fetching entitlments ...
+
+
+
Available Previews
+
+
+
Requested Transaction
+
+
+
+
˄
+
+`;
diff --git a/src/runtime/preview-mode.js b/src/runtime/preview-mode.js
new file mode 100644
index 0000000000..1af87cd177
--- /dev/null
+++ b/src/runtime/preview-mode.js
@@ -0,0 +1,445 @@
+/**
+ * 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',
+ 'skipAccountCreationScreen',
+ 'product_',
+];
+
+// Period units
+const UNITS = {
+ 'D': 'day',
+ 'M': 'month',
+ 'Y': 'year',
+};
+
+/**
+ * @typedef {{
+ * name: string,
+ * previewCallback: function(*),
+ * cbParams: Array,
+ * }} PreviewOption
+ **/
+export let PreviewOption;
+
+/**
+ * Singleton class to manage preview/debug mode
+ * dialog state and avoid having to pass runtime with every call
+ */
+export class PreviewManager {
+ /**
+ * initialize the singlton preview manager
+ * @param {!../runtime/runtime.ConfiguredRuntime} runtime
+ */
+ static init(runtime) {
+ // Check hash, preview mode requested
+
+ try {
+ const query = parseQueryString(runtime.win().location.hash);
+ if (!query[QUERY_KEY]) {
+ return; // If preview is not enabled bail
+ }
+ // Create the singleton if needed
+ if (!swgPreviewManager) {
+ swgPreviewManager = new PreviewManager(runtime, 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/runtime.ConfiguredRuntime} runtime
+ * @param {string} level
+ */
+ constructor(runtime, level) {
+ /** @private @const {!../runtime/runtime.ConfiguredRuntime} */
+ this.runtime_ = runtime;
+
+ /** @private @const {string} */
+ this.level_ = level;
+
+ /** @private @const */
+ this.globalDoc_ = runtime.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}
+ */
+ this.availablePreviews_ = [];
+
+ // 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()./*OK*/ innerHTML = PREVIEW_FRAME_HTML;
+
+ this.setPageConfig();
+
+ this.tidy_();
+ });
+ }
+
+ /**
+ * messaheHandler
+ * @param {!Event} message
+ * @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.runtime_.showSubscribeOption();
+ this.runtime_.showOffers();
+ break;
+ case 'contribution':
+ this.tidy_();
+ this.runtime_.showContributionOptions();
+ break;
+ }
+ }
+
+ /**
+ * exit preview mode, delete our frame and remove the manager instance.
+ */
+ exit_() {
+ if (this.frameElement_) {
+ 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
+ [...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 = [...this.frameDoc_.getElementsByClassName('show')];
+ dataElements.forEach((el) => el.classList.remove('show'));
+ const menuElements = [...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')./*OK*/ innerText =
+ JSON.stringify(
+ this.runtime_.pageConfig(),
+ (key, value) => this.replacer_(key, value),
+ /* spaces */ 2
+ );
+ });
+ }
+
+ /**
+ * setClientConfig
+ * @param {!../model/client-config.ClientConfig} clientConf
+ * @public
+ */
+ setClientConfig(clientConf) {
+ const clientConfig = clientConf;
+ this.openPromise_
+ .then(() => {
+ this.frameDoc_.getElementById('clientConfig')./*OK*/ 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 {!../api/entitlements.Entitlements} entitlements
+ * @public
+ */
+ setEntitlements(entitlements) {
+ const ents = entitlements;
+ this.openPromise_.then(() => {
+ this.frameDoc_.getElementById('entitlementDetail')./*OK*/ innerText =
+ JSON.stringify(
+ ents,
+ (key, value) => this.replacer_(key, value),
+ /* spaces */ 2
+ );
+ });
+ }
+
+ /**
+ * replacer - sugar to remove keys from stringify
+ * @param {string} key
+ * @param {*} 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
+ }
+ }
+ // Change decrypted key to N/A if we're not in debug
+ if (
+ this.level_ != 'debug' &&
+ key == 'decryptedDocumentKey' &&
+ value === null
+ ) {
+ value = 'N/A (plain text document)';
+ }
+ // 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.
+ [...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
+ /** @type {!../api/subscriptions.Subscriptions} */
+ (this.runtime_).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'
+ )./*OK*/ 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 7c44d15ac6..2f1d1f042b 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,
@@ -635,6 +636,8 @@ export class ConfiguredRuntime {
integr.enableDefaultMeteringHandler || false
);
+ PreviewManager.init(this);
+
/** @private @const {!ClientConfigManager} */
this.clientConfigManager_ = new ClientConfigManager(
this, // See note about 'this' above