-
Notifications
You must be signed in to change notification settings - Fork 19
feat(analytics): integrate PostHog for user analytics and consent management #74
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
619f239
f532a3e
ddb1f59
1e948cb
9958fe5
0302102
24a9201
a0d17db
5e708fa
92588d9
c28356a
91c6550
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| <script setup lang="ts"> | ||
| const { needsConsent, acceptAnalytics, declineAnalytics } = useAnalyticsConsent() | ||
| </script> | ||
|
|
||
| <template> | ||
| <Transition | ||
| enter-active-class="transition ease-out duration-300" | ||
| enter-from-class="translate-y-4 opacity-0" | ||
| enter-to-class="translate-y-0 opacity-100" | ||
| leave-active-class="transition ease-in duration-200" | ||
| leave-from-class="translate-y-0 opacity-100" | ||
| leave-to-class="translate-y-4 opacity-0" | ||
| > | ||
| <div | ||
| v-if="needsConsent" | ||
| class="fixed bottom-4 right-4 z-50 max-w-sm rounded-lg border border-white/10 bg-zinc-900/95 px-4 py-3 shadow-2xl backdrop-blur-md sm:right-6 sm:bottom-6" | ||
| > | ||
| <p class="mb-1 text-[11px] font-semibold uppercase tracking-wider text-white/40">A small ask</p> | ||
| <p class="text-[13px] leading-relaxed text-white/70"> | ||
| Help us improve reqcore. No ads, no data selling, just product insights. | ||
| </p> | ||
| <p class="mt-1.5 text-[12px] text-white/40"> | ||
| <NuxtLink | ||
| to="/docs/legal/privacy-policy" | ||
| class="underline underline-offset-2 transition hover:text-white/70" | ||
| > | ||
| Privacy policy | ||
| </NuxtLink> | ||
| </p> | ||
| <div class="mt-3 flex gap-2"> | ||
| <button | ||
| type="button" | ||
| class="rounded-md px-3 py-1.5 text-xs font-medium text-white/40 transition hover:text-white/70" | ||
| @click="declineAnalytics" | ||
| > | ||
| No thanks | ||
| </button> | ||
| <button | ||
| type="button" | ||
| class="rounded-md bg-indigo-500 px-4 py-1.5 text-xs font-semibold text-white shadow-sm transition hover:bg-indigo-400" | ||
| @click="acceptAnalytics" | ||
| > | ||
| Sure, help improve it | ||
| </button> | ||
| </div> | ||
| </div> | ||
| </Transition> | ||
| </template> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| <script setup lang="ts"> | ||
| import { Github } from 'lucide-vue-next' | ||
|
|
||
| defineProps<{ | ||
| activePage?: 'features' | 'jobs' | 'roadmap' | 'blog' | 'docs' | ||
| }>() | ||
|
|
||
| const localePath = useLocalePath() | ||
| const { data: session } = await authClient.useSession(useFetch) | ||
| </script> | ||
|
|
||
| <template> | ||
| <nav class="fixed inset-x-0 top-0 z-50 border-b border-white/[0.06] bg-[#09090b]/80 backdrop-blur-xl"> | ||
| <div class="mx-auto flex h-14 max-w-6xl items-center justify-between px-6"> | ||
| <!-- Logo --> | ||
| <NuxtLink | ||
| :to="localePath('/')" | ||
| class="flex items-center gap-2.5 text-[15px] font-semibold tracking-tight text-white" | ||
| > | ||
| <img | ||
| src="/eagle-mascot-logo-128.png" | ||
| alt="Reqcore mascot" | ||
| width="28" | ||
| height="28" | ||
| loading="eager" | ||
| decoding="sync" | ||
| class="h-7 w-7 object-contain" | ||
| /> | ||
| Reqcore | ||
| </NuxtLink> | ||
|
|
||
| <!-- Center nav links (desktop) --> | ||
| <div class="hidden items-center gap-1 md:flex"> | ||
| <NuxtLink | ||
| :to="localePath('/catalog')" | ||
| class="rounded-md px-3 py-1.5 text-[13px] font-medium transition" | ||
| :class="activePage === 'features' ? 'text-white' : 'text-surface-400 hover:text-white'" | ||
| > | ||
| Features | ||
| </NuxtLink> | ||
| <NuxtLink | ||
| :to="localePath('/jobs')" | ||
| class="rounded-md px-3 py-1.5 text-[13px] font-medium transition" | ||
| :class="activePage === 'jobs' ? 'text-white' : 'text-surface-400 hover:text-white'" | ||
| > | ||
| Open Positions | ||
| </NuxtLink> | ||
| <NuxtLink | ||
| :to="localePath('/roadmap')" | ||
| class="rounded-md px-3 py-1.5 text-[13px] font-medium transition" | ||
| :class="activePage === 'roadmap' ? 'text-white' : 'text-surface-400 hover:text-white'" | ||
| > | ||
| Roadmap | ||
| </NuxtLink> | ||
| <NuxtLink | ||
| to="/blog" | ||
| class="rounded-md px-3 py-1.5 text-[13px] font-medium transition" | ||
| :class="activePage === 'blog' ? 'text-white' : 'text-surface-400 hover:text-white'" | ||
| > | ||
| Blog | ||
| </NuxtLink> | ||
| <NuxtLink | ||
| to="/docs" | ||
| class="rounded-md px-3 py-1.5 text-[13px] font-medium transition" | ||
| :class="activePage === 'docs' ? 'text-white' : 'text-surface-400 hover:text-white'" | ||
| > | ||
| Docs | ||
| </NuxtLink> | ||
| <a | ||
| href="https://github.com/reqcore-inc/reqcore" | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[13px] font-medium text-surface-400 transition hover:text-white" | ||
| > | ||
| <Github class="h-3.5 w-3.5" /> | ||
| GitHub | ||
| </a> | ||
| </div> | ||
|
|
||
| <!-- Right: session actions --> | ||
| <div class="flex items-center gap-2"> | ||
| <template v-if="session?.user"> | ||
| <NuxtLink | ||
| :to="localePath('/dashboard')" | ||
| class="rounded-md bg-white px-3.5 py-1.5 text-[13px] font-semibold text-[#09090b] transition hover:bg-white/90" | ||
| > | ||
| Dashboard | ||
| </NuxtLink> | ||
| </template> | ||
| <template v-else> | ||
| <NuxtLink | ||
| :to="localePath('/auth/sign-in')" | ||
| class="hidden rounded-md px-3 py-1.5 text-[13px] font-medium text-surface-400 transition hover:text-white sm:inline-flex" | ||
| > | ||
| Log In | ||
| </NuxtLink> | ||
| <NuxtLink | ||
| :to="localePath('/auth/sign-up')" | ||
| class="rounded-md bg-white px-3.5 py-1.5 text-[13px] font-semibold text-[#09090b] transition hover:bg-white/90" | ||
| > | ||
| Get Started | ||
| </NuxtLink> | ||
| </template> | ||
| </div> | ||
| </div> | ||
| </nav> | ||
| </template> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| /** | ||
| * Composable for managing PostHog analytics consent (GDPR compliance). | ||
| * | ||
| * By default, PostHog captures events. Users can opt out via this composable. | ||
| * The consent state is persisted in localStorage. | ||
|
Comment on lines
+3
to
+5
|
||
| * | ||
| * Usage: | ||
| * const { hasConsented, acceptAnalytics, declineAnalytics } = useAnalyticsConsent() | ||
| */ | ||
| const CONSENT_KEY = 'reqcore-analytics-consent' | ||
|
|
||
| type ConsentState = 'granted' | 'denied' | null | ||
|
|
||
| export function useAnalyticsConsent() { | ||
| // usePostHog() is auto-imported by @posthog/nuxt, but the module is | ||
| // conditionally loaded. Replicate the safe accessor so this composable | ||
| // works even when PostHog is not configured (CI, self-hosted without key). | ||
| const posthog = (useNuxtApp() as Record<string, unknown>).$posthog as ((() => { opt_in_capturing: () => void, opt_out_capturing: () => void }) | undefined) | ||
| const ph = posthog?.() | ||
|
|
||
| const consentState = useState<ConsentState>('analytics-consent', () => null) | ||
|
|
||
| // The useState factory only runs on the server: Nuxt serialises the `null` | ||
| // result into the SSR payload and restores it on the client without ever | ||
| // calling the factory again. We must therefore re-hydrate the persisted | ||
| // choice here — outside the factory — so that returning visitors don't see | ||
| // the banner a second time and PostHog's opt-in/out state is correct on the | ||
| // very first client render. | ||
| if (import.meta.client && consentState.value === null) { | ||
| const stored = localStorage.getItem(CONSENT_KEY) | ||
| if (stored === 'granted' || stored === 'denied') { | ||
| consentState.value = stored | ||
| } | ||
| } | ||
|
|
||
| const hasConsented = computed(() => consentState.value === 'granted') | ||
| const hasDeclined = computed(() => consentState.value === 'denied') | ||
| const needsConsent = computed(() => consentState.value === null) | ||
|
|
||
| function acceptAnalytics() { | ||
| consentState.value = 'granted' | ||
| if (import.meta.client) { | ||
| localStorage.setItem(CONSENT_KEY, 'granted') | ||
| } | ||
| ph?.opt_in_capturing() | ||
| } | ||
|
|
||
| function declineAnalytics() { | ||
| consentState.value = 'denied' | ||
| if (import.meta.client) { | ||
| localStorage.setItem(CONSENT_KEY, 'denied') | ||
| } | ||
| ph?.opt_out_capturing() | ||
| } | ||
|
|
||
| // Apply stored consent on mount. | ||
| // PostHog defaults to opt_out_capturing_by_default: true (GDPR), so we only | ||
| // need to explicitly opt-in for users who previously granted consent. | ||
| if (import.meta.client) { | ||
| if (consentState.value === 'granted') { | ||
| ph?.opt_in_capturing() | ||
| } | ||
| else { | ||
| ph?.opt_out_capturing() | ||
| } | ||
| } | ||
|
|
||
| return { | ||
| hasConsented, | ||
| hasDeclined, | ||
| needsConsent, | ||
| acceptAnalytics, | ||
| declineAnalytics, | ||
| } | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| /** | ||
| * Composable that syncs the Better Auth session with PostHog identity. | ||
| * Call once in a root-level layout or app.vue to enable automatic | ||
| * user identification and organization group analytics. | ||
| * | ||
| * Must be called in `<script setup>` context (not in a plugin). | ||
| * | ||
| * Identity and group calls are gated on analytics consent so that events are | ||
| * never sent before the user has opted in. When a user grants consent during | ||
| * their session (ConsentBanner), the watchers re-fire and identify them | ||
| * immediately without requiring a page reload. | ||
| */ | ||
| export async function usePostHogIdentity() { | ||
| const { $posthogIdentifyUser, $posthogSetOrganization, $posthogReset, $posthogResetGroups } = useNuxtApp() | ||
|
|
||
| if (!$posthogIdentifyUser) return | ||
|
|
||
| const { data: session } = await authClient.useSession(useFetch) | ||
| const activeOrgState = authClient.useActiveOrganization() | ||
|
|
||
| // Share the consent reactive state. useAnalyticsConsent also applies the | ||
| // stored consent flag to PostHog on the client, so calling it here means | ||
| // consent is active before the immediate watchers fire below. | ||
| const { hasConsented } = useAnalyticsConsent() | ||
|
|
||
| // Watch session AND consent so identify re-fires when a new user accepts | ||
| // consent during their visit, and is skipped when PostHog is opted-out. | ||
| watch( | ||
| [() => session.value, hasConsented] as const, | ||
| ([currentSession, consented], prev) => { | ||
| const user = currentSession?.user | ||
| const previousUser = (prev?.[0] as typeof session.value)?.user | ||
|
|
||
| if (user?.id && consented) { | ||
| // Only the user ID is forwarded — name and createdAt are intentionally | ||
| // omitted so PostHog receives the minimal data needed for analytics. | ||
| ;($posthogIdentifyUser as (userId: string) => void)(user.id) | ||
| } | ||
| else if (previousUser?.id && !user?.id) { | ||
| // Always reset on log-out regardless of consent state so that | ||
| // no user identity leaks into the next anonymous session. | ||
| ($posthogReset as () => void)() | ||
| } | ||
| }, | ||
| { immediate: true }, | ||
| ) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| // Watch org AND consent for group analytics — same gating logic as above. | ||
| watch( | ||
| [() => activeOrgState.value?.data, hasConsented] as const, | ||
| ([org, consented]) => { | ||
| if (consented) { | ||
| if (org?.id) { | ||
| // Only org id and name are forwarded; slug is omitted to minimise data. | ||
| ;($posthogSetOrganization as (org: { id: string, name?: string }) => void)({ | ||
| id: org.id, | ||
| name: org.name || undefined, | ||
| }) | ||
| } | ||
| else { | ||
| // Clear org group when user has no active organization to avoid | ||
| // attributing events to the previously selected org. | ||
| ($posthogResetGroups as (() => void) | undefined)?.() | ||
| } | ||
| } | ||
| }, | ||
| { immediate: true }, | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| <script setup lang="ts"> | ||
| import { ArrowLeft, Github, Calendar, User } from 'lucide-vue-next' | ||
| import { ArrowLeft, Calendar, User } from 'lucide-vue-next' | ||
|
||
|
|
||
| defineI18nRoute(false) | ||
|
|
||
|
|
@@ -66,40 +66,7 @@ const { data: session } = await authClient.useSession(useFetch) | |
| <template> | ||
| <div class="relative min-h-screen bg-[#09090b] text-white"> | ||
| <!-- ───── Nav ───── --> | ||
| <nav | ||
| class="fixed inset-x-0 top-0 z-50 border-b border-white/[0.06] bg-[#09090b]/80 backdrop-blur-xl" | ||
| > | ||
| <div class="mx-auto flex h-14 max-w-5xl items-center justify-between px-6"> | ||
| <NuxtLink :to="$localePath('/')" class="text-[15px] font-semibold tracking-tight">Reqcore</NuxtLink> | ||
| <div class="flex items-center gap-5 text-[13px] text-white/60"> | ||
| <NuxtLink :to="$localePath('/roadmap')" class="transition hover:text-white">Roadmap</NuxtLink> | ||
| <NuxtLink :to="$localePath('/catalog')" class="transition hover:text-white">Features</NuxtLink> | ||
| <NuxtLink to="/blog" class="text-white transition">Blog</NuxtLink> | ||
| <NuxtLink to="/docs" class="transition hover:text-white">Docs</NuxtLink> | ||
| <a | ||
| href="https://github.com/reqcore-inc/reqcore" | ||
| target="_blank" | ||
| class="transition hover:text-white" | ||
| > | ||
| <Github class="size-4" /> | ||
| </a> | ||
| <NuxtLink | ||
| v-if="session?.user" | ||
| :to="$localePath('/dashboard')" | ||
| class="rounded-md bg-white/10 px-3 py-1 text-white transition hover:bg-white/15" | ||
| > | ||
| Dashboard | ||
| </NuxtLink> | ||
| <NuxtLink | ||
| v-else | ||
| :to="$localePath('/auth/sign-in')" | ||
| class="rounded-md bg-white/10 px-3 py-1 text-white transition hover:bg-white/15" | ||
| > | ||
| Sign In | ||
| </NuxtLink> | ||
| </div> | ||
| </div> | ||
| </nav> | ||
| <PublicNavBar active-page="blog" /> | ||
|
|
||
| <!-- ───── Article ───── --> | ||
| <article class="relative pt-28 pb-24"> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Inconsistent use of
localePath()for navigation links.The Blog and Docs links use hardcoded paths (
to="/blog"andto="/docs") while other navigation links (Features, Open Positions, Roadmap) uselocalePath(). This inconsistency could cause issues if additional locales are configured.♻️ Proposed fix for consistent localization
📝 Committable suggestion
🤖 Prompt for AI Agents