feat: external surveys (#33948)

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Lucas Faria
2025-07-25 12:34:17 -03:00
committed by GitHub
parent 8b38b3bbbe
commit 100f72febb
31 changed files with 2161 additions and 502 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 62 KiB

View File

@@ -169,6 +169,7 @@ export const FEATURE_FLAGS = {
INSIGHT_HORIZONTAL_CONTROLS: 'insight-horizontal-controls', // owner: @benjackwhite
SURVEYS_ADAPTIVE_LIMITS: 'surveys-adaptive-limits', // owner: #team-surveys
SURVEYS_ACTIONS: 'surveys-actions', // owner: #team-surveys
EXTERNAL_SURVEYS: 'external-surveys', // owner: #team-surveys
DISCUSSIONS: 'discussions', // owner: @daibhin @benjackwhite
REDIRECT_INSIGHT_CREATION_PRODUCT_ANALYTICS_ONBOARDING: 'redirect-insight-creation-product-analytics-onboarding', // owner: @biancayang
AI_SESSION_SUMMARY: 'ai-session-summary', // owner: #team-replay

View File

@@ -22423,7 +22423,7 @@
"type": "string"
},
"SurveyType": {
"enum": ["popover", "widget", "full_screen", "email", "api"],
"enum": ["popover", "widget", "full_screen", "api", "external_survey"],
"type": "string"
},
"SurveyWidgetType": {

View File

@@ -0,0 +1,18 @@
import { LemonButton } from '@posthog/lemon-ui'
import { IconLink } from 'lib/lemon-ui/icons'
import { copyToClipboard } from 'lib/utils/copyToClipboard'
export function CopySurveyLink({ surveyId }: { surveyId: string }): JSX.Element {
return (
<LemonButton
icon={<IconLink />}
onClick={() => {
const url = new URL(window.location.origin)
url.pathname = `/external_surveys/${surveyId}`
copyToClipboard(url.toString(), 'survey link')
}}
>
Copy survey external link
</LemonButton>
)
}

View File

@@ -11,6 +11,7 @@ export function PresentationTypeCard({
onClick,
value,
active,
disabled,
}: {
title: string
description?: string
@@ -18,12 +19,14 @@ export function PresentationTypeCard({
onClick: () => void
value: any
active: boolean
disabled?: boolean
}): JSX.Element {
return (
<div
className={clsx(
'border rounded relative px-4 py-2 overflow-hidden h-[180px] w-[200px]',
active ? 'border-accent' : 'border-primary'
active ? 'border-accent' : 'border-primary',
disabled && 'opacity-50'
)}
>
<p className="font-semibold m-0">{title}</p>
@@ -35,6 +38,7 @@ export function PresentationTypeCard({
name="type"
value={value}
type="radio"
disabled={disabled}
/>
</div>
)

View File

@@ -12,6 +12,7 @@ import {
LemonDivider,
LemonInput,
LemonSelect,
LemonTag,
LemonTextArea,
Link,
Popover,
@@ -213,7 +214,7 @@ function SurveyCompletionConditions(): JSX.Element {
}}
</LemonField>
)}
<SurveyRepeatSchedule />
{survey.type !== SurveyType.ExternalSurvey && <SurveyRepeatSchedule />}
<SurveyResponsesCollection />
</div>
)
@@ -232,6 +233,7 @@ export default function SurveyEdit(): JSX.Element {
surveyRepeatedActivationAvailable,
deviceTypesMatchTypeValidationError,
surveyErrors,
isExternalSurveyFFEnabled,
} = useValues(surveyLogic)
const {
setSurveyValue,
@@ -338,6 +340,18 @@ export default function SurveyEdit(): JSX.Element {
Feedback
</button>
</PresentationTypeCard>
<PresentationTypeCard
active={value === SurveyType.ExternalSurvey}
onClick={() => onChange(SurveyType.ExternalSurvey)}
title="External survey"
description="Collect responses via an external link, hosted on PostHog. Make sure you update the posthog-js SDK if you are currently using surveys in your app."
value={SurveyType.ExternalSurvey}
disabled={!isExternalSurveyFFEnabled}
>
<LemonTag type="warning">
{isExternalSurveyFFEnabled ? 'BETA' : 'COMING SOON'}
</LemonTag>
</PresentationTypeCard>
</div>
{survey.type === SurveyType.Widget && <SurveyWidgetCustomization />}
</div>
@@ -660,420 +674,450 @@ export default function SurveyEdit(): JSX.Element {
},
]
: []),
{
key: SurveyEditSection.DisplayConditions,
header: 'Display conditions',
dataAttr: 'survey-display-conditions',
content: (
<LemonField.Pure>
<LemonSelect
onChange={(value) => {
if (value) {
resetTargeting()
} else {
// TRICKY: When attempting to set user match conditions
// we want a proxy value to be set so that the user
// can then edit these, or decide to go back to all user targeting
setSurveyValue('conditions', { url: '' })
}
}}
value={!hasTargetingSet}
options={[
{ label: 'All users', value: true },
{
label: 'Users who match all of the following...',
value: false,
'data-attr': 'survey-display-conditions-select-users',
},
]}
data-attr="survey-display-conditions-select"
/>
{!hasTargetingSet ? (
<span className="text-secondary">
Survey <b>will be released to everyone</b>
</span>
) : (
<>
<LemonField
name="linked_flag_id"
label="Link feature flag (optional)"
info={
<>
Connecting to a feature flag will automatically enable this
survey for everyone in the feature flag.
</>
}
>
{({ value, onChange }) => (
<div
className="flex"
data-attr="survey-display-conditions-linked-flag"
>
<FlagSelector value={value} onChange={onChange} />
{value && (
<LemonButton
className="ml-2"
icon={<IconCancel />}
size="small"
onClick={() => onChange(null)}
aria-label="close"
/>
)}
</div>
)}
</LemonField>
<LemonField name="conditions">
{({ value, onChange }) => (
<>
<LemonField.Pure
label="URL targeting"
error={urlMatchTypeValidationError}
info="Targeting by regex or exact match requires at least version 1.82 of posthog-js"
>
<div className="flex flex-row gap-2 items-center">
URL
<LemonSelect
value={
value?.urlMatchType || SurveyMatchType.Contains
}
onChange={(matchTypeVal) => {
onChange({
...value,
urlMatchType: matchTypeVal,
})
}}
data-attr="survey-url-matching-type"
options={Object.keys(SurveyMatchTypeLabels).map(
(key) => ({
label: SurveyMatchTypeLabels[key],
value: key,
})
)}
/>
<LemonInput
value={value?.url}
onChange={(urlVal) =>
onChange({ ...value, url: urlVal })
}
placeholder="ex: https://app.posthog.com"
fullWidth
/>
</div>
</LemonField.Pure>
<LemonField.Pure
label="Device Types"
error={deviceTypesMatchTypeValidationError}
info={
<>
Add the device types to show the survey on. Possible
values: 'Desktop', 'Mobile', 'Tablet'. For the full
list and caveats,{' '}
<Link to="https://posthog.com/docs/surveys/creating-surveys#display-conditions">
check the documentation here
</Link>
. Requires at least version 1.214 of posthog-js
</>
}
>
<div className="flex flex-row gap-2 items-center">
Device Types
<LemonSelect
value={
value?.deviceTypesMatchType ||
SurveyMatchType.Contains
}
onChange={(matchTypeVal) => {
onChange({
...value,
deviceTypesMatchType: matchTypeVal,
})
}}
data-attr="survey-device-types-matching-type"
options={Object.keys(SurveyMatchTypeLabels).map(
(key) => ({
label: SurveyMatchTypeLabels[key],
value: key,
})
)}
/>
{[
SurveyMatchType.Regex,
SurveyMatchType.NotRegex,
].includes(
value?.deviceTypesMatchType ||
SurveyMatchType.Contains
) ? (
<LemonInput
value={value?.deviceTypes?.join('|')}
onChange={(deviceTypesVal) =>
onChange({
...value,
deviceTypes: [deviceTypesVal],
})
}
// regex placeholder for device type
className="flex-1"
placeholder="ex: Desktop|Mobile"
/>
) : (
<PropertyValue
propertyKey={getPropertyKey(
'Device Type',
TaxonomicFilterGroupType.EventProperties
)}
type={PropertyFilterType.Event}
onSet={(deviceTypes: string | string[]) => {
onChange({
...value,
deviceTypes: Array.isArray(deviceTypes)
? deviceTypes
: [deviceTypes],
})
}}
operator={PropertyOperator.Exact}
value={value?.deviceTypes}
inputClassName="flex-1"
/>
)}
</div>
</LemonField.Pure>
<LemonField.Pure label="CSS selector matches:">
<LemonInput
value={value?.selector}
onChange={(selectorVal) =>
onChange({ ...value, selector: selectorVal })
}
placeholder="ex: .className or #id"
/>
</LemonField.Pure>
<LemonField.Pure
label="Survey wait period"
info="Note that this condition will only apply reliably for identified users within a single browser session. Anonymous users or users who switch browsers, use incognito sessions, or log out and log back in may see the survey again. Additionally, responses submitted while a user is anonymous may be associated with their account if they log in during the same session."
>
<div className="flex flex-row gap-2 items-center">
<LemonCheckbox
checked={!!value?.seenSurveyWaitPeriodInDays}
onChange={(checked) => {
if (checked) {
onChange({
...value,
seenSurveyWaitPeriodInDays:
value?.seenSurveyWaitPeriodInDays ||
30,
})
} else {
const {
seenSurveyWaitPeriodInDays,
...rest
} = value || {}
onChange(rest)
}
}}
/>
Don't show to users who saw any survey in the last
<LemonInput
type="number"
size="xsmall"
min={0}
value={value?.seenSurveyWaitPeriodInDays || NaN}
onChange={(val) => {
if (val !== undefined && val > 0) {
onChange({
...value,
seenSurveyWaitPeriodInDays: val,
})
} else {
onChange({
...value,
seenSurveyWaitPeriodInDays: null,
})
}
}}
className="w-12"
/>{' '}
{value?.seenSurveyWaitPeriodInDays === 1 ? (
<span>day.</span>
) : (
<span>days.</span>
)}
</div>
</LemonField.Pure>
</>
)}
</LemonField>
<LemonField.Pure label="Properties">
<BindLogic
logic={featureFlagLogic}
props={{ id: survey.targeting_flag?.id || 'new' }}
>
{!targetingFlagFilters && (
<LemonButton
type="secondary"
className="w-max"
onClick={() => {
setSurveyValue('targeting_flag_filters', {
groups: [
{
properties: [],
rollout_percentage: 100,
variant: null,
},
],
multivariate: null,
payloads: {},
})
setSurveyValue('remove_targeting_flag', false)
}}
>
Add property targeting
</LemonButton>
)}
{targetingFlagFilters && (
<>
<div className="mt-2">
<FeatureFlagReleaseConditions
id={String(survey.targeting_flag?.id) || 'new'}
excludeTitle={true}
filters={targetingFlagFilters}
onChange={(filters, errors) => {
setFlagPropertyErrors(errors)
setSurveyValue(
'targeting_flag_filters',
filters
)
}}
showTrashIconWithOneCondition
removedLastConditionCallback={
removeTargetingFlagFilters
}
/>
</div>
<LemonButton
type="secondary"
status="danger"
className="w-max"
onClick={removeTargetingFlagFilters}
>
Remove all property targeting
</LemonButton>
</>
)}
</BindLogic>
</LemonField.Pure>
<LemonField.Pure
label="User sends events"
info="It only triggers when the event is captured in the current user session and using the PostHog SDK."
>
<>
<EventSelect
filterGroupTypes={[
TaxonomicFilterGroupType.CustomEvents,
TaxonomicFilterGroupType.Events,
]}
allowNonCapturedEvents
onChange={(includedEvents) => {
setSurveyValue('conditions', {
...survey.conditions,
events: {
values: includedEvents.map((e) => {
return { name: e }
}),
},
})
}}
selectedEvents={
survey.conditions?.events?.values?.length != undefined &&
survey.conditions?.events?.values?.length > 0
? survey.conditions?.events?.values.map((v) => v.name)
: []
}
addElement={
<LemonButton
size="small"
type="secondary"
icon={<IconPlus />}
sideIcon={null}
>
Add event
</LemonButton>
}
/>
{surveyRepeatedActivationAvailable && (
<div className="flex flex-row gap-2 items-center">
Survey display frequency
<LemonSelect
onChange={(value) => {
setSurveyValue('conditions', {
...survey.conditions,
events: {
...survey.conditions?.events,
repeatedActivation: value,
},
})
}}
value={
survey.conditions?.events?.repeatedActivation ||
false
}
options={[
{
label: 'Just once',
value: false,
},
{
label: 'Every time any of the above events are captured',
value: true,
},
]}
/>
</div>
)}
</>
</LemonField.Pure>
{featureFlags[FEATURE_FLAGS.SURVEYS_ACTIONS] && (
<LemonField.Pure
label="User performs actions"
info="Note that these actions are only observed, and activate this survey, in the current user session."
>
<EventSelect
filterGroupTypes={[TaxonomicFilterGroupType.Actions]}
onItemChange={(items: ActionType[]) => {
setSurveyValue('conditions', {
...survey.conditions,
actions: {
values: items.map((e) => {
return { id: e.id, name: e.name }
}),
},
})
}}
selectedItems={
survey.conditions?.actions?.values &&
survey.conditions?.actions?.values.length > 0
? survey.conditions?.actions?.values
: []
}
selectedEvents={
survey.conditions?.actions?.values?.map((v) => v.name) ?? []
}
addElement={
<LemonButton
size="small"
type="secondary"
icon={<IconPlus />}
sideIcon={null}
>
Add action
</LemonButton>
}
/>
</LemonField.Pure>
)}
</>
)}
</LemonField.Pure>
),
},
...(survey.type !== SurveyType.ExternalSurvey
? [
{
key: SurveyEditSection.DisplayConditions,
header: 'Display conditions',
dataAttr: 'survey-display-conditions',
content: (
<LemonField.Pure>
<LemonSelect
onChange={(value) => {
if (value) {
resetTargeting()
} else {
// TRICKY: When attempting to set user match conditions
// we want a proxy value to be set so that the user
// can then edit these, or decide to go back to all user targeting
setSurveyValue('conditions', { url: '' })
}
}}
value={!hasTargetingSet}
options={[
{ label: 'All users', value: true },
{
label: 'Users who match all of the following...',
value: false,
'data-attr': 'survey-display-conditions-select-users',
},
]}
data-attr="survey-display-conditions-select"
/>
{!hasTargetingSet ? (
<span className="text-secondary">
Survey <b>will be released to everyone</b>
</span>
) : (
<>
<LemonField
name="linked_flag_id"
label="Link feature flag (optional)"
info={
<>
Connecting to a feature flag will automatically enable
this survey for everyone in the feature flag.
</>
}
>
{({ value, onChange }) => (
<div
className="flex"
data-attr="survey-display-conditions-linked-flag"
>
<FlagSelector value={value} onChange={onChange} />
{value && (
<LemonButton
className="ml-2"
icon={<IconCancel />}
size="small"
onClick={() => onChange(null)}
aria-label="close"
/>
)}
</div>
)}
</LemonField>
<LemonField name="conditions">
{({ value, onChange }) => (
<>
<LemonField.Pure
label="URL targeting"
error={urlMatchTypeValidationError}
info="Targeting by regex or exact match requires at least version 1.82 of posthog-js"
>
<div className="flex flex-row gap-2 items-center">
URL
<LemonSelect
value={
value?.urlMatchType ||
SurveyMatchType.Contains
}
onChange={(matchTypeVal) => {
onChange({
...value,
urlMatchType: matchTypeVal,
})
}}
data-attr="survey-url-matching-type"
options={Object.keys(
SurveyMatchTypeLabels
).map((key) => ({
label: SurveyMatchTypeLabels[key],
value: key,
}))}
/>
<LemonInput
value={value?.url}
onChange={(urlVal) =>
onChange({ ...value, url: urlVal })
}
placeholder="ex: https://app.posthog.com"
fullWidth
/>
</div>
</LemonField.Pure>
<LemonField.Pure
label="Device Types"
error={deviceTypesMatchTypeValidationError}
info={
<>
Add the device types to show the survey
on. Possible values: 'Desktop', 'Mobile',
'Tablet'. For the full list and caveats,{' '}
<Link to="https://posthog.com/docs/surveys/creating-surveys#display-conditions">
check the documentation here
</Link>
. Requires at least version 1.214 of
posthog-js
</>
}
>
<div className="flex flex-row gap-2 items-center">
Device Types
<LemonSelect
value={
value?.deviceTypesMatchType ||
SurveyMatchType.Contains
}
onChange={(matchTypeVal) => {
onChange({
...value,
deviceTypesMatchType:
matchTypeVal,
})
}}
data-attr="survey-device-types-matching-type"
options={Object.keys(
SurveyMatchTypeLabels
).map((key) => ({
label: SurveyMatchTypeLabels[key],
value: key,
}))}
/>
{[
SurveyMatchType.Regex,
SurveyMatchType.NotRegex,
].includes(
value?.deviceTypesMatchType ||
SurveyMatchType.Contains
) ? (
<LemonInput
value={value?.deviceTypes?.join('|')}
onChange={(deviceTypesVal) =>
onChange({
...value,
deviceTypes: [deviceTypesVal],
})
}
// regex placeholder for device type
className="flex-1"
placeholder="ex: Desktop|Mobile"
/>
) : (
<PropertyValue
propertyKey={getPropertyKey(
'Device Type',
TaxonomicFilterGroupType.EventProperties
)}
type={PropertyFilterType.Event}
onSet={(
deviceTypes: string | string[]
) => {
onChange({
...value,
deviceTypes: Array.isArray(
deviceTypes
)
? deviceTypes
: [deviceTypes],
})
}}
operator={PropertyOperator.Exact}
value={value?.deviceTypes}
inputClassName="flex-1"
/>
)}
</div>
</LemonField.Pure>
<LemonField.Pure label="CSS selector matches:">
<LemonInput
value={value?.selector}
onChange={(selectorVal) =>
onChange({
...value,
selector: selectorVal,
})
}
placeholder="ex: .className or #id"
/>
</LemonField.Pure>
<LemonField.Pure
label="Survey wait period"
info="Note that this condition will only apply reliably for identified users within a single browser session. Anonymous users or users who switch browsers, use incognito sessions, or log out and log back in may see the survey again. Additionally, responses submitted while a user is anonymous may be associated with their account if they log in during the same session."
>
<div className="flex flex-row gap-2 items-center">
<LemonCheckbox
checked={
!!value?.seenSurveyWaitPeriodInDays
}
onChange={(checked) => {
if (checked) {
onChange({
...value,
seenSurveyWaitPeriodInDays:
value?.seenSurveyWaitPeriodInDays ||
30,
})
} else {
const {
seenSurveyWaitPeriodInDays,
...rest
} = value || {}
onChange(rest)
}
}}
/>
Don't show to users who saw any survey in the
last
<LemonInput
type="number"
size="xsmall"
min={0}
value={
value?.seenSurveyWaitPeriodInDays ||
NaN
}
onChange={(val) => {
if (val !== undefined && val > 0) {
onChange({
...value,
seenSurveyWaitPeriodInDays:
val,
})
} else {
onChange({
...value,
seenSurveyWaitPeriodInDays:
null,
})
}
}}
className="w-12"
/>{' '}
{value?.seenSurveyWaitPeriodInDays === 1 ? (
<span>day.</span>
) : (
<span>days.</span>
)}
</div>
</LemonField.Pure>
</>
)}
</LemonField>
<LemonField.Pure label="Properties">
<BindLogic
logic={featureFlagLogic}
props={{ id: survey.targeting_flag?.id || 'new' }}
>
{!targetingFlagFilters && (
<LemonButton
type="secondary"
className="w-max"
onClick={() => {
setSurveyValue('targeting_flag_filters', {
groups: [
{
properties: [],
rollout_percentage: 100,
variant: null,
},
],
multivariate: null,
payloads: {},
})
setSurveyValue('remove_targeting_flag', false)
}}
>
Add property targeting
</LemonButton>
)}
{targetingFlagFilters && (
<>
<div className="mt-2">
<FeatureFlagReleaseConditions
id={
String(survey.targeting_flag?.id) ||
'new'
}
excludeTitle={true}
filters={targetingFlagFilters}
onChange={(filters, errors) => {
setFlagPropertyErrors(errors)
setSurveyValue(
'targeting_flag_filters',
filters
)
}}
showTrashIconWithOneCondition
removedLastConditionCallback={
removeTargetingFlagFilters
}
/>
</div>
<LemonButton
type="secondary"
status="danger"
className="w-max"
onClick={removeTargetingFlagFilters}
>
Remove all property targeting
</LemonButton>
</>
)}
</BindLogic>
</LemonField.Pure>
<LemonField.Pure
label="User sends events"
info="It only triggers when the event is captured in the current user session and using the PostHog SDK."
>
<>
<EventSelect
filterGroupTypes={[
TaxonomicFilterGroupType.CustomEvents,
TaxonomicFilterGroupType.Events,
]}
allowNonCapturedEvents
onChange={(includedEvents) => {
setSurveyValue('conditions', {
...survey.conditions,
events: {
values: includedEvents.map((e) => {
return { name: e }
}),
},
})
}}
selectedEvents={
survey.conditions?.events?.values?.length !=
undefined &&
survey.conditions?.events?.values?.length > 0
? survey.conditions?.events?.values.map(
(v) => v.name
)
: []
}
addElement={
<LemonButton
size="small"
type="secondary"
icon={<IconPlus />}
sideIcon={null}
>
Add event
</LemonButton>
}
/>
{surveyRepeatedActivationAvailable && (
<div className="flex flex-row gap-2 items-center">
Survey display frequency
<LemonSelect
onChange={(value) => {
setSurveyValue('conditions', {
...survey.conditions,
events: {
...survey.conditions?.events,
repeatedActivation: value,
},
})
}}
value={
survey.conditions?.events
?.repeatedActivation || false
}
options={[
{
label: 'Just once',
value: false,
},
{
label: 'Every time any of the above events are captured',
value: true,
},
]}
/>
</div>
)}
</>
</LemonField.Pure>
{featureFlags[FEATURE_FLAGS.SURVEYS_ACTIONS] && (
<LemonField.Pure
label="User performs actions"
info="Note that these actions are only observed, and activate this survey, in the current user session."
>
<EventSelect
filterGroupTypes={[TaxonomicFilterGroupType.Actions]}
onItemChange={(items: ActionType[]) => {
setSurveyValue('conditions', {
...survey.conditions,
actions: {
values: items.map((e) => {
return { id: e.id, name: e.name }
}),
},
})
}}
selectedItems={
survey.conditions?.actions?.values &&
survey.conditions?.actions?.values.length > 0
? survey.conditions?.actions?.values
: []
}
selectedEvents={
survey.conditions?.actions?.values?.map(
(v) => v.name
) ?? []
}
addElement={
<LemonButton
size="small"
type="secondary"
icon={<IconPlus />}
sideIcon={null}
>
Add action
</LemonButton>
}
/>
</LemonField.Pure>
)}
</>
)}
</LemonField.Pure>
),
},
]
: []),
{
key: SurveyEditSection.CompletionConditions,
header: 'Completion conditions',

View File

@@ -1,9 +1,12 @@
import { LemonDivider, Link } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { TZLabel } from 'lib/components/TZLabel'
import { FEATURE_FLAGS } from 'lib/constants'
import { IconAreaChart, IconComment, IconGridView, IconLink, IconListView } from 'lib/lemon-ui/icons'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { pluralize } from 'lib/utils'
import { SURVEY_TYPE_LABEL_MAP, SurveyQuestionLabel } from 'scenes/surveys/constants'
import { CopySurveyLink } from 'scenes/surveys/CopySurveyLink'
import { SurveyDisplaySummary } from 'scenes/surveys/Survey'
import { SurveyAPIEditor } from 'scenes/surveys/SurveyAPIEditor'
import { SurveyFormAppearance } from 'scenes/surveys/SurveyFormAppearance'
@@ -51,6 +54,8 @@ const QuestionIconMap = {
export function SurveyOverview(): JSX.Element {
const { survey, selectedPageIndex, targetingFlagFilters } = useValues(surveyLogic)
const { setSelectedPageIndex } = useActions(surveyLogic)
const { featureFlags } = useValues(featureFlagLogic)
const { surveyUsesLimit, surveyUsesAdaptiveLimit } = useValues(surveyLogic)
return (
<div className="flex gap-4">
@@ -105,6 +110,14 @@ export function SurveyOverview(): JSX.Element {
<SurveyOption label="Partial responses">
{survey.enable_partial_responses ? 'Enabled' : 'Disabled'}
</SurveyOption>
{featureFlags[FEATURE_FLAGS.EXTERNAL_SURVEYS] && (
<SurveyOption label="Responses via external link">
<div className="flex flex-row items-center gap-2">
{survey.type === SurveyType.ExternalSurvey ? 'Enabled' : 'Disabled'}
{survey.type === SurveyType.ExternalSurvey && <CopySurveyLink surveyId={survey.id} />}
</div>
</SurveyOption>
)}
<LemonDivider />
<SurveyDisplaySummary id={survey.id} survey={survey} targetingFlagFilters={targetingFlagFilters} />
</dl>

View File

@@ -1,12 +1,13 @@
import { LemonLabel, LemonSkeleton, LemonSwitch } from '@posthog/lemon-ui'
import { LemonSkeleton, LemonSwitch } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { TZLabel } from 'lib/components/TZLabel'
import { humanFriendlyNumber, percentage, pluralize } from 'lib/utils'
import { memo } from 'react'
import { StackedBar, StackedBarSegment, StackedBarSkeleton } from 'scenes/surveys/components/StackedBar'
import { SurveyEventName, SurveyRates, SurveyStats } from '~/types'
import { SurveyEventName, SurveyRates, SurveyStats, SurveyType } from '~/types'
import { CopySurveyLink } from 'scenes/surveys/CopySurveyLink'
import { surveyLogic } from './surveyLogic'
interface StatCardProps {
@@ -161,22 +162,23 @@ function SurveyStatsContainer({ children }: { children: React.ReactNode }): JSX.
const { filterSurveyStatsByDistinctId, processedSurveyStats, survey } = useValues(surveyLogic)
const { setFilterSurveyStatsByDistinctId } = useActions(surveyLogic)
const isPubliclyShareable = survey.type === SurveyType.ExternalSurvey
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2 justify-between">
<h3 className="mb-0">Survey performance</h3>
{processedSurveyStats && processedSurveyStats[SurveyEventName.SHOWN].total_count > 0 && (
<div className="flex items-center gap-2">
<LemonLabel>
Count each person once
<LemonSwitch
checked={filterSurveyStatsByDistinctId}
onChange={(checked) => setFilterSurveyStatsByDistinctId(checked)}
tooltip="If enabled, each user will only be counted once, even if they have multiple responses."
/>
</LemonLabel>
</div>
)}
<div className="flex items-center gap-2">
{isPubliclyShareable && <CopySurveyLink surveyId={survey.id} />}
{processedSurveyStats && processedSurveyStats[SurveyEventName.SHOWN].total_count > 0 && (
<LemonSwitch
checked={filterSurveyStatsByDistinctId}
onChange={(checked) => setFilterSurveyStatsByDistinctId(checked)}
tooltip="If enabled, each user will only be counted once, even if they have multiple responses."
label="Count each person once"
/>
)}
</div>
</div>
{survey.start_date && (
<div className="flex items-center text-sm text-secondary">

View File

@@ -401,7 +401,7 @@ export const SURVEY_TYPE_LABEL_MAP = {
[SurveyType.Widget]: 'Feedback Button',
[SurveyType.Popover]: 'Popover',
[SurveyType.FullScreen]: 'Full Screen',
[SurveyType.Email]: 'Email',
[SurveyType.ExternalSurvey]: 'External Survey',
}
export const LOADING_SURVEY_RESULTS_TOAST_ID = 'survey-results-loading'

View File

@@ -148,7 +148,7 @@ export function SurveyAppearanceModal({
const { setIsAppearanceModalOpen } = useActions(surveysLogic)
const { surveysStylingAvailable, isAppearanceModalOpen } = useValues(surveysLogic)
if (survey.type === SurveyType.API) {
if (survey.type === SurveyType.API || survey.type === SurveyType.ExternalSurvey) {
return null
}

View File

@@ -88,7 +88,7 @@ export function SurveyContainerAppearance({
onAppearanceChange,
validationErrors,
surveyType,
}: CommonProps): JSX.Element {
}: CommonProps): JSX.Element | null {
const { surveysStylingAvailable } = useValues(surveysLogic)
return (

View File

@@ -11,7 +11,7 @@ import {
} from 'scenes/surveys/survey-appearance/SurveyAppearanceSections'
import { CustomizationProps } from 'scenes/surveys/survey-appearance/types'
import { AvailableFeature } from '~/types'
import { AvailableFeature, SurveyType } from '~/types'
import { surveysLogic } from '../surveysLogic'
@@ -48,13 +48,17 @@ export function Customization({
hasRatingButtons={hasRatingButtons}
validationErrors={validationErrors}
/>
<SurveyContainerAppearance
appearance={surveyAppearance}
onAppearanceChange={onAppearanceChange}
validationErrors={validationErrors}
surveyType={survey.type}
/>
<LemonDivider />
{survey.type !== SurveyType.ExternalSurvey && (
<>
<SurveyContainerAppearance
appearance={surveyAppearance}
onAppearanceChange={onAppearanceChange}
validationErrors={validationErrors}
surveyType={survey.type}
/>
<LemonDivider />
</>
)}
<SurveyColorsAppearance
appearance={surveyAppearance}
onAppearanceChange={onAppearanceChange}
@@ -118,35 +122,37 @@ export function Customization({
checked={survey.appearance?.shuffleQuestions}
/>
</div>
<LemonField.Pure>
<div className="flex flex-row gap-2 items-center font-medium">
<LemonCheckbox
checked={!!survey.appearance?.surveyPopupDelaySeconds}
onChange={(checked) => {
const surveyPopupDelaySeconds = checked ? 5 : undefined
onAppearanceChange({ surveyPopupDelaySeconds })
}}
/>
Delay survey popup by at least{' '}
<LemonInput
type="number"
data-attr="survey-popup-delay-input"
size="small"
min={1}
max={3600}
value={survey.appearance?.surveyPopupDelaySeconds || NaN}
onChange={(newValue) => {
if (newValue && newValue > 0) {
onAppearanceChange({ surveyPopupDelaySeconds: newValue })
} else {
onAppearanceChange({ surveyPopupDelaySeconds: undefined })
}
}}
className="w-12 ignore-error-border"
/>{' '}
seconds once the display conditions are met.
</div>
</LemonField.Pure>
{survey.type !== SurveyType.ExternalSurvey && (
<LemonField.Pure>
<div className="flex flex-row gap-2 items-center font-medium">
<LemonCheckbox
checked={!!survey.appearance?.surveyPopupDelaySeconds}
onChange={(checked) => {
const surveyPopupDelaySeconds = checked ? 5 : undefined
onAppearanceChange({ surveyPopupDelaySeconds })
}}
/>
Delay survey popup by at least{' '}
<LemonInput
type="number"
data-attr="survey-popup-delay-input"
size="small"
min={1}
max={3600}
value={survey.appearance?.surveyPopupDelaySeconds || NaN}
onChange={(newValue) => {
if (newValue && newValue > 0) {
onAppearanceChange({ surveyPopupDelaySeconds: newValue })
} else {
onAppearanceChange({ surveyPopupDelaySeconds: undefined })
}
}}
className="w-12 ignore-error-border"
/>{' '}
seconds once the display conditions are met.
</div>
</LemonField.Pure>
)}
</div>
</div>
</>

View File

@@ -1283,6 +1283,12 @@ export const surveyLogic = kea<surveyLogicType>([
)`
},
],
isExternalSurveyFFEnabled: [
(s) => [s.enabledFlags],
(enabledFlags: FeatureFlagsSet): boolean => {
return !!enabledFlags[FEATURE_FLAGS.EXTERNAL_SURVEYS]
},
],
isAdaptiveLimitFFEnabled: [
(s) => [s.enabledFlags],
(enabledFlags: FeatureFlagsSet): boolean => {

View File

@@ -3079,7 +3079,6 @@ export interface Survey {
response_sampling_limit?: number | null
response_sampling_daily_limits?: string[] | null
enable_partial_responses?: boolean | null
is_publicly_shareable?: boolean | null
_create_in_folder?: string | null
}
@@ -3096,8 +3095,8 @@ export enum SurveyType {
Popover = 'popover',
Widget = 'widget', // feedback button survey
FullScreen = 'full_screen',
Email = 'email',
API = 'api',
ExternalSurvey = 'external_survey',
}
export enum SurveyPosition {

View File

@@ -4,20 +4,24 @@ from datetime import datetime, timedelta, UTC
import re
from typing import Any, cast, TypedDict
from urllib.parse import urlparse
import json
import nh3
import posthoganalytics
from posthoganalytics import capture_exception
from django.conf import settings
from django.core.cache import cache
from django.db.models import Min
from django.http import HttpResponse, JsonResponse
from django.utils.text import slugify
from django.views.decorators.csrf import csrf_exempt
from axes.decorators import axes_dispatch
from loginas.utils import is_impersonated_session
from nanoid import generate
from rest_framework import request, serializers, status, viewsets, exceptions, filters
from rest_framework.request import Request
from rest_framework.response import Response
from django.shortcuts import render
from ee.surveys.summaries.summarize_surveys import summarize_survey_responses
from posthog.api.action import ActionSerializer, ActionStepJSONSerializer
@@ -53,6 +57,9 @@ from posthog.models.surveys.util import (
SurveyEventName,
SurveyEventProperties,
)
import structlog
from posthog.models.utils import UUIDT
ALLOWED_LINK_URL_SCHEMES = ["https", "mailto"]
EMAIL_REGEX = r"^mailto:[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
@@ -1364,6 +1371,116 @@ def surveys(request: Request):
return cors_response(request, JsonResponse(get_surveys_response(team)))
# Constants for better maintainability
logger = structlog.get_logger(__name__)
CACHE_TIMEOUT_SECONDS = 300
@csrf_exempt
@axes_dispatch
def public_survey_page(request, survey_id: str):
"""
Server-side rendered public survey page with security and performance optimizations
"""
if request.method == "OPTIONS":
return cors_response(request, HttpResponse(""))
# Input validation
if not UUIDT.is_valid_uuid(survey_id):
logger.warning("survey_page_invalid_id", survey_id=survey_id)
return render(
request,
"surveys/error.html",
{
"error_title": "Invalid request",
"error_message": "The requested survey is not available.",
},
status=400,
)
# Database query with minimal fields and timeout protection
try:
survey = (
Survey.objects.select_related("team")
.only("id", "name", "appearance", "archived", "type", "team__id", "team__api_token")
.get(id=survey_id)
)
except Survey.DoesNotExist:
logger.info("survey_page_not_found", survey_id=survey_id)
# Use generic error message to prevent survey ID enumeration
return render(
request,
"surveys/error.html",
{
"error_title": "Survey not available",
"error_message": "The requested survey is not available.",
},
status=404,
)
except Exception as e:
logger.exception("survey_page_db_error", error=str(e), survey_id=survey_id)
capture_exception(e)
return render(
request,
"surveys/error.html",
{
"error_title": "Service unavailable",
"error_message": "The service is temporarily unavailable. Please try again later.",
},
status=503,
)
survey_is_running = (
survey.start_date is not None and survey.start_date <= datetime.now(UTC) and survey.end_date is None
)
# Check survey availability (combine checks for consistent error message)
if survey.archived or survey.type != Survey.SurveyType.EXTERNAL_SURVEY or not survey_is_running:
logger.info(
"survey_page_access_denied",
survey_id=survey_id,
archived=survey.archived,
survey_type=survey.type,
)
return render(
request,
"surveys/error.html",
{
"error_title": "Survey not receiving responses",
"error_message": "The requested survey is not receiving responses.",
},
status=404, # Use 404 instead of 403 to prevent information leakage
)
# Build project config
project_config = {
"api_host": request.build_absolute_uri("/").rstrip("/"),
"token": survey.team.api_token,
}
if hasattr(survey.team, "ui_host") and survey.team.ui_host:
project_config["ui_host"] = survey.team.ui_host
context = {
"name": survey.name,
"id": survey.id,
"appearance": json.dumps(survey.appearance),
"project_config_json": json.dumps(project_config),
"debug": settings.DEBUG,
}
logger.info("survey_page_rendered", survey_id=survey_id, team_id=survey.team.id)
response = render(request, "surveys/public_survey.html", context)
response["X-Frame-Options"] = "DENY" # Override global SAMEORIGIN to prevent iframe embedding
# Cache headers
response["Cache-Control"] = f"public, max-age={CACHE_TIMEOUT_SECONDS}"
response["Vary"] = "Accept-Encoding" # Enable compression caching
return response
@contextmanager
def create_flag_with_survey_errors():
# context manager to raise error with a different message when flag creation fails

View File

@@ -732,7 +732,6 @@
"posthog_survey"."current_iteration_start_date",
"posthog_survey"."schedule",
"posthog_survey"."enable_partial_responses",
"posthog_survey"."is_publicly_shareable",
"posthog_featureflag"."id",
"posthog_featureflag"."key",
"posthog_featureflag"."name",
@@ -5498,7 +5497,6 @@
"posthog_survey"."current_iteration_start_date",
"posthog_survey"."schedule",
"posthog_survey"."enable_partial_responses",
"posthog_survey"."is_publicly_shareable",
"posthog_featureflag"."id",
"posthog_featureflag"."key",
"posthog_featureflag"."name",
@@ -6206,7 +6204,6 @@
"posthog_survey"."current_iteration_start_date",
"posthog_survey"."schedule",
"posthog_survey"."enable_partial_responses",
"posthog_survey"."is_publicly_shareable",
"posthog_featureflag"."id",
"posthog_featureflag"."key",
"posthog_featureflag"."name",
@@ -6641,7 +6638,6 @@
"posthog_survey"."current_iteration_start_date",
"posthog_survey"."schedule",
"posthog_survey"."enable_partial_responses",
"posthog_survey"."is_publicly_shareable",
"posthog_featureflag"."id",
"posthog_featureflag"."key",
"posthog_featureflag"."name",

View File

@@ -2083,8 +2083,7 @@
"posthog_survey"."current_iteration",
"posthog_survey"."current_iteration_start_date",
"posthog_survey"."schedule",
"posthog_survey"."enable_partial_responses",
"posthog_survey"."is_publicly_shareable"
"posthog_survey"."enable_partial_responses"
FROM "posthog_survey"
WHERE "posthog_survey"."internal_response_sampling_flag_id" = 99999
'''
@@ -2121,8 +2120,7 @@
"posthog_survey"."current_iteration",
"posthog_survey"."current_iteration_start_date",
"posthog_survey"."schedule",
"posthog_survey"."enable_partial_responses",
"posthog_survey"."is_publicly_shareable"
"posthog_survey"."enable_partial_responses"
FROM "posthog_survey"
WHERE "posthog_survey"."internal_response_sampling_flag_id" = 99999
'''
@@ -2299,8 +2297,7 @@
"posthog_survey"."current_iteration",
"posthog_survey"."current_iteration_start_date",
"posthog_survey"."schedule",
"posthog_survey"."enable_partial_responses",
"posthog_survey"."is_publicly_shareable"
"posthog_survey"."enable_partial_responses"
FROM "posthog_survey"
WHERE "posthog_survey"."linked_flag_id" = 99999
'''

View File

@@ -1728,7 +1728,6 @@
"posthog_survey"."current_iteration_start_date",
"posthog_survey"."schedule",
"posthog_survey"."enable_partial_responses",
"posthog_survey"."is_publicly_shareable",
"posthog_featureflag"."id",
"posthog_featureflag"."key",
"posthog_featureflag"."name",

View File

@@ -0,0 +1,249 @@
import json
import uuid
from datetime import datetime, timedelta, UTC
from unittest.mock import patch
from django.test import TestCase
from django.core.cache import cache
from posthog.models import Survey
from posthog.test.base import APIBaseTest
class TestExternalSurveys(APIBaseTest):
"""
Test suite for external survey functionality including the public_survey_page view.
Focuses on security, performance, and proper survey rendering.
"""
def setUp(self):
super().setUp()
cache.clear()
def create_external_survey(self, **kwargs):
"""Helper method to create external surveys for testing"""
# Generate unique name to avoid constraint violations
unique_name = kwargs.get("name", f"Test External Survey {uuid.uuid4().hex[:8]}")
default_data = {
"team": self.team,
"name": unique_name,
"type": Survey.SurveyType.EXTERNAL_SURVEY,
"questions": [
{
"id": str(uuid.uuid4()),
"type": "open",
"question": "What do you think of our product?",
}
],
"appearance": {
"backgroundColor": "#1d4ed8",
"submitButtonColor": "#2563eb",
},
"start_date": datetime.now(UTC) - timedelta(days=1),
"end_date": None,
"archived": False,
}
default_data.update(kwargs)
return Survey.objects.create(**default_data)
# SECURITY TESTS
def test_valid_survey_id_required(self):
"""Test that invalid survey IDs are rejected"""
# Invalid UUID format
response = self.client.get(f"/external_surveys/invalid-id/")
self.assertEqual(response.status_code, 400)
self.assertContains(response, "Invalid request", status_code=400)
# Valid UUID format but non-existent survey
fake_uuid = str(uuid.uuid4())
response = self.client.get(f"/external_surveys/{fake_uuid}/")
self.assertEqual(response.status_code, 404)
self.assertContains(response, "Survey not available", status_code=404)
def test_only_external_surveys_accessible(self):
"""Test that only external survey types can be accessed via public URL"""
# Create non-external survey
popover_survey = self.create_external_survey(type=Survey.SurveyType.POPOVER, name="Popover Survey")
response = self.client.get(f"/external_surveys/{popover_survey.id}/")
self.assertEqual(response.status_code, 404)
self.assertContains(response, "Survey not receiving responses", status_code=404)
def test_archived_surveys_not_accessible(self):
"""Test that archived surveys return 404"""
survey = self.create_external_survey(archived=True)
response = self.client.get(f"/external_surveys/{survey.id}/")
self.assertEqual(response.status_code, 404)
self.assertContains(response, "Survey not receiving responses", status_code=404)
def test_survey_must_be_running(self):
"""Test survey availability based on start/end dates"""
# Survey not started yet
future_survey = self.create_external_survey(start_date=datetime.now(UTC) + timedelta(days=1))
response = self.client.get(f"/external_surveys/{future_survey.id}/")
self.assertEqual(response.status_code, 404)
# Survey ended
ended_survey = self.create_external_survey(
start_date=datetime.now(UTC) - timedelta(days=2), end_date=datetime.now(UTC) - timedelta(days=1)
)
response = self.client.get(f"/external_surveys/{ended_survey.id}/")
self.assertEqual(response.status_code, 404)
# Survey never started
never_started_survey = self.create_external_survey(start_date=None)
response = self.client.get(f"/external_surveys/{never_started_survey.id}/")
self.assertEqual(response.status_code, 404)
def test_security_headers_present(self):
"""Test that proper security headers are set"""
survey = self.create_external_survey()
response = self.client.get(f"/external_surveys/{survey.id}/")
self.assertEqual(response.status_code, 200)
# Check security headers
self.assertEqual(response["X-Frame-Options"], "DENY")
self.assertIn("Cache-Control", response)
self.assertIn("Vary", response)
def test_no_sensitive_data_exposed(self):
"""Test that sensitive survey data is not exposed in the template"""
survey = self.create_external_survey(description="SENSITIVE: Internal team feedback for Q4 planning")
response = self.client.get(f"/external_surveys/{survey.id}/")
self.assertEqual(response.status_code, 200)
# Description should not be in the response
self.assertNotContains(response, "SENSITIVE")
self.assertNotContains(response, "Internal team feedback")
# FUNCTIONALITY TESTS
def test_successful_survey_rendering(self):
"""Test that a valid external survey renders correctly"""
survey = self.create_external_survey()
response = self.client.get(f"/external_surveys/{survey.id}/")
self.assertEqual(response.status_code, 200)
# Check that essential elements are present
self.assertContains(response, survey.name)
self.assertContains(response, str(survey.id))
self.assertContains(response, "posthog-survey-container")
# Check PostHog configuration is injected
self.assertContains(response, "window.projectConfig")
self.assertContains(response, survey.team.api_token)
def test_survey_appearance_configuration(self):
"""Test that survey appearance settings are properly injected"""
survey = self.create_external_survey(
appearance={"backgroundColor": "#ff0000", "submitButtonColor": "#00ff00", "borderRadius": "12px"}
)
response = self.client.get(f"/external_surveys/{survey.id}/")
self.assertEqual(response.status_code, 200)
# Check appearance data is injected
self.assertContains(response, "window.surveyAppearance")
self.assertContains(response, "#ff0000")
self.assertContains(response, "#00ff00")
def test_project_config_injection(self):
"""Test that project configuration is properly injected"""
survey = self.create_external_survey()
response = self.client.get(f"/external_surveys/{survey.id}/")
self.assertEqual(response.status_code, 200)
# Verify project config contains required fields
content = response.content.decode()
self.assertIn("window.projectConfig", content)
self.assertIn(survey.team.api_token, content)
# Extract and validate project config JSON
import re
config_match = re.search(r"window\.projectConfig = ({.*?});", content)
self.assertIsNotNone(config_match)
assert config_match is not None # Type guard for mypy
project_config = json.loads(config_match.group(1))
self.assertIn("api_host", project_config)
self.assertIn("token", project_config)
# PERFORMANCE & CACHING TESTS
def test_caching_headers(self):
"""Test that appropriate caching headers are set"""
survey = self.create_external_survey()
response = self.client.get(f"/external_surveys/{survey.id}/")
self.assertEqual(response.status_code, 200)
cache_control = response.get("Cache-Control", "")
self.assertIn("public", cache_control)
self.assertIn("max-age=300", cache_control) # 5 minutes as per CACHE_TIMEOUT_SECONDS
# ERROR HANDLING TESTS
@patch("posthog.api.survey.logger")
def test_database_error_handling(self, mock_logger):
"""Test proper error handling for database errors"""
with patch("posthog.models.surveys.survey.Survey.objects.select_related") as mock_select:
mock_select.side_effect = Exception("Database connection error")
fake_uuid = str(uuid.uuid4())
response = self.client.get(f"/external_surveys/{fake_uuid}/")
self.assertEqual(response.status_code, 503)
self.assertContains(response, "Service unavailable", status_code=503)
mock_logger.exception.assert_called_once()
@patch("posthog.api.survey.capture_exception")
def test_exception_reporting(self, mock_capture):
"""Test that exceptions are properly reported to error tracking"""
with patch("posthog.models.surveys.survey.Survey.objects.select_related") as mock_select:
test_exception = Exception("Test error")
mock_select.side_effect = test_exception
fake_uuid = str(uuid.uuid4())
self.client.get(f"/external_surveys/{fake_uuid}/")
mock_capture.assert_called_once_with(test_exception)
# INTEGRATION TESTS
def test_cors_options_request(self):
"""Test that OPTIONS requests are handled for CORS"""
survey = self.create_external_survey()
response = self.client.options(f"/external_surveys/{survey.id}/")
self.assertEqual(response.status_code, 200)
def test_csrf_exemption(self):
"""Test that the view is properly exempt from CSRF protection"""
survey = self.create_external_survey()
# This should work without CSRF token
response = self.client.get(f"/external_surveys/{survey.id}/")
self.assertEqual(response.status_code, 200)
class TestExternalSurveysURLs(TestCase):
"""Test URL routing for external surveys"""
def test_survey_url_pattern(self):
"""Test that survey URLs are properly routed"""
from django.test import Client
survey_id = str(uuid.uuid4())
client = Client()
# Test that the URL pattern matches correctly (should return 404 for non-existent survey)
response = client.get(f"/external_surveys/{survey_id}/")
# We expect 404 since survey doesn't exist, but URL should be routed correctly
self.assertEqual(response.status_code, 404)

View File

@@ -0,0 +1,25 @@
# Generated by Django 4.2.22 on 2025-07-25 13:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("posthog", "0804_add_rich_content_json_to_comment"),
]
operations = [
migrations.AlterField(
model_name="survey",
name="type",
field=models.CharField(
choices=[
("popover", "popover"),
("widget", "widget"),
("external_survey", "external survey"),
("api", "api"),
],
max_length=40,
),
),
]

View File

@@ -1 +1 @@
0804_add_rich_content_json_to_comment
0805_alter_survey_type

View File

@@ -14,6 +14,7 @@ from posthog.models.file_system.file_system_representation import FileSystemRepr
from dateutil.rrule import rrule, DAILY
from django.db.models.signals import pre_save
from django.dispatch import receiver
from django_deprecate_fields import deprecate_field
# we have seen users accidentally set a huge value for iteration count
# and cause performance issues, so we are extra careful with this value
@@ -28,9 +29,7 @@ class Survey(FileSystemSyncMixin, RootTeamMixin, UUIDModel):
class SurveyType(models.TextChoices):
POPOVER = "popover", "popover"
WIDGET = "widget", "widget"
BUTTON = "button", "button"
EMAIL = "email", "email"
FULL_SCREEN = "full_screen", "full screen"
EXTERNAL_SURVEY = "external_survey", "external survey"
API = "api", "api"
class SurveySamplingIntervalType(models.TextChoices):
@@ -222,10 +221,13 @@ class Survey(FileSystemSyncMixin, RootTeamMixin, UUIDModel):
blank=True,
)
enable_partial_responses = models.BooleanField(default=False, null=True)
is_publicly_shareable = models.BooleanField(
null=True,
blank=True,
help_text="Allow this survey to be accessed via public URL (https://app.posthog.com/surveys/[survey_id]) without authentication",
# Use the survey_type instead. If it's external_survey, it's publicly shareable.
is_publicly_shareable = deprecate_field(
models.BooleanField(
null=True,
blank=True,
help_text="Allow this survey to be accessed via public URL (https://app.posthog.com/surveys/[survey_id]) without authentication",
),
)
actions = models.ManyToManyField(Action)

View File

@@ -2397,8 +2397,8 @@ class SurveyType(StrEnum):
POPOVER = "popover"
WIDGET = "widget"
FULL_SCREEN = "full_screen"
EMAIL = "email"
API = "api"
EXTERNAL_SURVEY = "external_survey"
class SurveyWidgetType(StrEnum):

View File

@@ -750,8 +750,7 @@
"posthog_survey"."current_iteration",
"posthog_survey"."current_iteration_start_date",
"posthog_survey"."schedule",
"posthog_survey"."enable_partial_responses",
"posthog_survey"."is_publicly_shareable"
"posthog_survey"."enable_partial_responses"
FROM "posthog_survey"
WHERE "posthog_survey"."internal_response_sampling_flag_id" = 99999
'''
@@ -788,8 +787,7 @@
"posthog_survey"."current_iteration",
"posthog_survey"."current_iteration_start_date",
"posthog_survey"."schedule",
"posthog_survey"."enable_partial_responses",
"posthog_survey"."is_publicly_shareable"
"posthog_survey"."enable_partial_responses"
FROM "posthog_survey"
WHERE "posthog_survey"."internal_response_sampling_flag_id" = 99999
'''
@@ -1797,8 +1795,7 @@
"posthog_survey"."current_iteration",
"posthog_survey"."current_iteration_start_date",
"posthog_survey"."schedule",
"posthog_survey"."enable_partial_responses",
"posthog_survey"."is_publicly_shareable"
"posthog_survey"."enable_partial_responses"
FROM "posthog_survey"
WHERE "posthog_survey"."internal_response_sampling_flag_id" = 99999
'''
@@ -1844,8 +1841,7 @@
"posthog_survey"."current_iteration",
"posthog_survey"."current_iteration_start_date",
"posthog_survey"."schedule",
"posthog_survey"."enable_partial_responses",
"posthog_survey"."is_publicly_shareable"
"posthog_survey"."enable_partial_responses"
FROM "posthog_survey"
WHERE "posthog_survey"."internal_response_sampling_flag_id" = 99999
'''

View File

@@ -0,0 +1,266 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ error_title }}</title>
<style>
:root {
--ph-brand-blue: #1D4AFF;
--ph-brand-orange: #F54E00;
--ph-brand-yellow: #F9BD2B;
--ph-brand-black: #000;
--ph-brand-white: #fff;
--ph-bg-primary: #fafaf9;
--ph-bg-surface-primary: #fff;
--ph-text-primary: #1d1f27;
--ph-text-secondary: #6b7280;
--ph-text-tertiary: #9ca3af;
--ph-border-primary: #e5e7eb;
--ph-shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
--ph-radius: 0.75rem;
--ph-radius-lg: 1rem;
--ph-font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', 'Roboto', 'Helvetica Neue', helvetica, arial, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--ph-bg-primary: #1d1f27;
--ph-bg-surface-primary: #2d2f3a;
--ph-text-primary: #fff;
--ph-text-secondary: #d1d5db;
--ph-text-tertiary: #9ca3af;
--ph-border-primary: #374151;
--ph-brand-black: #fff;
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--ph-font-family);
background: var(--ph-bg-primary);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
color: var(--ph-text-primary);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.error-container {
background: var(--ph-bg-surface-primary);
border: 1px solid var(--ph-border-primary);
border-radius: var(--ph-radius-lg);
box-shadow: var(--ph-shadow-lg);
max-width: 600px;
width: 100%;
padding: 3rem 2rem;
text-align: center;
position: relative;
overflow: hidden;
}
.error-container::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg,
var(--ph-brand-blue) 0%,
var(--ph-brand-orange) 50%,
var(--ph-brand-yellow) 100%);
}
.logo-container {
margin-bottom: 2rem;
}
.logo {
height: 40px;
width: auto;
}
.error-icon {
width: 80px;
height: 80px;
margin: 0 auto 1.5rem;
background: var(--ph-brand-orange);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.error-icon::before {
content: '!';
color: white;
font-size: 2rem;
font-weight: 700;
}
.error-title {
font-size: 1.75rem;
font-weight: 700;
color: var(--ph-text-primary);
margin-bottom: 1rem;
line-height: 1.2;
}
.error-message {
font-size: 1.125rem;
color: var(--ph-text-secondary);
margin-bottom: 2rem;
line-height: 1.5;
}
.error-actions {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1.5rem;
border-radius: var(--ph-radius);
font-weight: 600;
text-decoration: none;
transition: all 0.15s ease;
cursor: pointer;
font-size: 0.875rem;
min-width: 140px;
}
.btn-primary {
background: var(--ph-brand-orange);
color: white;
border: none;
}
.btn-primary:hover {
background: #e04400;
transform: translateY(-1px);
}
.btn-secondary {
background: transparent;
color: var(--ph-text-secondary);
border: 1px solid var(--ph-border-primary);
}
.btn-secondary:hover {
background: var(--ph-bg-primary);
color: var(--ph-text-primary);
}
.error-code {
position: absolute;
top: 1rem;
right: 1rem;
font-size: 0.75rem;
color: var(--ph-text-tertiary);
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
}
.decorative-dots {
position: absolute;
bottom: -20px;
right: -20px;
width: 120px;
height: 120px;
opacity: 0.05;
background-image: radial-gradient(var(--ph-brand-blue) 2px, transparent 2px);
background-size: 20px 20px;
border-radius: 50%;
}
@media (max-width: 640px) {
.error-container {
padding: 2rem 1.5rem;
margin: 1rem;
}
.error-title {
font-size: 1.5rem;
}
.error-message {
font-size: 1rem;
}
.logo {
height: 32px;
}
.error-icon {
width: 64px;
height: 64px;
}
.error-icon::before {
font-size: 1.5rem;
}
}
</style>
</head>
<body>
<div class="error-container">
<div class="error-code">ERROR</div>
<div class="decorative-dots"></div>
<div class="logo-container">
<svg class="logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 140">
<g fill-rule="nonzero" fill="none">
<path
d="M55.383 75.225c-1.874 3.748-7.222 3.748-9.096 0l-4.482-8.963a5.085 5.085 0 0 1 0-4.548l4.482-8.964c1.874-3.748 7.222-3.748 9.096 0l4.482 8.964a5.084 5.084 0 0 1 0 4.548l-4.482 8.963Zm0 50.836c-1.874 3.747-7.222 3.747-9.096 0l-4.482-8.964a5.085 5.085 0 0 1 0-4.548l4.482-8.964c1.874-3.748 7.222-3.748 9.096 0l4.482 8.964a5.084 5.084 0 0 1 0 4.548l-4.482 8.964Z"
fill="var(--ph-brand-blue)" />
<path
d="M0 106.765c0-4.53 5.477-6.8 8.68-3.596l23.307 23.307c3.204 3.204.935 8.68-3.595 8.68H5.085A5.085 5.085 0 0 1 0 130.073v-23.307Zm0-24.55c0 1.35.536 2.643 1.49 3.596l47.856 47.856a5.086 5.086 0 0 0 3.595 1.49h26.286c4.53 0 6.799-5.477 3.595-8.681L8.681 52.334C5.477 49.131 0 51.4 0 55.93v26.286Zm0-50.834c0 1.348.536 2.642 1.49 3.595l98.69 98.691a5.086 5.086 0 0 0 3.596 1.49h26.285c4.53 0 6.8-5.477 3.596-8.681L8.681 1.5C5.477-1.704 0 .565 0 5.095v26.286Zm50.835 0c0 1.348.536 2.642 1.49 3.595l91.5 91.5c3.203 3.204 8.68.935 8.68-3.596V96.595a5.086 5.086 0 0 0-1.49-3.596l-91.5-91.5c-3.203-3.203-8.68-.934-8.68 3.596v26.286ZM110.35 1.5c-3.203-3.204-8.68-.935-8.68 3.595v26.286c0 1.348.536 2.642 1.49 3.595l40.664 40.665c3.204 3.204 8.68.935 8.68-3.596V45.76a5.086 5.086 0 0 0-1.489-3.596L110.35 1.5Z"
fill="var(--ph-brand-yellow)" />
<path
d="m216.24 107.388-47.864-47.863c-3.204-3.204-8.681-.935-8.681 3.595v66.952a5.085 5.085 0 0 0 5.085 5.085h74.142a5.085 5.085 0 0 0 5.085-5.085v-6.097c0-2.809-2.286-5.052-5.07-5.414a39.27 39.27 0 0 1-22.698-11.173Zm-32.145 11.502a8.137 8.137 0 0 1-8.133-8.134 8.137 8.137 0 0 1 8.133-8.134 8.137 8.137 0 0 1 8.134 8.134 8.137 8.137 0 0 1-8.134 8.134Z"
fill="var(--ph-brand-black)" />
<path
d="M0 130.072a5.085 5.085 0 0 0 5.085 5.085h23.307c4.53 0 6.799-5.477 3.595-8.681L8.681 103.169C5.477 99.966 0 102.235 0 106.765v23.307Zm50.835-86.418L8.68 1.5C5.477-1.704 0 .565 0 5.095v26.286c0 1.348.536 2.642 1.49 3.595l49.345 49.346V43.654ZM8.68 52.334C5.477 49.131 0 51.4 0 55.93v26.286c0 1.348.536 2.642 1.49 3.595l49.345 49.346V94.489L8.68 52.334Z"
fill="var(--ph-brand-blue)" />
<path
d="M101.67 45.76a5.083 5.083 0 0 0-1.49-3.596L59.516 1.5c-3.204-3.204-8.681-.935-8.681 3.595v26.286c0 1.348.536 2.642 1.49 3.595l49.345 49.346V45.76Zm-50.835 89.397h28.392c4.53 0 6.799-5.477 3.595-8.681L50.835 94.489v40.668Zm0-91.503v38.562c0 1.348.536 2.642 1.49 3.595l49.345 49.346V96.595a5.084 5.084 0 0 0-1.49-3.596L50.835 43.654Z"
fill="var(--ph-brand-orange)" />
<path
d="M303.32 114.86h20.888V80.22h17.452c19.17 0 31.466-11.37 31.466-28.954 0-17.584-12.295-28.954-31.466-28.954h-38.34v92.547Zm20.888-52.488V40.16h15.337c7.932 0 12.692 4.23 12.692 11.105 0 6.876-4.76 11.106-12.692 11.106h-15.337Zm86.71 53.545c20.36 0 35.167-14.543 35.167-34.375 0-19.831-14.807-34.374-35.167-34.374-20.625 0-35.168 14.543-35.168 34.374 0 19.832 14.543 34.375 35.168 34.375Zm-15.866-34.375c0-10.577 6.346-17.848 15.866-17.848 9.386 0 15.733 7.271 15.733 17.848 0 10.577-6.347 17.849-15.733 17.849-9.52 0-15.866-7.272-15.866-17.849Zm84.462 34.375c15.601 0 26.178-9.784 26.178-21.286 0-26.97-35.829-18.245-35.829-28.822 0-2.908 3.04-4.759 7.404-4.759 4.495 0 9.916 2.776 11.634 8.858l15.601-6.479c-3.04-9.65-14.279-16.261-27.896-16.261-14.676 0-23.798 8.725-23.798 19.17 0 25.252 35.3 18.245 35.3 28.69 0 3.702-3.437 6.214-8.594 6.214-7.403 0-12.56-5.156-14.146-11.37l-15.601 6.081c3.438 10.048 13.486 19.964 29.747 19.964Zm76.43-1.718-1.321-16.791c-2.248 1.19-5.157 1.586-7.536 1.586-4.76 0-7.933-3.437-7.933-9.387V64.355h16.13v-16.13h-16.13V28.923h-19.435v19.302h-10.577v16.13h10.577v27.764c0 16.13 10.974 23.798 25.384 23.798 3.967 0 7.669-.66 10.842-1.718Zm67.764-91.887v35.961h-36.755v-35.96h-20.89v92.546h20.89V76.122h36.755v38.737h21.021V22.312h-21.021Zm67.386 93.605c20.36 0 35.168-14.543 35.168-34.375 0-19.831-14.807-34.374-35.168-34.374-20.625 0-35.168 14.543-35.168 34.374 0 19.832 14.543 34.375 35.168 34.375ZM675.23 81.542c0-10.577 6.346-17.848 15.865-17.848 9.387 0 15.733 7.271 15.733 17.848 0 10.577-6.346 17.849-15.733 17.849-9.519 0-15.865-7.272-15.865-17.849Zm88.545 31.202c7.272 0 13.75-2.512 17.188-6.875v6.346c0 7.404-5.95 12.56-15.072 12.56-6.479 0-12.164-3.173-13.09-8.594l-17.715 2.777c2.38 12.56 15.204 21.022 30.805 21.022 20.492 0 34.11-12.032 34.11-29.88V48.225h-19.17v5.685c-3.57-4.098-9.652-6.742-17.452-6.742-18.51 0-30.144 12.692-30.144 32.788 0 20.096 11.634 32.788 30.54 32.788ZM752.14 79.956c0-9.916 5.817-16.262 14.807-16.262 9.123 0 14.94 6.346 14.94 16.262s-5.817 16.262-14.94 16.262c-8.99 0-14.807-6.346-14.807-16.262Z"
fill="var(--ph-brand-black)" />
</g>
</svg>
</div>
<div class="error-icon"></div>
<h1 class="error-title">{{ error_title }}</h1>
<p class="error-message">{{ error_message }}</p>
<div class="error-actions">
<a href="javascript:history.back()" class="btn btn-primary">Go Back</a>
<a href="javascript:location.reload()" class="btn btn-secondary">Try Again</a>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,918 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ name }}</title>
<meta name="description" content="Take our survey: {{ name }}">
<style>
/* CSS Variables */
:root {
--ph-survey-font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', Helvetica, Arial, sans-serif;
--ph-survey-border-color: #e5e7eb;
--ph-survey-border-radius: 8px;
--ph-survey-card-border-radius: 16px;
--ph-survey-body-background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--ph-survey-header-background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--ph-survey-header-text-color: white;
--ph-survey-background-color: #ffffff;
--ph-survey-text-primary-color: #111827;
--ph-survey-text-subtle-color: #6b7280;
--ph-survey-input-background: #ffffff;
--ph-survey-submit-button-color: #2563eb;
--ph-survey-submit-button-text-color: white;
--ph-survey-rating-button-color: #f8fafc;
--ph-survey-rating-active-bg-color: #2563eb;
--ph-survey-rating-button-text-color: #374151;
--ph-survey-rating-button-active-text-color: white;
--ph-survey-disabled-button-opacity: 0.6;
--ph-survey-focus-ring: 0 0 0 3px rgba(37, 99, 235, 0.1);
--ph-survey-box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
* {
box-sizing: border-box;
}
/* Base Layout */
body {
font-family: var(--ph-survey-font-family);
margin: 0;
padding: 0;
background: var(--ph-survey-body-background);
min-height: 100vh;
line-height: 1.6;
color: var(--ph-survey-text-primary-color);
font-size: 16px;
-webkit-font-smoothing: antialiased;
}
.main-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
box-sizing: border-box;
}
.survey-card {
background: white;
border-radius: var(--ph-survey-card-border-radius);
box-shadow: var(--ph-survey-box-shadow);
max-width: 720px;
width: 100%;
overflow: hidden;
max-height: 90vh;
display: flex;
flex-direction: column;
animation: slideUp 0.6s ease-out;
}
/* Survey Header */
.survey-header {
background: var(--ph-survey-header-background);
color: var(--ph-survey-header-text-color);
padding: 1.75rem 2rem 1.5rem;
text-align: center;
flex-shrink: 0;
}
.survey-title {
font-size: 1.75rem;
font-weight: 700;
margin: 0;
}
.survey-description {
font-size: 1rem;
margin: 0;
opacity: 0.9;
line-height: 1.4;
}
/* Survey Content */
.survey-content {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.survey-box {
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
min-height: 0;
}
.bottom-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.survey-form,
.thank-you-message {
width: 100%;
margin: 0;
border: none;
border-radius: 0;
box-shadow: none;
background: white;
padding: 1.5rem 2rem;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 0;
}
/* Question Styling */
.survey-question {
font-size: 1.25rem;
font-weight: 600;
color: var(--ph-survey-text-primary-color);
line-height: 1.4;
margin: 0;
}
.survey-question-description {
font-size: 1rem;
color: var(--ph-survey-text-subtle-color);
margin-top: 0.25rem;
margin-bottom: 1.25rem;
}
.question-container,
.thank-you-message {
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
}
.response-choice {
display: flex;
gap: 8px;
align-items: center;
}
/* Input Styling */
textarea,
input[type='text'] {
border: 2px solid var(--ph-survey-border-color);
border-radius: var(--ph-survey-border-radius);
padding: 1rem;
font-size: 1rem;
line-height: 1.5;
transition: all 0.2s ease;
background: var(--ph-survey-input-background);
color: var(--ph-survey-text-primary-color);
width: 100%;
font-family: var(--ph-survey-font-family);
}
textarea:focus,
input[type='text']:focus {
outline: none;
border-color: var(--ph-survey-rating-active-bg-color);
box-shadow: var(--ph-survey-focus-ring);
}
textarea:hover:not(:focus),
input[type='text']:hover:not(:focus) {
border-color: var(--ph-survey-rating-active-bg-color);
}
/* Multiple Choice Options */
.multiple-choice-options {
display: flex;
flex-direction: column;
gap: 0.625rem;
flex: 1;
justify-content: flex-start;
overflow-y: auto;
max-height: 400px;
padding-right: 0.5rem;
margin: 0;
padding-left: 0;
border: none;
}
/* Fieldset styling */
fieldset {
border: none;
margin: 0;
padding: 0;
}
.multiple-choice-options label {
border: 2px solid var(--ph-survey-border-color);
border-radius: var(--ph-survey-border-radius);
padding: 0.875rem 1.25rem;
cursor: pointer;
transition: all 0.2s ease;
background: white;
font-size: 1rem;
min-height: 56px;
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
color: var(--ph-survey-text-primary-color);
font-family: var(--ph-survey-font-family);
}
.choice-option-open {
flex-wrap: wrap;
}
.multiple-choice-options label:hover:not(:has(input:checked)) {
border-color: #d1d5db;
background: #f9fafb;
}
.multiple-choice-options label:has(input:checked) {
border-color: var(--ph-survey-rating-active-bg-color);
background: #eff6ff;
}
.multiple-choice-options input[type='checkbox'],
.multiple-choice-options input[type='radio'] {
appearance: none;
width: 1rem;
height: 1rem;
background: var(--ph-survey-input-background);
border: 1.5px solid var(--ph-survey-border-color);
cursor: pointer;
border-radius: 3px;
flex-shrink: 0;
transition: all 0.2s ease;
position: relative;
margin: 0;
}
.multiple-choice-options input[type='radio'] {
border-radius: 50%;
}
.multiple-choice-options input[type='checkbox']:hover,
.multiple-choice-options input[type='radio']:hover {
border-color: var(--ph-survey-rating-active-bg-color);
transform: scale(1.05);
}
.multiple-choice-options input[type='checkbox']:checked,
.multiple-choice-options input[type='radio']:checked {
background: var(--ph-survey-rating-active-bg-color);
border-color: var(--ph-survey-rating-active-bg-color);
}
.multiple-choice-options input[type='checkbox']:checked::after {
content: '';
position: absolute;
left: 4px;
width: 4px;
height: 8px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.multiple-choice-options input[type='radio']:checked::after {
content: '';
position: absolute;
left: 3.5px;
top: 3.5px;
width: 6px;
height: 6px;
border-radius: 50%;
background: white;
}
/* Rating Styles */
.rating-text {
display: flex;
flex-direction: row;
font-size: 0.8rem;
justify-content: space-between;
opacity: 0.7;
}
.rating-options-number {
display: grid;
grid-auto-columns: 1fr;
grid-auto-flow: column;
border-radius: var(--ph-survey-border-radius);
overflow: hidden;
border: 2px solid var(--ph-survey-border-color);
}
.ratings-number {
padding: 0.875rem 0;
border: none;
background-color: var(--ph-survey-rating-button-color);
border-right: 1px solid var(--ph-survey-border-color);
text-align: center;
cursor: pointer;
color: var(--ph-survey-rating-button-text-color);
font-weight: 600;
transition: all 0.2s ease;
font-family: var(--ph-survey-font-family);
}
.ratings-number:last-of-type {
border-right: 0;
}
.ratings-number:hover {
filter: brightness(0.95);
}
.ratings-number.rating-active {
background: var(--ph-survey-rating-active-bg-color);
color: var(--ph-survey-rating-button-active-text-color);
}
/* Button Styling */
.form-submit {
background: var(--ph-survey-submit-button-color);
border: none;
border-radius: var(--ph-survey-border-radius);
padding: 0.875rem 2rem;
font-size: 1rem;
font-weight: 600;
color: var(--ph-survey-submit-button-text-color);
cursor: pointer;
transition: all 0.2s ease;
width: 100%;
margin-top: 1rem;
font-family: var(--ph-survey-font-family);
}
.form-submit:hover:not([disabled]) {
filter: brightness(0.9);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
}
.form-submit:active:not([disabled]) {
transform: translateY(0);
}
.form-submit[disabled] {
opacity: var(--ph-survey-disabled-button-opacity);
cursor: not-allowed;
}
/* Footer Branding */
.footer-branding {
font-size: 11px;
font-weight: 500;
display: flex;
justify-content: center;
gap: 4px;
align-items: center;
text-decoration: none;
opacity: 0.6;
transition: all 0.2s ease;
color: var(--ph-survey-text-subtle-color);
font-family: var(--ph-survey-font-family);
}
.footer-branding:hover {
opacity: 1;
}
.footer-branding a {
text-decoration: none;
color: inherit;
}
/* Thank You Message */
.thank-you-message {
text-align: center;
padding: 2rem;
}
.thank-you-message-header {
font-size: 1.5rem;
font-weight: 700;
color: var(--ph-survey-text-primary-color);
margin: 0 0 1rem 0;
}
.thank-you-message-body {
font-size: 1rem;
color: var(--ph-survey-text-subtle-color);
opacity: 0.8;
}
/* Scrollbar Styling */
.multiple-choice-options::-webkit-scrollbar {
width: 6px;
}
.multiple-choice-options::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 3px;
}
.multiple-choice-options::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
.multiple-choice-options::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Loading State */
.loading {
text-align: center;
padding: 3rem 2rem;
color: var(--ph-survey-text-subtle-color);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
}
.loading-spinner {
display: inline-block;
width: 32px;
height: 32px;
border: 3px solid #e5e7eb;
border-top: 3px solid var(--ph-survey-rating-active-bg-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
.loading-text {
font-size: 1.125rem;
font-weight: 500;
}
/* Hide loading when survey is rendered */
#posthog-survey-container:has(.ph-survey) .loading {
display: none;
}
/* Animations */
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Responsive Design */
@media (max-width: 768px) {
.main-container {
padding: 0.5rem;
}
.survey-header {
padding: 1.5rem 1.5rem 1.25rem;
}
.survey-title {
font-size: 1.5rem;
}
.survey-description {
font-size: 0.9rem;
}
.survey-form,
.thank-you-message {
padding: 1.25rem 1.5rem;
}
.multiple-choice-options {
max-height: 256px;
}
}
@media (max-height: 700px) {
.main-container {
padding: 0.75rem;
}
.survey-header {
padding: 1.25rem 2rem 1rem;
}
.survey-title {
font-size: 1.5rem;
margin-bottom: 0.25rem;
}
.survey-description {
font-size: 0.9rem;
}
.survey-form,
.thank-you-message {
padding: 1.25rem 2rem;
}
}
/* Utility classes */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Motion preferences */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
</style>
</head>
<body>
<div class="main-container">
<div class="survey-card">
<div class="survey-header">
<h1 class="survey-title">{{ name }}</h1>
</div>
<div class="survey-content">
<div id="posthog-survey-container">
<div class="loading">
<div class="loading-spinner"></div>
<div class="loading-text">Loading survey...</div>
</div>
</div>
</div>
</div>
</div>
<!-- PostHog JavaScript -->
<script>
// Project configuration from Django context
window.projectConfig = {{ project_config_json | safe }};
window.surveyName = "{{ name }}";
window.surveyId = "{{ id }}";
window.surveyAppearance = {{ appearance | safe }};
const BLACK_TEXT_COLOR = '#020617'
function hex2rgb(c) {
if (c[0] === '#') {
const hexColor = c.replace(/^#/, '')
const r = parseInt(hexColor.slice(0, 2), 16)
const g = parseInt(hexColor.slice(2, 4), 16)
const b = parseInt(hexColor.slice(4, 6), 16)
return 'rgb(' + r + ',' + g + ',' + b + ')'
}
return 'rgb(255, 255, 255)'
}
function nameToHex(name) {
return {
aliceblue: '#f0f8ff',
antiquewhite: '#faebd7',
aqua: '#00ffff',
aquamarine: '#7fffd4',
azure: '#f0ffff',
beige: '#f5f5dc',
bisque: '#ffe4c4',
black: '#000000',
blanchedalmond: '#ffebcd',
blue: '#0000ff',
blueviolet: '#8a2be2',
brown: '#a52a2a',
burlywood: '#deb887',
cadetblue: '#5f9ea0',
chartreuse: '#7fff00',
chocolate: '#d2691e',
coral: '#ff7f50',
cornflowerblue: '#6495ed',
cornsilk: '#fff8dc',
crimson: '#dc143c',
cyan: '#00ffff',
darkblue: '#00008b',
darkcyan: '#008b8b',
darkgoldenrod: '#b8860b',
darkgray: '#a9a9a9',
darkgreen: '#006400',
darkkhaki: '#bdb76b',
darkmagenta: '#8b008b',
darkolivegreen: '#556b2f',
darkorange: '#ff8c00',
darkorchid: '#9932cc',
darkred: '#8b0000',
darksalmon: '#e9967a',
darkseagreen: '#8fbc8f',
darkslateblue: '#483d8b',
darkslategray: '#2f4f4f',
darkturquoise: '#00ced1',
darkviolet: '#9400d3',
deeppink: '#ff1493',
deepskyblue: '#00bfff',
dimgray: '#696969',
dodgerblue: '#1e90ff',
firebrick: '#b22222',
floralwhite: '#fffaf0',
forestgreen: '#228b22',
fuchsia: '#ff00ff',
gainsboro: '#dcdcdc',
ghostwhite: '#f8f8ff',
gold: '#ffd700',
goldenrod: '#daa520',
gray: '#808080',
green: '#008000',
greenyellow: '#adff2f',
honeydew: '#f0fff0',
hotpink: '#ff69b4',
'indianred ': '#cd5c5c',
indigo: '#4b0082',
ivory: '#fffff0',
khaki: '#f0e68c',
lavender: '#e6e6fa',
lavenderblush: '#fff0f5',
lawngreen: '#7cfc00',
lemonchiffon: '#fffacd',
lightblue: '#add8e6',
lightcoral: '#f08080',
lightcyan: '#e0ffff',
lightgoldenrodyellow: '#fafad2',
lightgrey: '#d3d3d3',
lightgreen: '#90ee90',
lightpink: '#ffb6c1',
lightsalmon: '#ffa07a',
lightseagreen: '#20b2aa',
lightskyblue: '#87cefa',
lightslategray: '#778899',
lightsteelblue: '#b0c4de',
lightyellow: '#ffffe0',
lime: '#00ff00',
limegreen: '#32cd32',
linen: '#faf0e6',
magenta: '#ff00ff',
maroon: '#800000',
mediumaquamarine: '#66cdaa',
mediumblue: '#0000cd',
mediumorchid: '#ba55d3',
mediumpurple: '#9370d8',
mediumseagreen: '#3cb371',
mediumslateblue: '#7b68ee',
mediumspringgreen: '#00fa9a',
mediumturquoise: '#48d1cc',
mediumvioletred: '#c71585',
midnightblue: '#191970',
mintcream: '#f5fffa',
mistyrose: '#ffe4e1',
moccasin: '#ffe4b5',
navajowhite: '#ffdead',
navy: '#000080',
oldlace: '#fdf5e6',
olive: '#808000',
olivedrab: '#6b8e23',
orange: '#ffa500',
orangered: '#ff4500',
orchid: '#da70d6',
palegoldenrod: '#eee8aa',
palegreen: '#98fb98',
paleturquoise: '#afeeee',
palevioletred: '#d87093',
papayawhip: '#ffefd5',
peachpuff: '#ffdab9',
peru: '#cd853f',
pink: '#ffc0cb',
plum: '#dda0dd',
powderblue: '#b0e0e6',
purple: '#800080',
red: '#ff0000',
rosybrown: '#bc8f8f',
royalblue: '#4169e1',
saddlebrown: '#8b4513',
salmon: '#fa8072',
sandybrown: '#f4a460',
seagreen: '#2e8b57',
seashell: '#fff5ee',
sienna: '#a0522d',
silver: '#c0c0c0',
skyblue: '#87ceeb',
slateblue: '#6a5acd',
slategray: '#708090',
snow: '#fffafa',
springgreen: '#00ff7f',
steelblue: '#4682b4',
tan: '#d2b48c',
teal: '#008080',
thistle: '#d8bfd8',
tomato: '#ff6347',
turquoise: '#40e0d0',
violet: '#ee82ee',
wheat: '#f5deb3',
white: '#ffffff',
whitesmoke: '#f5f5f5',
yellow: '#ffff00',
yellowgreen: '#9acd32',
}[name.toLowerCase()]
}
function getContrastingTextColor(color) {
let rgb
if (color[0] === '#') {
rgb = hex2rgb(color)
}
if (color.startsWith('rgb')) {
rgb = color
}
// otherwise it's a color name
const nameColorToHex = nameToHex(color)
if (nameColorToHex) {
rgb = hex2rgb(nameColorToHex)
}
if (!rgb) {
return BLACK_TEXT_COLOR
}
const colorMatch = rgb.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/)
if (colorMatch) {
const r = parseInt(colorMatch[1])
const g = parseInt(colorMatch[2])
const b = parseInt(colorMatch[3])
const hsp = Math.sqrt(0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b))
return hsp > 127.5 ? BLACK_TEXT_COLOR : 'white'
}
return BLACK_TEXT_COLOR
}
function colorToRgbaWithOpacity(color, opacity = 0.25) {
let rgb
// Handle hex colors
if (color[0] === '#') {
rgb = hex2rgb(color)
}
// Handle rgb/rgba colors
else if (color.startsWith('rgb')) {
rgb = color
}
// Handle color names
else {
const nameColorToHex = nameToHex(color)
if (nameColorToHex) {
rgb = hex2rgb(nameColorToHex)
}
}
if (!rgb) {
return `rgba(255, 255, 255, ${opacity})` // fallback to white with opacity
}
const colorMatch = rgb.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/)
if (colorMatch) {
const r = parseInt(colorMatch[1])
const g = parseInt(colorMatch[2])
const b = parseInt(colorMatch[3])
return `rgba(${r}, ${g}, ${b}, ${opacity})`
}
return `rgba(255, 255, 255, ${opacity})` // fallback
}
// Apply survey appearance settings as CSS variables
function applySurveyAppearance(appearance) {
if (!appearance) return;
const root = document.documentElement;
// Apply custom appearance settings
if (appearance.backgroundColor) {
// Apply to body and header instead of survey container
// Use 25% opacity for body background for a softer effect
root.style.setProperty('--ph-survey-body-background', colorToRgbaWithOpacity(appearance.backgroundColor, 0.25));
root.style.setProperty('--ph-survey-header-background', appearance.backgroundColor);
root.style.setProperty('--ph-survey-header-text-color', getContrastingTextColor(appearance.backgroundColor));
}
if (appearance.submitButtonColor) {
root.style.setProperty('--ph-survey-submit-button-color', appearance.submitButtonColor);
root.style.setProperty('--ph-survey-submit-button-text-color',
appearance.submitButtonTextColor || getContrastingTextColor(appearance.submitButtonColor));
}
if (appearance.ratingButtonColor) {
root.style.setProperty('--ph-survey-rating-button-color', appearance.ratingButtonColor);
root.style.setProperty('--ph-survey-rating-button-text-color', getContrastingTextColor(appearance.ratingButtonColor));
}
if (appearance.ratingButtonActiveColor) {
root.style.setProperty('--ph-survey-rating-active-bg-color', appearance.ratingButtonActiveColor);
root.style.setProperty('--ph-survey-rating-button-active-text-color', getContrastingTextColor(appearance.ratingButtonActiveColor));
}
if (appearance.borderColor) {
root.style.setProperty('--ph-survey-border-color', appearance.borderColor);
}
if (appearance.borderRadius) {
root.style.setProperty('--ph-survey-border-radius', appearance.borderRadius);
root.style.setProperty('--ph-survey-card-border-radius', appearance.borderRadius);
}
if (appearance.inputBackground) {
root.style.setProperty('--ph-survey-input-background', appearance.inputBackground);
}
if (appearance.textSubtleColor) {
root.style.setProperty('--ph-survey-text-subtle-color', appearance.textSubtleColor);
}
if (appearance.disabledButtonOpacity) {
root.style.setProperty('--ph-survey-disabled-button-opacity', appearance.disabledButtonOpacity);
}
// Properties not suitable for external surveys (ignored):
// - zIndex: not relevant for full page
// - maxWidth: we manage our own layout
// - position: not relevant for full page
// - boxShadow: we have our own design
// - boxPadding: we have our own padding
// - fontFamily: we have our own font stack
// - whiteLabel: not relevant for this context
// - placeholder: handled by PostHog survey rendering
// - shuffleQuestions: behavioral setting, not appearance
// - thankYouMessageHeader/thankYouMessageDescription: handled by PostHog survey rendering
// - displayThankYouMessage: handled by PostHog survey rendering
}
// Apply survey appearance if available
if (window.surveyAppearance) {
applySurveyAppearance(window.surveyAppearance);
}
// Load PostHog from CDN
!function (t, e) { var o, n, p, r; e.__SV || (window.posthog = e, e._i = [], e.init = function (i, s, a) { function g(t, e) { var o = e.split("."); 2 == o.length && (t = t[o[0]], e = o[1]), t[e] = function () { t.push([e].concat(Array.prototype.slice.call(arguments, 0))) } } (p = t.createElement("script")).type = "text/javascript", p.crossOrigin = "anonymous", p.async = !0, p.src = s.api_host.replace(".i.posthog.com", "-assets.i.posthog.com") + "/static/array.js", (r = t.getElementsByTagName("script")[0]).parentNode.insertBefore(p, r); var u = e; for (void 0 !== a ? u = e[a] = [] : a = "posthog", u.people = u.people || [], u.toString = function (t) { var e = "posthog"; return "posthog" !== a && (e += "." + a), t || (e += " (stub)"), e }, u.people.toString = function () { return u.toString(1) + ".people (stub)" }, o = "init Ie Ts Ms Ee Es Rs capture Ge calculateEventProperties Os register register_once register_for_session unregister unregister_for_session js getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSurveysLoaded onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey canRenderSurveyAsync identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty Ds Fs createPersonProfile Ls Ps opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing Cs debug I As getPageViewId captureTraceFeedback captureTraceMetric".split(" "), n = 0; n < o.length; n++)g(u, o[n]); e._i.push([i, s, a]) }, e.__SV = 1) }(document, window.posthog || []);
// Initialize PostHog with project configuration
const config = {
api_host: window.projectConfig.api_host,
disable_surveys_automatic_display: true,
debug: false,
advanced_disable_toolbar_metrics: true,
persistence: 'localStorage',
capture_pageview: false, // Don't capture pageviews for survey pages
capture_pageleave: false
};
const distinctID = new URLSearchParams(window.location.search).get('distinct_id');
if (distinctID) {
config.bootstrap = {
distinctID: distinctID,
}
}
// Add ui_host if available (for reverse proxy setups)
if (window.projectConfig.ui_host) {
config.ui_host = window.projectConfig.ui_host;
}
posthog.init(window.projectConfig.token, config);
posthog.onSurveysLoaded(() => {
posthog.renderSurvey(window.surveyId, '#posthog-survey-container');
});
</script>
</body>
</html>

View File

@@ -40,7 +40,7 @@ from posthog.api.zendesk_orgcheck import ensure_zendesk_organization
from posthog.api.web_experiment import web_experiments
from posthog.api.utils import hostname_in_allowed_url_list
from products.early_access_features.backend.api import early_access_features
from posthog.api.survey import surveys
from posthog.api.survey import surveys, public_survey_page
from posthog.constants import PERMITTED_FORUM_DOMAINS
from posthog.demo.legacy import demo_route
from posthog.models import User
@@ -184,6 +184,7 @@ urlpatterns = [
opt_slash_path("api/early_access_features", early_access_features),
opt_slash_path("api/web_experiments", web_experiments),
opt_slash_path("api/surveys", surveys),
re_path(r"^external_surveys/(?P<survey_id>[^/]+)/?$", public_survey_page),
opt_slash_path("api/signup", signup.SignupViewset.as_view()),
opt_slash_path("api/social_signup", signup.SocialSignupViewset.as_view()),
path("api/signup/<str:invite_id>/", signup.InviteSignupViewset.as_view()),