feat: Implement Dub.co for conversion tracking (#16165)

* FEAT: Implement Dub.co for conversion tracking

* add link creation, @vercel/functions

* use refer.cal.com/:username instead of go.cal.com/r/:username

* sale conversion tracking

* add copy referral link

* limit to IS_CALCOM only

* Update yarn.lock

* fix DubAnalytics

* use workaround for isNewUser

* pass req to getOptions

* Update next-auth-options.ts

* fix ts errors

* only show DubAnalytics outside EU

* add Dub Analytics to /signup

* use WEBSITE_URL instead

* on-demand generate links

* add migration

* add check for existing link + change fetch method to poast

* remove refer.cal.com from PoweredByCal

* limit DubAnalytics to /signup only

* simplify generate-referral-link

* restore yarn.lock

* add yarn with dub sdk in

* add yarn with dub sdk in

* yarn

* Update yarn.lock

---------

Co-authored-by: sean <sean@brydon.io>
Co-authored-by: Peer Richelsen <peer@cal.com>
This commit is contained in:
authored and GitHub committed 2024-09-12 12:33:10 -07:00
1 parent f4bc7d2ea4
commit cf16cd3ed2
49 files changed
+1548 -120

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
import { Dub } from "dub";
export const dub = new Dub({
token: process.env.DUB_API_KEY,
});
@@ -0,0 +1,8 @@
import { getServerSession } from "next-auth";
import { cookies } from "next/headers";
import { getOptions } from "./next-auth-options";
export async function getServerSessionForAppDir() {
return await getServerSession(getOptions({ getDclid: () => cookies().get("dclid")?.value }));
}
@@ -1,5 +1,6 @@
import type { Membership, Team, UserPermissionRole } from "@prisma/client";
import type { AuthOptions, Session } from "next-auth";
import { waitUntil } from "@vercel/functions";
import type { AuthOptions, Session, User } from "next-auth";
import type { JWT } from "next-auth/jwt";
import { encode } from "next-auth/jwt";
import type { Provider } from "next-auth/providers";
@@ -13,7 +14,7 @@ import ImpersonationProvider from "@calcom/features/ee/impersonation/lib/Imperso
import { getOrgFullOrigin, subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import { clientSecretVerifier, hostedCal, isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { HOSTED_CAL_FEATURES, IS_CALCOM } from "@calcom/lib/constants";
import { ENABLE_PROFILE_SWITCHER, IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@calcom/lib/crypto";
import { defaultCookies } from "@calcom/lib/default-cookies";
@@ -29,6 +30,7 @@ import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums";
import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils";
import { ErrorCode } from "./ErrorCode";
import { dub } from "./dub";
import { isPasswordValid } from "./isPasswordValid";
import CalComAdapter from "./next-auth-custom-adapter";
import { verifyPassword } from "./verifyPassword";
@@ -235,6 +237,7 @@ const providers: Provider[] = [
belongsToActiveTeam: hasActiveTeams,
locale: user.locale,
profile: user.allProfiles[0],
createdAt: user.createdDate,
};
},
}),
@@ -404,7 +407,12 @@ const mapIdentityProvider = (providerName: string) => {
}
};
export const AUTH_OPTIONS: AuthOptions = {
export const getOptions = ({
getDclid,
}: {
/** so we can extract the Dub cookie in both pages and app routers */
getDclid: () => string | undefined;
}): AuthOptions => ({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
adapter: calcomAdapter,
@@ -924,7 +932,43 @@ export const AUTH_OPTIONS: AuthOptions = {
return baseUrl;
},
},
};
events: {
async signIn(message) {
/* only run this code if:
- it's a hosted cal account
- DUB_API_KEY is configured
- it's a new user
*/
const user = message.user as User & {
username: string;
createdAt: string;
};
// check if the user was created in the last 10 minutes
// this is a workaround – in the future once we move to use the Account model in the DB
// we should use NextAuth's isNewUser flag instead: https://next-auth.js.org/configuration/events#signin
const isNewUser = new Date(user.createdAt) > new Date(Date.now() - 10 * 60 * 1000);
if ((isENVDev || IS_CALCOM) && process.env.DUB_API_KEY && isNewUser) {
const dclid = getDclid();
// check if there's a dclid cookie set by @dub/analytics
if (dclid) {
// here we use waitUntil – meaning this code will run async to not block the main thread
waitUntil(
// if so, send a lead event to Dub
// @see https://d.to/conversions/next-auth
dub.track.lead({
clickId: dclid,
eventName: "Sign Up",
customerId: user.id.toString(),
customerName: user.name,
customerEmail: user.email,
customerAvatar: user.image,
})
);
}
}
},
},
});
/**
* Identifies the profile the user should be logged into.
+1
View File
@@ -13,6 +13,7 @@
"@calcom/trpc": "*",
"@calcom/ui": "*",
"bcryptjs": "^2.4.3",
"dub": "^0.35.0",
"handlebars": "^4.7.7",
"jose": "^4.13.1",
"lru-cache": "^9.0.3",
@@ -79,6 +79,7 @@ export const generateTeamCheckoutSession = async ({
teamName,
teamSlug,
userId,
dubCustomerId: userId, // pass the userId during checkout creation for sales conversion tracking: https://d.to/conversions/stripe
},
});
return session;
@@ -173,6 +174,7 @@ export const purchaseTeamOrOrgSubscription = async (input: {
subscription_data: {
metadata: {
teamId,
dubCustomerId: userId,
},
},
});
+1
View File
@@ -13,6 +13,7 @@
"@calcom/ui": "*",
"@lexical/react": "^0.9.0",
"@tanstack/react-table": "^8.9.3",
"@vercel/functions": "^1.4.0",
"framer-motion": "^10.12.8",
"lexical": "^0.9.0",
"react-select": "^5.7.0",
@@ -1,8 +1,7 @@
import { getServerSession } from "next-auth";
import dynamic from "next/dynamic";
import React from "react";
import { AUTH_OPTIONS } from "@calcom/features/auth/lib/next-auth-options";
import { getServerSessionForAppDir } from "@calcom/feature-auth/lib/get-server-session-for-app-dir";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import type { SettingsLayoutProps } from "./SettingsLayoutAppDirClient";
@@ -14,7 +13,7 @@ const SettingsLayoutAppDirClient = dynamic(() => import("./SettingsLayoutAppDirC
type SettingsLayoutAppDir = Omit<SettingsLayoutProps, "currentOrg" | "otherTeams">;
export default async function SettingsLayoutAppDir(props: SettingsLayoutAppDir) {
const session = await getServerSession(AUTH_OPTIONS);
const session = await getServerSessionForAppDir();
const userId = session?.user?.id ?? -1;
const orgId = session?.user?.org?.id ?? -1;
let currentOrg = null;
+24 -1
View File
@@ -42,6 +42,7 @@ import {
APP_NAME,
DESKTOP_APP_LINK,
ENABLE_PROFILE_SWITCHER,
IS_CALCOM,
IS_VISUAL_REGRESSION_TESTING,
JOIN_COMMUNITY,
ROADMAP,
@@ -52,6 +53,7 @@ import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useFormbricks } from "@calcom/lib/formbricks-client";
import getBrandColours from "@calcom/lib/getBrandColours";
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useNotifications, ButtonState } from "@calcom/lib/hooks/useNotifications";
import useTheme from "@calcom/lib/hooks/useTheme";
@@ -927,6 +929,7 @@ function SideBarContainer({ bannersHeight, isPlatformUser = false }: SideBarCont
}
function SideBar({ bannersHeight, user }: SideBarProps) {
const { isCopied, copyToClipboard, resetCopyStatus } = useCopy();
const { t, isLocaleReady } = useLocale();
const orgBranding = useOrgBranding();
const pathname = usePathname();
@@ -960,12 +963,32 @@ function SideBar({ bannersHeight, user }: SideBarProps) {
},
icon: "copy",
},
IS_CALCOM
? {
name: "copy_referral_link",
href: "",
onClick: async (e: { preventDefault: () => void }) => {
e.preventDefault();
const res = await fetch("/api/generate-referral-link", {
method: "POST",
});
const { shortLink } = await res.json();
copyToClipboard(shortLink);
if (isCopied) {
showToast(t("link_copied"), "success");
}
},
icon: "copy",
}
: null,
{
name: "settings",
href: user?.org ? `/settings/organizations/profile` : "/settings/my-account/profile",
icon: "settings",
},
];
].filter(Boolean) as NavigationItemType[];
return (
<div className="relative">
<aside
+5
View File
@@ -9,6 +9,11 @@ export function useCopy() {
.writeText(text)
.then(() => setIsCopied(true))
.catch((error) => console.error("Copy to clipboard failed:", error));
} else {
console.warn(
"You need to use a secure context to use clipboard \n Please use the following link: ",
text
);
}
};
+1
View File
@@ -225,6 +225,7 @@ export class UserRepository {
},
},
},
createdDate: true,
},
});
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "referralLinkId" TEXT;
+1
View File
@@ -335,6 +335,7 @@ model User {
smsLockState SMSLockState @default(UNLOCKED)
smsLockReviewedByAdmin Boolean @default(false)
NotificationsSubscriptions NotificationsSubscriptions[]
referralLinkId String?
@@unique([email])
@@unique([email, username])