feat(surveys): Formbricks in-app survey bridge over PostHog events

Self-hosted Formbricks (anket.sase.tr) replaces PostHog surveys (free-tier
branding). PostHog stays the single instrumentation source: capture() forwards
allowlisted trigger events (trial_urgency_banner_viewed, subscription_cancelled,
onboarding_completed, vin_decode_error, empty_catalog_cta_clicked) to the
Formbricks SDK, identify/reset/people-properties mirror into Formbricks
attributes, and $pageview registers SPA route changes for no-code triggers.

- apps/web/src/lib/formbricks.ts: lazy fire-and-forget wrapper (inert without
  VITE_FORMBRICKS_APP_URL + VITE_FORMBRICKS_ENV_ID)
- CSP: allow anket.sase.tr in connect-src/img-src
- Dockerfile + compose: bake the two VITE_ build args

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 23:44:45 +03:00
parent 4716232398
commit 597391bbdf
8 changed files with 124 additions and 63 deletions

View File

@@ -17,6 +17,7 @@
},
"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,79 @@
// 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,8 +1,26 @@
// 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";
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;
@@ -60,14 +78,17 @@ export function identifyUser(user: {
role: user.role,
});
});
identifyFormbricksUser({ id: user.id, email: user.email, role: user.role });
}
export function resetUser(): void {
load().then((ph) => ph.reset());
resetFormbricksUser();
}
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);
}
export function capturePageView(path: string): void {
@@ -79,10 +100,12 @@ export function capturePageView(path: string): void {
load().then((ph) =>
ph.capture("$pageview", { $current_url: window.location.href, $pathname: path }),
);
formbricksRouteChange();
}
export function setPeopleProperties(properties: Record<string, unknown>): void {
load().then((ph) => ph.people?.set(properties));
setFormbricksAttributes(properties);
}
/**

View File

@@ -4,6 +4,7 @@ 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";
@@ -30,6 +31,9 @@ initMetaPixel();
// Initialize Chatwoot live-chat widget
initChatwoot();
// Initialize Formbricks in-app surveys
initFormbricks();
const queryClient = new QueryClient({
defaultOptions: {
queries: {