trypost/resources/js/components/UserMenuContent.vue
Paulo Castellano e3538df2f0 feat: end-to-end PostHog tracking with reactive group metrics
Wire PostHog identify + dual-group context across the stack so events
land on the right person and account/workspace groups, with counts kept
fresh by Inertia navigations rather than per-domain triggers.

- New `app/Jobs/SyncUserToPostHog.php` (queue `posthog`): centralised
  high-level sync — identifies the user and group-identifies their
  account + current workspace using `$account->usage()` so the metrics
  reuse the same source of truth Inertia ships in shared props.
- `app/Actions/User/CreateUser.php`: dispatches `SyncUserToPostHog` on
  signup instead of calling `PostHogService` inline. Keeps the action
  fast and routes everything through the queue.
- `app/Listeners/StripeEventListener.php`: webhook now captures
  `subscription.created`/`updated`/`cancelled` against the account
  owner profile (with `account` group auto-attached) and re-dispatches
  `SyncUserToPostHog` so plan/has_active_subscription/is_on_trial
  refresh after Stripe state changes.
- `app/Services/PostHogService.php`: `capture()` accepts an optional
  `Account` that auto-attaches `$groups.account`, `account_id`, and
  `plan` properties. Each public method short-circuits when
  `POSTHOG_API_KEY` is unset so self-hosted installs are unaffected.
- `app/Models/Traits/HasUsage.php`: adds `postCount` to the usage
  shape (combined `withCount(['socialAccounts','posts'])` query) so
  posts count is part of the same payload Inertia already ships.
- `config/horizon.php`: adds `posthog` to `supervisor-1` queues so the
  queued PostHog jobs actually drain in production.
- `resources/js/app.ts`: extracts `syncPostHogContext(page)` and calls
  it on boot AND on every Inertia navigation, reading the fresh
  `usage` props. This:
  - Refreshes account group counts (workspaces, social accounts,
    posts, members, credits) without per-domain triggers.
  - Resolves the workspace-switch case where `setup()` does not
    re-run but `navigate` fires with the new `auth.currentWorkspace`.
  - Captures the initial `$pageview` so the first page of a session
    is no longer dropped.
- `resources/js/components/UserMenuContent.vue`: `posthog.reset()` on
  logout so a follow-up login on the same browser doesn't keep events
  attributed to the previous user.
- `resources/js/composables/useFeatureAccess.ts`: TS `Usage`
  interface gains `postCount`.
- `tests/Feature/Models/HasUsageTraitTest.php`: updated for the new
  usage shape. Full suite: 1407 passed.

Hierarchy aligned with the domain model: person = User,
group `account` = billing/plan parent, group `workspace` =
collaboration child (carries `account_id` for drill-down).
2026-05-07 09:28:02 -03:00

146 lines
4.6 KiB
Vue

<script setup lang="ts">
import { Link, router, usePage } from '@inertiajs/vue3';
import {
IconLanguage,
IconLifebuoy,
IconLogout,
IconMessageCircle,
IconUser,
} from '@tabler/icons-vue';
import { loadLanguageAsync } from 'laravel-vue-i18n';
import { computed } from 'vue';
import { updateLanguage } from '@/actions/App/Http/Controllers/App/Settings/ProfileController';
import {
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from '@/components/ui/dropdown-menu';
import UserInfo from '@/components/UserInfo.vue';
import dayjs from '@/dayjs';
import posthog from '@/posthog';
import { logout } from '@/routes';
import { edit } from '@/routes/app/profile';
import type { User } from '@/types';
interface Language {
code: string;
name: string;
}
type Props = {
user: User;
};
defineProps<Props>();
const page = usePage();
const languages = computed<Language[]>(() => page.props.languages as Language[]);
const currentLanguage = computed(() => languages.value?.find((l: Language) => l.code === page.props.locale));
const switchLanguage = (code: string) => {
const previousCode = currentLanguage.value?.code || 'en';
loadLanguageAsync(code);
dayjs.locale(code.toLowerCase());
router.put(updateLanguage.url(), { locale: code }, {
preserveScroll: true,
preserveState: false,
onError: () => {
loadLanguageAsync(previousCode);
dayjs.locale(previousCode.toLowerCase());
},
});
};
const handleLogout = () => {
posthog.reset();
router.flushAll();
};
</script>
<template>
<DropdownMenuLabel class="p-0 text-sm font-normal normal-case tracking-normal text-foreground">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<UserInfo
:user="user"
:show-email="true"
fallback-class="bg-violet-100 text-violet-700"
/>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem :as-child="true">
<Link class="block w-full cursor-pointer" :href="edit()" prefetch>
<IconUser class="size-4" />
{{ $t('sidebar.profile') }}
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuSub v-if="languages && languages.length > 1">
<DropdownMenuSubTrigger>
<IconLanguage />
{{ $t('sidebar.language', { name: currentLanguage?.name ?? 'English' }) }}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem
v-for="language in languages"
:key="language.code"
:class="language.code === currentLanguage?.code ? 'bg-accent' : ''"
@click="switchLanguage(language.code)"
>
{{ language.name }}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem :as-child="true">
<a
class="block w-full cursor-pointer"
href="https://github.com/trypost-it/trypost/discussions"
target="_blank"
rel="noopener noreferrer"
>
<IconMessageCircle class="size-4" />
{{ $t('sidebar.support.share_feedback') }}
</a>
</DropdownMenuItem>
<DropdownMenuItem :as-child="true">
<a
class="block w-full cursor-pointer"
href="https://trypost.it/docs"
target="_blank"
rel="noopener noreferrer"
>
<IconLifebuoy class="size-4" />
{{ $t('sidebar.support.docs') }}
</a>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem :as-child="true">
<Link
class="block w-full cursor-pointer"
:href="logout()"
@click="handleLogout"
as="button"
data-test="logout-button"
>
<IconLogout class="size-4" />
{{ $t('sidebar.log_out') }}
</Link>
</DropdownMenuItem>
</template>