diff --git a/apps/web/app/(ee)/api/track/application/route.ts b/apps/web/app/(ee)/api/track/application/route.ts new file mode 100644 index 00000000000..4a1f9a00a77 --- /dev/null +++ b/apps/web/app/(ee)/api/track/application/route.ts @@ -0,0 +1,119 @@ +import { COMMON_CORS_HEADERS } from "@/lib/api/cors"; +import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors"; +import { parseRequestBody } from "@/lib/api/utils"; +import { getIP } from "@/lib/api/utils/get-ip"; +import { APPLICATION_ID_COOKIE } from "@/lib/application-tracker/constants"; +import { recordApplicationEvent } from "@/lib/application-tracker/record-application-event"; +import { trackApplicationInputSchema } from "@/lib/application-tracker/schema"; +import { withAxiom } from "@/lib/axiom/server"; +import { detectBot } from "@/lib/middleware/utils/detect-bot"; +import { ratelimit, redis } from "@/lib/upstash"; +import { prisma } from "@dub/prisma"; +import { nanoid } from "@dub/utils"; +import { addMonths } from "date-fns"; +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +function extractProgramFromPathname(pathname: string) { + return pathname.split("/").filter(Boolean)[0]?.toLowerCase() ?? null; +} + +// POST /api/track/application – Track an application event +export const POST = withAxiom(async (req) => { + try { + if (detectBot(req)) { + return NextResponse.json( + { applicationId: null }, + { status: 202, headers: COMMON_CORS_HEADERS }, + ); + } + + const ip = await getIP(); + const { success } = await ratelimit(10, "10 s").limit( + `track-application:${ip}`, + ); + + if (!success) { + throw new DubApiError({ + code: "rate_limit_exceeded", + message: "Too many requests. Please try again later.", + }); + } + + let { eventName, pathname, applicationId, referrerUsername } = + trackApplicationInputSchema.parse(await parseRequestBody(req)); + + // Extract the program slug from pathname + const programSlug = extractProgramFromPathname(pathname); + + if (!programSlug) { + throw new DubApiError({ + code: "bad_request", + message: "Couldn't extract program slug from pathname.", + }); + } + + // Find the program + const program = await prisma.program.findUnique({ + where: { + slug: programSlug, + }, + select: { + id: true, + slug: true, + }, + }); + + if (!program) { + throw new DubApiError({ + code: "bad_request", + message: "Program not found.", + }); + } + + // Set or create cookie + if (!applicationId) { + applicationId = nanoid(16); + + const cookieStore = await cookies(); + + cookieStore.set(APPLICATION_ID_COOKIE, applicationId, { + expires: addMonths(new Date(), 1), // 30 days + path: `/${program.slug}`, + }); + } + + // Dedupe the events + const redisKey = `applicationEvent:${applicationId}:${eventName}`; + const isFirstTime = await redis.set(redisKey, "1", { + nx: true, + ex: 30 * 24 * 60 * 60, // 30 days + }); + + console.log({ isFirstTime, redisKey }); + + if (isFirstTime) { + await recordApplicationEvent({ + applicationId, + programId: program.id, + partnerId: "", + eventName, + req, + }); + } + + return NextResponse.json( + { applicationId }, + { status: 202, headers: COMMON_CORS_HEADERS }, + ); + } catch (error) { + return handleAndReturnErrorResponse(error, COMMON_CORS_HEADERS); + } +}); + +export const OPTIONS = () => { + return new Response(null, { + status: 204, + headers: COMMON_CORS_HEADERS, + }); +}; diff --git a/apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/apply/page.tsx b/apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/apply/page.tsx index d92a9a7c787..0100aaac64b 100644 --- a/apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/apply/page.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/apply/page.tsx @@ -1,6 +1,7 @@ import { getProgram } from "@/lib/fetchers/get-program"; import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups"; import { programApplicationFormSchema } from "@/lib/zod/schemas/program-application-form"; +import { ApplicationTracker } from "@/ui/application-tracker/application-tracker"; import { ApplicationFormHero } from "@/ui/partners/groups/design/application-form/application-hero-preview"; import { ProgramApplicationForm } from "@/ui/partners/groups/design/application-form/program-application-form"; import { LanderRewards } from "@/ui/partners/lander/lander-rewards"; @@ -51,8 +52,8 @@ export default async function ApplicationPage(props: { } > +
- {/* Hero section */} - {/* Application form */}
diff --git a/apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/page.tsx b/apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/page.tsx index dfc21b821e6..279e27360a6 100644 --- a/apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/page.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/page.tsx @@ -1,6 +1,8 @@ import { getProgram } from "@/lib/fetchers/get-program"; import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups"; import { programLanderSchema } from "@/lib/zod/schemas/program-lander"; + +import { ApplicationTracker } from "@/ui/application-tracker/application-tracker"; import { BLOCK_COMPONENTS } from "@/ui/partners/lander/blocks"; import { LanderHero } from "@/ui/partners/lander/lander-hero"; import { LanderRewards } from "@/ui/partners/lander/lander-rewards"; @@ -47,6 +49,7 @@ export default async function ApplyPage(props: { } as CSSProperties } > +
diff --git a/apps/web/app/(ee)/partners.dub.co/(auth-login-register)/(generic)/register/page-client.tsx b/apps/web/app/(ee)/partners.dub.co/(auth-login-register)/(generic)/register/page-client.tsx index 80d711355ee..5da92c00728 100644 --- a/apps/web/app/(ee)/partners.dub.co/(auth-login-register)/(generic)/register/page-client.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(auth-login-register)/(generic)/register/page-client.tsx @@ -14,7 +14,7 @@ import { truncate } from "@dub/utils"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; -type PartialProgram = Pick; +type PartialProgram = Pick; export default function RegisterPageClient({ program, diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx index 6d61a6fefb3..0ef3c115c42 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx @@ -20,6 +20,7 @@ import { buttonVariants, } from "@dub/ui"; import { OG_AVATAR_URL, cn } from "@dub/utils"; +import slugify from "@sindresorhus/slugify"; import { AnimatePresence, LayoutGroup, motion } from "motion/react"; import { useAction } from "next-safe-action/hooks"; import { RefObject, useEffect, useRef } from "react"; @@ -35,6 +36,7 @@ import { SettingsRow } from "./settings-row"; type BasicInfoFormData = { name: string; email: string; + username: string | null; image: string | null; country: string; profileType: PartnerProfileType; @@ -52,6 +54,7 @@ export function ProfileDetailsForm({ partner }: { partner?: PartnerProps }) { defaultValues: { name: partner?.name, email: partner?.email ?? "", + username: partner?.username ?? null, image: partner?.image, country: partner?.country ?? "", profileType: partner?.profileType ?? "individual", @@ -291,6 +294,40 @@ function BasicInfoForm({ })} /> +