Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ useHead(() => ({
link: i18nHead.value.link,
meta: i18nHead.value.meta,
}))

// Sync Better Auth session → PostHog identity & org group
await usePostHogIdentity()
</script>

<template>
Expand All @@ -16,5 +19,8 @@ useHead(() => ({
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<ClientOnly>
<ConsentBanner />
</ClientOnly>
</div>
</template>
48 changes: 48 additions & 0 deletions app/components/ConsentBanner.vue
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>
107 changes: 107 additions & 0 deletions app/components/PublicNavBar.vue
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>
Comment on lines +55 to +68
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistent use of localePath() for navigation links.

The Blog and Docs links use hardcoded paths (to="/blog" and to="/docs") while other navigation links (Features, Open Positions, Roadmap) use localePath(). This inconsistency could cause issues if additional locales are configured.

♻️ Proposed fix for consistent localization
         <NuxtLink
-          to="/blog"
+          :to="localePath('/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"
+          :to="localePath('/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>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<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>
<NuxtLink
:to="localePath('/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="localePath('/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>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/PublicNavBar.vue` around lines 55 - 68, The Blog and Docs
NuxtLink entries use hardcoded paths (to="/blog" and to="/docs") while other
links use localePath(), causing inconsistent locale routing; update the two
NuxtLink components to use localePath('blog') and localePath('docs') (or
localePath({ name: 'blog' } if your routes are named) for their "to" bindings,
keep the existing :class logic and activePage checks, and ensure localePath is
imported/available in the component where NuxtLink, activePage, and other
navigation links are defined.

<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>
75 changes: 75 additions & 0 deletions app/composables/useAnalyticsConsent.ts
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
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc comment says "By default, PostHog captures events. Users can opt out via this composable." but this is incorrect given the configuration in nuxt.config.ts which sets opt_out_capturing_by_default: true. In reality, PostHog starts in opted-out mode and users must opt in for any capturing to occur. The docstring should read "By default, PostHog does not capture events. Users can opt in via this composable."

Copilot uses AI. Check for mistakes.
*
* 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,
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
69 changes: 69 additions & 0 deletions app/composables/usePostHogIdentity.ts
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 },
)
Comment thread
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 },
)
}
37 changes: 2 additions & 35 deletions app/pages/blog/[...slug].vue
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'
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, after removing the inline navbar in blog/[...slug].vue, the session variable fetched via authClient.useSession(useFetch) (which was only used by the navbar) is now unused. It should be removed since PublicNavBar fetches the session itself.

Copilot uses AI. Check for mistakes.

defineI18nRoute(false)

Expand Down Expand Up @@ -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">
Expand Down
Loading
Loading