feat(billing): attach persona to conversion tracking and polish annual banner
Send the user's persona on both the server-side subscription.created event and the client-side checkout.completed/dataLayer purchase event, for ICP analysis. Hold the processing screen ~5s before redirecting so PostHog and the ad pixels (Google/Meta via GTM) reliably flush. Sharpen the annual-upgrade banner copy (lead with '2 months free') and give its check icon a white tile.
This commit is contained in:
parent
b628f30f1b
commit
bde8d4801e
9 changed files with 58 additions and 25 deletions
|
|
@ -40,6 +40,7 @@ public function processing(Request $request): Response|RedirectResponse
|
|||
return Inertia::render('billing/Processing', [
|
||||
'subscriptionActive' => $account && $account->subscribed(Account::SUBSCRIPTION_NAME),
|
||||
'fromCheckout' => $fromCheckout,
|
||||
'persona' => $request->user()->persona?->value,
|
||||
'conversion' => $fromCheckout && $account?->stripe_id
|
||||
? fn () => $this->buildConversionData($account, $sessionId)
|
||||
: null,
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ public function handle(PostHogService $postHog): void
|
|||
return;
|
||||
}
|
||||
|
||||
$account = Account::with('plan')->find($this->accountId);
|
||||
$account = Account::with(['plan', 'owner'])->find($this->accountId);
|
||||
|
||||
if (! $account || ! $account->owner_id) {
|
||||
return;
|
||||
|
|
@ -53,6 +53,7 @@ public function handle(PostHogService $postHog): void
|
|||
'plan' => $account->plan?->name,
|
||||
'plan_slug' => $account->plan?->slug->value,
|
||||
'previous_plan' => $this->previousPlan,
|
||||
'persona' => $account->owner?->persona?->value,
|
||||
],
|
||||
$account,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@
|
|||
],
|
||||
|
||||
'annual_banner' => [
|
||||
'title' => 'Save 2 months with annual billing',
|
||||
'description' => 'Switch to annual and get 2 months free — pay less every month.',
|
||||
'title' => 'Get 2 months free',
|
||||
'description' => 'Switch to annual billing and pay less every month — same plan, nothing else changes.',
|
||||
'cta' => 'Upgrade to annual',
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@
|
|||
],
|
||||
|
||||
'annual_banner' => [
|
||||
'title' => 'Ahorra 2 meses con la facturación anual',
|
||||
'description' => 'Cambia al anual y obtén 2 meses gratis — paga menos cada mes.',
|
||||
'title' => 'Consigue 2 meses gratis',
|
||||
'description' => 'Cambia a la facturación anual y paga menos al mes — el mismo plan, sin cambios.',
|
||||
'cta' => 'Cambiar a anual',
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@
|
|||
],
|
||||
|
||||
'annual_banner' => [
|
||||
'title' => 'Economize 2 meses com a cobrança anual',
|
||||
'description' => 'Mude para o anual e ganhe 2 meses grátis — pague menos por mês.',
|
||||
'title' => 'Ganhe 2 meses grátis',
|
||||
'description' => 'Mude para a cobrança anual e pague menos por mês — sem mudar nada do seu plano.',
|
||||
'cta' => 'Mudar para anual',
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -33,10 +33,12 @@ export const useTracking = () => ({
|
|||
trackPurchase: (
|
||||
plan: { name: string; interval: string },
|
||||
conversion?: { value: number; currency: string; transaction_id: string } | null,
|
||||
persona?: string | null,
|
||||
) => {
|
||||
captureEvent('checkout.completed', {
|
||||
plan_name: plan.name,
|
||||
interval: plan.interval,
|
||||
...(persona ? { persona } : {}),
|
||||
...(conversion ? {
|
||||
conversion_value: conversion.value,
|
||||
conversion_currency: conversion.currency,
|
||||
|
|
@ -48,6 +50,7 @@ export const useTracking = () => ({
|
|||
event: 'purchase',
|
||||
plan_name: plan.name,
|
||||
plan_interval: plan.interval,
|
||||
...(persona ? { persona } : {}),
|
||||
...(conversion ? {
|
||||
conversion_value: conversion.value,
|
||||
conversion_currency: conversion.currency,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, router, usePage, usePoll } from '@inertiajs/vue3';
|
||||
import { IconLoader2 } from '@tabler/icons-vue';
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import { useTracking } from '@/composables/useTracking';
|
||||
import { accounts } from '@/routes/app';
|
||||
|
|
@ -10,9 +10,15 @@ import type { Auth } from '@/types';
|
|||
const props = defineProps<{
|
||||
subscriptionActive: boolean;
|
||||
fromCheckout: boolean;
|
||||
persona?: string | null;
|
||||
conversion?: { value: number; currency: string; transaction_id: string } | null;
|
||||
}>();
|
||||
|
||||
// Hold on the processing screen after firing the purchase event so PostHog and
|
||||
// the ad pixels (Google/Meta via dataLayer → GTM) have time to send before we
|
||||
// navigate away — an immediate redirect can cut those requests off.
|
||||
const REDIRECT_DELAY_MS = 5000;
|
||||
|
||||
const page = usePage();
|
||||
|
||||
// Polls `auth` alongside so `auth.plan.interval` is fresh once the Stripe
|
||||
|
|
@ -25,9 +31,10 @@ const { stop } = usePoll(2000, {
|
|||
|
||||
const { trackPurchase } = useTracking();
|
||||
|
||||
const tracked = ref(false);
|
||||
const finishing = ref(false);
|
||||
let redirectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const goToAccounts = () => router.visit(accounts.url({ query: { openDialog: true } }));
|
||||
const goToAccounts = () => router.visit(accounts.url());
|
||||
|
||||
// Fires `checkout.completed` exactly once for a real checkout. A trial-with-card
|
||||
// subscription is already `subscribed()` (status `trialing`) by the time the
|
||||
|
|
@ -36,24 +43,25 @@ const goToAccounts = () => router.visit(accounts.url({ query: { openDialog: true
|
|||
// from whichever path runs first (immediate active state or poll transition),
|
||||
// gated on `fromCheckout` so back-button/refresh visits don't over-count.
|
||||
const completePurchase = () => {
|
||||
if (finishing.value) {
|
||||
return;
|
||||
}
|
||||
finishing.value = true;
|
||||
stop();
|
||||
|
||||
if (! tracked.value && props.fromCheckout) {
|
||||
tracked.value = true;
|
||||
const plan = (page.props.auth as Auth | undefined)?.plan;
|
||||
|
||||
const plan = (page.props.auth as Auth | undefined)?.plan;
|
||||
if (plan) {
|
||||
trackPurchase(
|
||||
{
|
||||
name: plan.name,
|
||||
interval: plan.interval,
|
||||
},
|
||||
props.conversion ?? null,
|
||||
);
|
||||
}
|
||||
if (props.fromCheckout && plan) {
|
||||
trackPurchase(
|
||||
{ name: plan.name, interval: plan.interval },
|
||||
props.conversion ?? null,
|
||||
props.persona ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
goToAccounts();
|
||||
// Always hold for the same window before navigating, so PostHog and the ad
|
||||
// pixels (Google/Meta via dataLayer → GTM) reliably flush.
|
||||
redirectTimer = setTimeout(goToAccounts, REDIRECT_DELAY_MS);
|
||||
};
|
||||
|
||||
watch(
|
||||
|
|
@ -70,6 +78,12 @@ onMounted(() => {
|
|||
completePurchase();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (redirectTimer) {
|
||||
clearTimeout(redirectTimer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -99,8 +99,8 @@ const upgradeToAnnual = (): void => {
|
|||
v-if="showAnnualBanner"
|
||||
class="flex flex-wrap items-center gap-4 rounded-2xl border-2 border-foreground bg-emerald-100 p-5 shadow-2xs"
|
||||
>
|
||||
<span class="inline-flex size-12 -rotate-3 items-center justify-center rounded-2xl border-2 border-foreground bg-emerald-300 shadow-2xs">
|
||||
<IconRosetteDiscountCheck class="size-6 text-foreground" stroke-width="2" />
|
||||
<span class="inline-flex size-12 -rotate-3 items-center justify-center rounded-2xl border-2 border-foreground bg-card shadow-2xs">
|
||||
<IconRosetteDiscountCheck class="size-6 text-emerald-600" stroke-width="2.25" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-base font-bold text-foreground">
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\PostHog\BillingEvent;
|
||||
use App\Enums\User\Persona;
|
||||
use App\Jobs\PostHog\SendEvent;
|
||||
use App\Jobs\PostHog\SyncUser;
|
||||
use App\Jobs\PostHog\TrackBilling;
|
||||
|
|
@ -50,6 +51,19 @@
|
|||
});
|
||||
});
|
||||
|
||||
test('handle forwards the owner persona as an event property', function () {
|
||||
$this->user->update(['persona' => Persona::Agency->value]);
|
||||
Queue::fake();
|
||||
|
||||
(new TrackBilling((string) $this->account->id, BillingEvent::Created, $this->payload))
|
||||
->handle(app(PostHogService::class));
|
||||
|
||||
Queue::assertPushed(
|
||||
SendEvent::class,
|
||||
fn ($job) => ($job->payload['properties']['persona'] ?? null) === Persona::Agency->value,
|
||||
);
|
||||
});
|
||||
|
||||
test('handle forwards previousPlan as a property when supplied', function () {
|
||||
Queue::fake();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue