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__/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/__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..49d434e86 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?.extendPostHogWithSurveys + + 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.extendPostHogWithSurveys(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..1b5891a57 --- /dev/null +++ b/src/extensions/surveys.ts @@ -0,0 +1,556 @@ +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 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 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 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 = ` +
+
+ +
+
+
${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 +} + +export const createThankYouMessage = (survey: Survey) => { + const thankYouHTML = ` +
+

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

+
${survey.appearance?.thankYouMessageDescription || ''}
+ ${ + survey.appearance?.whiteLabel + ? '' + : `` + } +
+ ` + 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')) + }) +} + +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) + } + } 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 +} + +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(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 + } + + const shadow = createShadow(style(survey.id, survey?.appearance), survey.id) + + 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(), + }) + 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) +} + +export function generateSurveys(posthog: PostHog) { + 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..489ddefb0 --- /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).extendPostHogWithSurveys = generateSurveys + +export default generateSurveys diff --git a/src/posthog-core.ts b/src/posthog-core.ts index e52a055cd..ac23dec7d 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: 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..6fa184eb0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,7 @@ import { RetryQueue } from './retry-queue' export type Property = any export type Properties = Record + export interface CaptureResult { uuid: string event: string @@ -227,6 +228,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 */