diff --git a/README.md b/README.md index 1e77fbf..3759326 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,74 @@ The docs task will walk through every `.js` file in the examples source director This allows us to keep our source code in one place. Changing a js file in the examples source folder will change the code snippet in the docs and update the example .html file. +### Widget playground + +`playground/` is a local page for rendering any widget type and layout, for manual +QA and for demoing. Start the dev server and open it: + +```sh +$ yarn run local +``` + +Then visit [http://localhost:8080/playground/](http://localhost:8080/playground/). + +Pick any combination from the sidebar to render it, then use either mode to configure it: + +- **Form** builds the config from controls covering content, buttons, placement, theme + and colours, all 14 display conditions, content recommendations and custom form + fields. Controls only appear where the option actually applies, which keeps you away + from the combinations that throw - `footerText` on a bar, a `position` on a gate, a + `pushDown` on a bar that is not top-positioned. +- **Config** is the generated JavaScript, editable by hand. It is the same shape as the + examples in `docs/docs/examples/src`, so a snippet from a bug report can be pasted in + and run as-is. Switching back to Form regenerates the config from the controls. + +Two things it handles that are easy to get wrong by hand: + +- It sets `window.PathforaCSS` to `/dist/pathfora.min.css` before loading the SDK. The + SDK otherwise injects the CDN stylesheet, and that production CSS wins the cascade + over your local build - so local CSS changes appear to do nothing, with no error. +- It clears pathfora's stored state before each render. `pathfora.clearAll()` only + resets in-memory trackers, so without this a submitted gate stays unlocked and + impression caps stay spent, across renders *and* across reloads. Tick **Keep stored + state** when you are deliberately testing impressions or `hideAfterAction`. + +**Lytics tag** in the toolbar swaps the stubs for the real tag, against the same demo +account the published docs examples use. It is off by default so the playground stays +network-free for anyone just checking a layout. With it on: + +- Audience targeting works - the **Audience** section targets a segment, matched against + the visitor's own memberships, an attribute against a field on their profile, or both. + The segment suggestions are the demo account's Lytics managed audiences, hardcoded in + `playground/fields.js` so that reading them live does not mean storing an API key; any + other slug can be typed in. An exclude subtracts from that match, which is the only + thing exclusions do: `initTargetedWidgets` filters the widgets a target already + matched, so an exclusion on its own matches nothing. The exclude field only appears + once a "show to" segment is set, for that reason. +- "everyone" is the literal `*` segment, and it is not a target at all: + `validateWidgetsObject` hoists a `*` entry into `widgets.common`, which + `initTargetedWidgets` renders before the targeting callback ever runs. So there is + nothing for an exclusion or an attribute to act on, and those controls hide while it + is selected. +- A segment and an attribute together come out as one target entry whose rule ORs + `pathfora.rules.inSegment` with the attribute rule, rather than as two entries. Two + entries each concat `[widget]`, so a visitor matching both would hand + `initializeWidgetArray` the same widget twice and it would throw on the duplicate id. +- Content recommendations call the recommendation API for real, with the `content` + default document as the fallback. The collection field suggests the account's + Lytics managed collections, hardcoded alongside the audiences in + `playground/fields.js`; any other slug can be typed in. Without the tag there is no account to call, so the + default is all you see. Either way `setupWidgetContentUnit` needs both `recommend` and + `content` set, so a default document on its own renders nothing. + +The tag is configured with `publish` and `preview` disabled, which stops the demo +account's own campaigns rendering on top of the widget under test and, as a side effect, +stops the tag installing its own SDK - so the local `dist/` build stays in charge. + +`SiteGate` is deliberately absent: it is deprecated, and its confirm button is dead code +because `construct-widget-actions.js` never assigns it a `widgetAction`. Use `Form` with +layout `gate` instead. + ### Testing Pathfora uses [Jasmine](https://github.com/jasmine/jasmine) as a test framework, and [Karma](https://github.com/karma-runner/karma/) to run tests. Before running tests, or commiting changes be sure to run `gulp build` instead of `gulp local`, or tests may fail due to mismatching URLs. diff --git a/gulpfile.js b/gulpfile.js index 29f5c1a..4c11b18 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -293,7 +293,7 @@ gulp.task( gulp.series( 'build:js', shell.task([ - 'eslint --fix src/rollup/**/*.js gulpfile.js test/**/*.js docs/docs/examples/**/*.js', + 'eslint --fix src/rollup/**/*.js gulpfile.js test/**/*.js docs/docs/examples/**/*.js playground/**/*.js', ]) ) ); diff --git a/playground/fields.js b/playground/fields.js new file mode 100644 index 0000000..9e43751 --- /dev/null +++ b/playground/fields.js @@ -0,0 +1,730 @@ +/** + * Field schema for the playground's form mode. + * + * Every control is described as data rather than markup so the rules about + * where an option applies live in one place. Those rules are not cosmetic - a + * few of them stop the form producing a config that throws: + * + * footerText - construct-widget-layout.js sets widgetFooter.innerHTML with no + * null guard, and bar/button/inline templates have no footer + * pushDown - init-widget.js throws unless the layout is a top-positioned bar + * position - validate-widget-position.js has no case for gate, so it + * dereferences an undefined `choices` + * recommend - validate-recommendation-widget.js throws for any type but + * message, or any layout but modal/slideout/inline + * + * The rest describe options the library accepts but silently ignores, which is + * worth hiding for a different reason: a control that does nothing is worse + * than no control. + */ +(function () { + 'use strict'; + + function layoutIn(list) { + return function (ctx) { + return list.indexOf(ctx.layout) !== -1; + }; + } + + function layoutNotIn(list) { + return function (ctx) { + return list.indexOf(ctx.layout) === -1; + }; + } + + function every(tests) { + return function (ctx) { + return tests.every(function (test) { + return test(ctx); + }); + }; + } + + function typeIs(type) { + return function (ctx) { + return ctx.type === type; + }; + } + + function typeIn(list) { + return function (ctx) { + return list.indexOf(ctx.type) !== -1; + }; + } + + // validateWidgetsObject hoists a `segment: '*'` entry into widgets.common, + // and initializeTargetedWidgets renders common straight away - before the + // targeting callback runs at all. So "everyone" is not a target you can + // subtract from or add a condition to: it just renders. The rest of the + // Audience controls hide rather than quietly do nothing. + function targetsEveryone(ctx) { + return ctx.config.targetSegment === '*'; + } + + // The Lytics managed audiences on the demo account the stage's tag points + // at (aid 6262), hardcoded because reading them live would mean keeping an + // API key somewhere. Free text is still accepted, for a custom audience or + // any other account. + var MANAGED_AUDIENCES = [ + { value: 'all', label: 'All' }, + { value: 'anonymous_profiles', label: 'Anonymous Profiles' }, + { value: 'anonymous_profiles_30_days', label: 'Anonymous Profiles - 30 days' }, + { value: 'anonymous_profiles_60_days', label: 'Anonymous Profiles - 60 days' }, + { value: 'anonymous_profiles_90_days', label: 'Anonymous Profiles - 90 days' }, + { value: 'default_unhealthy_profiles', label: 'Unhealthy Profiles' }, + { value: 'smt_new', label: 'Lytics New' }, + { value: 'smt_active', label: 'Lytics Currently Engaged' }, + { value: 'smt_power', label: 'Lytics Highly Engaged' }, + { value: 'smt_inactive', label: 'Lytics Previously Engaged' }, + { value: 'smt_dormant', label: 'Lytics Disengaged' }, + { value: 'smt_unscored', label: 'Lytics Unscored' }, + { value: 'ly_has_visited_web', label: 'Web Activity: Has Visited Web' }, + { value: 'ly_has_visited_mobile_web', label: 'Web Activity: Has Visited Mobile Web' }, + { value: 'ly_single_page_visitor', label: 'Web Activity: Single Page Visitor' }, + { value: 'ly_multi_session_visitor', label: 'Web Activity: Multi Session Visitor' }, + { value: 'ly_from_email', label: 'Campaign Referral Interactions: Email' }, + { value: 'ly_from_paid', label: 'Campaign Referral Interactions: Paid' }, + { value: 'ly_from_social', label: 'Campaign Referral Interactions: Social' }, + { value: 'ly_uses_desktop', label: 'Browser / OS: Desktop' }, + { value: 'ly_uses_mobile', label: 'Browser / OS: Mobile' }, + { value: 'ly_uses_ios', label: 'Browser / OS: iOS' }, + { value: 'ly_uses_android', label: 'Browser / OS: Android' }, + { value: 'ly_uses_other', label: 'Browser / OS: Other' }, + { value: 'ly_us_visitor', label: 'Location: US Visitors' }, + { value: 'ly_international_visitor', label: 'Location: International Visitors' }, + { value: 'ly_first_time_visitor', label: 'Engagement: First-time Visitors' }, + { value: 'ly_repeat_visitor', label: 'Engagement: Repeat Visitors' }, + { value: 'ly_casual_visitor', label: 'Engagement: Casual Visitors' }, + { value: 'ly_moderately_engaged_visitor', label: 'Engagement: Moderately Engaged Visitors' }, + { value: 'ly_deeply_engaged_users', label: 'Engagement: Deeply Engaged Users' }, + { value: 'ly_known_email', label: 'Email Capture Status: Known Email' }, + { value: 'ly_unknown_email', label: 'Email Capture Status: Unknown Email' }, + { value: 'ly_peruser', label: 'Behavior: Perusers' }, + { value: 'ly_binge_user', label: 'Behavior: Binge Users' }, + { value: 'ly_at_risk', label: 'Behavior: At Risk Users' }, + { value: 'ly_infrequent_user', label: 'Behavior: Infrequent Users' }, + { value: 'ly_moderately_frequent_user', label: 'Behavior: Moderately Frequent Users' }, + { value: 'ly_frequent_user', label: 'Behavior: Frequent Users' }, + { value: 'ly_reporting_last_visit_within_day', label: 'Last Visit Within A Day' }, + { value: 'ly_reporting_last_visit_within_week', label: 'Last Visit Within A Week' }, + { value: 'ly_reporting_last_visit_within_month', label: 'Last Visit Within A Month' }, + { value: 'ly_reporting_last_visit_within_3_months', label: 'Last Visit Within 3 Months' }, + { value: 'ly_reporting_single_page_visitor', label: 'Single Page Visitor' }, + { value: 'ly_reporting_multi_session_visitor', label: 'Multi Session Visitor' }, + { value: 'ly_reporting_has_visited_web', label: 'Has Visited Web' }, + { value: 'ly_reporting_has_visited_mobile_web', label: 'Has Visited Mobile Web' }, + { value: 'ly_reporting_from_facebook', label: 'Facebook' }, + { value: 'ly_reporting_from_google', label: 'Google' }, + { value: 'ly_reporting_from_email', label: 'Email' }, + { value: 'ly_reporting_from_paid', label: 'Paid' }, + { value: 'ly_reporting_from_social', label: 'Social' }, + { value: 'ly_reporting_casual_visitors', label: 'Casual Visitors' }, + { value: 'ly_reporting_deeply_engaged_users', label: 'Deeply Engaged Users' }, + { value: 'ly_reporting_infrequent_users', label: 'Infrequent Users' }, + { value: 'ly_reporting_frequent_users', label: 'Frequent Users' }, + ]; + + // The Lytics managed content collections on the same demo account. Hardcoded + // for the same reason as the audiences above - reading them live would mean + // keeping an API key somewhere. Free text is still accepted. + var MANAGED_COLLECTIONS = [ + { + value: 'default_recommendations', + label: 'Default Recommendation Collection', + }, + { value: 'all_documents', label: 'All Documents' }, + { value: 'all_documents_with_images', label: 'Documents With Images' }, + { + value: 'recently_classified_documents', + label: 'Recently Classified Documents', + }, + ]; + + // Valid positions per layout, from validate-widget-position.js. Gate and + // inline are absent on purpose - gate throws, inline uses positionSelector. + var POSITIONS = { + modal: ['', 'middle-center'], + slideout: [ + 'bottom-left', + 'bottom-right', + 'left', + 'right', + 'top-left', + 'top-right', + ], + bar: [ + 'top-absolute', + 'top-fixed', + 'bottom-fixed', + 'top-center', + 'bottom-center', + ], + button: [ + 'left', + 'right', + 'top-left', + 'top-right', + 'bottom-left', + 'bottom-right', + ], + }; + + // The colour keys set-custom-colors.js understands, only used with theme custom + var COLOR_KEYS = [ + 'background', + 'text', + 'headline', + 'close', + 'actionText', + 'actionBackground', + 'cancelText', + 'cancelBackground', + 'fieldBackground', + 'required', + 'requiredText', + ]; + + function colorFields() { + return COLOR_KEYS.map(function (key) { + return { + key: 'colors.' + key, + label: key, + type: 'color', + applies: function (ctx) { + return ctx.config.theme === 'custom'; + }, + }; + }); + } + + function impressionFields(scope) { + return [ + { + key: 'displayConditions.impressions.' + scope + '.session', + label: scope + ' session', + type: 'number', + }, + { + key: 'displayConditions.impressions.' + scope + '.total', + label: scope + ' total', + type: 'number', + }, + { + key: 'displayConditions.impressions.' + scope + '.buffer', + label: scope + ' buffer (s)', + type: 'number', + }, + { + key: 'displayConditions.impressions.' + scope + '.duration', + label: scope + ' duration (s)', + type: 'number', + }, + ]; + } + + function hideAfterActionFields(action) { + return [ + { + key: 'displayConditions.hideAfterAction.' + action + '.hideCount', + label: action + ' hideCount', + type: 'number', + }, + { + key: 'displayConditions.hideAfterAction.' + action + '.duration', + label: action + ' duration (s)', + type: 'number', + }, + ]; + } + + function formStateFields(state, defaults) { + return [ + { + key: 'formStates.' + state + '.headline', + label: state + ' headline', + type: 'text', + note: 'defaults to "' + defaults.headline + '"', + }, + { + key: 'formStates.' + state + '.msg', + label: state + ' msg', + type: 'textarea', + }, + { + key: 'formStates.' + state + '.delay', + label: state + ' delay (s)', + type: 'number', + note: 'defaults to 3; use 0 to keep it open', + }, + { + key: 'formStates.' + state + '.okShow', + label: state + ' okShow', + type: 'bool', + }, + { + key: 'formStates.' + state + '.okMessage', + label: state + ' okMessage', + type: 'text', + }, + { + key: 'formStates.' + state + '.cancelShow', + label: state + ' cancelShow', + type: 'bool', + }, + { + key: 'formStates.' + state + '.cancelMessage', + label: state + ' cancelMessage', + type: 'text', + }, + ]; + } + + var SECTIONS = [ + { + title: 'Content', + fields: [ + { + key: 'headline', + label: 'headline', + type: 'text', + applies: layoutNotIn(['bar', 'button']), + note: 'bar and button templates have no headline element', + }, + { key: 'msg', label: 'msg', type: 'textarea' }, + { + key: 'image', + label: 'image (url)', + type: 'text', + applies: layoutNotIn(['button']), + }, + { + key: 'footerText', + label: 'footerText', + type: 'text', + applies: layoutIn(['modal', 'slideout', 'gate']), + note: 'throws on bar, button and inline - no footer element', + }, + { key: 'className', label: 'className', type: 'text' }, + { key: 'responsive', label: 'responsive', type: 'bool' }, + ], + }, + + { + title: 'Buttons', + fields: [ + { key: 'okShow', label: 'okShow', type: 'bool' }, + { key: 'okMessage', label: 'okMessage', type: 'text' }, + { + key: 'cancelShow', + label: 'cancelShow', + type: 'bool', + applies: function (ctx) { + if (ctx.type === 'subscription') { + return false; + } + return ['inline', 'button'].indexOf(ctx.layout) === -1; + }, + note: 'no cancel button in subscription, inline or button templates', + }, + { + key: 'cancelMessage', + label: 'cancelMessage', + type: 'text', + applies: function (ctx) { + if (ctx.type === 'subscription') { + return false; + } + return ['inline', 'button'].indexOf(ctx.layout) === -1; + }, + }, + ], + }, + + { + title: 'Placement', + fields: [ + { + key: 'position', + label: 'position', + type: 'select', + optionsFor: function (ctx) { + return POSITIONS[ctx.layout] || []; + }, + applies: function (ctx) { + return Boolean(POSITIONS[ctx.layout]); + }, + note: 'gate has no positions - setting one throws', + }, + { + key: 'origin', + label: 'origin', + type: 'select', + options: ['', 'bottom'], + applies: layoutIn(['slideout']), + note: 'only pf-origin-bottom has styles', + }, + { + key: 'positionSelector', + label: 'positionSelector', + type: 'text', + note: 'required for inline layouts', + }, + { + key: 'pushDown', + label: 'pushDown', + type: 'text', + applies: every([ + layoutIn(['bar']), + function (ctx) { + var pos = ctx.config.position; + return pos === 'top-fixed' || pos === 'top-absolute'; + }, + ]), + note: 'top-positioned bars only - throws otherwise', + }, + ], + }, + + { + title: 'Theme', + fields: [ + { + key: 'theme', + label: 'theme', + type: 'select', + options: ['', 'light', 'dark', 'custom', 'none'], + }, + ].concat(colorFields()), + }, + + { + title: 'Display conditions', + fields: [ + { + key: 'displayConditions.showOnInit', + label: 'showOnInit', + type: 'bool', + }, + { + key: 'displayConditions.showDelay', + label: 'showDelay (s)', + type: 'number', + }, + { + key: 'displayConditions.hideAfter', + label: 'hideAfter (s)', + type: 'number', + }, + { + key: 'displayConditions.showOnExitIntent', + label: 'showOnExitIntent', + type: 'bool', + }, + { + key: 'displayConditions.displayWhenElementVisible', + label: 'displayWhenElementVisible', + type: 'text', + note: 'a selector in the stage page', + }, + { + key: 'displayConditions.scrollPercentageToDisplay', + label: 'scrollPercentageToDisplay', + type: 'number', + }, + { + key: 'displayConditions.pageVisits', + label: 'pageVisits', + type: 'number', + note: 'counts up in PathforaPageView, which Reset clears', + }, + { + key: 'displayConditions.showOnMissingFields', + label: 'showOnMissingFields', + type: 'bool', + }, + { + key: 'displayConditions.date.start_at', + label: 'date start_at', + type: 'datetime', + }, + { + key: 'displayConditions.date.end_at', + label: 'date end_at', + type: 'datetime', + }, + ] + .concat(impressionFields('widget')) + .concat(impressionFields('global')) + .concat(hideAfterActionFields('confirm')) + .concat(hideAfterActionFields('cancel')) + .concat(hideAfterActionFields('closed')) + .concat([ + { + key: 'displayConditions.urlContains', + label: 'urlContains', + type: 'list', + row: [ + { + key: 'match', + label: 'match', + type: 'select', + options: ['simple', 'exact', 'string', 'regex'], + }, + { key: 'value', label: 'value', type: 'text' }, + { key: 'exclude', label: 'exclude', type: 'bool' }, + ], + }, + { + key: 'displayConditions.metaContains', + label: 'metaContains', + type: 'list', + row: [ + { key: 'property', label: 'property', type: 'text' }, + { key: 'name', label: 'name', type: 'text' }, + { key: 'content', label: 'content', type: 'text' }, + ], + }, + ]), + }, + + { + title: 'Content recommendation', + requiresTag: true, + applies: every([ + typeIs('message'), + layoutIn(['modal', 'slideout', 'inline']), + ]), + intro: + 'Set at least one recommend option below - setupWidgetContentUnit only ' + + 'runs when recommend and content are both present, so a default ' + + 'document on its own renders nothing. Without the Lytics tag switched ' + + 'on there is no account to call, so the default document is what ' + + 'renders; with the tag on, the recommendation API is called for real ' + + 'and the default is the fallback.', + fields: [ + { + key: 'recommend.collection', + label: 'collection', + type: 'datalist', + optionsFor: function () { + return MANAGED_COLLECTIONS; + }, + note: "the account's Lytics managed collections, or type any slug", + }, + { + key: 'recommend.rollups', + label: 'rollups', + type: 'csv', + note: 'comma separated', + }, + { key: 'recommend.visited', label: 'visited', type: 'bool' }, + { key: 'recommend.shuffle', label: 'shuffle', type: 'bool' }, + { + key: 'recommend.rank', + label: 'rank', + type: 'select', + options: ['', 'popular', 'recent', 'affinity'], + }, + { key: 'recommend.display.title', label: 'show title', type: 'bool' }, + { key: 'recommend.display.image', label: 'show image', type: 'bool' }, + { + key: 'recommend.display.description', + label: 'show description', + type: 'bool', + }, + { key: 'recommend.display.author', label: 'show author', type: 'bool' }, + { key: 'recommend.display.date', label: 'show date', type: 'bool' }, + { + key: 'recommend.display.descriptionLimit', + label: 'descriptionLimit', + type: 'number', + }, + { key: 'content.0.title', label: 'default title', type: 'text' }, + { key: 'content.0.url', label: 'default url', type: 'text' }, + { + key: 'content.0.description', + label: 'default description', + type: 'textarea', + }, + { key: 'content.0.image', label: 'default image', type: 'text' }, + { key: 'content.0.author', label: 'default author', type: 'text' }, + ], + }, + + { + title: 'Custom form fields', + applies: typeIs('form'), + intro: + 'Adding any field here replaces the default form entirely - ' + + 'formElements takes over from the legacy fields, required and ' + + 'placeholders options.', + fields: [ + { + key: 'formElements', + label: 'formElements', + type: 'list', + row: [ + { + key: 'type', + label: 'type', + type: 'select', + options: [ + 'input', + 'text', + 'email', + 'date', + 'us-postal-code', + 'textarea', + 'select', + 'radio-group', + 'checkbox-group', + ], + }, + { key: 'name', label: 'name', type: 'text' }, + { key: 'label', label: 'label', type: 'text' }, + { key: 'placeholder', label: 'placeholder', type: 'text' }, + { key: 'required', label: 'required', type: 'bool' }, + { + key: 'values', + label: 'values', + type: 'options', + note: 'comma separated, for select and group types', + }, + ], + }, + ], + }, + { + title: 'Form states', + applies: every([ + typeIn(['form', 'subscription']), + layoutIn(['modal', 'slideout', 'gate', 'inline']), + ]), + intro: + 'Shown after a submit. Bar layouts are excluded because ' + + 'constructWidgetLayout never builds the state elements for them, so a ' + + 'bar with formStates just blanks for a few seconds. The error state ' + + 'only ever fires for a confirmAction with waitForAsyncResponse, which ' + + 'needs a callback - use the simulate control to see it.', + fields: [ + { + key: 'simulateSubmit', + label: 'simulate submit outcome', + type: 'select', + options: ['', 'success', 'error'], + note: 'adds a waitForAsyncResponse confirmAction to the config', + }, + ] + .concat( + formStateFields('success', { headline: 'Thank You' }) + ) + .concat(formStateFields('error', { headline: 'Error' })), + }, + { + title: 'Audience', + requiresTag: true, + intro: + 'Targeted widgets go in through the object form of initializeWidgets. ' + + 'A segment is matched against the visitor\'s own memberships, an ' + + 'attribute against a field on their profile; setting both matches ' + + 'either, emitted as one target entry whose rule ORs the two. Both ' + + 'need the Lytics tag, since without it there is no profile to match ' + + 'against. Targeting something the visitor does not match is sometimes ' + + 'the point: the widget then correctly renders nothing.', + fields: [ + { + key: 'targetSegment', + label: 'show to segment', + type: 'datalist', + // reveals the exclude field below. Only rebuilds the form once the + // value settles on change, not on every keystroke. + structural: true, + optionsFor: function () { + return [{ value: '*', label: 'everyone' }].concat( + MANAGED_AUDIENCES + ); + }, + note: + 'the account\'s Lytics managed audiences, or type any slug. ' + + '"everyone" is the literal * segment, which the SDK renders ' + + 'unconditionally rather than targeting - it leaves nothing for ' + + 'the controls below to act on, so they hide.', + }, + { + key: 'excludeSegment', + label: 'but not to segment', + type: 'datalist', + optionsFor: function () { + return MANAGED_AUDIENCES; + }, + // Hidden until there is something to subtract from: + // initTargetedWidgets only removes exclusions from the widgets a + // target already matched, so on its own an exclusion matches nothing + // and the widget never renders. Confirmed against the SDK, not + // assumed. "everyone" is not such a match either - see + // targetsEveryone. + applies: function (ctx) { + return Boolean(ctx.config.targetSegment) && !targetsEveryone(ctx); + }, + note: 'subtracts from the segment above', + }, + { + key: 'attributeField', + label: 'or attribute', + type: 'datalist', + // reveals the operator and value below + structural: true, + applies: function (ctx) { + return !targetsEveryone(ctx); + }, + optionsFor: function (ctx) { + return ctx.fields || []; + }, + note: + 'a field on the Lytics user profile, e.g. visit_country. ' + + 'Suggestions are the fields this visitor actually has.', + }, + { + key: 'attributeOp', + label: 'is', + type: 'select', + options: [ + 'eq', + 'notEq', + 'includes', + 'excludes', + 'gt', + 'gte', + 'lt', + 'lte', + ], + applies: function (ctx) { + return !targetsEveryone(ctx) && Boolean(ctx.config.attributeField); + }, + note: + 'gt, gte, lt and lte parse the attribute as an integer. includes ' + + 'and excludes call .includes on it, which throws if this visitor ' + + 'has no such field - pick one the suggestions offer.', + }, + { + key: 'attributeValue', + label: 'value', + type: 'text', + applies: function (ctx) { + return !targetsEveryone(ctx) && Boolean(ctx.config.attributeField); + }, + // eq and notEq compare with ===, so a numeric or boolean profile + // field can only ever match an unquoted literal + note: 'a bare number or true/false is emitted unquoted, for eq/notEq', + }, + ], + }, + ]; + + window.PlaygroundFields = { + sections: SECTIONS, + positionsFor: function (layout) { + return POSITIONS[layout] || []; + }, + }; +}()); diff --git a/playground/index.html b/playground/index.html new file mode 100644 index 0000000..0e9705d --- /dev/null +++ b/playground/index.html @@ -0,0 +1,108 @@ + + + + + + Pathfora widget playground + + + + +
+ Pathfora playground + Loading… + + + + + + +
+ +
+ + +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+ +
+
+ + + + + +
+ + + + + diff --git a/playground/playground.css b/playground/playground.css new file mode 100644 index 0000000..c87e479 --- /dev/null +++ b/playground/playground.css @@ -0,0 +1,468 @@ +/* + * Playground chrome only. Every selector is namespaced pg- so nothing here can + * reach into the pf-* markup we are trying to look at. + * + * Widgets render inside the stage iframe, so there is no z-index contest with + * them at all - a bottom-left slideout and a top-fixed bar both land where they + * would on a real page instead of underneath this interface. + */ + +:root { + --pg-bg: #12151a; + --pg-panel: #1a1f27; + --pg-line: #2b323d; + --pg-text: #e6e9ef; + --pg-muted: #939cab; + --pg-accent: #4f9cf9; + --pg-error: #ff6b6b; + --pg-sidebar-w: 190px; + --pg-controls-w: 360px; +} + +body { + margin: 0; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; + font-size: 14px; + color: #23282f; + background: #fff; +} + +/* Top bar */ + +.pg-bar { + position: fixed; + top: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + gap: 16px; + height: 48px; + padding: 0 16px; + background: var(--pg-bg); + color: var(--pg-text); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); +} + +.pg-bar-title { + font-weight: 600; + white-space: nowrap; +} + +/* A handle on the seam between the panels and the stage, so it reads as + belonging to the panels rather than to the toolbar. Rides to the window edge + when they collapse. */ +.pg-panels-toggle { + position: fixed; + top: 50%; + left: calc(var(--pg-sidebar-w) + var(--pg-controls-w)); + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 54px; + padding: 0; + border: 1px solid var(--pg-line); + border-left: 0; + border-radius: 0 6px 6px 0; + background: var(--pg-bg); + color: var(--pg-muted); + cursor: pointer; + transform: translateY(-50%); + transition: left 0.15s ease; +} + +.pg-panels-toggle:hover { + color: var(--pg-text); + background: var(--pg-panel); +} + +.pg-collapsed .pg-panels-toggle { + left: 0; +} + +.pg-panels-toggle svg { + transition: transform 0.15s ease; +} + +.pg-collapsed .pg-panels-toggle svg { + transform: rotate(180deg); +} + +.pg-status { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--pg-muted); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; +} + +.pg-bar-actions { + display: flex; + align-items: center; + gap: 10px; + white-space: nowrap; +} + +.pg-check { + display: flex; + align-items: center; + gap: 5px; + color: var(--pg-muted); + font-size: 12px; + cursor: pointer; +} + +.pg-bar button { + padding: 5px 11px; + border: 1px solid var(--pg-line); + border-radius: 4px; + background: var(--pg-panel); + color: var(--pg-text); + font-size: 12px; + cursor: pointer; +} + +.pg-bar button:hover { + border-color: var(--pg-accent); +} + +/* Shell: catalogue | controls | stage */ + +.pg-shell { + display: flex; + height: 100vh; + padding-top: 48px; + box-sizing: border-box; +} + +.pg-sidebar { + flex: 0 0 var(--pg-sidebar-w); + width: var(--pg-sidebar-w); + padding: 16px; + overflow-y: auto; + background: var(--pg-bg); + color: var(--pg-text); + box-sizing: border-box; +} + +.pg-hint { + margin: 0 0 16px; + color: var(--pg-muted); + font-size: 12px; + line-height: 1.5; +} + +.pg-group { + margin-bottom: 18px; +} + +.pg-group-title { + margin: 0 0 6px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--pg-muted); +} + +.pg-group button { + display: block; + width: 100%; + margin-bottom: 3px; + padding: 6px 9px; + border: 1px solid transparent; + border-radius: 4px; + background: var(--pg-panel); + color: var(--pg-text); + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.pg-group button:hover { + border-color: var(--pg-line); +} + +.pg-group button.is-active { + background: var(--pg-accent); + color: #fff; +} + +/* Controls column */ + +.pg-controls { + display: flex; + flex: 0 0 360px; + flex-direction: column; + width: 360px; + border-right: 1px solid #e2e7ec; + background: #f7f9fb; + box-sizing: border-box; +} + +.pg-modes { + display: flex; + flex: 0 0 auto; + gap: 4px; + padding: 10px 14px 0; +} + +.pg-mode { + padding: 6px 14px; + border: 1px solid #d5dae1; + border-bottom: 0; + border-radius: 5px 5px 0 0; + background: #e8edf2; + color: #55606d; + font-size: 13px; + cursor: pointer; +} + +.pg-mode.is-active { + background: #fff; + color: #23282f; + font-weight: 600; +} + +.pg-form { + flex: 1; + min-height: 0; + padding: 14px; + overflow-y: auto; + border-top: 1px solid #d5dae1; + background: #fff; +} + +.pg-form[hidden] { + display: none; +} + +.pg-section { + margin-bottom: 20px; +} + +.pg-section-title { + margin: 0 0 8px; + padding-bottom: 5px; + border-bottom: 1px solid #eceff3; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #7a838f; +} + +.pg-section-intro { + margin: 0 0 10px; + color: #6c7480; + font-size: 11.5px; + line-height: 1.5; +} + +.pg-section-warn { + margin: 0 0 10px; + padding: 7px 9px; + border-left: 3px solid #d9822b; + border-radius: 0 4px 4px 0; + background: #fdf6ec; + color: #8a5a1b; + font-size: 11.5px; + line-height: 1.5; +} + +.pg-field { + display: block; + margin-bottom: 9px; +} + +.pg-field-label { + display: block; + margin-bottom: 3px; + font-size: 12px; + color: #444c56; +} + +.pg-field-note { + display: block; + margin-top: 2px; + color: #8a929d; + font-size: 11px; + line-height: 1.4; +} + +.pg-field input[type='text'], +.pg-field input[type='number'], +.pg-field input[type='datetime-local'], +.pg-field select, +.pg-field textarea { + width: 100%; + padding: 5px 7px; + border: 1px solid #d5dae1; + border-radius: 4px; + background: #fff; + font-family: inherit; + font-size: 12.5px; + box-sizing: border-box; +} + +.pg-field textarea { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + resize: vertical; +} + +.pg-datalist input[type='text'] { + width: 100%; +} + +.pg-color { + display: flex; + gap: 6px; +} + +.pg-color input[type='color'] { + flex: 0 0 32px; + height: 28px; + padding: 0; + border: 1px solid #d5dae1; + border-radius: 4px; + background: #fff; +} + +/* Repeating rows: urlContains, metaContains, formElements */ + +.pg-field-list { + margin-bottom: 14px; +} + +.pg-list-row { + margin-bottom: 8px; + padding: 9px; + border: 1px solid #e2e7ec; + border-radius: 5px; + background: #fafbfc; +} + +.pg-list-remove { + padding: 3px 9px; + border: 1px solid #e2c7c7; + border-radius: 4px; + background: #fff; + color: #a4565a; + font-size: 11.5px; + cursor: pointer; +} + +.pg-list-add { + padding: 5px 11px; + border: 1px dashed #c3cad3; + border-radius: 4px; + background: #fff; + color: #55606d; + font-size: 12px; + cursor: pointer; +} + +/* Editor */ + +.pg-editor-pane { + flex: 0 0 auto; + padding: 10px 14px 12px; + border-top: 1px solid #d5dae1; + background: #f0f3f7; +} + +.pg-editor-head { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 5px; +} + +.pg-editor-head label { + font-size: 12px; + font-weight: 600; +} + +.pg-editor-note { + flex: 1; + min-width: 0; + color: #6c7480; + font-size: 11px; + line-height: 1.35; +} + +#pg-editor { + display: block; + width: 100%; + height: 128px; + padding: 9px; + border: 1px solid #d5dae1; + border-radius: 5px; + background: #fbfcfd; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11.5px; + line-height: 1.5; + box-sizing: border-box; + resize: vertical; +} + +#pg-editor:read-only { + background: #f2f4f7; + color: #4a525d; +} + +#pg-editor:focus { + outline: 2px solid var(--pg-accent); + outline-offset: 1px; +} + +.pg-editor-actions { + margin-top: 8px; +} + +.pg-primary { + padding: 6px 15px; + border: 0; + border-radius: 4px; + background: var(--pg-accent); + color: #fff; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; +} + +.pg-error { + display: block; + margin-top: 8px; + color: var(--pg-error); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11.5px; + line-height: 1.5; +} + +.pg-error[hidden] { + display: none; +} + +/* Collapsed: the stage takes the whole window, for looking at a modal or a + gate at something close to real proportions */ + +.pg-collapsed .pg-sidebar, +.pg-collapsed .pg-controls { + display: none; +} + +/* The frame widgets render into */ + +.pg-stage { + flex: 1; + min-width: 0; + border: 0; + background: #fff; +} diff --git a/playground/playground.js b/playground/playground.js new file mode 100644 index 0000000..06d8b0d --- /dev/null +++ b/playground/playground.js @@ -0,0 +1,1341 @@ +/** + * Pathfora widget playground. + * + * Renders any valid type/layout combination against the local dist/ build. + * Settings can be driven from a form or written by hand as JavaScript. + * + * Widgets live in the stage iframe (playground/stage.html), not in this page, so + * the sidebar and toolbar can never sit on top of one. + */ +(function () { + 'use strict'; + + // Every key prefix pathfora persists under, from src/rollup/globals/config.js. + // Impressions and recommendations also go to sessionStorage, so both stores + // have to be swept - see clearStoredState below. + var STORAGE_PREFIXES = [ + 'PathforaRecommend_', + 'PathforaUnlocked_', + 'PathforaImpressions_', + 'PathforaTotalImpressionsSince_', + 'PathforaConfirm_', + 'PathforaCancel_', + 'PathforaClosed_', + 'PathforaTest_', + 'PathforaPageView', + ]; + + // The valid type x layout matrix, per the switch statements in + // src/rollup/widgets/construct-widget-layout.js. SiteGate is deprecated and + // deliberately absent - its confirm button is dead code anyway, because + // construct-widget-actions.js never assigns it a widgetAction. Form with + // layout "gate" is the working equivalent. + var CATALOGUE = [ + { + ctor: 'Message', + type: 'message', + layouts: ['modal', 'slideout', 'bar', 'gate', 'button', 'inline'], + }, + { + ctor: 'Form', + type: 'form', + layouts: ['modal', 'slideout', 'gate', 'inline'], + }, + { + ctor: 'Subscription', + type: 'subscription', + layouts: ['modal', 'slideout', 'bar', 'gate', 'inline'], + }, + ]; + + // Defaults only where the layout accepts a position. Gate is deliberately + // absent: validateWidgetPosition has no case for it, so setting one + // dereferences an undefined `choices` and throws. + var DEFAULT_POSITION = { + slideout: 'bottom-left', + bar: 'top-absolute', + button: 'top-left', + }; + + // Errors that are a known library quirk rather than something wrong with the + // config in front of you. Matched narrowly so genuine failures still surface. + var KNOWN_ERRORS = [ + { + match: /Cannot add two widgets with the same id/, + // Only a targeted render with the tag on can hit the race, and only the + // form guarantees a single widget with a single id - see watchStage + onlyWhenFormTargeted: true, + note: + 'A targeted widget was rendered while the tag was still starting up, ' + + 'so it initialised twice - add-callback.js registers the callback with ' + + 'jstag.entityReady and also pushes it onto pathfora.callbacks, and the ' + + 'tag drains that queue as it comes up. Harmless, and it does not ' + + 'happen once the tag has settled. Render again.', + }, + ]; + + var INLINE_HOST = '#pg-inline-host'; + var RENDER_DEBOUNCE = 400; + // targeted renders reload the stage frame, so they get a longer leash + var TARGETED_DEBOUNCE = 900; + + // The error form state only fires for a confirmAction with + // waitForAsyncResponse, which needs a real callback - and a function cannot + // survive JSON.stringify. So the config carries a sentinel string and + // snippetFor swaps it for source on the way out. + var CALLBACK_SENTINEL = '__pg_async_'; + var ASYNC_CALLBACK = { + success: 'function (name, payload, done) {\n done(true);\n }', + error: 'function (name, payload, done) {\n done(false);\n }', + }; + + var el = {}; + var state = { ctor: null, type: null, layout: null, config: null }; + var mode = 'form'; + var manualSnippet = null; + var renderTimer = null; + var stagePoll = null; + var stageStarted = false; + var stageWanted = null; + var tagWaits = 0; + var tagMissing = false; + var pendingSnippet = null; + var entityFields = []; + + // Forward references. commit() and the repeating-row controls need to + // rebuild the form, and buildForm is what creates those controls in the + // first place; renderCurrent reloads the stage frame, and the reload has to + // re-arm the readiness poll, which lives down beside stageReady. + var rebuildForm = function () {}; + var reloadStage = function () {}; + + function byId(id) { + return document.getElementById(id); + } + + function keyFor(ctor, layout) { + return ctor + '/' + layout; + } + + function setStatus(text) { + el.status.textContent = text; + } + + function showError(message) { + el.error.textContent = message; + el.error.hidden = false; + } + + function hideError() { + el.error.textContent = ''; + el.error.hidden = true; + } + + function stageWindow() { + return el.stage.contentWindow; + } + + function stageDocument() { + return el.stage.contentDocument; + } + + /* ---------- config paths ---------- */ + + function setPath(obj, path, value) { + var parts = path.split('.'); + var cursor = obj; + var i; + var key; + + for (i = 0; i < parts.length - 1; i++) { + key = parts[i]; + + if (cursor[key] === undefined || cursor[key] === null) { + cursor[key] = String(Number(parts[i + 1])) === parts[i + 1] ? [] : {}; + } + + cursor = cursor[key]; + } + + cursor[parts[parts.length - 1]] = value; + } + + function getPath(obj, path) { + return path.split('.').reduce(function (cursor, key) { + return cursor === undefined || cursor === null ? undefined : cursor[key]; + }, obj); + } + + function clearPath(obj, path) { + var parts = path.split('.'); + // a single-segment path has no parent path to walk - getPath('') would + // return undefined and the delete would silently do nothing + var parent = + parts.length === 1 ? obj : getPath(obj, parts.slice(0, -1).join('.')); + + if (parent && typeof parent === 'object') { + delete parent[parts[parts.length - 1]]; + } + } + + // Drop the empty objects left behind when every field under a branch is unset, + // so the generated config stays readable + function prune(obj) { + Object.keys(obj).forEach(function (key) { + var value = obj[key]; + + if (value && typeof value === 'object' && !Array.isArray(value)) { + prune(value); + + if (Object.keys(value).length === 0) { + delete obj[key]; + } + } + }); + + return obj; + } + + /* ---------- stage ---------- */ + + function isPathforaKey(key) { + return STORAGE_PREFIXES.some(function (prefix) { + return key.indexOf(prefix) === 0; + }); + } + + function sweep(store) { + var doomed = []; + var i; + + for (i = 0; i < store.length; i++) { + if (isPathforaKey(store.key(i))) { + doomed.push(store.key(i)); + } + } + + doomed.forEach(function (key) { + store.removeItem(key); + }); + + return doomed.length; + } + + /** + * pathfora.clearAll() resets in-memory trackers only - it never touches + * storage. Without this a submitted gate stays unlocked and impression caps + * stay spent, across renders and across reloads, which makes repeat testing + * baffling. + */ + function clearStoredState() { + var win = stageWindow(); + var cleared = 0; + + try { + cleared += sweep(win.localStorage); + cleared += sweep(win.sessionStorage); + } catch (storageError) { + showError('Could not clear storage: ' + storageError.message); + } + + stageDocument() + .cookie.split(';') + .forEach(function (entry) { + var name = entry.split('=')[0].trim(); + + if (name && isPathforaKey(name)) { + win.pathfora.utils.deleteCookie(name); + cleared++; + } + }); + + return cleared; + } + + function describeRendered() { + var nodes = stageDocument().querySelectorAll('.pf-widget'); + + if (!nodes.length) { + return 'Nothing rendered'; + } + + return ( + 'Rendered: ' + + Array.prototype.map + .call(nodes, function (node) { + return node.id; + }) + .join(', ') + ); + } + + function clearWidgets() { + try { + stageWindow().pathfora.clearAll(); + } catch (clearError) { + // clearAll on an empty tracker is harmless; never block a render on it + window.console.debug('clearAll: ' + clearError.message); + } + } + + function run(snippet) { + var doc = stageDocument(); + var script; + + hideError(); + clearWidgets(); + + if (!el.preserve.checked) { + clearStoredState(); + } + + if (!stageWindow().pathfora) { + showError('The stage frame has not finished loading the SDK yet.'); + return; + } + + // Injected as a script element rather than eval'd so it runs in the stage's + // own scope. Errors thrown here reach the stage window's error handler, + // wired up in watchStage, rather than this call stack. + script = doc.createElement('script'); + script.textContent = snippet; + doc.body.appendChild(script); + doc.body.removeChild(script); + + if (el.error.hidden) { + setStatus(describeRendered()); + + // A targeted widget goes in through addCallback, which defers to + // jstag.entityReady - so it is not in the DOM yet when the status above + // is read. Look again once the tag has had a chance to answer. + window.setTimeout(function () { + if (el.error.hidden) { + setStatus(describeRendered()); + } + }, 600); + } + } + + /* ---------- snippet ---------- */ + + function context() { + // config is null until an entry is picked, and gating predicates read + // through it - ctx.config.theme and friends must not throw on first paint + return { + type: state.type, + layout: state.layout, + config: state.config || {}, + fields: entityFields, + }; + } + + function applies(item) { + return typeof item.applies !== 'function' || item.applies(context()); + } + + /** + * The working config with every key whose control is not currently + * applicable dropped, so clearing the theme takes its colours with it and + * the emitted config never carries a key the current layout would choke on. + * + * state.config keeps those values - re-revealing a control brings the old + * value back - so everything that reads the config as the form presents it + * has to come through here, not just the widget builder. Targeting reads it + * too: blanking a segment has to take its exclusion with it, or the snippet + * ends up excluding against a target that is no longer there. + */ + function applicableConfig() { + if (!state.config) { + return null; + } + + var config = JSON.parse(JSON.stringify(state.config)); + + window.PlaygroundFields.sections.forEach(function (section) { + var sectionApplies = applies(section); + + section.fields.forEach(function (field) { + if (!sectionApplies || !applies(field)) { + clearPath(config, field.key); + } + }); + }); + + return config; + } + + function buildConfig() { + var config = applicableConfig(); + + if (!config) { + return null; + } + + // validate-recommendation-widget throws unless the default flag is set + if (config.content && config.content[0]) { + config.content[0].default = true; + } + + // Playground-only controls, not part of a widget config + delete config.targetSegment; + delete config.excludeSegment; + delete config.attributeField; + delete config.attributeOp; + delete config.attributeValue; + + // simulateSubmit is a playground-only control - turn it into the async + // confirmAction that drives the success and error states + var simulate = config.simulateSubmit; + delete config.simulateSubmit; + + if (simulate) { + config.confirmAction = { + waitForAsyncResponse: true, + callback: CALLBACK_SENTINEL + simulate, + }; + } + + return prune(config); + } + + /** + * The source form of the operand handed to a pathfora.rules helper. + * + * gt/gte/lt/lte parseInt the profile value, so their operand has to be a + * number. The rest compare the profile value as it stands - eq and notEq + * with ===, includes and excludes through Array/String.includes - so a + * numeric or boolean field never matches a quoted operand, and typing 50 + * into a text input has to come out as 50 rather than "50". + */ + function operandSource(op, raw) { + var text = raw === undefined || raw === null ? '' : String(raw); + + if (['gt', 'gte', 'lt', 'lte'].indexOf(op) !== -1) { + return String(Number(text) || 0); + } + + // only the literals that survive a round trip unchanged, so " 50", "1e3" + // and "0x10" stay strings rather than silently becoming something else + if (text === 'true' || text === 'false' || String(Number(text)) === text) { + return text; + } + + return JSON.stringify(text); + } + + function ruleSource(op, attribute, raw) { + return ( + 'pathfora.rules.' + + op + + '(' + + JSON.stringify(attribute) + + ', ' + + operandSource(op, raw) + + ')' + ); + } + + /** + * Targeted widgets go in through the object form of initializeWidgets rather + * than a plain array. The segment is matched against getUserSegments(), which + * only returns anything real once the Lytics tag is loaded. + */ + function initCall() { + var config = applicableConfig() || {}; + var segment = config.targetSegment; + var excluded = config.excludeSegment; + var attribute = config.attributeField; + var op = config.attributeOp || 'eq'; + var value = config.attributeValue; + var target; + var parts = []; + + if (!segment && !attribute) { + return 'pathfora.initializeWidgets([widget]);'; + } + + if (segment && attribute) { + // Both conditions ride on a single entry. Two entries would each concat + // [widget] onto initializeTargetedWidgets' list, and a visitor matching + // both would hand initializeWidgetArray the same widget twice, which + // throws "Cannot add two widgets with the same id". A segment and a rule + // cannot share an entry either - validateWidgetsObject throws - so the + // segment test moves inside the rule as pathfora.rules.inSegment. + target = + '{\n' + + ' rule: function (profile) {\n' + + ' return (\n' + + ' pathfora.rules.inSegment(' + + JSON.stringify(segment) + + ')(profile) ||\n' + + ' ' + + ruleSource(op, attribute, value) + + '(profile)\n' + + ' );\n' + + ' },\n' + + ' widgets: [widget]\n' + + ' }'; + } else if (segment) { + target = + '{ segment: ' + JSON.stringify(segment) + ', widgets: [widget] }'; + } else { + target = + '{ rule: ' + ruleSource(op, attribute, value) + ', widgets: [widget] }'; + } + + // one target list, not one key per entry + parts.push(' target: [\n ' + target + '\n ]'); + + if (excluded) { + parts.push( + ' exclude: [{ segment: ' + + JSON.stringify(excluded) + + ', widgets: [widget] }]' + ); + } + + return 'pathfora.initializeWidgets({\n' + parts.join(',\n') + '\n});'; + } + + function snippetFor(config) { + var json = JSON.stringify(config, null, 2).replace( + new RegExp('"' + CALLBACK_SENTINEL + '(success|error)"', 'g'), + function (match, outcome) { + return ASYNC_CALLBACK[outcome]; + } + ); + + return ( + 'var widget = new pathfora.' + + state.ctor + + '(' + + json + + ');\n\n' + + initCall() + + '\n' + ); + } + + function currentSnippet() { + if (mode === 'config' && manualSnippet !== null) { + return manualSnippet; + } + + var config = buildConfig(); + + // nothing is selected yet on first paint + return config ? snippetFor(config) : ''; + } + + function syncEditor() { + el.editor.value = currentSnippet(); + } + + function isTargeted() { + var config = applicableConfig(); + + return Boolean(config && (config.targetSegment || config.attributeField)); + } + + /** + * A targeted widget reaches the DOM through addCallback. On this account the + * entity has no `user` key, so the jstag.entityReady branch never calls back + * and the only path that fires is the tag draining pathfora.callbacks - which + * it does once, while starting up. Rendering again into the same page + * therefore queues a callback nobody will ever drain. Reloading the stage + * gives the tag another pass, which is what makes repeat renders work. + */ + function renderCurrent() { + var snippet = currentSnippet(); + + if (isTargeted() && el.tag.checked) { + pendingSnippet = snippet; + reloadStage('/playground/stage.html?tag=1&r=' + Date.now()); + return; + } + + run(snippet); + } + + function scheduleRender() { + window.clearTimeout(renderTimer); + renderTimer = window.setTimeout( + renderCurrent, + isTargeted() && el.tag.checked ? TARGETED_DEBOUNCE : RENDER_DEBOUNCE + ); + } + + /* ---------- form controls ---------- */ + + function toStored(field, raw) { + if (raw === '' || raw === null || raw === undefined) { + return undefined; + } + + switch (field.type) { + case 'number': + return Number(raw); + case 'bool': + return raw === 'true'; + case 'csv': + return raw + .split(',') + .map(function (part) { + return part.trim(); + }) + .filter(Boolean); + case 'options': + return raw + .split(',') + .map(function (part) { + return part.trim(); + }) + .filter(Boolean) + .map(function (part) { + return { label: part, value: part }; + }); + default: + return raw; + } + } + + function toDisplay(field, value) { + if (value === undefined || value === null) { + return ''; + } + + if (field.type === 'bool') { + return String(value); + } + + if (field.type === 'csv') { + return value.join(', '); + } + + if (field.type === 'options') { + return value + .map(function (option) { + return option.value; + }) + .join(', '); + } + + return String(value); + } + + function labelled(field, control) { + var wrap = document.createElement('label'); + var name = document.createElement('span'); + + wrap.className = 'pg-field'; + name.className = 'pg-field-label'; + name.textContent = field.label; + wrap.appendChild(name); + wrap.appendChild(control); + + if (field.note) { + var note = document.createElement('span'); + note.className = 'pg-field-note'; + note.textContent = field.note; + wrap.appendChild(note); + } + + return wrap; + } + + function makeControl(field, value, onChange) { + var control; + var options; + + if (field.type === 'bool' || field.type === 'select') { + control = document.createElement('select'); + options = + field.type === 'bool' + ? ['', 'true', 'false'] + : typeof field.optionsFor === 'function' + ? field.optionsFor(context()) + : field.options || []; + + options.forEach(function (option) { + var node = document.createElement('option'); + node.value = option; + node.textContent = option === '' ? '—' : option; + control.appendChild(node); + }); + + control.value = value; + control.addEventListener('change', function () { + onChange(control.value, true); + }); + } else if (field.type === 'textarea') { + control = document.createElement('textarea'); + control.rows = 2; + control.value = value; + control.addEventListener('input', function () { + onChange(control.value, false); + }); + } else if (field.type === 'color') { + control = document.createElement('span'); + control.className = 'pg-color'; + + var hex = document.createElement('input'); + hex.type = 'text'; + hex.placeholder = '#rrggbb'; + hex.value = value; + + var swatch = document.createElement('input'); + swatch.type = 'color'; + swatch.value = /^#[0-9a-f]{6}$/i.test(value) ? value : '#ffffff'; + + hex.addEventListener('input', function () { + onChange(hex.value, false); + }); + swatch.addEventListener('input', function () { + hex.value = swatch.value; + onChange(swatch.value, false); + }); + + control.appendChild(hex); + control.appendChild(swatch); + } else if (field.type === 'datalist') { + // free text, because you may want to target a segment this visitor is not + // in, with the visitor's own segments offered as suggestions + control = document.createElement('span'); + control.className = 'pg-datalist'; + + var text = document.createElement('input'); + var list = document.createElement('datalist'); + + list.id = 'pg-list-' + field.key.replace(/[^a-z0-9]+/gi, '-'); + text.type = 'text'; + text.value = value; + text.setAttribute('list', list.id); + + (typeof field.optionsFor === 'function' + ? field.optionsFor(context()) + : field.options || [] + ).forEach(function (option) { + var node = document.createElement('option'); + + // suggestions may be plain slugs or { value, label } pairs + if (option && typeof option === 'object') { + node.value = option.value; + node.label = option.label; + node.textContent = option.label; + } else { + node.value = option; + } + + list.appendChild(node); + }); + + text.addEventListener('input', function () { + onChange(text.value, false); + }); + + if (field.structural) { + // rebuilding on every keystroke would take the focus out of the field + // mid-word, so a gating text field settles on change instead + text.addEventListener('change', function () { + onChange(text.value, true); + }); + } + + control.appendChild(text); + control.appendChild(list); + } else { + control = document.createElement('input'); + control.type = + field.type === 'number' + ? 'number' + : field.type === 'datetime' + ? 'datetime-local' + : 'text'; + control.value = value; + control.addEventListener('input', function () { + onChange(control.value, false); + }); + + if (field.structural) { + control.addEventListener('change', function () { + onChange(control.value, true); + }); + } + } + + return control; + } + + function commit(path, field, raw, structural) { + if (!state.config) { + return; + } + + var value = toStored(field, raw); + + if (value === undefined) { + clearPath(state.config, path); + } else { + setPath(state.config, path, value); + } + + syncEditor(); + + if (structural) { + // gating depends on config values - theme custom reveals the colour + // fields, a top-positioned bar reveals pushDown + rebuildForm(); + renderCurrent(); + } else { + scheduleRender(); + } + } + + function listControl(field) { + var wrap = document.createElement('div'); + var rows = getPath(state.config, field.key) || []; + var add = document.createElement('button'); + + wrap.className = 'pg-list'; + + rows.forEach(function (row, index) { + var rowEl = document.createElement('div'); + var remove = document.createElement('button'); + + rowEl.className = 'pg-list-row'; + + field.row.forEach(function (sub) { + var path = field.key + '.' + index + '.' + sub.key; + var control = makeControl( + sub, + toDisplay(sub, row[sub.key]), + function (raw) { + commit(path, sub, raw, false); + }, + ); + + control.setAttribute('data-pg-key', path); + rowEl.appendChild(labelled(sub, control)); + }); + + remove.type = 'button'; + remove.className = 'pg-list-remove'; + remove.textContent = 'Remove'; + remove.addEventListener('click', function () { + rows.splice(index, 1); + + if (!rows.length) { + clearPath(state.config, field.key); + } + + syncEditor(); + rebuildForm(); + renderCurrent(); + }); + + rowEl.appendChild(remove); + wrap.appendChild(rowEl); + }); + + add.type = 'button'; + add.className = 'pg-list-add'; + add.textContent = 'Add ' + field.label; + add.addEventListener('click', function () { + var next = getPath(state.config, field.key) || []; + var blank = {}; + + field.row.forEach(function (sub) { + if (sub.type === 'select' && sub.options && sub.options.length) { + blank[sub.key] = sub.options[0]; + } + }); + + next.push(blank); + setPath(state.config, field.key, next); + syncEditor(); + rebuildForm(); + }); + + wrap.appendChild(add); + + return wrap; + } + + /** Where the caret and the scroll position were, so a rebuild can put them back */ + function formFocus() { + var active = document.activeElement; + var holder = active && active.closest ? active.closest('[data-pg-key]') : null; + var caret = null; + + if (active && typeof active.selectionStart === 'number') { + caret = active.selectionStart; + } + + return { + scroll: el.form.scrollTop, + key: holder ? holder.getAttribute('data-pg-key') : null, + caret: caret, + }; + } + + function restoreFormFocus(saved) { + el.form.scrollTop = saved.scroll; + + if (!saved.key) { + return; + } + + var holder = el.form.querySelector('[data-pg-key="' + saved.key + '"]'); + + if (!holder) { + return; + } + + var input = holder.matches('input, select, textarea') + ? holder + : holder.querySelector('input, select, textarea'); + + if (!input) { + return; + } + + input.focus(); + + if (saved.caret !== null && typeof input.setSelectionRange === 'function') { + input.setSelectionRange(saved.caret, saved.caret); + } + } + + /** + * Keys on the visitor's Lytics profile, for the attribute suggestions. + * + * addCallback hands a rule `e.data.user`, so that - not the top level of the + * entity, which is just { user, errors } - is what an attribute rule reads. + * The legacy lio shape puts the same fields at the top of data. + */ + function readEntityFields() { + var win = stageWindow(); + + try { + if (win.jstag && typeof win.jstag.getEntity === 'function') { + var data = (win.jstag.getEntity() || {}).data; + + if (data) { + return Object.keys(data.user || data); + } + } + } catch (entityError) { + window.console.debug('getEntity: ' + entityError.message); + } + + return []; + } + + function buildForm() { + var form = el.form; + var saved = formFocus(); + + // read once per rebuild rather than per field, since context() is called + // for every applies() check + entityFields = readEntityFields(); + + form.innerHTML = ''; + + window.PlaygroundFields.sections.forEach(function (section) { + if (!applies(section)) { + return; + } + + var fields = section.fields.filter(applies); + + if (!fields.length) { + return; + } + + var group = document.createElement('section'); + var heading = document.createElement('h2'); + + group.className = 'pg-section'; + heading.className = 'pg-section-title'; + heading.textContent = section.title; + group.appendChild(heading); + + if (section.requiresTag && !el.tag.checked) { + var warn = document.createElement('p'); + warn.className = 'pg-section-warn'; + warn.textContent = + 'Needs the Lytics tag. Switch it on in the toolbar - without it ' + + 'there is no account to call and no profile to match against.'; + group.appendChild(warn); + } + + if (section.intro) { + var intro = document.createElement('p'); + intro.className = 'pg-section-intro'; + intro.textContent = section.intro; + group.appendChild(intro); + } + + fields.forEach(function (field) { + if (field.type === 'list') { + var listWrap = document.createElement('div'); + var listLabel = document.createElement('span'); + + listWrap.className = 'pg-field pg-field-list'; + listLabel.className = 'pg-field-label'; + listLabel.textContent = field.label; + listWrap.appendChild(listLabel); + listWrap.appendChild(listControl(field)); + group.appendChild(listWrap); + return; + } + + var control = makeControl( + field, + toDisplay(field, getPath(state.config, field.key)), + function (raw, isStructural) { + // makeControl says whether this particular event is structural: + // false while typing, true once the value settles. Ignoring it + // rebuilt the form on every keystroke. + commit(field.key, field, raw, Boolean(isStructural)); + }, + ); + + control.setAttribute('data-pg-key', field.key); + group.appendChild(labelled(field, control)); + }); + + form.appendChild(group); + }); + + restoreFormFocus(saved); + } + + rebuildForm = buildForm; + + /* ---------- catalogue and modes ---------- */ + + function baseConfig(ctor, layout) { + var config = { + id: 'playground-' + ctor.toLowerCase() + '-' + layout, + layout: layout, + headline: ctor + ' / ' + layout, + msg: 'This is a ' + layout + ' rendered from the playground.', + }; + + if (DEFAULT_POSITION[layout]) { + config.position = DEFAULT_POSITION[layout]; + } + + if (layout === 'inline') { + config.positionSelector = INLINE_HOST; + } + + return config; + } + + function setMode(next) { + mode = next; + + el.modeForm.classList.toggle('is-active', mode === 'form'); + el.modeConfig.classList.toggle('is-active', mode === 'config'); + el.form.hidden = mode !== 'form'; + el.editor.readOnly = mode === 'form'; + el.editorHint.textContent = + mode === 'form' + ? 'Generated from the form — switch to Config to edit by hand' + : 'Runs as JavaScript, same shape as the examples in docs/docs/examples/src'; + + if (mode === 'form') { + manualSnippet = null; + } + + syncEditor(); + } + + function selectEntry(entry, layout) { + Array.prototype.forEach.call( + el.catalogue.querySelectorAll('button'), + function (button) { + button.classList.toggle( + 'is-active', + button.dataset.key === keyFor(entry.ctor, layout), + ); + }, + ); + + state.ctor = entry.ctor; + state.type = entry.type; + state.layout = layout; + state.config = baseConfig(entry.ctor, layout); + manualSnippet = null; + + buildForm(); + syncEditor(); + renderCurrent(); + } + + function buildCatalogue() { + CATALOGUE.forEach(function (entry) { + var section = document.createElement('div'); + var heading = document.createElement('h2'); + + section.className = 'pg-group'; + heading.className = 'pg-group-title'; + heading.textContent = entry.ctor; + section.appendChild(heading); + + entry.layouts.forEach(function (layout) { + var button = document.createElement('button'); + + button.type = 'button'; + button.textContent = layout; + button.dataset.key = keyFor(entry.ctor, layout); + button.addEventListener('click', function () { + selectEntry(entry, layout); + }); + + section.appendChild(button); + }); + + el.catalogue.appendChild(section); + }); + } + + /** + * Surface anything the stage throws. Widgets can fail well after the click + * that created them - a showDelay widget throws from inside a timeout - and a + * config with a syntax error never reaches a try/catch here at all. + */ + function watchStage() { + stageWindow().addEventListener('error', function (event) { + var known = KNOWN_ERRORS.filter(function (entry) { + if (!entry.match.test(event.message)) { + return false; + } + + // A snippet you wrote yourself can genuinely declare the same id + // twice, and that has to keep its own message rather than be + // explained away as a startup race the form alone is prone to. + return ( + !entry.onlyWhenFormTargeted || + (mode === 'form' && el.tag.checked && isTargeted()) + ); + })[0]; + + if (known) { + showError(known.note); + setStatus(describeRendered()); + return; + } + + showError(event.message); + setStatus('Render failed'); + }); + } + + /** + * Setting src does not swap documents there and then, and the outgoing one + * still has a pathfora of its own - so the poll can tick in that gap and + * take a page that is about to be discarded for the new one, rendering the + * widget into nothing. Only the document that was actually asked for counts. + */ + function isRequestedStage(win) { + if (!stageWanted) { + return true; + } + + // the stage is always same-origin, so reading across is safe - mid-swap it + // reads as about:blank, which is exactly the case being excluded + return ( + win.location.href === new URL(stageWanted, window.location.href).href + ); + } + + // run() clears the banner before every render, so the tag warning has to go + // back up after the snippet has gone in rather than before it + function warnIfTagMissing() { + if (tagMissing) { + showError('The Lytics tag did not load - targeting will not match.'); + setStatus('Rendered without the tag'); + } + } + + /** + * Runs once, when the stage is genuinely usable. + * + * readyState is not a trustworthy signal here: a freshly created iframe + * reports 'complete' for its own initial blank document, well before + * stage.html and the SDK inside it have loaded. Rendering against that gives + * "pathfora is not defined". The SDK being present is the real signal. + */ + function stageReady() { + var win = el.stage.contentWindow; + + if (stageStarted || !win || !win.pathfora || !isRequestedStage(win)) { + return; + } + + // With the real tag the account id comes from jstag.config.cid, which only + // exists once the tag script has loaded - rendering a targeted widget + // before then throws "Could not get account id". Bounded, so a blocked or + // offline request cannot leave the playground empty forever. + // cid only exists once the tag script has loaded. It is not a perfect + // signal - the tag is still wiring up its own pathfora integration for a + // moment afterwards - but jstag.entityReady is not stubbed by the loader + // snippet, so it cannot be called any earlier than this to get a better one. + if ( + win.pgTagRequested && + !(win.jstag && win.jstag.config && win.jstag.config.cid) + ) { + tagWaits++; + + if (tagWaits < 60) { + return; + } + + tagMissing = true; + } + + stageStarted = true; + watchStage(); + + // The profile arrives after the tag script does, so the attribute + // suggestions are empty at this point. entityReady is a real function now + // that the tag has loaded - it is not stubbed by the loader snippet - so + // rebuild once the profile lands. buildForm keeps scroll and focus, so this + // is not disruptive. + if (win.pgTagRequested && typeof win.jstag.entityReady === 'function') { + win.jstag.entityReady(function () { + if (state.config) { + buildForm(); + } + }); + } + + if (pendingSnippet) { + var queued = pendingSnippet; + pendingSnippet = null; + run(queued); + warnIfTagMissing(); + return; + } + + if (!state.config) { + selectEntry(CATALOGUE[0], CATALOGUE[0].layouts[0]); + warnIfTagMissing(); + return; + } + + // the stage was reloaded under an existing selection - keep it, and render + // straight into the fresh frame rather than asking for another reload + buildForm(); + run(currentSnippet()); + warnIfTagMissing(); + } + + /** + * The load event on its own is a single shot, and stageReady bails out of + * that shot whenever the tag has not answered yet - so a frame reloaded with + * the tag on would sit at "Loading the Lytics tag…" forever with nothing + * left to ask again. Every reload re-arms the poll, not just the first one, + * which is also what keeps stageReady's bounded wait meaningful. + */ + function armStagePoll() { + window.clearInterval(stagePoll); + + stagePoll = window.setInterval(function () { + stageReady(); + + if (stageStarted) { + window.clearInterval(stagePoll); + stagePoll = null; + } + }, 50); + } + + reloadStage = function (src) { + stageStarted = false; + tagWaits = 0; + tagMissing = false; + stageWanted = src; + el.stage.src = src; + armStagePoll(); + }; + + function init() { + el.catalogue = byId('pg-catalogue'); + el.form = byId('pg-form'); + el.editor = byId('pg-editor'); + el.editorHint = byId('pg-editor-hint'); + el.error = byId('pg-error'); + el.status = byId('pg-status'); + el.preserve = byId('pg-preserve'); + el.stage = byId('pg-stage'); + el.tag = byId('pg-tag'); + el.modeForm = byId('pg-mode-form'); + el.modeConfig = byId('pg-mode-config'); + + hideError(); + buildCatalogue(); + + el.modeForm.addEventListener('click', function () { + setMode('form'); + }); + + el.modeConfig.addEventListener('click', function () { + manualSnippet = el.editor.value; + setMode('config'); + }); + + el.editor.addEventListener('input', function () { + if (mode === 'config') { + manualSnippet = el.editor.value; + } + }); + + byId('pg-render').addEventListener('click', renderCurrent); + + el.tag.addEventListener('change', function () { + var on = el.tag.checked; + + // the tag has to be installed before the SDK runs, so the frame is + // reloaded rather than having the tag injected into a live page + setStatus(on ? 'Loading the Lytics tag…' : 'Reloading without the tag…'); + reloadStage('/playground/stage.html' + (on ? '?tag=1' : '')); + }); + + byId('pg-panels').addEventListener('click', function () { + var panels = byId('pg-panels'); + var collapsed = document.body.classList.toggle('pg-collapsed'); + + var label = collapsed ? 'Show panels' : 'Hide panels'; + + // the button holds an icon, so the name has to come from the attributes + panels.setAttribute('aria-label', label); + panels.setAttribute('title', label); + panels.setAttribute('aria-expanded', String(!collapsed)); + }); + + byId('pg-clear').addEventListener('click', function () { + clearWidgets(); + setStatus('Cleared'); + }); + + byId('pg-reset').addEventListener('click', function () { + clearWidgets(); + setStatus('Cleared ' + clearStoredState() + ' stored key(s)'); + }); + + setMode('form'); + + // Handle both orders: the iframe may load after this script runs, or it may + // have loaded already and never fire another load event. + stageWanted = el.stage.getAttribute('src'); + el.stage.addEventListener('load', stageReady); + + armStagePoll(); + } + + init(); +}()); diff --git a/playground/stage.html b/playground/stage.html new file mode 100644 index 0000000..51bb600 --- /dev/null +++ b/playground/stage.html @@ -0,0 +1,186 @@ + + + + + + Playground stage + + + + + + + + + + + + +

Example page

+

+ This stands in for a customer's page. Widgets render over it exactly as + they would in the wild — nothing from the playground's own interface + overlaps this frame. +

+

+ The dashed box is the mount point for + inline layouts. The filler below gives the + page enough height to exercise scroll-based display conditions. +

+ +
#pg-inline-host
+ +
Scroll area
+ + + + + diff --git a/test.html b/test.html deleted file mode 100644 index d1b0ca7..0000000 --- a/test.html +++ /dev/null @@ -1,94 +0,0 @@ - - - - Action widget - - -

Action widget example

- - - - - -