diff --git a/apps/web/app/(ee)/api/cron/groups/create-default-links/route.ts b/apps/web/app/(ee)/api/cron/groups/create-default-links/route.ts index c542e3a7472..c4101be741d 100644 --- a/apps/web/app/(ee)/api/cron/groups/create-default-links/route.ts +++ b/apps/web/app/(ee)/api/cron/groups/create-default-links/route.ts @@ -1,7 +1,7 @@ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { bulkCreateLinks } from "@/lib/api/links"; import { generatePartnerLink } from "@/lib/api/partners/generate-partner-link"; -import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; +import { applyGroupUtmToLink } from "@/lib/api/utm/apply-group-utm-to-link"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { loadAppsFlyerParameters } from "@/lib/integrations/appsflyer/apply-parameters"; @@ -9,12 +9,7 @@ import { AppsFlyerSettings } from "@/lib/integrations/appsflyer/schema"; import { isAppsFlyerTrackingUrl } from "@/lib/middleware/utils/is-appsflyer-tracking-url"; import { prisma } from "@/lib/prisma"; import { WorkspaceProps } from "@/lib/types"; -import { - APP_DOMAIN_WITH_NGROK, - constructURLFromUTMParams, - isFulfilled, - log, -} from "@dub/utils"; +import { APP_DOMAIN_WITH_NGROK, isFulfilled, log } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; @@ -133,8 +128,8 @@ export async function POST(req: Request) { // Create a new defaultLink for each partner in the group const processedLinks = ( await Promise.allSettled( - programEnrollments.map(({ partner, ...programEnrollment }) => - generatePartnerLink({ + programEnrollments.map(async ({ partner, ...programEnrollment }) => { + const processedLink = await generatePartnerLink({ workspace: { id: workspace.id, plan: workspace.plan as WorkspaceProps["plan"], @@ -151,18 +146,20 @@ export async function POST(req: Request) { }, link: { domain: defaultLink.domain, - url: constructURLFromUTMParams( - defaultLink.url, - extractUtmParams(group.utmTemplate), - ), - ...extractUtmParams(group.utmTemplate, { excludeRef: true }), + url: defaultLink.url, tenantId: programEnrollment.tenantId ?? undefined, partnerGroupDefaultLinkId: defaultLink.id, }, userId, appsFlyerParameters, - }), - ), + }); + + return applyGroupUtmToLink({ + link: processedLink, + utmTemplate: group.utmTemplate, + partnerName: partner.name, + }); + }), ) ) .filter(isFulfilled) diff --git a/apps/web/app/(ee)/api/cron/groups/remap-default-links/route.ts b/apps/web/app/(ee)/api/cron/groups/remap-default-links/route.ts index 85d0a7548f2..d04ac31f145 100644 --- a/apps/web/app/(ee)/api/cron/groups/remap-default-links/route.ts +++ b/apps/web/app/(ee)/api/cron/groups/remap-default-links/route.ts @@ -1,10 +1,12 @@ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { bulkCreateLinks } from "@/lib/api/links"; import { generatePartnerLink } from "@/lib/api/partners/generate-partner-link"; +import { applyGroupUtmToLink } from "@/lib/api/utm/apply-group-utm-to-link"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { loadAppsFlyerParameters } from "@/lib/integrations/appsflyer/apply-parameters"; import { AppsFlyerSettings } from "@/lib/integrations/appsflyer/schema"; +import { syncGroupUtmJob } from "@/lib/jobs/handlers/sync-group-utm-job"; import { isAppsFlyerTrackingUrl } from "@/lib/middleware/utils/is-appsflyer-tracking-url"; import { prisma } from "@/lib/prisma"; import { WorkspaceProps } from "@/lib/types"; @@ -158,14 +160,14 @@ export async function POST(req: Request) { const processedLinks = ( await Promise.allSettled( - linksToCreate.map((link) => { + linksToCreate.map(async (link) => { const programEnrollment = programEnrollments.find( (p) => p.partner.id === link.partnerId, ); const partner = programEnrollment?.partner; - return generatePartnerLink({ + const processedLink = await generatePartnerLink({ workspace: { id: program.workspace.id, plan: program.workspace.plan as WorkspaceProps["plan"], @@ -189,6 +191,12 @@ export async function POST(req: Request) { userId: userId ?? undefined, appsFlyerParameters, }); + + return applyGroupUtmToLink({ + link: processedLink, + utmTemplate: partnerGroup.utmTemplate, + partnerName: partner?.name, + }); }), ) ) @@ -251,18 +259,11 @@ export async function POST(req: Request) { ); } - const syncUtmJob = await qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/sync-utm`, - body: { - groupId, - partnerIds, - }, + await syncGroupUtmJob.dispatch({ + groupId, + partnerIds, }); - console.log( - `Scheduled sync-utm job for group ${groupId}: ${prettyPrint(syncUtmJob)}`, - ); - const remapDiscountCodesJob = await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/remap-discount-codes`, body: { diff --git a/apps/web/app/(ee)/api/cron/groups/sync-utm/route.ts b/apps/web/app/(ee)/api/cron/groups/sync-utm/route.ts index 9d30e7c5b78..3f1fb5abce6 100644 --- a/apps/web/app/(ee)/api/cron/groups/sync-utm/route.ts +++ b/apps/web/app/(ee)/api/cron/groups/sync-utm/route.ts @@ -1,153 +1,29 @@ -import { handleAndReturnErrorResponse } from "@/lib/api/errors"; -import { linkCache } from "@/lib/api/links/cache"; -import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; -import { qstash } from "@/lib/cron"; -import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; -import { prisma } from "@/lib/prisma"; -import { - APP_DOMAIN_WITH_NGROK, - constructURLFromUTMParams, - log, -} from "@dub/utils"; +import { withCron } from "@/lib/cron/with-cron"; +import { syncGroupUtmJob } from "@/lib/jobs/handlers/sync-group-utm-job"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; -const PAGE_SIZE = 50; - const schema = z.object({ groupId: z.string(), partnerIds: z.array(z.string()).optional(), startAfterProgramEnrollmentId: z.string().optional(), }); -/** - Syncs the UTM parameter settings for a given group (whether there is a UTM template or not) - - This job is triggered when: - 1. a UTM template is created for a group - 2. a UTM template is updated - 3. in groups/remap-default-links cron - */ +// TODO: +// Remove this route after few hours of deployment // POST /api/cron/groups/sync-utm -export async function POST(req: Request) { - try { - const rawBody = await req.text(); - await verifyQstashSignature({ req, rawBody }); - - const { groupId, partnerIds, startAfterProgramEnrollmentId } = schema.parse( - JSON.parse(rawBody), - ); - - // Find the UTM template - const group = await prisma.partnerGroup.findUnique({ - where: { - id: groupId, - }, - include: { - utmTemplate: true, - }, - }); - - if (!group) { - return logAndRespond( - `Group ${groupId} not found for groups/sync-utm cron. Skipping...`, - { - logLevel: "error", - }, - ); - } - - const { utmTemplate } = group; - - // Find partners in the group - const programEnrollments = await prisma.programEnrollment.findMany({ - where: { - groupId: group.id, - ...(partnerIds && { - partnerId: { - in: partnerIds, - }, - }), - ...(startAfterProgramEnrollmentId && { - id: { - gt: startAfterProgramEnrollmentId, - }, - }), - }, - take: PAGE_SIZE, - orderBy: { - id: "asc", - }, - include: { - links: true, - }, - }); - - if (programEnrollments.length === 0) { - return logAndRespond(`No program enrollments found. Skipping...`); - } - - // extract links from program enrollments - const linksToUpdate = programEnrollments.flatMap((enrollment) => - enrollment.links.map((link) => link), - ); - // group links by the same url - const groupedLinksToUpdate = linksToUpdate.reduce( - (acc, link) => { - acc[link.url] = acc[link.url] || []; - acc[link.url].push(link.id); - return acc; - }, - {} as Record, - ); - - // Update the UTM for each partner links in the group - for (const [url, linkIds] of Object.entries(groupedLinksToUpdate)) { - const payload = { - url: constructURLFromUTMParams(url, extractUtmParams(utmTemplate)), - ...extractUtmParams(utmTemplate, { excludeRef: true }), - }; - - const updatedLinks = await prisma.link.updateMany({ - where: { - id: { - in: linkIds, - }, - }, - data: payload, - }); - console.log( - `Updated ${updatedLinks.count} links with URL: ${payload.url}`, - ); - } - - const redisRes = await linkCache.expireMany(linksToUpdate); - console.log(`Updated Redis cache: ${JSON.stringify(redisRes, null, 2)}`); - - if (programEnrollments.length === PAGE_SIZE) { - await qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/sync-utm`, - method: "POST", - body: { - groupId, - partnerIds, - startAfterProgramEnrollmentId: - programEnrollments[programEnrollments.length - 1].id, - }, - }); - } - - return logAndRespond( - `Finished syncing UTM settings for ${programEnrollments.length} partners in the ${group.name} group (${group.id}).`, - ); - } catch (error) { - await log({ - message: `Error syncing UTM settings: ${error.message}.`, - type: "errors", - }); - - return handleAndReturnErrorResponse(error); - } -} +export const POST = withCron(async ({ rawBody }) => { + const { groupId, partnerIds, startAfterProgramEnrollmentId } = schema.parse( + JSON.parse(rawBody), + ); + + await syncGroupUtmJob.dispatch({ + groupId, + partnerIds, + startAfterProgramEnrollmentId, + }); + + return logAndRespond("OK"); +}); diff --git a/apps/web/app/(ee)/api/cron/groups/update-default-links/route.ts b/apps/web/app/(ee)/api/cron/groups/update-default-links/route.ts index c49ee264f46..d2c960e7c63 100644 --- a/apps/web/app/(ee)/api/cron/groups/update-default-links/route.ts +++ b/apps/web/app/(ee)/api/cron/groups/update-default-links/route.ts @@ -1,6 +1,6 @@ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { linkCache } from "@/lib/api/links/cache"; -import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; +import { applyGroupUtmToLink } from "@/lib/api/utm/apply-group-utm-to-link"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { @@ -10,11 +10,8 @@ import { import { AppsFlyerSettings } from "@/lib/integrations/appsflyer/schema"; import { isAppsFlyerTrackingUrl } from "@/lib/middleware/utils/is-appsflyer-tracking-url"; import { prisma } from "@/lib/prisma"; -import { - APP_DOMAIN_WITH_NGROK, - constructURLFromUTMParams, - log, -} from "@dub/utils"; +import { ProcessedLinkProps } from "@/lib/types"; +import { APP_DOMAIN_WITH_NGROK, log } from "@dub/utils"; import { Link } from "@prisma/client"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; @@ -148,10 +145,24 @@ export async function POST(req: Request) { }[] = []; for (const defaultPartnerLink of defaultPartnerLinks) { - let url = constructURLFromUTMParams( - defaultLink.url, - extractUtmParams(group.utmTemplate), - ); + const utmContext = { + partnerName: + defaultPartnerLink.partner?.name || defaultPartnerLink.key, + partnerLinkKey: defaultPartnerLink.key, + }; + + const linkWithUtm = applyGroupUtmToLink({ + link: { + domain: defaultPartnerLink.domain, + key: defaultPartnerLink.key, + url: defaultLink.url, + projectId: defaultLink.program.workspaceId, + } as ProcessedLinkProps, + utmTemplate: group.utmTemplate, + partnerName: defaultPartnerLink.partner?.name, + }); + + let url = linkWithUtm.url; // Inject AppsFlyer parameters with resolved macros if ( @@ -161,11 +172,7 @@ export async function POST(req: Request) { url = applyAppsFlyerParameters({ url, parameters: appsFlyerParameters, - context: { - partnerName: - defaultPartnerLink.partner?.name || defaultPartnerLink.key, - partnerLinkKey: defaultPartnerLink.key, - }, + context: utmContext, }); } @@ -173,7 +180,11 @@ export async function POST(req: Request) { id: defaultPartnerLink.id, link: { url, - ...extractUtmParams(group.utmTemplate, { excludeRef: true }), + utm_source: linkWithUtm.utm_source ?? null, + utm_medium: linkWithUtm.utm_medium ?? null, + utm_campaign: linkWithUtm.utm_campaign ?? null, + utm_term: linkWithUtm.utm_term ?? null, + utm_content: linkWithUtm.utm_content ?? null, }, }); } diff --git a/apps/web/app/(ee)/api/embed/referrals/links/[linkId]/route.ts b/apps/web/app/(ee)/api/embed/referrals/links/[linkId]/route.ts index 192c3deff3b..b3984b2f7bc 100644 --- a/apps/web/app/(ee)/api/embed/referrals/links/[linkId]/route.ts +++ b/apps/web/app/(ee)/api/embed/referrals/links/[linkId]/route.ts @@ -2,6 +2,7 @@ import { DubApiError, ErrorCodes } from "@/lib/api/errors"; import { processLink, updateLink } from "@/lib/api/links"; import { validatePartnerLinkUrl } from "@/lib/api/links/validate-partner-link-url"; import { parseRequestBody } from "@/lib/api/utils"; +import { applyGroupUtmToLink } from "@/lib/api/utm/apply-group-utm-to-link"; import { withReferralsEmbedToken } from "@/lib/embed/referrals/auth"; import { prisma } from "@/lib/prisma"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; @@ -60,6 +61,25 @@ export const PATCH = withReferralsEmbedToken( validatePartnerLinkUrl({ group, url }); + const [groupUtmTemplate, partner] = await Promise.all([ + group.utmTemplateId + ? prisma.utmTemplate.findUnique({ + where: { + id: group.utmTemplateId, + }, + }) + : null, + + prisma.partner.findUnique({ + where: { + id: programEnrollment.partnerId, + }, + select: { + name: true, + }, + }), + ]); + // if domain and key are the same, we don't need to check if the key exists const skipKeyChecks = link.key.toLowerCase() === key?.toLowerCase(); @@ -93,13 +113,19 @@ export const PATCH = withReferralsEmbedToken( }); } + const linkWithUtm = applyGroupUtmToLink({ + link: processedLink, + utmTemplate: groupUtmTemplate, + partnerName: partner?.name, + }); + const partnerLink = await updateLink({ oldLink: { domain: link.domain, key: link.key, image: link.image, }, - updatedLink: processedLink, + updatedLink: linkWithUtm, }); waitUntil( @@ -113,6 +139,7 @@ export const PATCH = withReferralsEmbedToken( webhookEnabled: true, }, }); + if (workspace) { await sendWorkspaceWebhook({ trigger: "link.updated", diff --git a/apps/web/app/(ee)/api/embed/referrals/links/route.ts b/apps/web/app/(ee)/api/embed/referrals/links/route.ts index 4de31589434..c64632d25d8 100644 --- a/apps/web/app/(ee)/api/embed/referrals/links/route.ts +++ b/apps/web/app/(ee)/api/embed/referrals/links/route.ts @@ -2,14 +2,13 @@ import { DubApiError, ErrorCodes } from "@/lib/api/errors"; import { createLink, processLink } from "@/lib/api/links"; import { validatePartnerLinkUrl } from "@/lib/api/links/validate-partner-link-url"; import { parseRequestBody } from "@/lib/api/utils"; -import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; +import { applyGroupUtmToLink } from "@/lib/api/utm/apply-group-utm-to-link"; import { withReferralsEmbedToken } from "@/lib/embed/referrals/auth"; import { prisma } from "@/lib/prisma"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; import { linkEventSchema } from "@/lib/zod/schemas/links"; import { createPartnerLinkSchema } from "@/lib/zod/schemas/partners"; import { ReferralsEmbedLinkSchema } from "@/lib/zod/schemas/referrals-embed"; -import { getUTMParamsFromURL } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; @@ -51,7 +50,7 @@ export const POST = withReferralsEmbedToken( validatePartnerLinkUrl({ group, url }); - const [workspaceOwner, partnerGroup] = await Promise.all([ + const [workspaceOwner, partnerGroup, partner] = await Promise.all([ prisma.projectUsers.findFirst({ where: { projectId: program.workspaceId, @@ -79,6 +78,15 @@ export const POST = withReferralsEmbedToken( utmTemplate: true, }, }), + + prisma.partner.findUnique({ + where: { + id: programEnrollment.partnerId, + }, + select: { + name: true, + }, + }), ]); // shouldn't happen but just in case @@ -95,12 +103,6 @@ export const POST = withReferralsEmbedToken( payload: { key: key || undefined, url: linkUrl, - ...(partnerGroup.utmTemplate - ? { - ...extractUtmParams(partnerGroup.utmTemplate), - ...getUTMParamsFromURL(linkUrl), - } - : {}), domain: program.domain, programId: program.id, folderId: program.defaultFolderId, @@ -126,7 +128,13 @@ export const POST = withReferralsEmbedToken( }); } - const partnerLink = await createLink(link); + const linkWithUtm = applyGroupUtmToLink({ + link, + utmTemplate: partnerGroup.utmTemplate, + partnerName: partner?.name, + }); + + const partnerLink = await createLink(linkWithUtm); // this should always be present but just in case const workspace = workspaceOwner?.project; diff --git a/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts b/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts index 5b8a8fc5d55..0bce5a5c5a6 100644 --- a/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts +++ b/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts @@ -8,7 +8,7 @@ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-progr import { parseRequestBody } from "@/lib/api/utils"; import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; import { withWorkspace } from "@/lib/auth"; -import { qstash } from "@/lib/cron"; +import { syncGroupUtmJob } from "@/lib/jobs/handlers/sync-group-utm-job"; import { prisma } from "@/lib/prisma"; import { GroupWithProgramSchema } from "@/lib/zod/schemas/group-with-program"; import { @@ -17,7 +17,7 @@ import { sanitizeAdditionalLinks, updateGroupSchema, } from "@/lib/zod/schemas/groups"; -import { APP_DOMAIN_WITH_NGROK, constructURLFromUTMParams } from "@dub/utils"; +import { constructURLFromUTMParams } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; @@ -241,11 +241,8 @@ export const PATCH = withWorkspace( }), group.utmTemplateId !== updatedGroup.utmTemplateId && - qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/sync-utm`, - body: { - groupId: group.id, - }, + syncGroupUtmJob.dispatch({ + groupId: group.id, }), ]); })(), diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts index 0fcfafa048a..b93bfe4534e 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts @@ -3,7 +3,7 @@ import { deleteLink, processLink, updateLink } from "@/lib/api/links"; import { validatePartnerLinkUrl } from "@/lib/api/links/validate-partner-link-url"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; -import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; +import { applyGroupUtmToLink } from "@/lib/api/utm/apply-group-utm-to-link"; import { withPartnerProfile } from "@/lib/auth/partner"; import { prisma } from "@/lib/prisma"; import { NewLinkProps } from "@/lib/types"; @@ -101,7 +101,6 @@ export const PATCH = withPartnerProfile( } = await processLink({ payload: { ...link, - ...(groupUtmTemplate ? extractUtmParams(groupUtmTemplate) : {}), // coerce types expiresAt: link.expiresAt instanceof Date @@ -142,13 +141,19 @@ export const PATCH = withPartnerProfile( }); } + const linkWithUtm = applyGroupUtmToLink({ + link: processedLink, + utmTemplate: groupUtmTemplate, + partnerName: partner.name, + }); + const partnerLink = await updateLink({ oldLink: { domain: link.domain, key: link.key, image: link.image, }, - updatedLink: processedLink, + updatedLink: linkWithUtm, }); return NextResponse.json(PartnerProfileLinkSchema.parse(partnerLink)); diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts index fb1f32983df..da514026b80 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts @@ -3,7 +3,7 @@ import { createLink, processLink } from "@/lib/api/links"; import { validatePartnerLinkUrl } from "@/lib/api/links/validate-partner-link-url"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; -import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; +import { applyGroupUtmToLink } from "@/lib/api/utm/apply-group-utm-to-link"; import { withPartnerProfile } from "@/lib/auth/partner"; import { prisma } from "@/lib/prisma"; import { PartnerProfileLinkSchema } from "@/lib/zod/schemas/partner-profile"; @@ -11,7 +11,6 @@ import { createPartnerLinkSchema, INACTIVE_ENROLLMENT_STATUSES, } from "@/lib/zod/schemas/partners"; -import { getUTMParamsFromURL } from "@dub/utils"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; @@ -115,12 +114,6 @@ export const POST = withPartnerProfile( domain: program.domain, key: key || undefined, url: linkUrl, - ...(groupUtmTemplate - ? { - ...extractUtmParams(groupUtmTemplate), - ...getUTMParamsFromURL(linkUrl), - } - : {}), programId: program.id, tenantId, partnerId: partner.id, @@ -146,7 +139,13 @@ export const POST = withPartnerProfile( }); } - const partnerLink = await createLink(link); + const linkWithUtm = applyGroupUtmToLink({ + link, + utmTemplate: groupUtmTemplate, + partnerName: partner.name, + }); + + const partnerLink = await createLink(linkWithUtm); return NextResponse.json(PartnerProfileLinkSchema.parse(partnerLink), { status: 201, diff --git a/apps/web/app/(ee)/api/partners/links/route.ts b/apps/web/app/(ee)/api/partners/links/route.ts index 82b8dd71cf5..2ae176a38e7 100644 --- a/apps/web/app/(ee)/api/partners/links/route.ts +++ b/apps/web/app/(ee)/api/partners/links/route.ts @@ -4,7 +4,7 @@ import { validatePartnerLinkUrl } from "@/lib/api/links/validate-partner-link-ur import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; -import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; +import { applyGroupUtmToLink } from "@/lib/api/utm/apply-group-utm-to-link"; import { withWorkspace } from "@/lib/auth"; import { throwIfNoPartnerIdOrTenantId } from "@/lib/partners/throw-if-no-partnerid-tenantid"; import { prisma } from "@/lib/prisma"; @@ -15,7 +15,6 @@ import { retrievePartnerLinksSchema, } from "@/lib/zod/schemas/partners"; import { ProgramPartnerLinkSchema } from "@/lib/zod/schemas/programs"; -import { getUTMParamsFromURL } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; @@ -93,7 +92,14 @@ export const POST = withWorkspace( where: partnerId ? { partnerId_programId: { partnerId, programId } } : { tenantId_programId: { tenantId: tenantId!, programId } }, - include: { + select: { + tenantId: true, + partnerId: true, + partner: { + select: { + name: true, + }, + }, partnerGroup: { include: { partnerGroupDefaultLinks: true, @@ -130,12 +136,6 @@ export const POST = withWorkspace( domain: program.domain, key: key || undefined, url: linkUrl, - ...(partnerGroup.utmTemplate - ? { - ...extractUtmParams(partnerGroup.utmTemplate), - ...getUTMParamsFromURL(linkUrl), - } - : {}), programId: program.id, tenantId: partner.tenantId, partnerId: partner.partnerId, @@ -154,7 +154,13 @@ export const POST = withWorkspace( }); } - const partnerLink = await createLink(link); + const linkWithUtm = applyGroupUtmToLink({ + link, + utmTemplate: partnerGroup.utmTemplate, + partnerName: partner.partner.name, + }); + + const partnerLink = await createLink(linkWithUtm); waitUntil( sendWorkspaceWebhook({ diff --git a/apps/web/app/(ee)/api/partners/links/upsert/route.ts b/apps/web/app/(ee)/api/partners/links/upsert/route.ts index d251d2a19a3..2104d5fb39b 100644 --- a/apps/web/app/(ee)/api/partners/links/upsert/route.ts +++ b/apps/web/app/(ee)/api/partners/links/upsert/route.ts @@ -10,7 +10,7 @@ import { validatePartnerLinkUrl } from "@/lib/api/links/validate-partner-link-ur import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; -import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; +import { applyGroupUtmToLink } from "@/lib/api/utm/apply-group-utm-to-link"; import { withWorkspace } from "@/lib/auth"; import { throwIfNoPartnerIdOrTenantId } from "@/lib/partners/throw-if-no-partnerid-tenantid"; import { prisma } from "@/lib/prisma"; @@ -18,7 +18,7 @@ import { NewLinkProps } from "@/lib/types"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; import { linkEventSchema } from "@/lib/zod/schemas/links"; import { upsertPartnerLinkSchema } from "@/lib/zod/schemas/partners"; -import { deepEqual, getUTMParamsFromURL } from "@dub/utils"; +import { deepEqual } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; @@ -50,6 +50,11 @@ export const PUT = withWorkspace( ? { partnerId_programId: { partnerId, programId } } : { tenantId_programId: { tenantId: tenantId!, programId } }, include: { + partner: { + select: { + name: true, + }, + }, partnerGroup: { include: { partnerGroupDefaultLinks: true, @@ -164,6 +169,12 @@ export const PUT = withWorkspace( }); } + const linkWithUtm = applyGroupUtmToLink({ + link: processedLink, + utmTemplate: partnerGroup.utmTemplate, + partnerName: partner.partner.name, + }); + try { const response = await updateLink({ oldLink: { @@ -171,7 +182,7 @@ export const PUT = withWorkspace( key: link.key, image: link.image, }, - updatedLink: processedLink, + updatedLink: linkWithUtm, }); waitUntil( @@ -201,12 +212,6 @@ export const PUT = withWorkspace( domain: program.domain, key: key || undefined, url: linkUrl, - ...(partnerGroup.utmTemplate - ? { - ...extractUtmParams(partnerGroup.utmTemplate), - ...getUTMParamsFromURL(linkUrl), - } - : {}), programId: program.id, tenantId: partner.tenantId, partnerId: partner.partnerId, @@ -225,7 +230,13 @@ export const PUT = withWorkspace( }); } - const partnerLink = await createLink(link); + const linkWithUtm = applyGroupUtmToLink({ + link, + utmTemplate: partnerGroup.utmTemplate, + partnerName: partner.partner.name, + }); + + const partnerLink = await createLink(linkWithUtm); waitUntil( sendWorkspaceWebhook({ diff --git a/apps/web/app/api/utm/[id]/route.ts b/apps/web/app/api/utm/[id]/route.ts index 466faf0dd8d..ae1574f4014 100644 --- a/apps/web/app/api/utm/[id]/route.ts +++ b/apps/web/app/api/utm/[id]/route.ts @@ -1,14 +1,10 @@ import { DubApiError } from "@/lib/api/errors"; import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; import { withWorkspace } from "@/lib/auth"; -import { qstash } from "@/lib/cron"; +import { syncGroupUtmJob } from "@/lib/jobs/handlers/sync-group-utm-job"; import { prisma } from "@/lib/prisma"; import { updateUTMTemplateBodySchema } from "@/lib/zod/schemas/utm"; -import { - APP_DOMAIN_WITH_NGROK, - constructURLFromUTMParams, - deepEqual, -} from "@dub/utils"; +import { constructURLFromUTMParams, deepEqual } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; @@ -96,15 +92,9 @@ export const PATCH = withWorkspace( } } - const res = await qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/sync-utm`, - body: { - groupId: partnerGroup.id, - }, + await syncGroupUtmJob.dispatch({ + groupId: partnerGroup.id, }); - console.log( - `Scheduled sync-utm job for template ${template.id}: ${JSON.stringify(res, null, 2)}`, - ); })(), ); } diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/group-link-settings.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/group-link-settings.tsx index e7b10629a5e..6af691513e6 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/group-link-settings.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/group-link-settings.tsx @@ -1,6 +1,7 @@ "use client"; import { getLinkStructureOptions } from "@/lib/partners/get-link-structure-options"; +import { PARTNER_MACROS } from "@/lib/partners/macros"; import { mutatePrefix } from "@/lib/swr/mutate"; import { useApiMutation } from "@/lib/swr/use-api-mutation"; import useGroup from "@/lib/swr/use-group"; @@ -260,19 +261,30 @@ function GroupLinkSettingsForm({ group }: { group: GroupProps }) { heading="UTM parameters" description="Configure [UTM tracking parameters](https://dub.co/help/article/partner-link-settings#utm-parameters) for all links in this group" > - { - setValue(key, value, { shouldDirty: true }); - }} - /> +
+ { + setValue(key, value, { shouldDirty: true }); + }} + suggestions={PARTNER_MACROS.map((m) => ({ + value: m.macro, + description: m.description, + }))} + /> +

