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?.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 */