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:
@@ -39,6 +39,7 @@ async function bootstrap() {
|
||||
"https://storage.sase.tr",
|
||||
"https://www.facebook.com",
|
||||
"https://destek.sase.tr",
|
||||
"https://anket.sase.tr",
|
||||
],
|
||||
fontSrc: ["'self'", "https:", "data:"],
|
||||
mediaSrc: ["'self'", "data:", "https://destek.sase.tr"],
|
||||
@@ -51,6 +52,7 @@ async function bootstrap() {
|
||||
"https://challenges.cloudflare.com",
|
||||
"https://destek.sase.tr",
|
||||
"wss://destek.sase.tr",
|
||||
"https://anket.sase.tr",
|
||||
// Sentry browser SDK envelope POSTs (otolog org, de region).
|
||||
// Without this CSP silently blocks every error/replay upload.
|
||||
"https://*.ingest.de.sentry.io",
|
||||
|
||||
@@ -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",
|
||||
|
||||
79
apps/web/src/lib/formbricks.ts
Normal file
79
apps/web/src/lib/formbricks.ts
Normal 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(() => {});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user