+ Dynamic values:{" "} + {"{{PARTNER_NAME}}"},{" "} + {"{{PARTNER_LINK_KEY}}"} +

+
)} diff --git a/apps/web/lib/actions/partners/bulk-invite-partners.ts b/apps/web/lib/actions/partners/bulk-invite-partners.ts index 496b531a3b8..c54eda5b524 100644 --- a/apps/web/lib/actions/partners/bulk-invite-partners.ts +++ b/apps/web/lib/actions/partners/bulk-invite-partners.ts @@ -5,14 +5,14 @@ import { createId } from "@/lib/api/create-id"; import { bulkCreateLinks } from "@/lib/api/links"; import { getGroupRewardsAndBounties } from "@/lib/api/partners/get-group-rewards-and-bounties"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; -import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; +import { applyGroupUtmToLink } from "@/lib/api/utm/apply-group-utm-to-link"; import { throwIfPartnersLimitExceeded } from "@/lib/partners/throw-if-partners-limit-exceeded"; import { prisma } from "@/lib/prisma"; import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups"; import { bulkInvitePartnersSchema } from "@/lib/zod/schemas/partners"; import { sendBatchEmail } from "@dub/email"; import ProgramInvite from "@dub/email/templates/program-invite"; -import { constructURLFromUTMParams, nanoid } from "@dub/utils"; +import { nanoid } from "@dub/utils"; import { prettyPrint } from "@dub/utils/src"; import slugify from "@sindresorhus/slugify"; import { waitUntil } from "@vercel/functions"; @@ -121,7 +121,6 @@ export const bulkInvitePartnersAction = authActionClient const group = program.groups[0]; const partnerGroupDefaultLinks = group.partnerGroupDefaultLinks; - const utmTemplate = group.utmTemplate; const invitedCount = await prisma.$transaction(async (tx) => { const { count: invitedCount } = await tx.programEnrollment.createMany({ @@ -177,21 +176,27 @@ export const bulkInvitePartnersAction = authActionClient // Create default links for the partners for each group default link for (const partnerGroupDefaultLink of partnerGroupDefaultLinks) { const links = await bulkCreateLinks({ - links: partners.map((partner) => ({ - domain: partnerGroupDefaultLink.domain, - key: `${slugify(partner.email!.split("@")[0])}-${nanoid(4)}`, - url: constructURLFromUTMParams( - partnerGroupDefaultLink.url, - extractUtmParams(utmTemplate), - ), - ...extractUtmParams(utmTemplate, { excludeRef: true }), - projectId: workspace.id, - programId: program.id, - partnerId: partner.id, - userId: user.id, - folderId: program.defaultFolderId, - partnerGroupDefaultLinkId: partnerGroupDefaultLink.id, - })), + links: partners.map((partner) => { + const key = `${slugify(partner.email!.split("@")[0])}-${nanoid(4)}`; + + const link = { + domain: partnerGroupDefaultLink.domain, + key, + url: partnerGroupDefaultLink.url, + projectId: workspace.id, + programId: program.id, + partnerId: partner.id, + userId: user.id, + folderId: program.defaultFolderId, + partnerGroupDefaultLinkId: partnerGroupDefaultLink.id, + }; + + return applyGroupUtmToLink({ + link, + utmTemplate: group.utmTemplate, + partnerName: partner.name, + }); + }), }); console.log( diff --git a/apps/web/lib/actions/partners/update-partner-profile.ts b/apps/web/lib/actions/partners/update-partner-profile.ts index d2fdfab3843..fc758a8bfc5 100644 --- a/apps/web/lib/actions/partners/update-partner-profile.ts +++ b/apps/web/lib/actions/partners/update-partner-profile.ts @@ -5,6 +5,7 @@ import { confirmEmailChange } from "@/lib/auth/confirm-email-change"; import { throwIfNoPermission } from "@/lib/auth/partner-users/throw-if-no-permission"; import { qstash } from "@/lib/cron"; import { isReservedUsername } from "@/lib/edge-config"; +import { dispatchGroupUtmSyncForPartner } from "@/lib/partners/dispatch-partner-utm-sync"; import { assertEmailAvailableForIdentitySync, requestSyncedEmailChange, @@ -253,31 +254,33 @@ export const updatePartnerProfileAction = authPartnerActionClient } waitUntil( - Promise.allSettled([ - (async () => { - const shouldExpireCache = !deepEqual( - { - name: partner.name, - image: partner.image, - }, - { - name: updatedPartner.name, - image: updatedPartner.image, - }, - ); - - if (!shouldExpireCache) { - return; - } - - await qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/invalidate-for-partners`, - body: { - partnerId: partner.id, - }, - }); - })(), - ]), + (async () => { + const shouldExpireCache = !deepEqual( + { + name: partner.name, + image: partner.image, + }, + { + name: updatedPartner.name, + image: updatedPartner.image, + }, + ); + + await Promise.allSettled([ + shouldExpireCache + ? qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/invalidate-for-partners`, + body: { + partnerId: partner.id, + }, + }) + : undefined, + + partner.name !== updatedPartner.name + ? dispatchGroupUtmSyncForPartner(partner.id) + : undefined, + ]); + })(), ); return { diff --git a/apps/web/lib/api/partners/create-partner-default-links.ts b/apps/web/lib/api/partners/create-partner-default-links.ts index 0fb57779060..ac60221596f 100644 --- a/apps/web/lib/api/partners/create-partner-default-links.ts +++ b/apps/web/lib/api/partners/create-partner-default-links.ts @@ -7,10 +7,10 @@ import { UtmTemplateProps, WorkspaceProps, } from "@/lib/types"; -import { constructURLFromUTMParams, isFulfilled } from "@dub/utils"; +import { isFulfilled } from "@dub/utils"; import { PartnerGroupDefaultLink } from "@prisma/client"; import { bulkCreateLinks } from "../links"; -import { extractUtmParams } from "../utm/extract-utm-params"; +import { applyGroupUtmToLink } from "../utm/apply-group-utm-to-link"; import { buildPartnerDefaultLinkKey, generatePartnerLink, @@ -59,14 +59,14 @@ export async function createPartnerDefaultLinks({ const processedLinks = ( await Promise.allSettled( - defaultLinks.map((defaultLink) => { + defaultLinks.map(async (defaultLink) => { const key = buildPartnerDefaultLinkKey({ link, partner, hasMoreThanOneDefaultLink, }); - return generatePartnerLink({ + const processedLink = await generatePartnerLink({ workspace, program, partner, @@ -74,16 +74,18 @@ export async function createPartnerDefaultLinks({ ...link, key, domain: defaultLink.domain, - url: constructURLFromUTMParams( - defaultLink.url, - extractUtmParams(utmTemplate), - ), - ...extractUtmParams(utmTemplate, { excludeRef: true }), + url: defaultLink.url, partnerGroupDefaultLinkId: defaultLink.id, }, userId, appsFlyerParameters, }); + + return applyGroupUtmToLink({ + link: processedLink, + utmTemplate, + partnerName: partner.name, + }); }), ) ) diff --git a/apps/web/lib/api/utm/apply-group-utm-to-link.ts b/apps/web/lib/api/utm/apply-group-utm-to-link.ts new file mode 100644 index 00000000000..990577035c3 --- /dev/null +++ b/apps/web/lib/api/utm/apply-group-utm-to-link.ts @@ -0,0 +1,48 @@ +import { ProcessedLinkProps } from "@/lib/types"; +import { constructURLFromUTMParams } from "@dub/utils"; +import { UtmTemplate } from "@prisma/client"; +import { extractAndResolveUtmParams } from "./extract-and-resolve-utm-params"; + +// Applies a group's UTM template to a processed partner link before createLink / updateLink +export function applyGroupUtmToLink({ + link, + utmTemplate, + partnerName, +}: { + link: T; + utmTemplate: + | Pick< + UtmTemplate, + | "utm_source" + | "utm_medium" + | "utm_campaign" + | "utm_term" + | "utm_content" + | "ref" + > + | null + | undefined; + partnerName?: string | null; +}): T { + if (!utmTemplate) { + return link; + } + + const utmContext = { + partnerName: partnerName || link.key, + partnerLinkKey: link.key, + }; + + const resolvedUtmParams = extractAndResolveUtmParams(utmTemplate, utmContext); + const resolvedUtmColumns = extractAndResolveUtmParams( + utmTemplate, + utmContext, + { excludeRef: true }, + ); + + return { + ...link, + ...resolvedUtmColumns, + url: constructURLFromUTMParams(link.url, resolvedUtmParams), + }; +} diff --git a/apps/web/lib/api/utm/extract-and-resolve-utm-params.ts b/apps/web/lib/api/utm/extract-and-resolve-utm-params.ts new file mode 100644 index 00000000000..0a14dea2e33 --- /dev/null +++ b/apps/web/lib/api/utm/extract-and-resolve-utm-params.ts @@ -0,0 +1,31 @@ +import { + PartnerMacroContext, + resolvePartnerMacros, +} from "@/lib/partners/macros"; +import { UTM_TAG_MAX_LENGTH } from "@/lib/zod/schemas/utm"; +import { extractUtmParams } from "./extract-utm-params"; + +// Extracts UTM params from a template and resolves partner macros +// (e.g. {{PARTNER_NAME}}, {{PARTNER_LINK_KEY}}) against the given context. +export const extractAndResolveUtmParams = ( + utmTemplate: Parameters[0], + context: PartnerMacroContext, + options?: Parameters[1], +) => { + const params = extractUtmParams(utmTemplate, options); + + return Object.fromEntries( + Object.entries(params).map(([key, value]) => { + if (value == null) { + return [key, value]; + } + + const resolved = resolvePartnerMacros(value, context).slice( + 0, + UTM_TAG_MAX_LENGTH, + ); + + return [key, resolved]; + }), + ) as ReturnType; +}; diff --git a/apps/web/lib/integrations/appsflyer/apply-parameters.ts b/apps/web/lib/integrations/appsflyer/apply-parameters.ts index 93251f0a995..4a8d114f85e 100644 --- a/apps/web/lib/integrations/appsflyer/apply-parameters.ts +++ b/apps/web/lib/integrations/appsflyer/apply-parameters.ts @@ -1,17 +1,11 @@ +import { + PartnerMacroContext, + resolvePartnerMacros, +} from "@/lib/partners/macros"; import { prisma } from "@/lib/prisma"; import { APPSFLYER_INTEGRATION_ID } from "@dub/utils"; import { AppsFlyerSettings, appsFlyerSettingsSchema } from "./schema"; -interface AppsFlyerParameterContext { - partnerName: string; - partnerLinkKey: string; -} - -const macroReplacements: Record = { - "{{PARTNER_NAME}}": "partnerName", - "{{PARTNER_LINK_KEY}}": "partnerLinkKey", -}; - // Resolve macros in parameter values and append them to a URL export function applyAppsFlyerParameters({ url, @@ -20,21 +14,12 @@ export function applyAppsFlyerParameters({ }: { url: string; parameters: { key: string; value: string }[]; - context: AppsFlyerParameterContext; + context: PartnerMacroContext; }) { const urlObj = new URL(url); for (const { key, value } of parameters) { - let resolvedValue = value; - - for (const [macro, contextKey] of Object.entries(macroReplacements)) { - resolvedValue = resolvedValue.replaceAll( - macro, - context[contextKey] ?? "", - ); - } - - urlObj.searchParams.set(key, resolvedValue); + urlObj.searchParams.set(key, resolvePartnerMacros(value, context)); } return urlObj.toString(); diff --git a/apps/web/lib/integrations/appsflyer/constants.ts b/apps/web/lib/integrations/appsflyer/constants.ts index bfecdcd0d5a..2f753663e1c 100644 --- a/apps/web/lib/integrations/appsflyer/constants.ts +++ b/apps/web/lib/integrations/appsflyer/constants.ts @@ -31,18 +31,3 @@ export const APPSFLYER_REQUIRED_PARAMETERS = [ // @see: https://support.appsflyer.com/hc/en-us/articles/207447093-AppsFlyer-server-IP-address-ranges-allowlisting export const APPSFLYER_IP_RANGES = ["45.92.116.0/22", "194.28.46.0/23"]; - -export const APPSFLYER_MACROS = [ - { - macro: "{{PARTNER_NAME}}", - description: "The partner's name (e.g. 'John Doe')", - }, - { - macro: "{{PARTNER_LINK_KEY}}", - description: "The partner's link key (e.g. 'john-doe')", - }, -] as const; - -export const APPSFLYER_MACRO_VALUES: readonly string[] = APPSFLYER_MACROS.map( - (m) => m.macro, -); diff --git a/apps/web/lib/integrations/appsflyer/macro-template.ts b/apps/web/lib/integrations/appsflyer/macro-template.ts deleted file mode 100644 index acf72a3a787..00000000000 --- a/apps/web/lib/integrations/appsflyer/macro-template.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { APPSFLYER_MACRO_VALUES } from "./constants"; - -const MACRO_TOKEN_RE = /\{\{[^}]+\}\}/g; - -/** Every `{{...}}` substring must be a known AppsFlyer macro. */ -export function isValidAppsFlyerMacroTemplate(value: string): boolean { - const matches = value.match(MACRO_TOKEN_RE) ?? []; - return matches.every((token) => APPSFLYER_MACRO_VALUES.includes(token)); -} - -/** - * Validates a free-form custom parameter value (any `{{...}}` tokens must be known macros). - * @throws Error when validation fails. - */ -export function assertAppsFlyerMacroValueParses(value: string): void { - if (!isValidAppsFlyerMacroTemplate(value)) { - throw new Error( - `Invalid macro in value. Use only: ${APPSFLYER_MACRO_VALUES.join(", ")}`, - ); - } -} diff --git a/apps/web/lib/integrations/appsflyer/schema.ts b/apps/web/lib/integrations/appsflyer/schema.ts index ef9a41e017e..2fe6ca409f5 100644 --- a/apps/web/lib/integrations/appsflyer/schema.ts +++ b/apps/web/lib/integrations/appsflyer/schema.ts @@ -1,18 +1,20 @@ +import { + PARTNER_MACRO_VALUES, + isValidPartnerMacroTemplate, +} from "@/lib/partners/macros"; import * as z from "zod/v4"; -import { APPSFLYER_MACRO_VALUES } from "./constants"; -import { isValidAppsFlyerMacroTemplate } from "./macro-template"; export const appsFlyerMacroExactValueSchema = z .string() - .refine((v) => APPSFLYER_MACRO_VALUES.includes(v), { - message: `Value must be one of: ${APPSFLYER_MACRO_VALUES.join(", ")}`, + .refine((v) => PARTNER_MACRO_VALUES.includes(v), { + message: `Value must be one of: ${PARTNER_MACRO_VALUES.join(", ")}`, }); /** Free-form value; every `{{...}}` token must be a known macro. */ export const appsFlyerMacroTemplateValueSchema = z .string() - .refine((v) => isValidAppsFlyerMacroTemplate(v), { - message: `Invalid macro in value. Use only: ${APPSFLYER_MACRO_VALUES.join(", ")}`, + .refine((v) => isValidPartnerMacroTemplate(v), { + message: `Invalid macro in value. Use only: ${PARTNER_MACRO_VALUES.join(", ")}`, }); export const appsFlyerRequiredParameterSchema = z.object({ diff --git a/apps/web/lib/integrations/appsflyer/ui/settings.tsx b/apps/web/lib/integrations/appsflyer/ui/settings.tsx index d8691908ac5..85ba197f3b4 100644 --- a/apps/web/lib/integrations/appsflyer/ui/settings.tsx +++ b/apps/web/lib/integrations/appsflyer/ui/settings.tsx @@ -1,5 +1,9 @@ "use client"; +import { + assertValidPartnerMacroValue, + PARTNER_MACROS, +} from "@/lib/partners/macros"; import useWorkspace from "@/lib/swr/use-workspace"; import { InstalledIntegrationInfoProps } from "@/lib/types"; import { Button, Combobox, ComboboxOption, Input } from "@dub/ui"; @@ -11,10 +15,8 @@ import { toast } from "sonner"; import { APPSFLYER_DEFAULT_SETTINGS, APPSFLYER_HARDCODED_PARAMETERS, - APPSFLYER_MACROS, APPSFLYER_REQUIRED_PARAMETERS, } from "../constants"; -import { assertAppsFlyerMacroValueParses } from "../macro-template"; import { appsFlyerSettingsSchema } from "../schema"; import { updateAppsFlyerSettingsAction } from "../update-settings"; @@ -35,7 +37,7 @@ function cloneFormBaseline(data: AppsFlyerFormBaseline): AppsFlyerFormBaseline { type AppsFlyerMacroMeta = { description: string }; const REQUIRED_MACRO_COMBO_OPTIONS: ComboboxOption[] = - APPSFLYER_MACROS.map((m) => ({ + PARTNER_MACROS.map((m) => ({ value: m.macro, label: m.macro, meta: { description: m.description }, @@ -172,7 +174,7 @@ export const AppsFlyerSettings = ({ for (const p of filteredParameters) { try { - assertAppsFlyerMacroValueParses(p.value); + assertValidPartnerMacroValue(p.value); } catch (err) { toast.error( err instanceof Error diff --git a/apps/web/lib/jobs/handlers/sync-group-utm-job.ts b/apps/web/lib/jobs/handlers/sync-group-utm-job.ts new file mode 100644 index 00000000000..eab621bf3c4 --- /dev/null +++ b/apps/web/lib/jobs/handlers/sync-group-utm-job.ts @@ -0,0 +1,198 @@ +import { linkCache } from "@/lib/api/links/cache"; +import { extractAndResolveUtmParams } from "@/lib/api/utm/extract-and-resolve-utm-params"; +import { prisma } from "@/lib/prisma"; +import { LinkProps } from "@/lib/types"; +import { chunk, constructURLFromUTMParams } from "@dub/utils"; +import * as z from "zod/v4"; +import { defineJob } from "../index"; + +const PAGE_SIZE = 50; +const UPDATE_BATCH_SIZE = 25; + +const inputSchema = z.object({ + groupId: z.string(), + partnerIds: z.array(z.string()).optional(), + startAfterProgramEnrollmentId: z.string().optional(), +}); + +/** + Syncs the UTM parameter settings for a given group (whether there is a UTM template or not) + + This job is triggered when: + 1. a UTM template is created for a group + 2. a UTM template is updated + 3. in groups/remap-default-links cron + 4. a partner's name changes (via dispatchGroupUtmSyncForPartner) + */ +export const syncGroupUtmJob = defineJob({ + name: "sync-group-utm-job", + schema: inputSchema, + async handle(input) { + let { groupId, partnerIds, startAfterProgramEnrollmentId } = input; + + // Find the UTM template + const group = await prisma.partnerGroup.findUnique({ + where: { + id: groupId, + }, + select: { + id: true, + name: true, + utmTemplate: true, + }, + }); + + if (!group) { + console.error( + `[syncGroupUtmJob] Group ${groupId} not found. Skipping...`, + ); + return; + } + + const { utmTemplate } = group; + + // Find partners in the group + const programEnrollments = await prisma.programEnrollment.findMany({ + where: { + groupId: group.id, + ...(partnerIds && { + partnerId: { + in: partnerIds, + }, + }), + ...(startAfterProgramEnrollmentId && { + id: { + gt: startAfterProgramEnrollmentId, + }, + }), + }, + take: PAGE_SIZE, + orderBy: { + id: "asc", + }, + select: { + id: true, + partner: { + select: { + name: true, + }, + }, + links: { + select: { + id: true, + domain: true, + key: true, + url: true, + }, + }, + }, + }); + + if (programEnrollments.length === 0) { + console.log( + `[syncGroupUtmJob] No program enrollments found. Skipping...`, + ); + return; + } + + const linksToExpire: Pick[] = []; + const linkUpdateGroupsByUrl = new Map< + string, + { + linkIds: string[]; + data: { + url: string; + } & ReturnType; + } + >(); + + for (const { links, partner } of programEnrollments) { + for (const link of links) { + const utmContext = { + partnerName: partner.name || link.key, + partnerLinkKey: link.key, + }; + + const resolvedUtmParams = extractAndResolveUtmParams( + utmTemplate, + utmContext, + ); + + const resolvedUtmColumns = extractAndResolveUtmParams( + utmTemplate, + utmContext, + { excludeRef: true }, + ); + + const data = { + url: constructURLFromUTMParams(link.url, resolvedUtmParams), + ...resolvedUtmColumns, + }; + + const existing = linkUpdateGroupsByUrl.get(data.url); + + if (existing) { + existing.linkIds.push(link.id); + } else { + linkUpdateGroupsByUrl.set(data.url, { linkIds: [link.id], data }); + } + + linksToExpire.push({ + domain: link.domain, + key: link.key, + }); + } + } + + const linkUpdateGroups = [...linkUpdateGroupsByUrl.values()]; + const linkUpdateGroupBatches = chunk(linkUpdateGroups, UPDATE_BATCH_SIZE); + + for (const batch of linkUpdateGroupBatches) { + await Promise.all( + batch.map(({ linkIds, data }) => + prisma.link.updateMany({ + where: { + id: { + in: linkIds, + }, + }, + data, + }), + ), + ); + } + + const totalLinks = linkUpdateGroups.reduce( + (sum, { linkIds }) => sum + linkIds.length, + 0, + ); + + console.log( + `[syncGroupUtmJob] Updated ${totalLinks} links. Expiring Redis cache for ${linksToExpire.length} links...`, + ); + + await linkCache.expireMany(linksToExpire); + + if (programEnrollments.length === PAGE_SIZE) { + startAfterProgramEnrollmentId = + programEnrollments[programEnrollments.length - 1].id; + + await syncGroupUtmJob.dispatch( + { + groupId, + partnerIds, + startAfterProgramEnrollmentId, + }, + { + delay: 1, + label: groupId, + }, + ); + return; + } + + console.log( + `[syncGroupUtmJob] Finished syncing UTM settings for ${programEnrollments.length} partners in the ${group.name} group (${group.id}).`, + ); + }, +}); diff --git a/apps/web/lib/jobs/registry.ts b/apps/web/lib/jobs/registry.ts index a93e0a52d1d..93d0f0562da 100644 --- a/apps/web/lib/jobs/registry.ts +++ b/apps/web/lib/jobs/registry.ts @@ -29,6 +29,9 @@ const jobLoaders = { import("./handlers/create-tremendous-campaign-job").then( (m) => m.createTremendousCampaignJob, ), + + "sync-group-utm-job": () => + import("./handlers/sync-group-utm-job").then((m) => m.syncGroupUtmJob), } as const satisfies Record Promise>; const jobCache = new Map(); diff --git a/apps/web/lib/partners/dispatch-partner-utm-sync.ts b/apps/web/lib/partners/dispatch-partner-utm-sync.ts new file mode 100644 index 00000000000..a374e7fde32 --- /dev/null +++ b/apps/web/lib/partners/dispatch-partner-utm-sync.ts @@ -0,0 +1,45 @@ +import { syncGroupUtmJob } from "@/lib/jobs/handlers/sync-group-utm-job"; +import { prisma } from "@/lib/prisma"; + +// Partner UTM macros ({{PARTNER_NAME}}, {{PARTNER_LINK_KEY}}) are resolved into +// concrete Link.url / utm_* values at write time. +// When Partner.name changes, those stored values go stale unless we re-run syncGroupUtmJob. +export async function dispatchGroupUtmSyncForPartner(partnerId: string) { + const programEnrollments = await prisma.programEnrollment.findMany({ + where: { + partnerId, + groupId: { + not: null, + }, + }, + select: { + groupId: true, + }, + }); + + const groupIds = [ + ...new Set( + programEnrollments + .map((enrollment) => enrollment.groupId) + .filter((id): id is string => id != null), + ), + ]; + + if (groupIds.length === 0) { + return; + } + + await Promise.all( + groupIds.map((groupId) => + syncGroupUtmJob.dispatch( + { + groupId, + partnerIds: [partnerId], + }, + { + label: groupId, + }, + ), + ), + ); +} diff --git a/apps/web/lib/partners/macros.ts b/apps/web/lib/partners/macros.ts new file mode 100644 index 00000000000..aad7f0191db --- /dev/null +++ b/apps/web/lib/partners/macros.ts @@ -0,0 +1,54 @@ +export interface PartnerMacroContext { + partnerName: string; + partnerLinkKey: string; +} + +export const PARTNER_MACROS = [ + { + macro: "{{PARTNER_NAME}}", + description: "The partner's name (e.g. 'John Doe')", + }, + { + macro: "{{PARTNER_LINK_KEY}}", + description: "The partner's link key (e.g. 'john-doe')", + }, +] as const; + +export const PARTNER_MACRO_VALUES: readonly string[] = PARTNER_MACROS.map( + (m) => m.macro, +); + +const MACRO_TOKEN_RE = /\{\{[^}]+\}\}/g; + +// Every `{{...}}` substring must be a known partner macro. +export function isValidPartnerMacroTemplate(value: string): boolean { + const matches = value.match(MACRO_TOKEN_RE) ?? []; + return matches.every((token) => PARTNER_MACRO_VALUES.includes(token)); +} + +// Validates a free-form value (any `{{...}}` tokens must be known macros). +export function assertValidPartnerMacroValue(value: string): void { + if (!isValidPartnerMacroTemplate(value)) { + throw new Error( + `Invalid macro in value. Use only: ${PARTNER_MACRO_VALUES.join(", ")}`, + ); + } +} + +const macroReplacements: Record = { + "{{PARTNER_NAME}}": "partnerName", + "{{PARTNER_LINK_KEY}}": "partnerLinkKey", +}; + +export function resolvePartnerMacros( + value: string, + context: PartnerMacroContext, +): string { + let resolvedValue = value; + + for (const [macro, contextKey] of Object.entries(macroReplacements)) { + resolvedValue = resolvedValue.replaceAll(macro, context[contextKey] ?? ""); + } + + return resolvedValue; +} diff --git a/apps/web/lib/partners/sync-partner-identity.ts b/apps/web/lib/partners/sync-partner-identity.ts index 037456c8f1c..ac5a5bd5481 100644 --- a/apps/web/lib/partners/sync-partner-identity.ts +++ b/apps/web/lib/partners/sync-partner-identity.ts @@ -1,8 +1,10 @@ import { DubApiError } from "@/lib/api/errors"; import { confirmEmailChange } from "@/lib/auth/confirm-email-change"; +import { dispatchGroupUtmSyncForPartner } from "@/lib/partners/dispatch-partner-utm-sync"; import { prisma } from "@/lib/prisma"; import { storage } from "@/lib/storage"; import { nanoid } from "@dub/utils"; +import { waitUntil } from "@vercel/functions"; export async function assertEmailAvailableForIdentitySync({ newEmail, @@ -101,6 +103,10 @@ export async function syncNameAndImageToPartner({ ...(hasImageUpdate && { image: partnerImage ?? null }), }, }); + + if (hasNameUpdate && name) { + waitUntil(dispatchGroupUtmSyncForPartner(partnerId)); + } } export async function syncNameAndImageToUser({ diff --git a/apps/web/lib/zod/schemas/utm.ts b/apps/web/lib/zod/schemas/utm.ts index 5dfda0513b5..aa8932a57d6 100644 --- a/apps/web/lib/zod/schemas/utm.ts +++ b/apps/web/lib/zod/schemas/utm.ts @@ -1,11 +1,18 @@ +import { + PARTNER_MACRO_VALUES, + isValidPartnerMacroTemplate, +} from "@/lib/partners/macros"; import * as z from "zod/v4"; -const UTM_TAG_MAX_LENGTH = 255; +export const UTM_TAG_MAX_LENGTH = 255; export const utmTagInputSchema = z .string() .trim() .max(UTM_TAG_MAX_LENGTH) + .refine((v) => v === "" || isValidPartnerMacroTemplate(v), { + message: `Invalid macro in value. Use only: ${PARTNER_MACRO_VALUES.join(", ")}`, + }) .transform((v) => (v === "" ? null : v)) .nullish(); diff --git a/apps/web/tests/misc/apply-group-utm-to-link.test.ts b/apps/web/tests/misc/apply-group-utm-to-link.test.ts new file mode 100644 index 00000000000..b9acb19736c --- /dev/null +++ b/apps/web/tests/misc/apply-group-utm-to-link.test.ts @@ -0,0 +1,126 @@ +import { applyGroupUtmToLink } from "@/lib/api/utm/apply-group-utm-to-link"; +import { ProcessedLinkProps } from "@/lib/types"; +import { describe, expect, it } from "vitest"; + +const baseLink = { + domain: "dub.sh", + key: "abc1234", + url: "https://example.com/path?foo=bar", + projectId: "ws_test", +} as ProcessedLinkProps; + +const template = { + utm_source: "{{PARTNER_NAME}}", + utm_medium: "affiliate", + utm_campaign: "{{PARTNER_LINK_KEY}}", + utm_term: null, + utm_content: null, + ref: "partner-{{PARTNER_LINK_KEY}}", +}; + +describe("applyGroupUtmToLink", () => { + it("returns the link unchanged when there is no UTM template", () => { + expect( + applyGroupUtmToLink({ + link: baseLink, + utmTemplate: null, + partnerName: "John Doe", + }), + ).toBe(baseLink); + + expect( + applyGroupUtmToLink({ + link: baseLink, + utmTemplate: undefined, + partnerName: "John Doe", + }), + ).toBe(baseLink); + }); + + it("resolves macros with the final processed link key (not partner name)", () => { + const result = applyGroupUtmToLink({ + link: baseLink, + utmTemplate: template, + partnerName: "John Doe", + }); + + const parsed = new URL(result.url); + expect(parsed.searchParams.get("utm_source")).toBe("John Doe"); + expect(parsed.searchParams.get("utm_medium")).toBe("affiliate"); + expect(parsed.searchParams.get("utm_campaign")).toBe("abc1234"); + expect(parsed.searchParams.get("ref")).toBe("partner-abc1234"); + expect(parsed.searchParams.get("foo")).toBe("bar"); + + expect(result.utm_source).toBe("John Doe"); + expect(result.utm_medium).toBe("affiliate"); + expect(result.utm_campaign).toBe("abc1234"); + expect(result).not.toHaveProperty("ref"); + }); + + it("uses link.key as partnerName fallback when partnerName is missing", () => { + const result = applyGroupUtmToLink({ + link: baseLink, + utmTemplate: template, + partnerName: null, + }); + + expect(new URL(result.url).searchParams.get("utm_source")).toBe("abc1234"); + }); + + it("overwrites unresolved macros already present on the destination URL", () => { + const result = applyGroupUtmToLink({ + link: { + ...baseLink, + url: "https://example.com/?utm_source={{PARTNER_NAME}}&utm_campaign={{PARTNER_LINK_KEY}}&foo=1", + }, + utmTemplate: template, + partnerName: "John Doe", + }); + + const parsed = new URL(result.url); + expect(parsed.searchParams.get("utm_source")).toBe("John Doe"); + expect(parsed.searchParams.get("utm_campaign")).toBe("abc1234"); + expect(parsed.searchParams.get("foo")).toBe("1"); + expect(parsed.searchParams.get("utm_source")).not.toContain("{{"); + }); + + it("overwrites existing static UTM params on the destination URL", () => { + const result = applyGroupUtmToLink({ + link: { + ...baseLink, + url: "https://example.com/?utm_source=old-source&utm_campaign=old-campaign", + }, + utmTemplate: template, + partnerName: "John Doe", + }); + + const parsed = new URL(result.url); + expect(parsed.searchParams.get("utm_source")).toBe("John Doe"); + expect(parsed.searchParams.get("utm_campaign")).toBe("abc1234"); + }); + + it("simulates create-without-key: random key must win over partner name for PARTNER_LINK_KEY", () => { + // Mimics processLink assigning getRandomKey() after the request omitted key. + const processedLink = { + ...baseLink, + key: "x7k9m2p", + url: "https://example.com/", + } as ProcessedLinkProps; + + const result = applyGroupUtmToLink({ + link: processedLink, + utmTemplate: { + ...template, + utm_source: "{{PARTNER_LINK_KEY}}", + utm_campaign: "{{PARTNER_NAME}}", + }, + partnerName: "John Doe", + }); + + const parsed = new URL(result.url); + expect(parsed.searchParams.get("utm_source")).toBe("x7k9m2p"); + expect(parsed.searchParams.get("utm_campaign")).toBe("John Doe"); + // Regression: old create paths fell back partnerLinkKey to partner name. + expect(parsed.searchParams.get("utm_source")).not.toBe("John Doe"); + }); +}); diff --git a/apps/web/ui/modals/add-partner-link-modal.tsx b/apps/web/ui/modals/add-partner-link-modal.tsx index bc29bf122a6..523df109b48 100644 --- a/apps/web/ui/modals/add-partner-link-modal.tsx +++ b/apps/web/ui/modals/add-partner-link-modal.tsx @@ -1,9 +1,6 @@ -import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; -import useGroup from "@/lib/swr/use-group"; import useProgram from "@/lib/swr/use-program"; import useWorkspace from "@/lib/swr/use-workspace"; import { EnrolledPartnerProps, LinkProps } from "@/lib/types"; -import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups"; import { ArrowTurnLeft, Button, @@ -12,8 +9,6 @@ import { useCopyToClipboard, useMediaQuery, } from "@dub/ui"; -import { constructURLFromUTMParams } from "@dub/utils"; -import { UtmTemplate } from "@prisma/client"; import { useCallback, useMemo, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -24,7 +19,7 @@ interface AddPartnerLinkModalProps { showModal: boolean; setShowModal: (showModal: boolean) => void; onSuccess?: (link: LinkProps) => void; - partner: Pick; + partner: Pick; } interface FormData { @@ -53,14 +48,10 @@ const AddPartnerLinkModal = ({ }, }); - const { group: partnerGroup } = useGroup({ - groupIdOrSlug: partner.groupId ?? DEFAULT_PARTNER_GROUP.slug, - }); - - const [key, url] = watch(["key", "url"]); + const key = watch("key"); const onSubmit = async (formData: FormData) => { - if (!program?.id || !partner.id) { + if (!partner.id) { return; } @@ -68,27 +59,20 @@ const AddPartnerLinkModal = ({ setErrorMessage(null); try { - const response = await fetch(`/api/links?workspaceId=${workspaceId}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - ...formData, - partnerId: partner.id, - programId: program.id, - domain: program.domain, - url: constructURLFromUTMParams( - url, - extractUtmParams(partnerGroup?.utmTemplate as UtmTemplate), - ), - ...extractUtmParams(partnerGroup?.utmTemplate as UtmTemplate, { - excludeRef: true, + const response = await fetch( + `/api/partners/links?workspaceId=${workspaceId}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + partnerId: partner.id, + key: formData.key, + url: formData.url || undefined, }), - trackConversion: true, - folderId: program.defaultFolderId, - }), - }); + }, + ); const data = await response.json(); @@ -218,7 +202,7 @@ export function useAddPartnerLinkModal({ partner, }: { onSuccess?: (link: LinkProps) => void; - partner: Pick; + partner: Pick; }) { const [showAddPartnerLinkModal, setShowAddPartnerLinkModal] = useState(false); diff --git a/packages/ui/src/utm-builder.tsx b/packages/ui/src/utm-builder.tsx index cc1bd86c9be..e5c893d0032 100644 --- a/packages/ui/src/utm-builder.tsx +++ b/packages/ui/src/utm-builder.tsx @@ -1,7 +1,17 @@ "use client"; import { cn } from "@dub/utils"; -import { ReactNode, useEffect, useId, useRef, useState } from "react"; +import { + CSSProperties, + KeyboardEvent, + ReactNode, + Ref, + useEffect, + useId, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; import { useMediaQuery } from "./hooks/use-media-query"; import { Flag6, @@ -58,6 +68,270 @@ export const UTM_PARAMETERS = [ }, ] as const; +export type UTMSuggestion = { + value: string; + description?: string; +}; + +function getOpenMacroToken( + value: string, + caret: number, +): { start: number; query: string } | null { + const before = value.slice(0, caret); + const openIdx = before.lastIndexOf("{{"); + if (openIdx === -1) return null; + + const afterOpen = before.slice(openIdx + 2); + if (afterOpen.includes("}")) return null; + + return { start: openIdx, query: afterOpen }; +} + +function filterSuggestions( + suggestions: UTMSuggestion[], + query: string, +): UTMSuggestion[] { + const q = query.toLowerCase(); + if (!q) return suggestions; + + return suggestions.filter((s) => { + const value = s.value.toLowerCase(); + return value.includes(q) || value.slice(2).startsWith(q); + }); +} + +function UTMInput({ + id, + inputRef, + placeholder, + disabled, + disabledTooltip, + value, + onChange, + suggestions, +}: { + id: string; + inputRef?: Ref; + placeholder: string; + disabled?: boolean; + disabledTooltip?: string | ReactNode; + value: string; + onChange: (value: string) => void; + suggestions?: UTMSuggestion[]; +}) { + const localRef = useRef(null); + const blurTimeoutRef = useRef | null>(null); + + const [menuOpen, setMenuOpen] = useState(false); + const [query, setQuery] = useState(""); + const [tokenStart, setTokenStart] = useState(0); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const [menuStyle, setMenuStyle] = useState({}); + + const filtered = + suggestions && menuOpen ? filterSuggestions(suggestions, query) : []; + + const setInputRef = (el: HTMLInputElement | null) => { + localRef.current = el; + if (!inputRef) return; + if (typeof inputRef === "function") { + inputRef(el); + } else { + inputRef.current = el; + } + }; + + const updateMenuPosition = () => { + const el = localRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + setMenuStyle({ + position: "fixed", + top: rect.bottom + 4, + left: rect.left, + width: rect.width, + zIndex: 50, + }); + }; + + const updateAutocomplete = (nextValue: string, caret: number) => { + if (!suggestions?.length) { + setMenuOpen(false); + return; + } + + const token = getOpenMacroToken(nextValue, caret); + if (!token) { + setMenuOpen(false); + return; + } + + setTokenStart(token.start); + setQuery(token.query); + setHighlightedIndex(0); + updateMenuPosition(); + setMenuOpen(true); + }; + + const insertSuggestion = (suggestion: UTMSuggestion) => { + const input = localRef.current; + const caret = input?.selectionStart ?? value.length; + const token = getOpenMacroToken(value, caret); + const start = token?.start ?? tokenStart; + + const nextValue = + value.slice(0, start) + suggestion.value + value.slice(caret); + const nextCaret = start + suggestion.value.length; + + onChange(nextValue); + setMenuOpen(false); + + requestAnimationFrame(() => { + const el = localRef.current; + if (!el) return; + el.focus(); + el.setSelectionRange(nextCaret, nextCaret); + }); + }; + + const onKeyDown = (e: KeyboardEvent) => { + if (!menuOpen || filtered.length === 0) return; + + if (e.key === "ArrowDown") { + e.preventDefault(); + setHighlightedIndex((i) => (i + 1) % filtered.length); + return; + } + + if (e.key === "ArrowUp") { + e.preventDefault(); + setHighlightedIndex((i) => (i - 1 + filtered.length) % filtered.length); + return; + } + + if (e.key === "Enter") { + e.preventDefault(); + insertSuggestion(filtered[highlightedIndex]); + return; + } + + if (e.key === "Escape") { + e.preventDefault(); + setMenuOpen(false); + } + }; + + useEffect(() => { + return () => { + if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current); + }; + }, []); + + useEffect(() => { + setHighlightedIndex(0); + }, [query]); + + useEffect(() => { + if (!menuOpen) return; + + const onScrollOrResize = () => updateMenuPosition(); + window.addEventListener("resize", onScrollOrResize); + window.addEventListener("scroll", onScrollOrResize, true); + return () => { + window.removeEventListener("resize", onScrollOrResize); + window.removeEventListener("scroll", onScrollOrResize, true); + }; + }, [menuOpen]); + + return ( +
+ + { + const nextValue = e.target.value; + onChange(nextValue); + updateAutocomplete( + nextValue, + e.target.selectionStart ?? nextValue.length, + ); + }} + onSelect={(e) => { + const target = e.currentTarget; + updateAutocomplete( + target.value, + target.selectionStart ?? target.value.length, + ); + }} + onKeyDown={onKeyDown} + onBlur={() => { + blurTimeoutRef.current = setTimeout(() => setMenuOpen(false), 150); + }} + onFocus={(e) => { + if (blurTimeoutRef.current) { + clearTimeout(blurTimeoutRef.current); + blurTimeoutRef.current = null; + } + updateAutocomplete( + e.currentTarget.value, + e.currentTarget.selectionStart ?? e.currentTarget.value.length, + ); + }} + /> + + + {menuOpen && + filtered.length > 0 && + createPortal( +
+ {filtered.map((suggestion, index) => ( + + ))} +
, + document.body, + )} +
+ ); +} + export function UTMBuilder({ values, onChange, @@ -65,6 +339,7 @@ export function UTMBuilder({ autoFocus, disabledTooltip, className, + suggestions, }: { values: Record< (typeof UTM_PARAMETERS)[number]["key"], @@ -78,6 +353,7 @@ export function UTMBuilder({ autoFocus?: boolean; disabledTooltip?: string | ReactNode; className?: string; + suggestions?: UTMSuggestion[]; }) { const { isMobile } = useMediaQuery(); @@ -129,29 +405,16 @@ export function UTMBuilder({ -
- - onChange(key, e.target.value)} - /> - -
+ onChange(key, value)} + suggestions={suggestions} + /> );