diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/analytics-partners-table.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/analytics-partners-table.tsx index 0a52f83562..1906888812 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/analytics-partners-table.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/analytics-partners-table.tsx @@ -60,6 +60,7 @@ export function AnalyticsPartnersTable() { ? new Date(p.payoutsEnabledAt) : null, }} + showRewardsTooltip={true} /> ); }, diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/page-client.tsx index 11c2e7dcdb..0ba952110b 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/page-client.tsx @@ -170,7 +170,11 @@ export function ProgramPartnersApplicationsPageClient() { minSize: 250, cell: ({ row }) => { return ( - + ); }, }, diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/rejected/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/rejected/page-client.tsx index 21012e1906..a1c27b388a 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/rejected/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/applications/rejected/page-client.tsx @@ -8,7 +8,6 @@ import useWorkspace from "@/lib/swr/use-workspace"; import { EnrolledPartnerProps } from "@/lib/types"; import { useConfirmModal } from "@/ui/modals/confirm-modal"; import { PartnerApplicationSheet } from "@/ui/partners/partner-application-sheet"; -import { PartnerRowItem } from "@/ui/partners/partner-row-item"; import { PartnerSocialColumn } from "@/ui/partners/partner-social-column"; import { AnimatedEmptyState } from "@/ui/shared/animated-empty-state"; import { SearchBoxPersisted } from "@/ui/shared/search-box"; @@ -30,6 +29,7 @@ import { formatDate, getDomainWithoutWWW, } from "@dub/utils"; +import { OG_AVATAR_URL } from "@dub/utils/src/constants"; import { Row } from "@tanstack/react-table"; import { Command } from "cmdk"; import { useAction } from "next-safe-action/hooks"; @@ -124,7 +124,20 @@ export function ProgramPartnersRejectedApplicationsPageClient() { minSize: 250, cell: ({ row }) => { return ( - +
+
+ {row.original.name} +
+ + {row.original.name} + +
); }, }, 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 6ff820f1dd..08a3da7dc0 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 @@ -158,7 +158,11 @@ export function PartnersTable() { minSize: 250, cell: ({ row }) => { return ( - + ); }, }, diff --git a/apps/web/lib/api/groups/get-groups.ts b/apps/web/lib/api/groups/get-groups.ts index 250ec08c0c..1a483e3811 100644 --- a/apps/web/lib/api/groups/get-groups.ts +++ b/apps/web/lib/api/groups/get-groups.ts @@ -31,6 +31,7 @@ export async function getGroups(filters: GroupFilters) { includeExpandedFields, } = filters; + // First get the basic group data with metrics const groups = (await prisma.$queryRaw` SELECT pg.id, @@ -94,6 +95,37 @@ export async function getGroups(filters: GroupFilters) { LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize} `) satisfies Array; + // Get group IDs for fetching rewards + const groupIdsFromQuery = groups.map((group) => group.id); + + // Fetch rewards for these groups + const groupsWithRewards = await prisma.partnerGroup.findMany({ + where: { + id: { + in: groupIdsFromQuery, + }, + }, + include: { + clickReward: true, + leadReward: true, + saleReward: true, + discount: true, + }, + }); + + // Create a map for quick lookup + const rewardsMap = new Map( + groupsWithRewards.map((group) => [ + group.id, + { + clickReward: group.clickReward, + leadReward: group.leadReward, + saleReward: group.saleReward, + discount: group.discount, + }, + ]), + ); + return groups.map((group) => ({ ...group, partners: Number(group.partners), @@ -104,5 +136,7 @@ export async function getGroups(filters: GroupFilters) { totalConversions: Number(group.totalConversions), totalCommissions: Number(group.totalCommissions), netRevenue: Number(group.netRevenue), + // Add reward data + ...rewardsMap.get(group.id), })); } diff --git a/apps/web/ui/partners/partner-rewards-tooltip.tsx b/apps/web/ui/partners/partner-rewards-tooltip.tsx new file mode 100644 index 0000000000..fcd1861c97 --- /dev/null +++ b/apps/web/ui/partners/partner-rewards-tooltip.tsx @@ -0,0 +1,142 @@ +import { CursorRays, Gift, InvoiceDollar, UserPlus } from "@dub/ui/icons"; +import React from "react"; + +interface PartnerRewardsTooltipProps { + group?: { + clickReward?: { + amount: number; + type: "percentage" | "flat"; + maxDuration?: number | null; + } | null; + leadReward?: { + amount: number; + type: "percentage" | "flat"; + } | null; + saleReward?: { + amount: number; + type: "percentage" | "flat"; + maxDuration?: number | null; + } | null; + discount?: { + amount: number; + type: "percentage" | "flat"; + maxDuration?: number | null; + } | null; + } | null; +} + +export function PartnerRewardsTooltip({ group }: PartnerRewardsTooltipProps) { + if (!group) { + return null; // Don't show tooltip if no group found + } + + const rewards: Array<{ + icon: React.ComponentType<{ className?: string }>; + text: string; + }> = []; + + // Add click reward if exists + if (group.clickReward) { + const amount = + group.clickReward.type === "percentage" + ? `${group.clickReward.amount}%` + : `$${(group.clickReward.amount / 100).toFixed(2)}`; + rewards.push({ + icon: CursorRays, + text: `${amount} per click`, + }); + } + + // Add lead reward if exists + if (group.leadReward) { + const amount = + group.leadReward.type === "percentage" + ? `${group.leadReward.amount}%` + : `$${(group.leadReward.amount / 100).toFixed(2)}`; + rewards.push({ + icon: UserPlus, + text: `${amount} per lead`, + }); + } + + // Add sale reward if exists + if (group.saleReward) { + const amount = + group.saleReward.type === "percentage" + ? `${group.saleReward.amount}%` + : `$${(group.saleReward.amount / 100).toFixed(2)}`; + + let durationText = ""; + const maxDuration = group.saleReward.maxDuration; + if (maxDuration === null) { + durationText = " for the customer's lifetime"; + } else if (maxDuration === 0) { + durationText = " for the first sale"; + } else if (maxDuration && maxDuration > 1) { + durationText = + maxDuration % 12 === 0 + ? ` for ${maxDuration / 12} year${maxDuration / 12 > 1 ? "s" : ""}` + : ` for ${maxDuration} month${maxDuration > 1 ? "s" : ""}`; + } + + let text = ""; + if (maxDuration === 0) { + // For first sale only, use "earn" instead of "per sale" + text = `Earn ${amount}${durationText}`; + } else { + // For recurring sales, use "per sale" with prefix + const prefix = group.saleReward.type === "percentage" ? "Up to " : ""; + text = `${prefix}${amount} per sale${durationText}`; + } + + rewards.push({ + icon: InvoiceDollar, + text, + }); + } + + // Add discount if exists + if (group.discount) { + const amount = + group.discount.type === "percentage" + ? `${group.discount.amount}%` + : `$${(group.discount.amount / 100).toFixed(2)}`; + + let durationText = ""; + const maxDuration = group.discount.maxDuration; + if (maxDuration === null) { + durationText = " for their lifetime"; + } else if (maxDuration === 0) { + durationText = " for their first purchase"; + } else if (maxDuration && maxDuration > 1) { + durationText = + maxDuration % 12 === 0 + ? ` for ${maxDuration / 12} year${maxDuration / 12 > 1 ? "s" : ""}` + : ` for ${maxDuration} month${maxDuration > 1 ? "s" : ""}`; + } + + rewards.push({ + icon: Gift, // Using Gift icon for discount + text: `New users get ${amount} off${durationText}`, + }); + } + + // Always show rewards - partners will always have rewards through their group + return ( +
+ {rewards.map((reward, index) => { + const IconComponent = reward.icon; + return ( +
+
+ +
+
+ {reward.text} +
+
+ ); + })} +
+ ); +} diff --git a/apps/web/ui/partners/partner-row-item.tsx b/apps/web/ui/partners/partner-row-item.tsx index 88bfdb03a2..2ceffbf65e 100644 --- a/apps/web/ui/partners/partner-row-item.tsx +++ b/apps/web/ui/partners/partner-row-item.tsx @@ -1,61 +1,77 @@ +import useGroups from "@/lib/swr/use-groups"; import { DynamicTooltipWrapper, GreekTemple } from "@dub/ui"; import { cn } from "@dub/utils"; import { OG_AVATAR_URL } from "@dub/utils/src/constants"; import { CircleMinus } from "lucide-react"; import Link from "next/link"; import { useParams } from "next/navigation"; +import { PartnerRewardsTooltip } from "./partner-rewards-tooltip"; export function PartnerRowItem({ partner, showPermalink = true, + showRewardsTooltip = false, }: { partner: { id: string; name: string; image?: string | null; payoutsEnabledAt?: Date | null; + groupId?: string | null; }; showPermalink?: boolean; + showRewardsTooltip?: boolean; }) { const { slug } = useParams(); const As = showPermalink ? Link : "div"; const showPayoutsEnabled = "payoutsEnabledAt" in partner; + // Get groups data to find partner's group and their rewards + const { groups } = useGroups({ enabled: showRewardsTooltip }); + const partnerGroup = showRewardsTooltip + ? groups?.find((group) => group.id === partner.groupId) + : undefined; + return (
-
- Payouts{" "} - {partner.payoutsEnabledAt ? "enabled" : "disabled"} -
- {partner.payoutsEnabledAt ? ( - - ) : ( - - )} + content: , + delayDuration: 150, + } + : showPayoutsEnabled + ? { + content: ( +
+
+ Payouts{" "} + {partner.payoutsEnabledAt ? "enabled" : "disabled"} +
+ {partner.payoutsEnabledAt ? ( + + ) : ( + + )} +
+
+
+ {partner.payoutsEnabledAt + ? "This partner has payouts enabled, which means they will be able to receive payouts from this program" + : "This partner does not have payouts enabled, which means they will not be able to receive any payouts from this program"}
-
- {partner.payoutsEnabledAt - ? "This partner has payouts enabled, which means they will be able to receive payouts from this program" - : "This partner does not have payouts enabled, which means they will not be able to receive any payouts from this program"} -
-
- ), - } - : undefined + ), + } + : undefined } >
@@ -64,7 +80,7 @@ export function PartnerRowItem({ alt={partner.name} className="size-5 shrink-0 rounded-full" /> - {showPayoutsEnabled && ( + {showPayoutsEnabled && !showRewardsTooltip && (