feat(surveys): replace Formbricks with self-rendered PostHog API surveys
Formbricks CE turned out to gate person-based targeting (setUserId/attributes → 403 enterprise) — the exact flexibility surveys need. PostHog already holds the person properties, so surveys move back there in API mode: posthog-js evaluates eligibility (event triggers, targeting flags like subscription_status=active, wait periods, per-distinct_id dedup) via getActiveMatchingSurveys, and we render the popover ourselves — zero PostHog branding, sase.tr dark-theme styling. - lib/surveys.ts: display manager + capture payload builders that mirror posthog-js's own "survey shown/dismissed/sent" shapes exactly ($survey_response_<qid>, $set $survey_dismissed/<id>[/iter], seenSurvey_*, lastSeenSurveyDate) so the PostHog Surveys results UI works unchanged - components/survey-popover.tsx: single_choice (+Diğer), open text, rating/NPS - posthog.ts: capture/pageview/identify now schedule survey checks; register deploy_env (dev.sase.tr ships the key now → staging traffic is filterable) - remove @formbricks/js, its CSP entries and build args Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@cardog-icons/react": "^1.1.1",
|
||||
"@formbricks/js": "4.4.0",
|
||||
"@grafana/faro-web-sdk": "^2.2.4",
|
||||
"@grafana/faro-web-tracing": "^2.2.4",
|
||||
"@remotion/player": "^4.0.422",
|
||||
|
||||
222
apps/web/src/components/survey-popover.tsx
Normal file
222
apps/web/src/components/survey-popover.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import {
|
||||
type SurveyResponses,
|
||||
closeActiveSurvey,
|
||||
dismissActiveSurvey,
|
||||
submitActiveSurvey,
|
||||
subscribeActiveSurvey,
|
||||
} from "@/lib/surveys";
|
||||
import { Button } from "@sase/ui";
|
||||
import { X } from "lucide-react";
|
||||
import type { Survey } from "posthog-js";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Self-rendered PostHog survey popover (API-mode surveys). Question copy comes
|
||||
// from the survey definition in PostHog; this component only knows how to draw
|
||||
// the three question shapes we use (single choice, open text, rating/NPS).
|
||||
|
||||
const OTHER_CHOICE = "__other__";
|
||||
|
||||
export function SurveyPopover() {
|
||||
const [survey, setSurvey] = useState<Survey | null>(null);
|
||||
useEffect(() => subscribeActiveSurvey(setSurvey), []);
|
||||
if (!survey) return null;
|
||||
return <SurveyCard key={`${survey.id}-${survey.current_iteration ?? 0}`} survey={survey} />;
|
||||
}
|
||||
|
||||
function SurveyCard({ survey }: { survey: Survey }) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [answers, setAnswers] = useState<SurveyResponses>({});
|
||||
const [choice, setChoice] = useState<string | null>(null);
|
||||
const [otherText, setOtherText] = useState("");
|
||||
const [openText, setOpenText] = useState("");
|
||||
const [rating, setRating] = useState<number | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const questions = survey.questions;
|
||||
const q = questions[step];
|
||||
|
||||
useEffect(() => {
|
||||
if (!done) return;
|
||||
const t = setTimeout(() => closeActiveSurvey(), 6000);
|
||||
return () => clearTimeout(t);
|
||||
}, [done]);
|
||||
|
||||
const qid = q?.id ?? `q${step}`;
|
||||
const choices = q && "choices" in q && Array.isArray(q.choices) ? q.choices : [];
|
||||
const hasOpenChoice = Boolean(q && "hasOpenChoice" in q && q.hasOpenChoice);
|
||||
const scale = q && "scale" in q && typeof q.scale === "number" ? q.scale : 5;
|
||||
const lowerLabel = q && "lowerBoundLabel" in q ? (q.lowerBoundLabel ?? "") : "";
|
||||
const upperLabel = q && "upperBoundLabel" in q ? (q.upperBoundLabel ?? "") : "";
|
||||
const optional = Boolean(q && "optional" in q && q.optional);
|
||||
|
||||
function currentValue(): string | number | null {
|
||||
if (!q) return null;
|
||||
if (q.type === "single_choice") {
|
||||
if (choice === OTHER_CHOICE) return otherText.trim() || null;
|
||||
return choice;
|
||||
}
|
||||
if (q.type === "rating") return rating;
|
||||
if (q.type === "open") return openText.trim() || null;
|
||||
return null;
|
||||
}
|
||||
|
||||
const canContinue = optional || currentValue() !== null;
|
||||
|
||||
function collect(): SurveyResponses {
|
||||
const value = currentValue();
|
||||
return value === null && !(qid in answers)
|
||||
? { ...answers, [qid]: null }
|
||||
: { ...answers, [qid]: value };
|
||||
}
|
||||
|
||||
function advance() {
|
||||
const next = collect();
|
||||
setAnswers(next);
|
||||
if (step + 1 < questions.length) {
|
||||
setStep(step + 1);
|
||||
setChoice(null);
|
||||
setOtherText("");
|
||||
setOpenText("");
|
||||
setRating(null);
|
||||
return;
|
||||
}
|
||||
submitActiveSurvey(next);
|
||||
setDone(true);
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
if (done) {
|
||||
closeActiveSurvey();
|
||||
return;
|
||||
}
|
||||
dismissActiveSurvey(collect());
|
||||
}
|
||||
|
||||
const buttonLabel =
|
||||
(q && "buttonText" in q && q.buttonText) || (step + 1 < questions.length ? "Devam" : "Gönder");
|
||||
const ratingValues = Array.from({ length: scale === 10 ? 11 : scale }, (_, i) =>
|
||||
scale === 10 ? i : i + 1,
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={survey.name}
|
||||
className="fixed bottom-24 right-4 z-[70] w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-border bg-card p-5 text-card-foreground shadow-2xl"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Kapat"
|
||||
onClick={dismiss}
|
||||
className="absolute right-3 top-3 rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
|
||||
{done ? (
|
||||
<div>
|
||||
<p className="pr-6 text-sm font-semibold">
|
||||
{survey.appearance?.thankYouMessageHeader || "Teşekkürler!"}
|
||||
</p>
|
||||
{survey.appearance?.thankYouMessageDescription ? (
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">
|
||||
{survey.appearance.thankYouMessageDescription}
|
||||
</p>
|
||||
) : null}
|
||||
<Button variant="outline" className="mt-4 w-full" onClick={closeActiveSurvey}>
|
||||
Kapat
|
||||
</Button>
|
||||
</div>
|
||||
) : q ? (
|
||||
<div>
|
||||
<p className="pr-6 text-sm font-medium leading-snug">{q.question}</p>
|
||||
|
||||
{q.type === "single_choice" ? (
|
||||
<div className="mt-3 flex flex-col gap-1.5">
|
||||
{choices.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setChoice(c)}
|
||||
className={`rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
|
||||
choice === c
|
||||
? "border-brand bg-brand/10 text-foreground"
|
||||
: "border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
{hasOpenChoice ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChoice(OTHER_CHOICE)}
|
||||
className={`rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
|
||||
choice === OTHER_CHOICE
|
||||
? "border-brand bg-brand/10 text-foreground"
|
||||
: "border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Diğer
|
||||
</button>
|
||||
{choice === OTHER_CHOICE ? (
|
||||
<textarea
|
||||
rows={2}
|
||||
value={otherText}
|
||||
onChange={(e) => setOtherText(e.target.value)}
|
||||
placeholder="Yazmaya başlayın..."
|
||||
// biome-ignore lint/a11y/noAutofocus: revealed by an explicit user action
|
||||
autoFocus
|
||||
className="w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:border-foreground/40"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{q.type === "rating" ? (
|
||||
<div className="mt-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ratingValues.map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => setRating(v)}
|
||||
className={`h-8 min-w-8 flex-1 rounded-md border text-sm transition-colors ${
|
||||
rating === v
|
||||
? "border-brand bg-brand text-brand-foreground"
|
||||
: "border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{lowerLabel || upperLabel ? (
|
||||
<div className="mt-1.5 flex justify-between text-xs text-muted-foreground">
|
||||
<span>{lowerLabel}</span>
|
||||
<span>{upperLabel}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{q.type === "open" ? (
|
||||
<textarea
|
||||
rows={3}
|
||||
value={openText}
|
||||
onChange={(e) => setOpenText(e.target.value)}
|
||||
placeholder="Yazmaya başlayın..."
|
||||
className="mt-3 w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:border-foreground/40"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Button variant="brand" className="mt-4 w-full" disabled={!canContinue} onClick={advance}>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
73
apps/web/src/lib/__tests__/surveys.test.ts
Normal file
73
apps/web/src/lib/__tests__/surveys.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Survey } from "posthog-js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildDismissedProperties,
|
||||
buildSentProperties,
|
||||
seenSurveyStorageKey,
|
||||
surveyInteractionKey,
|
||||
} from "../surveys";
|
||||
|
||||
// The capture payloads must match what posthog-js's own widget sends — the
|
||||
// Surveys results UI, the internal targeting flags ($survey_dismissed/<id>)
|
||||
// and cross-device dedup all key off these exact shapes.
|
||||
|
||||
function mkSurvey(overrides: Record<string, unknown> = {}): Survey {
|
||||
return {
|
||||
id: "sv1",
|
||||
name: "Test Survey",
|
||||
type: "api",
|
||||
questions: [
|
||||
{ id: "q1", type: "single_choice", question: "Neden?", choices: ["A", "B"] },
|
||||
{ id: "q2", type: "open", question: "Detay?", optional: true },
|
||||
],
|
||||
current_iteration: null,
|
||||
current_iteration_start_date: null,
|
||||
...overrides,
|
||||
} as unknown as Survey;
|
||||
}
|
||||
|
||||
describe("surveyInteractionKey", () => {
|
||||
it("builds the plain person-property key for one-off surveys", () => {
|
||||
expect(surveyInteractionKey(mkSurvey(), "dismissed")).toBe("$survey_dismissed/sv1");
|
||||
});
|
||||
|
||||
it("appends the iteration for recurring surveys (matches the internal targeting flag)", () => {
|
||||
const s = mkSurvey({ current_iteration: 1 });
|
||||
expect(surveyInteractionKey(s, "responded")).toBe("$survey_responded/sv1/1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("seenSurveyStorageKey", () => {
|
||||
it("matches posthog-js seenSurvey_ localStorage convention", () => {
|
||||
expect(seenSurveyStorageKey(mkSurvey())).toBe("seenSurvey_sv1");
|
||||
expect(seenSurveyStorageKey(mkSurvey({ current_iteration: 2 }))).toBe("seenSurvey_sv1_2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSentProperties", () => {
|
||||
it("keys responses by question id and marks the responded person property", () => {
|
||||
const props = buildSentProperties(mkSurvey(), { q1: "A", q2: null }, "sub-1");
|
||||
expect(props.$survey_id).toBe("sv1");
|
||||
expect(props.$survey_response_q1).toBe("A");
|
||||
expect(props.$survey_response_q2).toBeNull();
|
||||
expect(props.$survey_completed).toBe(true);
|
||||
expect(props.$survey_submission_id).toBe("sub-1");
|
||||
expect(props.$set).toEqual({ "$survey_responded/sv1": true });
|
||||
expect(props.$survey_questions).toEqual([
|
||||
{ id: "q1", question: "Neden?", response: "A" },
|
||||
{ id: "q2", question: "Detay?", response: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDismissedProperties", () => {
|
||||
it("flags partial completion only when something was answered", () => {
|
||||
const empty = buildDismissedProperties(mkSurvey(), { q1: null });
|
||||
expect(empty.$survey_partially_completed).toBe(false);
|
||||
expect(empty.$set).toEqual({ "$survey_dismissed/sv1": true });
|
||||
|
||||
const partial = buildDismissedProperties(mkSurvey(), { q1: "B", q2: null });
|
||||
expect(partial.$survey_partially_completed).toBe(true);
|
||||
expect(partial.$survey_response_q1).toBe("B");
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
// Formbricks in-app surveys (self-hosted at anket.sase.tr) — lazily loaded so
|
||||
// it stays out of the initial bundle. All exported functions are fire-and-forget;
|
||||
// when the env vars are unset (e.g. local dev) every call is a no-op.
|
||||
//
|
||||
// PostHog stays the single instrumentation source: lib/posthog.ts forwards
|
||||
// allowlisted capture() events here (FORMBRICKS_TRIGGER_EVENTS) so surveys
|
||||
// trigger on the same event names that exist in PostHog.
|
||||
|
||||
type Formbricks = typeof import("@formbricks/js")["default"];
|
||||
|
||||
const APP_URL = import.meta.env.VITE_FORMBRICKS_APP_URL;
|
||||
const ENV_ID = import.meta.env.VITE_FORMBRICKS_ENV_ID;
|
||||
|
||||
let _setupPromise: Promise<Formbricks | null> | null = null;
|
||||
|
||||
function load(): Promise<Formbricks | null> {
|
||||
if (!APP_URL || !ENV_ID) return Promise.resolve(null);
|
||||
if (_setupPromise) return _setupPromise;
|
||||
_setupPromise = import("@formbricks/js")
|
||||
.then(async (m) => {
|
||||
await m.default.setup({ environmentId: ENV_ID, appUrl: APP_URL });
|
||||
return m.default;
|
||||
})
|
||||
.catch(() => null);
|
||||
return _setupPromise;
|
||||
}
|
||||
|
||||
export function initFormbricks(): void {
|
||||
void load();
|
||||
}
|
||||
|
||||
/** Forwarded from posthog.ts capture() for allowlisted events — shows any
|
||||
* survey whose trigger action matches the event code. */
|
||||
export function trackFormbricks(event: string): void {
|
||||
load()
|
||||
.then((fb) => fb?.track(event))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
export function identifyFormbricksUser(user: {
|
||||
id: string;
|
||||
email: string;
|
||||
role?: string;
|
||||
}): void {
|
||||
load()
|
||||
.then(async (fb) => {
|
||||
if (!fb) return;
|
||||
await fb.setUserId(user.id);
|
||||
await fb.setEmail(user.email);
|
||||
if (user.role) await fb.setAttribute("role", user.role);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/** Person properties → Formbricks attributes (string-only API), so surveys can
|
||||
* target e.g. subscription_status=trial the same way PostHog cohorts do. */
|
||||
export function setFormbricksAttributes(properties: Record<string, unknown>): void {
|
||||
const attrs: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
if (value !== null && value !== undefined) attrs[key] = String(value);
|
||||
}
|
||||
if (Object.keys(attrs).length === 0) return;
|
||||
load()
|
||||
.then((fb) => fb?.setAttributes(attrs))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
export function resetFormbricksUser(): void {
|
||||
load()
|
||||
.then((fb) => fb?.logout())
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/** SPA navigation hook — lets URL-based (no-code) survey triggers fire. */
|
||||
export function formbricksRouteChange(): void {
|
||||
load()
|
||||
.then((fb) => fb?.registerRouteChange())
|
||||
.catch(() => {});
|
||||
}
|
||||
@@ -1,26 +1,10 @@
|
||||
// PostHog — lazily loaded so it doesn't land in the initial bundle.
|
||||
// All exported functions are fire-and-forget; analytics loss on failure is acceptable.
|
||||
|
||||
import {
|
||||
formbricksRouteChange,
|
||||
identifyFormbricksUser,
|
||||
resetFormbricksUser,
|
||||
setFormbricksAttributes,
|
||||
trackFormbricks,
|
||||
} from "./formbricks";
|
||||
import { resetSurveySession, scheduleSurveyCheck } from "./surveys";
|
||||
|
||||
type PostHog = import("posthog-js").PostHog;
|
||||
|
||||
// Events that double as Formbricks survey triggers (mirrored as code actions
|
||||
// at anket.sase.tr). Keep in sync when adding a survey with an event trigger.
|
||||
const FORMBRICKS_TRIGGER_EVENTS = new Set([
|
||||
"trial_urgency_banner_viewed",
|
||||
"subscription_cancelled",
|
||||
"onboarding_completed",
|
||||
"vin_decode_error",
|
||||
"empty_catalog_cta_clicked",
|
||||
]);
|
||||
|
||||
let _ph: PostHog | null = null;
|
||||
let _loadPromise: Promise<PostHog> | null = null;
|
||||
let _initialized = false;
|
||||
@@ -62,6 +46,11 @@ export function initPostHog(): void {
|
||||
maskInputOptions: { password: true },
|
||||
},
|
||||
});
|
||||
// dev.sase.tr now ships with the key too (surveys must be testable on dev
|
||||
// first) — stamp every event so staging traffic is filterable in analyses.
|
||||
ph.register({
|
||||
deploy_env: window.location.hostname === "sase.tr" ? "production" : "staging",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -78,17 +67,19 @@ export function identifyUser(user: {
|
||||
role: user.role,
|
||||
});
|
||||
});
|
||||
identifyFormbricksUser({ id: user.id, email: user.email, role: user.role });
|
||||
scheduleSurveyCheck();
|
||||
}
|
||||
|
||||
export function resetUser(): void {
|
||||
load().then((ph) => ph.reset());
|
||||
resetFormbricksUser();
|
||||
resetSurveySession();
|
||||
}
|
||||
|
||||
export function capture(event: string, properties?: Record<string, unknown>): void {
|
||||
load().then((ph) => ph.capture(event, properties));
|
||||
if (FORMBRICKS_TRIGGER_EVENTS.has(event)) trackFormbricks(event);
|
||||
// Event-triggered surveys: posthog-js's event receiver sees the capture
|
||||
// above; the debounced check then asks for newly eligible surveys.
|
||||
scheduleSurveyCheck();
|
||||
}
|
||||
|
||||
export function capturePageView(path: string): void {
|
||||
@@ -100,12 +91,12 @@ export function capturePageView(path: string): void {
|
||||
load().then((ph) =>
|
||||
ph.capture("$pageview", { $current_url: window.location.href, $pathname: path }),
|
||||
);
|
||||
formbricksRouteChange();
|
||||
scheduleSurveyCheck();
|
||||
}
|
||||
|
||||
export function setPeopleProperties(properties: Record<string, unknown>): void {
|
||||
load().then((ph) => ph.people?.set(properties));
|
||||
setFormbricksAttributes(properties);
|
||||
scheduleSurveyCheck();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
210
apps/web/src/lib/surveys.ts
Normal file
210
apps/web/src/lib/surveys.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
// PostHog surveys in API mode — we render the popover ourselves (zero PostHog
|
||||
// branding, no PostHog survey DOM), while targeting and analytics stay in
|
||||
// PostHog: eligibility (event triggers, URL/device rules, wait periods and the
|
||||
// person-property targeting flags, e.g. subscription_status=active) is
|
||||
// evaluated by posthog-js inside getActiveMatchingSurveys, and we capture the
|
||||
// same "survey shown/dismissed/sent" events with the exact property shapes the
|
||||
// official widget uses, so per-distinct_id dedup and the Surveys results UI
|
||||
// keep working unchanged.
|
||||
|
||||
import type { PostHog, Survey } from "posthog-js";
|
||||
|
||||
const ENABLED = Boolean(import.meta.env.VITE_POSTHOG_KEY);
|
||||
|
||||
function loadPh(): Promise<PostHog> {
|
||||
return import("posthog-js").then((m) => m.default);
|
||||
}
|
||||
|
||||
/** Answers keyed by question id; null = question left unanswered. */
|
||||
export type SurveyResponses = Record<string, string | number | null>;
|
||||
|
||||
// ── capture payloads (must mirror posthog-js dist/surveys.js) ────────────────
|
||||
|
||||
/** Person-property key the survey's internal targeting flag filters on —
|
||||
* `$survey_dismissed/<id>` plus `/<iteration>` for recurring surveys. */
|
||||
export function surveyInteractionKey(survey: Survey, kind: "dismissed" | "responded"): string {
|
||||
const base = `$survey_${kind}/${survey.id}`;
|
||||
return survey.current_iteration && survey.current_iteration > 0
|
||||
? `${base}/${survey.current_iteration}`
|
||||
: base;
|
||||
}
|
||||
|
||||
/** localStorage key posthog-js checks for client-side "already seen" gating. */
|
||||
export function seenSurveyStorageKey(survey: Survey): string {
|
||||
const base = `seenSurvey_${survey.id}`;
|
||||
return survey.current_iteration && survey.current_iteration > 0
|
||||
? `${base}_${survey.current_iteration}`
|
||||
: base;
|
||||
}
|
||||
|
||||
export function surveyResponseKey(questionId: string): string {
|
||||
return `$survey_response_${questionId}`;
|
||||
}
|
||||
|
||||
function baseProperties(survey: Survey): Record<string, unknown> {
|
||||
return {
|
||||
$survey_name: survey.name,
|
||||
$survey_id: survey.id,
|
||||
$survey_iteration: survey.current_iteration,
|
||||
$survey_iteration_start_date: survey.current_iteration_start_date,
|
||||
};
|
||||
}
|
||||
|
||||
function responseProperties(survey: Survey, responses: SurveyResponses): Record<string, unknown> {
|
||||
const props: Record<string, unknown> = {};
|
||||
for (const q of survey.questions) {
|
||||
if (!q.id) continue;
|
||||
props[surveyResponseKey(q.id)] = responses[q.id] ?? null;
|
||||
}
|
||||
return props;
|
||||
}
|
||||
|
||||
export function buildDismissedProperties(
|
||||
survey: Survey,
|
||||
responses: SurveyResponses,
|
||||
): Record<string, unknown> {
|
||||
const hasAnyResponse = Object.values(responses).some((v) => v !== null && v !== "");
|
||||
return {
|
||||
...baseProperties(survey),
|
||||
...responseProperties(survey, responses),
|
||||
$survey_partially_completed: hasAnyResponse,
|
||||
$set: { [surveyInteractionKey(survey, "dismissed")]: true },
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSentProperties(
|
||||
survey: Survey,
|
||||
responses: SurveyResponses,
|
||||
submissionId: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...baseProperties(survey),
|
||||
...responseProperties(survey, responses),
|
||||
$survey_questions: survey.questions.map((q) => ({
|
||||
id: q.id,
|
||||
question: q.question,
|
||||
response: q.id ? (responses[q.id] ?? null) : null,
|
||||
})),
|
||||
$survey_submission_id: submissionId,
|
||||
$survey_completed: true,
|
||||
$set: { [surveyInteractionKey(survey, "responded")]: true },
|
||||
};
|
||||
}
|
||||
|
||||
// ── display manager ──────────────────────────────────────────────────────────
|
||||
|
||||
let _active: Survey | null = null;
|
||||
let _shownThisSession = new Set<string>();
|
||||
const _listeners = new Set<(survey: Survey | null) => void>();
|
||||
let _checkTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let _showTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let _wired = false;
|
||||
|
||||
function notify(): void {
|
||||
for (const cb of _listeners) cb(_active);
|
||||
}
|
||||
|
||||
function markSeen(survey: Survey): void {
|
||||
try {
|
||||
localStorage.setItem(seenSurveyStorageKey(survey), "true");
|
||||
} catch {
|
||||
// localStorage unavailable — the server-side person-property dedup still applies
|
||||
}
|
||||
}
|
||||
|
||||
function wireOnce(ph: PostHog): void {
|
||||
if (_wired) return;
|
||||
_wired = true;
|
||||
// Re-check when the lazily loaded surveys module becomes ready and whenever
|
||||
// feature flags re-evaluate (identify, property changes) — both change eligibility.
|
||||
ph.surveys?.onSurveysLoaded?.(() => scheduleSurveyCheck());
|
||||
ph.onFeatureFlags(() => scheduleSurveyCheck());
|
||||
}
|
||||
|
||||
function check(ph: PostHog): void {
|
||||
wireOnce(ph);
|
||||
if (_active || _showTimer) return;
|
||||
ph.getActiveMatchingSurveys((surveys) => {
|
||||
if (_active || _showTimer) return;
|
||||
const candidate = surveys.find((s) => s.type === "api" && !_shownThisSession.has(s.id));
|
||||
if (!candidate) return;
|
||||
const delaySeconds = candidate.appearance?.surveyPopupDelaySeconds ?? 0;
|
||||
_showTimer = setTimeout(() => {
|
||||
_showTimer = null;
|
||||
show(ph, candidate);
|
||||
}, delaySeconds * 1000);
|
||||
});
|
||||
}
|
||||
|
||||
function show(ph: PostHog, survey: Survey): void {
|
||||
if (_active) return;
|
||||
_shownThisSession.add(survey.id);
|
||||
_active = survey;
|
||||
ph.capture("survey shown", baseProperties(survey));
|
||||
try {
|
||||
localStorage.setItem("lastSeenSurveyDate", new Date().toISOString());
|
||||
} catch {
|
||||
// best effort — only affects cross-survey wait-period spacing
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
/** Debounced eligibility check — called after captures, route changes and identify. */
|
||||
export function scheduleSurveyCheck(): void {
|
||||
if (!ENABLED || typeof window === "undefined") return;
|
||||
if (_checkTimer) clearTimeout(_checkTimer);
|
||||
_checkTimer = setTimeout(() => {
|
||||
_checkTimer = null;
|
||||
loadPh()
|
||||
.then((ph) => check(ph))
|
||||
.catch(() => {});
|
||||
}, 700);
|
||||
}
|
||||
|
||||
export function subscribeActiveSurvey(cb: (survey: Survey | null) => void): () => void {
|
||||
_listeners.add(cb);
|
||||
cb(_active);
|
||||
return () => {
|
||||
_listeners.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
export function dismissActiveSurvey(responses: SurveyResponses): void {
|
||||
const survey = _active;
|
||||
if (!survey) return;
|
||||
_active = null;
|
||||
notify();
|
||||
markSeen(survey);
|
||||
loadPh()
|
||||
.then((ph) => ph.capture("survey dismissed", buildDismissedProperties(survey, responses)))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/** Capture the responses; the popover stays mounted to show its thank-you
|
||||
* state and calls closeActiveSurvey() when it's done. */
|
||||
export function submitActiveSurvey(responses: SurveyResponses): void {
|
||||
const survey = _active;
|
||||
if (!survey) return;
|
||||
markSeen(survey);
|
||||
const submissionId = crypto.randomUUID();
|
||||
loadPh()
|
||||
.then((ph) => ph.capture("survey sent", buildSentProperties(survey, responses, submissionId)))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
export function closeActiveSurvey(): void {
|
||||
if (!_active) return;
|
||||
_active = null;
|
||||
notify();
|
||||
}
|
||||
|
||||
/** Logout — drop in-memory survey state; localStorage/person-property dedup stays. */
|
||||
export function resetSurveySession(): void {
|
||||
if (_showTimer) {
|
||||
clearTimeout(_showTimer);
|
||||
_showTimer = null;
|
||||
}
|
||||
_active = null;
|
||||
_shownThisSession = new Set();
|
||||
notify();
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { initChatwoot } from "./lib/chatwoot";
|
||||
import { initFaro } from "./lib/faro";
|
||||
import { initFormbricks } from "./lib/formbricks";
|
||||
import { initLocale } from "./lib/i18n";
|
||||
import { initMetaPixel } from "./lib/meta-pixel";
|
||||
import { initPostHog } from "./lib/posthog";
|
||||
@@ -31,9 +30,6 @@ initMetaPixel();
|
||||
// Initialize Chatwoot live-chat widget
|
||||
initChatwoot();
|
||||
|
||||
// Initialize Formbricks in-app surveys
|
||||
initFormbricks();
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { InAppEscape } from "@/components/in-app-escape";
|
||||
import { SurveyPopover } from "@/components/survey-popover";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { resetChatwootUser, setChatwootUser } from "@/lib/chatwoot";
|
||||
@@ -156,6 +157,7 @@ function RootComponent() {
|
||||
</a>
|
||||
<InAppEscape />
|
||||
<Outlet />
|
||||
<SurveyPopover />
|
||||
<Toaster position="top-center" />
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user