diff --git a/apps/web/app/(ee)/api/admin/partners/[partnerId]/platforms/route.ts b/apps/web/app/(ee)/api/admin/partners/[partnerId]/platforms/route.ts index 3698b3c7266..0954477ecbd 100644 --- a/apps/web/app/(ee)/api/admin/partners/[partnerId]/platforms/route.ts +++ b/apps/web/app/(ee)/api/admin/partners/[partnerId]/platforms/route.ts @@ -25,8 +25,12 @@ export const POST = withAdmin( } = postSchema.parse(await req.json()); const partner = await prisma.partner.findUnique({ - where: { id: partnerId }, - select: { id: true }, + where: { + id: partnerId, + }, + select: { + id: true, + }, }); if (!partner) { @@ -86,45 +90,36 @@ export const POST = withAdmin( }; } + const data = { + verifiedAt: new Date(), + platformId: verifiedData.platformId, + subscribers: verifiedData.subscribers, + posts: verifiedData.posts, + views: verifiedData.views, + avatarUrl: verifiedData.avatarUrl, + metadata: { + ...(verifiedData.metadata ?? {}), + manuallyVerifiedByAdmin: true, + manuallyVerifiedAt: new Date().toISOString(), + }, + lastCheckedAt: new Date(), + }; + const updated = await prisma.partnerPlatform.upsert({ where: { - partnerId_type: { + partnerId_type_identifier: { partnerId, type: platform, + identifier, }, }, create: { partnerId, type: platform, identifier, - verifiedAt: new Date(), - platformId: verifiedData.platformId, - subscribers: verifiedData.subscribers, - posts: verifiedData.posts, - views: verifiedData.views, - avatarUrl: verifiedData.avatarUrl, - metadata: { - ...(verifiedData.metadata ?? {}), - manuallyVerifiedByAdmin: true, - manuallyVerifiedAt: new Date().toISOString(), - }, - lastCheckedAt: new Date(), - }, - update: { - identifier, - verifiedAt: new Date(), - platformId: verifiedData.platformId, - subscribers: verifiedData.subscribers, - posts: verifiedData.posts, - views: verifiedData.views, - avatarUrl: verifiedData.avatarUrl, - metadata: { - ...(verifiedData.metadata ?? {}), - manuallyVerifiedByAdmin: true, - manuallyVerifiedAt: new Date().toISOString(), - }, - lastCheckedAt: new Date(), + ...data, }, + update: data, }); return NextResponse.json({ diff --git a/apps/web/app/(ee)/api/admin/partners/[partnerId]/shared-platforms/route.ts b/apps/web/app/(ee)/api/admin/partners/[partnerId]/shared-platforms/route.ts index 7db13bb4e38..d69f9bc671c 100644 --- a/apps/web/app/(ee)/api/admin/partners/[partnerId]/shared-platforms/route.ts +++ b/apps/web/app/(ee)/api/admin/partners/[partnerId]/shared-platforms/route.ts @@ -11,7 +11,9 @@ export const GET = withAdmin(async ({ params }) => { const { partnerId } = params; const partner = await prisma.partner.findUnique({ - where: { id: partnerId }, + where: { + id: partnerId, + }, include: { platforms: true, }, @@ -20,25 +22,19 @@ export const GET = withAdmin(async ({ params }) => { if (!partner) { return new Response("Partner not found.", { status: 404 }); } + const verifiedPlatforms = partner.platforms.filter( (platform) => platform.verifiedAt !== null, ); - // a partner has at most one platform per type, so there is at most one website - const websitePlatform = verifiedPlatforms.find( - (platform) => platform.type === "website", - ); - - const websiteDomain = websitePlatform - ? getDomainWithoutWWW(websitePlatform.identifier) ?? null - : null; - const matchConditions: Prisma.PartnerPlatformWhereInput[] = []; for (const platform of verifiedPlatforms) { if (platform.type === "website") { // website identifiers are full URLs with inconsistent normalization, // so match on the domain instead of the exact identifier + const websiteDomain = getDomainWithoutWWW(platform.identifier); + if (websiteDomain) { matchConditions.push({ identifier: { @@ -83,23 +79,30 @@ export const GET = withAdmin(async ({ params }) => { orderBy: { createdAt: "asc", }, - take: 10, + take: 50, }); const sharedPlatforms = verifiedPlatforms .map((platform) => { - let platformMatches = sharedPlatformMatches.filter( - (match) => match.type === platform.type, - ); + let platformMatches: typeof sharedPlatformMatches; - // "contains" matches on the domain need exact verification - // (e.g. contains "example.com" would also match "notexample.com") if (platform.type === "website") { - platformMatches = platformMatches.filter( + // "contains" matches on the domain need exact verification + // (e.g. contains "example.com" would also match "notexample.com") + const websiteDomain = getDomainWithoutWWW(platform.identifier); + + platformMatches = sharedPlatformMatches.filter( (match) => + match.type === platform.type && websiteDomain !== null && getDomainWithoutWWW(match.identifier) === websiteDomain, ); + } else { + platformMatches = sharedPlatformMatches.filter( + (match) => + match.type === platform.type && + match.identifier === platform.identifier, + ); } return { diff --git a/apps/web/app/(ee)/api/partners/platforms/callback/route.ts b/apps/web/app/(ee)/api/partners/platforms/callback/route.ts index 83e6e8c0c6a..6c5dd70a529 100644 --- a/apps/web/app/(ee)/api/partners/platforms/callback/route.ts +++ b/apps/web/app/(ee)/api/partners/platforms/callback/route.ts @@ -18,11 +18,12 @@ const requestSchema = z.object({ state: z.string(), }); -interface State { - platform: PlatformType; - partnerId: string; - source: "onboarding" | "settings"; -} +const stateSchema = z.object({ + platform: z.enum(PlatformType), + partnerId: z.string(), + identifier: z.string(), + source: z.enum(["onboarding", "settings"]), +}); // GET /api/partners/platforms/callback export async function GET(req: Request) { @@ -47,7 +48,7 @@ export async function GET(req: Request) { } // Find the state from Redis - const stateFromRedis = await redis.get( + const stateFromRedis = await redis.get>( `partnerSocialVerification:${state}`, ); @@ -56,7 +57,14 @@ export async function GET(req: Request) { return NextResponse.redirect(PARTNERS_DOMAIN); } - const { platform, partnerId, source } = stateFromRedis; + const parsedState = stateSchema.safeParse(stateFromRedis); + + if (!parsedState.success) { + console.warn("State is invalid."); + return NextResponse.redirect(PARTNERS_DOMAIN); + } + + const { platform, partnerId, identifier, source } = parsedState.data; if (session.user.defaultPartnerId !== partnerId) { console.warn("Unauthorized: User is not the default partner."); @@ -127,11 +135,16 @@ export async function GET(req: Request) { const partnerPlatform = await prisma.partnerPlatform.findUnique({ where: { - partnerId_type: { + partnerId_type_identifier: { partnerId, type: platform, + identifier, }, }, + select: { + id: true, + identifier: true, + }, }); if (!partnerPlatform || !partnerPlatform.identifier) { @@ -174,10 +187,7 @@ export async function GET(req: Request) { await prisma.partnerPlatform.update({ where: { - partnerId_type: { - partnerId, - type: platform, - }, + id: partnerPlatform.id, }, data: { ...socialStats, diff --git a/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-fields.tsx b/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-fields.tsx index c7113915bf1..8bae57307b1 100644 --- a/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-fields.tsx +++ b/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-fields.tsx @@ -209,14 +209,17 @@ export function EmbedSocialUrlField({ bounty, value, onChange, - partnerPlatform, + partnerPlatforms, onVerifyingChange, onRequirementsMetChange, }: { bounty: PartnerBountyProps; value: string; onChange: (v: string) => void; - partnerPlatform?: Pick; + partnerPlatforms?: Pick< + PartnerPlatformProps, + "type" | "identifier" | "verifiedAt" + >[]; onVerifyingChange: (value: boolean) => void; onRequirementsMetChange: (value: boolean) => void; }) { @@ -245,15 +248,8 @@ export function EmbedSocialUrlField({ const { isPostedFromYourAccount, isAfterStartDate } = evaluateSocialContentRequirements({ content: data, - bounty: { - startsAt: new Date(bounty.startsAt), - }, - partnerPlatform: { - identifier: partnerPlatform?.identifier ?? "", - verifiedAt: partnerPlatform?.verifiedAt - ? new Date(partnerPlatform.verifiedAt) - : null, - }, + bounty, + partnerPlatforms, }); useEffect(() => { diff --git a/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-form.tsx b/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-form.tsx index 2b19e1c6987..85f1b7534e9 100644 --- a/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-form.tsx +++ b/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-form.tsx @@ -2,10 +2,13 @@ import { getPeriodLabel } from "@/lib/bounty/periods"; import { resolveBountyDetails } from "@/lib/bounty/utils"; -import { PartnerBountyProps, PartnerBountySubmission } from "@/lib/types"; +import { + PartnerBountyProps, + PartnerBountySubmission, + PartnerPlatformProps, +} from "@/lib/types"; import { SocialAccountNotVerifiedWarning } from "@/ui/partners/bounties/bounty-social-content"; import { Button, ChevronRight, Popover, Trophy } from "@dub/ui"; -import { PlatformType } from "@prisma/client"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; @@ -75,11 +78,10 @@ export function EmbedBountySubmissionForm({ onSuccess, }: { bounty: PartnerBountyProps; - partnerPlatforms: Array<{ - type: PlatformType; - identifier: string; - verifiedAt: Date | null; - }>; + partnerPlatforms: Pick< + PartnerPlatformProps, + "type" | "identifier" | "verifiedAt" + >[]; periodNumber: number; existingSubmission?: PartnerBountySubmission | null; onCancel: () => void; @@ -90,9 +92,6 @@ export function EmbedBountySubmissionForm({ const token = useEmbedToken(); const bountyInfo = resolveBountyDetails(bounty); const isSocialMetricsBounty = bountyInfo?.hasSocialMetrics ?? false; - const partnerPlatform = bountyInfo?.socialPlatform - ? partnerPlatforms.find((p) => p.type === bountyInfo.socialPlatform?.value) - : undefined; const imageRequired = !!bounty.submissionRequirements?.image; const urlRequired = !!bounty.submissionRequirements?.url && !isSocialMetricsBounty; @@ -205,6 +204,10 @@ export function EmbedBountySubmissionForm({ } }; + const hasVerifiedPlatform = partnerPlatforms.some( + (p) => p.type === bountyInfo?.socialPlatform?.value && p.verifiedAt, + ); + return (
- {!partnerPlatform?.verifiedAt && ( + {!hasVerifiedPlatform && ( )} setUrls([v])} - partnerPlatform={partnerPlatform} + partnerPlatforms={partnerPlatforms} onVerifyingChange={setSocialContentVerifying} onRequirementsMetChange={setSocialContentRequirementsMet} /> diff --git a/apps/web/app/(ee)/partners.dub.co/(onboarding)/onboarding/platforms/page.tsx b/apps/web/app/(ee)/partners.dub.co/(onboarding)/onboarding/platforms/page.tsx index b0350d19d28..c743d139cdb 100644 --- a/apps/web/app/(ee)/partners.dub.co/(onboarding)/onboarding/platforms/page.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(onboarding)/onboarding/platforms/page.tsx @@ -1,10 +1,8 @@ import { getSession } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; -import { buildSocialPlatformLookup } from "@/lib/social-utils"; -import { PartnerPlatformProps } from "@/lib/types"; +import { buildSocialPlatformLookup, PLATFORM_ORDER } from "@/lib/social-utils"; import { partnerPlatformSchema } from "@/lib/zod/schemas/partners"; import { PartnerPlatformsForm } from "@/ui/partners/partner-platforms-form"; -import { PlatformType } from "@prisma/client"; import { Suspense } from "react"; import * as z from "zod/v4"; import { OnboardingPlatformsPageClient } from "./page-client"; @@ -82,23 +80,12 @@ async function OnboardingPlatformsFormRSC() { } // Merge social handles from a partner application into the partner platforms list. - const platforms: PartnerPlatformProps[] = z - .array(partnerPlatformSchema) - .parse(partner.platforms); + const platforms = z.array(partnerPlatformSchema).parse(partner.platforms); if (application) { const socialPlatformsMap = buildSocialPlatformLookup(platforms); - const APPLICATION_SOCIAL_PLATFORMS = [ - "website", - "youtube", - "twitter", - "linkedin", - "instagram", - "tiktok", - ] as const satisfies readonly PlatformType[]; - - for (const platform of APPLICATION_SOCIAL_PLATFORMS) { + for (const platform of PLATFORM_ORDER) { const handle = application[platform]; // Application-provided handles are added only if the partner does not already have that platform. diff --git a/apps/web/lib/actions/partners/start-partner-platform-verification.ts b/apps/web/lib/actions/partners/start-partner-platform-verification.ts index 5c6cf996c5f..53ebc28f513 100644 --- a/apps/web/lib/actions/partners/start-partner-platform-verification.ts +++ b/apps/web/lib/actions/partners/start-partner-platform-verification.ts @@ -9,8 +9,11 @@ import { upsertPartnerPlatform } from "@/lib/api/partner-profile/upsert-partner- import { generateOTP } from "@/lib/auth/utils"; import { extractEmailDomain } from "@/lib/email/extract-email-domain"; import { isGenericEmail } from "@/lib/is-generic-email"; +import { prisma } from "@/lib/prisma"; import { + MAX_PLATFORMS_PER_TYPE, sanitizeSocialHandle, + sanitizeWebsite, SOCIAL_PLATFORM_CONFIGS, } from "@/lib/social-utils"; import { PartnerProps } from "@/lib/types"; @@ -21,7 +24,7 @@ import { nanoid, PARTNERS_DOMAIN_WITH_NGROK, } from "@dub/utils"; -import { PlatformType } from "@prisma/client"; +import { PartnerPlatform, PlatformType } from "@prisma/client"; import { cookies } from "next/headers"; import { v4 as uuid } from "uuid"; import * as z from "zod/v4"; @@ -57,7 +60,17 @@ export const startPartnerPlatformVerificationAction = authPartnerActionClient .inputSchema(startPartnerPlatformVerificationSchema) .action(async ({ ctx, parsedInput }) => { const { partner } = ctx; - const { platform, handle, source } = parsedInput; + const { platform, handle: rawHandle, source } = parsedInput; + + // Normalize the handle so it matches what we store and use to look up rows + const handle = + platform === "website" + ? sanitizeWebsite(rawHandle) + : sanitizeSocialHandle(rawHandle, platform); + + if (!handle) { + throw new Error("Please enter a valid handle."); + } // Rate limit check const { success } = await ratelimit(5, "1 h").limit( @@ -70,6 +83,13 @@ export const startPartnerPlatformVerificationAction = authPartnerActionClient ); } + // Enforce the per-type limit before adding a new handle + await assertPartnerPlatformLimit({ + partnerId: partner.id, + type: platform, + identifier: handle, + }); + const params: VerificationParams = { partner, platform, @@ -120,15 +140,18 @@ async function startWebsiteVerification({ await upsertPartnerPlatform({ where: { partnerId: partner.id, - type: "website", + type: PlatformType.website, + identifier: handle, }, data: { - identifier: handle, verifiedAt: new Date(), metadata: {}, }, }); - return { type: "auto_verified" }; + + return { + type: "auto_verified", + }; } } @@ -137,10 +160,10 @@ async function startWebsiteVerification({ await upsertPartnerPlatform({ where: { partnerId: partner.id, - type: "website", + type: PlatformType.website, + identifier: handle, }, data: { - identifier: handle, verifiedAt: null, metadata: { websiteTxtRecord, @@ -158,7 +181,7 @@ async function startWebsiteVerification({ async function startOAuthVerification({ partner, platform, - handle: rawHandle, + handle, source, }: VerificationParams): Promise< Extract @@ -174,20 +197,14 @@ async function startOAuthVerification({ throw new Error(`Invalid platform: ${platform}`); } - const handle = sanitizeSocialHandle(rawHandle, platform); - - if (!handle) { - throw new Error(`Please enter a valid handle for ${platformConfig.name}.`); - } - // Store handle before OAuth redirect await upsertPartnerPlatform({ where: { partnerId: partner.id, type: platform, + identifier: handle, }, data: { - identifier: handle, verifiedAt: null, }, }); @@ -199,6 +216,7 @@ async function startOAuthVerification({ { platform, partnerId: partner.id, + identifier: handle, source, }, { @@ -243,7 +261,7 @@ async function startOAuthVerification({ async function startCodeVerification({ partner, platform, - handle: rawHandle, + handle, }: Pick): Promise< Extract > { @@ -253,12 +271,6 @@ async function startCodeVerification({ throw new Error(`Invalid platform: ${platform}`); } - const handle = sanitizeSocialHandle(rawHandle, platform); - - if (!handle) { - throw new Error(`Please enter a valid handle for ${platformConfig.name}.`); - } - const verificationCode = generateOTP(); const cacheKey = `social-verification:${partner.id}:${platform}:${handle}`; await redis.set(cacheKey, verificationCode, { @@ -269,9 +281,9 @@ async function startCodeVerification({ where: { partnerId: partner.id, type: platform, + identifier: handle, }, data: { - identifier: handle, verifiedAt: null, }, }); @@ -281,3 +293,34 @@ async function startCodeVerification({ verificationCode, }; } + +async function assertPartnerPlatformLimit({ + partnerId, + type, + identifier, +}: Pick) { + const existingPlatforms = await prisma.partnerPlatform.findMany({ + where: { + partnerId, + type, + }, + select: { + identifier: true, + }, + }); + + // Re-verifying / updating an existing handle is always allowed + const alreadyExists = existingPlatforms.some( + (p) => p.identifier === identifier, + ); + + if (alreadyExists) { + return; + } + + if (existingPlatforms.length >= MAX_PLATFORMS_PER_TYPE) { + throw new Error( + `You can add up to ${MAX_PLATFORMS_PER_TYPE} ${type} handles.`, + ); + } +} diff --git a/apps/web/lib/actions/partners/update-partner-platforms.ts b/apps/web/lib/actions/partners/update-partner-platforms.ts index 1649b0c891b..0cb7083eef0 100644 --- a/apps/web/lib/actions/partners/update-partner-platforms.ts +++ b/apps/web/lib/actions/partners/update-partner-platforms.ts @@ -1,192 +1,154 @@ "use server"; -import { upsertPartnerPlatform } from "@/lib/api/partner-profile/upsert-partner-platform"; +import { throwIfNoPermission } from "@/lib/auth/partner-users/throw-if-no-permission"; import { prisma } from "@/lib/prisma"; -import { sanitizeSocialHandle, sanitizeWebsite } from "@/lib/social-utils"; -import { parseUrlSchemaAllowEmpty } from "@/lib/zod/schemas/utils"; -import { getDomainWithoutWWW, getUrlFromString, isValidUrl } from "@dub/utils"; -import { PartnerPlatform, PlatformType } from "@prisma/client"; +import { + MAX_PLATFORMS_PER_TYPE, + sanitizeSocialHandle, + sanitizeWebsite, +} from "@/lib/social-utils"; +import { getPrettyUrl } from "@dub/utils"; +import { PartnerPlatform, PlatformType, Prisma } from "@prisma/client"; import * as z from "zod/v4"; import { authPartnerActionClient } from "../safe-action"; -/** - * Helper function to create a schema for social platform handles. - * Preserves undefined (ignore) and null (remove), sanitizes string values. - */ -const createSocialPlatformSchema = (platform: PlatformType) => { - return z - .string() - .nullish() - .transform((input) => { - if (input === undefined) return undefined; - if (input === null) return null; - return sanitizeSocialHandle(input, platform); - }); -}; - -/** - * Helper function to create a schema for website URLs. - * Preserves undefined (ignore) and null (remove), sanitizes string values. - */ -const createWebsiteSchema = () => { - return parseUrlSchemaAllowEmpty() - .nullish() - .transform((input) => { - if (input === undefined) return undefined; - if (input === null) return null; - return sanitizeWebsite(input); - }) - .refine( - (value) => { - return !value || isValidUrl(value); - }, - { - message: "Invalid website URL.", - }, - ); -}; - const updatePartnerPlatformsSchema = z.object({ - website: createWebsiteSchema(), - youtube: createSocialPlatformSchema("youtube"), - twitter: createSocialPlatformSchema("twitter"), - linkedin: createSocialPlatformSchema("linkedin"), - instagram: createSocialPlatformSchema("instagram"), - tiktok: createSocialPlatformSchema("tiktok"), - source: z.enum(["onboarding", "settings"]).default("onboarding"), + platforms: z.array( + z.object({ + type: z.enum(PlatformType), + identifier: z.string(), + }), + ), }); +// Normalize an identifier for the given platform type. +// Returns null when the value is empty (treated as "not present"). +// Throws when the value is non-empty but invalid. +function normalizeIdentifier(type: PlatformType, identifier: string) { + const trimmed = identifier?.trim(); + + if (!trimmed) { + return null; + } + + const sanitized = + type === "website" + ? sanitizeWebsite(trimmed) + : sanitizeSocialHandle(trimmed, type); + + if (!sanitized) { + throw new Error( + type === "website" + ? "Please enter a valid website URL." + : `Please enter a valid ${type} handle.`, + ); + } + + return sanitized; +} + +function platformKey(type: PlatformType, identifier: string) { + const canonical = type === "website" ? getPrettyUrl(identifier) : identifier; + + return `${type}:${canonical.toLowerCase()}`; +} + export const updatePartnerPlatformsAction = authPartnerActionClient .inputSchema(updatePartnerPlatformsSchema) .action(async ({ ctx, parsedInput }) => { - const { partner } = ctx; + const { partner, partnerUser } = ctx; + const { platforms } = parsedInput; - const partnerPlatform = await prisma.partnerPlatform.findMany({ - where: { - partnerId: partner.id, - }, + throwIfNoPermission({ + role: partnerUser.role, + permission: "partner_profile.update", }); - const platformIdentifiers = new Map( - partnerPlatform.map((p) => [p.type, p.identifier]), - ); + const seen = new Set(); + const submitted: Pick[] = []; + const countByType = new Map(); - const partnerPlatformsData: Pick< - PartnerPlatform, - "type" | "identifier" | "verifiedAt" - >[] = []; - - const platformsToDelete: Array<{ - partnerId: string; - type: PlatformType; - }> = []; - - // Define platform configurations - // null = remove, undefined = ignore (no changes) - const platformConfigs: Array<{ - platform: PlatformType; - inputValue: string | null | undefined; - }> = [ - { platform: "youtube", inputValue: parsedInput.youtube }, - { platform: "twitter", inputValue: parsedInput.twitter }, - { platform: "linkedin", inputValue: parsedInput.linkedin }, - { platform: "instagram", inputValue: parsedInput.instagram }, - { platform: "tiktok", inputValue: parsedInput.tiktok }, - { platform: "website", inputValue: parsedInput.website }, - ]; - - for (const { platform, inputValue } of platformConfigs) { - const currentIdentifier = platformIdentifiers.get(platform); - - // Handle deletion: null = remove - if (inputValue === null && currentIdentifier !== undefined) { - platformsToDelete.push({ - partnerId: partner.id, - type: platform, - }); + for (const { type, identifier } of platforms) { + const normalizedIdentifier = normalizeIdentifier(type, identifier); + + if (!normalizedIdentifier) { continue; } - // Handle update: non-null, non-undefined value that differs from current - if ( - inputValue !== undefined && - inputValue !== null && - inputValue !== currentIdentifier - ) { - // Special handling for website: check domain change - if (platform === "website") { - let domainChanged = false; - - try { - const oldDomain = getDomainWithoutWWW( - getUrlFromString(currentIdentifier ?? ""), - ); - const newDomain = getDomainWithoutWWW( - getUrlFromString(inputValue ?? ""), - ); - - domainChanged = oldDomain !== newDomain; - } catch (error) { - console.error("Failed to get domain from partner website", error); - domainChanged = true; - } - - if (domainChanged) { - partnerPlatformsData.push({ - type: "website", - identifier: inputValue, - verifiedAt: null, - }); - } - } else { - // For all other platforms, update if value changed - partnerPlatformsData.push({ - type: platform, - identifier: inputValue, - verifiedAt: null, - }); - } + const key = platformKey(type, normalizedIdentifier); + + if (seen.has(key)) { + continue; } - } - // Execute deletions and updates in parallel - const operations: Promise[] = []; + seen.add(key); + submitted.push({ + type, + identifier: normalizedIdentifier, + }); - if (platformsToDelete.length > 0) { - operations.push( - ...platformsToDelete.map(({ partnerId, type }) => - prisma.partnerPlatform.delete({ - where: { - partnerId_type: { - partnerId, - type, - }, - }, - }), - ), - ); + const nextCount = (countByType.get(type) ?? 0) + 1; + countByType.set(type, nextCount); + + if (nextCount > MAX_PLATFORMS_PER_TYPE) { + throw new Error( + `You can add up to ${MAX_PLATFORMS_PER_TYPE} ${type} handles.`, + ); + } } - if (partnerPlatformsData.length > 0) { - operations.push( - ...partnerPlatformsData.map((item) => - upsertPartnerPlatform({ + await prisma.$transaction( + async (tx) => { + const existingPlatforms = await tx.partnerPlatform.findMany({ + where: { + partnerId: partner.id, + }, + select: { + type: true, + identifier: true, + }, + }); + + const existingKeys = new Set( + existingPlatforms.map((p) => platformKey(p.type, p.identifier)), + ); + + // Delete existing rows that are no longer present in the submission + const toDelete = existingPlatforms.filter( + (p) => !seen.has(platformKey(p.type, p.identifier)), + ); + + // Create rows that are newly present. Existing rows are left untouched so + // their verification state (verifiedAt) is preserved. + const toCreate = submitted.filter( + (p) => !existingKeys.has(platformKey(p.type, p.identifier)), + ); + + if (toDelete.length > 0) { + await tx.partnerPlatform.deleteMany({ where: { partnerId: partner.id, - type: item.type, - }, - data: { - identifier: item.identifier, - verifiedAt: item.verifiedAt, + OR: toDelete.map(({ type, identifier }) => ({ + type, + identifier, + })), }, - }), - ), - ); - } - - if (operations.length === 0) { - return; - } + }); + } - await Promise.all(operations); + if (toCreate.length > 0) { + await tx.partnerPlatform.createMany({ + data: toCreate.map(({ type, identifier }) => ({ + partnerId: partner.id, + type, + identifier, + })), + skipDuplicates: true, + }); + } + }, + { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable, + }, + ); }); diff --git a/apps/web/lib/actions/partners/verify-partner-website.ts b/apps/web/lib/actions/partners/verify-partner-website.ts index ea6b814b21f..e7b7aa4b590 100644 --- a/apps/web/lib/actions/partners/verify-partner-website.ts +++ b/apps/web/lib/actions/partners/verify-partner-website.ts @@ -1,21 +1,41 @@ "use server"; import { prisma } from "@/lib/prisma"; +import { sanitizeWebsite } from "@/lib/social-utils"; import { getDomainWithoutWWW } from "@dub/utils"; +import { PlatformType } from "@prisma/client"; import dns from "dns"; +import * as z from "zod/v4"; import { authPartnerActionClient } from "../safe-action"; -export const verifyPartnerWebsiteAction = authPartnerActionClient.action( - async ({ ctx }) => { +const schema = z.object({ + identifier: z.string().min(1), +}); + +export const verifyPartnerWebsiteAction = authPartnerActionClient + .inputSchema(schema) + .action(async ({ ctx, parsedInput }) => { const { partner } = ctx; + const identifier = sanitizeWebsite(parsedInput.identifier); + + if (!identifier) { + throw new Error("Please provide a valid website."); + } + const partnerPlatform = await prisma.partnerPlatform.findUnique({ where: { - partnerId_type: { + partnerId_type_identifier: { partnerId: partner.id, - type: "website", + type: PlatformType.website, + identifier, }, }, + select: { + id: true, + identifier: true, + metadata: true, + }, }); if (!partnerPlatform || !partnerPlatform.identifier) { @@ -66,14 +86,10 @@ export const verifyPartnerWebsiteAction = authPartnerActionClient.action( await prisma.partnerPlatform.update({ where: { - partnerId_type: { - partnerId: partner.id, - type: "website", - }, + id: partnerPlatform.id, }, data: { verifiedAt: new Date(), }, }); - }, -); + }); diff --git a/apps/web/lib/actions/partners/verify-social-account-by-code.ts b/apps/web/lib/actions/partners/verify-social-account-by-code.ts index 10dbd4230a2..64b70e41fcb 100644 --- a/apps/web/lib/actions/partners/verify-social-account-by-code.ts +++ b/apps/web/lib/actions/partners/verify-social-account-by-code.ts @@ -3,6 +3,7 @@ import { getLinkedInPost } from "@/lib/api/scrape-creators/get-linkedin-post"; import { getSocialProfile } from "@/lib/api/scrape-creators/get-social-profile"; import { prisma } from "@/lib/prisma"; +import { sanitizeSocialHandle } from "@/lib/social-utils"; import { ratelimit } from "@/lib/upstash"; import { redis } from "@/lib/upstash/redis"; import { PlatformType } from "@prisma/client"; @@ -20,16 +21,22 @@ export const verifySocialAccountByCodeAction = authPartnerActionClient .inputSchema(schema) .action(async ({ ctx, parsedInput }) => { const { partner } = ctx; - const { platform, handle, postUrl } = parsedInput; + const { platform, handle: rawHandle, postUrl } = parsedInput; if (!["youtube", "instagram", "linkedin"].includes(platform)) { throw new Error("Only YouTube, Instagram, and LinkedIn are supported."); } - if (platform === "linkedin" && !postUrl) { + if (platform === PlatformType.linkedin && !postUrl) { throw new Error("Please provide the LinkedIn post URL."); } + const handle = sanitizeSocialHandle(rawHandle, platform); + + if (!handle) { + throw new Error("Please enter a valid handle."); + } + // Rate limit check const { success } = await ratelimit(5, "1 h").limit( `social-verification:${partner.id}:${platform}`, @@ -51,15 +58,16 @@ export const verifySocialAccountByCodeAction = authPartnerActionClient ); } - // Check if the account is already verified const partnerPlatform = await prisma.partnerPlatform.findUnique({ where: { - partnerId_type: { + partnerId_type_identifier: { partnerId: partner.id, type: platform, + identifier: handle, }, }, select: { + id: true, identifier: true, verifiedAt: true, }, @@ -70,10 +78,7 @@ export const verifySocialAccountByCodeAction = authPartnerActionClient } // No further action is needed if the account is already verified - if ( - partnerPlatform?.identifier?.toLowerCase() === handle.toLowerCase() && - partnerPlatform.verifiedAt - ) { + if (partnerPlatform.verifiedAt) { return; } @@ -108,10 +113,7 @@ export const verifySocialAccountByCodeAction = authPartnerActionClient await prisma.partnerPlatform.update({ where: { - partnerId_type: { - partnerId: partner.id, - type: platform, - }, + id: partnerPlatform.id, }, data: { verifiedAt: new Date(), @@ -149,10 +151,7 @@ export const verifySocialAccountByCodeAction = authPartnerActionClient await prisma.partnerPlatform.update({ where: { - partnerId_type: { - partnerId: partner.id, - type: platform, - }, + id: partnerPlatform.id, }, data: { verifiedAt: new Date(), diff --git a/apps/web/lib/api/fraud/rules/check-partner-email-domain-mismatch.ts b/apps/web/lib/api/fraud/rules/check-partner-email-domain-mismatch.ts index 50dd18ac804..48722fa4d38 100644 --- a/apps/web/lib/api/fraud/rules/check-partner-email-domain-mismatch.ts +++ b/apps/web/lib/api/fraud/rules/check-partner-email-domain-mismatch.ts @@ -1,5 +1,6 @@ import { PartnerProps } from "@/lib/types"; import { getDomainWithoutWWW } from "@dub/utils"; +import { PlatformType } from "@prisma/client"; function normalizeDomain(domain: string) { return domain @@ -16,9 +17,11 @@ export function checkPartnerEmailDomainMismatch( return false; } - const website = partner.platforms.find((p) => p.type === "website"); + const websites = partner.platforms.filter( + (p) => p.type === PlatformType.website && p.identifier, + ); - if (!website || !website.identifier) { + if (websites.length === 0) { return false; } @@ -28,11 +31,14 @@ export function checkPartnerEmailDomainMismatch( } const emailDomain = normalizeDomain(emailParts[1]); - const websiteDomain = getDomainWithoutWWW(website.identifier); - if (!websiteDomain) { + const websiteDomains = websites + .map((website) => getDomainWithoutWWW(website.identifier)?.toLowerCase()) + .filter((domain): domain is string => Boolean(domain)); + + if (websiteDomains.length === 0) { return false; } - return emailDomain !== websiteDomain.toLowerCase(); + return !websiteDomains.some((domain) => domain === emailDomain); } diff --git a/apps/web/lib/api/partner-profile/upsert-partner-platform.ts b/apps/web/lib/api/partner-profile/upsert-partner-platform.ts index 56b734f7c13..7a197c69adb 100644 --- a/apps/web/lib/api/partner-profile/upsert-partner-platform.ts +++ b/apps/web/lib/api/partner-profile/upsert-partner-platform.ts @@ -1,41 +1,34 @@ import { prisma } from "@/lib/prisma"; -import { PlatformType, Prisma } from "@prisma/client"; +import { PartnerPlatform, Prisma } from "@prisma/client"; type UpsertPartnerPlatformParams = { - where: { - partnerId: string; - type: PlatformType; - }; - data: Pick< - Prisma.PartnerPlatformCreateInput, - "identifier" | "verifiedAt" | "metadata" - >; + where: Pick; + data: Pick; }; export async function upsertPartnerPlatform({ where, data, }: UpsertPartnerPlatformParams) { - const { partnerId, type } = where; + const { partnerId, type, identifier } = where; return await prisma.partnerPlatform.upsert({ where: { - partnerId_type: { + partnerId_type_identifier: { partnerId, type, + identifier, }, }, create: { partnerId, type, - identifier: data.identifier, + identifier, verifiedAt: data.verifiedAt, metadata: data.metadata, }, update: { - identifier: data.identifier, - verifiedAt: data.verifiedAt, - metadata: data.metadata, + ...data, }, }); } diff --git a/apps/web/lib/bounty/api/create-bounty-submission.ts b/apps/web/lib/bounty/api/create-bounty-submission.ts index e652689e869..3db7f70a129 100644 --- a/apps/web/lib/bounty/api/create-bounty-submission.ts +++ b/apps/web/lib/bounty/api/create-bounty-submission.ts @@ -472,12 +472,13 @@ export class BountySubmissionHandler { }); } - const partnerPlatform = await prisma.partnerPlatform.findUnique({ + // A partner can have multiple handles of the same type, so gather all of + // them and require that the submitted content was published from one that + // is verified. + const partnerPlatforms = await prisma.partnerPlatform.findMany({ where: { - partnerId_type: { - partnerId: this.partner.id, - type: platform.value, - }, + partnerId: this.partner.id, + type: platform.value, }, select: { identifier: true, @@ -485,14 +486,16 @@ export class BountySubmissionHandler { }, }); - if (!partnerPlatform) { + if (partnerPlatforms.length === 0) { throw new DubApiError({ code: "unprocessable_entity", message: `You must connect your ${platform.label} account to your profile before submitting this bounty.`, }); } - if (!partnerPlatform.verifiedAt) { + const verifiedPlatforms = partnerPlatforms.filter((p) => p.verifiedAt); + + if (verifiedPlatforms.length === 0) { throw new DubApiError({ code: "unprocessable_entity", message: `You must verify your ${platform.label} account before submitting this bounty.`, @@ -512,13 +515,15 @@ export class BountySubmissionHandler { }); } - if ( - socialContent.handle.toLowerCase() !== - partnerPlatform.identifier.toLowerCase() - ) { + const contentHandle = socialContent.handle.toLowerCase(); + const hasVerifiedMatchingHandle = verifiedPlatforms.some( + (p) => p.identifier.toLowerCase() === contentHandle, + ); + + if (!hasVerifiedMatchingHandle) { throw new DubApiError({ code: "unprocessable_entity", - message: `The content was not published from your connected ${platform.label} account.`, + message: `The content was not published from a verified ${platform.label} account on your profile.`, }); } diff --git a/apps/web/lib/firstpromoter/import-partners.ts b/apps/web/lib/firstpromoter/import-partners.ts index 025071d3368..d05d33a07ea 100644 --- a/apps/web/lib/firstpromoter/import-partners.ts +++ b/apps/web/lib/firstpromoter/import-partners.ts @@ -161,10 +161,9 @@ async function createPartnerAndLinks({ where: { partnerId: partner.id, type: platform, - }, - data: { identifier: identifier!.trim(), }, + data: {}, }), ), ); diff --git a/apps/web/lib/network/get-network-approval-requirements.ts b/apps/web/lib/network/get-network-approval-requirements.ts index 80011065204..7a12d46c646 100644 --- a/apps/web/lib/network/get-network-approval-requirements.ts +++ b/apps/web/lib/network/get-network-approval-requirements.ts @@ -3,7 +3,6 @@ import { EXCLUDED_PROGRAM_IDS, PARTNER_NETWORK_MIN_COMMISSIONS_CENTS, } from "../constants/partner-profile"; -import { PARTNER_PLATFORM_FIELDS } from "../partners/partner-platforms"; import { EnrolledPartnerProps, PartnerProps } from "../types"; /** Program enrollments with totalCommissions as number | bigint (Prisma returns bigint). */ @@ -55,9 +54,7 @@ export function getNetworkApprovalRequirements({ label: "Verify at least 2 social accounts/website", href: "#platforms", completed: - PARTNER_PLATFORM_FIELDS.filter( - (field) => field.data(partner.platforms).verified, - ).length >= 2, + partner.platforms.filter((platform) => platform.verifiedAt).length >= 2, }, { label: "Fill out your profile description", diff --git a/apps/web/lib/partners/complete-program-applications.ts b/apps/web/lib/partners/complete-program-applications.ts index 1311e735e75..abdc23cff70 100644 --- a/apps/web/lib/partners/complete-program-applications.ts +++ b/apps/web/lib/partners/complete-program-applications.ts @@ -6,7 +6,11 @@ import { detectAndRecordFraudApplication } from "../api/fraud/detect-record-frau import { notifyPartnerApplication } from "../api/partners/notify-partner-application"; import { markApplicationEventSubmitted } from "../application-events/update-application-event"; import { qstash } from "../cron"; -import { buildSocialPlatformLookup } from "../social-utils"; +import { + buildSocialPlatformLookup, + sanitizeSocialHandle, + sanitizeWebsite, +} from "../social-utils"; import { sendWorkspaceWebhook } from "../webhook/publish"; import { partnerApplicationWebhookSchema } from "../zod/schemas/program-application"; import { evaluateApplicationRequirements } from "./evaluate-application-requirements"; @@ -238,13 +242,28 @@ export async function completeProgramApplications(userEmail: string) { // update the partner to use the website they applied with hasMissingSocialFields && prisma.partnerPlatform.createMany({ - data: Object.entries(missingSocialFields) - .filter(([, identifier]) => identifier !== undefined) - .map(([platform, identifier]) => ({ - partnerId: partner.id, - type: platform as PlatformType, - identifier: identifier as string, - })), + data: Object.entries(missingSocialFields).flatMap( + ([platform, identifier]) => { + const type = platform as PlatformType; + + const sanitized = + type === "website" + ? sanitizeWebsite(identifier) + : sanitizeSocialHandle(identifier, type); + + if (!sanitized) { + return []; + } + + return [ + { + partnerId: partner.id, + type, + identifier: sanitized, + }, + ]; + }, + ), skipDuplicates: true, }), diff --git a/apps/web/lib/partners/partner-platforms.ts b/apps/web/lib/partners/partner-platforms.ts index c72d6e4d26f..5b7c873b3f3 100644 --- a/apps/web/lib/partners/partner-platforms.ts +++ b/apps/web/lib/partners/partner-platforms.ts @@ -13,8 +13,22 @@ import { nFormatter, pluralize, } from "@dub/utils"; +import { PlatformType } from "@prisma/client"; +import { PLATFORM_ORDER, selectPrimaryPlatform } from "../social-utils"; import { PartnerPlatformProps } from "../types"; +const PARTNER_PLATFORM_META: Record< + PlatformType, + { label: string; icon: Icon } +> = { + website: { label: "Website", icon: Globe }, + youtube: { label: "YouTube", icon: YouTube }, + twitter: { label: "X/Twitter", icon: Twitter }, + linkedin: { label: "LinkedIn", icon: LinkedIn }, + instagram: { label: "Instagram", icon: Instagram }, + tiktok: { label: "TikTok", icon: TikTok }, +}; + export const PARTNER_PLATFORM_FIELDS: { label: string; icon: Icon; @@ -26,158 +40,156 @@ export const PARTNER_PLATFORM_FIELDS: { info?: string[]; stat?: string | null; }; -}[] = [ - { - label: "Website", - icon: Globe, - data: (platforms) => { - const website = platforms.find((p) => p.type === "website"); +}[] = PLATFORM_ORDER.map((type) => ({ + label: PARTNER_PLATFORM_META[type].label, + icon: PARTNER_PLATFORM_META[type].icon, + data: (platforms: PartnerPlatformProps[]) => { + const platform = selectPrimaryPlatform(platforms, type); + if (!platform) { 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, + value: null, + verified: false, + verifiedAt: null, + href: null, + info: [], + stat: null, }; - }, + } + + const { label, icon, ...data } = getPartnerPlatformDisplay(platform); + + return data; }, - { - label: "YouTube", - icon: YouTube, - data: (platforms) => { - const youtube = platforms.find((p) => p.type === "youtube"); +})); + +// Builds the display data (value, link, stats) for a single platform handle. +export function getPartnerPlatformDisplay(platform: PartnerPlatformProps) { + const { label, icon } = PARTNER_PLATFORM_META[platform.type]; + + const verified = !!platform.verifiedAt; + const verifiedAt = platform.verifiedAt ?? null; + const subscribers = Number(platform.subscribers ?? 0); + const posts = Number(platform.posts ?? 0); + const views = Number(platform.views ?? 0); + switch (platform.type) { + case "website": return { - value: youtube?.identifier ? `@${youtube.identifier}` : null, - verified: !!youtube?.verifiedAt, - verifiedAt: youtube?.verifiedAt ?? null, - href: youtube?.identifier - ? `https://youtube.com/@${youtube.identifier}` + label, + icon, + value: getPrettyUrl(platform.identifier), + verified, + verifiedAt, + href: platform.identifier + ? getUrlFromStringIfValid(platform.identifier) + : null, + info: [subscribers && verified ? `${subscribers} DR` : null].filter( + Boolean, + ) as string[], + stat: subscribers && verified ? `${subscribers} DR` : null, + }; + + case "youtube": + return { + label, + icon, + value: platform.identifier ? `@${platform.identifier}` : null, + verified, + verifiedAt, + href: platform.identifier + ? `https://youtube.com/@${platform.identifier}` : null, info: [ - youtube?.subscribers && youtube?.verifiedAt - ? `${nFormatter(Number(youtube.subscribers))} ${pluralize("subscriber", Number(youtube.subscribers))}` - : null, - youtube?.views && youtube?.verifiedAt - ? `${nFormatter(Number(youtube.views))} ${pluralize("view", Number(youtube.views))}` + subscribers && verified + ? `${nFormatter(subscribers)} ${pluralize("subscriber", subscribers)}` : null, - ].filter(Boolean), - stat: - youtube?.subscribers && youtube?.verifiedAt - ? nFormatter(Number(youtube.subscribers)) + views && verified + ? `${nFormatter(views)} ${pluralize("view", views)}` : null, + ].filter(Boolean) as string[], + stat: subscribers && verified ? nFormatter(subscribers) : null, }; - }, - }, - { - label: "X/Twitter", - icon: Twitter, - data: (platforms) => { - const twitter = platforms.find((p) => p.type === "twitter"); + case "twitter": return { - value: twitter ? `@${twitter.identifier}` : null, - verified: !!twitter?.verifiedAt, - verifiedAt: twitter?.verifiedAt ?? null, - href: twitter?.identifier - ? `https://x.com/${twitter.identifier}` + label, + icon, + value: platform.identifier ? `@${platform.identifier}` : null, + verified, + verifiedAt, + href: platform.identifier + ? `https://x.com/${platform.identifier}` : null, info: [ - twitter?.subscribers && twitter?.verifiedAt - ? `${nFormatter(Number(twitter.subscribers))} ${pluralize("follower", Number(twitter.subscribers))}` + subscribers && verified + ? `${nFormatter(subscribers)} ${pluralize("follower", subscribers)}` : null, - twitter?.posts && twitter?.verifiedAt - ? `${nFormatter(Number(twitter.posts))} ${pluralize("tweet", Number(twitter.posts))}` - : null, - ].filter(Boolean), - stat: - twitter?.subscribers && twitter?.verifiedAt - ? nFormatter(Number(twitter.subscribers)) + posts && verified + ? `${nFormatter(posts)} ${pluralize("tweet", posts)}` : null, + ].filter(Boolean) as string[], + stat: subscribers && verified ? nFormatter(subscribers) : null, }; - }, - }, - { - label: "LinkedIn", - icon: LinkedIn, - data: (platforms) => { - const linkedin = platforms.find((p) => p.type === "linkedin"); + case "linkedin": return { - value: linkedin ? linkedin.identifier : null, - verified: !!linkedin?.verifiedAt, - verifiedAt: linkedin?.verifiedAt ?? null, - href: linkedin?.identifier - ? `https://linkedin.com/in/${linkedin.identifier}` + label, + icon, + value: platform.identifier || null, + verified, + verifiedAt, + href: platform.identifier + ? `https://linkedin.com/in/${platform.identifier}` : null, + info: [ + subscribers && verified + ? `${nFormatter(subscribers)} ${pluralize("follower", subscribers)}` + : null, + ].filter(Boolean) as string[], + stat: subscribers && verified ? nFormatter(subscribers) : null, }; - }, - }, - { - label: "Instagram", - icon: Instagram, - data: (platforms) => { - const instagram = platforms.find((p) => p.type === "instagram"); + case "instagram": return { - value: instagram ? `@${instagram.identifier}` : null, - verified: !!instagram?.verifiedAt, - verifiedAt: instagram?.verifiedAt ?? null, - href: instagram?.identifier - ? `https://instagram.com/${instagram.identifier}` + label, + icon, + value: platform.identifier ? `@${platform.identifier}` : null, + verified, + verifiedAt, + href: platform.identifier + ? `https://instagram.com/${platform.identifier}` : null, info: [ - instagram?.subscribers && instagram?.verifiedAt - ? `${nFormatter(Number(instagram.subscribers))} ${pluralize("follower", Number(instagram.subscribers))}` - : null, - instagram?.posts && instagram?.verifiedAt - ? `${nFormatter(Number(instagram.posts))} ${pluralize("post", Number(instagram.posts))}` + subscribers && verified + ? `${nFormatter(subscribers)} ${pluralize("follower", subscribers)}` : null, - ].filter(Boolean), - stat: - instagram?.subscribers && instagram?.verifiedAt - ? nFormatter(Number(instagram.subscribers)) + posts && verified + ? `${nFormatter(posts)} ${pluralize("post", posts)}` : null, + ].filter(Boolean) as string[], + stat: subscribers && verified ? nFormatter(subscribers) : null, }; - }, - }, - { - label: "Tiktok", - icon: TikTok, - data: (platforms) => { - const tiktok = platforms.find((p) => p.type === "tiktok"); + case "tiktok": return { - value: tiktok ? `@${tiktok.identifier}` : null, - verified: !!tiktok?.verifiedAt, - verifiedAt: tiktok?.verifiedAt ?? null, - href: tiktok?.identifier - ? `https://tiktok.com/@${tiktok.identifier}` + label, + icon, + value: platform.identifier ? `@${platform.identifier}` : null, + verified, + verifiedAt, + href: platform.identifier + ? `https://tiktok.com/@${platform.identifier}` : null, info: [ - tiktok?.subscribers && tiktok?.verifiedAt - ? `${nFormatter(Number(tiktok.subscribers))} ${pluralize("follower", Number(tiktok.subscribers))}` - : null, - tiktok?.posts && tiktok?.verifiedAt - ? `${nFormatter(Number(tiktok.posts))} ${pluralize("post", Number(tiktok.posts))} ` + subscribers && verified + ? `${nFormatter(subscribers)} ${pluralize("follower", subscribers)}` : null, - ].filter(Boolean), - stat: - tiktok?.subscribers && tiktok?.verifiedAt - ? nFormatter(Number(tiktok.subscribers)) + posts && verified + ? `${nFormatter(posts)} ${pluralize("post", posts)}` : null, + ].filter(Boolean) as string[], + stat: subscribers && verified ? nFormatter(subscribers) : null, }; - }, - }, -]; + } +} diff --git a/apps/web/lib/social-utils.ts b/apps/web/lib/social-utils.ts index d19f8594ad5..4f0a8a2610b 100644 --- a/apps/web/lib/social-utils.ts +++ b/apps/web/lib/social-utils.ts @@ -1,5 +1,23 @@ import { getUrlFromStringIfValid } from "@dub/utils"; -import { PlatformType } from "@prisma/client"; +import { PartnerPlatform, PlatformType } from "@prisma/client"; + +type PrimarySelectablePlatform = Pick< + PartnerPlatform, + "type" | "identifier" | "verifiedAt" | "subscribers" +>; + +// Maximum number of handles a partner can add per platform type +export const MAX_PLATFORMS_PER_TYPE = 2; + +// Canonical display order for platform types +export const PLATFORM_ORDER: PlatformType[] = [ + "website", + "youtube", + "twitter", + "instagram", + "tiktok", + "linkedin", +]; interface SocialPlatformConfig { patterns: RegExp[]; @@ -80,7 +98,7 @@ export const sanitizeSocialHandle = ( .replace(/\?.*$/, "") // query params (e.g. ?s=21&t=...) .replace(/#.*$/, ""); // hash/fragment (e.g. #section) - const { patterns, allowedChars, allowedDomains, maxLength } = + const { patterns, allowedChars, maxLength } = SOCIAL_PLATFORM_CONFIGS[platform]; for (const pattern of patterns) { @@ -101,9 +119,59 @@ export const sanitizeSocialHandle = ( return handle || null; }; -// Converts an array of platform objects into a key-value object -// for easy lookup by platform name. Returns null for platforms not found. -export function buildSocialPlatformLookup( +// Prefer a verified handle, then the highest subscribers +function isBetterPrimary({ + candidate, + current, +}: { + candidate: PrimarySelectablePlatform; + current: PrimarySelectablePlatform; +}) { + const candidateVerified = candidate.verifiedAt ? 1 : 0; + const currentVerified = current.verifiedAt ? 1 : 0; + + if (candidateVerified !== currentVerified) { + return candidateVerified > currentVerified; + } + + const candidateSubs = Number(candidate.subscribers ?? 0); + const currentSubs = Number(current.subscribers ?? 0); + + if (candidateSubs !== currentSubs) { + return candidateSubs > currentSubs; + } + + return false; +} + +// Selects the single "primary" handle of a given type from a list that may +// contain multiple handles per type. +export function selectPrimaryPlatform( + platforms: T[], + type: PlatformType, +): T | undefined { + let primary: T | undefined; + + for (const platform of platforms) { + if (platform.type !== type) { + continue; + } + + if ( + !primary || + isBetterPrimary({ candidate: platform, current: primary }) + ) { + primary = platform; + } + } + + return primary; +} + +// Converts an array of platform objects into a key-value object for easy lookup +// by platform name. When a partner has multiple handles of the same type, the +// "primary" handle (verified > highest subscribers > first seen) is used. +export function buildSocialPlatformLookup( platforms: T[], ): Record { const result = { @@ -116,16 +184,26 @@ export function buildSocialPlatformLookup( } as Record; for (const platform of platforms) { - result[platform.type] = platform; + const current = result[platform.type]; + + if ( + !current || + isBetterPrimary({ + candidate: platform, + current, + }) + ) { + result[platform.type] = platform; + } } return result; } // Polyfills social media fields from platforms array for backward compatibility -export function polyfillSocialMediaFields< - T extends { type: PlatformType; identifier: string | null }, ->(platforms: T[]) { +export function polyfillSocialMediaFields( + platforms: T[], +) { const platformsMap = buildSocialPlatformLookup(platforms); return { diff --git a/apps/web/lib/swr/use-partner-profile.ts b/apps/web/lib/swr/use-partner-profile.ts index 86d43875445..86b547ff66e 100644 --- a/apps/web/lib/swr/use-partner-profile.ts +++ b/apps/web/lib/swr/use-partner-profile.ts @@ -26,19 +26,12 @@ export default function usePartnerProfile() { }, ); - const platformsVerified = partner?.platforms?.length - ? Object.fromEntries( - partner.platforms.map((p) => [p.type, p.verifiedAt != null]), - ) - : undefined; - const availablePayoutMethods = getPayoutMethodsForCountry({ country: partner?.country, }); return { partner, - platformsVerified, error, loading: status === "loading" || isLoading, mutate, diff --git a/apps/web/prisma/schema/platform.prisma b/apps/web/prisma/schema/platform.prisma index f3d5a285bdb..5bd469691fb 100644 --- a/apps/web/prisma/schema/platform.prisma +++ b/apps/web/prisma/schema/platform.prisma @@ -11,7 +11,7 @@ model PartnerPlatform { id String @id @default(cuid()) partnerId String type PlatformType - identifier String // The unique identifier for the platform (e.g., username for social platforms, domain for websites) + identifier String // The handle for the platform (e.g., username for social platforms, domain for websites) platformId String? // Platform-specific immutable ID (e.g., YouTube channel ID) avatarUrl String? @db.Text subscribers BigInt @default(0) // Subscribers/followers for social platforms, also for newsletter subscribers @@ -25,7 +25,7 @@ model PartnerPlatform { partner Partner @relation(fields: [partnerId], references: [id], onDelete: Cascade) - @@unique([partnerId, type]) + @@unique([partnerId, type, identifier]) @@index([type, verifiedAt, lastCheckedAt]) @@index(identifier) } diff --git a/apps/web/ui/messages/messages-panel.tsx b/apps/web/ui/messages/messages-panel.tsx index cf209c04ebd..58adce41b6f 100644 --- a/apps/web/ui/messages/messages-panel.tsx +++ b/apps/web/ui/messages/messages-panel.tsx @@ -1,4 +1,9 @@ -import { Message, MessageAttachment, PartnerProps, ProgramProps } from "@/lib/types"; +import { + Message, + MessageAttachment, + PartnerProps, + ProgramProps, +} from "@/lib/types"; import { AnimatedSizeContainer, Check2, diff --git a/apps/web/ui/modals/domain-verification-modal.tsx b/apps/web/ui/modals/domain-verification-modal.tsx index 898b8248501..de3ca9de910 100644 --- a/apps/web/ui/modals/domain-verification-modal.tsx +++ b/apps/web/ui/modals/domain-verification-modal.tsx @@ -3,13 +3,7 @@ import usePartnerProfile from "@/lib/swr/use-partner-profile"; import { Button, CopyButton, Modal } from "@dub/ui"; import { X } from "lucide-react"; import { useAction } from "next-safe-action/hooks"; -import { - Dispatch, - SetStateAction, - useCallback, - useMemo, - useState, -} from "react"; +import { Dispatch, SetStateAction } from "react"; import { toast } from "sonner"; interface DomainVerificationModalProps { @@ -17,6 +11,7 @@ interface DomainVerificationModalProps { setShowDomainVerificationModal: Dispatch>; domain: string; txtRecord: string; + identifier: string; } export function DomainVerificationModal(props: DomainVerificationModalProps) { @@ -34,6 +29,7 @@ function DomainVerificationModalInner({ setShowDomainVerificationModal, domain, txtRecord, + identifier, }: DomainVerificationModalProps) { const { mutate: mutatePartner } = usePartnerProfile(); @@ -91,44 +87,9 @@ function DomainVerificationModalInner({ text="Verify" className="h-8 w-fit px-3" loading={status === "executing" || status === "hasSucceeded"} - onClick={async () => await executeAsync()} + onClick={async () => await executeAsync({ identifier })} />
); } - -export function useDomainVerificationModal({ - domain, - txtRecord, -}: { - domain: string; - txtRecord: string; -}) { - const [showDomainVerificationModal, setShowDomainVerificationModal] = - useState(false); - - const DomainVerificationModalCallback = useCallback(() => { - return ( - - ); - }, [ - showDomainVerificationModal, - setShowDomainVerificationModal, - domain, - txtRecord, - ]); - - return useMemo( - () => ({ - setShowDomainVerificationModal, - DomainVerificationModal: DomainVerificationModalCallback, - }), - [setShowDomainVerificationModal, DomainVerificationModalCallback], - ); -} diff --git a/apps/web/ui/partners/bounties/bounty-social-content.tsx b/apps/web/ui/partners/bounties/bounty-social-content.tsx index 6a84281d397..40c44513ff7 100644 --- a/apps/web/ui/partners/bounties/bounty-social-content.tsx +++ b/apps/web/ui/partners/bounties/bounty-social-content.tsx @@ -22,18 +22,11 @@ function SocialContentRequirementChecks({ }) { const { partner } = usePartnerProfile(); - const bountyInfo = resolveBountyDetails(bounty); - const socialPlatform = bountyInfo?.socialPlatform; - - const partnerPlatform = partner?.platforms?.find( - (p) => p.type === socialPlatform?.value, - ); - const { isPostedFromYourAccount, isAfterStartDate } = evaluateSocialContentRequirements({ content, bounty, - partnerPlatform, + partnerPlatforms: partner?.platforms, }); return ( @@ -103,16 +96,11 @@ export function SocialContentUrlField({ return () => setSocialContentVerifying(false); }, [isValidating, setSocialContentVerifying]); - const bountyInfo = resolveBountyDetails(bounty); - const partnerPlatform = partner?.platforms?.find( - (p) => p.type === bountyInfo?.socialPlatform?.value, - ); - useEffect(() => { const checks = evaluateSocialContentRequirements({ content: data, bounty, - partnerPlatform, + partnerPlatforms: partner?.platforms, }); setSocialContentRequirementsMet( @@ -120,9 +108,10 @@ export function SocialContentUrlField({ ); return () => setSocialContentRequirementsMet(true); - }, [data, bounty, partnerPlatform, setSocialContentRequirementsMet]); + }, [data, bounty, partner?.platforms, setSocialContentRequirementsMet]); const showIcon = isValidating || (error && urlToCheck); + const bountyInfo = resolveBountyDetails(bounty); if (!bountyInfo?.socialPlatform) { return null; diff --git a/apps/web/ui/partners/bounties/evaluate-social-content-requirements.ts b/apps/web/ui/partners/bounties/evaluate-social-content-requirements.ts index b7424be3040..daf970bc586 100644 --- a/apps/web/ui/partners/bounties/evaluate-social-content-requirements.ts +++ b/apps/web/ui/partners/bounties/evaluate-social-content-requirements.ts @@ -1,3 +1,4 @@ +import { resolveBountyDetails } from "@/lib/bounty/utils"; import { PartnerBountyProps, PartnerPlatformProps, @@ -8,17 +9,29 @@ import { isBefore } from "date-fns/isBefore"; export function evaluateSocialContentRequirements({ content, bounty, - partnerPlatform, + partnerPlatforms, }: { content?: SocialContent | null; - bounty: Pick; - partnerPlatform?: Pick; + bounty: Pick; + partnerPlatforms?: Pick< + PartnerPlatformProps, + "identifier" | "verifiedAt" | "type" + >[]; }) { - const isPostedFromYourAccount = - !!content && - !!partnerPlatform && - !!partnerPlatform.verifiedAt && - partnerPlatform.identifier.toLowerCase() === content.handle?.toLowerCase(); + const bountyInfo = resolveBountyDetails(bounty); + const bountySocialPlatform = bountyInfo?.socialPlatform?.value; + const contentHandle = content?.handle?.toLowerCase(); + + const verifiedPlatforms = partnerPlatforms?.filter( + (platform) => platform.type === bountySocialPlatform && platform.verifiedAt, + ); + + // The content must be posted from one of the partner's verified handles + const hasVerifiedMatchingHandle = verifiedPlatforms?.some( + (platform) => platform.identifier.toLowerCase() === contentHandle, + ); + + const isPostedFromYourAccount = !!content && !!hasVerifiedMatchingHandle; const isAfterStartDate = !!content?.publishedAt && diff --git a/apps/web/ui/partners/partner-about.tsx b/apps/web/ui/partners/partner-about.tsx index 2af8482a711..21df0c849ff 100644 --- a/apps/web/ui/partners/partner-about.tsx +++ b/apps/web/ui/partners/partner-about.tsx @@ -48,7 +48,6 @@ export function PartnerAbout({ diff --git a/apps/web/ui/partners/partner-platform-summary.tsx b/apps/web/ui/partners/partner-platform-summary.tsx index 4b2e983e93a..c4bd536ea97 100644 --- a/apps/web/ui/partners/partner-platform-summary.tsx +++ b/apps/web/ui/partners/partner-platform-summary.tsx @@ -1,8 +1,10 @@ -import { PARTNER_PLATFORM_FIELDS } from "@/lib/partners/partner-platforms"; +import { getPartnerPlatformDisplay } from "@/lib/partners/partner-platforms"; +import { PLATFORM_ORDER } from "@/lib/social-utils"; import { PartnerPlatformProps, PartnerSharedPlatformProps } from "@/lib/types"; import { AnimatedSizeContainer, useCurrentSubdomain } from "@dub/ui"; -import { cn, fetcher } from "@dub/utils"; -import { Fragment, useMemo, useState } from "react"; +import { fetcher } from "@dub/utils"; +import { PlatformType } from "@prisma/client"; +import { Fragment, useState } from "react"; import { toast } from "sonner"; import useSWR, { useSWRConfig } from "swr"; import { PartnerPlatformCard } from "./partner-platform-card"; @@ -11,11 +13,9 @@ import { PartnerPlatformSharedPartners } from "./partner-platform-shared-partner export function PartnerPlatformSummary({ platforms, partnerId, - className, }: { platforms: PartnerPlatformProps[] | undefined; partnerId: string; - className?: string; }) { const { subdomain } = useCurrentSubdomain(); const { mutate } = useSWRConfig(); @@ -28,50 +28,30 @@ export function PartnerPlatformSummary({ ); const [verifyingPlatforms, setVerifyingPlatforms] = useState< - Partial> + Record >({}); - const fieldByType = useMemo( - () => - ({ - website: PARTNER_PLATFORM_FIELDS[0], - youtube: PARTNER_PLATFORM_FIELDS[1], - twitter: PARTNER_PLATFORM_FIELDS[2], - linkedin: PARTNER_PLATFORM_FIELDS[3], - instagram: PARTNER_PLATFORM_FIELDS[4], - tiktok: PARTNER_PLATFORM_FIELDS[5], - }) as Record< - PartnerPlatformProps["type"], - (typeof PARTNER_PLATFORM_FIELDS)[number] - >, - [], - ); - if (!platforms || platforms.length === 0) { return ( -
+
No platforms connected
); } - const fieldData = (Object.keys(fieldByType) as PartnerPlatformProps["type"][]) - .map((type) => { - const field = fieldByType[type]; - const platform = platforms.find((p) => p.type === type); - const data = field.data(platforms); - return { - type, - label: field.label, - icon: field.icon, - identifier: platform?.identifier ?? null, - ...data, - }; - }) - .filter((field) => field.value && field.href && field.identifier); + // One entry per handle, grouped by platform type in display order + const platformRows = [...platforms] + .sort( + (a, b) => PLATFORM_ORDER.indexOf(a.type) - PLATFORM_ORDER.indexOf(b.type), + ) + .map((platform) => ({ + platform, + ...getPartnerPlatformDisplay(platform), + })) + .filter((row) => row.value && row.href && row.platform.identifier); const handleVerifyPlatform = async ( - platform: PartnerPlatformProps["type"], + platform: PlatformType, identifier: string, label: string, ) => { @@ -79,7 +59,9 @@ export function PartnerPlatformSummary({ return; } - setVerifyingPlatforms((prev) => ({ ...prev, [platform]: true })); + const verifyingKey = `${platform}:${identifier}`; + + setVerifyingPlatforms((prev) => ({ ...prev, [verifyingKey]: true })); try { const response = await fetch( @@ -108,34 +90,22 @@ export function PartnerPlatformSummary({ error instanceof Error ? error.message : "Failed to verify platform.", ); } finally { - setVerifyingPlatforms((prev) => ({ ...prev, [platform]: false })); + setVerifyingPlatforms((prev) => ({ ...prev, [verifyingKey]: false })); } }; return ( -
- {fieldData.map( - ({ - type, - label, - icon: Icon, - value, - verified, - href, - info, - identifier, - }) => { +
+ {platformRows.map( + ({ platform, label, icon: Icon, value, verified, href, info }) => { const sharedPlatform = sharedPlatforms?.find( - (platform) => platform.type === type, + (shared) => + shared.type === platform.type && + shared.identifier === platform.identifier, ); return ( - +
- handleVerifyPlatform(type, identifier as string, label), - isVerifying: Boolean(verifyingPlatforms[type]), + handleVerifyPlatform( + platform.type, + platform.identifier, + label, + ), + isVerifying: Boolean( + verifyingPlatforms[ + `${platform.type}:${platform.identifier}` + ], + ), })} /> diff --git a/apps/web/ui/partners/partner-platforms-form.tsx b/apps/web/ui/partners/partner-platforms-form.tsx index 2f8cb0fd482..8f23f7ae072 100644 --- a/apps/web/ui/partners/partner-platforms-form.tsx +++ b/apps/web/ui/partners/partner-platforms-form.tsx @@ -4,50 +4,63 @@ import { parseActionError } from "@/lib/actions/parse-action-errors"; import { startPartnerPlatformVerificationAction } from "@/lib/actions/partners/start-partner-platform-verification"; import { updatePartnerPlatformsAction } from "@/lib/actions/partners/update-partner-platforms"; import { hasPermission } from "@/lib/auth/partner-users/partner-user-permissions"; -import { sanitizeSocialHandle, sanitizeWebsite } from "@/lib/social-utils"; +import { getPartnerPlatformDisplay } from "@/lib/partners/partner-platforms"; +import { + MAX_PLATFORMS_PER_TYPE, + PLATFORM_ORDER, + sanitizeSocialHandle, + sanitizeWebsite, +} from "@/lib/social-utils"; import usePartnerProfile from "@/lib/swr/use-partner-profile"; import { PartnerPlatformProps, PartnerProps } from "@/lib/types"; -import { parseUrlSchemaAllowEmpty } from "@/lib/zod/schemas/utils"; import { DomainVerificationModal } from "@/ui/modals/domain-verification-modal"; import { SocialVerificationByCodeModal } from "@/ui/modals/social-verification-by-code-modal"; import { - AnimatedSizeContainer, Button, - CircleCheckFill, Globe, Icon, Instagram, LinkedIn, + Popover, TikTok, Twitter, YouTube, } from "@dub/ui"; -import { getPrettyUrl, nFormatter } from "@dub/utils"; +import { getPrettyUrl } from "@dub/utils"; import { cn } from "@dub/utils/src/functions"; import { PlatformType } from "@prisma/client"; +import { Command } from "cmdk"; +import { Plus } from "lucide-react"; import { useAction } from "next-safe-action/hooks"; -import { forwardRef, ReactNode, useCallback, useMemo, useState } from "react"; +import { forwardRef, useMemo, useState } from "react"; import { FormProvider, + useFieldArray, useForm, useFormContext, useWatch, } from "react-hook-form"; import { toast } from "sonner"; import { mutate } from "swr"; -import * as z from "zod/v4"; import { PartnerPlatformCard } from "./partner-platform-card"; -const onlinePresenceSchema = z.object({ - website: parseUrlSchemaAllowEmpty().nullish(), - youtube: z.string().nullish(), - twitter: z.string().nullish(), - linkedin: z.string().nullish(), - instagram: z.string().nullish(), - tiktok: z.string().nullish(), -}); +type PlatformInputConfig = { + label: string; + icon: Icon; + domainLabel?: string; + innerAtPrefix?: boolean; + cardPrefix?: string; + placeholder: string; +}; + +type PlatformRow = { + type: PlatformType; + identifier: string; +}; -type PartnerPlatformsFormData = z.infer; +type PartnerPlatformsFormData = { + platforms: PlatformRow[]; +}; interface PartnerPlatformsFormProps { variant?: "onboarding" | "settings"; @@ -55,20 +68,117 @@ interface PartnerPlatformsFormProps { onSubmitSuccessful?: () => void; } -// Helper function to get platform data from platforms array -function getPlatformData( +const PLATFORM_CONFIG: Record = { + website: { + label: "Website", + icon: Globe, + placeholder: "example.com", + }, + youtube: { + label: "YouTube", + icon: YouTube, + domainLabel: "youtube.com", + innerAtPrefix: true, + cardPrefix: "@", + placeholder: "handle", + }, + twitter: { + label: "X/Twitter", + icon: Twitter, + domainLabel: "x.com", + cardPrefix: "@", + placeholder: "handle", + }, + instagram: { + label: "Instagram", + icon: Instagram, + domainLabel: "instagram.com", + cardPrefix: "@", + placeholder: "handle", + }, + tiktok: { + label: "TikTok", + icon: TikTok, + domainLabel: "tiktok.com", + innerAtPrefix: true, + cardPrefix: "@", + placeholder: "handle", + }, + linkedin: { + label: "LinkedIn", + icon: LinkedIn, + domainLabel: "linkedin.com/in", + cardPrefix: "in/", + placeholder: "handle", + }, +}; + +// Normalize a value the same way the server does, so we can match against +// stored identifiers. +function normalizeForType(type: PlatformType, value: string | undefined) { + if (!value) { + return null; + } + return type === "website" + ? sanitizeWebsite(value) + : sanitizeSocialHandle(value, type); +} + +// Find the partner's stored platform that matches a given row value. +function findMatchingPlatform( platforms: PartnerPlatformProps[] | undefined, - platform: PlatformType, + type: PlatformType, + value: string | undefined, ): PartnerPlatformProps | undefined { - return platforms?.find((p) => p.type === platform); + const normalized = normalizeForType(type, value); + if (!normalized) { + return undefined; + } + + return platforms?.find((p) => { + if (p.type !== type) { + return false; + } + + if (type === "website") { + return ( + getPrettyUrl(p.identifier).toLowerCase() === + getPrettyUrl(normalized).toLowerCase() + ); + } + + return p.identifier.toLowerCase() === normalized.toLowerCase(); + }); } -// Helper function to get identifier from platforms array -function getPlatformIdentifier( +// Seed form rows: one row per existing handle, plus one empty base row for any +// platform type the partner has no handle for (so all six are always visible). +function getDefaultPlatformRows( partner: PartnerPlatformsFormProps["partner"], - platform: PlatformType, -): string | undefined { - return getPlatformData(partner?.platforms, platform)?.identifier; +): PlatformRow[] { + const platforms = partner?.platforms ?? []; + const rows: PlatformRow[] = []; + + for (const type of PLATFORM_ORDER) { + const handles = platforms.filter((p) => p.type === type); + + if (handles.length === 0) { + rows.push({ type, identifier: "" }); + continue; + } + + for (const handle of handles) { + rows.push({ + type, + identifier: + type === "website" + ? getPrettyUrl(handle.identifier) + : handle.identifier, + }); + } + } + + return rows; } /** @@ -80,14 +190,7 @@ export function usePartnerPlatformsForm({ }: Pick) { return useForm({ defaultValues: { - website: getPlatformIdentifier(partner, "website") - ? getPrettyUrl(getPlatformIdentifier(partner, "website")!) - : undefined, - youtube: getPlatformIdentifier(partner, "youtube") || undefined, - twitter: getPlatformIdentifier(partner, "twitter") || undefined, - linkedin: getPlatformIdentifier(partner, "linkedin") || undefined, - instagram: getPlatformIdentifier(partner, "instagram") || undefined, - tiktok: getPlatformIdentifier(partner, "tiktok") || undefined, + platforms: getDefaultPlatformRows(partner), }, }); } @@ -118,14 +221,18 @@ export const PartnerPlatformsForm = forwardRef< : true; const { - register, + control, getValues, handleSubmit, reset, - setValue, - formState: { errors, isSubmitting, isSubmitSuccessful }, + formState: { isSubmitting, isSubmitSuccessful }, } = form; + const { fields, append, remove } = useFieldArray({ + control, + name: "platforms", + }); + const { executeAsync } = useAction(updatePartnerPlatformsAction, { onSuccess: async () => { toast.success( @@ -148,6 +255,7 @@ export const PartnerPlatformsForm = forwardRef< const [domainVerificationData, setDomainVerificationData] = useState<{ domain: string; txtRecord: string; + identifier: string; } | null>(null); const [socialVerificationData, setSocialVerificationData] = useState<{ @@ -156,80 +264,87 @@ export const PartnerPlatformsForm = forwardRef< verificationCode: string; } | null>(null); - const { - executeAsync: startSocialVerification, - isPending: isStartingSocialVerification, - } = useAction(startPartnerPlatformVerificationAction, { - onSuccess: async ({ input, data }) => { - if (!input || !data) { - return; - } - - // For website: auto-verified when email domain matches website domain - if (data.type === "auto_verified") { - toast.success( - "Website automatically verified because your email domain matches the website domain.", - ); - await mutate("/api/partner-profile"); - return; - } - - // For website verification (TXT record) - if (data.type === "txt_record") { - const websiteUrl = input.handle.startsWith("http") - ? input.handle - : `https://${input.handle}`; - - setDomainVerificationData({ - domain: new URL(websiteUrl).hostname, - txtRecord: data.websiteTxtRecord, - }); - } + const [verifyingIndex, setVerifyingIndex] = useState(null); + + const { executeAsync: startSocialVerification } = useAction( + startPartnerPlatformVerificationAction, + { + onSuccess: async ({ input, data }) => { + if (!input || !data) { + return; + } + + // For website: auto-verified when email domain matches website domain + if (data.type === "auto_verified") { + toast.success( + "Website automatically verified because your email domain matches the website domain.", + ); + await mutate("/api/partner-profile"); + return; + } + + // For website verification (TXT record) + if (data.type === "txt_record") { + const websiteUrl = input.handle.startsWith("http") + ? input.handle + : `https://${input.handle}`; + + setDomainVerificationData({ + domain: new URL(websiteUrl).hostname, + txtRecord: data.websiteTxtRecord, + identifier: input.handle, + }); + } + + // For OAuth flow + else if (data.type === "oauth") { + window.location.href = data.oauthUrl; + } + + // For verification code flow + else if (data.type === "verification_code") { + setSocialVerificationData({ + platform: input.platform, + handle: input.handle, + verificationCode: data.verificationCode, + }); + } + }, + onError: ({ error }) => { + toast.error(parseActionError(error, "Failed to start verification.")); + }, + }, + ); - // For OAuth flow - else if (data.type === "oauth") { - window.location.href = data.oauthUrl; - } + const countByType = useMemo(() => { + const counts: Partial> = {}; + for (const row of fields) { + counts[row.type] = (counts[row.type] ?? 0) + 1; + } + return counts; + }, [fields]); - // For verification code flow - else if (data.type === "verification_code") { - setSocialVerificationData({ - platform: input.platform, - handle: input.handle, - verificationCode: data.verificationCode, - }); - } - }, - onError: ({ error }) => { - toast.error(parseActionError(error, "Failed to start verification.")); - }, - }); + const onVerifyRow = async (index: number, type: PlatformType) => { + const handle = getValues(`platforms.${index}.identifier`); - const onPasteWebsite = useCallback( - (e: React.ClipboardEvent) => { - const text = e.clipboardData.getData("text/plain"); - const sanitized = sanitizeWebsite(text); + if (!handle) { + return; + } - if (sanitized) { - setValue("website", sanitized); - e.preventDefault(); - } - }, - [setValue], - ); + setVerifyingIndex(index); - const onPasteSocial = useCallback( - (e: React.ClipboardEvent, platform: PlatformType) => { - const text = e.clipboardData.getData("text/plain"); - const sanitized = sanitizeSocialHandle(text, platform); + const result = await startSocialVerification({ + platform: type, + handle, + source: variant, + }); - if (sanitized) { - setValue(platform, sanitized); - e.preventDefault(); - } - }, - [setValue], - ); + // Keep the spinner on while redirecting to an OAuth provider + const redirecting = result?.data?.type === "oauth"; + if (!redirecting) { + setVerifyingIndex(null); + } + }; return ( <> @@ -237,6 +352,7 @@ export const PartnerPlatformsForm = forwardRef< setDomainVerificationData(null) @@ -266,276 +382,27 @@ export const PartnerPlatformsForm = forwardRef< >
- { - const website = getValues("website"); - - if (website) { - await startSocialVerification({ - platform: "website", - handle: website, - source: variant, - }); - } - - return isStartingSocialVerification; - }} - input={ - - } - variant={variant} - /> - - { - const handle = getValues("youtube"); - - if (handle) { - await startSocialVerification({ - platform: "youtube", - handle, - source: variant, - }); - } - - return isStartingSocialVerification; - }} - input={ -
- - youtube.com - -
- - @ - - onPasteSocial(e, "youtube")} - {...register("youtube")} - /> -
-
- } - variant={variant} - /> - - { - const handle = getValues("twitter"); - - if (handle) { - await startSocialVerification({ - platform: "twitter", - handle, - source: variant, - }); - } - - return isStartingSocialVerification; - }} - input={ -
- - x.com - - onPasteSocial(e, "twitter")} - {...register("twitter")} - /> -
- } - variant={variant} - /> - - { - const handle = getValues("instagram"); - - if (handle) { - await startSocialVerification({ - platform: "instagram", - handle, - source: variant, - }); - } - - return isStartingSocialVerification; - }} - input={ -
- - instagram.com - - onPasteSocial(e, "instagram")} - {...register("instagram")} - /> -
- } - variant={variant} - /> - - { - const handle = getValues("tiktok"); - - if (handle) { - await startSocialVerification({ - platform: "tiktok", - handle, - source: variant, - }); - } - - return isStartingSocialVerification; - }} - input={ -
- - tiktok.com - -
- - @ - - onPasteSocial(e, "tiktok")} - {...register("tiktok")} - /> -
-
- } - variant={variant} - /> + {fields.map((field, index) => ( + onVerifyRow(index, field.type)} + onRemove={() => remove(index)} + /> + ))} - { - const handle = getValues("linkedin"); - - if (handle) { - await startSocialVerification({ - platform: "linkedin", - handle, - source: variant, - }); - } - - return isStartingSocialVerification; - }} - input={ -
- - linkedin.com/in - - onPasteSocial(e, "linkedin")} - {...register("linkedin")} - /> -
- } - variant={variant} + countByType={countByType} + onAdd={(type) => append({ type, identifier: "" })} />
@@ -555,281 +422,214 @@ export const PartnerPlatformsForm = forwardRef< }, ); -function useVerifiedState({ - property, +function AddPlatformButton({ + disabled, + countByType, + onAdd, }: { - property: keyof PartnerPlatformsFormData; + disabled: boolean; + countByType: Partial>; + onAdd: (type: PlatformType) => void; }) { - const { partner: partnerProfile } = usePartnerProfile(); - - const { watch, getFieldState } = useFormContext(); - - const value = watch(property); - const isValid = !!value && !getFieldState(property).invalid; + const [openPopover, setOpenPopover] = useState(false); - const loading = !partnerProfile && isValid; - - // Map form property to PlatformType enum - const platformMap: Record = { - website: "website", - youtube: "youtube", - twitter: "twitter", - linkedin: "linkedin", - instagram: "instagram", - tiktok: "tiktok", - }; - - const platform = platformMap[property]; - const currentHandle = getPlatformIdentifier(partnerProfile, platform); - - const noChange = - property === "website" - ? getPrettyUrl(currentHandle ?? "") === getPrettyUrl(value ?? "") - : currentHandle === value; - - const platformData = getPlatformData(partnerProfile?.platforms, platform); - const isVerified = noChange && Boolean(platformData?.verifiedAt); - - return { - isVerified, - loading, - }; -} - -function VerifyButton({ - property, - icon: Icon, - onClick, - disabledTooltip, - disabled: formDisabled = false, -}: { - property: keyof PartnerPlatformsFormData; - icon: Icon; - onClick: () => Promise; - disabledTooltip?: string; - disabled?: boolean; -}) { - const { control, getFieldState } = useFormContext(); - const value = useWatch({ control, name: property }); - - const { isVerified, loading } = useVerifiedState({ - property, - }); - - const [isSaving, setIsSaving] = useState(false); + if (disabled) { + return null; + } return ( -
); } -function FormRow({ - label, - input, - property, - prefix, - icon: Icon, - onVerifyClick, - verifyDisabledTooltip, +function PlatformFormRow({ + index, + type, variant, - disabled = false, + disabled, + isVerifying, + onVerify, + onRemove, }: { - label: string; - input: ReactNode; - property: keyof PartnerPlatformsFormData; - prefix?: string; - icon: Icon; - onVerifyClick: () => Promise; - verifyDisabledTooltip?: string; + index: number; + type: PlatformType; variant: "onboarding" | "settings"; - disabled?: boolean; + disabled: boolean; + isVerifying: boolean; + onVerify: () => void; + onRemove: () => void; }) { + const config = PLATFORM_CONFIG[type]; const { partner } = usePartnerProfile(); - const { control, setValue } = useFormContext(); - const value = useWatch({ control, name: property }); - - const { isVerified } = useVerifiedState({ property }); - - const info = useMemo(() => { - if (partner && isVerified) { - // Type assertion for platforms array that exists at runtime but not in type - const partnerWithPlatforms = partner as typeof partner & { - 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, - "youtube", - ); - const subscribers = youtubePlatform?.subscribers ?? 0; - const views = youtubePlatform?.views ?? 0; - - return [ - subscribers > 0 - ? `${nFormatter(Number(subscribers))} subscribers` - : null, - views > 0 ? `${nFormatter(Number(views))} views` : null, - ].filter(Boolean) as string[]; - } - - if (property === "instagram") { - const instagramPlatform = getPlatformData( - partnerWithPlatforms.platforms, - "instagram", - ); - const subscribers = instagramPlatform?.subscribers ?? 0; - const posts = instagramPlatform?.posts ?? 0; - - return [ - subscribers > 0 - ? `${nFormatter(Number(subscribers))} followers` - : null, - posts > 0 ? `${nFormatter(Number(posts))} posts` : null, - ].filter(Boolean) as string[]; - } + const { register, control, setValue } = + useFormContext(); - if (property === "tiktok") { - const tiktokPlatform = getPlatformData( - partnerWithPlatforms.platforms, - "tiktok", - ); - const subscribers = tiktokPlatform?.subscribers ?? 0; - const posts = tiktokPlatform?.posts ?? 0; - - return [ - subscribers > 0 - ? `${nFormatter(Number(subscribers))} followers` - : null, - posts > 0 ? `${nFormatter(Number(posts))} posts` : null, - ].filter(Boolean) as string[]; - } + const value = useWatch({ + control, + name: `platforms.${index}.identifier`, + }); - if (property === "twitter") { - const twitterPlatform = getPlatformData( - partnerWithPlatforms.platforms, - "twitter", - ); - const subscribers = twitterPlatform?.subscribers ?? 0; - const posts = twitterPlatform?.posts ?? 0; - - return [ - subscribers > 0 - ? `${nFormatter(Number(subscribers))} followers` - : null, - posts > 0 ? `${nFormatter(Number(posts))} tweets` : null, - ].filter(Boolean) as string[]; - } + const matchedPlatform = useMemo( + () => + findMatchingPlatform( + (partner as typeof partner & { platforms?: PartnerPlatformProps[] }) + ?.platforms, + type, + value, + ), + [partner, type, value], + ); - if (property === "linkedin") { - const linkedinPlatform = getPlatformData( - partnerWithPlatforms.platforms, - "linkedin", - ); + const info = matchedPlatform + ? getPartnerPlatformDisplay(matchedPlatform).info + : undefined; - const subscribers = linkedinPlatform?.subscribers ?? 0; + const onPaste = (e: React.ClipboardEvent) => { + const text = e.clipboardData.getData("text/plain"); + const sanitized = + type === "website" + ? sanitizeWebsite(text) + : sanitizeSocialHandle(text, type); - return [ - subscribers > 0 - ? `${nFormatter(Number(subscribers))} followers` - : null, - ].filter(Boolean) as string[]; - } + if (sanitized) { + setValue(`platforms.${index}.identifier`, sanitized); + e.preventDefault(); } - return null; - }, [partner, property, isVerified]); + }; + + if (matchedPlatform?.verifiedAt) { + return ( +
+ {variant === "onboarding" && ( + + {config.label} + + )} + 0 ? info : undefined} + onRemove={disabled ? undefined : onRemove} + /> +
+ ); + } return ( -
- -
- {isVerified ? ( -
- + {variant === "onboarding" && ( + + {config.label} + + )} +
+ {config.domainLabel ? ( +
+ + {config.domainLabel} + +
+ {config.innerAtPrefix && ( + + @ + + )} + - {label} - - setValue(property, null, { shouldDirty: true })} + placeholder={config.placeholder} + onPaste={onPaste} + {...register(`platforms.${index}.identifier`)} />
- ) : ( - - )} -
- +
+ ) : ( + + )} + +
); }