UNPKG

@ninetailed/experience.js-plugin-preview

Version:
643 lines (615 loc) 23.6 kB
import { logger, ComponentTypeEnum, ChangeTypes, unionBy } from '@ninetailed/experience.js-shared'; import { OnChangeEmitter, PROFILE_CHANGE, isExperienceMatch, selectDistribution } from '@ninetailed/experience.js'; import { NinetailedPlugin } from '@ninetailed/experience.js-plugin-analytics'; import { isEqual } from 'radash'; function _objectWithoutPropertiesLoose(source, excluded) { if (source == null) return {}; var target = {}; var sourceKeys = Object.keys(source); var key, i; for (i = 0; i < sourceKeys.length; i++) { key = sourceKeys[i]; if (excluded.indexOf(key) >= 0) continue; target[key] = source[key]; } return target; } function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); } function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); } const CONTAINER_WIDTH = 432; const BUTTON_WIDTH = 48; const BUTTON_HEIGHT = 192; const BUTTON_BOTTOM_POSITION = 128; const TRANSFORM_CLOSED = `translate(${CONTAINER_WIDTH - BUTTON_WIDTH}px, 0px)`; const TRANSFORM_CLOSED_HIDE = `translate(${CONTAINER_WIDTH}px, 0px)`; const TRANSFORM_OPEN = `translate(0px, 0px)`; class WidgetContainer { constructor(options) { var _options$ui; this.container = void 0; this.options = options; this.container = document.createElement('div'); this.container.classList.add(WidgetContainer.CONTAINER_CLASS); this.container.style.position = 'fixed'; this.container.style.zIndex = '999999'; this.container.style.right = '0px'; this.container.style.bottom = `${BUTTON_BOTTOM_POSITION}px`; this.container.style.width = `${CONTAINER_WIDTH}px`; this.container.style.height = `${BUTTON_HEIGHT}px`; this.container.style.overflow = 'hidden'; if ((_options$ui = options.ui) != null && (_options$ui = _options$ui.opener) != null && _options$ui.hide) { this.container.style.transform = TRANSFORM_CLOSED_HIDE; } else { this.container.style.transform = TRANSFORM_CLOSED; } this.container.style.transitionTimingFunction = 'cubic-bezier(0.4, 0, 0.2, 1)'; this.container.style.transitionDuration = '700ms'; this.container.style.transitionProperty = 'transform'; document.body.appendChild(this.container); } open() { this.container.style.transform = TRANSFORM_OPEN; this.container.style.height = '100vh'; this.container.style.bottom = `0px`; } close() { var _this$options$ui; if ((_this$options$ui = this.options.ui) != null && (_this$options$ui = _this$options$ui.opener) != null && _this$options$ui.hide) { this.container.style.transform = TRANSFORM_CLOSED_HIDE; } else { this.container.style.transform = TRANSFORM_CLOSED; } setTimeout(() => { this.container.style.height = `${BUTTON_HEIGHT}px`; this.container.style.bottom = `${BUTTON_BOTTOM_POSITION}px`; }, 700); } get element() { return this.container; } static isContainerAttached() { return document.querySelector(`.${WidgetContainer.CONTAINER_CLASS}`) !== null; } } WidgetContainer.CONTAINER_CLASS = 'nt-preview-widget-container'; class NinetailedPreviewPlugin extends NinetailedPlugin { constructor(options) { var _this; super(); _this = this; this.name = 'ninetailed:preview' + Math.random(); this.isOpen = false; this.experiences = []; this.audienceDefinitions = []; this.audienceOverwrites = {}; this.experienceVariantIndexOverwrites = {}; this.variableOverwrites = {}; this.profile = null; this.changes = []; this.container = null; // eslint-disable-next-line @typescript-eslint/no-explicit-any this.bridge = null; /** * Since several instances of the plugin can be created, we need to make sure only one is marked as active. */ this.isActiveInstance = false; this.onChangeEmitter = new OnChangeEmitter(); this.onOpenExperienceEditor = void 0; this.onOpenAudienceEditor = void 0; this.clientId = null; this.environment = null; this.initialize = async function () { if (typeof window !== 'undefined') { var _window$ninetailed, _this$changes; if (WidgetContainer.isContainerAttached()) { logger.warn('Preview plugin is already attached.'); _this.isActiveInstance = false; return; } const { PreviewBridge } = await import('@ninetailed/experience.js-preview-bridge'); _this.isActiveInstance = true; _this.container = new WidgetContainer({ ui: _this.options.ui }); _this.bridge = PreviewBridge({ url: _this.options.url, nonce: _this.options.nonce }); _this.bridge.render(_this.container.element); window.ninetailed = Object.assign({}, window.ninetailed, { plugins: Object.assign({}, (_window$ninetailed = window.ninetailed) == null ? void 0 : _window$ninetailed.plugins, { preview: _this.windowApi }) }); _this.bridge.updateProps({ props: _this.pluginApi }); if (!((_this$changes = _this.changes) != null && _this$changes.length)) { _this.onChange(); } } }; this.loaded = () => true; this[PROFILE_CHANGE] = ({ payload }) => { if (!this.isActiveInstance) { return; } if (payload != null && payload.profile) { this.onProfileChange(payload.profile, payload.changes || []); } }; /** * Implements the HasChangesModificationMiddleware interface * Returns a middleware function that applies variable overwrites to changes */ this.getChangesModificationMiddleware = () => { if (!this.isActiveInstance || Object.keys(this.variableOverwrites).length === 0 && Object.keys(this.audienceOverwrites).length === 0) { return undefined; } return ({ changes: inputChanges }) => { if (!inputChanges || inputChanges.length === 0) { return { changes: inputChanges }; } const filteredChanges = inputChanges.filter(change => { var _change$meta, _experience$audience; const experienceId = (_change$meta = change.meta) == null ? void 0 : _change$meta.experienceId; if (!experienceId) return true; const experience = this.experiences.find(e => e.id === experienceId); if (!(experience != null && (_experience$audience = experience.audience) != null && _experience$audience.id)) return true; return this.audienceOverwrites[experience.audience.id] !== false; }); return { changes: this.getEffectiveChanges(filteredChanges) }; }; }; this.getExperienceSelectionMiddleware = ({ baseline, experiences }) => { if (!this.isActiveInstance) { return; } return () => { const experienceIds = Object.keys(this.pluginApi.experienceVariantIndexes); const experience = experiences.find(experience => { return experienceIds.includes(experience.id); }); if (!experience) { return { experience: null, variant: baseline, variantIndex: 0 }; } // Handle entry replacements as before const entryReplacementComponents = experience.components.filter(component => component.type === ComponentTypeEnum.EntryReplacement && 'id' in component.baseline); const baselineComponent = entryReplacementComponents.find(component => component.baseline.id === baseline.id); const variantIndex = this.pluginApi.experienceVariantIndexes[experience.id]; // Continue with entry replacement handling if (!baselineComponent) { return { experience, variant: baseline, variantIndex: 0 }; } const allVariants = [baseline, ...baselineComponent.variants]; if (allVariants.length <= variantIndex) { return { experience, variant: baseline, variantIndex: 0 }; } const variant = allVariants[variantIndex]; if (!variant) { return { experience, variant: baseline, variantIndex: 0 }; } return { experience, variant, variantIndex }; }; }; this.onChange = () => { logger.debug('Ninetailed Preview Plugin onChange pluginApi:', this.pluginApi); if (typeof window !== 'undefined') { var _window$ninetailed2; window.ninetailed = Object.assign({}, window.ninetailed, { plugins: Object.assign({}, (_window$ninetailed2 = window.ninetailed) == null ? void 0 : _window$ninetailed2.plugins, { preview: this.windowApi }) }); } this.bridge.updateProps({ props: this.pluginApi }); this.onChangeEmitter.invokeListeners(); }; this.onProfileChange = (profile, changes) => { this.profile = profile; logger.debug('Profile changed:', { profile, changes }); // If changes are provided, update them if (changes) { this.onChangesChange(changes); } this.onChange(); }; /** * Handles changes from the SDK and applies any variable overrides. * This should be called whenever the original changes are updated. */ this.onChangesChange = incomingChanges => { if (!this.isActiveInstance) { return; } logger.debug('Received changes:', incomingChanges); // Store the original changes this.changes = incomingChanges; // Notify listeners and update UI this.onChange(); }; this.setCredentials = ({ clientId, environment }) => { this.clientId = clientId; this.environment = environment; }; this.options = options; this.experiences = options.experiences || []; this.audienceDefinitions = options.audiences || []; this.onOpenExperienceEditor = options.onOpenExperienceEditor; this.onOpenAudienceEditor = options.onOpenAudienceEditor; } open() { var _this$container; if (!this.isActiveInstance) { return; } (_this$container = this.container) == null || _this$container.open(); this.isOpen = true; this.onChange(); } close() { var _this$container2; if (!this.isActiveInstance) { return; } (_this$container2 = this.container) == null || _this$container2.close(); setTimeout(() => { this.isOpen = false; this.onChange(); }, 700); } toggle() { if (!this.isActiveInstance) { return; } if (this.isOpen) { this.close(); } else { this.open(); } } activateAudience(id) { if (!this.isActiveInstance) { return; } if (!this.isKnownAudience(id)) { logger.warn(`You cannot activate an unknown audience (id: ${id}).`); return; } this.audienceOverwrites = Object.assign({}, this.audienceOverwrites, { [id]: true }); this.experienceVariantIndexOverwrites = Object.assign({}, this.experienceVariantIndexOverwrites, this.experiences.filter(experience => { var _experience$audience2; return ((_experience$audience2 = experience.audience) == null ? void 0 : _experience$audience2.id) === id; }).map(experience => experience.id).reduce((acc, curr) => { return Object.assign({}, acc, { [curr]: this.experienceVariantIndexes[curr] || 0 }); }, {})); this.onChange(); } deactivateAudience(id) { if (!this.isActiveInstance) { return; } if (!this.isKnownAudience(id)) { logger.warn(`You cannot deactivate an unknown audience (id: ${id}). How did you get it in the first place?`); return; } this.audienceOverwrites = Object.assign({}, this.audienceOverwrites, { [id]: false }); this.onChange(); } resetAudience(id) { if (!this.isActiveInstance) { return; } if (!this.isKnownAudience(id)) { logger.warn(`You cannot reset an unknown audience (id: ${id}). How did you get it in the first place?`); return; } // Identify all experiences that belong to this audience const experiencesToReset = this.experiences.filter(experience => { var _experience$audience3; return ((_experience$audience3 = experience.audience) == null ? void 0 : _experience$audience3.id) === id; }); if (experiencesToReset.length > 0) { const experienceIdsToReset = experiencesToReset.map(e => e.id); // 1. Clear any variable overwrites that were set for these experiences (allows natural evaluation) this.variableOverwrites = Object.fromEntries(Object.entries(this.variableOverwrites).filter(([, change]) => { var _change$meta2; return !((_change$meta2 = change.meta) != null && _change$meta2.experienceId && experienceIdsToReset.includes(change.meta.experienceId)); })); // 2. Clear experience variant index overwrites for these experiences (allows natural variant selection) this.experienceVariantIndexOverwrites = Object.fromEntries(Object.entries(this.experienceVariantIndexOverwrites).filter(([key]) => !experienceIdsToReset.includes(key))); } // 3. Remove audience override (allows natural audience evaluation) const _this$audienceOverwri = this.audienceOverwrites, audienceOverwrites = _objectWithoutPropertiesLoose(_this$audienceOverwri, [id].map(_toPropertyKey)); this.audienceOverwrites = audienceOverwrites; this.onChange(); } setExperienceVariant({ experienceId, variantIndex }) { if (!this.isActiveInstance) { return; } const experience = this.experiences.find(experience => experience.id === experienceId); if (!experience) { logger.warn(`Cannot activate a variant for an unknown experience (id: ${experienceId})`); return; } if (experience.audience && !this.activeAudiences.some(id => { var _experience$audience4; return id === ((_experience$audience4 = experience.audience) == null ? void 0 : _experience$audience4.id); })) { logger.warn(`Cannot activate a variant for an experience (id: ${experienceId}) which is not in the active audiences.`); return; } const isValidIndex = experience.components.map(component => component.variants.length + 1).every(length => length > variantIndex); if (!isValidIndex) { logger.warn(`You activated a variant at index ${variantIndex} for the experience (id: ${experienceId}). Not all components have that many variants, you may see the baseline for some.`); } // Update the experience variant index this.experienceVariantIndexOverwrites = Object.assign({}, this.experienceVariantIndexOverwrites, { [experienceId]: variantIndex }); // Process all components to extract variable values experience.components.forEach(component => { if (component.type === ComponentTypeEnum.InlineVariable) { var _component$variants$v, _component$variants; const key = component.key; const value = variantIndex === 0 ? component.baseline.value : (_component$variants$v = (_component$variants = component.variants[variantIndex - 1]) == null ? void 0 : _component$variants.value) != null ? _component$variants$v : component.baseline.value; // Set the variable value this.setVariableValue({ experienceId, key, value, variantIndex }); } }); // Trigger change notification - this updates the middleware this.onChange(); } resetExperience(experienceId) { if (!this.isActiveInstance) { return; } const _this$experienceVaria = this.experienceVariantIndexOverwrites, experienceVariantIndexOverwrites = _objectWithoutPropertiesLoose(_this$experienceVaria, [experienceId].map(_toPropertyKey)); this.experienceVariantIndexOverwrites = experienceVariantIndexOverwrites; this.onChange(); } reset() { if (!this.isActiveInstance) { return; } if (typeof window !== 'undefined' && window.ninetailed && typeof window.ninetailed.reset === 'function') { window.ninetailed.reset(); } } /** * Sets a variable value override for preview */ setVariableValue({ experienceId, key, value, variantIndex }) { var _this$variableOverwri; if (!this.isActiveInstance) { return; } const overrideKey = `${experienceId}:${key}`; // Only create new object if actually changing if (isEqual((_this$variableOverwri = this.variableOverwrites[overrideKey]) == null ? void 0 : _this$variableOverwri.value, value)) { return; // No change needed } const change = { type: ChangeTypes.Variable, key, value, meta: { experienceId, variantIndex } }; // Update variable overwrites this.variableOverwrites = Object.assign({}, this.variableOverwrites, { [overrideKey]: change }); // Notify listeners this.onChangeEmitter.invokeListeners(); this.onChange(); } openExperienceEditor(experience) { if (this.onOpenExperienceEditor && typeof this.onOpenExperienceEditor === 'function') return this.onOpenExperienceEditor(experience); } /** * @deprecated This method will be removed in a future release. Use `openExperienceEditor` instead to see the experience insights. */ openExperienceAnalytics(experience) { logger.warn('The `openExperienceAnalytics` method is deprecated and will be removed in a future release. Use `openExperienceEditor` instead to see the experience insights.'); window.open(`https://app.ninetailed.io/${this.clientId}/${this.environment}/experiences/${experience.id}`, '_blank'); } openAudienceEditor(audience) { if (this.onOpenAudienceEditor && typeof this.onOpenAudienceEditor === 'function') return this.onOpenAudienceEditor(audience); } get pluginApi() { var _this$profile; return { version: "7.18.9" , open: this.open.bind(this), close: this.close.bind(this), toggle: this.toggle.bind(this), isOpen: this.isOpen, activateAudience: this.activateAudience.bind(this), deactivateAudience: this.deactivateAudience.bind(this), resetAudience: this.resetAudience.bind(this), apiAudiences: ((_this$profile = this.profile) == null ? void 0 : _this$profile.audiences) || [], audienceOverwrites: this.audienceOverwrites, activeAudiences: this.activeAudiences, audienceDefinitions: this.audienceDefinitions, setExperienceVariant: this.setExperienceVariant.bind(this), resetExperience: this.resetExperience.bind(this), apiExperienceVariantIndexes: this.apiExperienceVariantIndexes, experienceVariantIndexes: Object.assign({}, this.experienceVariantIndexes, this.experienceVariantIndexOverwrites), experienceVariantIndexOverwrites: this.experienceVariantIndexOverwrites, reset: this.reset.bind(this), experiences: this.experiences, openExperienceEditor: this.onOpenExperienceEditor ? this.openExperienceEditor.bind(this) : undefined, openExperienceAnalytics: this.openExperienceAnalytics.bind(this), openAudienceEditor: this.onOpenAudienceEditor ? this.openAudienceEditor.bind(this) : undefined }; } get windowApi() { return { version: '2.0.0', open: this.open.bind(this), close: this.close.bind(this), toggle: this.toggle.bind(this), activateAudience: this.activateAudience.bind(this), deactivateAudience: this.deactivateAudience.bind(this), resetAudience: this.resetAudience.bind(this), activeAudiences: this.activeAudiences, setExperienceVariant: this.setExperienceVariant.bind(this), resetExperience: this.resetExperience.bind(this), experienceVariantIndexes: Object.assign({}, this.experienceVariantIndexes, this.experienceVariantIndexOverwrites), setVariableValue: this.setVariableValue.bind(this), variableOverwrites: this.variableOverwrites }; } isKnownAudience(id) { return this.potentialAudiences.some(audience => audience.id === id); } get potentialAudiences() { const audiencesFromExperiences = this.experiences.map(experience => experience.audience).filter(audience => !!audience); return unionBy(this.audienceDefinitions, audiencesFromExperiences, 'id'); } get activeAudiences() { var _this$profile2; const deactivatedAudiences = Object.entries(this.audienceOverwrites).filter(([id, active]) => !active).map(([id]) => id); const activatedAudiences = Object.entries(this.audienceOverwrites).filter(([id, active]) => active).map(([id]) => id); return [...(((_this$profile2 = this.profile) == null ? void 0 : _this$profile2.audiences) || []), ...activatedAudiences].filter(id => !deactivatedAudiences.includes(id)); } calculateExperienceVariantIndexes(profile) { const matchedExperiences = this.experiences.filter(experience => isExperienceMatch({ experience, profile })); return matchedExperiences.reduce((acc, experience) => { const distribution = selectDistribution({ experience, profile }); return Object.assign({}, acc, { [experience.id]: distribution.index }); }, {}); } get apiExperienceVariantIndexes() { const profile = this.profile; if (!profile) { return {}; } return this.calculateExperienceVariantIndexes(profile); } get experienceVariantIndexes() { const profile = this.profile; if (!profile) { return {}; } return this.calculateExperienceVariantIndexes(Object.assign({}, profile, { audiences: this.activeAudiences })); } /** * Get the override key for a variable */ getOverrideKey(experienceId, key) { return `${experienceId}:${key}`; } /** * Get effective changes by applying overwrites - compute on demand */ getEffectiveChanges(inputChanges = this.changes) { if (!inputChanges || Object.keys(this.variableOverwrites).length === 0) { return inputChanges || []; } // Filter out changes that we're overriding const filteredChanges = inputChanges.filter(change => { var _change$meta3; if (change.type !== ChangeTypes.Variable) return true; const changeKey = (_change$meta3 = change.meta) != null && _change$meta3.experienceId ? this.getOverrideKey(change.meta.experienceId, change.key) : change.key; return !this.variableOverwrites[changeKey]; }); const effectiveChanges = [...filteredChanges, ...Object.values(this.variableOverwrites)]; logger.debug('Overridden changes after applying override:', effectiveChanges); // Add our overrides to create the final result return effectiveChanges; } } if (typeof window === 'object' && !('process' in window)) { // eslint-disable-next-line @typescript-eslint/no-explicit-any window.process = {}; } export { NinetailedPreviewPlugin, NinetailedPreviewPlugin as default };