Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions apps/web/app/(ee)/api/track/application/route.ts
Original file line number Diff line number Diff line change
@@ -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,
});
};
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -51,8 +52,8 @@ export default async function ApplicationPage(props: {
}
>
<ApplyHeader group={program.group} showApply={false} />
<ApplicationTracker />
<div className="p-6">
{/* Hero section */}
<ApplicationFormHero
program={program}
applicationFormData={applicationFormData}
Expand All @@ -64,7 +65,6 @@ export default async function ApplicationPage(props: {
discount={program.discount}
/>

{/* Application form */}
<div className="mt-10">
<ProgramApplicationForm program={program} group={program.group} />
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -47,6 +49,7 @@ export default async function ApplyPage(props: {
} as CSSProperties
}
>
<ApplicationTracker />
<ApplyHeader group={program.group} />
<div className="p-6">
<LanderHero program={program} landerData={landerData} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { truncate } from "@dub/utils";
import Link from "next/link";
import { useSearchParams } from "next/navigation";

type PartialProgram = Pick<Program, "name" | "logo" | "slug">;
type PartialProgram = Pick<Program, "name" | "logo" | "slug" | "id">;

export default function RegisterPageClient({
program,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -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",
Expand Down Expand Up @@ -291,6 +294,40 @@ function BasicInfoForm({
})}
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium text-neutral-800">Username</span>
<input
type="text"
disabled={disabled}
className={cn(
"block w-full rounded-md focus:outline-none sm:text-sm",
disabled && "cursor-not-allowed bg-neutral-50 text-neutral-400",
errors.username
? "border-red-300 pr-10 text-red-900 placeholder-red-300 focus:border-red-500 focus:ring-red-500"
: "border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-neutral-500",
)}
placeholder={partner?.name ? slugify(partner.name) : "username"}
{...register("username", {
setValueAs: (v) => (v === "" ? null : v) || null,
minLength: {
value: 3,
message: "Username must be at least 3 characters",
},
maxLength: {
value: 30,
message: "Username must be at most 30 characters",
},
pattern: {
value: /^[a-zA-Z0-9_]*$/,
message:
"Username can only contain letters, numbers, and underscores",
},
})}
/>
{errors.username && (
<p className="text-sm text-red-600">{errors.username.message}</p>
)}
</label>
<label className="flex flex-col">
<span className="text-sm font-medium text-neutral-800">Country</span>
<Controller
Expand Down
26 changes: 26 additions & 0 deletions apps/web/lib/actions/partners/create-program-application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { createId } from "@/lib/api/create-id";
import { detectAndRecordFraudApplication } from "@/lib/api/fraud/detect-record-fraud-application";
import { notifyPartnerApplication } from "@/lib/api/partners/notify-partner-application";
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 { getSession } from "@/lib/auth";
import { qstash } from "@/lib/cron";
import { getPartnerProfileChecklistProgress } from "@/lib/network/get-partner-profile-checklist-progress";
Expand Down Expand Up @@ -327,6 +329,18 @@ async function createApplicationAndEnrollment({
},
}),
]);

const cookieStore = await cookies();

const dubApplicationId = cookieStore.get("dub_application_id")?.value;

if (dubApplicationId) {
await recordApplicationEvent({
applicationId: dubApplicationId,
eventName: "submit",
programId: program.id,
});
}
})(),
);

Expand Down Expand Up @@ -373,6 +387,18 @@ async function createApplication({
},
);

const dubApplicationId = cookieStore.get(APPLICATION_ID_COOKIE)?.value;

if (dubApplicationId) {
waitUntil(
recordApplicationEvent({
applicationId: dubApplicationId,
eventName: "submit",
programId: program.id,
}),
);
}

return {
programApplicationId: application.id,
partnerData: {
Expand Down
39 changes: 38 additions & 1 deletion apps/web/lib/actions/partners/update-partner-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,23 @@ import * as z from "zod/v4";
import { uploadedImageSchema } from "../../zod/schemas/misc";
import { authPartnerActionClient } from "../safe-action";

const usernameSchema = z
.string()
.trim()
.toLowerCase()
.min(3, "Username must be at least 3 characters")
.max(30, "Username must be at most 30 characters")
.regex(
/^[a-z0-9_]+$/,
"Username can only contain letters, numbers, and underscores",
)
.nullish();

const updatePartnerProfileSchema = z
.object({
name: z.string().optional(),
email: z.email().optional(),
username: usernameSchema,
image: uploadedImageSchema.nullish(),
description: z.string().max(MAX_PARTNER_DESCRIPTION_LENGTH).nullish(),
country: z.enum(Object.keys(COUNTRIES) as [string, ...string[]]).nullish(),
Expand All @@ -54,6 +67,7 @@ export const updatePartnerProfileAction = authPartnerActionClient
const {
name,
email: newEmail,
username: newUsername,
image,
description,
country,
Expand All @@ -79,6 +93,24 @@ export const updatePartnerProfileAction = authPartnerActionClient
let imageUrl: string | null = null;
let needsEmailVerification = false;
const emailChanged = newEmail !== undefined && partner.email !== newEmail;
const usernameChanged = newUsername && partner.username !== newUsername;

if (usernameChanged && newUsername) {
const usernameExists = await prisma.partner.findUnique({
where: {
username: newUsername,
},
select: {
id: true,
},
});

if (usernameExists && usernameExists.id !== partner.id) {
throw new Error(
"This username is already taken. Please choose another.",
);
}
}

// Upload the new image
if (image) {
Expand All @@ -101,6 +133,9 @@ export const updatePartnerProfileAction = authPartnerActionClient
country,
profileType,
companyName,
...(newUsername !== undefined && {
username: newUsername,
}),
monthlyTraffic,
...(industryInterests && {
industryInterests: {
Expand Down Expand Up @@ -196,7 +231,9 @@ export const updatePartnerProfileAction = authPartnerActionClient
} catch (error) {
console.error(error);

throw new Error(error.message);
throw new Error(
error instanceof Error ? error.message : "Something went wrong.",
);
}
});

Expand Down
1 change: 1 addition & 0 deletions apps/web/lib/application-tracker/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const APPLICATION_ID_COOKIE = "dub_application_id";
Loading
Loading