feat: store utm tags in stripe on signup (#26838)
* feat: store utm params in stripe on signup * fix: fallback to cookie when query params don't contain valid UTM data Changed from else-if to separate if statement so that when query params exist but don't contain valid UTM data, the cookie fallback is still tried. Previously, any request with non-UTM query params would skip the stored cookie data entirely. Co-Authored-By: unknown <> * fix: e2e --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
7 files changed
+106
-41
No files matched your search
@@ -35,7 +35,7 @@ import {
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["signupCalcomHandler"] });
|
||||
|
||||
const handler: CustomNextApiHandler = async (body, usernameStatus) => {
|
||||
const handler: CustomNextApiHandler = async (body, usernameStatus, query) => {
|
||||
const {
|
||||
email: _email,
|
||||
password,
|
||||
@@ -122,7 +122,7 @@ const handler: CustomNextApiHandler = async (body, usernameStatus) => {
|
||||
// Create the customer in Stripe with ad tracking metadata
|
||||
const cookieStore = await cookies();
|
||||
const cookiesObj = Object.fromEntries(cookieStore.getAll().map((c) => [c.name, c.value]));
|
||||
const tracking = getTrackingFromCookies(cookiesObj);
|
||||
const tracking = getTrackingFromCookies(cookiesObj, query);
|
||||
|
||||
const customer = await billingService.createCustomer({
|
||||
email,
|
||||
@@ -179,7 +179,7 @@ const handler: CustomNextApiHandler = async (body, usernameStatus) => {
|
||||
},
|
||||
});
|
||||
if (team) {
|
||||
const organizationId = team.isOrganization ? team.id : team.parent?.id ?? null;
|
||||
const organizationId = team.isOrganization ? team.id : (team.parent?.id ?? null);
|
||||
|
||||
if (username) {
|
||||
const existingUserByUsername = await prisma.user.findFirst({
|
||||
@@ -272,10 +272,7 @@ const handler: CustomNextApiHandler = async (body, usernameStatus) => {
|
||||
if (isPrismaError(error) && error.code === "P2002") {
|
||||
const target = String(error.meta?.target ?? "");
|
||||
if (target.includes("email") || target.includes("username")) {
|
||||
return NextResponse.json(
|
||||
{ message: SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS },
|
||||
{ status: 409 }
|
||||
);
|
||||
return NextResponse.json({ message: SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS }, { status: 409 });
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
|
||||
@@ -47,6 +47,7 @@ async function handler(req: NextRequest) {
|
||||
});
|
||||
|
||||
const body = await parseRequestData(req);
|
||||
const query = Object.fromEntries(req.nextUrl.searchParams.entries());
|
||||
await checkCfTurnstileToken({
|
||||
token: req.headers.get("cf-access-token") as string,
|
||||
remoteIp,
|
||||
@@ -62,7 +63,7 @@ async function handler(req: NextRequest) {
|
||||
* @zomars: We need to be able to test this with E2E. They way it's done RN it will never run on CI.
|
||||
*/
|
||||
if (IS_PREMIUM_USERNAME_ENABLED) {
|
||||
return await calcomSignupHandler(body);
|
||||
return await calcomSignupHandler(body, query);
|
||||
}
|
||||
|
||||
return await selfHostedSignupHandler(body);
|
||||
|
||||
@@ -11,7 +11,7 @@ const handler = (req: NextApiRequest, res: NextApiResponse) =>
|
||||
res,
|
||||
getOptions({
|
||||
getDubId: () => req.cookies.dub_id || req.cookies.dclid,
|
||||
getTrackingData: () => getTrackingFromCookies(req.cookies),
|
||||
getTrackingData: () => getTrackingFromCookies(req.cookies, req.query),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -1205,6 +1205,7 @@ export const getOptions = ({
|
||||
liFatId: tracking.linkedInAds.liFatId,
|
||||
linkedInCampaignId: tracking.linkedInAds.campaignId,
|
||||
}),
|
||||
...(tracking.utmData && tracking.utmData),
|
||||
},
|
||||
});
|
||||
await prisma.user.update({
|
||||
|
||||
@@ -25,7 +25,28 @@ export type SignupResult =
|
||||
| { ok: false; status: number; error: SignupErrorResponse };
|
||||
|
||||
export async function fetchSignup(data: SignupData, cfToken?: string): Promise<SignupResult> {
|
||||
const response = await fetch("/api/auth/signup", {
|
||||
const allParams = new URLSearchParams(window.location.search);
|
||||
|
||||
const utmParams = new URLSearchParams();
|
||||
const utmKeys = [
|
||||
"utm_source",
|
||||
"utm_medium",
|
||||
"utm_campaign",
|
||||
"utm_term",
|
||||
"utm_content",
|
||||
"utm_id",
|
||||
"utm_referral",
|
||||
"landing_page",
|
||||
];
|
||||
|
||||
utmKeys.forEach((key) => {
|
||||
const value = allParams.get(key);
|
||||
if (value) utmParams.set(key, value);
|
||||
});
|
||||
|
||||
const url = utmParams.toString() ? `/api/auth/signup?${utmParams.toString()}` : "/api/auth/signup";
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -60,9 +81,13 @@ export async function fetchSignup(data: SignupData, cfToken?: string): Promise<S
|
||||
}
|
||||
|
||||
export function isUserAlreadyExistsError(result: SignupResult): boolean {
|
||||
return !result.ok && result.status === 409 && result.error.message === SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS;
|
||||
return (
|
||||
!result.ok && result.status === 409 && result.error.message === SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS
|
||||
);
|
||||
}
|
||||
|
||||
export function hasCheckoutSession(result: SignupResult): result is { ok: false; status: number; error: SignupErrorResponse & { checkoutSessionId: string } } {
|
||||
export function hasCheckoutSession(
|
||||
result: SignupResult
|
||||
): result is { ok: false; status: number; error: SignupErrorResponse & { checkoutSessionId: string } } {
|
||||
return !result.ok && !!result.error.checkoutSessionId;
|
||||
}
|
||||
@@ -33,7 +33,8 @@ type UsernameStatus = {
|
||||
|
||||
export type CustomNextApiHandler = (
|
||||
body: Record<string, string>,
|
||||
usernameStatus: UsernameStatus
|
||||
usernameStatus: UsernameStatus,
|
||||
query?: Record<string, string>
|
||||
) => Promise<NextResponse<any>>;
|
||||
|
||||
export async function isBlacklisted(username: string) {
|
||||
@@ -85,27 +86,28 @@ const processResult = (
|
||||
}
|
||||
};
|
||||
|
||||
const usernameHandler = (handler: CustomNextApiHandler) => async (body: Record<string, string>) => {
|
||||
const username = slugify(body.username);
|
||||
const check = await usernameCheckForSignup({ username, email: body.email });
|
||||
const usernameHandler =
|
||||
(handler: CustomNextApiHandler) => async (body: Record<string, string>, query: Record<string, string>) => {
|
||||
const username = slugify(body.username);
|
||||
const check = await usernameCheckForSignup({ username, email: body.email });
|
||||
|
||||
let result: Parameters<typeof processResult>[0] = "ok";
|
||||
if (check.premium) result = "is_premium";
|
||||
if (!check.available) result = "username_exists";
|
||||
let result: Parameters<typeof processResult>[0] = "ok";
|
||||
if (check.premium) result = "is_premium";
|
||||
if (!check.available) result = "username_exists";
|
||||
|
||||
const { statusCode, message } = processResult(result);
|
||||
const usernameStatus = {
|
||||
statusCode,
|
||||
requestedUserName: username,
|
||||
json: {
|
||||
available: result !== "username_exists",
|
||||
premium: result === "is_premium",
|
||||
message,
|
||||
suggestion: check.suggestedUsername,
|
||||
},
|
||||
const { statusCode, message } = processResult(result);
|
||||
const usernameStatus = {
|
||||
statusCode,
|
||||
requestedUserName: username,
|
||||
json: {
|
||||
available: result !== "username_exists",
|
||||
premium: result === "is_premium",
|
||||
message,
|
||||
suggestion: check.suggestedUsername,
|
||||
},
|
||||
};
|
||||
return handler(body, usernameStatus, query);
|
||||
};
|
||||
return handler(body, usernameStatus);
|
||||
};
|
||||
|
||||
const usernameCheck = async (usernameRaw: string, currentOrgDomain?: string | null) => {
|
||||
log.debug("usernameCheck", { usernameRaw, currentOrgDomain });
|
||||
@@ -124,10 +126,7 @@ const usernameCheck = async (usernameRaw: string, currentOrgDomain?: string | nu
|
||||
const organization = await prisma.team.findFirst({
|
||||
where: {
|
||||
isOrganization: true,
|
||||
OR: [
|
||||
{ slug: currentOrgDomain },
|
||||
{ metadata: { path: ["requestedSlug"], equals: currentOrgDomain } },
|
||||
],
|
||||
OR: [{ slug: currentOrgDomain }, { metadata: { path: ["requestedSlug"], equals: currentOrgDomain } }],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
const utmTrackingDataSchema = z.object({
|
||||
utm_source: z.string().optional(),
|
||||
utm_medium: z.string().optional(),
|
||||
utm_campaign: z.string().optional(),
|
||||
utm_term: z.string().optional(),
|
||||
utm_content: z.string().optional(),
|
||||
utm_id: z.string().optional(),
|
||||
utm_referral: z.string().optional(),
|
||||
landing_page: z.string().optional(),
|
||||
});
|
||||
|
||||
export type GoogleAdsTrackingData = {
|
||||
gclid: string;
|
||||
@@ -10,30 +22,60 @@ export type LinkedInAdsTrackingData = {
|
||||
campaignId?: string;
|
||||
};
|
||||
|
||||
export type UtmTrackingData = z.infer<typeof utmTrackingDataSchema>;
|
||||
|
||||
export type TrackingData = {
|
||||
googleAds?: GoogleAdsTrackingData;
|
||||
linkedInAds?: LinkedInAdsTrackingData;
|
||||
utmData?: UtmTrackingData;
|
||||
};
|
||||
|
||||
|
||||
export function getTrackingFromCookies(cookies?: NextApiRequest["cookies"]): TrackingData {
|
||||
export function getTrackingFromCookies(
|
||||
cookies?: NextApiRequest["cookies"],
|
||||
query?: NextApiRequest["query"]
|
||||
): TrackingData {
|
||||
const tracking: TrackingData = {};
|
||||
|
||||
if (!cookies) return tracking;
|
||||
|
||||
if (process.env.GOOGLE_ADS_ENABLED === "1" && cookies.gclid) {
|
||||
if (process.env.GOOGLE_ADS_ENABLED === "1" && cookies?.gclid) {
|
||||
tracking.googleAds = {
|
||||
gclid: cookies.gclid,
|
||||
...(cookies.gad_campaignId && { campaignId: cookies.gad_campaignId }),
|
||||
};
|
||||
}
|
||||
|
||||
if (process.env.LINKEDIN_ADS_ENABLED === "1" && cookies.li_fat_id) {
|
||||
if (process.env.LINKEDIN_ADS_ENABLED === "1" && cookies?.li_fat_id) {
|
||||
tracking.linkedInAds = {
|
||||
liFatId: cookies.li_fat_id,
|
||||
...(cookies.li_campaignId && { campaignId: cookies.li_campaignId }),
|
||||
};
|
||||
}
|
||||
|
||||
const parseUtm = (input: unknown): UtmTrackingData | null => {
|
||||
const parsed = utmTrackingDataSchema.safeParse(input);
|
||||
if (!parsed.success) return null;
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed.data).filter(([_, value]) => value !== undefined)
|
||||
) as UtmTrackingData;
|
||||
};
|
||||
|
||||
let utmData: UtmTrackingData | null = null;
|
||||
|
||||
if (query && Object.keys(query).length > 0) {
|
||||
utmData = parseUtm(query);
|
||||
}
|
||||
|
||||
if (!utmData && cookies?.utm_data) {
|
||||
try {
|
||||
utmData = parseUtm(JSON.parse(cookies.utm_data));
|
||||
} catch {
|
||||
console.debug("Failed to parse utm_data cookie");
|
||||
}
|
||||
}
|
||||
|
||||
if (utmData) {
|
||||
tracking.utmData = utmData;
|
||||
}
|
||||
|
||||
return tracking;
|
||||
}
|
||||
Reference in new issue
Block a user