From d23399133430b8f1f950146cd5cf287a4a19dc7c Mon Sep 17 00:00:00 2001 From: Neil Kakkar Date: Fri, 15 Sep 2023 16:11:28 +0100 Subject: [PATCH 1/8] feat(surveys): Make surveys site app native to posthog-js --- rollup.config.js | 12 + src/__tests__/surveys.js | 3 +- src/decide.ts | 17 ++ src/extensions/surveys.ts | 561 +++++++++++++++++++++++++++++++++++ src/loader-surveys.ts | 7 + src/posthog-core.ts | 4 +- src/posthog-surveys-types.ts | 90 ++++++ src/posthog-surveys.ts | 56 +--- src/types.ts | 1 + 9 files changed, 694 insertions(+), 57 deletions(-) create mode 100644 src/extensions/surveys.ts create mode 100644 src/loader-surveys.ts create mode 100644 src/posthog-surveys-types.ts diff --git a/rollup.config.js b/rollup.config.js index 870a8c22f..a05281819 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -46,6 +46,18 @@ export default [ ], plugins: [...plugins], }, + { + input: 'src/loader-surveys.ts', + output: [ + { + file: 'dist/surveys.js', + sourcemap: true, + format: 'iife', + name: 'posthog', + }, + ], + plugins: [...plugins], + }, { input: 'src/loader-globals.ts', output: [ diff --git a/src/__tests__/surveys.js b/src/__tests__/surveys.js index 47da6e74e..20024e24b 100644 --- a/src/__tests__/surveys.js +++ b/src/__tests__/surveys.js @@ -1,4 +1,5 @@ -import { PostHogSurveys, SurveyQuestionType, SurveyType } from '../posthog-surveys' +import { PostHogSurveys } from '../posthog-surveys' +import { SurveyType, SurveyQuestionType } from '../posthog-surveys-types' import { PostHogPersistence } from '../posthog-persistence' describe('surveys', () => { diff --git a/src/decide.ts b/src/decide.ts index bf92c8fe1..8f376ab04 100644 --- a/src/decide.ts +++ b/src/decide.ts @@ -74,6 +74,23 @@ export class Decide { this.instance['compression'] = compression } + // Check if recorder.js is already loaded + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const surveysGenerator = window?.generateSurveys + + if (response['surveys'] && !surveysGenerator) { + loadScript(this.instance.get_config('api_host') + `/static/surveys.js`, (err) => { + if (err) { + return console.error(`Could not load surveys script`, err) + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + window.generateSurveys(this.instance) + }) + } + if (response['siteApps']) { if (this.instance.get_config('opt_in_site_apps')) { const apiHost = this.instance.get_config('api_host') diff --git a/src/extensions/surveys.ts b/src/extensions/surveys.ts new file mode 100644 index 000000000..af5ac8c7b --- /dev/null +++ b/src/extensions/surveys.ts @@ -0,0 +1,561 @@ +import { PostHog } from 'posthog-core' +import { + BasicSurveyQuestion, + LinkSurveyQuestion, + MultipleSurveyQuestion, + RatingSurveyQuestion, + Survey, + SurveyAppearance, +} from 'posthog-surveys-types' + +const posthogLogo = + '' +const satisfiedEmoji = + '' +const neutralEmoji = + '' +const dissatisfiedEmoji = + '' +const veryDissatisfiedEmoji = + '' +const verySatisfiedEmoji = + '' +const cancelSVG = + '' + +const style = (id: string, appearance: SurveyAppearance | null) => ` + .survey-${id}-form { + position: fixed; + bottom: 3vh; + right: 20px; + color: black; + font-weight: normal; + font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", "Roboto", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + text-align: left; + max-width: ${parseInt(appearance?.maxWidth || '320')}px; + z-index: ${parseInt(appearance?.zIndex || '99999')}; + } + .form-submit[disabled] { + opacity: 0.6; + filter: grayscale(100%); + cursor: not-allowed; + } + .survey-${id}-form { + flex-direction: column; + background: ${appearance?.backgroundColor || 'white'}; + border: 1px solid #f0f0f0; + border-radius: 8px; + padding-top: 5px; + box-shadow: -6px 0 16px -8px rgb(0 0 0 / 8%), -9px 0 28px 0 rgb(0 0 0 / 5%), -12px 0 48px 16px rgb(0 0 0 / 3%); + } + .survey-${id}-form textarea { + color: #2d2d2d; + font-size: 14px; + font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", "Roboto", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + background: white; + color: black; + outline: none; + padding-left: 10px; + padding-right: 10px; + padding-top: 10px; + border-radius: 6px; + margin: 0.5rem; + } + .form-submit { + box-sizing: border-box; + margin: 0; + font-family: inherit; + overflow: visible; + text-transform: none; + line-height: 1.5715; + position: relative; + display: inline-block; + font-weight: 400; + white-space: nowrap; + text-align: center; + border: 1px solid transparent; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1); + user-select: none; + touch-action: manipulation; + height: 32px; + padding: 4px 15px; + font-size: 14px; + border-radius: 4px; + outline: 0; + background: ${appearance?.submitButtonColor || '#2C2C2C'} !important; + color: #E5E7E0; + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.12); + box-shadow: 0 2px 0 rgba(0, 0, 0, 0.045); + } + .form-submit:hover { + filter: brightness(1.2); + } + .form-cancel { + float: right; + border: none; + background: ${appearance?.backgroundColor || 'white'}; + cursor: pointer; + } + .bolded { font-weight: 600; } + .bottom-section { + padding-bottom: .5rem; + } + .buttons { + display: flex; + justify-content: center; + } + .footer-branding { + color: #6a6b69; + font-size: 10.5px; + padding-top: .5rem; + text-align: center; + } + .survey-${id}-box { + padding: .5rem 1rem; + display: flex; + flex-direction: column; + } + .survey-question { + padding-top: 4px; + padding-bottom: 4px; + font-weight: 500; + color: ${appearance?.textColor || 'black'}; + } + .question-textarea-wrapper { + display: flex; + flex-direction: column; + padding-bottom: 4px; + } + .description { + font-size: 14px; + margin-top: .75rem; + margin-bottom: .75rem; + color: ${appearance?.descriptionTextColor || '#4b4b52'}; + } + .ratings-number { + background-color: ${appearance?.ratingButtonColor || '#e0e2e8'}; + font-size: 14px; + border-radius: 6px; + border: 1px solid ${appearance?.ratingButtonColor || '#e0e2e8'}; + padding: 8px; + } + .ratings-number:hover { + cursor: pointer; + filter: brightness(1.1); + } + .rating-options { + margin-top: .5rem; + } + .rating-options-buttons { + display: flex; + justify-content: space-evenly; + } + .max-numbers { + min-width: 280px; + } + .rating-options-emoji { + display: flex; + justify-content: space-evenly; + } + .ratings-emoji { + font-size: 16px; + background-color: transparent; + border: none; + } + .ratings-emoji:hover { + cursor: pointer; + } + .emoji-svg { + fill: ${appearance?.ratingButtonColor || 'black'}; + } + .emoji-svg:hover { + fill: ${appearance?.ratingButtonHoverColor || 'coral'}; + } + .rating-text { + display: flex; + flex-direction: row; + font-size: 12px; + justify-content: space-between; + margin-top: .5rem; + margin-bottom: .5rem; + color: #4b4b52; + } + .rating-section { + margin-bottom: .5rem; + } + .multiple-choice-options { + margin-bottom: .5rem; + margin-top: .5rem; + font-size: 14px; + } + .multiple-choice-options .choice-option { + display: flex; + align-items: center; + gap: 4px; + background: #00000003; + font-size: 14px; + padding: 10px 20px 10px 15px; + border: 1px solid #0000000d; + border-radius: 4px; + cursor: pointer; + margin-bottom: 6px; + } + .multiple-choice-options .choice-option:hover { + background: #0000000a; + } + .multiple-choice-options input { + cursor: pointer; + } + .multiple-choice-options label { + width: 100%; + cursor: pointer; + } + .thank-you-message { + position: fixed; + bottom: 8vh; + right: 20px; + border-radius: 8px; + z-index: ${parseInt(appearance?.zIndex || '99999')}; + box-shadow: -6px 0 16px -8px rgb(0 0 0 / 8%), -9px 0 28px 0 rgb(0 0 0 / 5%), -12px 0 48px 16px rgb(0 0 0 / 3%); + font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", "Roboto", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + } + .thank-you-message-container { + background: ${appearance?.backgroundColor || 'white'}; + border: 1px solid #f0f0f0; + border-radius: 8px; + padding: 12px 18px; + text-align: center; + max-width: 320px; + min-width: 150px; + } + .thank-you-message { + color: ${appearance?.textColor || 'black'}; + } + .thank-you-message-body { + padding-bottom: 8px; + font-size: 14px; + color: ${appearance?.descriptionTextColor || '#4b4b52'}; + } + ` + +export function generateSurveys(posthog: PostHog) { + const createShadow = (styleSheet: string, surveyId: string) => { + const div = document.createElement('div') + div.className = `PostHogSurvey${surveyId}` + const shadow = div.attachShadow({ mode: 'open' }) + if (styleSheet) { + const styleElement = Object.assign(document.createElement('style'), { + innerText: styleSheet, + }) + shadow.appendChild(styleElement) + } + document.body.appendChild(div) + return shadow + } + + const closeSurveyPopup = (surveyId: string, surveyPopup: HTMLFormElement) => { + Object.assign(surveyPopup.style, { display: 'none' }) + localStorage.setItem(`seenSurvey_${surveyId}`, 'true') + window.setTimeout(() => { + window.dispatchEvent(new Event('PHSurveyClosed')) + }, 2000) + surveyPopup.reset() + } + + const createThankYouMessage = (survey: Survey) => { + const thankYouHTML = ` +
+

${survey.appearance?.thankYouMessageHeader || 'Thank you!'}

+
${survey.appearance?.thankYouMessageDescription || ''}
+ +
+ ` + const thankYouElement = Object.assign(document.createElement('div'), { + className: `thank-you-message`, + innerHTML: thankYouHTML, + }) + return thankYouElement + } + + const createOpenTextPopup = (survey: Survey) => { + const question = survey.questions[0] as BasicSurveyQuestion | LinkSurveyQuestion + const surveyQuestionType = question.type + const surveyDescription = question.description + const questionText = question.question + const form = ` +
+
+ +
+
+
${questionText}
+ ${surveyDescription ? `${surveyDescription}` : ''} + ${ + surveyQuestionType === 'open' + ? `` + : '' + } +
+
+
+ +
+ +
+
+ ` + const formElement = Object.assign(document.createElement('form'), { + className: `survey-${survey.id}-form`, + innerHTML: form, + onsubmit: function (e: any) { + e.preventDefault() + const surveyQuestionType = question.type + posthog.capture('survey sent', { + $survey_name: survey.name, + $survey_id: survey.id, + $survey_question: survey.questions[0], + $survey_response: surveyQuestionType === 'open' ? e.target.survey.value : 'link clicked', + sessionRecordingUrl: posthog.get_session_replay_url(), + }) + if (surveyQuestionType === 'link') { + window.open(question.link || undefined) + } + window.setTimeout(() => { + window.dispatchEvent(new Event('PHSurveySent')) + }, 200) + closeSurveyPopup(survey.id, formElement) + }, + }) + + return formElement + } + + const addCancelListeners = (surveyPopup: HTMLFormElement, surveyId: string, surveyEventName: string) => { + const cancelButton = surveyPopup.getElementsByClassName('form-cancel')?.[0] as HTMLButtonElement + + cancelButton.addEventListener('click', (e) => { + e.preventDefault() + Object.assign(surveyPopup.style, { display: 'none' }) + localStorage.setItem(`seenSurvey_${surveyId}`, 'true') + posthog.capture('survey dismissed', { + $survey_name: surveyEventName, + $survey_id: surveyId, + sessionRecordingUrl: posthog.get_session_replay_url(), + }) + window.dispatchEvent(new Event('PHSurveyClosed')) + }) + } + + const createRatingsPopup = (survey: Survey) => { + const question = survey.questions[0] as RatingSurveyQuestion + const scale = question.scale + const displayType = question.display + const ratingOptionsElement = document.createElement('div') + if (displayType === 'number') { + ratingOptionsElement.className = `rating-options-buttons ${scale === 10 ? 'max-numbers' : ''}` + for (let i = 1; i <= scale; i++) { + const buttonElement = document.createElement('button') + buttonElement.className = `ratings-number rating_${i}` + buttonElement.type = 'submit' + buttonElement.value = `${i}` + buttonElement.innerHTML = `${i}` + ratingOptionsElement.append(buttonElement) + } + } else if (displayType === 'emoji') { + ratingOptionsElement.className = 'rating-options-emoji' + const threeEmojis = [dissatisfiedEmoji, neutralEmoji, satisfiedEmoji] + const fiveEmojis = [ + veryDissatisfiedEmoji, + dissatisfiedEmoji, + neutralEmoji, + satisfiedEmoji, + verySatisfiedEmoji, + ] + for (let i = 1; i <= scale; i++) { + const emojiElement = document.createElement('button') + emojiElement.className = `ratings-emoji rating_${i}` + emojiElement.type = 'submit' + emojiElement.value = `${i}` + emojiElement.innerHTML = scale === 3 ? threeEmojis[i - 1] : fiveEmojis[i - 1] + ratingOptionsElement.append(emojiElement) + } + } + const ratingsForm = ` +
+
+ +
+
${question.question}
+ ${question.description ? `${question.description}` : ''} +
+
+
+
+
${question.lowerBoundLabel}
+
${question.upperBoundLabel}
+
+ +
+
+ ` + const formElement = Object.assign(document.createElement('form'), { + className: `survey-${survey.id}-form`, + innerHTML: ratingsForm, + }) + formElement + .getElementsByClassName('rating-options')[0] + .insertAdjacentElement('afterbegin', ratingOptionsElement) + for (const x of Array(question.scale).keys()) { + formElement.getElementsByClassName(`rating_${x + 1}`)[0].addEventListener('click', (e: Event) => { + e.preventDefault() + posthog.capture('survey sent', { + $survey_name: survey.name, + $survey_id: survey.id, + $survey_question: question.question, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore // TODO: Fix this, error because it doesn't know that the target is a button + $survey_response: parseInt(e.currentTarget?.value), + sessionRecordingUrl: posthog.get_session_replay_url(), + }) + window.setTimeout(() => { + window.dispatchEvent(new Event('PHSurveySent')) + }, 200) + closeSurveyPopup(survey.id, formElement) + }) + } + + return formElement + } + + const createMultipleChoicePopup = (survey: Survey) => { + const surveyQuestion = survey.questions[0].question + const surveyDescription = survey.questions[0].description + const surveyQuestionChoices = (survey.questions[0] as MultipleSurveyQuestion).choices + const singleOrMultiSelect = survey.questions[0].type + const form = ` +
+
+ +
+
${surveyQuestion}
+ ${surveyDescription ? `${surveyDescription}` : ''} +
+ ${surveyQuestionChoices + .map((option, idx) => { + const inputType = singleOrMultiSelect === 'single_choice' ? 'radio' : 'checkbox' + const singleOrMultiSelectString = `
+
` + return singleOrMultiSelectString + }) + .join(' ')} +
+
+
+ +
+ +
+ +
+ ` + const formElement = Object.assign(document.createElement('form'), { + className: `survey-${survey.id}-form`, + innerHTML: form, + onsubmit: (e: any) => { + e.preventDefault() + const selectedChoices = + singleOrMultiSelect === 'single_choice' + ? e.target.querySelector('input[type=radio]:checked').value + : [...e.target.querySelectorAll('input[type=checkbox]:checked')].map((choice) => choice.value) + posthog.capture('survey sent', { + $survey_name: survey.name, + $survey_id: survey.id, + $survey_question: survey.questions[0], + $survey_response: selectedChoices, + sessionRecordingUrl: posthog.get_session_replay_url(), + }) + closeSurveyPopup(survey.id, formElement) + }, + }) + return formElement + } + + const callSurveys = (posthog: PostHog, forceReload: boolean = false) => { + posthog?.getActiveMatchingSurveys((surveys) => { + const nonAPISurveys = surveys.filter((survey) => survey.type !== 'api') + nonAPISurveys.forEach((survey) => { + if (document.querySelectorAll("div[class^='PostHogSurvey']").length === 0) { + const surveyWaitPeriodInDays = survey.conditions?.seenSurveyWaitPeriodInDays + const lastSeenSurveyDate = localStorage.getItem(`lastSeenSurveyDate`) + if (surveyWaitPeriodInDays && lastSeenSurveyDate) { + const today = new Date() + const diff = Math.abs(today.getTime() - new Date(lastSeenSurveyDate).getTime()) + const diffDaysFromToday = Math.ceil(diff / (1000 * 3600 * 24)) + if (diffDaysFromToday < surveyWaitPeriodInDays) { + return + } + } + + if (!localStorage.getItem(`seenSurvey_${survey.id}`)) { + let surveyPopup + const surveyQuestionType = survey.questions[0].type + if (surveyQuestionType === 'rating') { + surveyPopup = createRatingsPopup(survey) + } else if (surveyQuestionType === 'open' || surveyQuestionType === 'link') { + surveyPopup = createOpenTextPopup(survey) + } else if (surveyQuestionType === 'single_choice' || surveyQuestionType === 'multiple_choice') { + surveyPopup = createMultipleChoicePopup(survey) + } + + if (!surveyPopup) { + console.error(`PostHog: Survey question type: ${surveyQuestionType} not supported`) + return + } + + const shadow = createShadow(style(survey.id, survey?.appearance), survey.id) + + addCancelListeners(surveyPopup, survey.id, survey.name) + if (survey.appearance?.whiteLabel) { + ;( + surveyPopup.getElementsByClassName('footer-branding') as HTMLCollectionOf + )[0].style.display = 'none' + } + shadow.appendChild(surveyPopup) + + window.dispatchEvent(new Event('PHSurveyShown')) + posthog.capture('survey shown', { + $survey_name: survey.name, + $survey_id: survey.id, + sessionRecordingUrl: posthog.get_session_replay_url(), + }) + localStorage.setItem(`lastSeenSurveyDate`, new Date().toISOString()) + if (survey.appearance?.displayThankYouMessage) { + window.addEventListener('PHSurveySent', () => { + const thankYouElement = createThankYouMessage(survey) + shadow.appendChild(thankYouElement) + window.setTimeout(() => { + thankYouElement.remove() + }, 2000) + }) + } + } + } + }) + }, forceReload) + } + + callSurveys(posthog, true) + + let currentUrl = location.href + if (location.href) { + setInterval(() => { + if (location.href !== currentUrl) { + currentUrl = location.href + callSurveys(posthog, false) + } + }, 1500) + } +} diff --git a/src/loader-surveys.ts b/src/loader-surveys.ts new file mode 100644 index 000000000..33146a9b9 --- /dev/null +++ b/src/loader-surveys.ts @@ -0,0 +1,7 @@ +import { generateSurveys } from './extensions/surveys' + +const win: Window & typeof globalThis = typeof window !== 'undefined' ? window : ({} as typeof window) + +;(win as any).generateSurveys = generateSurveys + +export default generateSurveys diff --git a/src/posthog-core.ts b/src/posthog-core.ts index e52a055cd..e815c9f40 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -55,9 +55,10 @@ import { SentryIntegration } from './extensions/sentry-integration' import { createSegmentIntegration } from './extensions/segment-integration' import { PageViewManager } from './page-view' import { ExceptionObserver } from './extensions/exceptions/exception-autocapture' -import { PostHogSurveys, SurveyCallback } from './posthog-surveys' +import { PostHogSurveys } from './posthog-surveys' import { RateLimiter } from './rate-limiter' import { uuidv7 } from './uuidv7' +import { SurveyCallback } from 'posthog-surveys-types' /* SIMPLE STYLE GUIDE: @@ -256,6 +257,7 @@ const create_phlib = function ( return instance } +// TODO(neil): create interface & implement /** * PostHog Library Object * @constructor diff --git a/src/posthog-surveys-types.ts b/src/posthog-surveys-types.ts new file mode 100644 index 000000000..d50dd262c --- /dev/null +++ b/src/posthog-surveys-types.ts @@ -0,0 +1,90 @@ +/** + * Having Survey types in types.ts was confusing tsc + * and generating an invalid module.d.ts + * See https://github.com/PostHog/posthog-js/issues/698 + */ + +export interface SurveyAppearance { + // keep in sync with frontend/src/types.ts -> SurveyAppearance + backgroundColor?: string + submitButtonColor?: string + textColor?: string + submitButtonText?: string + descriptionTextColor?: string + ratingButtonColor?: string + ratingButtonHoverColor?: string + whiteLabel?: boolean + displayThankYouMessage?: boolean + thankYouMessageHeader?: string + thankYouMessageDescription?: string + // questionable: Not in frontend/src/types.ts -> SurveyAppearance, but used in site app + maxWidth?: string + zIndex?: string +} + +export enum SurveyType { + Popover = 'popover', + Button = 'button', + FullScreen = 'full_screen', + Email = 'email', + API = 'api', +} + +export type SurveyQuestion = BasicSurveyQuestion | LinkSurveyQuestion | RatingSurveyQuestion | MultipleSurveyQuestion + +interface SurveyQuestionBase { + question: string + description?: string | null + required?: boolean +} + +export interface BasicSurveyQuestion extends SurveyQuestionBase { + type: SurveyQuestionType.Open +} + +export interface LinkSurveyQuestion extends SurveyQuestionBase { + type: SurveyQuestionType.Link + link: string | null +} + +export interface RatingSurveyQuestion extends SurveyQuestionBase { + type: SurveyQuestionType.Rating + display: 'number' | 'emoji' + scale: number + lowerBoundLabel: string + upperBoundLabel: string +} + +export interface MultipleSurveyQuestion extends SurveyQuestionBase { + type: SurveyQuestionType.SingleChoice | SurveyQuestionType.MultipleChoice + choices: string[] +} + +export enum SurveyQuestionType { + Open = 'open', + MultipleChoice = 'multiple_choice', + SingleChoice = 'single_choice', + Rating = 'rating', + Link = 'link', +} + +export interface SurveyResponse { + surveys: Survey[] +} + +export type SurveyCallback = (surveys: Survey[]) => void + +export interface Survey { + // Sync this with the backend's SurveyAPISerializer! + id: string + name: string + description: string + type: SurveyType + linked_flag_key: string | null + targeting_flag_key: string | null + questions: SurveyQuestion[] + appearance: SurveyAppearance | null + conditions: { url?: string; selector?: string; seenSurveyWaitPeriodInDays?: number } | null + start_date: string | null + end_date: string | null +} diff --git a/src/posthog-surveys.ts b/src/posthog-surveys.ts index 428068a9b..20801da35 100644 --- a/src/posthog-surveys.ts +++ b/src/posthog-surveys.ts @@ -1,60 +1,6 @@ import { PostHog } from './posthog-core' import { SURVEYS } from './constants' - -/** - * Having Survey types in types.ts was confusing tsc - * and generating an invalid module.d.ts - * See https://github.com/PostHog/posthog-js/issues/698 - */ -export interface SurveyAppearance { - background_color?: string - button_color?: string - text_color?: string -} - -export enum SurveyType { - Popover = 'Popover', - Button = 'Button', - Email = 'Email', - FullScreen = 'Fullscreen', -} - -export interface SurveyQuestion { - type: SurveyQuestionType - question: string - required?: boolean - link?: boolean - choices?: string[] -} - -export enum SurveyQuestionType { - Open = 'open', - MultipleChoiceSingle = 'multiple_single', - MultipleChoiceMulti = 'multiple_multi', - NPS = 'nps', - Rating = 'rating', - Link = 'link', -} - -export interface SurveyResponse { - surveys: Survey[] -} - -export type SurveyCallback = (surveys: Survey[]) => void - -export interface Survey { - // Sync this with the backend's SurveySerializer! - name: string - description: string - type: SurveyType - linked_flag_key?: string | null - targeting_flag_key?: string | null - questions: SurveyQuestion[] - appearance?: SurveyAppearance | null - conditions?: { url?: string; selector?: string } | null - start_date?: string | null - end_date?: string | null -} +import { SurveyCallback } from 'posthog-surveys-types' export class PostHogSurveys { instance: PostHog diff --git a/src/types.ts b/src/types.ts index 9d1108c3a..b7f66871d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -227,6 +227,7 @@ export interface DecideResponse { consoleLogRecordingEnabled?: boolean recorderVersion?: 'v1' | 'v2' } + surveys?: boolean toolbarParams: ToolbarParams editorParams?: ToolbarParams /** @deprecated, renamed to toolbarParams, still present on older API responses */ toolbarVersion: 'toolbar' /** @deprecated, moved to toolbarParams */ From 63d2eaa63b503abab420388723d202ae713a5e7a Mon Sep 17 00:00:00 2001 From: Neil Kakkar Date: Mon, 18 Sep 2023 16:26:23 +0100 Subject: [PATCH 2/8] check bundle size on import change --- src/extensions/surveys.ts | 4 ++-- src/posthog-core.ts | 4 ++-- src/types.ts | 24 ++++++++++++++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/extensions/surveys.ts b/src/extensions/surveys.ts index af5ac8c7b..0f73b46ec 100644 --- a/src/extensions/surveys.ts +++ b/src/extensions/surveys.ts @@ -1,4 +1,3 @@ -import { PostHog } from 'posthog-core' import { BasicSurveyQuestion, LinkSurveyQuestion, @@ -7,6 +6,7 @@ import { Survey, SurveyAppearance, } from 'posthog-surveys-types' +import { PostHogInterface } from 'types' const posthogLogo = '' @@ -239,7 +239,7 @@ const style = (id: string, appearance: SurveyAppearance | null) => ` } ` -export function generateSurveys(posthog: PostHog) { +export function generateSurveys(posthog: PostHogInterface) { const createShadow = (styleSheet: string, surveyId: string) => { const div = document.createElement('div') div.className = `PostHogSurvey${surveyId}` diff --git a/src/posthog-core.ts b/src/posthog-core.ts index e815c9f40..c51b79d57 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -43,6 +43,7 @@ import { JsonType, OptInOutCapturingOptions, PostHogConfig, + PostHogInterface, Properties, Property, RequestCallback, @@ -257,12 +258,11 @@ const create_phlib = function ( return instance } -// TODO(neil): create interface & implement /** * PostHog Library Object * @constructor */ -export class PostHog { +export class PostHog implements PostHogInterface { __loaded: boolean __loaded_recorder_version: 'v1' | 'v2' | undefined // flag that keeps track of which version of recorder is loaded config: PostHogConfig diff --git a/src/types.ts b/src/types.ts index b7f66871d..af0c33897 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,9 +1,33 @@ import type { MaskInputOptions, SlimDOMOptions } from 'rrweb-snapshot' import { PostHog } from './posthog-core' import { RetryQueue } from './retry-queue' +import { RequestQueueScaffold } from 'base-request-queue' export type Property = any export type Properties = Record + +export interface PostHogInterface { + __loaded: boolean + __loaded_recorder_version: 'v1' | 'v2' | undefined // flag that keeps track of which version of recorder is loaded + config: PostHogConfig + + _requestQueue?: RequestQueueScaffold + _retryQueue?: RequestQueueScaffold + + _triggered_notifs: any + compression: Partial> + _jsc: JSC + __captureHooks: ((eventName: string) => void)[] + __request_queue: [url: string, data: Record, options: XHROptions, callback?: RequestCallback][] + __autocapture: boolean | AutocaptureConfig | undefined + decideEndpointWasHit: boolean + + segmentIntegration: () => any + + capture: (event_name: string, properties?: Properties | null, options?: CaptureOptions) => CaptureResult | void + + get_session_replay_url: (options?: { withTimestamp?: boolean; timestampLookBack?: number }) => string +} export interface CaptureResult { uuid: string event: string From b7f5039388e0cd4ef3200611dfece7bb076229ad Mon Sep 17 00:00:00 2001 From: Neil Kakkar Date: Mon, 18 Sep 2023 17:05:49 +0100 Subject: [PATCH 3/8] check bundle size on import change --- src/extensions/surveys.ts | 2 +- src/types.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/extensions/surveys.ts b/src/extensions/surveys.ts index 0f73b46ec..bae213b7f 100644 --- a/src/extensions/surveys.ts +++ b/src/extensions/surveys.ts @@ -483,7 +483,7 @@ export function generateSurveys(posthog: PostHogInterface) { return formElement } - const callSurveys = (posthog: PostHog, forceReload: boolean = false) => { + const callSurveys = (posthog: PostHogInterface, forceReload: boolean = false) => { posthog?.getActiveMatchingSurveys((surveys) => { const nonAPISurveys = surveys.filter((survey) => survey.type !== 'api') nonAPISurveys.forEach((survey) => { diff --git a/src/types.ts b/src/types.ts index af0c33897..e383e55a8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,7 @@ import type { MaskInputOptions, SlimDOMOptions } from 'rrweb-snapshot' import { PostHog } from './posthog-core' import { RetryQueue } from './retry-queue' import { RequestQueueScaffold } from 'base-request-queue' +import { SurveyCallback } from 'posthog-surveys-types' export type Property = any export type Properties = Record @@ -27,6 +28,8 @@ export interface PostHogInterface { capture: (event_name: string, properties?: Properties | null, options?: CaptureOptions) => CaptureResult | void get_session_replay_url: (options?: { withTimestamp?: boolean; timestampLookBack?: number }) => string + + getActiveMatchingSurveys: (callback: SurveyCallback, forceReload?: boolean) => void } export interface CaptureResult { uuid: string From 5b9a8fe7c117e7c5302140d8b10b40d303c5e861 Mon Sep 17 00:00:00 2001 From: Neil Kakkar Date: Tue, 19 Sep 2023 10:46:52 +0100 Subject: [PATCH 4/8] revert interface --- src/__tests__/extensions/surveys.js | 151 ++++++++++++++++++++++++++++ src/posthog-core.ts | 3 +- src/types.ts | 26 ----- 3 files changed, 152 insertions(+), 28 deletions(-) create mode 100644 src/__tests__/extensions/surveys.js diff --git a/src/__tests__/extensions/surveys.js b/src/__tests__/extensions/surveys.js new file mode 100644 index 000000000..3cb97c086 --- /dev/null +++ b/src/__tests__/extensions/surveys.js @@ -0,0 +1,151 @@ +import { createShadow, callSurveys, generateSurveys } from '../../extensions/surveys' + +describe('survey display logic', () => { + beforeEach(() => { + // we have to manually reset the DOM before each test + document.getElementsByTagName('html')[0].innerHTML = '' + localStorage.clear() + jest.clearAllMocks() + }) + + test('createShadow', () => { + const surveyId = 'randomSurveyId' + const mockShadow = createShadow(`.survey-${surveyId}-form {}`, surveyId) + expect(mockShadow.mode).toBe('open') + expect(mockShadow.host.className).toBe(`PostHogSurvey${surveyId}`) + }) + + let mockSurveys = [ + { + id: 'testSurvey1', + name: 'Test survey 1', + appearance: null, + questions: [ + { + question: 'How satisfied are you with our newest product?', + description: 'This is a question description', + type: 'rating', + display: 'number', + scale: 10, + lower_bound_label: 'Not Satisfied', + upper_bound_label: 'Very Satisfied', + }, + ], + }, + ] + const mockPostHog = { + getActiveMatchingSurveys: jest.fn().mockImplementation((callback) => callback(mockSurveys)), + get_session_replay_url: jest.fn(), + capture: jest.fn().mockImplementation((eventName) => eventName), + } + + test('does not show survey to user if they have dismissed it before', () => { + expect(localStorage.getItem(`seenSurvey_${mockSurveys[0].id}`)).toBe(null) + callSurveys(mockPostHog, false) + expect(mockPostHog.capture).toBeCalledTimes(1) + expect(mockPostHog.capture).toBeCalledWith('survey shown', { + $survey_id: 'testSurvey1', + $survey_name: 'Test survey 1', + sessionRecordingUrl: undefined, + }) + + // now we dismiss the survey + const cancelButton = document + .getElementsByClassName(`PostHogSurvey${mockSurveys[0].id}`)[0] + .shadowRoot.querySelectorAll('.form-cancel')[0] + cancelButton.click() + expect(mockPostHog.capture).toBeCalledTimes(2) + expect(mockPostHog.capture).toBeCalledWith('survey dismissed', { + $survey_id: 'testSurvey1', + $survey_name: 'Test survey 1', + sessionRecordingUrl: undefined, + }) + expect(localStorage.getItem(`seenSurvey_${mockSurveys[0].id}`)).toBe('true') + + // now we clear the DOM to imitate a new page load and call surveys again, and it should not show the survey + document.getElementsByTagName('html')[0].innerHTML = '' + callSurveys(mockPostHog, false) + expect(document.getElementsByClassName(`PostHogSurvey${mockSurveys[0].id}`)[0]).toBe(undefined) + // no additional capture events are called because the survey is not shown + expect(mockPostHog.capture).toBeCalledTimes(2) + }) + + test('does not show survey to user if they have already completed it', () => { + expect(localStorage.getItem(`seenSurvey_${mockSurveys[0].id}`)).toBe(null) + callSurveys(mockPostHog, false) + expect(mockPostHog.capture).toBeCalledTimes(1) + expect(mockPostHog.capture).toBeCalledWith('survey shown', { + $survey_id: 'testSurvey1', + $survey_name: 'Test survey 1', + sessionRecordingUrl: undefined, + }) + + // submit the survey + const submitButton = document + .getElementsByClassName(`PostHogSurvey${mockSurveys[0].id}`)[0] + .shadowRoot.querySelectorAll('.rating_1')[0] + submitButton.click() + expect(mockPostHog.capture).toBeCalledTimes(2) + expect(mockPostHog.capture).toBeCalledWith('survey sent', { + $survey_id: 'testSurvey1', + $survey_name: 'Test survey 1', + $survey_question: 'How satisfied are you with our newest product?', + $survey_response: 1, + sessionRecordingUrl: undefined, + }) + expect(localStorage.getItem(`seenSurvey_${mockSurveys[0].id}`)).toBe('true') + + // now we clear the DOM to imitate a new page load and call surveys again, and it should not show the survey + document.getElementsByTagName('html')[0].innerHTML = '' + callSurveys(mockPostHog, false) + expect(document.getElementsByClassName(`PostHogSurvey${mockSurveys[0].id}`)[0]).toBe(undefined) + // no additional capture events are called because the survey is not shown + expect(mockPostHog.capture).toBeCalledTimes(2) + }) + + test('does not show survey to user if they have seen it before and survey wait period is set', () => { + mockSurveys = [ + { + id: 'testSurvey2', + name: 'Test survey 2', + appearance: null, + conditions: { seenSurveyWaitPeriodInDays: 10 }, + questions: [ + { + question: 'How was your experience?', + description: 'This is a question description', + type: 'rating', + display: 'emoji', + scale: 5, + lower_bound_label: 'Not Good', + upper_bound_label: 'Very Good', + }, + ], + }, + ] + expect(mockSurveys[0].conditions.seenSurveyWaitPeriodInDays).toBe(10) + expect(localStorage.getItem(`seenSurvey_${mockSurveys[0].id}`)).toBe(null) + callSurveys(mockPostHog, false) + expect(mockPostHog.capture).toBeCalledTimes(1) + expect(mockPostHog.capture).toBeCalledWith('survey shown', { + $survey_id: 'testSurvey2', + $survey_name: 'Test survey 2', + sessionRecordingUrl: undefined, + }) + expect(localStorage.getItem('lastSeenSurveyDate').split('T')[0]).toBe(new Date().toISOString().split('T')[0]) + + document.getElementsByTagName('html')[0].innerHTML = '' + callSurveys(mockPostHog, false) + expect(document.getElementsByClassName(`PostHogSurvey${mockSurveys[0].id}`)[0]).toBe(undefined) + // no additional capture events are called because the survey is not shown + expect(mockPostHog.capture).toBeCalledTimes(1) + }) + + test('when url changes, callSurveys runs again', () => { + jest.useFakeTimers() + jest.spyOn(global, 'setInterval') + generateSurveys(mockPostHog) + expect(mockPostHog.getActiveMatchingSurveys).toBeCalledTimes(1) + expect(setInterval).toHaveBeenLastCalledWith(expect.any(Function), 1500) + }) +}) diff --git a/src/posthog-core.ts b/src/posthog-core.ts index c51b79d57..ac23dec7d 100644 --- a/src/posthog-core.ts +++ b/src/posthog-core.ts @@ -43,7 +43,6 @@ import { JsonType, OptInOutCapturingOptions, PostHogConfig, - PostHogInterface, Properties, Property, RequestCallback, @@ -262,7 +261,7 @@ const create_phlib = function ( * PostHog Library Object * @constructor */ -export class PostHog implements PostHogInterface { +export class PostHog { __loaded: boolean __loaded_recorder_version: 'v1' | 'v2' | undefined // flag that keeps track of which version of recorder is loaded config: PostHogConfig diff --git a/src/types.ts b/src/types.ts index e383e55a8..6fa184eb0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,36 +1,10 @@ import type { MaskInputOptions, SlimDOMOptions } from 'rrweb-snapshot' import { PostHog } from './posthog-core' import { RetryQueue } from './retry-queue' -import { RequestQueueScaffold } from 'base-request-queue' -import { SurveyCallback } from 'posthog-surveys-types' export type Property = any export type Properties = Record -export interface PostHogInterface { - __loaded: boolean - __loaded_recorder_version: 'v1' | 'v2' | undefined // flag that keeps track of which version of recorder is loaded - config: PostHogConfig - - _requestQueue?: RequestQueueScaffold - _retryQueue?: RequestQueueScaffold - - _triggered_notifs: any - compression: Partial> - _jsc: JSC - __captureHooks: ((eventName: string) => void)[] - __request_queue: [url: string, data: Record, options: XHROptions, callback?: RequestCallback][] - __autocapture: boolean | AutocaptureConfig | undefined - decideEndpointWasHit: boolean - - segmentIntegration: () => any - - capture: (event_name: string, properties?: Properties | null, options?: CaptureOptions) => CaptureResult | void - - get_session_replay_url: (options?: { withTimestamp?: boolean; timestampLookBack?: number }) => string - - getActiveMatchingSurveys: (callback: SurveyCallback, forceReload?: boolean) => void -} export interface CaptureResult { uuid: string event: string From 44dd6bea939742ee30c8564de4d48e4a231960aa Mon Sep 17 00:00:00 2001 From: Neil Kakkar Date: Tue, 19 Sep 2023 10:47:09 +0100 Subject: [PATCH 5/8] update from feature-surveys branch pr #17 --- src/extensions/surveys.ts | 547 +++++++++++++++++++------------------- 1 file changed, 269 insertions(+), 278 deletions(-) diff --git a/src/extensions/surveys.ts b/src/extensions/surveys.ts index bae213b7f..2db3cd6d8 100644 --- a/src/extensions/surveys.ts +++ b/src/extensions/surveys.ts @@ -1,3 +1,4 @@ +import { PostHog } from 'posthog-core' import { BasicSurveyQuestion, LinkSurveyQuestion, @@ -6,7 +7,6 @@ import { Survey, SurveyAppearance, } from 'posthog-surveys-types' -import { PostHogInterface } from 'types' const posthogLogo = '' @@ -239,314 +239,305 @@ const style = (id: string, appearance: SurveyAppearance | null) => ` } ` -export function generateSurveys(posthog: PostHogInterface) { - const createShadow = (styleSheet: string, surveyId: string) => { - const div = document.createElement('div') - div.className = `PostHogSurvey${surveyId}` - const shadow = div.attachShadow({ mode: 'open' }) - if (styleSheet) { - const styleElement = Object.assign(document.createElement('style'), { - innerText: styleSheet, - }) - shadow.appendChild(styleElement) - } - document.body.appendChild(div) - return shadow +export const createShadow = (styleSheet: string, surveyId: string) => { + const div = document.createElement('div') + div.className = `PostHogSurvey${surveyId}` + const shadow = div.attachShadow({ mode: 'open' }) + if (styleSheet) { + const styleElement = Object.assign(document.createElement('style'), { + innerText: styleSheet, + }) + shadow.appendChild(styleElement) } + document.body.appendChild(div) + return shadow +} - const closeSurveyPopup = (surveyId: string, surveyPopup: HTMLFormElement) => { - Object.assign(surveyPopup.style, { display: 'none' }) - localStorage.setItem(`seenSurvey_${surveyId}`, 'true') - window.setTimeout(() => { - window.dispatchEvent(new Event('PHSurveyClosed')) - }, 2000) - surveyPopup.reset() - } +export const closeSurveyPopup = (surveyId: string, surveyPopup: HTMLFormElement) => { + Object.assign(surveyPopup.style, { display: 'none' }) + localStorage.setItem(`seenSurvey_${surveyId}`, 'true') + window.setTimeout(() => { + window.dispatchEvent(new Event('PHSurveyClosed')) + }, 2000) + surveyPopup.reset() +} - const createThankYouMessage = (survey: Survey) => { - const thankYouHTML = ` -
-

${survey.appearance?.thankYouMessageHeader || 'Thank you!'}

-
${survey.appearance?.thankYouMessageDescription || ''}
- +export const createOpenTextPopup = (posthog: PostHog, survey: Survey) => { + const question = survey.questions[0] as BasicSurveyQuestion | LinkSurveyQuestion + const surveyQuestionType = question.type + const surveyDescription = question.description + const questionText = question.question + const form = ` +
+
+
- ` - const thankYouElement = Object.assign(document.createElement('div'), { - className: `thank-you-message`, - innerHTML: thankYouHTML, - }) - return thankYouElement - } - - const createOpenTextPopup = (survey: Survey) => { - const question = survey.questions[0] as BasicSurveyQuestion | LinkSurveyQuestion - const surveyQuestionType = question.type - const surveyDescription = question.description - const questionText = question.question - const form = ` -
-
- -
-
-
${questionText}
- ${surveyDescription ? `${surveyDescription}` : ''} - ${ - surveyQuestionType === 'open' - ? `` - : '' - } -
-
-
- -
- +
+
${questionText}
+ ${surveyDescription ? `${surveyDescription}` : ''} + ${surveyQuestionType === 'open' ? `` : ''} +
+
+
+
+
- ` - const formElement = Object.assign(document.createElement('form'), { - className: `survey-${survey.id}-form`, - innerHTML: form, - onsubmit: function (e: any) { - e.preventDefault() - const surveyQuestionType = question.type - posthog.capture('survey sent', { - $survey_name: survey.name, - $survey_id: survey.id, - $survey_question: survey.questions[0], - $survey_response: surveyQuestionType === 'open' ? e.target.survey.value : 'link clicked', - sessionRecordingUrl: posthog.get_session_replay_url(), - }) - if (surveyQuestionType === 'link') { - window.open(question.link || undefined) - } - window.setTimeout(() => { - window.dispatchEvent(new Event('PHSurveySent')) - }, 200) - closeSurveyPopup(survey.id, formElement) - }, - }) - - return formElement - } - - const addCancelListeners = (surveyPopup: HTMLFormElement, surveyId: string, surveyEventName: string) => { - const cancelButton = surveyPopup.getElementsByClassName('form-cancel')?.[0] as HTMLButtonElement - - cancelButton.addEventListener('click', (e) => { +
+` + const formElement = Object.assign(document.createElement('form'), { + className: `survey-${survey.id}-form`, + innerHTML: form, + onsubmit: function (e: any) { e.preventDefault() - Object.assign(surveyPopup.style, { display: 'none' }) - localStorage.setItem(`seenSurvey_${surveyId}`, 'true') - posthog.capture('survey dismissed', { - $survey_name: surveyEventName, - $survey_id: surveyId, + const surveyQuestionType = question.type + posthog.capture('survey sent', { + $survey_name: survey.name, + $survey_id: survey.id, + $survey_question: survey.questions[0], + $survey_response: surveyQuestionType === 'open' ? e.target.survey.value : 'link clicked', sessionRecordingUrl: posthog.get_session_replay_url(), }) - window.dispatchEvent(new Event('PHSurveyClosed')) + if (surveyQuestionType === 'link') { + window.open(question.link || undefined) + } + window.setTimeout(() => { + window.dispatchEvent(new Event('PHSurveySent')) + }, 200) + closeSurveyPopup(survey.id, formElement) + }, + }) + + return formElement +} + +export const createThankYouMessage = (survey: Survey) => { + const thankYouHTML = ` +
+

${survey.appearance?.thankYouMessageHeader || 'Thank you!'}

+
${survey.appearance?.thankYouMessageDescription || ''}
+ +
+ ` + const thankYouElement = Object.assign(document.createElement('div'), { + className: `thank-you-message`, + innerHTML: thankYouHTML, + }) + return thankYouElement +} + +export const addCancelListeners = ( + posthog: PostHog, + surveyPopup: HTMLFormElement, + surveyId: string, + surveyEventName: string +) => { + const cancelButton = surveyPopup.getElementsByClassName('form-cancel')?.[0] as HTMLButtonElement + + cancelButton.addEventListener('click', (e) => { + e.preventDefault() + Object.assign(surveyPopup.style, { display: 'none' }) + localStorage.setItem(`seenSurvey_${surveyId}`, 'true') + posthog.capture('survey dismissed', { + $survey_name: surveyEventName, + $survey_id: surveyId, + sessionRecordingUrl: posthog.get_session_replay_url(), }) - } + window.dispatchEvent(new Event('PHSurveyClosed')) + }) +} - const createRatingsPopup = (survey: Survey) => { - const question = survey.questions[0] as RatingSurveyQuestion - const scale = question.scale - const displayType = question.display - const ratingOptionsElement = document.createElement('div') - if (displayType === 'number') { - ratingOptionsElement.className = `rating-options-buttons ${scale === 10 ? 'max-numbers' : ''}` - for (let i = 1; i <= scale; i++) { - const buttonElement = document.createElement('button') - buttonElement.className = `ratings-number rating_${i}` - buttonElement.type = 'submit' - buttonElement.value = `${i}` - buttonElement.innerHTML = `${i}` - ratingOptionsElement.append(buttonElement) - } - } else if (displayType === 'emoji') { - ratingOptionsElement.className = 'rating-options-emoji' - const threeEmojis = [dissatisfiedEmoji, neutralEmoji, satisfiedEmoji] - const fiveEmojis = [ - veryDissatisfiedEmoji, - dissatisfiedEmoji, - neutralEmoji, - satisfiedEmoji, - verySatisfiedEmoji, - ] - for (let i = 1; i <= scale; i++) { - const emojiElement = document.createElement('button') - emojiElement.className = `ratings-emoji rating_${i}` - emojiElement.type = 'submit' - emojiElement.value = `${i}` - emojiElement.innerHTML = scale === 3 ? threeEmojis[i - 1] : fiveEmojis[i - 1] - ratingOptionsElement.append(emojiElement) - } +export const createRatingsPopup = (posthog: PostHog, survey: Survey) => { + const question = survey.questions[0] as RatingSurveyQuestion + const scale = question.scale + const displayType = question.display + const ratingOptionsElement = document.createElement('div') + if (displayType === 'number') { + ratingOptionsElement.className = `rating-options-buttons ${scale === 10 ? 'max-numbers' : ''}` + for (let i = 1; i <= scale; i++) { + const buttonElement = document.createElement('button') + buttonElement.className = `ratings-number rating_${i}` + buttonElement.type = 'submit' + buttonElement.value = `${i}` + buttonElement.innerHTML = `${i}` + ratingOptionsElement.append(buttonElement) } - const ratingsForm = ` -
-
- -
-
${question.question}
- ${question.description ? `${question.description}` : ''} -
-
-
-
-
${question.lowerBoundLabel}
-
${question.upperBoundLabel}
-
- -
-
- ` - const formElement = Object.assign(document.createElement('form'), { - className: `survey-${survey.id}-form`, - innerHTML: ratingsForm, - }) - formElement - .getElementsByClassName('rating-options')[0] - .insertAdjacentElement('afterbegin', ratingOptionsElement) - for (const x of Array(question.scale).keys()) { - formElement.getElementsByClassName(`rating_${x + 1}`)[0].addEventListener('click', (e: Event) => { - e.preventDefault() - posthog.capture('survey sent', { - $survey_name: survey.name, - $survey_id: survey.id, - $survey_question: question.question, - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore // TODO: Fix this, error because it doesn't know that the target is a button - $survey_response: parseInt(e.currentTarget?.value), - sessionRecordingUrl: posthog.get_session_replay_url(), - }) - window.setTimeout(() => { - window.dispatchEvent(new Event('PHSurveySent')) - }, 200) - closeSurveyPopup(survey.id, formElement) - }) + } else if (displayType === 'emoji') { + ratingOptionsElement.className = 'rating-options-emoji' + const threeEmojis = [dissatisfiedEmoji, neutralEmoji, satisfiedEmoji] + const fiveEmojis = [veryDissatisfiedEmoji, dissatisfiedEmoji, neutralEmoji, satisfiedEmoji, verySatisfiedEmoji] + for (let i = 1; i <= scale; i++) { + const emojiElement = document.createElement('button') + emojiElement.className = `ratings-emoji rating_${i}` + emojiElement.type = 'submit' + emojiElement.value = `${i}` + emojiElement.innerHTML = scale === 3 ? threeEmojis[i - 1] : fiveEmojis[i - 1] + ratingOptionsElement.append(emojiElement) } - - return formElement } - - const createMultipleChoicePopup = (survey: Survey) => { - const surveyQuestion = survey.questions[0].question - const surveyDescription = survey.questions[0].description - const surveyQuestionChoices = (survey.questions[0] as MultipleSurveyQuestion).choices - const singleOrMultiSelect = survey.questions[0].type - const form = ` -
-
- -
-
${surveyQuestion}
- ${surveyDescription ? `${surveyDescription}` : ''} -
- ${surveyQuestionChoices - .map((option, idx) => { - const inputType = singleOrMultiSelect === 'single_choice' ? 'radio' : 'checkbox' - const singleOrMultiSelectString = `
-
` - return singleOrMultiSelectString - }) - .join(' ')} + const ratingsForm = ` +
+
+ +
+
${question.question}
+ ${question.description ? `${question.description}` : ''} +
+
-
-
- +
+
${question.lowerBoundLabel}
+
${question.upperBoundLabel}
- -
- ` - const formElement = Object.assign(document.createElement('form'), { - className: `survey-${survey.id}-form`, - innerHTML: form, - onsubmit: (e: any) => { - e.preventDefault() - const selectedChoices = - singleOrMultiSelect === 'single_choice' - ? e.target.querySelector('input[type=radio]:checked').value - : [...e.target.querySelectorAll('input[type=checkbox]:checked')].map((choice) => choice.value) - posthog.capture('survey sent', { - $survey_name: survey.name, - $survey_id: survey.id, - $survey_question: survey.questions[0], - $survey_response: selectedChoices, - sessionRecordingUrl: posthog.get_session_replay_url(), - }) - closeSurveyPopup(survey.id, formElement) - }, +
+ ` + const formElement = Object.assign(document.createElement('form'), { + className: `survey-${survey.id}-form`, + innerHTML: ratingsForm, + }) + formElement.getElementsByClassName('rating-options')[0].insertAdjacentElement('afterbegin', ratingOptionsElement) + for (const x of Array(question.scale).keys()) { + formElement.getElementsByClassName(`rating_${x + 1}`)[0].addEventListener('click', (e: Event) => { + e.preventDefault() + posthog.capture('survey sent', { + $survey_name: survey.name, + $survey_id: survey.id, + $survey_question: question.question, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore // TODO: Fix this, error because it doesn't know that the target is a button + $survey_response: parseInt(e.currentTarget?.value), + sessionRecordingUrl: posthog.get_session_replay_url(), + }) + window.setTimeout(() => { + window.dispatchEvent(new Event('PHSurveySent')) + }, 200) + closeSurveyPopup(survey.id, formElement) }) - return formElement } - const callSurveys = (posthog: PostHogInterface, forceReload: boolean = false) => { - posthog?.getActiveMatchingSurveys((surveys) => { - const nonAPISurveys = surveys.filter((survey) => survey.type !== 'api') - nonAPISurveys.forEach((survey) => { - if (document.querySelectorAll("div[class^='PostHogSurvey']").length === 0) { - const surveyWaitPeriodInDays = survey.conditions?.seenSurveyWaitPeriodInDays - const lastSeenSurveyDate = localStorage.getItem(`lastSeenSurveyDate`) - if (surveyWaitPeriodInDays && lastSeenSurveyDate) { - const today = new Date() - const diff = Math.abs(today.getTime() - new Date(lastSeenSurveyDate).getTime()) - const diffDaysFromToday = Math.ceil(diff / (1000 * 3600 * 24)) - if (diffDaysFromToday < surveyWaitPeriodInDays) { - return - } + return formElement +} + +export const createMultipleChoicePopup = (posthog: PostHog, survey: Survey) => { + const surveyQuestion = survey.questions[0].question + const surveyDescription = survey.questions[0].description + const surveyQuestionChoices = (survey.questions[0] as MultipleSurveyQuestion).choices + const singleOrMultiSelect = survey.questions[0].type + const form = ` +
+
+ +
+
${surveyQuestion}
+ ${surveyDescription ? `${surveyDescription}` : ''} +
+ ${surveyQuestionChoices + .map((option, idx) => { + const inputType = singleOrMultiSelect === 'single_choice' ? 'radio' : 'checkbox' + const singleOrMultiSelectString = `
+
` + return singleOrMultiSelectString + }) + .join(' ')} +
+
+
+ +
+ +
+ +
+ ` + const formElement = Object.assign(document.createElement('form'), { + className: `survey-${survey.id}-form`, + innerHTML: form, + onsubmit: (e: any) => { + e.preventDefault() + const selectedChoices = + singleOrMultiSelect === 'single_choice' + ? e.target.querySelector('input[type=radio]:checked').value + : [...e.target.querySelectorAll('input[type=checkbox]:checked')].map((choice) => choice.value) + posthog.capture('survey sent', { + $survey_name: survey.name, + $survey_id: survey.id, + $survey_question: survey.questions[0], + $survey_response: selectedChoices, + sessionRecordingUrl: posthog.get_session_replay_url(), + }) + closeSurveyPopup(survey.id, formElement) + }, + }) + return formElement +} + +export const callSurveys = (posthog: PostHog, forceReload: boolean = false) => { + posthog?.getActiveMatchingSurveys((surveys) => { + const nonAPISurveys = surveys.filter((survey) => survey.type !== 'api') + nonAPISurveys.forEach((survey) => { + if (document.querySelectorAll("div[class^='PostHogSurvey']").length === 0) { + const surveyWaitPeriodInDays = survey.conditions?.seenSurveyWaitPeriodInDays + const lastSeenSurveyDate = localStorage.getItem(`lastSeenSurveyDate`) + if (surveyWaitPeriodInDays && lastSeenSurveyDate) { + const today = new Date() + const diff = Math.abs(today.getTime() - new Date(lastSeenSurveyDate).getTime()) + const diffDaysFromToday = Math.ceil(diff / (1000 * 3600 * 24)) + if (diffDaysFromToday < surveyWaitPeriodInDays) { + return } + } - if (!localStorage.getItem(`seenSurvey_${survey.id}`)) { - let surveyPopup - const surveyQuestionType = survey.questions[0].type - if (surveyQuestionType === 'rating') { - surveyPopup = createRatingsPopup(survey) - } else if (surveyQuestionType === 'open' || surveyQuestionType === 'link') { - surveyPopup = createOpenTextPopup(survey) - } else if (surveyQuestionType === 'single_choice' || surveyQuestionType === 'multiple_choice') { - surveyPopup = createMultipleChoicePopup(survey) - } + if (!localStorage.getItem(`seenSurvey_${survey.id}`)) { + let surveyPopup + const surveyQuestionType = survey.questions[0].type + if (surveyQuestionType === 'rating') { + surveyPopup = createRatingsPopup(posthog, survey) + } else if (surveyQuestionType === 'open' || surveyQuestionType === 'link') { + surveyPopup = createOpenTextPopup(posthog, survey) + } else if (surveyQuestionType === 'single_choice' || surveyQuestionType === 'multiple_choice') { + surveyPopup = createMultipleChoicePopup(posthog, survey) + } - if (!surveyPopup) { - console.error(`PostHog: Survey question type: ${surveyQuestionType} not supported`) - return - } + if (!surveyPopup) { + console.error(`PostHog: Survey question type: ${surveyQuestionType} not supported`) + return + } - const shadow = createShadow(style(survey.id, survey?.appearance), survey.id) + const shadow = createShadow(style(survey.id, survey?.appearance), survey.id) - addCancelListeners(surveyPopup, survey.id, survey.name) - if (survey.appearance?.whiteLabel) { - ;( - surveyPopup.getElementsByClassName('footer-branding') as HTMLCollectionOf - )[0].style.display = 'none' - } - shadow.appendChild(surveyPopup) + addCancelListeners(posthog, surveyPopup, survey.id, survey.name) + if (survey.appearance?.whiteLabel) { + ;( + surveyPopup.getElementsByClassName('footer-branding') as HTMLCollectionOf + )[0].style.display = 'none' + } + shadow.appendChild(surveyPopup) - window.dispatchEvent(new Event('PHSurveyShown')) - posthog.capture('survey shown', { - $survey_name: survey.name, - $survey_id: survey.id, - sessionRecordingUrl: posthog.get_session_replay_url(), + window.dispatchEvent(new Event('PHSurveyShown')) + posthog.capture('survey shown', { + $survey_name: survey.name, + $survey_id: survey.id, + sessionRecordingUrl: posthog.get_session_replay_url(), + }) + localStorage.setItem(`lastSeenSurveyDate`, new Date().toISOString()) + if (survey.appearance?.displayThankYouMessage) { + window.addEventListener('PHSurveySent', () => { + const thankYouElement = createThankYouMessage(survey) + shadow.appendChild(thankYouElement) + window.setTimeout(() => { + thankYouElement.remove() + }, 2000) }) - localStorage.setItem(`lastSeenSurveyDate`, new Date().toISOString()) - if (survey.appearance?.displayThankYouMessage) { - window.addEventListener('PHSurveySent', () => { - const thankYouElement = createThankYouMessage(survey) - shadow.appendChild(thankYouElement) - window.setTimeout(() => { - thankYouElement.remove() - }, 2000) - }) - } } } - }) - }, forceReload) - } + } + }) + }, forceReload) +} +export function generateSurveys(posthog: PostHog) { callSurveys(posthog, true) let currentUrl = location.href From 03c120d8031213c4bc638978920c022716cdaa52 Mon Sep 17 00:00:00 2001 From: Neil Kakkar Date: Tue, 19 Sep 2023 10:50:40 +0100 Subject: [PATCH 6/8] merge pr #17 --- src/extensions/surveys.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/extensions/surveys.ts b/src/extensions/surveys.ts index 2db3cd6d8..ce2b8603f 100644 --- a/src/extensions/surveys.ts +++ b/src/extensions/surveys.ts @@ -281,7 +281,11 @@ export const createOpenTextPopup = (posthog: PostHog, survey: Survey) => {
- + ${ + survey.appearance?.whiteLabel + ? '' + : `` + }
` @@ -316,7 +320,11 @@ export const createThankYouMessage = (survey: Survey) => {

${survey.appearance?.thankYouMessageHeader || 'Thank you!'}

${survey.appearance?.thankYouMessageDescription || ''}
- + ${ + survey.appearance?.whiteLabel + ? '' + : `` + }
` const thankYouElement = Object.assign(document.createElement('div'), { @@ -386,8 +394,8 @@ export const createRatingsPopup = (posthog: PostHog, survey: Survey) => {
-
${question.lowerBoundLabel}
-
${question.upperBoundLabel}
+
${question.lowerBoundLabel || ''}
+
${question.upperBoundLabel || ''}
@@ -446,7 +454,11 @@ export const createMultipleChoicePopup = (posthog: PostHog, survey: Survey) => {
- + ${ + survey.appearance?.whiteLabel + ? '' + : `` + }
From 5d51d37540fbd95ffd7df97296586d6efd81bf08 Mon Sep 17 00:00:00 2001 From: Neil Kakkar Date: Tue, 19 Sep 2023 14:01:58 +0100 Subject: [PATCH 7/8] no need to whitelabel --- src/extensions/surveys.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/extensions/surveys.ts b/src/extensions/surveys.ts index ce2b8603f..1b5891a57 100644 --- a/src/extensions/surveys.ts +++ b/src/extensions/surveys.ts @@ -281,11 +281,7 @@ export const createOpenTextPopup = (posthog: PostHog, survey: Survey) => {
- ${ - survey.appearance?.whiteLabel - ? '' - : `` - } +
` @@ -454,11 +450,7 @@ export const createMultipleChoicePopup = (posthog: PostHog, survey: Survey) => {
- ${ - survey.appearance?.whiteLabel - ? '' - : `` - } + From 8472158dca32d5191f589c7300d1ad72a8211f93 Mon Sep 17 00:00:00 2001 From: Neil Kakkar Date: Mon, 25 Sep 2023 13:26:36 +0100 Subject: [PATCH 8/8] address comment --- src/decide.ts | 4 ++-- src/loader-surveys.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/decide.ts b/src/decide.ts index 8f376ab04..49d434e86 100644 --- a/src/decide.ts +++ b/src/decide.ts @@ -77,7 +77,7 @@ export class Decide { // Check if recorder.js is already loaded // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - const surveysGenerator = window?.generateSurveys + const surveysGenerator = window?.extendPostHogWithSurveys if (response['surveys'] && !surveysGenerator) { loadScript(this.instance.get_config('api_host') + `/static/surveys.js`, (err) => { @@ -87,7 +87,7 @@ export class Decide { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - window.generateSurveys(this.instance) + window.extendPostHogWithSurveys(this.instance) }) } diff --git a/src/loader-surveys.ts b/src/loader-surveys.ts index 33146a9b9..489ddefb0 100644 --- a/src/loader-surveys.ts +++ b/src/loader-surveys.ts @@ -2,6 +2,6 @@ import { generateSurveys } from './extensions/surveys' const win: Window & typeof globalThis = typeof window !== 'undefined' ? window : ({} as typeof window) -;(win as any).generateSurveys = generateSurveys +;(win as any).extendPostHogWithSurveys = generateSurveys export default generateSurveys