Skip to content
Merged
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
3 changes: 0 additions & 3 deletions apps/web/app/(ee)/api/network/programs/count/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { withPartnerProfile } from "@/lib/auth/partner";
import { throwIfPartnerCannotViewMarketplace } from "@/lib/network/throw-if-partner-cannot-view-marketplace";
import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups";
import { getNetworkProgramsCountQuerySchema } from "@/lib/zod/schemas/program-network";
import { prisma } from "@dub/prisma";
Expand All @@ -15,8 +14,6 @@ const rewardTypeMap = {

// GET /api/network/programs/count - get the number of available programs in the network
export const GET = withPartnerProfile(async ({ partner, searchParams }) => {
await throwIfPartnerCannotViewMarketplace({ partner });

const { groupBy, category, rewardType, status, featured, search } =
getNetworkProgramsCountQuerySchema.parse(searchParams);

Expand Down
3 changes: 0 additions & 3 deletions apps/web/app/(ee)/api/network/programs/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { withPartnerProfile } from "@/lib/auth/partner";
import { throwIfPartnerCannotViewMarketplace } from "@/lib/network/throw-if-partner-cannot-view-marketplace";
import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups";
import {
NetworkProgramSchema,
Expand All @@ -11,8 +10,6 @@ import * as z from "zod/v4";

// GET /api/network/programs - get all available programs in the network
export const GET = withPartnerProfile(async ({ partner, searchParams }) => {
await throwIfPartnerCannotViewMarketplace({ partner });

const {
search,
featured,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export function ProfileDetailsForm({ partner }: { partner?: PartnerProps }) {
</div>

<SettingsRow
id="info"
heading="Basic information"
description="Your core details, and information that's required to set up your Dub Partner account."
>
Expand All @@ -100,7 +101,7 @@ export function ProfileDetailsForm({ partner }: { partner?: PartnerProps }) {
</SettingsRow>

<SettingsRow
id="sites"
id="platforms"
heading="Website and socials"
description="Add your website and social accounts you use to share links. Verifying as many platforms as possible helps build trust with programs."
>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import { EXCLUDED_PROGRAM_IDS } from "@/lib/constants/partner-profile";
import {
getDiscoverabilityRequirements,
partnerHasEarnedCommissions,
} from "@/lib/network/get-discoverability-requirements";
import { getPartnerProfileChecklistProgress } from "@/lib/network/get-partner-profile-checklist-progress";
import usePartnerProfile from "@/lib/swr/use-partner-profile";
import useProgramEnrollments from "@/lib/swr/use-program-enrollments";
import { useMemo } from "react";
Expand All @@ -14,23 +10,11 @@ export function usePartnerDiscoveryRequirements() {
return useMemo(() => {
if (!partner || !programEnrollments) return undefined;

const enrollmentProgramIds = new Set(
programEnrollments.map((e) => e.programId),
);
const hasExcludedProgram = EXCLUDED_PROGRAM_IDS.some((id) =>
enrollmentProgramIds.has(id),
);

if (
hasExcludedProgram &&
!partnerHasEarnedCommissions(programEnrollments)
) {
return undefined;
}

return getDiscoverabilityRequirements({
const checklistProgress = getPartnerProfileChecklistProgress({
partner,
programEnrollments,
});

return checklistProgress.tasks;
}, [partner, programEnrollments]);
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
"use client";

import { acceptProgramInviteAction } from "@/lib/actions/partners/accept-program-invite";
import { getPartnerProfileChecklistProgress } from "@/lib/network/get-partner-profile-checklist-progress";
import { mutatePrefix } from "@/lib/swr/mutate";
import useProgramEnrollment from "@/lib/swr/use-program-enrollment";
import usePartnerProfile from "@/lib/swr/use-partner-profile";
import useProgramEnrollments from "@/lib/swr/use-program-enrollments";
import { NetworkProgramProps } from "@/lib/types";
import { useProgramApplicationSheet } from "@/ui/partners/program-application-sheet";
import { Button, useKeyboardShortcut } from "@dub/ui";
import { Button, ProgressCircle, useKeyboardShortcut } from "@dub/ui";
import { useAction } from "next-safe-action/hooks";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { ReactNode, useMemo } from "react";
import { toast } from "sonner";

export function MarketplaceProgramHeaderControls({
Expand Down Expand Up @@ -47,16 +49,55 @@ function ApplyButton({ program }: { program: NetworkProgramProps }) {
onSuccess: () => mutatePrefix("/api/network/programs"),
});

const { programEnrollment } = useProgramEnrollment();
const { partner } = usePartnerProfile();
const { programEnrollments } = useProgramEnrollments();

const programEnrollment = useMemo(
() =>
programEnrollments?.find(
(programEnrollment) => programEnrollment.program.slug === program.slug,
),
[programEnrollments, program.slug],
);

const disabledTooltip =
programEnrollment?.status === "banned"
? "You are banned from this program"
: programEnrollment?.status === "pending"
? "Your application is under review"
: programEnrollment?.status === "rejected"
? "Your application was rejected"
: undefined;
const checklistProgress = useMemo(() => {
return partner && programEnrollments
? getPartnerProfileChecklistProgress({
partner,
programEnrollments,
})
: undefined;
}, [partner, programEnrollments]);

const disabledTooltip: ReactNode =
programEnrollment?.status === "banned" ? (
"You are banned from this program"
) : programEnrollment?.status === "pending" ? (
"Your application is under review"
) : programEnrollment?.status === "rejected" ? (
"Your application was rejected"
) : checklistProgress && !checklistProgress.isComplete ? (
<div className="max-w-xs p-4">
<div className="text-content-default text-sm leading-5">
Complete your partner profile to apply
</div>
<Link
href="/profile"
className="bg-bg-subtle mt-3 flex items-center justify-center gap-2 rounded-lg px-2.5 py-1.5"
>
<ProgressCircle
progress={
checklistProgress.completedCount / checklistProgress.totalCount
}
className="text-green-500"
/>
<span className="text-content-default text-sm font-medium">
{checklistProgress.completedCount} of {checklistProgress.totalCount}{" "}
tasks completed
</span>
</Link>
</div>
) : undefined;

useKeyboardShortcut("a", () => setIsApplicationSheetOpen(true), {
enabled: !disabledTooltip,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,7 @@
"use client";

import { partnerCanViewMarketplace } from "@/lib/network/get-discoverability-requirements";
import usePartnerProfile from "@/lib/swr/use-partner-profile";
import useProgramEnrollments from "@/lib/swr/use-program-enrollments";
import { redirect } from "next/navigation";
import { PropsWithChildren } from "react";

export default function MarketplaceLayout({ children }: PropsWithChildren) {
const { partner } = usePartnerProfile();
const { programEnrollments, isLoading } = useProgramEnrollments();
if (
!isLoading &&
programEnrollments &&
!partnerCanViewMarketplace({ partner, programEnrollments })
)
redirect("/programs");

return children;
}
24 changes: 15 additions & 9 deletions apps/web/app/api/user/referrals-token/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { withSession } from "@/lib/auth";
import { dub } from "@/lib/dub";
import { partnerCanViewMarketplace } from "@/lib/network/get-discoverability-requirements";
import {
partnerHasEarnedCommissions,
partnerIsNotBanned,
} from "@/lib/network/get-discoverability-requirements";
import { prisma } from "@dub/prisma";
import { NextResponse } from "next/server";

Expand Down Expand Up @@ -31,16 +34,19 @@ export const GET = withSession(async ({ session }) => {
},
});
const paidWorkspaces = user.projects.map((project) => project);

// for free users, need to do some extra checks
if (!paidWorkspaces || paidWorkspaces.length === 0) {
// if the free user has a partner account, they are only eligible if they can view the program marketplace
const programEnrollments = user.partners[0]?.partner.programs;
if (paidWorkspaces.length === 0) {
// for partners with free workspaces, they are only eligible if they have a good reputation
const programEnrollments = user.partners[0]
? user.partners[0].partner.programs
: [];
if (
programEnrollments &&
!partnerCanViewMarketplace({
partner: user.partners[0]?.partner,
programEnrollments,
})
programEnrollments.length > 0 &&
!(
partnerHasEarnedCommissions(programEnrollments) &&
partnerIsNotBanned(programEnrollments)
)
) {
return NextResponse.json({ publicToken: null });
}
Expand Down
24 changes: 14 additions & 10 deletions apps/web/lib/actions/partners/create-program-application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { notifyPartnerApplication } from "@/lib/api/partners/notify-partner-appl
import { getIP } from "@/lib/api/utils/get-ip";
import { getSession } from "@/lib/auth";
import { qstash } from "@/lib/cron";
import { partnerCanViewMarketplace } from "@/lib/network/get-discoverability-requirements";
import { getPartnerProfileChecklistProgress } from "@/lib/network/get-partner-profile-checklist-progress";
import {
formatApplicationFormData,
formatWebsiteAndSocialsFields,
Expand Down Expand Up @@ -165,24 +165,28 @@ export const createProgramApplicationAction = actionClient
},
include: {
programs: true,
platforms: true,
},
})
: null;

// if the application form is not published and
// the partner is not logged in OR is logged in but cannot view the marketplace, throw an error
if (
!group.applicationFormPublishedAt &&
(!existingPartner ||
!partnerCanViewMarketplace({
partner: existingPartner,
programEnrollments: existingPartner.programs,
}))
) {
// the partner is not logged in, throw an error
if (!group.applicationFormPublishedAt && !existingPartner) {
throw new Error("This program is no longer accepting applications.");
}

if (existingPartner) {
// if an existing partner has an incomplete profile, prompt them to complete it
const { isComplete } = getPartnerProfileChecklistProgress({
partner: existingPartner,
programEnrollments: existingPartner.programs,
});

if (!isComplete) {
throw new Error("Complete your partner profile to apply.");
}

return createApplicationAndEnrollment({
workspace: program.workspace,
program,
Expand Down
54 changes: 21 additions & 33 deletions apps/web/lib/network/get-discoverability-requirements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,37 +22,19 @@ export const partnerHasEarnedCommissions = (
);
};

const partnerIsNotBanned = (
export const partnerIsNotBanned = (
programEnrollments: Pick<EnrolledPartnerProps, "programId" | "status">[],
) => {
return programEnrollments.every((pe) => pe.status !== "banned");
};

export const partnerCanViewMarketplace = ({
partner,
programEnrollments,
}: {
partner?: Pick<PartnerProps, "email">;
programEnrollments: Pick<
EnrolledPartnerProps,
"programId" | "status" | "totalCommissions"
>[];
}) => {
if (partner?.email?.endsWith("@dub.co")) {
return true;
}
return (
partnerHasEarnedCommissions(programEnrollments) &&
partnerIsNotBanned(programEnrollments)
);
};

export function getDiscoverabilityRequirements({
partner,
programEnrollments,
}: {
partner: Pick<
PartnerProps,
| "image"
| "description"
| "monthlyTraffic"
| "preferredEarningStructures"
Expand All @@ -63,21 +45,27 @@ export function getDiscoverabilityRequirements({
EnrolledPartnerProps,
"programId" | "status" | "totalCommissions"
>[];
}) {
}): {
label: string;
href?: string;
completed: boolean;
}[] {
return [
{
label: "Add basic profile info",
completed: true,
label: "Upload your logo",
href: "#info",
completed: !!partner.image,
},
{
label: "Connect your website or social account",
href: "#sites",
completed: PARTNER_PLATFORM_FIELDS.some(
(field) => field.data(partner.platforms).value, // TODO: update this to also check for "verified" in the future
),
label: "Verify at least 2 social accounts/website",
href: "#platforms",
completed:
PARTNER_PLATFORM_FIELDS.filter(
(field) => field.data(partner.platforms).verified,
).length >= 2,
},
{
label: "Update your profile description",
label: "Fill out your profile description",
href: "#about",
completed: !!partner.description,
},
Expand All @@ -96,13 +84,13 @@ export function getDiscoverabilityRequirements({
href: "#channels",
completed: Boolean(partner.salesChannels?.length),
},
{
label: "Maintain a healthy partner profile",
completed: partnerIsNotBanned(programEnrollments),
},
{
label: `Earn ${currencyFormatter(PARTNER_NETWORK_MIN_COMMISSIONS_CENTS, { trailingZeroDisplay: "stripIfInteger" })} in commissions`,
completed: partnerHasEarnedCommissions(programEnrollments),
},
{
label: "Maintain a healthy partner profile",
completed: partnerIsNotBanned(programEnrollments),
},
];
}
Loading