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:
2026-06-10 00:40:18 +03:00
parent 4ffc3577a5
commit 72c3fe0dc2
12 changed files with 520 additions and 123 deletions

View File

@@ -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",

View 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>
);
}

View 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");
});
});

View File

@@ -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(() => {});
}

View File

@@ -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
View 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();
}

View File

@@ -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: {

View File

@@ -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" />
</>
);