Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export function AnalyticsPartnersTable() {
? new Date(p.payoutsEnabledAt)
: null,
}}
showRewardsTooltip={true}
/>
);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,11 @@ export function ProgramPartnersApplicationsPageClient() {
minSize: 250,
cell: ({ row }) => {
return (
<PartnerRowItem partner={row.original} showPermalink={false} />
<PartnerRowItem
partner={row.original}
showPermalink={false}
showRewardsTooltip={true}
/>
);
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -124,7 +124,20 @@ export function ProgramPartnersRejectedApplicationsPageClient() {
minSize: 250,
cell: ({ row }) => {
return (
<PartnerRowItem partner={row.original} showPermalink={false} />
<div className="flex items-center gap-2">
<div className="relative shrink-0">
<img
src={
row.original.image || `${OG_AVATAR_URL}${row.original.name}`
}
alt={row.original.name}
className="size-5 shrink-0 rounded-full"
/>
</div>
<span className="min-w-0 truncate text-sm font-medium">
{row.original.name}
</span>
</div>
);
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,11 @@ export function PartnersTable() {
minSize: 250,
cell: ({ row }) => {
return (
<PartnerRowItem partner={row.original} showPermalink={false} />
<PartnerRowItem
partner={row.original}
showPermalink={false}
showRewardsTooltip={true}
/>
);
},
},
Expand Down
34 changes: 34 additions & 0 deletions apps/web/lib/api/groups/get-groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -94,6 +95,37 @@ export async function getGroups(filters: GroupFilters) {
LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}
`) satisfies Array<any>;

// 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),
Expand All @@ -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),
}));
}
142 changes: 142 additions & 0 deletions apps/web/ui/partners/partner-rewards-tooltip.tsx
Original file line number Diff line number Diff line change
@@ -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}`;
Comment on lines +75 to +89
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle single-month durations.

When maxDuration is exactly 1, the tooltip omits the timeframe (“for 1 month”), leaving partners without crucial context. Add explicit handling so single-month rewards show the proper copy.

-    } else if (maxDuration && maxDuration > 1) {
-      durationText =
-        maxDuration % 12 === 0
-          ? ` for ${maxDuration / 12} year${maxDuration / 12 > 1 ? "s" : ""}`
-          : ` for ${maxDuration} month${maxDuration > 1 ? "s" : ""}`;
+    } else if (typeof maxDuration === "number" && maxDuration > 0) {
+      durationText =
+        maxDuration % 12 === 0
+          ? ` for ${maxDuration / 12} year${maxDuration / 12 === 1 ? "" : "s"}`
+          : ` for ${maxDuration} month${maxDuration === 1 ? "" : "s"}`;
@@
-    } else if (maxDuration && maxDuration > 1) {
-      durationText =
-        maxDuration % 12 === 0
-          ? ` for ${maxDuration / 12} year${maxDuration / 12 > 1 ? "s" : ""}`
-          : ` for ${maxDuration} month${maxDuration > 1 ? "s" : ""}`;
+    } else if (typeof maxDuration === "number" && maxDuration > 0) {
+      durationText =
+        maxDuration % 12 === 0
+          ? ` for ${maxDuration / 12} year${maxDuration / 12 === 1 ? "" : "s"}`
+          : ` for ${maxDuration} month${maxDuration === 1 ? "" : "s"}`;

Also applies to: 112-120

🤖 Prompt for AI Agents
In apps/web/ui/partners/partner-rewards-tooltip.tsx around lines 75-89 (and also
apply same fix around lines 112-120), the logic skips an explicit "for 1 month"
phrase when maxDuration === 1; update the conditional so a maxDuration of 1
renders "for 1 month" (singular) rather than falling through to no timeframe —
implement an explicit branch or adjust the ternary checks to treat 1 as a month
case and ensure pluralization uses singular when value === 1; mirror the same
change in the later block at lines 112-120.

}

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 (
<div className="flex flex-col gap-1 p-2.5">
{rewards.map((reward, index) => {
const IconComponent = reward.icon;
return (
<div key={index} className="flex w-full items-center gap-2">
<div className="flex size-6 shrink-0 items-center justify-center rounded-[6px] bg-neutral-100">
<IconComponent className="size-4" />
</div>
<div className="text-xs font-medium leading-4 text-neutral-700">
{reward.text}
</div>
</div>
);
})}
</div>
);
}
72 changes: 44 additions & 28 deletions apps/web/ui/partners/partner-row-item.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center gap-2">
<DynamicTooltipWrapper
tooltipProps={
showPayoutsEnabled
showRewardsTooltip
? {
content: (
<div className="grid max-w-xs gap-2 p-4">
<div className="flex items-center gap-2 text-sm font-medium">
Payouts{" "}
{partner.payoutsEnabledAt ? "enabled" : "disabled"}
<div
className={cn(
"flex size-5 items-center justify-center rounded-md border border-green-300 bg-green-200 text-green-800",
!partner.payoutsEnabledAt &&
"border-red-300 bg-red-200 text-red-800",
)}
>
{partner.payoutsEnabledAt ? (
<GreekTemple className="size-3" />
) : (
<CircleMinus className="size-3" />
)}
content: <PartnerRewardsTooltip group={partnerGroup} />,
delayDuration: 150,
}
: showPayoutsEnabled
? {
content: (
<div className="grid max-w-xs gap-2 p-4">
<div className="flex items-center gap-2 text-sm font-medium">
Payouts{" "}
{partner.payoutsEnabledAt ? "enabled" : "disabled"}
<div
className={cn(
"flex size-5 items-center justify-center rounded-md border border-green-300 bg-green-200 text-green-800",
!partner.payoutsEnabledAt &&
"border-red-300 bg-red-200 text-red-800",
)}
>
{partner.payoutsEnabledAt ? (
<GreekTemple className="size-3" />
) : (
<CircleMinus className="size-3" />
)}
</div>
</div>
<div className="text-pretty text-sm text-neutral-500">
{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"}
</div>
</div>
<div className="text-pretty text-sm text-neutral-500">
{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"}
</div>
</div>
),
}
: undefined
),
}
: undefined
}
>
<div className="relative shrink-0">
Expand All @@ -64,7 +80,7 @@ export function PartnerRowItem({
alt={partner.name}
className="size-5 shrink-0 rounded-full"
/>
{showPayoutsEnabled && (
{showPayoutsEnabled && !showRewardsTooltip && (
<div
className={cn(
"absolute -bottom-0.5 -right-0.5 size-2 rounded-full bg-green-500",
Expand Down
Loading