Skip to content

Commit 8bd4bd5

Browse files
authored
Merge pull request #74 from reqcore-inc/feat/posthog-integration
feat(analytics): integrate PostHog for user analytics and consent management
2 parents 021f8db + 91c6550 commit 8bd4bd5

20 files changed

Lines changed: 1898 additions & 347 deletions

app/app.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ useHead(() => ({
88
link: i18nHead.value.link,
99
meta: i18nHead.value.meta,
1010
}))
11+
12+
// Sync Better Auth session → PostHog identity & org group
13+
await usePostHogIdentity()
1114
</script>
1215

1316
<template>
@@ -16,5 +19,8 @@ useHead(() => ({
1619
<NuxtLayout>
1720
<NuxtPage />
1821
</NuxtLayout>
22+
<ClientOnly>
23+
<ConsentBanner />
24+
</ClientOnly>
1925
</div>
2026
</template>

app/components/ConsentBanner.vue

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<script setup lang="ts">
2+
const { needsConsent, acceptAnalytics, declineAnalytics } = useAnalyticsConsent()
3+
</script>
4+
5+
<template>
6+
<Transition
7+
enter-active-class="transition ease-out duration-300"
8+
enter-from-class="translate-y-4 opacity-0"
9+
enter-to-class="translate-y-0 opacity-100"
10+
leave-active-class="transition ease-in duration-200"
11+
leave-from-class="translate-y-0 opacity-100"
12+
leave-to-class="translate-y-4 opacity-0"
13+
>
14+
<div
15+
v-if="needsConsent"
16+
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"
17+
>
18+
<p class="mb-1 text-[11px] font-semibold uppercase tracking-wider text-white/40">A small ask</p>
19+
<p class="text-[13px] leading-relaxed text-white/70">
20+
Help us improve reqcore. No ads, no data selling, just product insights.
21+
</p>
22+
<p class="mt-1.5 text-[12px] text-white/40">
23+
<NuxtLink
24+
to="/docs/legal/privacy-policy"
25+
class="underline underline-offset-2 transition hover:text-white/70"
26+
>
27+
Privacy policy
28+
</NuxtLink>
29+
</p>
30+
<div class="mt-3 flex gap-2">
31+
<button
32+
type="button"
33+
class="rounded-md px-3 py-1.5 text-xs font-medium text-white/40 transition hover:text-white/70"
34+
@click="declineAnalytics"
35+
>
36+
No thanks
37+
</button>
38+
<button
39+
type="button"
40+
class="rounded-md bg-indigo-500 px-4 py-1.5 text-xs font-semibold text-white shadow-sm transition hover:bg-indigo-400"
41+
@click="acceptAnalytics"
42+
>
43+
Sure, help improve it
44+
</button>
45+
</div>
46+
</div>
47+
</Transition>
48+
</template>

app/components/PublicNavBar.vue

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<script setup lang="ts">
2+
import { Github } from 'lucide-vue-next'
3+
4+
defineProps<{
5+
activePage?: 'features' | 'jobs' | 'roadmap' | 'blog' | 'docs'
6+
}>()
7+
8+
const localePath = useLocalePath()
9+
const { data: session } = await authClient.useSession(useFetch)
10+
</script>
11+
12+
<template>
13+
<nav class="fixed inset-x-0 top-0 z-50 border-b border-white/[0.06] bg-[#09090b]/80 backdrop-blur-xl">
14+
<div class="mx-auto flex h-14 max-w-6xl items-center justify-between px-6">
15+
<!-- Logo -->
16+
<NuxtLink
17+
:to="localePath('/')"
18+
class="flex items-center gap-2.5 text-[15px] font-semibold tracking-tight text-white"
19+
>
20+
<img
21+
src="/eagle-mascot-logo-128.png"
22+
alt="Reqcore mascot"
23+
width="28"
24+
height="28"
25+
loading="eager"
26+
decoding="sync"
27+
class="h-7 w-7 object-contain"
28+
/>
29+
Reqcore
30+
</NuxtLink>
31+
32+
<!-- Center nav links (desktop) -->
33+
<div class="hidden items-center gap-1 md:flex">
34+
<NuxtLink
35+
:to="localePath('/catalog')"
36+
class="rounded-md px-3 py-1.5 text-[13px] font-medium transition"
37+
:class="activePage === 'features' ? 'text-white' : 'text-surface-400 hover:text-white'"
38+
>
39+
Features
40+
</NuxtLink>
41+
<NuxtLink
42+
:to="localePath('/jobs')"
43+
class="rounded-md px-3 py-1.5 text-[13px] font-medium transition"
44+
:class="activePage === 'jobs' ? 'text-white' : 'text-surface-400 hover:text-white'"
45+
>
46+
Open Positions
47+
</NuxtLink>
48+
<NuxtLink
49+
:to="localePath('/roadmap')"
50+
class="rounded-md px-3 py-1.5 text-[13px] font-medium transition"
51+
:class="activePage === 'roadmap' ? 'text-white' : 'text-surface-400 hover:text-white'"
52+
>
53+
Roadmap
54+
</NuxtLink>
55+
<NuxtLink
56+
to="/blog"
57+
class="rounded-md px-3 py-1.5 text-[13px] font-medium transition"
58+
:class="activePage === 'blog' ? 'text-white' : 'text-surface-400 hover:text-white'"
59+
>
60+
Blog
61+
</NuxtLink>
62+
<NuxtLink
63+
to="/docs"
64+
class="rounded-md px-3 py-1.5 text-[13px] font-medium transition"
65+
:class="activePage === 'docs' ? 'text-white' : 'text-surface-400 hover:text-white'"
66+
>
67+
Docs
68+
</NuxtLink>
69+
<a
70+
href="https://github.com/reqcore-inc/reqcore"
71+
target="_blank"
72+
rel="noopener noreferrer"
73+
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"
74+
>
75+
<Github class="h-3.5 w-3.5" />
76+
GitHub
77+
</a>
78+
</div>
79+
80+
<!-- Right: session actions -->
81+
<div class="flex items-center gap-2">
82+
<template v-if="session?.user">
83+
<NuxtLink
84+
:to="localePath('/dashboard')"
85+
class="rounded-md bg-white px-3.5 py-1.5 text-[13px] font-semibold text-[#09090b] transition hover:bg-white/90"
86+
>
87+
Dashboard
88+
</NuxtLink>
89+
</template>
90+
<template v-else>
91+
<NuxtLink
92+
:to="localePath('/auth/sign-in')"
93+
class="hidden rounded-md px-3 py-1.5 text-[13px] font-medium text-surface-400 transition hover:text-white sm:inline-flex"
94+
>
95+
Log In
96+
</NuxtLink>
97+
<NuxtLink
98+
:to="localePath('/auth/sign-up')"
99+
class="rounded-md bg-white px-3.5 py-1.5 text-[13px] font-semibold text-[#09090b] transition hover:bg-white/90"
100+
>
101+
Get Started
102+
</NuxtLink>
103+
</template>
104+
</div>
105+
</div>
106+
</nav>
107+
</template>
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* Composable for managing PostHog analytics consent (GDPR compliance).
3+
*
4+
* By default, PostHog captures events. Users can opt out via this composable.
5+
* The consent state is persisted in localStorage.
6+
*
7+
* Usage:
8+
* const { hasConsented, acceptAnalytics, declineAnalytics } = useAnalyticsConsent()
9+
*/
10+
const CONSENT_KEY = 'reqcore-analytics-consent'
11+
12+
type ConsentState = 'granted' | 'denied' | null
13+
14+
export function useAnalyticsConsent() {
15+
// usePostHog() is auto-imported by @posthog/nuxt, but the module is
16+
// conditionally loaded. Replicate the safe accessor so this composable
17+
// works even when PostHog is not configured (CI, self-hosted without key).
18+
const posthog = (useNuxtApp() as Record<string, unknown>).$posthog as ((() => { opt_in_capturing: () => void, opt_out_capturing: () => void }) | undefined)
19+
const ph = posthog?.()
20+
21+
const consentState = useState<ConsentState>('analytics-consent', () => null)
22+
23+
// The useState factory only runs on the server: Nuxt serialises the `null`
24+
// result into the SSR payload and restores it on the client without ever
25+
// calling the factory again. We must therefore re-hydrate the persisted
26+
// choice here — outside the factory — so that returning visitors don't see
27+
// the banner a second time and PostHog's opt-in/out state is correct on the
28+
// very first client render.
29+
if (import.meta.client && consentState.value === null) {
30+
const stored = localStorage.getItem(CONSENT_KEY)
31+
if (stored === 'granted' || stored === 'denied') {
32+
consentState.value = stored
33+
}
34+
}
35+
36+
const hasConsented = computed(() => consentState.value === 'granted')
37+
const hasDeclined = computed(() => consentState.value === 'denied')
38+
const needsConsent = computed(() => consentState.value === null)
39+
40+
function acceptAnalytics() {
41+
consentState.value = 'granted'
42+
if (import.meta.client) {
43+
localStorage.setItem(CONSENT_KEY, 'granted')
44+
}
45+
ph?.opt_in_capturing()
46+
}
47+
48+
function declineAnalytics() {
49+
consentState.value = 'denied'
50+
if (import.meta.client) {
51+
localStorage.setItem(CONSENT_KEY, 'denied')
52+
}
53+
ph?.opt_out_capturing()
54+
}
55+
56+
// Apply stored consent on mount.
57+
// PostHog defaults to opt_out_capturing_by_default: true (GDPR), so we only
58+
// need to explicitly opt-in for users who previously granted consent.
59+
if (import.meta.client) {
60+
if (consentState.value === 'granted') {
61+
ph?.opt_in_capturing()
62+
}
63+
else {
64+
ph?.opt_out_capturing()
65+
}
66+
}
67+
68+
return {
69+
hasConsented,
70+
hasDeclined,
71+
needsConsent,
72+
acceptAnalytics,
73+
declineAnalytics,
74+
}
75+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* Composable that syncs the Better Auth session with PostHog identity.
3+
* Call once in a root-level layout or app.vue to enable automatic
4+
* user identification and organization group analytics.
5+
*
6+
* Must be called in `<script setup>` context (not in a plugin).
7+
*
8+
* Identity and group calls are gated on analytics consent so that events are
9+
* never sent before the user has opted in. When a user grants consent during
10+
* their session (ConsentBanner), the watchers re-fire and identify them
11+
* immediately without requiring a page reload.
12+
*/
13+
export async function usePostHogIdentity() {
14+
const { $posthogIdentifyUser, $posthogSetOrganization, $posthogReset, $posthogResetGroups } = useNuxtApp()
15+
16+
if (!$posthogIdentifyUser) return
17+
18+
const { data: session } = await authClient.useSession(useFetch)
19+
const activeOrgState = authClient.useActiveOrganization()
20+
21+
// Share the consent reactive state. useAnalyticsConsent also applies the
22+
// stored consent flag to PostHog on the client, so calling it here means
23+
// consent is active before the immediate watchers fire below.
24+
const { hasConsented } = useAnalyticsConsent()
25+
26+
// Watch session AND consent so identify re-fires when a new user accepts
27+
// consent during their visit, and is skipped when PostHog is opted-out.
28+
watch(
29+
[() => session.value, hasConsented] as const,
30+
([currentSession, consented], prev) => {
31+
const user = currentSession?.user
32+
const previousUser = (prev?.[0] as typeof session.value)?.user
33+
34+
if (user?.id && consented) {
35+
// Only the user ID is forwarded — name and createdAt are intentionally
36+
// omitted so PostHog receives the minimal data needed for analytics.
37+
;($posthogIdentifyUser as (userId: string) => void)(user.id)
38+
}
39+
else if (previousUser?.id && !user?.id) {
40+
// Always reset on log-out regardless of consent state so that
41+
// no user identity leaks into the next anonymous session.
42+
($posthogReset as () => void)()
43+
}
44+
},
45+
{ immediate: true },
46+
)
47+
48+
// Watch org AND consent for group analytics — same gating logic as above.
49+
watch(
50+
[() => activeOrgState.value?.data, hasConsented] as const,
51+
([org, consented]) => {
52+
if (consented) {
53+
if (org?.id) {
54+
// Only org id and name are forwarded; slug is omitted to minimise data.
55+
;($posthogSetOrganization as (org: { id: string, name?: string }) => void)({
56+
id: org.id,
57+
name: org.name || undefined,
58+
})
59+
}
60+
else {
61+
// Clear org group when user has no active organization to avoid
62+
// attributing events to the previously selected org.
63+
($posthogResetGroups as (() => void) | undefined)?.()
64+
}
65+
}
66+
},
67+
{ immediate: true },
68+
)
69+
}

app/pages/blog/[...slug].vue

Lines changed: 2 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { ArrowLeft, Github, Calendar, User } from 'lucide-vue-next'
2+
import { ArrowLeft, Calendar, User } from 'lucide-vue-next'
33
44
defineI18nRoute(false)
55
@@ -66,40 +66,7 @@ const { data: session } = await authClient.useSession(useFetch)
6666
<template>
6767
<div class="relative min-h-screen bg-[#09090b] text-white">
6868
<!-- ───── Nav ───── -->
69-
<nav
70-
class="fixed inset-x-0 top-0 z-50 border-b border-white/[0.06] bg-[#09090b]/80 backdrop-blur-xl"
71-
>
72-
<div class="mx-auto flex h-14 max-w-5xl items-center justify-between px-6">
73-
<NuxtLink :to="$localePath('/')" class="text-[15px] font-semibold tracking-tight">Reqcore</NuxtLink>
74-
<div class="flex items-center gap-5 text-[13px] text-white/60">
75-
<NuxtLink :to="$localePath('/roadmap')" class="transition hover:text-white">Roadmap</NuxtLink>
76-
<NuxtLink :to="$localePath('/catalog')" class="transition hover:text-white">Features</NuxtLink>
77-
<NuxtLink to="/blog" class="text-white transition">Blog</NuxtLink>
78-
<NuxtLink to="/docs" class="transition hover:text-white">Docs</NuxtLink>
79-
<a
80-
href="https://github.com/reqcore-inc/reqcore"
81-
target="_blank"
82-
class="transition hover:text-white"
83-
>
84-
<Github class="size-4" />
85-
</a>
86-
<NuxtLink
87-
v-if="session?.user"
88-
:to="$localePath('/dashboard')"
89-
class="rounded-md bg-white/10 px-3 py-1 text-white transition hover:bg-white/15"
90-
>
91-
Dashboard
92-
</NuxtLink>
93-
<NuxtLink
94-
v-else
95-
:to="$localePath('/auth/sign-in')"
96-
class="rounded-md bg-white/10 px-3 py-1 text-white transition hover:bg-white/15"
97-
>
98-
Sign In
99-
</NuxtLink>
100-
</div>
101-
</div>
102-
</nav>
69+
<PublicNavBar active-page="blog" />
10370

10471
<!-- ───── Article ───── -->
10572
<article class="relative pt-28 pb-24">

0 commit comments

Comments
 (0)