feat(analytics): heatmaps + feature-flag hook + first A/B test
- Enable PostHog heatmaps (coordinate-only; autocapture stays off so the PII-heavy logged-in dashboard never ships VIN/OEM text to PostHog). - Add reusable feature-flag plumbing: subscribeFeatureFlag in lib/posthog and a useFeatureFlag React hook (works for boolean flags + experiments). - Wire experiment `demo-cta-copy` into DemoFooterCta: control = current "sınırsız sorgulama" framing, benefit = B2B OEM value hook. Variant is tagged on demo_to_register_click; exposure tracked via $feature_flag_called. PostHog experiment 82775 created in draft (primary metric: demo→user_signed_up, test accounts excluded). Launches after this reaches prod. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { useFeatureFlag } from "@/hooks/use-feature-flag";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
@@ -8,6 +9,21 @@ interface DemoFooterCtaProps {
|
||||
source: string;
|
||||
}
|
||||
|
||||
// A/B test `demo-cta-copy` (PostHog experiment). `control` = the original
|
||||
// "sınırsız sorgulama" framing; `benefit` = a B2B value-led hook (find the
|
||||
// right OEM part for a customer's vehicle in seconds). `undefined` (flag not
|
||||
// yet loaded / user out of rollout) falls through to control.
|
||||
const CTA_COPY = {
|
||||
control: {
|
||||
headline: "Sınırsız şase sorgulamak için ücretsiz hesap aç",
|
||||
button: "Hesap Aç",
|
||||
},
|
||||
benefit: {
|
||||
headline: "Müşteri aracının doğru OEM parçasını saniyede bul",
|
||||
button: "Ücretsiz Dene",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Footer conversion card on /demo pages — anti-gimmick B2B trust strip +
|
||||
* single "Hesap Aç" primary button routed to /register (no VIN carried;
|
||||
@@ -16,10 +32,14 @@ interface DemoFooterCtaProps {
|
||||
* the headline; `mb-20` clears the Chatwoot widget in the bottom-right.
|
||||
*/
|
||||
export function DemoFooterCta({ source }: DemoFooterCtaProps) {
|
||||
const flag = useFeatureFlag("demo-cta-copy");
|
||||
const variant = flag === "benefit" ? "benefit" : "control";
|
||||
const copy = CTA_COPY[variant];
|
||||
|
||||
return (
|
||||
<div className="mb-20 mt-8 flex flex-col items-start gap-3 rounded-lg border border-border bg-muted/30 p-5 sm:mb-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Sınırsız şase sorgulamak için ücretsiz hesap aç</p>
|
||||
<p className="text-sm font-semibold">{copy.headline}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Kart bilgisi gerekmez · 30 gün ücretsiz · istediğin an iptal
|
||||
</p>
|
||||
@@ -29,8 +49,11 @@ export function DemoFooterCta({ source }: DemoFooterCtaProps) {
|
||||
className="w-full shrink-0 sm:w-auto"
|
||||
data-faro-user-action-name={`demo-footer-cta-${source}`}
|
||||
>
|
||||
<Link to="/register" onClick={() => capture("demo_to_register_click", { source })}>
|
||||
Hesap Aç
|
||||
<Link
|
||||
to="/register"
|
||||
onClick={() => capture("demo_to_register_click", { source, cta_variant: variant })}
|
||||
>
|
||||
{copy.button}
|
||||
<ArrowRight className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
27
apps/web/src/hooks/use-feature-flag.ts
Normal file
27
apps/web/src/hooks/use-feature-flag.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { subscribeFeatureFlag } from "@/lib/posthog";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Reactively read a PostHog feature flag in a component.
|
||||
*
|
||||
* Returns `undefined` until PostHog has loaded and evaluated flags (PostHog is
|
||||
* lazy loaded), then the flag value — a `boolean` for simple flags or the
|
||||
* variant key `string` for multivariant / experiment flags. The value updates
|
||||
* automatically if flags reload (e.g. after login re-evaluates targeting).
|
||||
*
|
||||
* Always design the UI so `undefined` renders the safe/control branch — that's
|
||||
* what shows during the brief load window and for users PostHog can't reach.
|
||||
*
|
||||
* @example
|
||||
* const variant = useFeatureFlag("demo-cta-copy");
|
||||
* const enabled = useFeatureFlag("new-billing-flow") === true;
|
||||
*/
|
||||
export function useFeatureFlag(key: string): string | boolean | undefined {
|
||||
const [value, setValue] = useState<string | boolean | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeFeatureFlag(key, setValue);
|
||||
}, [key]);
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -32,7 +32,13 @@ export function initPostHog(): void {
|
||||
person_profiles: "always",
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
// Autocapture stays OFF: it serializes the text/attributes of clicked
|
||||
// elements, which on the logged-in dashboard would ship customer VINs,
|
||||
// OEM codes and PII to PostHog. Heatmaps below give us click/scroll/
|
||||
// rage-click maps from coordinates only — the visual signal without the
|
||||
// PII leak.
|
||||
autocapture: false,
|
||||
enable_heatmaps: true,
|
||||
session_recording: {
|
||||
maskAllInputs: false,
|
||||
maskInputOptions: { password: true },
|
||||
@@ -78,3 +84,39 @@ export function capturePageView(path: string): void {
|
||||
export function setPeopleProperties(properties: Record<string, unknown>): void {
|
||||
load().then((ph) => ph.people?.set(properties));
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a feature flag's value, reactively.
|
||||
*
|
||||
* Fires `cb` once with the current value as soon as PostHog has loaded its
|
||||
* flags, then again on every reload (e.g. after `identify()` re-evaluates
|
||||
* targeting). Returns an unsubscribe function. Because PostHog itself is lazy
|
||||
* loaded, the first callback is async — until then, treat the value as
|
||||
* `undefined` (callers should default to the control behaviour).
|
||||
*
|
||||
* Works for both boolean flags (`true`/`false`) and multivariant /
|
||||
* experiment flags (the variant key string, e.g. `"control"` / `"test"`).
|
||||
* Reading a flag here also emits `$feature_flag_called`, which is what powers
|
||||
* experiment exposure tracking — so just rendering a variant counts a user in.
|
||||
*/
|
||||
export function subscribeFeatureFlag(
|
||||
key: string,
|
||||
cb: (value: string | boolean | undefined) => void,
|
||||
): () => void {
|
||||
let unsub: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
load().then((ph) => {
|
||||
if (cancelled) return;
|
||||
cb(ph.getFeatureFlag(key));
|
||||
unsub = ph.onFeatureFlags(() => cb(ph.getFeatureFlag(key)));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsub?.();
|
||||
};
|
||||
}
|
||||
|
||||
/** Read a feature flag's payload (the JSON attached to the matched variant). */
|
||||
export function getFeatureFlagPayload(key: string): Promise<unknown> {
|
||||
return load().then((ph) => ph.getFeatureFlagPayload(key));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user