diff --git a/app/Http/Middleware/App/HandleInertiaRequests.php b/app/Http/Middleware/App/HandleInertiaRequests.php
index da9300ed..1ffcb50f 100644
--- a/app/Http/Middleware/App/HandleInertiaRequests.php
+++ b/app/Http/Middleware/App/HandleInertiaRequests.php
@@ -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,
],
diff --git a/app/Http/Resources/App/HandleInertiaRequests/AuthAccountResource.php b/app/Http/Resources/App/HandleInertiaRequests/AuthAccountResource.php
new file mode 100644
index 00000000..84e44053
--- /dev/null
+++ b/app/Http/Resources/App/HandleInertiaRequests/AuthAccountResource.php
@@ -0,0 +1,22 @@
+
+ */
+ public static function make(Account $account): array
+ {
+ return [
+ 'id' => $account->id,
+ 'name' => $account->name,
+ 'created_at' => $account->created_at,
+ ];
+ }
+}
diff --git a/app/Http/Resources/App/HandleInertiaRequests/AuthPlanResource.php b/app/Http/Resources/App/HandleInertiaRequests/AuthPlanResource.php
new file mode 100644
index 00000000..b500fe14
--- /dev/null
+++ b/app/Http/Resources/App/HandleInertiaRequests/AuthPlanResource.php
@@ -0,0 +1,29 @@
+
+ */
+ 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,
+ ];
+ }
+}
diff --git a/resources/js/app.ts b/resources/js/app.ts
index f2e142a8..994667e5 100644
--- a/resources/js/app.ts
+++ b/resources/js/app.ts
@@ -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, {
diff --git a/resources/js/composables/useTracking.ts b/resources/js/composables/useTracking.ts
index c4f3ff6e..da363f02 100644
--- a/resources/js/composables/useTracking.ts
+++ b/resources/js/composables/useTracking.ts
@@ -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,
});
},
diff --git a/resources/js/datalayer.ts b/resources/js/datalayer.ts
new file mode 100644
index 00000000..37160fcc
--- /dev/null
+++ b/resources/js/datalayer.ts
@@ -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,
+ });
+ }
+};
diff --git a/resources/js/pages/billing/Processing.vue b/resources/js/pages/billing/Processing.vue
index cf89327b..d1a60087 100644
--- a/resources/js/pages/billing/Processing.vue
+++ b/resources/js/pages/billing/Processing.vue
@@ -1,28 +1,60 @@
diff --git a/resources/js/pages/billing/Subscribe.vue b/resources/js/pages/billing/Subscribe.vue
index ba83d449..370d6bf5 100644
--- a/resources/js/pages/billing/Subscribe.vue
+++ b/resources/js/pages/billing/Subscribe.vue
@@ -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(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,
});
diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts
index 66e9bf89..6892b075 100644
--- a/resources/js/types/index.d.ts
+++ b/resources/js/types/index.d.ts
@@ -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 {
diff --git a/tests/Feature/BillingControllerTest.php b/tests/Feature/BillingControllerTest.php
index 9c2eb80d..003f1772 100644
--- a/tests/Feature/BillingControllerTest.php
+++ b/tests/Feature/BillingControllerTest.php
@@ -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]);