feat: GTM dataLayer context + checkout/purchase events

Backend exposes account and plan as proper Inertia resources so any
page can read them off shared props:

- AuthAccountResource — id, name, created_at
- AuthPlanResource — id, slug, name, interval (derived from the active
  Cashier subscription's stripe_price)

Both wired into HandleInertiaRequests::share so `auth.account` and
`auth.plan` are available everywhere.

Frontend pushes app + identity context to GTM's dataLayer on every
page load via initializeDataLayer (resources/js/datalayer.ts), and
useTracking gets begin_checkout/purchase wired in the billing flow.

The pushes are no-ops without GTM configured (the array still exists
in memory, nothing reads it). Self-hosted instances without GTM_ID
incur zero error noise. The GTM partials in app.blade.php were
already conditional on `config('services.gtm.id')`.

Processing.vue polls `auth` alongside `subscriptionActive` so
auth.plan.interval is fresh once the Stripe webhook creates the local
Subscription row (it doesn't exist yet at the initial render). The
watch fires only on the false → true transition; an onMounted
fallback redirects users that land on the page with an already-active
subscription without re-firing trackPurchase.
This commit is contained in:
Paulo Castellano 2026-05-06 10:14:17 -03:00
parent 6b8a6d536a
commit 96e1aa36b6
10 changed files with 215 additions and 16 deletions

View file

@ -4,6 +4,8 @@
namespace App\Http\Middleware\App;
use App\Http\Resources\App\HandleInertiaRequests\AuthAccountResource;
use App\Http\Resources\App\HandleInertiaRequests\AuthPlanResource;
use App\Http\Resources\App\HandleInertiaRequests\AuthUserResource;
use App\Http\Resources\App\HandleInertiaRequests\AuthWorkspaceResource;
use App\Models\Account;
@ -40,7 +42,8 @@ public function share(Request $request): array
'workspaces' => $user
? $user->workspaces()->with('media')->get()->map(fn ($ws) => AuthWorkspaceResource::summary($ws))
: [],
'plan' => $account?->plan,
'account' => $account ? AuthAccountResource::make($account) : null,
'plan' => $account && $account->plan ? AuthPlanResource::make($account, $account->plan) : null,
'hasActiveSubscription' => $account ? $account->hasActiveSubscription() : false,
'currentPriceId' => $account?->subscription(Account::SUBSCRIPTION_NAME)?->stripe_price,
],

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\App\HandleInertiaRequests;
use App\Models\Account;
class AuthAccountResource
{
/**
* @return array<string, mixed>
*/
public static function make(Account $account): array
{
return [
'id' => $account->id,
'name' => $account->name,
'created_at' => $account->created_at,
];
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\App\HandleInertiaRequests;
use App\Models\Account;
use App\Models\Plan;
class AuthPlanResource
{
/**
* @return array<string, mixed>
*/
public static function make(Account $account, Plan $plan): array
{
$subscription = $account->subscription(Account::SUBSCRIPTION_NAME);
$interval = ($subscription && $subscription->stripe_price === $plan->stripe_yearly_price_id)
? 'yearly'
: 'monthly';
return [
'id' => $plan->id,
'slug' => $plan->slug->value,
'name' => $plan->name,
'interval' => $interval,
];
}
}

View file

@ -8,6 +8,7 @@ import type { DefineComponent } from 'vue';
import { createApp, h } from 'vue';
import { initializeTheme } from './composables/useAppearance';
import { initializeDataLayer } from './datalayer';
import dayjs from './dayjs';
import posthog from './posthog';
import type { Auth } from './types';
@ -33,6 +34,16 @@ createInertiaApp({
dayjs.locale(locale.toLowerCase());
const auth = props.initialPage.props.auth as Auth | undefined;
const flash = props.initialPage.props.flash as
| { conversion_event?: string; [key: string]: unknown }
| undefined;
initializeDataLayer(
auth,
flash,
props.initialPage.props.applicationUrl as string,
props.initialPage.props.env as string,
);
if (auth?.user) {
posthog.identify(auth.user.id, {

View file

@ -17,34 +17,28 @@ export const useTracking = () => ({
});
},
trackBeginCheckout: (plan: { name: string; price: number; interval: string }) => {
trackBeginCheckout: (plan: { name: string; interval: string }) => {
posthog.capture('checkout.started', {
plan_name: plan.name,
plan_price: plan.price,
interval: plan.interval,
});
push({
event: 'begin_checkout',
currency: 'USD',
plan_name: plan.name,
plan_price: plan.price,
plan_interval: plan.interval,
});
},
trackPurchase: (plan: { name: string; price: number; interval: string }) => {
trackPurchase: (plan: { name: string; interval: string }) => {
posthog.capture('checkout.completed', {
plan_name: plan.name,
plan_price: plan.price,
interval: plan.interval,
});
push({
event: 'purchase',
currency: 'USD',
plan_name: plan.name,
plan_price: plan.price,
plan_interval: plan.interval,
});
},

64
resources/js/datalayer.ts Normal file
View file

@ -0,0 +1,64 @@
import type { Auth } from './types';
interface FlashData {
conversion_event?: string;
[key: string]: unknown;
}
/**
* Push app + identity context to GTM's dataLayer on every page load. The
* pushes are no-ops when GTM isn't configured (the array still exists in
* memory; nothing reads it). Self-hosted instances without GTM_ID set
* incur zero error noise.
*
* Billing is account-scoped (not workspace-scoped) in trypost, so the
* plan/subscription fields are emitted under `account_*` keys.
*/
export const initializeDataLayer = (
auth: Auth | undefined,
flash: FlashData | undefined,
applicationUrl: string,
env: string,
): void => {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
app_url: applicationUrl,
app_env: env,
app_context: 'app',
});
if (flash?.conversion_event) {
window.dataLayer.push({ event: flash.conversion_event });
}
if (!auth?.user) {
return;
}
window.dataLayer.push({
user_id: auth.user.id,
user_email: auth.user.email,
user_name: auth.user.name,
user_created_at: auth.user.created_at,
});
if (auth.account) {
window.dataLayer.push({
account_id: auth.account.id,
account_name: auth.account.name,
account_created_at: auth.account.created_at,
account_plan: auth.plan?.name ?? null,
account_plan_slug: auth.plan?.slug ?? null,
account_subscribed: Boolean(auth.hasActiveSubscription),
});
}
if (auth.currentWorkspace) {
window.dataLayer.push({
workspace_id: auth.currentWorkspace.id,
workspace_name: auth.currentWorkspace.name,
workspace_count: auth.workspaces?.length ?? 0,
});
}
};

View file

@ -1,28 +1,60 @@
<script setup lang="ts">
import { Head, router, usePoll } from '@inertiajs/vue3';
import { Head, router, usePage, usePoll } from '@inertiajs/vue3';
import { IconLoader2 } from '@tabler/icons-vue';
import { watch } from 'vue';
import { onMounted, watch } from 'vue';
import { useTracking } from '@/composables/useTracking';
import { home } from '@/routes/app';
import type { Auth } from '@/types';
const props = defineProps<{
subscriptionActive: boolean;
}>();
const page = usePage();
// Polls `auth` alongside so `auth.plan.interval` is fresh once the Stripe
// webhook creates the local Subscription row at /billing/processing's
// initial render that row doesn't exist yet, so the interval would default
// to 'monthly' even for a yearly purchase.
const { stop } = usePoll(2000, {
only: ['subscriptionActive'],
only: ['subscriptionActive', 'auth'],
});
const { trackPurchase } = useTracking();
const goHome = () => router.visit(home.url());
// `watch` (without `immediate`) only fires on transition false true, which
// is exactly the purchase moment. The `onMounted` fallback covers the case
// where the user lands here with an already-active subscription (back button,
// refresh after the redirect) we just bounce them home, no extra event.
watch(
() => props.subscriptionActive,
(active) => {
if (active) {
stop();
router.visit(home.url());
if (! active) {
return;
}
stop();
const plan = (page.props.auth as Auth | undefined)?.plan;
if (plan) {
trackPurchase({
name: plan.name,
interval: plan.interval,
});
}
goHome();
},
{ immediate: true },
);
onMounted(() => {
if (props.subscriptionActive) {
goHome();
}
});
</script>
<template>

View file

@ -7,6 +7,7 @@ import { ref } from 'vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { useTracking } from '@/composables/useTracking';
import { checkout } from '@/routes/app/billing';
interface Plan {
@ -29,6 +30,8 @@ defineProps<{
const isYearly = ref(false);
const processing = ref<string | null>(null);
const { trackBeginCheckout } = useTracking();
const getPrice = (plan: Plan): string => {
const key = `billing.subscribe.prices.${plan.slug}.${isYearly.value ? 'yearly' : 'monthly'}`;
return trans(key);
@ -36,7 +39,15 @@ const getPrice = (plan: Plan): string => {
const selectPlan = (plan: Plan) => {
processing.value = plan.id;
const interval = isYearly.value ? 'yearly' : 'monthly';
const priceId = isYearly.value ? plan.stripe_yearly_price_id : plan.stripe_monthly_price_id;
trackBeginCheckout({
name: plan.name,
interval,
});
router.post(checkout.url(plan.id), {
price_id: priceId,
});

View file

@ -11,11 +11,28 @@ export interface Workspace {
[key: string]: unknown;
}
export interface AuthPlan {
id: string;
slug: string;
name: string;
interval: 'monthly' | 'yearly';
}
export interface AuthAccount {
id: string;
name: string;
created_at: string | null;
}
export interface Auth {
user: User;
role: WorkspaceRole | null;
currentWorkspace: Workspace | null;
workspaces: Workspace[];
account: AuthAccount | null;
plan: AuthPlan | null;
hasActiveSubscription: boolean;
currentPriceId: string | null;
}
export interface FlashData {

View file

@ -120,6 +120,22 @@
);
});
test('shared auth.plan exposes name slug and interval via AuthPlanResource', function () {
config(['trypost.self_hosted' => false]);
$plan = Plan::where('slug', 'pro')->firstOrFail();
$this->account->update(['plan_id' => $plan->id]);
$response = $this->actingAs($this->user->fresh())->get(route('app.billing.processing'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->where('auth.plan.name', 'Pro')
->where('auth.plan.slug', 'pro')
->where('auth.plan.interval', 'monthly')
);
});
test('billing processing redirects to calendar in self hosted mode', function () {
config(['trypost.self_hosted' => true]);