diff --git a/apps/web/app/(ee)/api/cron/partner-platforms/route.ts b/apps/web/app/(ee)/api/cron/partner-platforms/route.ts index c4926fc2add..9d7108f12eb 100644 --- a/apps/web/app/(ee)/api/cron/partner-platforms/route.ts +++ b/apps/web/app/(ee)/api/cron/partner-platforms/route.ts @@ -51,7 +51,7 @@ export const POST = withCron(async ({ rawBody }) => { lastCheckedAt: null, }, ], - // only check partners that are approved or trusted + // only check partners approved/trusted in the network partner: { networkStatus: { in: ["approved", "trusted"], diff --git a/apps/web/app/(ee)/api/cron/partner-platforms/website/get-domain-rating.ts b/apps/web/app/(ee)/api/cron/partner-platforms/website/get-domain-rating.ts new file mode 100644 index 00000000000..eac63a79cac --- /dev/null +++ b/apps/web/app/(ee)/api/cron/partner-platforms/website/get-domain-rating.ts @@ -0,0 +1,27 @@ +import * as z from "zod/v4"; + +const domainRatingSchema = z.object({ + domain_rating: z.object({ + domain_rating: z.number(), + }), +}); + +export async function getDomainRating(target: string) { + const response = await fetch( + `https://api.ahrefs.com/v3/public/domain-rating-free?target=${encodeURIComponent(target)}&output=json`, + { + headers: { + Accept: "application/json", + }, + }, + ); + + if (!response.ok) { + throw new Error( + `Failed to fetch domain rating for ${target}: ${response.status} ${await response.text()}`, + ); + } + + const data = await response.json(); + return Math.round(domainRatingSchema.parse(data).domain_rating.domain_rating); +} diff --git a/apps/web/app/(ee)/api/cron/partner-platforms/website/route.ts b/apps/web/app/(ee)/api/cron/partner-platforms/website/route.ts new file mode 100644 index 00000000000..ada3c15449a --- /dev/null +++ b/apps/web/app/(ee)/api/cron/partner-platforms/website/route.ts @@ -0,0 +1,118 @@ +import { qstash } from "@/lib/cron"; +import { withCron } from "@/lib/cron/with-cron"; +import { prisma } from "@dub/prisma"; +import { PlatformType } from "@dub/prisma/client"; +import { APP_DOMAIN_WITH_NGROK, chunk, getDomainWithoutWWW } from "@dub/utils"; +import * as z from "zod/v4"; +import { logAndRespond } from "../../utils"; +import { getDomainRating } from "./get-domain-rating"; + +export const dynamic = "force-dynamic"; + +const BATCH_SIZE = 500; +const CONCURRENCY = 10; + +const schema = z.object({ + startingAfter: z.string().optional(), +}); + +/** + * This route is used to update domain rating (DR) for verified website partners using the Ahrefs free API + * Runs once a day at 05:00 AM UTC (cron expression: 0 5 * * *) + * POST /api/cron/partner-platforms/website + */ +export const POST = withCron(async ({ rawBody }) => { + let { startingAfter } = schema.parse( + rawBody ? JSON.parse(rawBody) : { startingAfter: undefined }, + ); + + const websites = await prisma.partnerPlatform.findMany({ + where: { + type: PlatformType.website, + verifiedAt: { + not: null, + }, + }, + take: BATCH_SIZE, + ...(startingAfter && { + cursor: { + id: startingAfter, + }, + skip: 1, + }), + orderBy: { + id: "asc", + }, + }); + + if (websites.length === 0) { + return logAndRespond( + "No more website platforms found. Finished updating website domain ratings.", + ); + } + + const websiteChunks = chunk(websites, CONCURRENCY); + + for (const websiteChunk of websiteChunks) { + await Promise.allSettled( + websiteChunk.map(async (website) => { + const target = + getDomainWithoutWWW(website.identifier) ?? website.identifier; + + if (!target) { + console.error( + `Invalid website identifier for partner platform ${website.id}: ${website.identifier}`, + ); + return; + } + + try { + const domainRating = await getDomainRating(target); + + if (website.subscribers === BigInt(domainRating)) { + console.log( + `No changes to update for ${target} (DR: ${domainRating}), skipping...`, + ); + return; + } + + await prisma.partnerPlatform.update({ + where: { + id: website.id, + }, + data: { + subscribers: domainRating, + lastCheckedAt: new Date(), + }, + }); + + console.log(`Updated domain rating for ${target}`, { + domainRating, + }); + } catch (error) { + console.error(`Error updating domain rating for ${target}:`, error); + } + }), + ); + } + + if (websites.length === BATCH_SIZE) { + startingAfter = websites[websites.length - 1].id; + + await qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/partner-platforms/website`, + method: "POST", + body: { + startingAfter, + }, + }); + + return logAndRespond( + `Processed ${BATCH_SIZE} websites. Scheduled next batch (startingAfter: ${startingAfter}).`, + ); + } + + return logAndRespond( + `Finished updating domain ratings for ${websites.length} websites.`, + ); +}); diff --git a/apps/web/app/(ee)/api/cron/partner-platforms/youtube/route.ts b/apps/web/app/(ee)/api/cron/partner-platforms/youtube/route.ts index 1479931aa88..5c4a29d5127 100644 --- a/apps/web/app/(ee)/api/cron/partner-platforms/youtube/route.ts +++ b/apps/web/app/(ee)/api/cron/partner-platforms/youtube/route.ts @@ -1,23 +1,35 @@ +import { qstash } from "@/lib/cron"; import { withCron } from "@/lib/cron/with-cron"; import { prisma } from "@dub/prisma"; import { PlatformType } from "@dub/prisma/client"; -import { chunk } from "@dub/utils"; +import { APP_DOMAIN_WITH_NGROK, chunk } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; import { youtubeChannelSchema } from "./youtube-channel-schema"; export const dynamic = "force-dynamic"; +const BATCH_SIZE = 1000; +const YOUTUBE_API_CHUNK_SIZE = 50; + +const schema = z.object({ + startingAfter: z.string().optional(), +}); + /** * This route is used to update stats for YouTube verified partners using the YouTube API * Runs once a day at 06:00 AM UTC (cron expression: 0 6 * * *) * POST /api/cron/partner-platforms/youtube */ -export const POST = withCron(async () => { +export const POST = withCron(async ({ rawBody }) => { if (!process.env.YOUTUBE_API_KEY) { throw new Error("YOUTUBE_API_KEY is not defined"); } + let { startingAfter } = schema.parse( + rawBody ? JSON.parse(rawBody) : { startingAfter: undefined }, + ); + const youtubeChannels = await prisma.partnerPlatform.findMany({ where: { type: PlatformType.youtube, @@ -28,15 +40,25 @@ export const POST = withCron(async () => { not: null, }, }, + take: BATCH_SIZE, + ...(startingAfter && { + cursor: { + id: startingAfter, + }, + skip: 1, + }), + orderBy: { + id: "asc", + }, }); if (youtubeChannels.length === 0) { return logAndRespond( - "No YouTube platforms found. Skipping YouTube stats update.", + "No more YouTube platforms found. Finished updating YouTube stats.", ); } - const channelChunks = chunk(youtubeChannels, 50); + const channelChunks = chunk(youtubeChannels, YOUTUBE_API_CHUNK_SIZE); for (const channelChunk of channelChunks) { const channelIds = channelChunk.map((channel) => channel.platformId); @@ -79,12 +101,27 @@ export const POST = withCron(async () => { subscribers: channel.statistics.subscriberCount, posts: channel.statistics.videoCount, views: channel.statistics.viewCount, - avatarUrl: channel.snippet?.thumbnails?.default?.url, + avatarUrl: channel.snippet?.thumbnails?.default?.url ?? null, ...(channel.snippet?.customUrl && { identifier: channel.snippet.customUrl.replace("@", ""), }), }; + const hasChanges = + partnerPlatform.subscribers !== BigInt(newStats.subscribers) || + partnerPlatform.posts !== BigInt(newStats.posts) || + partnerPlatform.views !== BigInt(newStats.views) || + partnerPlatform.avatarUrl !== newStats.avatarUrl || + ("identifier" in newStats && + partnerPlatform.identifier !== newStats.identifier); + + if (!hasChanges) { + console.log( + `No changes to update for @${partnerPlatform.identifier}, skipping...`, + ); + return; + } + await prisma.partnerPlatform.update({ where: { id: partnerPlatform.id, @@ -104,7 +141,23 @@ export const POST = withCron(async () => { } } + if (youtubeChannels.length === BATCH_SIZE) { + startingAfter = youtubeChannels[youtubeChannels.length - 1].id; + + await qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/partner-platforms/youtube`, + method: "POST", + body: { + startingAfter, + }, + }); + + return logAndRespond( + `Processed ${BATCH_SIZE} YouTube channels. Scheduled next batch (startingAfter: ${startingAfter}).`, + ); + } + return logAndRespond( - `YouTube stats updated for ${youtubeChannels.length} partners`, + `Finished updating YouTube stats for ${youtubeChannels.length} partners.`, ); }); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/network-partner-card.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/network-partner-card.tsx index a197f62360b..8302f3db8b7 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/network-partner-card.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/network/network-partner-card.tsx @@ -1,28 +1,35 @@ "use client"; import { PARTNER_PLATFORM_FIELDS } from "@/lib/partners/partner-platforms"; +import { mutatePrefix } from "@/lib/swr/mutate"; +import usePartnerNetworkInvitesUsage from "@/lib/swr/use-partner-network-invites-usage"; import useWorkspace from "@/lib/swr/use-workspace"; import { NetworkPartnerProps } from "@/lib/types"; +import { useTrialLimitActivateModal } from "@/ui/modals/trial-limit-activate-modal"; import { PartnerAvatar } from "@/ui/partners/partner-avatar"; import { PartnerStarButton } from "@/ui/partners/partner-star-button"; import { TrustedPartnerBadge } from "@/ui/partners/trusted-partner-badge"; import { BadgeCheck2Fill, + Button, + DynamicTooltipWrapper, Tooltip, UserPlus, useResizeObserver, useRouterStuff, } from "@dub/ui"; import type { Icon } from "@dub/ui/icons"; -import { EnvelopeArrowRight, Globe } from "@dub/ui/icons"; +import { ChartActivity2, EnvelopeArrowRight, Globe } from "@dub/ui/icons"; import { COUNTRIES, cn, - formatDate, isClickOnInteractiveChild, + isWorkspaceBillingTrialActive, timeAgo, } from "@dub/utils"; -import Link from "next/link"; +import { EmailContent } from "app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-email-preview"; +import { InviteNetworkPartnerSheet } from "app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-network-partner-sheet"; +import { usePathname } from "next/navigation"; import { useEffect, useMemo, useRef, useState } from "react"; export function NetworkPartnerCard({ @@ -34,6 +41,13 @@ export function NetworkPartnerCard({ }) { const { slug: workspaceSlug } = useWorkspace(); const { queryParams } = useRouterStuff(); + const pathname = usePathname(); + + const showInvite = + !!partner && + !partner.invitedAt && + !partner.recruitedAt && + !pathname.endsWith("/ignored"); const basicFields = useMemo( () => [ @@ -56,9 +70,9 @@ export function NetworkPartnerCard({ }, { id: "joinedAt", - icon: , + icon: , text: partner - ? `Joined ${formatDate(partner.createdAt, { month: "short" })}` + ? `Joined ${timeAgo(partner.createdAt, { withAgo: true })}` : undefined, }, ], @@ -72,7 +86,10 @@ export function NetworkPartnerCard({ label: field.label, icon: field.icon, ...field.data(partner.platforms), - })).filter((field) => field.value && field.href) + })).sort((a, b) => { + if (a.verified === b.verified) return 0; + return a.verified ? -1 : 1; + }) : null, [partner], ); @@ -120,117 +137,314 @@ export function NetworkPartnerCard({ )} -
-
- {/* Avatar + country icon */} -
- {partner ? ( - +
+
+
+ {partner ? ( + + ) : ( +
+ )} + {partner?.networkStatus === "trusted" && ( + + )} +
+ + {partner && (onToggleStarred || showInvite) && ( + - ) : ( -
)} - {partner?.networkStatus === "trusted" && }
- {partner && onToggleStarred && ( - - )} -
- -
- {/* Name */} - {partner ? ( - - {partner.name} - - ) : ( -
- )} +
+ {partner ? ( + + {partner.name} + + ) : ( +
+ )} - {/* Basic details */} -
- {basicFields - .filter(({ text }) => text !== null) - .map(({ id, icon, text }) => ( -
-
- {text !== undefined ? ( - <> - {icon} - {text} - - ) : ( -
- )} +
+ {basicFields + .filter(({ text }) => text !== null) + .map(({ id, icon, text }) => ( +
+
+ {text !== undefined ? ( + <> + {icon} + {text} + + ) : ( +
+ )} +
-
- ))} + ))} +
+ +
+
+
+ + Audience + - {/* Platforms */}
- {partnerPlatformsData?.length + {partnerPlatformsData ? partnerPlatformsData.map( - ({ label, icon: Icon, verified, value, href }) => ( - ( + - - {value} - {verified && ( - - )} - - } - > - - - {label} - - {verified && ( - - )} - - + label={label} + icon={PlatformIcon} + verified={verified} + stat={stat} + value={value} + info={info} + verifiedAt={verifiedAt} + href={verified && href ? href : undefined} + /> ), ) - : [...Array(3)].map((_, idx) => ( -
+ : [...Array(6)].map((_, idx) => ( +
))}
+
+
+
+ ); +} + +function NetworkPartnerCardActions({ + partner, + onToggleStarred, + showInvite, +}: { + partner: NetworkPartnerProps; + onToggleStarred?: (starred: boolean) => void; + showInvite: boolean; +}) { + const { trialEndsAt } = useWorkspace(); + const { openTrialLimitModal, TrialLimitActivateModal } = + useTrialLimitActivateModal(); + const trialActive = isWorkspaceBillingTrialActive(trialEndsAt); + + const [showInviteSheet, setShowInviteSheet] = useState(false); + const [inviteEmailContent, setInviteEmailContent] = + useState(null); + + const { remaining: remainingInvites } = usePartnerNetworkInvitesUsage(); + const atNetworkInviteLimit = remainingInvites === 0; + const disabled = atNetworkInviteLimit && !trialActive; + + const handleInvitePress = () => { + if (trialActive && atNetworkInviteLimit) { + openTrialLimitModal("networkInvites"); + return; + } + setShowInviteSheet(true); + }; + + return ( + <> + + { + mutatePrefix("/api/network/partners"); + }} + {...(trialActive && { + onInviteLimitError: () => openTrialLimitModal("networkInvites"), + })} + /> +
+ {onToggleStarred && ( + + )} + {showInvite && ( +
+ + ); +} + +function PlatformStatCard({ + label, + icon: PlatformIcon, + verified, + stat, + value, + info, + verifiedAt, + href, +}: { + label: string; + icon: Icon; + verified: boolean; + stat?: string | null; + value?: string | null; + info?: string[]; + verifiedAt?: Date | null; + href?: string | null; +}) { + const content = ( +
+
+ + {verified && ( + + )} +
+ + {verified && stat ? stat : "—"} + + {label} +
+ ); + + const As = href ? "a" : "div"; - {/* Categories */} - + return ( + + ), + }} + > + e.stopPropagation()} + > + {content} + + + ); +} + +function PlatformStatTooltipContent({ + label, + icon: PlatformIcon, + value, + stat, + info, + verified, + verifiedAt, +}: { + label: string; + icon: Icon; + value?: string | null; + stat?: string | null; + info?: string[]; + verified: boolean; + verifiedAt?: Date | null; +}) { + if (!verified || !value) { + return ( +
+
+
+ Not connected
+ ); + } + + return ( +
+
+
+ +
+
+
+ {value} +
+ {(info?.[0] ?? stat) && ( +
+ {info?.[0] ?? stat} +
+ )} +
+
+ {verifiedAt && ( +
+ + Verified {timeAgo(verifiedAt, { withAgo: true })} +
+ )}
); } @@ -260,7 +474,6 @@ function ListRow({ useEffect(() => { if (!containerRef.current) return; - // Determine if we need to show less items if ( shownItems?.length && containerRef.current.scrollWidth > containerRef.current.clientWidth @@ -272,7 +485,6 @@ function ListRow({ } }, [shownItems]); - // Show less items if needed after resizing const entry = useResizeObserver(containerRef); useEffect(() => { if (!containerRef.current) return; @@ -314,7 +526,7 @@ function ListRow({
} > -
+
+{items.length - (shownItems?.length ?? 0)}
diff --git a/apps/web/lib/partners/partner-platforms.ts b/apps/web/lib/partners/partner-platforms.ts index 323ec69cf12..153a6e2e78b 100644 --- a/apps/web/lib/partners/partner-platforms.ts +++ b/apps/web/lib/partners/partner-platforms.ts @@ -16,8 +16,10 @@ export const PARTNER_PLATFORM_FIELDS: { data: (platforms: PartnerPlatformProps[]) => { value?: string | null; verified: boolean; + verifiedAt?: Date | null; href?: string | null; info?: string[]; + stat?: string | null; }; }[] = [ { @@ -29,9 +31,19 @@ export const PARTNER_PLATFORM_FIELDS: { return { value: website ? getPrettyUrl(website.identifier) : null, verified: !!website?.verifiedAt, + verifiedAt: website?.verifiedAt ?? null, href: website?.identifier ? getUrlFromStringIfValid(website.identifier) : null, + info: [ + website?.subscribers && website?.verifiedAt + ? `${Number(website.subscribers)} DR` + : null, + ].filter(Boolean), + stat: + website?.subscribers && website?.verifiedAt + ? `${Number(website.subscribers)} DR` + : null, }; }, }, @@ -44,6 +56,7 @@ export const PARTNER_PLATFORM_FIELDS: { return { value: youtube?.identifier ? `@${youtube.identifier}` : null, verified: !!youtube?.verifiedAt, + verifiedAt: youtube?.verifiedAt ?? null, href: youtube?.identifier ? `https://youtube.com/@${youtube.identifier}` : null, @@ -55,6 +68,10 @@ export const PARTNER_PLATFORM_FIELDS: { ? `${nFormatter(Number(youtube.views))} views` : null, ].filter(Boolean), + stat: + youtube?.subscribers && youtube?.verifiedAt + ? nFormatter(Number(youtube.subscribers)) + : null, }; }, }, @@ -67,6 +84,7 @@ export const PARTNER_PLATFORM_FIELDS: { return { value: twitter ? `@${twitter.identifier}` : null, verified: !!twitter?.verifiedAt, + verifiedAt: twitter?.verifiedAt ?? null, href: twitter?.identifier ? `https://x.com/${twitter.identifier}` : null, @@ -78,6 +96,10 @@ export const PARTNER_PLATFORM_FIELDS: { ? `${nFormatter(Number(twitter.posts))} tweets` : null, ].filter(Boolean), + stat: + twitter?.subscribers && twitter?.verifiedAt + ? nFormatter(Number(twitter.subscribers)) + : null, }; }, }, @@ -90,6 +112,7 @@ export const PARTNER_PLATFORM_FIELDS: { return { value: linkedin ? linkedin.identifier : null, verified: !!linkedin?.verifiedAt, + verifiedAt: linkedin?.verifiedAt ?? null, href: linkedin?.identifier ? `https://linkedin.com/in/${linkedin.identifier}` : null, @@ -105,6 +128,7 @@ export const PARTNER_PLATFORM_FIELDS: { return { value: instagram ? `@${instagram.identifier}` : null, verified: !!instagram?.verifiedAt, + verifiedAt: instagram?.verifiedAt ?? null, href: instagram?.identifier ? `https://instagram.com/${instagram.identifier}` : null, @@ -116,6 +140,10 @@ export const PARTNER_PLATFORM_FIELDS: { ? `${nFormatter(Number(instagram.posts))} posts` : null, ].filter(Boolean), + stat: + instagram?.subscribers && instagram?.verifiedAt + ? nFormatter(Number(instagram.subscribers)) + : null, }; }, }, @@ -128,6 +156,7 @@ export const PARTNER_PLATFORM_FIELDS: { return { value: tiktok ? `@${tiktok.identifier}` : null, verified: !!tiktok?.verifiedAt, + verifiedAt: tiktok?.verifiedAt ?? null, href: tiktok?.identifier ? `https://tiktok.com/@${tiktok.identifier}` : null, @@ -139,6 +168,10 @@ export const PARTNER_PLATFORM_FIELDS: { ? `${nFormatter(Number(tiktok.posts))} posts` : null, ].filter(Boolean), + stat: + tiktok?.subscribers && tiktok?.verifiedAt + ? nFormatter(Number(tiktok.subscribers)) + : null, }; }, }, diff --git a/apps/web/ui/partners/partner-platforms-form.tsx b/apps/web/ui/partners/partner-platforms-form.tsx index cdc1a3353c4..40ca2ed75f4 100644 --- a/apps/web/ui/partners/partner-platforms-form.tsx +++ b/apps/web/ui/partners/partner-platforms-form.tsx @@ -685,6 +685,18 @@ function FormRow({ platforms?: PartnerPlatformProps[]; }; + if (property === "website") { + const websitePlatform = getPlatformData( + partnerWithPlatforms.platforms, + "website", + ); + const domainRating = websitePlatform?.subscribers ?? 0; + + return [domainRating > 0 ? `${Number(domainRating)} DR` : null].filter( + Boolean, + ) as string[]; + } + if (property === "youtube") { const youtubePlatform = getPlatformData( partnerWithPlatforms.platforms, diff --git a/apps/web/ui/partners/partner-star-button.tsx b/apps/web/ui/partners/partner-star-button.tsx index 3d472f58c7a..5e78d1fe64b 100644 --- a/apps/web/ui/partners/partner-star-button.tsx +++ b/apps/web/ui/partners/partner-star-button.tsx @@ -100,7 +100,10 @@ export function PartnerStarButton({ ) } - className={cn("rounded-lg p-0", className)} + className={cn( + "border-border-subtle shrink-0 rounded-lg border p-0", + className, + )} /> ); }