feat: external surveys (#33948)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 62 KiB |
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
18
frontend/src/scenes/surveys/CopySurveyLink.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ export function SurveyContainerAppearance({
|
||||
onAppearanceChange,
|
||||
validationErrors,
|
||||
surveyType,
|
||||
}: CommonProps): JSX.Element {
|
||||
}: CommonProps): JSX.Element | null {
|
||||
const { surveysStylingAvailable } = useValues(surveysLogic)
|
||||
|
||||
return (
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
'''
|
||||
|
||||
@@ -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",
|
||||
|
||||
249
posthog/api/test/test_external_surveys.py
Normal 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)
|
||||
25
posthog/migrations/0805_alter_survey_type.py
Normal 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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1 +1 @@
|
||||
0804_add_rich_content_json_to_comment
|
||||
0805_alter_survey_type
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
'''
|
||||
|
||||
266
posthog/templates/surveys/error.html
Normal 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>
|
||||
918
posthog/templates/surveys/public_survey.html
Normal 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>
|
||||
@@ -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()),
|
||||
|
||||