diff --git a/apps/web/app/(ee)/api/commissions/analytics/route.ts b/apps/web/app/(ee)/api/commissions/analytics/route.ts index 8b977a3b7f2..f3bd98a037c 100644 --- a/apps/web/app/(ee)/api/commissions/analytics/route.ts +++ b/apps/web/app/(ee)/api/commissions/analytics/route.ts @@ -27,6 +27,12 @@ type CommissionGroupIdQueryRow = { count: bigint; }; +type CommissionPartnerTagQueryRow = { + partnerTagId: string; + earnings: bigint; + count: bigint; +}; + const excludedStatuses = [ CommissionStatus.duplicate, CommissionStatus.fraud, @@ -41,6 +47,7 @@ function commissionSqlConditions({ partnerFilter, typeFilter, groupIdParam, + partnerTagIdParam, }: { programId: string; startDate: Date; @@ -49,6 +56,7 @@ function commissionSqlConditions({ partnerFilter: ReturnType; typeFilter: ReturnType | null; groupIdParam: string | undefined; + partnerTagIdParam: string | undefined; }): Prisma.Sql[] { const conditions: Prisma.Sql[] = [ Prisma.sql`c.programId = ${programId}`, @@ -94,6 +102,30 @@ function commissionSqlConditions({ } } + if (partnerTagIdParam) { + const partnerTagFilter = parseFilterValue(partnerTagIdParam); + if (partnerTagFilter) { + const list = Prisma.join( + partnerTagFilter.values.map((v) => Prisma.sql`${v}`), + ); + if (partnerTagFilter.sqlOperator === "NOT IN") { + conditions.push(Prisma.sql`NOT EXISTS ( + SELECT 1 FROM ProgramPartnerTag ppt + WHERE ppt.programId = c.programId + AND ppt.partnerId = c.partnerId + AND ppt.partnerTagId IN (${list}) + )`); + } else { + conditions.push(Prisma.sql`EXISTS ( + SELECT 1 FROM ProgramPartnerTag ppt + WHERE ppt.programId = c.programId + AND ppt.partnerId = c.partnerId + AND ppt.partnerTagId IN (${list}) + )`); + } + } + } + return conditions; } @@ -123,6 +155,10 @@ export const GET = withWorkspace(async ({ workspace, searchParams }) => { return byGroupId({ programId, parsed, startDate, endDate }); } + if (groupBy === "partnerTagId") { + return byPartnerTagIdId({ programId, parsed, startDate, endDate }); + } + if (groupBy === "partnerId") { return byPartnerId({ programId, parsed, startDate, endDate }); } @@ -141,9 +177,10 @@ async function byType({ startDate: Date; endDate: Date; }) { - const { status, partnerId, groupId, type } = parsed; + const { status, partnerId, groupId, partnerTagId, type } = parsed; const partnerFilter = parseFilterValue(partnerId); const groupFilter = parseFilterValue(groupId); + const partnerTagFilter = parseFilterValue(partnerTagId); const rawTypeFilter = parseFilterValue(type); const validCommissionTypes = new Set(Object.values(CommissionType)); @@ -167,6 +204,21 @@ async function byType({ ? { ...rawTypeFilter, values: validTypeValues } : null; + const programEnrollmentFilter = { + ...(groupFilter && { + groupId: + groupFilter.sqlOperator === "NOT IN" + ? { notIn: groupFilter.values } + : { in: groupFilter.values }, + }), + ...(partnerTagFilter && { + programPartnerTags: + partnerTagFilter.sqlOperator === "NOT IN" + ? { none: { partnerTagId: { in: partnerTagFilter.values } } } + : { some: { partnerTagId: { in: partnerTagFilter.values } } }, + }), + }; + const baseWhere: Prisma.CommissionWhereInput = { programId, createdAt: { gte: startDate, lt: endDate }, @@ -183,13 +235,8 @@ async function byType({ ? { notIn: typeFilter.values } : { in: typeFilter.values }, }), - ...(groupFilter && { - programEnrollment: { - groupId: - groupFilter.sqlOperator === "NOT IN" - ? { notIn: groupFilter.values } - : { in: groupFilter.values }, - }, + ...(Object.keys(programEnrollmentFilter).length > 0 && { + programEnrollment: programEnrollmentFilter, }), }; @@ -224,7 +271,7 @@ async function byGroupId({ startDate: Date; endDate: Date; }) { - const { status, partnerId, groupId, type } = parsed; + const { status, partnerId, groupId, partnerTagId, type } = parsed; const partnerFilter = parseFilterValue(partnerId); const rawTypeFilter = parseFilterValue(type); @@ -257,6 +304,7 @@ async function byGroupId({ partnerFilter, typeFilter, groupIdParam: groupId, + partnerTagIdParam: partnerTagId, }); const whereClause = Prisma.join(conditions, " AND "); @@ -308,6 +356,112 @@ async function byGroupId({ return NextResponse.json(commissionAnalyticsSchema.groupId.parse(result)); } +async function byPartnerTagIdId({ + programId, + parsed, + startDate, + endDate, +}: { + programId: string; + parsed: CommissionAnalyticsQuery; + startDate: Date; + endDate: Date; +}) { + const { status, partnerId, groupId, partnerTagId, type } = parsed; + const partnerFilter = parseFilterValue(partnerId); + + const rawTypeFilter = parseFilterValue(type); + const validCommissionTypes = new Set(Object.values(CommissionType)); + + const validTypeValues = rawTypeFilter + ? (rawTypeFilter.values.filter((v) => + validCommissionTypes.has(v as CommissionType), + ) as CommissionType[]) + : []; + + if ( + rawTypeFilter?.sqlOperator === "IN" && + rawTypeFilter.values.length > 0 && + validTypeValues.length === 0 + ) { + return NextResponse.json(commissionAnalyticsSchema.partnerTagId.parse([])); + } + + const typeFilter = + rawTypeFilter && validTypeValues.length > 0 + ? { ...rawTypeFilter, values: validTypeValues } + : null; + + const partnerTagFilter = parseFilterValue(partnerTagId); + + const conditions = commissionSqlConditions({ + programId, + startDate, + endDate, + status, + partnerFilter, + typeFilter, + groupIdParam: groupId, + partnerTagIdParam: undefined, + }); + + if (partnerTagFilter) { + const list = Prisma.join( + partnerTagFilter.values.map((v) => Prisma.sql`${v}`), + ); + if (partnerTagFilter.sqlOperator === "IN") { + conditions.push(Prisma.sql`ppt.partnerTagId IN (${list})`); + } else { + conditions.push(Prisma.sql`NOT EXISTS ( + SELECT 1 FROM ProgramPartnerTag ppt_excl + WHERE ppt_excl.programId = c.programId + AND ppt_excl.partnerId = c.partnerId + AND ppt_excl.partnerTagId IN (${list}) + )`); + } + } + + const whereClause = Prisma.join(conditions, " AND "); + + const rows = await prisma.$queryRaw( + Prisma.sql` + SELECT + ppt.partnerTagId AS partnerTagId, + SUM(c.earnings) AS earnings, + COUNT(c.id) AS count + FROM Commission c + JOIN ProgramPartnerTag ppt + ON ppt.programId = c.programId + AND ppt.partnerId = c.partnerId + WHERE ${whereClause} + GROUP BY ppt.partnerTagId + ORDER BY earnings DESC`, + ); + + const partnerTagIds = rows.map((r) => r.partnerTagId); + + const partnerTags = + partnerTagIds.length > 0 + ? await prisma.partnerTag.findMany({ + where: { id: { in: partnerTagIds } }, + select: { id: true, name: true }, + }) + : []; + + const partnerTagById = new Map(partnerTags.map((t) => [t.id, t])); + + const result = rows.map((row) => ({ + key: row.partnerTagId, + label: partnerTagById.get(row.partnerTagId)?.name ?? row.partnerTagId, + earnings: Number(row.earnings), + count: Number(row.count), + })); + + return NextResponse.json( + commissionAnalyticsSchema.partnerTagId.parse(result), + ); +} + async function byPartnerId({ programId, parsed, @@ -319,9 +473,10 @@ async function byPartnerId({ startDate: Date; endDate: Date; }) { - const { status, partnerId, groupId, type } = parsed; + const { status, partnerId, groupId, partnerTagId, type } = parsed; const partnerFilter = parseFilterValue(partnerId); const groupFilter = parseFilterValue(groupId); + const partnerTagFilter = parseFilterValue(partnerTagId); const rawTypeFilter = parseFilterValue(type); const validCommissionTypes = new Set(Object.values(CommissionType)); @@ -345,6 +500,21 @@ async function byPartnerId({ ? { ...rawTypeFilter, values: validTypeValues } : null; + const programEnrollmentFilter = { + ...(groupFilter && { + groupId: + groupFilter.sqlOperator === "NOT IN" + ? { notIn: groupFilter.values } + : { in: groupFilter.values }, + }), + ...(partnerTagFilter && { + programPartnerTags: + partnerTagFilter.sqlOperator === "NOT IN" + ? { none: { partnerTagId: { in: partnerTagFilter.values } } } + : { some: { partnerTagId: { in: partnerTagFilter.values } } }, + }), + }; + const grouped = await prisma.commission.groupBy({ by: ["partnerId"], where: { @@ -363,13 +533,8 @@ async function byPartnerId({ ? { notIn: typeFilter.values } : { in: typeFilter.values }, }), - ...(groupFilter && { - programEnrollment: { - groupId: - groupFilter.sqlOperator === "NOT IN" - ? { notIn: groupFilter.values } - : { in: groupFilter.values }, - }, + ...(Object.keys(programEnrollmentFilter).length > 0 && { + programEnrollment: programEnrollmentFilter, }), }, _sum: { earnings: true }, @@ -418,8 +583,17 @@ async function byTimeseries({ programId: string; parsed: z.infer; }) { - const { start, end, interval, timezone, status, partnerId, groupId, type } = - parsed; + const { + start, + end, + interval, + timezone, + status, + partnerId, + groupId, + partnerTagId, + type, + } = parsed; const { startDate, endDate, granularity } = getStartEndDates({ interval, @@ -449,6 +623,7 @@ async function byTimeseries({ partnerFilter, typeFilter, groupIdParam: groupId, + partnerTagIdParam: partnerTagId, }); const whereClause = Prisma.join(conditions, " AND "); diff --git a/apps/web/app/(ee)/api/partners/applications/reject/route.ts b/apps/web/app/(ee)/api/partners/applications/reject/route.ts index 0a4922999c1..c3ba4310ca8 100644 --- a/apps/web/app/(ee)/api/partners/applications/reject/route.ts +++ b/apps/web/app/(ee)/api/partners/applications/reject/route.ts @@ -7,8 +7,14 @@ import { NextResponse } from "next/server"; // POST /api/partners/applications/reject – Reject a pending partner application export const POST = withWorkspace( async ({ workspace, req, session }) => { - const { partnerId, rejectionReason, rejectionNote, allowImmediateReapply } = - rejectPartnerSchema.parse(await parseRequestBody(req)); + const { + partnerId, + rejectionReason, + rejectionNote, + allowImmediateReapply, + flagForFraud, + flagForFraudReason, + } = rejectPartnerSchema.parse(await parseRequestBody(req)); await rejectPartner({ workspace, @@ -16,6 +22,8 @@ export const POST = withWorkspace( rejectionReason, rejectionNote, allowImmediateReapply, + flagForFraud, + flagForFraudReason, userId: session.user.id, }); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/applications/use-applications-analytics-filters.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/applications/use-applications-analytics-filters.tsx index 129bb200d69..e8770d88296 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/applications/use-applications-analytics-filters.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/applications/use-applications-analytics-filters.tsx @@ -11,6 +11,7 @@ import { nFormatter, parseFilterValue, } from "@dub/utils"; +import { useParams } from "next/navigation"; import { useCallback, useMemo } from "react"; import { ApplicationReferralSourceIcon } from "./application-referral-source-icon"; import { useApplicationsAnalytics } from "./use-applications-analytics"; @@ -20,6 +21,7 @@ const FILTER_KEYS = ["partnerId", "country", "referralSource"] as const; export function useApplicationAnalyticsFilters() { const { slug } = useWorkspace(); + const { tab } = useParams() as { tab?: string }; const { stage } = useApplicationsAnalyticsQuery(); const { searchParamsObj, queryParams } = useRouterStuff(); @@ -35,16 +37,19 @@ export function useApplicationAnalyticsFilters() { const { data: partners } = useApplicationsAnalytics({ groupBy: "partnerId", exclude: ["partnerId"], + enabled: tab === "applications", }); const { data: referralSources } = useApplicationsAnalytics({ groupBy: "referralSource", exclude: ["referralSource"], + enabled: tab === "applications", }); const { data: countries } = useApplicationsAnalytics({ groupBy: "country", exclude: ["country"], + enabled: tab === "applications", }); const filters = useMemo( diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/commissions-analytics-cards.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/commissions-analytics-cards.tsx index 98a7ff243c1..83737ab0430 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/commissions-analytics-cards.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/commissions-analytics-cards.tsx @@ -7,15 +7,25 @@ import { AnalyticsLoadingSpinner } from "@/ui/analytics/analytics-loading-spinne import { BarList } from "@/ui/analytics/bar-list"; import { CommissionTypeIcon } from "@/ui/partners/comission-type-icon"; import { GroupColorCircle } from "@/ui/partners/groups/group-color-circle"; -import { Modal, useRouterStuff } from "@dub/ui"; +import { Modal, TabSelect, useRouterStuff } from "@dub/ui"; import { CircleCheck, CircleDotted, CircleHalfDottedClock, + Tag, + Users6, } from "@dub/ui/icons"; import { cn } from "@dub/utils"; -import { ReactNode, useCallback, useMemo, useState } from "react"; +import { + Dispatch, + ReactNode, + SetStateAction, + useCallback, + useMemo, + useState, +} from "react"; import { CommissionStatusFilter } from "./commissions-status-selector"; +import { useCommissionsAnalyticsQuery } from "./use-commissions-analytics-query"; const STATUS_LABELS: Record = { pending: "Pending", @@ -31,23 +41,29 @@ const STATUS_ICONS: Record = { all: CircleDotted, }; -type CommissionsBarListTab = "type" | "groupId"; +type CommissionsBarListTab = "type" | "groupId" | "partnerTagId"; function mapCommissionBarRow( item: CommissionCategoryRow, tab: CommissionsBarListTab, groupColorMap: Map, ) { - const icon = - tab === "type" ? ( + let icon: ReactNode; + if (tab === "type") { + icon = ( - ) : ( + ); + } else if (tab === "partnerTagId") { + icon = ; + } else { + icon = ( ); + } return { icon, @@ -103,9 +119,12 @@ function useUrlListFilter(paramKey: string) { }; } -function CommissionAnalyticsCardShell({ +function CommissionAnalyticsCardShell({ status, title, + tabs, + selectedTabId, + onSelectTab, dataLength, expandLimit, isFilterActive, @@ -114,6 +133,9 @@ function CommissionAnalyticsCardShell({ }: { status: CommissionStatusFilter; title: string; + tabs?: { id: T; label: string; icon: React.ElementType }[]; + selectedTabId?: T; + onSelectTab?: Dispatch> | ((tabId: T) => void); dataLength?: number; expandLimit: number; isFilterActive?: boolean; @@ -127,6 +149,10 @@ function CommissionAnalyticsCardShell({ const showViewAll = (dataLength ?? 0) > expandLimit; const statusKey = status ?? "all"; const StatusIcon = STATUS_ICONS[statusKey]; + const activeTitle = + (tabs && selectedTabId + ? tabs.find((t) => t.id === selectedTabId)?.label + : undefined) ?? title; return ( <> @@ -136,7 +162,7 @@ function CommissionAnalyticsCardShell({ className="max-w-lg px-0" >
-

{title}

+

{activeTitle}

{STATUS_LABELS[statusKey]}

@@ -147,7 +173,15 @@ function CommissionAnalyticsCardShell({
-

{title}

+ {tabs && selectedTabId && onSelectTab ? ( + + ) : ( +

{title}

+ )}

{STATUS_LABELS[statusKey]}

@@ -200,10 +234,13 @@ const BAR_LIST_SHARED = { filterHoverClass: "bg-white border border-neutral-200", }; -function CommissionBarPanel({ +function CommissionBarPanel({ status, title, tab, + tabs, + selectedTabId, + onSelectTab, filter, rawItems, loading, @@ -214,6 +251,9 @@ function CommissionBarPanel({ status: CommissionStatusFilter; title: string; tab: CommissionsBarListTab; + tabs?: { id: T; label: string; icon: React.ElementType }[]; + selectedTabId?: T; + onSelectTab?: Dispatch> | ((tabId: T) => void); filter: ReturnType; rawItems: CommissionCategoryRow[] | undefined; loading: boolean; @@ -234,6 +274,9 @@ function CommissionBarPanel({ ("groupId"); + const groupFilter = useUrlListFilter("groupId"); + const partnerTagFilter = useUrlListFilter("partnerTagId"); const typeFilter = useUrlListFilter("type"); const { groups } = useGroups(); @@ -290,23 +341,35 @@ export function CommissionsAnalyticsCards({ }, [groups]); const { data: groupRows, isLoading: groupLoading } = useCommissionAnalytics({ - queryString, groupBy: "groupId", }); + const { data: partnerTagRows, isLoading: partnerTagLoading } = + useCommissionAnalytics({ + groupBy: "partnerTagId", + enabled: leftTab === "partnerTagId", + }); const { data: typeRows, isLoading: typeLoading } = useCommissionAnalytics({ - queryString, groupBy: "type", }); + const activeLeftFilter = + leftTab === "groupId" ? groupFilter : partnerTagFilter; + const activeLeftRows = leftTab === "groupId" ? groupRows : partnerTagRows; + const activeLeftLoading = + leftTab === "groupId" ? groupLoading : partnerTagLoading; + return (
({ @@ -95,7 +93,7 @@ export function CommissionsAnalyticsChart({ return (
- + ); } diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/program-analytics-nav.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/program-analytics-nav.tsx index ab1cf729779..d9a32bfb15f 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/program-analytics-nav.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/program-analytics-nav.tsx @@ -32,6 +32,7 @@ export function ProgramAnalyticsNav() { "end", "partnerId", "groupId", + "partnerTagId", ]} /> ); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/program-analytics-shell.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/program-analytics-shell.tsx index 225f2a69518..fd85a3fdc8a 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/program-analytics-shell.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/program-analytics-shell.tsx @@ -35,7 +35,6 @@ import { ProgramAnalyticsTabId, } from "./program-analytics-nav"; import { useCommissionsAnalyticsFilters } from "./use-commissions-analytics-filters"; -import { useCommissionsAnalyticsQuery } from "./use-commissions-analytics-query"; export function ProgramAnalyticsShell({ children }: { children: ReactNode }) { const { isMobile } = useMediaQuery(); @@ -49,11 +48,7 @@ export function ProgramAnalyticsShell({ children }: { children: ReactNode }) { ? (tab as ProgramAnalyticsTabId) : "performance"; - const { queryString: commissionsQueryString, status: commissionStatus } = - useCommissionsAnalyticsQuery(); - - const { stage: applicationsStage, view: applicationsView } = - useApplicationsAnalyticsQuery(); + const { stage: applicationsStage } = useApplicationsAnalyticsQuery(); const { start, end, interval, selectedTab, saleUnit, view } = useMemo(() => { const { event, ...rest } = searchParamsObj; @@ -121,7 +116,7 @@ export function ProgramAnalyticsShell({ children }: { children: ReactNode }) { onToggleOperator: commOnToggleOperator, onOpenFilter: commOnOpenFilter, setSearch: commSetSearch, - } = useCommissionsAnalyticsFilters(commissionsQueryString); + } = useCommissionsAnalyticsFilters(); const { filters: applicationsFilters, @@ -199,7 +194,7 @@ export function ProgramAnalyticsShell({ children }: { children: ReactNode }) { {pageTab === "commissions" ? ( - + ) : pageTab === "applications" ? ( ) : ( diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/use-commissions-analytics-filters.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/use-commissions-analytics-filters.tsx index ac5ddca55c5..a4d2e21ce06 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/use-commissions-analytics-filters.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/use-commissions-analytics-filters.tsx @@ -1,35 +1,95 @@ "use client"; +import useCommissionAnalytics from "@/lib/swr/use-commission-analytics"; import useGroups from "@/lib/swr/use-groups"; -import usePartners from "@/lib/swr/use-partners"; import useWorkspace from "@/lib/swr/use-workspace"; -import { EnrolledPartnerProps } from "@/lib/types"; +import { GroupProps } from "@/lib/types"; import { CommissionTypeIcon } from "@/ui/partners/comission-type-icon"; import { GroupColorCircle } from "@/ui/partners/groups/group-color-circle"; import { PartnerAvatar } from "@/ui/partners/partner-avatar"; import { CommissionType } from "@dub/prisma/client"; import { useRouterStuff } from "@dub/ui"; -import { Sliders, Users, Users6 } from "@dub/ui/icons"; -import { capitalize, FilterOperator, parseFilterValue } from "@dub/utils"; +import { Sliders, Tag, Users, Users6 } from "@dub/ui/icons"; +import { + currencyFormatter, + FilterOperator, + nFormatter, + parseFilterValue, +} from "@dub/utils"; +import { useParams } from "next/navigation"; import { useCallback, useMemo, useState } from "react"; import { useDebounce } from "use-debounce"; -const COMMISSION_FILTER_KEYS = ["partnerId", "groupId", "type"] as const; +const FILTER_KEYS = ["partnerId", "groupId", "partnerTagId", "type"] as const; -export function useCommissionsAnalyticsFilters( - _commissionsQueryString?: string, +type CategoryRow = { earnings: number; count: number }; +type PartnerRow = { earnings: number; commissionCount: number }; + +function metricValue( + commissionUnit: string | undefined, + row: CategoryRow | PartnerRow, +) { + if (commissionUnit === "count") { + return "commissionCount" in row ? row.commissionCount : row.count; + } + return row.earnings; +} + +function formatMetricRight( + commissionUnit: string | undefined, + row: CategoryRow | PartnerRow, ) { + return commissionUnit === "count" + ? nFormatter(metricValue(commissionUnit, row), { full: true }) + : currencyFormatter(row.earnings); +} + +export function useCommissionsAnalyticsFilters() { const { slug } = useWorkspace(); + const { tab } = useParams() as { tab?: string }; const { searchParamsObj, queryParams } = useRouterStuff(); + const commissionUnit = searchParamsObj.commissionUnit; + + const filtersTabEnabled = tab === "commissions"; const [selectedFilter, setSelectedFilter] = useState(null); const [search, setSearch] = useState(""); const [debouncedSearch] = useDebounce(search, 500); - const { partners } = usePartnerFilterOptions( - selectedFilter === "partnerId" ? debouncedSearch : "", - ); - const { groups } = useGroups(); + const { groups: programGroups } = useGroups(); + + const groupById = useMemo(() => { + const map = new Map(); + programGroups?.forEach((g) => map.set(g.id, g)); + return map; + }, [programGroups]); + + const { data: partners } = useCommissionAnalytics({ + groupBy: "partnerId", + exclude: ["partnerId"], + enabled: filtersTabEnabled, + }); + + const { data: groups } = useCommissionAnalytics({ + groupBy: "groupId", + exclude: ["groupId"], + enabled: filtersTabEnabled, + }); + + const { data: partnerTags } = useCommissionAnalytics({ + groupBy: "partnerTagId", + exclude: ["partnerTagId"], + enabled: filtersTabEnabled, + }); + + const { data: types } = useCommissionAnalytics({ + groupBy: "type", + exclude: ["type"], + enabled: filtersTabEnabled, + }); + + const partnerSearch = + selectedFilter === "partnerId" ? debouncedSearch.trim().toLowerCase() : ""; const filters = useMemo( () => [ @@ -39,36 +99,89 @@ export function useCommissionsAnalyticsFilters( label: "Partner", shouldFilter: false, options: - partners?.map((partner) => ({ - value: partner.id, - label: partner.name, - icon: , - })) ?? null, + partners + ?.filter((row) => metricValue(commissionUnit, row) > 0) + .filter( + (row) => + !partnerSearch || + row.name.toLowerCase().includes(partnerSearch), + ) + .map((row) => ({ + value: row.partnerId, + label: row.name, + icon: ( + + ), + right: formatMetricRight(commissionUnit, row), + })) ?? [], }, { key: "groupId", icon: Users6, label: "Partner Group", options: - groups?.map((group) => ({ - value: group.id, - label: group.name, - icon: , - permalink: `/${slug}/program/groups/${group.slug}/rewards`, - })) ?? null, + groups + ?.filter((row) => metricValue(commissionUnit, row) > 0) + .map((row) => { + const group = groupById.get(row.key); + return { + value: row.key, + label: row.label, + icon: ( + + ), + permalink: group + ? `/${slug}/program/groups/${group.slug}/rewards` + : undefined, + right: formatMetricRight(commissionUnit, row), + }; + }) ?? [], + }, + { + key: "partnerTagId", + icon: Tag, + label: "Partner Tag", + options: + partnerTags + ?.filter((row) => metricValue(commissionUnit, row) > 0) + .map((row) => ({ + value: row.key, + label: row.label, + right: formatMetricRight(commissionUnit, row), + })) ?? [], }, { key: "type", icon: Sliders, label: "Type", - options: Object.values(CommissionType).map((type) => ({ - value: type, - label: capitalize(type) as string, - icon: , - })), + options: + types + ?.filter((row) => metricValue(commissionUnit, row) > 0) + .map((row) => ({ + value: row.key, + label: row.label, + icon: , + right: formatMetricRight(commissionUnit, row), + })) ?? [], }, ], - [partners, groups, slug], + [ + partners, + groups, + partnerTags, + types, + commissionUnit, + partnerSearch, + groupById, + slug, + ], ); const activeFilters = useMemo(() => { @@ -78,7 +191,7 @@ export function useCommissionsAnalyticsFilters( values: string[]; }[] = []; - for (const key of COMMISSION_FILTER_KEYS) { + for (const key of FILTER_KEYS) { const raw = searchParamsObj[key]; if (!raw) continue; const parsed = parseFilterValue(raw); @@ -90,6 +203,7 @@ export function useCommissionsAnalyticsFilters( }, [ searchParamsObj.partnerId, searchParamsObj.groupId, + searchParamsObj.partnerTagId, searchParamsObj.type, ]); @@ -146,8 +260,7 @@ export function useCommissionsAnalyticsFilters( const onRemoveAll = useCallback( () => queryParams({ - // Include customerId for backwards compat in case it's still in the URL - del: [...COMMISSION_FILTER_KEYS, "customerId", "page"], + del: [...FILTER_KEYS, "customerId", "page"], scroll: false, }), [queryParams], @@ -185,29 +298,3 @@ export function useCommissionsAnalyticsFilters( setSearch, }; } - -function usePartnerFilterOptions(search: string) { - const { searchParamsObj } = useRouterStuff(); - - const { partners, loading: partnersLoading } = usePartners({ - query: { search, sortBy: "totalCommissions", sortOrder: "desc" }, - }); - const { partners: selectedPartners } = usePartners({ - query: { - partnerIds: searchParamsObj.partnerId - ? searchParamsObj.partnerId.replace(/^-/, "").split(",").filter(Boolean) - : undefined, - }, - }); - - return { - partners: partnersLoading - ? null - : ([ - ...(partners ?? []), - ...(selectedPartners - ?.filter((sp) => !partners?.some((p) => p.id === sp.id)) - ?.map((sp) => ({ ...sp, hideDuringSearch: true })) ?? []), - ] as (EnrolledPartnerProps & { hideDuringSearch?: boolean })[]), - }; -} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/use-commissions-analytics-query.ts b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/use-commissions-analytics-query.ts index 0c768693506..1af0209a6c2 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/use-commissions-analytics-query.ts +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/use-commissions-analytics-query.ts @@ -35,6 +35,7 @@ export function useCommissionsAnalyticsQuery() { end, partnerId, groupId, + partnerTagId, type, } = searchParamsObj; @@ -54,6 +55,7 @@ export function useCommissionsAnalyticsQuery() { if (partnerId) params.set("partnerId", partnerId); if (groupId) params.set("groupId", groupId); + if (partnerTagId) params.set("partnerTagId", partnerTagId); if (type) params.set("type", type); return params.toString(); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/commissions-table.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/commissions-table.tsx index ce2cd019f4c..333de0ceb21 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/commissions-table.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/commissions-table.tsx @@ -482,6 +482,7 @@ function CommissionsFilters() { "end", "partnerId", "groupId", + "partnerTagId", "type", ], }, diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/use-commission-filters.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/use-commission-filters.tsx index 0d8be774246..d35dc5aba86 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/use-commission-filters.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/use-commission-filters.tsx @@ -1,6 +1,7 @@ import useCommissionsCount from "@/lib/swr/use-commissions-count"; import useCustomers from "@/lib/swr/use-customers"; import useGroups from "@/lib/swr/use-groups"; +import { usePartnerTags } from "@/lib/swr/use-partner-tags"; import usePartners from "@/lib/swr/use-partners"; import useWorkspace from "@/lib/swr/use-workspace"; import { CustomerProps, EnrolledPartnerProps } from "@/lib/types"; @@ -11,7 +12,7 @@ import { GroupColorCircle } from "@/ui/partners/groups/group-color-circle"; import { PartnerAvatar } from "@/ui/partners/partner-avatar"; import { CommissionType } from "@dub/prisma/client"; import { CircleDotted, useRouterStuff } from "@dub/ui"; -import { Sliders, User, Users, Users6 } from "@dub/ui/icons"; +import { Sliders, Tag, User, Users, Users6 } from "@dub/ui/icons"; import { capitalize, cn, @@ -41,6 +42,32 @@ export function useCommissionFilters() { const { groups } = useGroups(); + const activePartnerTagIds = useMemo( + () => + searchParamsObj.partnerTagId + ? searchParamsObj.partnerTagId + .replace(/^-/, "") + .split(",") + .filter(Boolean) + : undefined, + [searchParamsObj.partnerTagId], + ); + + const { partnerTags } = usePartnerTags(); + const { partnerTags: selectedPartnerTags } = usePartnerTags({ + query: { ids: activePartnerTagIds }, + enabled: !!activePartnerTagIds?.length, + }); + + const mergedPartnerTags = useMemo(() => { + if (!partnerTags) return null; + const baseIds = new Set(partnerTags.map((t) => t.id)); + return [ + ...partnerTags, + ...(selectedPartnerTags ?? []).filter((t) => !baseIds.has(t.id)), + ]; + }, [partnerTags, selectedPartnerTags]); + const filters = useMemo( () => [ { @@ -85,6 +112,16 @@ export function useCommissionFilters() { }; }) ?? null, }, + { + key: "partnerTagId", + icon: Tag, + label: "Partner Tag", + options: + mergedPartnerTags?.map((tag) => ({ + value: tag.id, + label: tag.name, + })) ?? null, + }, { key: "type", icon: Sliders, @@ -123,7 +160,7 @@ export function useCommissionFilters() { ), }, ], - [commissionsCount, partners, customers, groups], + [commissionsCount, partners, customers, groups, mergedPartnerTags], ); const activeFilters = useMemo(() => { @@ -139,6 +176,7 @@ export function useCommissionFilters() { "type", "payoutId", "groupId", + "partnerTagId", ] as const; for (const key of keys) { const raw = searchParamsObj[key]; @@ -155,6 +193,7 @@ export function useCommissionFilters() { searchParamsObj.type, searchParamsObj.payoutId, searchParamsObj.groupId, + searchParamsObj.partnerTagId, ]); const onSelect = useCallback( @@ -212,6 +251,7 @@ export function useCommissionFilters() { "customerId", "payoutId", "groupId", + "partnerTagId", "type", ], }), diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx index da4ed5d7a14..b3a70120b09 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx @@ -22,7 +22,10 @@ import { GroupColorCircle } from "@/ui/partners/groups/group-color-circle"; import { PartnerRowItem } from "@/ui/partners/partner-row-item"; import { PartnerStatusBadges } from "@/ui/partners/partner-status-badges"; import { PartnerTagsList } from "@/ui/partners/partner-tags-list"; -import { useUpdatePartnerTagsModal } from "@/ui/partners/update-partner-tags-modal"; +import { + UpdatePartnerTagsModal, + useUpdatePartnerTagsModal, +} from "@/ui/partners/update-partner-tags-modal"; import { AnimatedEmptyState } from "@/ui/shared/animated-empty-state"; import { CountryFlag } from "@/ui/shared/country-flag"; import { ThreeDots } from "@/ui/shared/icons"; @@ -685,10 +688,8 @@ function RowMenuButton({ partners: [row.original], }); - const { UpdatePartnerTagsModal, setShowUpdatePartnerTagsModal } = - useUpdatePartnerTagsModal({ - partners: [row.original], - }); + const { showUpdatePartnerTagsModal, setShowUpdatePartnerTagsModal } = + useUpdatePartnerTagsModal(); const { ArchivePartnerModal, setShowArchivePartnerModal } = useArchivePartnerModal({ @@ -744,7 +745,11 @@ function RowMenuButton({ return ( <> - + @@ -933,14 +938,16 @@ const PartnerTagsCell = memo(function PartnerTagsCell({ }: { partner: EnrolledPartnerProps; }) { - const { UpdatePartnerTagsModal, setShowUpdatePartnerTagsModal } = - useUpdatePartnerTagsModal({ - partners: [partner], - }); + const { showUpdatePartnerTagsModal, setShowUpdatePartnerTagsModal } = + useUpdatePartnerTagsModal(); return ( <> - + - + diff --git a/apps/web/lib/actions/partners/reject-partner-application.ts b/apps/web/lib/actions/partners/reject-partner-application.ts index e67ab58e9fe..ede95d88564 100644 --- a/apps/web/lib/actions/partners/reject-partner-application.ts +++ b/apps/web/lib/actions/partners/reject-partner-application.ts @@ -15,8 +15,14 @@ export const rejectPartnerApplicationAction = authActionClient .inputSchema(inputSchema) .action(async ({ parsedInput, ctx }) => { const { workspace, user } = ctx; - const { partnerId, rejectionReason, rejectionNote, allowImmediateReapply } = - parsedInput; + const { + partnerId, + rejectionReason, + rejectionNote, + allowImmediateReapply, + flagForFraud, + flagForFraudReason, + } = parsedInput; throwIfNoPermission({ role: workspace.role, @@ -29,6 +35,8 @@ export const rejectPartnerApplicationAction = authActionClient rejectionReason, rejectionNote, allowImmediateReapply, + flagForFraud, + flagForFraudReason, userId: user.id, }); }); diff --git a/apps/web/lib/api/commissions/get-commissions-count.ts b/apps/web/lib/api/commissions/get-commissions-count.ts index d91fd586184..b53ac66d325 100644 --- a/apps/web/lib/api/commissions/get-commissions-count.ts +++ b/apps/web/lib/api/commissions/get-commissions-count.ts @@ -25,6 +25,7 @@ export async function getCommissionsCount(filters: CommissionsCountFilters) { payoutId, customerId, groupId, + partnerTagId, fraudEventGroupId, start, end, @@ -71,6 +72,7 @@ export async function getCommissionsCount(filters: CommissionsCountFilters) { }; const groupFilter = parseFilterValue(groupId); + const partnerTagFilter = parseFilterValue(partnerTagId); const programEnrollmentFilter = { ...(groupFilter && { @@ -79,6 +81,12 @@ export async function getCommissionsCount(filters: CommissionsCountFilters) { ? { notIn: groupFilter.values } : { in: groupFilter.values }, }), + ...(partnerTagFilter && { + programPartnerTags: + partnerTagFilter.sqlOperator === "NOT IN" + ? { none: { partnerTagId: { in: partnerTagFilter.values } } } + : { some: { partnerTagId: { in: partnerTagFilter.values } } }, + }), ...(isHoldStatus && { fraudEventGroups: { some: { diff --git a/apps/web/lib/api/commissions/get-commissions.ts b/apps/web/lib/api/commissions/get-commissions.ts index 993ba64618e..454e56f0c21 100644 --- a/apps/web/lib/api/commissions/get-commissions.ts +++ b/apps/web/lib/api/commissions/get-commissions.ts @@ -31,6 +31,7 @@ export async function getCommissions(filters: CommissionsFilters) { customerId, payoutId, groupId, + partnerTagId, fraudEventGroupId, start, end, @@ -93,6 +94,7 @@ export async function getCommissions(filters: CommissionsFilters) { const partnerFilter = parseFilterValue(partnerId); const groupFilter = parseFilterValue(groupId); + const partnerTagFilter = parseFilterValue(partnerTagId); const validCommissionTypes = new Set(Object.values(CommissionType)); const rawTypeFilter = parseFilterValue(type); @@ -138,6 +140,12 @@ export async function getCommissions(filters: CommissionsFilters) { ? { notIn: groupFilter.values } : { in: groupFilter.values }, }), + ...(partnerTagFilter && { + programPartnerTags: + partnerTagFilter.sqlOperator === "NOT IN" + ? { none: { partnerTagId: { in: partnerTagFilter.values } } } + : { some: { partnerTagId: { in: partnerTagFilter.values } } }, + }), ...(isHoldStatus && { fraudEventGroups: { some: { diff --git a/apps/web/lib/api/partner-profile/get-partner-for-program.ts b/apps/web/lib/api/partner-profile/get-partner-for-program.ts index 059286a5c82..ebe15e26778 100644 --- a/apps/web/lib/api/partner-profile/get-partner-for-program.ts +++ b/apps/web/lib/api/partner-profile/get-partner-for-program.ts @@ -56,7 +56,9 @@ export async function getPartnerForProgram({ toCentsNumber(programEnrollment.totalCommissions ?? 0), id: partner.id, createdAt: new Date(programEnrollment.createdAt), - tags: partner.programPartnerTags.map(({ partnerTag }) => partnerTag), + tags: partner.programPartnerTags + .map(({ partnerTag }) => partnerTag) + .filter((t) => t.programId != null && t.programId === programId), links, lastLeadAt: links.reduce((acc, link) => { return link.lastLeadAt && link.lastLeadAt > (acc ?? new Date(0)) diff --git a/apps/web/lib/api/partners/applications/reject-partner.ts b/apps/web/lib/api/partners/applications/reject-partner.ts index f446a05ce4e..1d2721280e9 100644 --- a/apps/web/lib/api/partners/applications/reject-partner.ts +++ b/apps/web/lib/api/partners/applications/reject-partner.ts @@ -24,10 +24,27 @@ export async function rejectPartner({ rejectionReason, rejectionNote, allowImmediateReapply, + flagForFraud, + flagForFraudReason, userId, }: RejectPartnerInput) { const programId = getDefaultProgramIdOrThrow(workspace); + if (flagForFraud && allowImmediateReapply) { + throw new DubApiError({ + code: "bad_request", + message: + "Cannot flag for fraud when allowing the partner to reapply immediately.", + }); + } + + if (flagForFraud && (!flagForFraudReason || !flagForFraudReason.trim())) { + throw new DubApiError({ + code: "bad_request", + message: "Fraud reason is required when flagging for fraud.", + }); + } + const programEnrollment = await prisma.programEnrollment.findUnique({ where: { partnerId_programId: { @@ -111,6 +128,16 @@ export async function rejectPartner({ discountId: null, }, }); + + if (flagForFraud && flagForFraudReason) { + await tx.fraudAlert.create({ + data: { + partnerId, + programId, + reason: flagForFraudReason, + }, + }); + } }); const { partner, program } = programEnrollment; diff --git a/apps/web/lib/api/partners/get-partners.ts b/apps/web/lib/api/partners/get-partners.ts index 03285df35c8..64d3ecf8fe5 100644 --- a/apps/web/lib/api/partners/get-partners.ts +++ b/apps/web/lib/api/partners/get-partners.ts @@ -55,7 +55,9 @@ export async function getPartners(filters: PartnerFilters) { ...programEnrollment, id: partner.id, createdAt: new Date(programEnrollment.createdAt), - tags: partner.programPartnerTags.map(({ partnerTag }) => partnerTag), + tags: partner.programPartnerTags + .map(({ partnerTag }) => partnerTag) + .filter((t) => t.programId != null && t.programId === programId), links, netRevenue: toCentsNumber(programEnrollment.totalSaleAmount ?? 0) - diff --git a/apps/web/lib/commissions/schema.ts b/apps/web/lib/commissions/schema.ts index 79e317a1791..c89574d093f 100644 --- a/apps/web/lib/commissions/schema.ts +++ b/apps/web/lib/commissions/schema.ts @@ -11,6 +11,7 @@ const sharedCommissionAnalyticsFilterSchema = analyticsQuerySchema }) .extend({ groupId: z.string().optional(), + partnerTagId: z.string().optional(), partnerId: z.string().optional(), type: z.string().optional(), status: z.enum(CommissionStatus).optional(), @@ -19,7 +20,13 @@ const sharedCommissionAnalyticsFilterSchema = analyticsQuerySchema // Commission program analytics (workspace dashboard) export const commissionAnalyticsQuerySchema = sharedCommissionAnalyticsFilterSchema.extend({ - groupBy: z.enum(["timeseries", "partnerId", "groupId", "type"]), + groupBy: z.enum([ + "timeseries", + "partnerId", + "groupId", + "partnerTagId", + "type", + ]), }); const commissionTotalsSchema = z.object({ @@ -50,6 +57,8 @@ export const commissionAnalyticsSchema = { groupId: z.array(commissionCategoryRowSchema), + partnerTagId: z.array(commissionCategoryRowSchema), + timeseries: z.array(commissionTimeseriesRowSchema), partnerId: z.array(commissionPartnerIdRowSchema), diff --git a/apps/web/lib/swr/use-commission-analytics.ts b/apps/web/lib/swr/use-commission-analytics.ts index 124fe27af2a..c014e799ad3 100644 --- a/apps/web/lib/swr/use-commission-analytics.ts +++ b/apps/web/lib/swr/use-commission-analytics.ts @@ -1,60 +1,82 @@ -import { DUB_PARTNERS_ANALYTICS_INTERVAL } from "@/lib/analytics/constants"; -import { IntervalOptions } from "@/lib/analytics/types"; import type { CommissionAnalyticsByGroup, CommissionAnalyticsGroupBy, + CommissionAnalyticsQuery, } from "@/lib/types"; +import { useRouterStuff } from "@dub/ui"; import { fetcher } from "@dub/utils"; -import { useMemo } from "react"; import useSWR from "swr"; import useWorkspace from "./use-workspace"; -export default function useCommissionAnalytics< +export type CommissionAnalyticsFilterKey = Extract< + keyof CommissionAnalyticsQuery, + "partnerId" | "groupId" | "partnerTagId" | "type" +>; + +interface UseCommissionAnalyticsProps< G extends CommissionAnalyticsGroupBy, ->({ - groupBy, - enabled = true, - queryString, - interval, - start, - end, -}: { +> extends Partial> { groupBy: G; + exclude?: CommissionAnalyticsFilterKey[]; enabled?: boolean; - /** Dashboard filters (workspaceId, dates, status, etc.) */ - queryString?: string; - /** When `queryString` is omitted, build URL from workspace + interval or start/end */ - interval?: IntervalOptions; - start?: Date; - end?: Date; -}) { - const { id: workspaceId } = useWorkspace(); +} - const url = useMemo(() => { - if (!enabled) return null; +function serializeCommissionAnalyticsFilters( + filters: Record, +): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(filters)) { + if (v === undefined || v === null) continue; + out[k] = v instanceof Date ? v.toISOString() : String(v); + } + return out; +} - let qs: string | null = null; +export default function useCommissionAnalytics< + G extends CommissionAnalyticsGroupBy, +>({ + exclude, + enabled = true, + ...filters +}: UseCommissionAnalyticsProps) { + const { id: workspaceId } = useWorkspace(); + const { getQueryString } = useRouterStuff(); - if (queryString !== undefined) { - qs = queryString || null; - } else if (workspaceId) { - const searchParams = new URLSearchParams({ - ...(start && end - ? { - start: start.toISOString(), - end: end.toISOString(), - } - : { interval: interval ?? DUB_PARTNERS_ANALYTICS_INTERVAL }), - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, - workspaceId, - }); - qs = searchParams.toString(); - } + const serialized = serializeCommissionAnalyticsFilters( + filters as Record, + ); - if (!qs) return null; + const querySuffix = getQueryString( + { + ...serialized, + ...(workspaceId ? { workspaceId } : {}), + timezone: + serialized.timezone ?? + Intl.DateTimeFormat().resolvedOptions().timeZone, + }, + { + exclude: [ + "pageTab", + "tab", + "event", + "saleUnit", + "view", + "commissionUnit", + "page", + "pageSize", + "slug", + "folderId", + "customerId", + "programId", + ...(exclude ?? []), + ], + }, + ); - return `/api/commissions/analytics?groupBy=${groupBy}&${qs}`; - }, [enabled, queryString, workspaceId, groupBy, interval, start, end]); + const url = + workspaceId && enabled + ? `/api/commissions/analytics${querySuffix}` + : null; const { data, error, isLoading } = useSWR( url, diff --git a/apps/web/lib/zod/schemas/commissions.ts b/apps/web/lib/zod/schemas/commissions.ts index 5c207ae580f..860fc28ffd8 100644 --- a/apps/web/lib/zod/schemas/commissions.ts +++ b/apps/web/lib/zod/schemas/commissions.ts @@ -127,6 +127,14 @@ export const getCommissionsQuerySchema = z "Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`). " + "Examples: `group_abc`, `group_abc,group_xyz`, `-group_abc`.", ), + partnerTagId: z + .string() + .optional() + .describe( + "Filter the list of commissions by the associated partner tag. " + + "Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`). " + + "Examples: `ptag_abc`, `ptag_abc,ptag_xyz`, `-ptag_abc`.", + ), invoiceId: z .string() .optional() diff --git a/apps/web/lib/zod/schemas/partners.ts b/apps/web/lib/zod/schemas/partners.ts index 82b1ebce13e..94b4d2dc6a9 100644 --- a/apps/web/lib/zod/schemas/partners.ts +++ b/apps/web/lib/zod/schemas/partners.ts @@ -904,33 +904,71 @@ export const PROGRAM_APPLICATION_REJECTION_NOTE_MAX_LENGTH = 500; // Max length for optional `flagForFraudReason` on `FraudAlert` export const MAX_FRAUD_REASON_LENGTH = 2000; -export const rejectPartnerSchema = z.object({ - partnerId: z.string().describe("The ID of the partner to reject."), - rejectionReason: z - .enum(ProgramApplicationRejectionReason) - .optional() - .describe( - "The reason for rejecting the partner application. This will be shared with the partner via email.", - ), - rejectionNote: z - .string() - .max(PROGRAM_APPLICATION_REJECTION_NOTE_MAX_LENGTH) - .optional() - .transform((s) => { - const t = s?.trim(); - return t === "" ? undefined : t; - }) - .describe( - "Additional details about the rejection. This will be shared with the partner via email.", - ), - allowImmediateReapply: z - .boolean() - .optional() - .default(false) - .describe( - "When true, pending enrollment is removed so the partner can submit a new application immediately.", - ), -}); +export const rejectPartnerSchema = z + .object({ + partnerId: z.string().describe("The ID of the partner to reject."), + rejectionReason: z + .enum(ProgramApplicationRejectionReason) + .optional() + .describe( + "The reason for rejecting the partner application. This will be shared with the partner via email.", + ), + rejectionNote: z + .string() + .max(PROGRAM_APPLICATION_REJECTION_NOTE_MAX_LENGTH) + .optional() + .transform((s) => { + const t = s?.trim(); + return t === "" ? undefined : t; + }) + .describe( + "Additional details about the rejection. This will be shared with the partner via email.", + ), + allowImmediateReapply: z + .boolean() + .optional() + .default(false) + .describe( + "When true, pending enrollment is removed so the partner can submit a new application immediately.", + ), + flagForFraud: z + .boolean() + .optional() + .describe( + "Whether to flag the partner for fraud review by the Dub team. Cannot be combined with allowImmediateReapply.", + ), + flagForFraudReason: z + .string() + .max(MAX_FRAUD_REASON_LENGTH) + .optional() + .transform((s) => { + const t = s?.trim(); + return t === "" ? undefined : t; + }) + .describe( + "The reason for flagging the partner for fraud. Required when flagForFraud is true.", + ), + }) + .superRefine((data, ctx) => { + if (data.allowImmediateReapply && data.flagForFraud) { + ctx.addIssue({ + code: "custom", + message: + "Cannot flag for fraud when allowing the partner to reapply immediately.", + path: ["flagForFraud"], + }); + } + if ( + data.flagForFraud && + (!data.flagForFraudReason || !data.flagForFraudReason.trim()) + ) { + ctx.addIssue({ + code: "custom", + message: "Fraud reason is required when flagging for fraud.", + path: ["flagForFraudReason"], + }); + } + }); export const bulkRejectPartnersSchema = z.object({ workspaceId: z.string(), diff --git a/apps/web/ui/modals/reject-partner-application-modal.tsx b/apps/web/ui/modals/reject-partner-application-modal.tsx index 92e4e5abc20..6af522b808c 100644 --- a/apps/web/ui/modals/reject-partner-application-modal.tsx +++ b/apps/web/ui/modals/reject-partner-application-modal.tsx @@ -5,18 +5,23 @@ import { } from "@/lib/partners/program-application-rejection"; import useWorkspace from "@/lib/swr/use-workspace"; import { PartnerProps } from "@/lib/types"; -import { PROGRAM_APPLICATION_REJECTION_NOTE_MAX_LENGTH } from "@/lib/zod/schemas/partners"; +import { + MAX_FRAUD_REASON_LENGTH, + PROGRAM_APPLICATION_REJECTION_NOTE_MAX_LENGTH, +} from "@/lib/zod/schemas/partners"; import { PartnerAvatar } from "@/ui/partners/partner-avatar"; import { ProgramApplicationRejectionReason } from "@dub/prisma/client"; import { Button, - Checkbox, Combobox, ComboboxOption, + InfoTooltip, Modal, + Switch, useKeyboardShortcut, } from "@dub/ui"; import { cn } from "@dub/utils"; +import { motion } from "motion/react"; import { useAction } from "next-safe-action/hooks"; import { Dispatch, @@ -55,7 +60,6 @@ export function RejectPartnerApplicationModal({ confirmShortcutOptions, }: RejectPartnerApplicationModalProps) { const { id: workspaceId } = useWorkspace(); - const allowReapplyCheckboxId = useId(); const allowImmediateReapplyOutcomeRef = useRef(false); const [selectedReason, setSelectedReason] = useState( @@ -63,12 +67,18 @@ export function RejectPartnerApplicationModal({ ); const [rejectionNote, setRejectionNote] = useState(""); const [allowImmediateReapply, setAllowImmediateReapply] = useState(false); + const [flagForFraud, setFlagForFraud] = useState(false); + const [flagForFraudReason, setFlagForFraudReason] = useState(""); + const fraudReasonFieldId = useId(); + const fraudReasonCounterId = useId(); useEffect(() => { if (!showRejectPartnerApplicationModal) { setSelectedReason(null); setRejectionNote(""); setAllowImmediateReapply(false); + setFlagForFraud(false); + setFlagForFraudReason(""); } }, [showRejectPartnerApplicationModal]); @@ -93,12 +103,20 @@ export function RejectPartnerApplicationModal({ const handleConfirm = useCallback(async () => { if (!workspaceId || !partner) return; + if (flagForFraud && !flagForFraudReason.trim()) { + toast.error("Fraud reason is required when flagging for fraud."); + return; + } + allowImmediateReapplyOutcomeRef.current = allowImmediateReapply; await rejectPartnerApplication({ workspaceId, partnerId: partner.id, allowImmediateReapply, + ...(flagForFraud + ? { flagForFraud: true, flagForFraudReason: flagForFraudReason.trim() } + : {}), ...(selectedReason && { rejectionReason: selectedReason.value as ProgramApplicationRejectionReason, @@ -112,6 +130,8 @@ export function RejectPartnerApplicationModal({ selectedReason, rejectionNote, allowImmediateReapply, + flagForFraud, + flagForFraudReason, ]); const handleClose = useCallback(() => { @@ -208,27 +228,105 @@ export function RejectPartnerApplicationModal({

-
-
- +
+
+ + Allow partner to reapply immediately + + +
+ - setAllowImmediateReapply(checked === true) + disabled={isPending || flagForFraud} + disabledTooltip={ + flagForFraud + ? 'Turn off "Flag partner for potential fraud" to allow immediate reapply.' + : undefined } - disabled={isPending} + fn={(checked: boolean) => { + setAllowImmediateReapply(checked); + if (checked) { + setFlagForFraud(false); + setFlagForFraudReason(""); + } + }} /> -
-

- This will skip the 30 day waiting period and allow the partner to - resubmit another application. -

+
+ +
+
+
+ + Flag partner for potential fraud + + +
+ { + setFlagForFraud(checked); + if (checked) { + setAllowImmediateReapply(false); + } + }} + /> +
+ +
+