Skip to content

Commit 81224f3

Browse files
authored
feat: google ads conversion tracking (#25198)
* feat: google ads conversion tracking * gaClientid * store gclid in stripe metadata * tracking only in the us * track google campaign id as well * rename gclid -> google ads * fix: build * fix * refactor * fix: type check * fix: type check * fix: type check * fix: type check * fix: store it in cookie * refactor * fix * cleanup * linked ads tracking * refactor checkout session tracking
1 parent 700d8e7 commit 81224f3

File tree

22 files changed

+181
-95
lines changed

22 files changed

+181
-95
lines changed

apps/web/app/layout.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
115115
<head nonce={nonce}>
116116
<style>{`
117117
:root {
118-
--font-inter: ${interFont.style.fontFamily.replace(/\'/g, "")};
119-
--font-cal: ${calFont.style.fontFamily.replace(/\'/g, "")};
118+
--font-inter: ${interFont.style.fontFamily.replace(/'/g, "")};
119+
--font-cal: ${calFont.style.fontFamily.replace(/'/g, "")};
120120
}
121121
`}</style>
122122
</head>

apps/web/components/PageWrapperAppDir.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ function PageWrapper(props: PageWrapperProps) {
4646
id="page-status"
4747
// It is strictly not necessary to disable, but in a future update of react/no-danger this will error.
4848
// And we don't want it to error here anyways
49-
// eslint-disable-next-line react/no-danger
5049
dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }}
5150
/>
5251
{props.requiresLicense ? (

apps/web/server/lib/auth/sso/[provider]/getServerSideProps.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ssoTenantProduct } from "@calcom/features/ee/sso/lib/sso";
99
import { checkUsername } from "@calcom/features/profile/lib/checkUsername";
1010
import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service";
1111
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
12+
import { getTrackingFromCookies, type TrackingData } from "@calcom/lib/tracking";
1213
import { prisma } from "@calcom/prisma";
1314
import { z } from "zod";
1415

@@ -20,9 +21,9 @@ const Params = z.object({
2021

2122
export const getServerSideProps = async ({ req, query }: GetServerSidePropsContext) => {
2223

23-
const {
24-
provider: providerParam,
25-
email: emailParam,
24+
const {
25+
provider: providerParam,
26+
email: emailParam,
2627
username: usernameParam,
2728
} = Params.parse(query);
2829

@@ -40,11 +41,13 @@ export const getServerSideProps = async ({ req, query }: GetServerSidePropsConte
4041
if (usernameParam && session.user.email) {
4142
const availability = await checkUsername(usernameParam, currentOrgDomain);
4243
if (availability.available && availability.premium && IS_PREMIUM_USERNAME_ENABLED) {
44+
const tracking = getTrackingFromCookies(req.cookies);
4345
const stripePremiumUrl = await getStripePremiumUsernameUrl({
4446
userId: session.user.id.toString(),
4547
userEmail: session.user.email,
4648
username: usernameParam,
4749
successDestination,
50+
tracking,
4851
});
4952
if (stripePremiumUrl) {
5053
return {
@@ -112,13 +115,15 @@ type GetStripePremiumUsernameUrl = {
112115
userEmail: string;
113116
username: string;
114117
successDestination: string;
118+
tracking?: TrackingData;
115119
};
116120

117121
const getStripePremiumUsernameUrl = async ({
118122
userId,
119123
userEmail,
120124
username,
121125
successDestination,
126+
tracking,
122127
}: GetStripePremiumUsernameUrl): Promise<string | null> => {
123128
// @TODO: probably want to check if stripe user email already exists? or not
124129
const customer = await stripe.customers.create({
@@ -143,6 +148,8 @@ const getStripePremiumUsernameUrl = async ({
143148
allow_promotion_codes: true,
144149
metadata: {
145150
dubCustomerId: userId, // pass the userId during checkout creation for sales conversion tracking: https://d.to/conversions/stripe
151+
...(tracking?.googleAds?.gclid && { gclid: tracking.googleAds.gclid, campaignId: tracking.googleAds.campaignId }),
152+
...(tracking?.linkedInAds?.liFatId && { liFatId: tracking.linkedInAds.liFatId, linkedInCampaignId: tracking.linkedInAds?.campaignId }),
146153
},
147154
});
148155

packages/app-store/stripepayment/api/subscription.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/li
55
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
66
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
77
import { WEBAPP_URL } from "@calcom/lib/constants";
8+
import { getTrackingFromCookies } from "@calcom/lib/tracking";
89
import prisma from "@calcom/prisma";
910
import type { Prisma } from "@calcom/prisma/client";
1011

@@ -39,6 +40,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
3940
return;
4041
}
4142

43+
const tracking = getTrackingFromCookies(req.cookies);
44+
4245
const return_url = `${WEBAPP_URL}/api/integrations/stripepayment/paymentCallback?checkoutSessionId={CHECKOUT_SESSION_ID}&callbackUrl=${callbackUrl}`;
4346
const createSessionParams: Stripe.Checkout.SessionCreateParams = {
4447
mode: "subscription",
@@ -52,6 +55,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
5255
customer: customerId,
5356
success_url: return_url,
5457
cancel_url: return_url,
58+
metadata: {
59+
userId: userId.toString(),
60+
intentUsername,
61+
...(tracking?.googleAds?.gclid && { gclid: tracking.googleAds.gclid, campaignId: tracking.googleAds.campaignId }),
62+
...(tracking?.linkedInAds?.liFatId && { liFatId: tracking.linkedInAds.liFatId, linkedInCampaignId: tracking.linkedInAds?.campaignId }),
63+
},
5564
};
5665

5766
const checkPremiumResult = await checkPremiumUsername(intentUsername);

packages/features/calAIPhone/interfaces/AIPhoneService.interface.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { Logger } from "tslog";
22

3+
import type { TrackingData } from "@calcom/lib/tracking";
4+
35
import type { RetellAIPhoneServiceProviderTypeMap } from "../providers/retellAI";
46

57
/**
@@ -243,6 +245,7 @@ export interface AIPhoneServiceProvider<T extends AIPhoneServiceProviderType = A
243245
teamId?: number;
244246
agentId?: string | null;
245247
workflowId?: string;
248+
tracking?: TrackingData;
246249
}): Promise<{ url: string; message: string }>;
247250

248251
/**

packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { TrackingData } from "@calcom/lib/tracking";
2+
13
import type {
24
AIPhoneServiceProvider,
35
AIPhoneServiceProviderType,
@@ -169,6 +171,7 @@ export class RetellAIPhoneServiceProvider
169171
teamId?: number;
170172
agentId?: string | null;
171173
workflowId?: string;
174+
tracking?: TrackingData;
172175
}): Promise<{ url: string; message: string }> {
173176
return await this.service.generatePhoneNumberCheckoutSession(params);
174177
}

packages/features/calAIPhone/providers/retellAI/RetellAIService.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { TrackingData } from "@calcom/lib/tracking";
2+
13
import type {
24
AIPhoneServiceUpdateModelParams,
35
AIPhoneServiceCreatePhoneNumberParams,
@@ -265,6 +267,7 @@ export class RetellAIService {
265267
teamId?: number;
266268
agentId?: string | null;
267269
workflowId?: string;
270+
tracking?: TrackingData;
268271
}) {
269272
return this.billingService.generatePhoneNumberCheckoutSession(params);
270273
}

packages/features/calAIPhone/providers/retellAI/services/BillingService.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import stripe from "@calcom/features/ee/payments/server/stripe";
77
import { WEBAPP_URL, IS_PRODUCTION } from "@calcom/lib/constants";
88
import { HttpError } from "@calcom/lib/http-error";
99
import logger from "@calcom/lib/logger";
10+
import type { TrackingData } from "@calcom/lib/tracking";
1011
import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums";
1112

1213
import type { PhoneNumberRepositoryInterface } from "../../interfaces/PhoneNumberRepositoryInterface";
@@ -25,18 +26,20 @@ export class BillingService {
2526
phoneNumberRepository: PhoneNumberRepositoryInterface;
2627
retellRepository: RetellAIRepository;
2728
}
28-
) {}
29+
) { }
2930

3031
async generatePhoneNumberCheckoutSession({
3132
userId,
3233
teamId,
3334
agentId,
3435
workflowId,
36+
tracking,
3537
}: {
3638
userId: number;
3739
teamId?: number;
3840
agentId?: string | null;
3941
workflowId?: string;
42+
tracking?: TrackingData;
4043
}) {
4144
const phoneNumberPriceId = getPhoneNumberMonthlyPriceId();
4245

@@ -80,6 +83,8 @@ export class BillingService {
8083
agentId: agentId || "",
8184
workflowId: workflowId || "",
8285
type: CHECKOUT_SESSION_TYPES.PHONE_NUMBER_SUBSCRIPTION,
86+
...(tracking?.googleAds?.gclid && { gclid: tracking.googleAds.gclid, campaignId: tracking.googleAds.campaignId }),
87+
...(tracking?.linkedInAds?.liFatId && { liFatId: tracking.linkedInAds.liFatId, linkedInCampaignId: tracking.linkedInAds?.campaignId }),
8388
},
8489
subscription_data: {
8590
metadata: {
@@ -114,14 +119,14 @@ export class BillingService {
114119
// Find phone number with proper team authorization
115120
const phoneNumber = teamId
116121
? await this.deps.phoneNumberRepository.findByIdWithTeamAccess({
117-
id: phoneNumberId,
118-
teamId,
119-
userId,
120-
})
122+
id: phoneNumberId,
123+
teamId,
124+
userId,
125+
})
121126
: await this.deps.phoneNumberRepository.findByIdAndUserId({
122-
id: phoneNumberId,
123-
userId,
124-
});
127+
id: phoneNumberId,
128+
userId,
129+
});
125130

126131
if (!phoneNumber) {
127132
throw new HttpError({

packages/features/ee/billing/api/webhook/_checkout.session.completed.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone";
22
import stripe from "@calcom/features/ee/payments/server/stripe";
3+
import logger from "@calcom/lib/logger";
34
import { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository";
45
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
56
import { CreditsRepository } from "@calcom/lib/server/repository/credits";
@@ -10,9 +11,36 @@ import { CHECKOUT_SESSION_TYPES } from "../../constants";
1011
import type { SWHMap } from "./__handler";
1112
import { HttpCode } from "./__handler";
1213

14+
const log = logger.getSubLogger({ prefix: ["checkout.session.completed"] });
15+
1316
const handler = async (data: SWHMap["checkout.session.completed"]["data"]) => {
1417
const session = data.object;
1518

19+
// Store ad tracking data in Stripe customer metadata for Zapier tracking
20+
if (session.customer && session.metadata) {
21+
try {
22+
const trackingMetadata = {
23+
gclid: session.metadata?.gclid,
24+
campaignId: session.metadata?.campaignId,
25+
liFatId: session.metadata?.liFatId,
26+
linkedInCampaignId: session.metadata?.linkedInCampaignId,
27+
};
28+
29+
const cleanedMetadata = Object.fromEntries(
30+
Object.entries(trackingMetadata).filter(([_, value]) => value)
31+
);
32+
33+
if (Object.keys(cleanedMetadata).length > 0) {
34+
const customerId = typeof session.customer === "string" ? session.customer : session.customer.id;
35+
await stripe.customers.update(customerId, {
36+
metadata: cleanedMetadata,
37+
});
38+
}
39+
} catch (error) {
40+
log.error("Failed to update Stripe customer metadata with ad tracking data", { error });
41+
}
42+
}
43+
1644
if (session.metadata?.type === CHECKOUT_SESSION_TYPES.PHONE_NUMBER_SUBSCRIPTION) {
1745
return await handleCalAIPhoneNumberSubscription(session);
1846
}

packages/features/ee/organizations/pages/settings/other-team-profile-view.tsx

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"use client";
22

33
import { zodResolver } from "@hookform/resolvers/zod";
4-
import { useSession } from "next-auth/react";
54
import Link from "next/link";
65
import { useRouter } from "next/navigation";
76
import { useEffect, useLayoutEffect, useState } from "react";
@@ -32,7 +31,6 @@ import { ImageUploader } from "@calcom/ui/components/image-uploader";
3231
import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
3332
import { showToast } from "@calcom/ui/components/toast";
3433
import { revalidateTeamDataCache } from "@calcom/web/app/(booking-page-wrapper)/team/[slug]/[type]/actions";
35-
import { revalidateTeamsList } from "@calcom/web/app/(use-page-wrapper)/(main-nav)/teams/actions";
3634

3735
import { subdomainSuffix } from "../../../organizations/lib/orgDomains";
3836

@@ -54,7 +52,6 @@ const OtherTeamProfileView = () => {
5452
const { t } = useLocale();
5553
const router = useRouter();
5654
const utils = trpc.useUtils();
57-
const session = useSession();
5855
const [firstRender, setFirstRender] = useState(true);
5956

6057
useLayoutEffect(() => {
@@ -133,19 +130,6 @@ const OtherTeamProfileView = () => {
133130
},
134131
});
135132

136-
const removeMemberMutation = trpc.viewer.teams.removeMember.useMutation({
137-
async onSuccess() {
138-
await utils.viewer.teams.get.invalidate();
139-
await utils.viewer.teams.list.invalidate();
140-
revalidateTeamsList();
141-
await utils.viewer.eventTypes.invalidate();
142-
showToast(t("success"), "success");
143-
},
144-
async onError(err) {
145-
showToast(err.message, "error");
146-
},
147-
});
148-
149133
const publishMutation = trpc.viewer.teams.publish.useMutation({
150134
async onSuccess(data: { url?: string }) {
151135
if (data.url) {
@@ -161,14 +145,6 @@ const OtherTeamProfileView = () => {
161145
if (team?.id) deleteTeamMutation.mutate({ teamId: team.id });
162146
}
163147

164-
function leaveTeam() {
165-
if (team?.id && session.data)
166-
removeMemberMutation.mutate({
167-
teamIds: [team.id],
168-
memberIds: [session.data.user.id],
169-
});
170-
}
171-
172148
if (!team) return null;
173149

174150
return (
@@ -291,7 +267,6 @@ const OtherTeamProfileView = () => {
291267
<Label className="text-emphasis mt-5">{t("about")}</Label>
292268
<div
293269
className=" text-subtle break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
294-
// eslint-disable-next-line react/no-danger
295270
dangerouslySetInnerHTML={{ __html: markdownToSafeHTML(team.bio) }}
296271
/>
297272
</>

0 commit comments

Comments
 (0)