UNPKG

@ninetailed/experience.js-plugin-preview

Version:
667 lines (638 loc) 23.9 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'; 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) { return undefined; } return ({ changes: inputChanges }) => { if (!inputChanges || inputChanges.length === 0) { return { changes: inputChanges }; } // Calculate and return overridden changes on demand instead of storing them return { changes: this.getEffectiveChanges(inputChanges) }; }; }; 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); // Get the selected variant index const variantIndex = this.pluginApi.experienceVariantIndexes[experience.id]; // Handle variable components for this experience (NEW CODE) if (variantIndex !== undefined) { // Process all variable components for this experience const variableComponents = experience.components.filter(component => component.type === ComponentTypeEnum.InlineVariable); // Set variable values based on the selected variant index variableComponents.forEach(component => { const key = component.key; let value; if (variantIndex === 0) { value = component.baseline; } else { const variant = component.variants[variantIndex - 1]; value = variant && 'value' in variant ? variant.value : component.baseline; } // Set the variable in our changes system this.setVariableValue({ experienceId: experience.id, key, value, variantIndex }); }); } // 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$audience; return ((_experience$audience = experience.audience) == null ? void 0 : _experience$audience.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.experienceVariantIndexOverwrites = Object.entries(this.experienceVariantIndexOverwrites).filter(([key, _]) => { return !this.experiences.filter(experience => { var _experience$audience2; return ((_experience$audience2 = experience.audience) == null ? void 0 : _experience$audience2.id) === id; }).map(experience => experience.id).includes(key); }).reduce((acc, [key, value]) => { return Object.assign({}, acc, { [key]: value }); }, {}); this.audienceOverwrites = Object.assign({}, this.audienceOverwrites, { [id]: false }); this.onChange(); this.experiences.filter(experience => { var _experience$audience3; return ((_experience$audience3 = experience.audience) == null ? void 0 : _experience$audience3.id) === id; }).forEach(experience => { this.setExperienceVariant({ experienceId: experience.id, variantIndex: 0 }); }); 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; } 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 (((_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.17.0" , 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 overrides - 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$meta; if (change.type !== ChangeTypes.Variable) return true; const changeKey = (_change$meta = change.meta) != null && _change$meta.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 };