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:
parent
6b8a6d536a
commit
96e1aa36b6
10 changed files with 215 additions and 16 deletions
|
|
@ -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,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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
64
resources/js/datalayer.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
17
resources/js/types/index.d.ts
vendored
17
resources/js/types/index.d.ts
vendored
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue