From ef2d2cc0af0bbd6aa5e2fad5e127e48ccbc067e7 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Thu, 26 Mar 2026 12:06:58 -0700 Subject: [PATCH 01/12] update scripts --- .../web/lib/middleware/utils/get-final-url.ts | 2 +- .../scripts/cal/backfill-referral-links.ts | 142 --------- .../annature/import-domains.ts | 0 .../buffer/delete-old-links.ts | 0 .../buffer/migrate-to-case-sensitive.ts | 4 +- .../framer/1-process-framer-combined.ts | 0 .../framer/2-sort-lead-events-by-date.ts | 0 .../framer/3-backfill-tb-events.ts | 0 .../framer/backfill-commissions.ts | 0 .../framer/check-pending-payout-totals.ts | 0 .../framer/get-links-to-backfill.ts | 0 .../framer/get-remaining-links-to-backfill.ts | 0 .../framer/mark-commissions-paid.ts | 0 .../framer/mark-commissions-pending.ts | 0 .../framer/process-lead-events.ts | 0 .../framer/tally-commissions.ts | 0 .../perplexity/backfill-leads.ts | 0 .../perplexity/backfill-tenantids.ts | 0 .../perplexity/ban-partners.ts | 0 .../perplexity/deactivate-partners.ts | 0 .../perplexity/move-partners.ts | 0 .../perplexity/partners-updated-countries.ts | 0 .../perplexity/review-bounties.ts | 0 .../perplexity/update-commissions.ts | 0 .../perplexity/update-notifications.ts | 0 .../export-invoicecs-by-stripe-id.ts | 63 ++++ .../import-customers-by-stripe-id.ts | 279 ++++++++++++++++++ .../tella/remind-applications.ts | 0 .../tella/update-commission-flat.ts | 0 .../tella/update-commission-percentage.ts | 0 .../tella/update-commissions.ts | 0 .../tella/update-reward-tier.ts | 0 .../wispr-flow/update-links.ts | 2 +- apps/web/scripts/ship30/backfill-leads.ts | 260 ---------------- .../testimonial/final-sync-commissions.ts | 64 ---- .../scripts/testimonial/sync-commissions.ts | 72 ----- .../scripts/testimonial/update-commissions.ts | 58 ---- 37 files changed, 346 insertions(+), 600 deletions(-) delete mode 100644 apps/web/scripts/cal/backfill-referral-links.ts rename apps/web/scripts/{ => customers}/annature/import-domains.ts (100%) rename apps/web/scripts/{ => customers}/buffer/delete-old-links.ts (100%) rename apps/web/scripts/{ => customers}/buffer/migrate-to-case-sensitive.ts (94%) rename apps/web/scripts/{ => customers}/framer/1-process-framer-combined.ts (100%) rename apps/web/scripts/{ => customers}/framer/2-sort-lead-events-by-date.ts (100%) rename apps/web/scripts/{ => customers}/framer/3-backfill-tb-events.ts (100%) rename apps/web/scripts/{ => customers}/framer/backfill-commissions.ts (100%) rename apps/web/scripts/{ => customers}/framer/check-pending-payout-totals.ts (100%) rename apps/web/scripts/{ => customers}/framer/get-links-to-backfill.ts (100%) rename apps/web/scripts/{ => customers}/framer/get-remaining-links-to-backfill.ts (100%) rename apps/web/scripts/{ => customers}/framer/mark-commissions-paid.ts (100%) rename apps/web/scripts/{ => customers}/framer/mark-commissions-pending.ts (100%) rename apps/web/scripts/{ => customers}/framer/process-lead-events.ts (100%) rename apps/web/scripts/{ => customers}/framer/tally-commissions.ts (100%) rename apps/web/scripts/{ => customers}/perplexity/backfill-leads.ts (100%) rename apps/web/scripts/{ => customers}/perplexity/backfill-tenantids.ts (100%) rename apps/web/scripts/{ => customers}/perplexity/ban-partners.ts (100%) rename apps/web/scripts/{ => customers}/perplexity/deactivate-partners.ts (100%) rename apps/web/scripts/{ => customers}/perplexity/move-partners.ts (100%) rename apps/web/scripts/{ => customers}/perplexity/partners-updated-countries.ts (100%) rename apps/web/scripts/{ => customers}/perplexity/review-bounties.ts (100%) rename apps/web/scripts/{ => customers}/perplexity/update-commissions.ts (100%) rename apps/web/scripts/{ => customers}/perplexity/update-notifications.ts (100%) create mode 100644 apps/web/scripts/customers/scaledmail/export-invoicecs-by-stripe-id.ts create mode 100644 apps/web/scripts/customers/scaledmail/import-customers-by-stripe-id.ts rename apps/web/scripts/{ => customers}/tella/remind-applications.ts (100%) rename apps/web/scripts/{ => customers}/tella/update-commission-flat.ts (100%) rename apps/web/scripts/{ => customers}/tella/update-commission-percentage.ts (100%) rename apps/web/scripts/{ => customers}/tella/update-commissions.ts (100%) rename apps/web/scripts/{ => customers}/tella/update-reward-tier.ts (100%) rename apps/web/scripts/{ => customers}/wispr-flow/update-links.ts (91%) delete mode 100644 apps/web/scripts/ship30/backfill-leads.ts delete mode 100644 apps/web/scripts/testimonial/final-sync-commissions.ts delete mode 100644 apps/web/scripts/testimonial/sync-commissions.ts delete mode 100644 apps/web/scripts/testimonial/update-commissions.ts diff --git a/apps/web/lib/middleware/utils/get-final-url.ts b/apps/web/lib/middleware/utils/get-final-url.ts index c39b3ddd08f..e4a082a5cf2 100644 --- a/apps/web/lib/middleware/utils/get-final-url.ts +++ b/apps/web/lib/middleware/utils/get-final-url.ts @@ -38,7 +38,7 @@ export const getFinalUrl = ( if (clickId) { /* - custom query param for stripe payment links + Dub Conversions + custom query param for stripe payment links: - if there is a clickId and dub_client_reference_id is 1 - then set client_reference_id to dub_id_${clickId} and drop the dub_client_reference_id param - our Stripe integration will then detect `dub_id_${clickId}` as the dubClickId in the `checkout.session.completed` webhook diff --git a/apps/web/scripts/cal/backfill-referral-links.ts b/apps/web/scripts/cal/backfill-referral-links.ts deleted file mode 100644 index d603adb5c1b..00000000000 --- a/apps/web/scripts/cal/backfill-referral-links.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { PlanProps } from "@/lib/types"; -import { prisma } from "@dub/prisma"; -import "dotenv-flow/config"; -import * as fs from "fs"; -import * as Papa from "papaparse"; -import { includeTags } from "../../lib/api/links/include-tags"; -import { createAndEnrollPartner } from "../../lib/api/partners/create-and-enroll-partner"; -import { recordLink } from "../../lib/tinybird/record-link"; - -interface BackfillLinkProp { - externalId: string; - key: string; - name: string; - email: string; - avatar?: string; -} - -const linksToBackfill: BackfillLinkProp[] = []; - -async function main() { - const { workspace, ...program } = await prisma.program.findUniqueOrThrow({ - where: { - id: "prog_mODHMDrJPWlkpT7uzsUASFhK", - }, - include: { - workspace: true, - }, - }); - - Papa.parse(fs.createReadStream("refer_cal_links_backfilled.csv", "utf-8"), { - header: true, - skipEmptyLines: true, - step: (result: { data: BackfillLinkProp }) => { - linksToBackfill.push(result.data); - }, - complete: async () => { - const batch = linksToBackfill.slice(0, 50); - - const finalResults: { - name: string; - email: string; - clicks: number; - leads: number; - sales: number; - }[] = []; - - await Promise.all( - batch.map(async (l) => { - const link = await prisma.link.findUnique({ - where: { - domain_key: { - domain: "refer.cal.com", - key: l.key, - }, - }, - }); - - if (!link) { - console.log("Link not found", l.email); - return; - } - - if (link.partnerId) { - console.log("Partner already enrolled", l.email); - return; - } - - try { - const res = await createAndEnrollPartner({ - workspace: { - ...workspace, - plan: workspace.plan as PlanProps, - webhookEnabled: false, - }, - program, - partner: { - name: l.name, - email: l.email, - image: l.avatar && l.avatar.length < 190 ? l.avatar : undefined, - tenantId: l.externalId, - }, - // @ts-ignore - link, - userId: "", - skipEnrollmentCheck: true, - }); - - finalResults.push({ - name: res.name, - email: res.email ?? "", - clicks: res.totalClicks, - leads: res.totalLeads, - sales: res.totalSales, - }); - - // remove row from csv - linksToBackfill.splice(linksToBackfill.indexOf(l), 1); - fs.writeFileSync( - "refer_cal_links_backfilled.csv", - Papa.unparse(linksToBackfill), - "utf-8", - ); - } catch (e) { - if (e.message.includes("already enrolled")) { - const partner = await prisma.partner.update({ - where: { - email: l.email, - }, - data: { - name: l.name, - image: l.avatar ?? undefined, - }, - }); - - await prisma.link - .update({ - where: { - id: link.id, - }, - data: { - programId: program.id, - partnerId: partner.id, - tenantId: l.externalId, - folderId: program.defaultFolderId, - trackConversion: true, - }, - include: includeTags, - }) - .then((link) => recordLink(link)); - } else { - console.error(e, l.email); - } - } - }), - ); - - console.table(finalResults); - }, - }); -} - -main(); diff --git a/apps/web/scripts/annature/import-domains.ts b/apps/web/scripts/customers/annature/import-domains.ts similarity index 100% rename from apps/web/scripts/annature/import-domains.ts rename to apps/web/scripts/customers/annature/import-domains.ts diff --git a/apps/web/scripts/buffer/delete-old-links.ts b/apps/web/scripts/customers/buffer/delete-old-links.ts similarity index 100% rename from apps/web/scripts/buffer/delete-old-links.ts rename to apps/web/scripts/customers/buffer/delete-old-links.ts diff --git a/apps/web/scripts/buffer/migrate-to-case-sensitive.ts b/apps/web/scripts/customers/buffer/migrate-to-case-sensitive.ts similarity index 94% rename from apps/web/scripts/buffer/migrate-to-case-sensitive.ts rename to apps/web/scripts/customers/buffer/migrate-to-case-sensitive.ts index 778b8ee98d0..62264f0ab87 100644 --- a/apps/web/scripts/buffer/migrate-to-case-sensitive.ts +++ b/apps/web/scripts/customers/buffer/migrate-to-case-sensitive.ts @@ -2,8 +2,8 @@ import { prisma } from "@dub/prisma"; import { Prisma } from "@dub/prisma/client"; import { linkConstructorSimple } from "@dub/utils"; import "dotenv-flow/config"; -import { linkCache } from "../../lib/api/links/cache"; -import { encodeKeyIfCaseSensitive } from "../../lib/api/links/case-sensitivity"; +import { linkCache } from "../../../lib/api/links/cache"; +import { encodeKeyIfCaseSensitive } from "../../../lib/api/links/case-sensitivity"; const domain = "buff.ly"; const userId = "user_EzRuKzR9sG3WmHapVV6aEec7"; diff --git a/apps/web/scripts/framer/1-process-framer-combined.ts b/apps/web/scripts/customers/framer/1-process-framer-combined.ts similarity index 100% rename from apps/web/scripts/framer/1-process-framer-combined.ts rename to apps/web/scripts/customers/framer/1-process-framer-combined.ts diff --git a/apps/web/scripts/framer/2-sort-lead-events-by-date.ts b/apps/web/scripts/customers/framer/2-sort-lead-events-by-date.ts similarity index 100% rename from apps/web/scripts/framer/2-sort-lead-events-by-date.ts rename to apps/web/scripts/customers/framer/2-sort-lead-events-by-date.ts diff --git a/apps/web/scripts/framer/3-backfill-tb-events.ts b/apps/web/scripts/customers/framer/3-backfill-tb-events.ts similarity index 100% rename from apps/web/scripts/framer/3-backfill-tb-events.ts rename to apps/web/scripts/customers/framer/3-backfill-tb-events.ts diff --git a/apps/web/scripts/framer/backfill-commissions.ts b/apps/web/scripts/customers/framer/backfill-commissions.ts similarity index 100% rename from apps/web/scripts/framer/backfill-commissions.ts rename to apps/web/scripts/customers/framer/backfill-commissions.ts diff --git a/apps/web/scripts/framer/check-pending-payout-totals.ts b/apps/web/scripts/customers/framer/check-pending-payout-totals.ts similarity index 100% rename from apps/web/scripts/framer/check-pending-payout-totals.ts rename to apps/web/scripts/customers/framer/check-pending-payout-totals.ts diff --git a/apps/web/scripts/framer/get-links-to-backfill.ts b/apps/web/scripts/customers/framer/get-links-to-backfill.ts similarity index 100% rename from apps/web/scripts/framer/get-links-to-backfill.ts rename to apps/web/scripts/customers/framer/get-links-to-backfill.ts diff --git a/apps/web/scripts/framer/get-remaining-links-to-backfill.ts b/apps/web/scripts/customers/framer/get-remaining-links-to-backfill.ts similarity index 100% rename from apps/web/scripts/framer/get-remaining-links-to-backfill.ts rename to apps/web/scripts/customers/framer/get-remaining-links-to-backfill.ts diff --git a/apps/web/scripts/framer/mark-commissions-paid.ts b/apps/web/scripts/customers/framer/mark-commissions-paid.ts similarity index 100% rename from apps/web/scripts/framer/mark-commissions-paid.ts rename to apps/web/scripts/customers/framer/mark-commissions-paid.ts diff --git a/apps/web/scripts/framer/mark-commissions-pending.ts b/apps/web/scripts/customers/framer/mark-commissions-pending.ts similarity index 100% rename from apps/web/scripts/framer/mark-commissions-pending.ts rename to apps/web/scripts/customers/framer/mark-commissions-pending.ts diff --git a/apps/web/scripts/framer/process-lead-events.ts b/apps/web/scripts/customers/framer/process-lead-events.ts similarity index 100% rename from apps/web/scripts/framer/process-lead-events.ts rename to apps/web/scripts/customers/framer/process-lead-events.ts diff --git a/apps/web/scripts/framer/tally-commissions.ts b/apps/web/scripts/customers/framer/tally-commissions.ts similarity index 100% rename from apps/web/scripts/framer/tally-commissions.ts rename to apps/web/scripts/customers/framer/tally-commissions.ts diff --git a/apps/web/scripts/perplexity/backfill-leads.ts b/apps/web/scripts/customers/perplexity/backfill-leads.ts similarity index 100% rename from apps/web/scripts/perplexity/backfill-leads.ts rename to apps/web/scripts/customers/perplexity/backfill-leads.ts diff --git a/apps/web/scripts/perplexity/backfill-tenantids.ts b/apps/web/scripts/customers/perplexity/backfill-tenantids.ts similarity index 100% rename from apps/web/scripts/perplexity/backfill-tenantids.ts rename to apps/web/scripts/customers/perplexity/backfill-tenantids.ts diff --git a/apps/web/scripts/perplexity/ban-partners.ts b/apps/web/scripts/customers/perplexity/ban-partners.ts similarity index 100% rename from apps/web/scripts/perplexity/ban-partners.ts rename to apps/web/scripts/customers/perplexity/ban-partners.ts diff --git a/apps/web/scripts/perplexity/deactivate-partners.ts b/apps/web/scripts/customers/perplexity/deactivate-partners.ts similarity index 100% rename from apps/web/scripts/perplexity/deactivate-partners.ts rename to apps/web/scripts/customers/perplexity/deactivate-partners.ts diff --git a/apps/web/scripts/perplexity/move-partners.ts b/apps/web/scripts/customers/perplexity/move-partners.ts similarity index 100% rename from apps/web/scripts/perplexity/move-partners.ts rename to apps/web/scripts/customers/perplexity/move-partners.ts diff --git a/apps/web/scripts/perplexity/partners-updated-countries.ts b/apps/web/scripts/customers/perplexity/partners-updated-countries.ts similarity index 100% rename from apps/web/scripts/perplexity/partners-updated-countries.ts rename to apps/web/scripts/customers/perplexity/partners-updated-countries.ts diff --git a/apps/web/scripts/perplexity/review-bounties.ts b/apps/web/scripts/customers/perplexity/review-bounties.ts similarity index 100% rename from apps/web/scripts/perplexity/review-bounties.ts rename to apps/web/scripts/customers/perplexity/review-bounties.ts diff --git a/apps/web/scripts/perplexity/update-commissions.ts b/apps/web/scripts/customers/perplexity/update-commissions.ts similarity index 100% rename from apps/web/scripts/perplexity/update-commissions.ts rename to apps/web/scripts/customers/perplexity/update-commissions.ts diff --git a/apps/web/scripts/perplexity/update-notifications.ts b/apps/web/scripts/customers/perplexity/update-notifications.ts similarity index 100% rename from apps/web/scripts/perplexity/update-notifications.ts rename to apps/web/scripts/customers/perplexity/update-notifications.ts diff --git a/apps/web/scripts/customers/scaledmail/export-invoicecs-by-stripe-id.ts b/apps/web/scripts/customers/scaledmail/export-invoicecs-by-stripe-id.ts new file mode 100644 index 00000000000..e109fc411bc --- /dev/null +++ b/apps/web/scripts/customers/scaledmail/export-invoicecs-by-stripe-id.ts @@ -0,0 +1,63 @@ +import { prisma } from "@dub/prisma"; +import "dotenv-flow/config"; +import * as fs from "fs"; +import * as Papa from "papaparse"; +import { stripeAppClient } from "../../../lib/stripe"; + +const programId = "prog_xxx"; +const customerIdsToImport: string[] = []; + +// script to export stripe invoices based on the customer's stripeCustomerId +async function main() { + console.log(`Found ${customerIdsToImport.length} paying customers`); + const program = await prisma.program.findUniqueOrThrow({ + where: { + id: programId, + }, + include: { + workspace: true, + }, + }); + + console.log(`Found ${customerIdsToImport.length} paying customers`); + + for (const customerId of customerIdsToImport) { + const invoices = await stripeAppClient({ + mode: "live", + }).invoices.list( + { + customer: customerId, + status: "paid", + }, + { + stripeAccount: program.workspace.stripeConnectId!, + }, + ); + + const invoicesToBackfill = invoices.data.map((invoice) => ({ + customerExternalId: customerId, + invoiceId: invoice.id, + amountPaid: invoice.amount_paid, + createdAt: new Date(invoice.created * 1000).toISOString(), + })); + + if (invoicesToBackfill.length > 0) { + console.table(invoicesToBackfill, [ + "customerExternalId", + "invoiceId", + "amountPaid", + "createdAt", + ]); + + const filePath = "scaledmail_customer_stripe_invoices.csv"; + const fileExists = fs.existsSync(filePath); + + fs.appendFileSync( + filePath, + Papa.unparse(invoicesToBackfill, { header: !fileExists }) + "\n", + ); + } + } +} + +main(); diff --git a/apps/web/scripts/customers/scaledmail/import-customers-by-stripe-id.ts b/apps/web/scripts/customers/scaledmail/import-customers-by-stripe-id.ts new file mode 100644 index 00000000000..f9b5f1c943d --- /dev/null +++ b/apps/web/scripts/customers/scaledmail/import-customers-by-stripe-id.ts @@ -0,0 +1,279 @@ +import { createId } from "@/lib/api/create-id"; +import { prisma } from "@dub/prisma"; +import { Prisma } from "@dub/prisma/client"; +import { chunk, nanoid, prettyPrint } from "@dub/utils"; +import "dotenv-flow/config"; +import { syncPartnerLinksStats } from "../../../lib/api/partners/sync-partner-links-stats"; +import { stripeAppClient } from "../../../lib/stripe"; +import { recordLeadWithTimestamp } from "../../../lib/tinybird/record-lead"; + +const programId = "prog_xxx"; +const linkId = "link_xxx"; +const customerIdsToImport: string[] = []; + +// script to backfill customers + leads +// we also use a batching logic for tinybird events ingestion +async function main() { + const program = await prisma.program.findUniqueOrThrow({ + where: { + id: programId, + }, + include: { + workspace: true, + }, + }); + const { workspace } = program; + + const link = await prisma.link.findUniqueOrThrow({ + where: { + id: linkId, + }, + }); + + const existingCustomers = await prisma.customer.findMany({ + where: { + stripeCustomerId: { + in: customerIdsToImport, + }, + }, + }); + + const finalCustomerIdsToImport = customerIdsToImport.filter( + (customerId) => + !existingCustomers.some( + (customer) => customer.stripeCustomerId === customerId, + ), + ); + + let finalLeadsToBackfill: { + customerExternalId: string; + stripeCustomerId: string; + name: string | null; + email: string | null; + country: string; + timestamp: Date; + }[] = []; + + for (const customerId of finalCustomerIdsToImport) { + const customer = await stripeAppClient({ + mode: "live", + }).customers.retrieve(customerId, { + stripeAccount: program.workspace.stripeConnectId!, + }); + + console.log(customer); + if (customer.deleted) { + continue; + } + + finalLeadsToBackfill.push({ + customerExternalId: customer.id, + stripeCustomerId: customerId, + name: customer.name ?? null, + email: customer.email, + country: customer.address?.country ?? "US", + timestamp: new Date(customer.created * 1000), + }); + } + + console.log(`Found ${finalLeadsToBackfill.length} leads to backfill`); + console.table(finalLeadsToBackfill.slice(0, 10)); + + const clicksToCreate = finalLeadsToBackfill + .map((lead) => { + const clickId = nanoid(16); + + return { + timestamp: new Date(lead.timestamp).toISOString(), + identity_hash: lead.customerExternalId, + click_id: clickId, + workspace_id: workspace.id, + link_id: link.id, + domain: link.domain, + key: link.key, + url: link.url, + ip: "", + continent: "NA", + country: lead.country, + region: "Unknown", + city: "Unknown", + latitude: "Unknown", + longitude: "Unknown", + vercel_region: "", + device: "Desktop", + device_vendor: "Unknown", + device_model: "Unknown", + browser: "Unknown", + browser_version: "Unknown", + engine: "Unknown", + engine_version: "Unknown", + os: "Unknown", + os_version: "Unknown", + cpu_architecture: "Unknown", + ua: "Unknown", + bot: 0, + qr: 0, + referer: "(direct)", + referer_url: "(direct)", + trigger: "link", + }; + }) + .filter((p): p is NonNullable => p !== null); + + // clickhouse only supports max 12 partitions (months) for a given event backfill + // so we need to transform this into a list of lists, one for each year + const clicksToCreateTB = clicksToCreate.reduce( + (acc, curr) => { + const year = new Date(curr.timestamp).getFullYear(); + if (!acc[year]) { + acc[year] = []; + } + acc[year].push(curr); + return acc; + }, + {} as Record, + ); + + // Record clicks + Object.entries(clicksToCreateTB).forEach(async ([year, clicks]) => { + const clicksBatch = clicks as typeof clicksToCreate; + console.log(`backfilling ${clicksBatch.length} clicks for ${year}`); + const clickRes = await fetch( + `${process.env.TINYBIRD_API_URL}/v0/events?name=dub_click_events&wait=true`, + { + method: "POST", + headers: { + Authorization: `Bearer ${process.env.TINYBIRD_API_KEY}`, + "Content-Type": "application/x-ndjson", + }, + body: (clicksBatch as typeof clicksToCreate) + .map((d) => JSON.stringify(d)) + .join("\n"), + }, + ).then((res) => res.json()); + console.log("backfilled clicks", JSON.stringify(clickRes, null, 2)); + }); + + const customersToCreate = finalLeadsToBackfill + .map((lead, idx) => { + const clickData = clicksToCreate[idx]; + if (!clickData) { + return null; + } + return { + id: createId({ prefix: "cus_" }), + name: lead.name, + email: lead.email, + externalId: lead.customerExternalId, + projectId: workspace.id, + projectConnectId: workspace.stripeConnectId, + stripeCustomerId: lead.stripeCustomerId, + linkId: link.id, + programId: link.programId, + partnerId: link.partnerId, + country: clickData.country, + clickId: clickData.click_id, + clickedAt: new Date(lead.timestamp).toISOString(), + createdAt: new Date(lead.timestamp).toISOString(), + }; + }) + .filter( + (p): p is NonNullable => p !== null, + ) satisfies Prisma.CustomerCreateManyInput[]; + + console.table(customersToCreate.slice(0, 10)); + + const customerRes = await prisma.customer.createMany({ + data: customersToCreate, + skipDuplicates: true, + }); + console.log("backfilled customers", prettyPrint(customerRes)); + + const leadsToCreate = clicksToCreate.map((clickData, idx) => ({ + ...clickData, + event_id: nanoid(16), + event_name: "Sign up", + customer_id: customersToCreate[idx]!.id, + })); + + // same batching logic as above + const leadsToCreateTB = leadsToCreate.reduce( + (acc, curr) => { + const year = new Date(curr.timestamp).getFullYear(); + if (!acc[year]) { + acc[year] = []; + } + acc[year].push(curr); + return acc; + }, + {} as Record, + ); + + Object.entries(leadsToCreateTB).forEach(async ([year, leads]) => { + const leadsBatch = leads as typeof leadsToCreate; + console.log(`backfilling ${leadsBatch.length} leads for ${year}`); + const leadRes = await recordLeadWithTimestamp(leadsBatch); + console.log("backfilled leads", prettyPrint(leadRes)); + }); + + const statsByLink = finalLeadsToBackfill.reduce( + (acc, lead) => { + const leadCreatedAt = new Date(lead.timestamp); + acc[link.id] = { + clicks: (acc[link.id]?.clicks || 0) + 1, + leads: (acc[link.id]?.leads || 0) + 1, + // if there is no lastLeadAt, or the leadCreatedAt is greater than the lastLeadAt, set the lastLeadAt to the leadCreatedAt + lastLeadAt: + leadCreatedAt > (acc[link.id]?.lastLeadAt ?? new Date(0)) + ? leadCreatedAt + : acc[link.id]?.lastLeadAt, + }; + return acc; + }, + {} as Record< + string, + { + clicks: number; + leads: number; + lastLeadAt: Date | undefined; + } + >, + ); + + console.log(prettyPrint(Object.entries(statsByLink).slice(0, 10))); + + const statsByLinkChunks = chunk(Object.entries(statsByLink), 50); + for (let i = 0; i < statsByLinkChunks.length; i++) { + const chunk = statsByLinkChunks[i]; + console.log( + `backfilling stats for ${chunk.length} links in batch ${i + 1} of ${statsByLinkChunks.length}`, + ); + await Promise.all( + chunk.map(async ([linkId, stats]) => { + const res = await prisma.link.update({ + where: { id: linkId }, + data: { + clicks: { + increment: stats.clicks, + }, + leads: { + increment: stats.leads, + }, + lastLeadAt: stats.lastLeadAt, + }, + }); + console.log( + `Updated ${linkId} to ${res.clicks} clicks (+${stats.clicks} clicks), ${res.leads} leads (+${stats.leads} leads)`, + ); + const syncRes = await syncPartnerLinksStats({ + partnerId: res.partnerId!, + programId: program.id, + eventType: "lead", + }); + console.log("synced stats", prettyPrint(syncRes)); + }), + ); + } +} + +main(); diff --git a/apps/web/scripts/tella/remind-applications.ts b/apps/web/scripts/customers/tella/remind-applications.ts similarity index 100% rename from apps/web/scripts/tella/remind-applications.ts rename to apps/web/scripts/customers/tella/remind-applications.ts diff --git a/apps/web/scripts/tella/update-commission-flat.ts b/apps/web/scripts/customers/tella/update-commission-flat.ts similarity index 100% rename from apps/web/scripts/tella/update-commission-flat.ts rename to apps/web/scripts/customers/tella/update-commission-flat.ts diff --git a/apps/web/scripts/tella/update-commission-percentage.ts b/apps/web/scripts/customers/tella/update-commission-percentage.ts similarity index 100% rename from apps/web/scripts/tella/update-commission-percentage.ts rename to apps/web/scripts/customers/tella/update-commission-percentage.ts diff --git a/apps/web/scripts/tella/update-commissions.ts b/apps/web/scripts/customers/tella/update-commissions.ts similarity index 100% rename from apps/web/scripts/tella/update-commissions.ts rename to apps/web/scripts/customers/tella/update-commissions.ts diff --git a/apps/web/scripts/tella/update-reward-tier.ts b/apps/web/scripts/customers/tella/update-reward-tier.ts similarity index 100% rename from apps/web/scripts/tella/update-reward-tier.ts rename to apps/web/scripts/customers/tella/update-reward-tier.ts diff --git a/apps/web/scripts/wispr-flow/update-links.ts b/apps/web/scripts/customers/wispr-flow/update-links.ts similarity index 91% rename from apps/web/scripts/wispr-flow/update-links.ts rename to apps/web/scripts/customers/wispr-flow/update-links.ts index c7fe21e7628..2faeccc9d43 100644 --- a/apps/web/scripts/wispr-flow/update-links.ts +++ b/apps/web/scripts/customers/wispr-flow/update-links.ts @@ -1,6 +1,6 @@ import { prisma } from "@dub/prisma"; import "dotenv-flow/config"; -import { linkCache } from "../../lib/api/links/cache"; +import { linkCache } from "../../../lib/api/links/cache"; // update links async function main() { diff --git a/apps/web/scripts/ship30/backfill-leads.ts b/apps/web/scripts/ship30/backfill-leads.ts deleted file mode 100644 index 0ed054aeeed..00000000000 --- a/apps/web/scripts/ship30/backfill-leads.ts +++ /dev/null @@ -1,260 +0,0 @@ -// @ts-nocheck some weird typing issues below - -import { createId } from "@/lib/api/create-id"; -import { prisma } from "@dub/prisma"; -import { nanoid } from "@dub/utils"; -import "dotenv-flow/config"; -import * as fs from "fs"; -import * as Papa from "papaparse"; -import { recordLeadWithTimestamp } from "../../lib/tinybird/record-lead"; - -let leadsToBackfill: { - customerExternalId: string; - partnerLinkKey: string; - timestamp: string; -}[] = []; - -// script to backfill customers + leads -// we also use a batching logic for tinybird events ingestion -async function main() { - Papa.parse(fs.createReadStream("ship30_leads.csv", "utf-8"), { - header: true, - skipEmptyLines: true, - step: (result: { - data: { - customerExternalId: string; - partnerLinkKey: string; - timestamp: string; - }; - }) => { - leadsToBackfill.push({ - customerExternalId: result.data.customerExternalId, - partnerLinkKey: result.data.partnerLinkKey, - timestamp: new Date(result.data.timestamp).toISOString(), - }); - }, - complete: async () => { - const workspace = await prisma.project.findUniqueOrThrow({ - where: { - id: "ws_xxx", - }, - }); - - const partnerLinks = await prisma.link.findMany({ - where: { - domain: "ship30.partners", - key: { - in: leadsToBackfill.map((lead) => lead.partnerLinkKey), - }, - }, - }); - - // filter out leads that are not associated with a partner link - const finalLeadsToBackfill = leadsToBackfill.filter((lead) => - partnerLinks.some( - (link) => - link.key.toLowerCase() === lead.partnerLinkKey.toLowerCase(), - ), - ); - - console.log(`Found ${finalLeadsToBackfill.length} leads to backfill`); - console.table(finalLeadsToBackfill.slice(0, 10)); - - const clicksToCreate = finalLeadsToBackfill - .map((lead) => { - const link = partnerLinks.find( - (link) => - link.key.toLowerCase() === lead.partnerLinkKey.toLowerCase(), - )!; // coerce here cause we already filtered out leads that are not associated with a partner link above - - const clickId = nanoid(16); - - return { - timestamp: new Date(lead.timestamp).toISOString(), - identity_hash: lead.customerExternalId, - click_id: clickId, - workspace_id: workspace.id, - link_id: link.id, - domain: link.domain, - key: link.key, - url: link.url, - ip: "", - continent: "NA", - country: "US", - region: "Unknown", - city: "Unknown", - latitude: "Unknown", - longitude: "Unknown", - vercel_region: "", - device: "Desktop", - device_vendor: "Unknown", - device_model: "Unknown", - browser: "Unknown", - browser_version: "Unknown", - engine: "Unknown", - engine_version: "Unknown", - os: "Unknown", - os_version: "Unknown", - cpu_architecture: "Unknown", - ua: "Unknown", - bot: 0, - qr: 0, - referer: "(direct)", - referer_url: "(direct)", - trigger: "link", - }; - }) - .filter((c) => c !== null); - - // clickhouse only supports max 12 partitions (months) for a given event backfill - // so we need to transform this into a list of lists, one for each year - const clicksToCreateTB = clicksToCreate.reduce( - (acc, curr) => { - const year = new Date(curr.timestamp).getFullYear(); - if (!acc[year]) { - acc[year] = []; - } - acc[year].push(curr); - return acc; - }, - {} as Record, - ); - - // Record clicks - Object.entries(clicksToCreateTB).forEach(async ([year, clicks]) => { - const clicksBatch = clicks as typeof clicksToCreate; - console.log(`backfilling ${clicksBatch.length} clicks for ${year}`); - const clickRes = await fetch( - `${process.env.TINYBIRD_API_URL}/v0/events?name=dub_click_events&wait=true`, - { - method: "POST", - headers: { - Authorization: `Bearer ${process.env.TINYBIRD_API_KEY}`, - "Content-Type": "application/x-ndjson", - }, - body: (clicksBatch as typeof clicksToCreate) - .map((d) => JSON.stringify(d)) - .join("\n"), - }, - ).then((res) => res.json()); - console.log("backfilled clicks", JSON.stringify(clickRes, null, 2)); - }); - - const customersToCreate = finalLeadsToBackfill - .map((lead, idx) => { - const clickData = clicksToCreate[idx]; - if (!clickData) { - return null; - } - return { - id: createId({ prefix: "cus_" }), - name: lead.customerExternalId, - email: lead.customerExternalId, - externalId: lead.customerExternalId, - projectId: workspace.id, - projectConnectId: workspace.stripeConnectId, - clickId: clickData.click_id, - linkId: clickData.link_id, - country: clickData.country, - clickedAt: new Date(lead.timestamp).toISOString(), - createdAt: new Date(lead.timestamp).toISOString(), - }; - }) - .filter((c) => c !== null); - - console.table(customersToCreate.slice(0, 10)); - - const customerRes = await prisma.customer.createMany({ - data: customersToCreate, - skipDuplicates: true, - }); - console.log("backfilled customers", JSON.stringify(customerRes, null, 2)); - - const leadsToCreate = clicksToCreate.map((clickData, idx) => ({ - ...clickData, - event_id: nanoid(16), - event_name: "Sign up", - customer_id: customersToCreate[idx]!.id, - })); - - // same batching logic as above - const leadsToCreateTB = leadsToCreate.reduce( - (acc, curr) => { - const year = new Date(curr.timestamp).getFullYear(); - if (!acc[year]) { - acc[year] = []; - } - acc[year].push(curr); - return acc; - }, - {} as Record, - ); - - Object.entries(leadsToCreateTB).forEach(async ([year, leads]) => { - const leadsBatch = leads as typeof leadsToCreate; - console.log(`backfilling ${leadsBatch.length} leads for ${year}`); - const leadRes = await recordLeadWithTimestamp(leadsBatch); - console.log("backfilled leads", JSON.stringify(leadRes, null, 2)); - }); - - const statsByLink = finalLeadsToBackfill - .filter((lead) => - partnerLinks.some( - (link) => - link.key.toLowerCase() === lead.partnerLinkKey.toLowerCase(), - ), - ) - .reduce( - (acc, lead) => { - const link = partnerLinks.find( - (link) => - link.key.toLowerCase() === lead.partnerLinkKey.toLowerCase(), - )!; - const leadCreatedAt = new Date(lead.timestamp); - acc[link.id] = { - clicks: (acc[link.id]?.clicks || 0) + 1, - leads: (acc[link.id]?.leads || 0) + 1, - // if there is no lastLeadAt, or the leadCreatedAt is greater than the lastLeadAt, set the lastLeadAt to the leadCreatedAt - lastLeadAt: - leadCreatedAt > (acc[link.id]?.lastLeadAt ?? new Date(0)) - ? leadCreatedAt - : acc[link.id]?.lastLeadAt, - }; - return acc; - }, - {} as Record< - string, - { - clicks: number; - leads: number; - lastLeadAt: Date | undefined; - } - >, - ); - - console.log( - JSON.stringify(Object.entries(statsByLink).slice(0, 10), null, 2), - ); - - for (const [linkId, stats] of Object.entries(statsByLink)) { - const res = await prisma.link.update({ - where: { id: linkId }, - data: { - clicks: { - increment: stats.clicks, - }, - leads: { - increment: stats.leads, - }, - lastLeadAt: stats.lastLeadAt, - }, - }); - console.log( - `Updated ${linkId} to ${res.clicks} clicks (+${stats.clicks} clicks), ${res.leads} leads (+${stats.leads} leads)`, - ); - } - }, - }); -} - -main(); diff --git a/apps/web/scripts/testimonial/final-sync-commissions.ts b/apps/web/scripts/testimonial/final-sync-commissions.ts deleted file mode 100644 index 9d628625214..00000000000 --- a/apps/web/scripts/testimonial/final-sync-commissions.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { prisma } from "@dub/prisma"; -import { Prisma } from "@dub/prisma/client"; -import "dotenv-flow/config"; - -// update commissions for a program -async function main() { - const where: Prisma.CommissionWhereInput = { - programId: "prog_xxx", - payoutId: { - not: null, - }, - status: "processed", - }; - - const payoutsStats = await prisma.commission.groupBy({ - by: ["payoutId"], - where, - _count: true, - _sum: { - earnings: true, - }, - orderBy: { - _sum: { - earnings: "desc", - }, - }, - }); - console.log(payoutsStats); - - const actualPayouts = await prisma.payout.findMany({ - where: { - id: { - in: payoutsStats.map((payout) => payout.payoutId!), - }, - }, - select: { - id: true, - amount: true, - }, - }); - - for (const payout of actualPayouts) { - const payoutStats = payoutsStats.find( - (payoutStat) => payoutStat.payoutId === payout.id, - ); - if (!payoutStats) { - console.log(`Payout ${payout.id} not found in payoutsStats`); - continue; - } - if (payoutStats._sum.earnings !== payout.amount) { - console.log( - `Payout ${payout.id} has a mismatch, updating payout and commissions`, - ); - await prisma.payout.update({ - where: { id: payout.id }, - data: { - amount: payoutStats._sum.earnings ?? 0, - }, - }); - } - } -} - -main(); diff --git a/apps/web/scripts/testimonial/sync-commissions.ts b/apps/web/scripts/testimonial/sync-commissions.ts deleted file mode 100644 index 437bcafe328..00000000000 --- a/apps/web/scripts/testimonial/sync-commissions.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { prisma } from "@dub/prisma"; -import { Prisma } from "@dub/prisma/client"; -import "dotenv-flow/config"; - -// update commissions for a program -async function main() { - const where: Prisma.CommissionWhereInput = { - programId: "prog_xxx", - payoutId: { - not: null, - }, - status: "paid", - }; - - const payoutsToUpdate = await prisma.commission.groupBy({ - by: ["payoutId"], - where, - _count: true, - orderBy: { - _count: { - payoutId: "desc", - }, - }, - }); - - for (const payout of payoutsToUpdate) { - if (!payout.payoutId) { - console.log(`No payout ID found for payout ${payout.payoutId}`); - continue; - } - - await prisma.commission.updateMany({ - where: { - payoutId: payout.payoutId, - status: "paid", - }, - data: { payoutId: null }, - }); - - const remainingCommissions = await prisma.commission.findMany({ - where: { - payoutId: payout.payoutId, - }, - }); - - console.log( - `Updated ${payout.payoutId} to have ${remainingCommissions.length} commissions`, - ); - - if (remainingCommissions.length === 0) { - console.log( - `No remaining commissions for payout ${payout.payoutId}, deleting payout`, - ); - await prisma.payout.delete({ - where: { id: payout.payoutId }, - }); - continue; - } - - await prisma.payout.update({ - where: { id: payout.payoutId }, - data: { - amount: remainingCommissions.reduce( - (acc, commission) => acc + commission.earnings, - 0, - ), - }, - }); - } -} - -main(); diff --git a/apps/web/scripts/testimonial/update-commissions.ts b/apps/web/scripts/testimonial/update-commissions.ts deleted file mode 100644 index 7cf6752fc6e..00000000000 --- a/apps/web/scripts/testimonial/update-commissions.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { serializeReward } from "@/lib/api/partners/serialize-reward"; -import { calculateSaleEarnings } from "@/lib/api/sales/calculate-sale-earnings"; -import { prisma } from "@dub/prisma"; -import { Prisma } from "@dub/prisma/client"; -import "dotenv-flow/config"; - -// update commissions for a program -async function main() { - const where: Prisma.CommissionWhereInput = { - earnings: 0, - programId: "prog_xxx", - }; - - const commissions = await prisma.commission.findMany({ - where, - take: 100, - }); - - const reward = await prisma.reward.findUniqueOrThrow({ - where: { - id: "rw_xxx", - }, - }); - - const updatedCommissions = await Promise.all( - commissions.map(async (commission) => { - // Recalculate the earnings based on the new amount - const earnings = calculateSaleEarnings({ - reward: serializeReward(reward), - sale: { - amount: commission.amount, - quantity: commission.quantity, - }, - }); - - return prisma.commission.update({ - where: { id: commission.id }, - data: { - earnings, - }, - }); - }), - ); - console.table(updatedCommissions, [ - "id", - "partnerId", - "amount", - "earnings", - "createdAt", - ]); - - const remainingCommissions = await prisma.commission.count({ - where, - }); - console.log(`${remainingCommissions} commissions left to update`); -} - -main(); From 2557108e4d25d678a232fa810e73a7922e31ed7d Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Thu, 26 Mar 2026 12:39:17 -0700 Subject: [PATCH 02/12] Update stripe-app.json --- packages/stripe-app/stripe-app.json | 43 ++++++++++++++--------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/packages/stripe-app/stripe-app.json b/packages/stripe-app/stripe-app.json index fc0284b5f29..f5f6df1ec47 100644 --- a/packages/stripe-app/stripe-app.json +++ b/packages/stripe-app/stripe-app.json @@ -1,8 +1,25 @@ { + "$schema": "https://stripe.com/stripe-app.schema.json", "id": "dub.co", - "version": "0.0.19", "name": "Dub Partners", + "version": "0.0.23", "icon": "./stripe-icon.png", + "ui_extension": { + "views": [ + { + "viewport": "settings", + "component": "AppSettings" + } + ], + "content_security_policy": { + "connect-src": [ + "https://api.dub.co/oauth/", + "https://api.dub.co/stripe/integration" + ], + "purpose": "" + } + }, + "extensions": null, "permissions": [ { "permission": "customer_read", @@ -61,32 +78,14 @@ "purpose": "Allows Dub to read promotion codes for an account." } ], - "ui_extension": { - "views": [ - { - "viewport": "settings", - "component": "AppSettings" - } - ], - "content_security_policy": { - "connect-src": [ - "https://api.dub.co/oauth/", - "https://api-staging.dub.co/oauth/", - "https://api.dub.co/stripe/integration", - "https://api-staging.dub.co/stripe/integration" - ], - "image-src": null, - "purpose": "" - } - }, "post_install_action": { "type": "settings", "url": "" }, "allowed_redirect_uris": [ - "https://app.dub.co/api/stripe/integration/callback", - "https://preview.dub.co/api/stripe/integration/callback" + "https://app.dub.co/api/stripe/integration/callback" ], + "distribution_type": "public", "stripe_api_access_type": "oauth", - "distribution_type": "public" + "sandbox_install_compatible": true } \ No newline at end of file From 91db78f2df94bbc11de25fa96fd5bd75c129e0d1 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Thu, 26 Mar 2026 15:58:48 -0700 Subject: [PATCH 03/12] small improvement to campaigns/broadcast --- .../api/cron/campaigns/broadcast/route.ts | 27 +++--- .../customers/framer/mark-commissions-paid.ts | 94 ------------------- 2 files changed, 14 insertions(+), 107 deletions(-) delete mode 100644 apps/web/scripts/customers/framer/mark-commissions-paid.ts diff --git a/apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts b/apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts index 2f13eb028c0..dedc09acb25 100644 --- a/apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts +++ b/apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts @@ -123,19 +123,20 @@ export async function POST(req: Request) { }); } - // Mark the campaign as sending - try { - await prisma.campaign.update({ - where: { - id: campaignId, - status: "scheduled", - }, - data: { - status: "sending", - }, - }); - } catch (error) { - // + // Mark the campaign as sending (if it's in scheduled status) + if (campaign.status === "scheduled") { + try { + await prisma.campaign.update({ + where: { + id: campaignId, + }, + data: { + status: "sending", + }, + }); + } catch (error) { + // + } } const campaignGroupIds = campaign.groups.map(({ groupId }) => groupId); diff --git a/apps/web/scripts/customers/framer/mark-commissions-paid.ts b/apps/web/scripts/customers/framer/mark-commissions-paid.ts deleted file mode 100644 index f7b9be7daf3..00000000000 --- a/apps/web/scripts/customers/framer/mark-commissions-paid.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { prisma } from "@dub/prisma"; -import "dotenv-flow/config"; -import * as fs from "fs"; -import * as Papa from "papaparse"; - -let eventIds: string[] = []; - -async function main() { - Papa.parse(fs.createReadStream("framer_paid_event_ids.csv", "utf-8"), { - header: true, - skipEmptyLines: true, - step: (result: { data: { event_id: string } }) => { - eventIds.push(result.data.event_id); - }, - complete: async () => { - const payoutIdsToUpdate = await prisma.commission.groupBy({ - by: ["payoutId"], - where: { - eventId: { in: eventIds }, - payoutId: { not: null }, - }, - _count: true, - orderBy: { - _count: { - eventId: "desc", - }, - }, - }); - - console.log(payoutIdsToUpdate.slice(0, 50)); - - for (const payout of payoutIdsToUpdate.slice(0, 50)) { - if (!payout.payoutId) { - continue; - } - - const updateCommissions = await prisma.commission.updateMany({ - where: { - payoutId: payout.payoutId, - eventId: { in: eventIds }, - }, - data: { - status: "paid", - payoutId: null, - }, - }); - - console.log( - `Updated ${updateCommissions.count} commissions to have status "paid" and payoutId null`, - ); - - const commissionGroupedByPayout = await prisma.commission.groupBy({ - by: ["payoutId"], - where: { - payoutId: payout.payoutId, - }, - _sum: { - earnings: true, - }, - }); - - const finalCommissionAmount = - commissionGroupedByPayout.length > 0 - ? commissionGroupedByPayout[0]._sum.earnings - : null; - - if (!finalCommissionAmount) { - console.log( - `No commission amount found for payout ${payout.payoutId}, deleting payout...`, - ); - await prisma.payout.delete({ - where: { - id: payout.payoutId, - }, - }); - } else { - console.log( - `Updating payout ${payout.payoutId} with amount ${finalCommissionAmount}`, - ); - await prisma.payout.update({ - where: { - id: payout.payoutId, - }, - data: { - amount: finalCommissionAmount, - }, - }); - } - } - }, - }); -} - -main(); From af34c27539b1755e9641f47a68748973a353c8d8 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Thu, 26 Mar 2026 17:09:46 -0700 Subject: [PATCH 04/12] restructure admin, add Partners tab --- .../(dashboard)/commissions/client.tsx | 397 --------------- .../(dashboard)/commissions/page.tsx | 394 ++++++++++++++- .../(dashboard)/layout-nav-client.tsx | 4 + .../(ee)/admin.dub.co/(dashboard)/layout.tsx | 5 +- .../(ee)/admin.dub.co/(dashboard)/page.tsx | 6 - .../(dashboard)/partners/page.tsx | 250 ++++++++++ .../(dashboard)/payouts/client.tsx | 470 ----------------- .../admin.dub.co/(dashboard)/payouts/page.tsx | 472 +++++++++++++++++- .../(dashboard)/revenue/client.tsx | 180 ------- .../admin.dub.co/(dashboard)/revenue/page.tsx | 182 ++++++- apps/web/app/(ee)/api/admin/partners/route.ts | 103 ++++ .../add-edit-bounty/add-edit-bounty-sheet.tsx | 2 +- .../bounty-criteria-manual-submission.tsx | 4 +- 13 files changed, 1394 insertions(+), 1075 deletions(-) delete mode 100644 apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/client.tsx create mode 100644 apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/page.tsx delete mode 100644 apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/client.tsx delete mode 100644 apps/web/app/(ee)/admin.dub.co/(dashboard)/revenue/client.tsx create mode 100644 apps/web/app/(ee)/api/admin/partners/route.ts diff --git a/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/client.tsx b/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/client.tsx deleted file mode 100644 index 55dbedba17f..00000000000 --- a/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/client.tsx +++ /dev/null @@ -1,397 +0,0 @@ -"use client"; - -import { formatDateTooltip } from "@/lib/analytics/format-date-tooltip"; -import { AnalyticsLoadingSpinner } from "@/ui/analytics/analytics-loading-spinner"; -import { FilterButtonTableRow } from "@/ui/shared/filter-button-table-row"; -import SimpleDateRangePicker from "@/ui/shared/simple-date-range-picker"; -import { - CrownSmall, - Filter, - Table, - usePagination, - useRouterStuff, - useTable, -} from "@dub/ui"; -import { Areas, TimeSeriesChart, XAxis, YAxis } from "@dub/ui/charts"; -import { GridIcon } from "@dub/ui/icons"; -import { - cn, - currencyFormatter, - DUB_FOUNDING_DATE, - fetcher, - OG_AVATAR_URL, -} from "@dub/utils"; -import NumberFlow from "@number-flow/react"; -import { Fragment, useCallback, useMemo, useState } from "react"; -import useSWR from "swr"; - -export default function CommissionsPageClient() { - const { queryParams, getQueryString, searchParamsObj } = useRouterStuff(); - const { interval, start, end, programId } = searchParamsObj; - - const { data: { programs, timeseries } = {}, isLoading } = useSWR<{ - programs: { - id: string; - name: string; - logo: string; - commissions: number; - fees: number; - }[]; - timeseries: { - start: Date; - commissions: number; - }[]; - }>( - `/api/admin/commissions${getQueryString({ - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, - })}`, - fetcher, - { - keepPreviousData: true, - }, - ); - - // Filter configuration - const filters = useMemo( - () => [ - { - key: "programId", - icon: GridIcon, - label: "Program", - options: - programs?.map((program) => ({ - value: program.id, - label: program.name, - icon: ( - {`${program.name} - ), - })) ?? null, - }, - ], - [programs], - ); - - const activeFilters = useMemo(() => { - return [...(programId ? [{ key: "programId", value: programId }] : [])]; - }, [programId]); - - const onSelect = useCallback( - (key: string, value: any) => - queryParams({ - set: { - [key]: value, - }, - del: "page", - }), - [queryParams], - ); - - const onRemove = useCallback( - (key: string) => - queryParams({ - del: [key, "page"], - }), - [queryParams], - ); - - const onRemoveAll = useCallback( - () => - queryParams({ - del: ["programId"], - }), - [queryParams], - ); - - const [selectedFilter, setSelectedFilter] = useState(null); - const [search, setSearch] = useState(""); - - const tabs: { - id: string; - label: string; - colorClassName: string; - disabled?: boolean; - }[] = [ - { - id: "commissions", - label: "Commissions", - colorClassName: "text-teal-500 bg-teal-500/50 border-teal-500", - }, - { - id: "fees", - label: "Fees", - colorClassName: "text-red-500 bg-red-500/50 border-red-500", - disabled: true, - }, - ]; - - const tab = tabs[0]; - const selectedTab = tab.id; - - const chartData = - timeseries?.map(({ start, commissions }) => ({ - date: start ? new Date(start) : new Date(), - values: { - commissions: commissions || 0, - }, - })) ?? null; - - const totals = useMemo(() => { - return { - commissions: - timeseries?.reduce( - (acc, { commissions }) => acc + (commissions || 0), - 0, - ) ?? 0, - fees: programs?.reduce((acc, { fees }) => acc + (fees || 0), 0) ?? 0, - }; - }, [timeseries, programs]); - - const { pagination, setPagination } = usePagination(); - - const { table, ...tableProps } = useTable({ - data: programs ?? [], - columns: [ - { - id: "position", - header: "Position", - size: 12, - minSize: 12, - maxSize: 12, - cell: ({ row }) => { - return ( -
- {row.index + 1} - {row.index <= 2 && ( - - )} -
- ); - }, - }, - { - id: "program", - header: "Program", - cell: ({ row }) => ( -
- {row.original.name} - {row.original.name} -
- ), - meta: { - filterParams: ({ row }) => ({ - programId: row.original.id, - }), - }, - }, - { - id: "commissions", - header: "Commissions", - accessorKey: "commissions", - cell: ({ row }) => currencyFormatter(row.original.commissions), - }, - { - id: "fees", - header: "Fees", - accessorKey: "fees", - cell: ({ row }) => currencyFormatter(row.original.fees), - }, - ], - pagination, - onPaginationChange: setPagination, - resourceName: (plural) => `program${plural ? "s" : ""}`, - rowCount: programs?.length ?? 0, - loading: isLoading, - cellRight: (cell) => { - const meta = cell.column.columnDef.meta as - | { - filterParams?: any; - } - | undefined; - - return ( - meta?.filterParams && ( - - ) - ); - }, - }); - - return ( -
-
- - -
- {activeFilters.length > 0 && ( -
- -
- )} -
-
- {tabs.map(({ id, label, colorClassName, disabled }) => { - return ( - - ); - })} -
-
-
- {chartData ? ( - chartData.length > 0 ? ( - d.values.commissions, - isActive: selectedTab === "commissions", - colorClassName: tab.colorClassName, - }, - ]} - tooltipClassName="p-0" - tooltipContent={(d) => ( - <> -

- {formatDateTooltip(d.date, { - interval, - start, - end, - })} -

-
- -
-
-

- {tab.label} -

-
-

- {currencyFormatter(d.values[tab.id])} -

- -
- - )} - > - - - formatDateTooltip(d, { - interval, - start, - end, - dataAvailableFrom: DUB_FOUNDING_DATE, - }) - } - /> - currencyFormatter(value)} - /> - - ) : ( -
- No data available. -
- ) - ) : ( - - )} -
-
-
-
- - - - ); -} diff --git a/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx b/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx index 70e3d5efad6..2589ce6b03d 100644 --- a/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx +++ b/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx @@ -1,10 +1,392 @@ -import { Suspense } from "react"; -import CommissionsPageClient from "./client"; +"use client"; + +import { formatDateTooltip } from "@/lib/analytics/format-date-tooltip"; +import { AnalyticsLoadingSpinner } from "@/ui/analytics/analytics-loading-spinner"; +import { FilterButtonTableRow } from "@/ui/shared/filter-button-table-row"; +import SimpleDateRangePicker from "@/ui/shared/simple-date-range-picker"; +import { + CrownSmall, + Filter, + Table, + usePagination, + useRouterStuff, + useTable, +} from "@dub/ui"; +import { Areas, TimeSeriesChart, XAxis, YAxis } from "@dub/ui/charts"; +import { GridIcon } from "@dub/ui/icons"; +import { + cn, + currencyFormatter, + DUB_FOUNDING_DATE, + fetcher, + OG_AVATAR_URL, +} from "@dub/utils"; +import NumberFlow from "@number-flow/react"; +import { Fragment, useCallback, useMemo } from "react"; +import useSWR from "swr"; + +export default function CommissionsPage() { + const { queryParams, getQueryString, searchParamsObj } = useRouterStuff(); + const { interval, start, end, programId } = searchParamsObj; + + const { data: { programs, timeseries } = {}, isLoading } = useSWR<{ + programs: { + id: string; + name: string; + logo: string; + commissions: number; + fees: number; + }[]; + timeseries: { + start: Date; + commissions: number; + }[]; + }>( + `/api/admin/commissions${getQueryString({ + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + })}`, + fetcher, + { + keepPreviousData: true, + }, + ); + + // Filter configuration + const filters = useMemo( + () => [ + { + key: "programId", + icon: GridIcon, + label: "Program", + options: + programs?.map((program) => ({ + value: program.id, + label: program.name, + icon: ( + {`${program.name} + ), + })) ?? null, + }, + ], + [programs], + ); + + const activeFilters = useMemo(() => { + return [...(programId ? [{ key: "programId", value: programId }] : [])]; + }, [programId]); + + const onSelect = useCallback( + (key: string, value: any) => + queryParams({ + set: { + [key]: value, + }, + del: "page", + }), + [queryParams], + ); + + const onRemove = useCallback( + (key: string) => + queryParams({ + del: [key, "page"], + }), + [queryParams], + ); + + const onRemoveAll = useCallback( + () => + queryParams({ + del: ["programId"], + }), + [queryParams], + ); + + const tabs: { + id: string; + label: string; + colorClassName: string; + disabled?: boolean; + }[] = [ + { + id: "commissions", + label: "Commissions", + colorClassName: "text-teal-500 bg-teal-500/50 border-teal-500", + }, + { + id: "fees", + label: "Fees", + colorClassName: "text-red-500 bg-red-500/50 border-red-500", + disabled: true, + }, + ]; + + const tab = tabs[0]; + const selectedTab = tab.id; + + const chartData = + timeseries?.map(({ start, commissions }) => ({ + date: start ? new Date(start) : new Date(), + values: { + commissions: commissions || 0, + }, + })) ?? null; + + const totals = useMemo(() => { + return { + commissions: + timeseries?.reduce( + (acc, { commissions }) => acc + (commissions || 0), + 0, + ) ?? 0, + fees: programs?.reduce((acc, { fees }) => acc + (fees || 0), 0) ?? 0, + }; + }, [timeseries, programs]); + + const { pagination, setPagination } = usePagination(); + + const { table, ...tableProps } = useTable({ + data: programs ?? [], + columns: [ + { + id: "position", + header: "Position", + size: 12, + minSize: 12, + maxSize: 12, + cell: ({ row }) => { + return ( +
+ {row.index + 1} + {row.index <= 2 && ( + + )} +
+ ); + }, + }, + { + id: "program", + header: "Program", + cell: ({ row }) => ( +
+ {row.original.name} + {row.original.name} +
+ ), + meta: { + filterParams: ({ row }) => ({ + programId: row.original.id, + }), + }, + }, + { + id: "commissions", + header: "Commissions", + accessorKey: "commissions", + cell: ({ row }) => currencyFormatter(row.original.commissions), + }, + { + id: "fees", + header: "Fees", + accessorKey: "fees", + cell: ({ row }) => currencyFormatter(row.original.fees), + }, + ], + pagination, + onPaginationChange: setPagination, + resourceName: (plural) => `program${plural ? "s" : ""}`, + rowCount: programs?.length ?? 0, + loading: isLoading, + cellRight: (cell) => { + const meta = cell.column.columnDef.meta as + | { + filterParams?: any; + } + | undefined; + + return ( + meta?.filterParams && ( + + ) + ); + }, + }); -export default async function CommissionsPage() { return ( - - - +
+
+ + +
+ {activeFilters.length > 0 && ( +
+ +
+ )} +
+
+ {tabs.map(({ id, label, colorClassName, disabled }) => { + return ( + + ); + })} +
+
+
+ {chartData ? ( + chartData.length > 0 ? ( + d.values.commissions, + isActive: selectedTab === "commissions", + colorClassName: tab.colorClassName, + }, + ]} + tooltipClassName="p-0" + tooltipContent={(d) => ( + <> +

+ {formatDateTooltip(d.date, { + interval, + start, + end, + })} +

+
+ +
+
+

+ {tab.label} +

+
+

+ {currencyFormatter(d.values[tab.id])} +

+ +
+ + )} + > + + + formatDateTooltip(d, { + interval, + start, + end, + dataAvailableFrom: DUB_FOUNDING_DATE, + }) + } + /> + currencyFormatter(value)} + /> + + ) : ( +
+ No data available. +
+ ) + ) : ( + + )} +
+
+
+
+
+ + ); } diff --git a/apps/web/app/(ee)/admin.dub.co/(dashboard)/layout-nav-client.tsx b/apps/web/app/(ee)/admin.dub.co/(dashboard)/layout-nav-client.tsx index b1b68015817..476b11e7f65 100644 --- a/apps/web/app/(ee)/admin.dub.co/(dashboard)/layout-nav-client.tsx +++ b/apps/web/app/(ee)/admin.dub.co/(dashboard)/layout-nav-client.tsx @@ -28,6 +28,10 @@ const tabs = [ href: "/payouts", label: "Payouts", }, + { + href: "/partners", + label: "Partners", + }, { href: "/revenue", label: "Revenue", diff --git a/apps/web/app/(ee)/admin.dub.co/(dashboard)/layout.tsx b/apps/web/app/(ee)/admin.dub.co/(dashboard)/layout.tsx index a76a50dfdbe..b88708f4498 100644 --- a/apps/web/app/(ee)/admin.dub.co/(dashboard)/layout.tsx +++ b/apps/web/app/(ee)/admin.dub.co/(dashboard)/layout.tsx @@ -2,7 +2,10 @@ import { constructMetadata } from "@dub/utils"; import { ReactNode } from "react"; import { AdminNav } from "./layout-nav-client"; -export const metadata = constructMetadata({ noIndex: true }); +export const metadata = constructMetadata({ + title: "Dub Admin", + noIndex: true, +}); export default function AdminLayout({ children }: { children: ReactNode }) { return ( diff --git a/apps/web/app/(ee)/admin.dub.co/(dashboard)/page.tsx b/apps/web/app/(ee)/admin.dub.co/(dashboard)/page.tsx index be2f038f8de..d47606c643b 100644 --- a/apps/web/app/(ee)/admin.dub.co/(dashboard)/page.tsx +++ b/apps/web/app/(ee)/admin.dub.co/(dashboard)/page.tsx @@ -1,4 +1,3 @@ -import { constructMetadata } from "@dub/utils"; import { BanLink } from "./components/ban-link"; import { DeletePartnerAccount } from "./components/delete-partner-account"; import { ImpersonateUser } from "./components/impersonate-user"; @@ -6,11 +5,6 @@ import { ImpersonateWorkspace } from "./components/impersonate-workspace"; import { RefreshDomain } from "./components/refresh-domain"; import { ResetLoginAttempts } from "./components/reset-login-attempts"; -export const metadata = constructMetadata({ - title: "Dub Admin", - noIndex: true, -}); - export default function AdminPage() { return (
diff --git a/apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/page.tsx b/apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/page.tsx new file mode 100644 index 00000000000..9f997cdff0c --- /dev/null +++ b/apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/page.tsx @@ -0,0 +1,250 @@ +"use client"; + +import { useConfirmModal } from "@/ui/modals/confirm-modal"; +import { PartnerAvatar } from "@/ui/partners/partner-avatar"; +import { Button, CopyText, TimestampTooltip } from "@dub/ui"; +import { Xmark } from "@dub/ui/icons"; +import { cn, fetcher, formatDateSmart } from "@dub/utils"; +import { FormEvent, useCallback, useMemo, useState } from "react"; +import { toast } from "sonner"; +import useSWR from "swr"; + +type TrustedPartner = { + id: string; + name: string; + email: string | null; + image: string | null; + trustedAt: Date; +}; + +function TrustedAtLabel({ + trustedAt, + className, +}: { + trustedAt: Date | string; + className?: string; +}) { + return ( + +
+ Trusted {formatDateSmart(trustedAt)} +
+
+ ); +} + +export default function TrustedPartnersPage() { + const [partnerIdOrEmail, setPartnerIdOrEmail] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [partnerToRemove, setPartnerToRemove] = useState( + null, + ); + + const { data, isLoading, mutate } = useSWR<{ partners: TrustedPartner[] }>( + "/api/admin/partners", + fetcher, + { keepPreviousData: true }, + ); + + const trustedPartners = data?.partners ?? []; + + const { setShowConfirmModal, confirmModal } = useConfirmModal({ + title: "Remove trusted partner", + description: partnerToRemove ? ( + <> + Remove{" "} + + {partnerToRemove.name} + {" "} + from the trusted partners list? They will no longer show the trusted + badge in the partner network. + + ) : null, + confirmText: "Remove", + confirmVariant: "danger", + cancelText: "Cancel", + onCancel: () => setPartnerToRemove(null), + onConfirm: async () => { + if (!partnerToRemove) return; + + const res = await fetch("/api/admin/partners", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ partnerId: partnerToRemove.id }), + }); + + if (!res.ok) { + const message = await res.text(); + toast.error(message || "Failed to remove trusted partner."); + throw new Error(message); + } + + await mutate(); + toast.success("Partner removed from trusted partners."); + setPartnerToRemove(null); + }, + }); + + const openRemoveModal = useCallback( + (partner: TrustedPartner) => { + setPartnerToRemove(partner); + setShowConfirmModal(true); + }, + [setShowConfirmModal], + ); + + const submitDisabled = useMemo( + () => isSubmitting || partnerIdOrEmail.trim().length === 0, + [isSubmitting, partnerIdOrEmail], + ); + + const onSubmit = async (e: FormEvent) => { + e.preventDefault(); + const trimmedValue = partnerIdOrEmail.trim(); + if (!trimmedValue) return; + + setIsSubmitting(true); + await fetch("/api/admin/partners", { + method: "POST", + body: JSON.stringify({ + partnerIdOrEmail: trimmedValue, + }), + }) + .then(async (res) => { + if (!res.ok) { + throw new Error(await res.text()); + } + await mutate(); + setPartnerIdOrEmail(""); + toast.success("Successfully marked partner as trusted."); + }) + .catch((error) => { + toast.error(error.message || "Failed to mark partner as trusted."); + }) + .finally(() => { + setIsSubmitting(false); + }); + }; + + return ( +
+ {confirmModal} +
+
+

+ Trusted partners +

+

+ Add a partner by ID (e.g. pn_xxx) or email to mark them as trusted. +

+
+ +
+ setPartnerIdOrEmail(e.target.value)} + placeholder="pn_123... or panic@thedis.co" + className="w-full rounded-md border border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm" + /> +
+ +
+ {isLoading ? ( +
+ {[0, 1, 2].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) : trustedPartners.length === 0 ? ( +

+ No trusted partners yet. +

+ ) : ( +
    + {trustedPartners.map((partner) => ( +
  • +
    + +
    +
    +

    + {partner.name} +

    + · + + {partner.id} + +
    + {partner.email ? ( + + {partner.email} + + ) : null} + +
    +
    + + +
    +
    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/client.tsx b/apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/client.tsx deleted file mode 100644 index e975d0258ab..00000000000 --- a/apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/client.tsx +++ /dev/null @@ -1,470 +0,0 @@ -"use client"; - -import { formatDateTooltip } from "@/lib/analytics/format-date-tooltip"; -import { AnalyticsLoadingSpinner } from "@/ui/analytics/analytics-loading-spinner"; -import { PayoutStatusBadges } from "@/ui/partners/payout-status-badges"; -import { FilterButtonTableRow } from "@/ui/shared/filter-button-table-row"; -import SimpleDateRangePicker from "@/ui/shared/simple-date-range-picker"; -import { InvoiceStatus } from "@dub/prisma/client"; -import { - Button, - Filter, - StatusBadge, - Table, - usePagination, - useRouterStuff, - useTable, -} from "@dub/ui"; -import { Areas, TimeSeriesChart, XAxis, YAxis } from "@dub/ui/charts"; -import { CircleDotted, GridIcon, Paypal } from "@dub/ui/icons"; -import { - cn, - currencyFormatter, - fetcher, - formatDateTime, - OG_AVATAR_URL, -} from "@dub/utils"; -import NumberFlow from "@number-flow/react"; -import Link from "next/link"; -import { Fragment, useCallback, useMemo, useState } from "react"; -import useSWR from "swr"; - -interface TimeseriesData { - date: Date; - payouts: number; - fees: number; - total: number; -} - -interface InvoiceData { - date: Date; - programId: string; - programName: string; - programLogo: string; - status: InvoiceStatus; - amount: number; - fee: number; - total: number; -} - -type Tab = { - id: "payouts" | "fees" | "total"; - label: string; - colorClassName: string; -}; - -export default function PayoutsPageClient() { - const { queryParams, getQueryString, searchParamsObj } = useRouterStuff(); - const { interval, start, end, status, programId } = searchParamsObj; - - const { data: { invoices, timeseriesData } = {}, isLoading } = useSWR<{ - invoices: InvoiceData[]; - timeseriesData: TimeseriesData[]; - }>(`/api/admin/payouts${getQueryString()}`, fetcher, { - keepPreviousData: true, - }); - - // Extract unique programs from invoices - const programs = useMemo(() => { - if (!invoices) return []; - const programMap = new Map< - string, - { id: string; name: string; logo: string } - >(); - invoices.forEach((invoice) => { - if (!programMap.has(invoice.programId)) { - programMap.set(invoice.programId, { - id: invoice.programId, - name: invoice.programName, - logo: invoice.programLogo, - }); - } - }); - return Array.from(programMap.values()).sort((a, b) => - a.name.localeCompare(b.name), - ); - }, [invoices]); - - // Filter configuration - const filters = useMemo( - () => [ - { - key: "programId", - icon: GridIcon, - label: "Program", - options: - programs.map((program) => ({ - value: program.id, - label: program.name, - icon: ( - {`${program.name} - ), - })) ?? null, - }, - { - key: "status", - icon: CircleDotted, - label: "Status", - options: Object.entries(PayoutStatusBadges) - .filter(([key]) => - ["processing", "completed", "failed"].includes(key), - ) - .map(([value, { label }]) => { - const Icon = - PayoutStatusBadges[value as keyof typeof PayoutStatusBadges].icon; - return { - value, - label, - icon: ( - - ), - }; - }), - }, - ], - [programs], - ); - - const activeFilters = useMemo(() => { - return [ - ...(programId ? [{ key: "programId", value: programId }] : []), - ...(status ? [{ key: "status", value: status }] : []), - ]; - }, [programId, status]); - - const onSelect = useCallback( - (key: string, value: any) => - queryParams({ - set: { - [key]: value, - }, - del: "page", - }), - [queryParams], - ); - - const onRemove = useCallback( - (key: string) => - queryParams({ - del: [key, "page"], - }), - [queryParams], - ); - - const onRemoveAll = useCallback( - () => - queryParams({ - del: ["status", "programId"], - }), - [queryParams], - ); - - const tabs: Tab[] = [ - { - id: "payouts", - label: "Payouts", - colorClassName: "text-blue-500/50 bg-blue-500/50 border-blue-500", - }, - { - id: "fees", - label: "Fees", - colorClassName: "text-red-500/50 bg-red-500/50 border-red-500", - }, - { - id: "total", - label: "Total", - colorClassName: "text-green-500/50 bg-green-500/50 border-green-500", - }, - ]; - - const [selectedTab, setSelectedTab] = useState<"payouts" | "fees" | "total">( - "payouts", - ); - const tab = tabs.find(({ id }) => id === selectedTab) ?? tabs[0]; - - // take the last 12 months - const chartData = - timeseriesData?.map(({ date, ...rest }) => ({ - date: new Date(date), - values: { - value: rest[selectedTab], - }, - })) ?? null; - - const dateFormatter = (date: Date) => - date.toLocaleDateString("en-US", { - month: "short", - year: "numeric", - timeZone: "UTC", - }); - - const totals = useMemo(() => { - return { - payouts: - timeseriesData?.reduce((acc, { payouts }) => acc + payouts, 0) ?? 0, - fees: timeseriesData?.reduce((acc, { fees }) => acc + fees, 0) ?? 0, - total: timeseriesData?.reduce((acc, { total }) => acc + total, 0) ?? 0, - }; - }, [timeseriesData]); - - const { pagination, setPagination } = usePagination(); - - const { table, ...tableProps } = useTable({ - data: invoices ?? [], - columns: [ - { - id: "date", - header: "Payment Date (UTC)", - accessorKey: "date", - cell: ({ row }) => - formatDateTime(row.original.date, { - timeZone: "UTC", - }), - }, - { - id: "program", - header: "Program", - cell: ({ row }) => ( -
- {row.original.programName} - - {row.original.programName} - -
- ), - meta: { - filterParams: ({ row }) => ({ - programId: row.original.programId, - }), - }, - }, - { - id: "status", - header: "Status", - cell: ({ row }) => { - const badge = PayoutStatusBadges[row.original.status]; - - return badge ? ( - - {badge.label} - - ) : ( - "-" - ); - }, - meta: { - filterParams: ({ row }) => ({ - status: row.original.status, - }), - }, - }, - { - id: "amount", - header: "Amount", - accessorKey: "amount", - cell: ({ row }) => currencyFormatter(row.original.amount), - }, - { - id: "fee", - header: "Fee", - accessorKey: "fee", - cell: ({ row }) => currencyFormatter(row.original.fee), - }, - { - id: "total", - header: "Total", - accessorKey: "total", - cell: ({ row }) => currencyFormatter(row.original.total), - }, - ], - pagination, - onPaginationChange: setPagination, - resourceName: (plural) => `invoice${plural ? "s" : ""}`, - rowCount: invoices?.length ?? 0, - loading: isLoading, - cellRight: (cell) => { - const meta = cell.column.columnDef.meta as - | { - filterParams?: any; - } - | undefined; - - return ( - meta?.filterParams && ( - - ) - ); - }, - }); - - return ( -
-
- - - -
- {activeFilters.length > 0 && ( -
- -
- )} -
-
- {tabs.map(({ id, label, colorClassName }) => { - return ( - - ); - })} -
-
-
- {chartData ? ( - chartData.length > 0 ? ( - d.values.value, - isActive: true, - colorClassName: tab.colorClassName, - }, - ]} - tooltipClassName="p-0" - tooltipContent={(d) => ( - <> -

- {formatDateTooltip(d.date, { - interval, - start, - end, - timezone: "UTC", - })} -

-
- -
-
-

- {tab.label} -

-
-

- {currencyFormatter(d.values.value)} -

- -
- - )} - > - - - currencyFormatter(value)} - /> - - ) : ( -
- No data available. -
- ) - ) : ( - - )} -
-
-
- -
-
- - - ); -} diff --git a/apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/page.tsx b/apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/page.tsx index b6d5bcf7f30..903c0695954 100644 --- a/apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/page.tsx +++ b/apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/page.tsx @@ -1,10 +1,470 @@ -import { Suspense } from "react"; -import PayoutsPageClient from "./client"; +"use client"; + +import { formatDateTooltip } from "@/lib/analytics/format-date-tooltip"; +import { AnalyticsLoadingSpinner } from "@/ui/analytics/analytics-loading-spinner"; +import { PayoutStatusBadges } from "@/ui/partners/payout-status-badges"; +import { FilterButtonTableRow } from "@/ui/shared/filter-button-table-row"; +import SimpleDateRangePicker from "@/ui/shared/simple-date-range-picker"; +import { InvoiceStatus } from "@dub/prisma/client"; +import { + Button, + Filter, + StatusBadge, + Table, + usePagination, + useRouterStuff, + useTable, +} from "@dub/ui"; +import { Areas, TimeSeriesChart, XAxis, YAxis } from "@dub/ui/charts"; +import { CircleDotted, GridIcon, Paypal } from "@dub/ui/icons"; +import { + cn, + currencyFormatter, + fetcher, + formatDateTime, + OG_AVATAR_URL, +} from "@dub/utils"; +import NumberFlow from "@number-flow/react"; +import Link from "next/link"; +import { Fragment, useCallback, useMemo, useState } from "react"; +import useSWR from "swr"; + +interface TimeseriesData { + date: Date; + payouts: number; + fees: number; + total: number; +} + +interface InvoiceData { + date: Date; + programId: string; + programName: string; + programLogo: string; + status: InvoiceStatus; + amount: number; + fee: number; + total: number; +} + +type Tab = { + id: "payouts" | "fees" | "total"; + label: string; + colorClassName: string; +}; + +export default function PayoutsPage() { + const { queryParams, getQueryString, searchParamsObj } = useRouterStuff(); + const { interval, start, end, status, programId } = searchParamsObj; + + const { data: { invoices, timeseriesData } = {}, isLoading } = useSWR<{ + invoices: InvoiceData[]; + timeseriesData: TimeseriesData[]; + }>(`/api/admin/payouts${getQueryString()}`, fetcher, { + keepPreviousData: true, + }); + + // Extract unique programs from invoices + const programs = useMemo(() => { + if (!invoices) return []; + const programMap = new Map< + string, + { id: string; name: string; logo: string } + >(); + invoices.forEach((invoice) => { + if (!programMap.has(invoice.programId)) { + programMap.set(invoice.programId, { + id: invoice.programId, + name: invoice.programName, + logo: invoice.programLogo, + }); + } + }); + return Array.from(programMap.values()).sort((a, b) => + a.name.localeCompare(b.name), + ); + }, [invoices]); + + // Filter configuration + const filters = useMemo( + () => [ + { + key: "programId", + icon: GridIcon, + label: "Program", + options: + programs.map((program) => ({ + value: program.id, + label: program.name, + icon: ( + {`${program.name} + ), + })) ?? null, + }, + { + key: "status", + icon: CircleDotted, + label: "Status", + options: Object.entries(PayoutStatusBadges) + .filter(([key]) => + ["processing", "completed", "failed"].includes(key), + ) + .map(([value, { label }]) => { + const Icon = + PayoutStatusBadges[value as keyof typeof PayoutStatusBadges].icon; + return { + value, + label, + icon: ( + + ), + }; + }), + }, + ], + [programs], + ); + + const activeFilters = useMemo(() => { + return [ + ...(programId ? [{ key: "programId", value: programId }] : []), + ...(status ? [{ key: "status", value: status }] : []), + ]; + }, [programId, status]); + + const onSelect = useCallback( + (key: string, value: any) => + queryParams({ + set: { + [key]: value, + }, + del: "page", + }), + [queryParams], + ); + + const onRemove = useCallback( + (key: string) => + queryParams({ + del: [key, "page"], + }), + [queryParams], + ); + + const onRemoveAll = useCallback( + () => + queryParams({ + del: ["status", "programId"], + }), + [queryParams], + ); + + const tabs: Tab[] = [ + { + id: "payouts", + label: "Payouts", + colorClassName: "text-blue-500/50 bg-blue-500/50 border-blue-500", + }, + { + id: "fees", + label: "Fees", + colorClassName: "text-red-500/50 bg-red-500/50 border-red-500", + }, + { + id: "total", + label: "Total", + colorClassName: "text-green-500/50 bg-green-500/50 border-green-500", + }, + ]; + + const [selectedTab, setSelectedTab] = useState<"payouts" | "fees" | "total">( + "payouts", + ); + const tab = tabs.find(({ id }) => id === selectedTab) ?? tabs[0]; + + // take the last 12 months + const chartData = + timeseriesData?.map(({ date, ...rest }) => ({ + date: new Date(date), + values: { + value: rest[selectedTab], + }, + })) ?? null; + + const dateFormatter = (date: Date) => + date.toLocaleDateString("en-US", { + month: "short", + year: "numeric", + timeZone: "UTC", + }); + + const totals = useMemo(() => { + return { + payouts: + timeseriesData?.reduce((acc, { payouts }) => acc + payouts, 0) ?? 0, + fees: timeseriesData?.reduce((acc, { fees }) => acc + fees, 0) ?? 0, + total: timeseriesData?.reduce((acc, { total }) => acc + total, 0) ?? 0, + }; + }, [timeseriesData]); + + const { pagination, setPagination } = usePagination(); + + const { table, ...tableProps } = useTable({ + data: invoices ?? [], + columns: [ + { + id: "date", + header: "Payment Date (UTC)", + accessorKey: "date", + cell: ({ row }) => + formatDateTime(row.original.date, { + timeZone: "UTC", + }), + }, + { + id: "program", + header: "Program", + cell: ({ row }) => ( +
+ {row.original.programName} + + {row.original.programName} + +
+ ), + meta: { + filterParams: ({ row }) => ({ + programId: row.original.programId, + }), + }, + }, + { + id: "status", + header: "Status", + cell: ({ row }) => { + const badge = PayoutStatusBadges[row.original.status]; + + return badge ? ( + + {badge.label} + + ) : ( + "-" + ); + }, + meta: { + filterParams: ({ row }) => ({ + status: row.original.status, + }), + }, + }, + { + id: "amount", + header: "Amount", + accessorKey: "amount", + cell: ({ row }) => currencyFormatter(row.original.amount), + }, + { + id: "fee", + header: "Fee", + accessorKey: "fee", + cell: ({ row }) => currencyFormatter(row.original.fee), + }, + { + id: "total", + header: "Total", + accessorKey: "total", + cell: ({ row }) => currencyFormatter(row.original.total), + }, + ], + pagination, + onPaginationChange: setPagination, + resourceName: (plural) => `invoice${plural ? "s" : ""}`, + rowCount: invoices?.length ?? 0, + loading: isLoading, + cellRight: (cell) => { + const meta = cell.column.columnDef.meta as + | { + filterParams?: any; + } + | undefined; + + return ( + meta?.filterParams && ( + + ) + ); + }, + }); -export default async function PayoutsPage() { return ( - - - +
+
+ + + +
+ {activeFilters.length > 0 && ( +
+ +
+ )} +
+
+ {tabs.map(({ id, label, colorClassName }) => { + return ( + + ); + })} +
+
+
+ {chartData ? ( + chartData.length > 0 ? ( + d.values.value, + isActive: true, + colorClassName: tab.colorClassName, + }, + ]} + tooltipClassName="p-0" + tooltipContent={(d) => ( + <> +

+ {formatDateTooltip(d.date, { + interval, + start, + end, + timezone: "UTC", + })} +

+
+ +
+
+

+ {tab.label} +

+
+

+ {currencyFormatter(d.values.value)} +

+ +
+ + )} + > + + + currencyFormatter(value)} + /> + + ) : ( +
+ No data available. +
+ ) + ) : ( + + )} +
+
+
+ +
+
+ + ); } diff --git a/apps/web/app/(ee)/admin.dub.co/(dashboard)/revenue/client.tsx b/apps/web/app/(ee)/admin.dub.co/(dashboard)/revenue/client.tsx deleted file mode 100644 index 401d62e2f87..00000000000 --- a/apps/web/app/(ee)/admin.dub.co/(dashboard)/revenue/client.tsx +++ /dev/null @@ -1,180 +0,0 @@ -"use client"; - -import SimpleDateRangePicker from "@/ui/shared/simple-date-range-picker"; -import { - CrownSmall, - Table, - usePagination, - useRouterStuff, - useTable, -} from "@dub/ui"; -import { cn, currencyFormatter, fetcher, nFormatter } from "@dub/utils"; -import NumberFlow from "@number-flow/react"; -import { useMemo } from "react"; -import useSWR from "swr"; - -export default function RevenuePageClient() { - const { getQueryString } = useRouterStuff(); - - const { data: { programs } = {}, isLoading } = useSWR<{ - programs: { - id: string; - name: string; - logo: string; - partners: number; - sales: number; - saleAmount: number; - }[]; - }>(`/api/admin/revenue${getQueryString()}`, fetcher, { - keepPreviousData: true, - }); - - const { pagination, setPagination } = usePagination(); - - const { table, ...tableProps } = useTable({ - data: programs ?? [], - columns: [ - { - id: "position", - header: "Position", - size: 12, - minSize: 12, - maxSize: 12, - cell: ({ row }) => { - return ( -
- {row.index + 1} - {row.index <= 2 && ( - - )} -
- ); - }, - }, - { - id: "program", - header: "Program", - cell: ({ row }) => ( -
- {row.original.name} - {row.original.name} -
- ), - }, - { - id: "partners", - header: "Active Partners", - accessorKey: "partners", - cell: ({ row }) => nFormatter(row.original.partners, { full: true }), - }, - { - id: "sales", - header: "Total Sales", - accessorKey: "sales", - cell: ({ row }) => nFormatter(row.original.sales, { full: true }), - }, - { - id: "revenue", - header: "Affiliate Revenue", - accessorKey: "revenue", - cell: ({ row }) => currencyFormatter(row.original.saleAmount), - }, - ], - pagination, - onPaginationChange: setPagination, - resourceName: (plural) => `program${plural ? "s" : ""}`, - rowCount: programs?.length ?? 0, - loading: isLoading, - }); - - const stats = useMemo( - () => [ - { - id: "partners", - label: "Active Partners", - value: programs?.reduce( - (acc, { partners }) => acc + (partners || 0), - 0, - ), - colorClassName: "bg-blue-500", - }, - { - id: "sales", - label: "Total Sales", - value: programs?.reduce((acc, { sales }) => acc + (sales || 0), 0), - colorClassName: "bg-green-500", - }, - { - id: "revenue", - label: "Affiliate Revenue", - value: programs?.reduce( - (acc, { saleAmount }) => acc + (saleAmount || 0), - 0, - ), - colorClassName: "bg-purple-500", - }, - ], - [programs], - ); - - return ( -
- -
-
- {stats.map(({ id, label, value, colorClassName }) => ( -
-
-
- {label} -
-
- {value !== undefined ? ( - id === "revenue" ? ( - - ) : ( - - ) - ) : ( -
- )} -
-
- ))} -
-
-
-
- - - ); -} diff --git a/apps/web/app/(ee)/admin.dub.co/(dashboard)/revenue/page.tsx b/apps/web/app/(ee)/admin.dub.co/(dashboard)/revenue/page.tsx index 0d1dffc6770..f43dba7b04f 100644 --- a/apps/web/app/(ee)/admin.dub.co/(dashboard)/revenue/page.tsx +++ b/apps/web/app/(ee)/admin.dub.co/(dashboard)/revenue/page.tsx @@ -1,10 +1,180 @@ -import { Suspense } from "react"; -import RevenuePageClient from "./client"; +"use client"; + +import SimpleDateRangePicker from "@/ui/shared/simple-date-range-picker"; +import { + CrownSmall, + Table, + usePagination, + useRouterStuff, + useTable, +} from "@dub/ui"; +import { cn, currencyFormatter, fetcher, nFormatter } from "@dub/utils"; +import NumberFlow from "@number-flow/react"; +import { useMemo } from "react"; +import useSWR from "swr"; + +export default function RevenuePage() { + const { getQueryString } = useRouterStuff(); + + const { data: { programs } = {}, isLoading } = useSWR<{ + programs: { + id: string; + name: string; + logo: string; + partners: number; + sales: number; + saleAmount: number; + }[]; + }>(`/api/admin/revenue${getQueryString()}`, fetcher, { + keepPreviousData: true, + }); + + const { pagination, setPagination } = usePagination(); + + const { table, ...tableProps } = useTable({ + data: programs ?? [], + columns: [ + { + id: "position", + header: "Position", + size: 12, + minSize: 12, + maxSize: 12, + cell: ({ row }) => { + return ( +
+ {row.index + 1} + {row.index <= 2 && ( + + )} +
+ ); + }, + }, + { + id: "program", + header: "Program", + cell: ({ row }) => ( +
+ {row.original.name} + {row.original.name} +
+ ), + }, + { + id: "partners", + header: "Active Partners", + accessorKey: "partners", + cell: ({ row }) => nFormatter(row.original.partners, { full: true }), + }, + { + id: "sales", + header: "Total Sales", + accessorKey: "sales", + cell: ({ row }) => nFormatter(row.original.sales, { full: true }), + }, + { + id: "revenue", + header: "Affiliate Revenue", + accessorKey: "revenue", + cell: ({ row }) => currencyFormatter(row.original.saleAmount), + }, + ], + pagination, + onPaginationChange: setPagination, + resourceName: (plural) => `program${plural ? "s" : ""}`, + rowCount: programs?.length ?? 0, + loading: isLoading, + }); + + const stats = useMemo( + () => [ + { + id: "partners", + label: "Active Partners", + value: programs?.reduce( + (acc, { partners }) => acc + (partners || 0), + 0, + ), + colorClassName: "bg-blue-500", + }, + { + id: "sales", + label: "Total Sales", + value: programs?.reduce((acc, { sales }) => acc + (sales || 0), 0), + colorClassName: "bg-green-500", + }, + { + id: "revenue", + label: "Affiliate Revenue", + value: programs?.reduce( + (acc, { saleAmount }) => acc + (saleAmount || 0), + 0, + ), + colorClassName: "bg-purple-500", + }, + ], + [programs], + ); -export default async function RevenuePage() { return ( - - - +
+ +
+
+ {stats.map(({ id, label, value, colorClassName }) => ( +
+
+
+ {label} +
+
+ {value !== undefined ? ( + id === "revenue" ? ( + + ) : ( + + ) + ) : ( +
+ )} +
+
+ ))} +
+
+
+
+ + ); } diff --git a/apps/web/app/(ee)/api/admin/partners/route.ts b/apps/web/app/(ee)/api/admin/partners/route.ts new file mode 100644 index 00000000000..00af983934a --- /dev/null +++ b/apps/web/app/(ee)/api/admin/partners/route.ts @@ -0,0 +1,103 @@ +import { withAdmin } from "@/lib/auth"; +import { prisma } from "@dub/prisma"; +import { NextResponse } from "next/server"; +import * as z from "zod/v4"; + +// GET /api/admin/partners +export const GET = withAdmin(async () => { + const partners = await prisma.partner.findMany({ + where: { + trustedAt: { + not: null, + }, + }, + orderBy: { + trustedAt: "desc", + }, + select: { + id: true, + name: true, + email: true, + image: true, + trustedAt: true, + }, + }); + + return NextResponse.json({ partners }); +}); + +// POST /api/admin/partners +export const POST = withAdmin(async ({ req }) => { + const { partnerIdOrEmail } = z + .object({ + partnerIdOrEmail: z.string().trim().min(1), + }) + .parse(await req.json()); + + if (!partnerIdOrEmail.startsWith("pn_") && !partnerIdOrEmail.includes("@")) { + return new Response("Invalid partner ID or email.", { status: 400 }); + } + + const partner = await prisma.partner.findFirst({ + where: partnerIdOrEmail.startsWith("pn_") + ? { id: partnerIdOrEmail } + : { email: partnerIdOrEmail }, + select: { + id: true, + trustedAt: true, + }, + }); + + if (!partner) { + return new Response("Partner not found.", { status: 404 }); + } + + if (partner.trustedAt) { + return new Response("Partner is already marked as trusted.", { + status: 400, + }); + } + + const updatedPartner = await prisma.partner.update({ + where: { + id: partner.id, + }, + data: { + trustedAt: new Date(), + }, + select: { + id: true, + name: true, + email: true, + image: true, + trustedAt: true, + }, + }); + + return NextResponse.json(updatedPartner); +}); + +// DELETE /api/admin/partners — clears trusted status (removes from trusted list) +export const DELETE = withAdmin(async ({ req }) => { + const { partnerId } = z + .object({ + partnerId: z.string().trim().min(1), + }) + .parse(await req.json()); + + const partner = await prisma.partner.findUnique({ + where: { id: partnerId }, + select: { id: true, trustedAt: true }, + }); + + if (!partner) { + return new Response("Partner not found.", { status: 404 }); + } + + await prisma.partner.update({ + where: { id: partner.id }, + data: { trustedAt: null }, + }); + + return NextResponse.json({ success: true }); +}); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx index ed425560687..1b24cf5f328 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx @@ -418,7 +418,7 @@ function BountySheetContent({ setIsOpen, bounty }: BountySheetProps) {
- Partners can submit{" "} + Partners can only submit{" "} - Restrict URLs to specific domains. Partners can submit URLs - from these domains or their subdomains. + Restrict URLs to specific domains. Partners can only submit + URLs from these domains or their subdomains.

From f3c8a181030dcfdd8b9fb8594ec9a8b250ee9072 Mon Sep 17 00:00:00 2001 From: Pedro Ladeira <57876830+pepeladeira@users.noreply.github.com> Date: Thu, 26 Mar 2026 23:44:30 -0300 Subject: [PATCH 05/12] Immediate filter on row click (#3654) --- .../analytics/analytics-partners-table.tsx | 4 ++ .../partner-analytics-filter-cell.tsx | 42 ++++++++++--------- apps/web/ui/analytics/bar-list.tsx | 22 ++++++++-- apps/web/ui/analytics/device-section.tsx | 1 + apps/web/ui/analytics/location-section.tsx | 1 + apps/web/ui/analytics/partner-section.tsx | 5 +++ apps/web/ui/analytics/referrers-utms.tsx | 1 + apps/web/ui/analytics/top-links.tsx | 5 +++ 8 files changed, 59 insertions(+), 22 deletions(-) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/analytics-partners-table.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/analytics-partners-table.tsx index 89adaceab8f..028a7a6f8ec 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/analytics-partners-table.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/analytics-partners-table.tsx @@ -115,6 +115,10 @@ export function AnalyticsPartnersTable() { isStaged={stagedPartnerIds?.includes(partnerId) ?? false} isApplied={activePartnerIdsFromUrl.includes(partnerId)} onToggle={() => toggleStagePartner(partnerId)} + onApplyImmediate={() => { + queryParams({ set: { partnerId }, del: "page" }); + setStagedPartnerIds(null); + }} /> ); }, diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/partner-analytics-filter-cell.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/partner-analytics-filter-cell.tsx index 037e8ed255f..b9bf7398724 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/partner-analytics-filter-cell.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/partner-analytics-filter-cell.tsx @@ -16,6 +16,7 @@ interface PartnerAnalyticsFilterCellProps { isStaged: boolean; isApplied: boolean; onToggle: () => void; + onApplyImmediate: () => void; } export function PartnerAnalyticsFilterCell({ @@ -24,30 +25,17 @@ export function PartnerAnalyticsFilterCell({ isStaged, isApplied, onToggle, + onApplyImmediate, }: PartnerAnalyticsFilterCellProps) { const { slug } = useParams() as { slug: string }; return (
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onToggle(); - } - } - : undefined - } + onClick={isApplied ? undefined : onApplyImmediate} >
-
{ + e.stopPropagation(); + if (isApplied) return; + if (isStaged) { + onToggle(); + } else { + onApplyImmediate(); + } + }} className={cn( "flex size-6 shrink-0 items-center justify-center rounded-lg transition-all duration-200", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:ring-offset-1", isStaged - ? "pointer-events-none translate-x-0 opacity-100" + ? "pointer-events-auto translate-x-0 opacity-100" : cn( "-translate-x-3 opacity-0 group-hover:translate-x-0 group-hover:opacity-100", - "pointer-events-none", + "pointer-events-none group-hover:pointer-events-auto", + "focus-visible:pointer-events-auto focus-visible:translate-x-0 focus-visible:opacity-100", ), isStaged || isApplied ? "bg-neutral-900" : "border border-neutral-200 bg-white", )} + aria-label={ + isStaged ? "Remove from multi-select" : "Filter by this partner" + } + aria-pressed={isStaged} > -
+
diff --git a/apps/web/ui/analytics/bar-list.tsx b/apps/web/ui/analytics/bar-list.tsx index f0a050e1f17..67421d3b5f2 100644 --- a/apps/web/ui/analytics/bar-list.tsx +++ b/apps/web/ui/analytics/bar-list.tsx @@ -44,6 +44,7 @@ export function BarList({ onClearFilter, onClearSelection, onApplyFilterValues, + onImmediateFilter, }: { tab: string; unit: string; @@ -78,6 +79,7 @@ export function BarList({ onClearFilter?: () => void; onClearSelection?: () => void; onApplyFilterValues?: (values: string[]) => void; + onImmediateFilter?: (value: string) => void; }) { const [search, setSearch] = useState(""); const [modalSelectedValues, setModalSelectedValues] = useState( @@ -161,6 +163,15 @@ export function BarList({ ? () => onToggleFilter(data.filterValue!) : undefined : undefined, + onRowClick: + data.filterValue && onImmediateFilter + ? !limit + ? () => { + onImmediateFilter(data.filterValue!); + setShowModal(false); + } + : () => onImmediateFilter(data.filterValue!) + : undefined, })); const filterButtons = hasSelection && @@ -291,6 +302,7 @@ export function LineItem({ isSelected, isActivelyFiltered, onFilterClick, + onRowClick, href, }: { icon: ReactNode; @@ -310,6 +322,7 @@ export function LineItem({ isSelected?: boolean; isActivelyFiltered?: boolean; onFilterClick?: () => void; + onRowClick?: () => void; href?: string; }) { const [isHovered, setIsHovered] = useState(false); @@ -414,16 +427,19 @@ export function LineItem({ ); const rowClickable = - (onFilterClick && !isActivelyFiltered) || (!!href && !onFilterClick); + (!isActivelyFiltered && (!!onRowClick || !!onFilterClick)) || + (!!href && !onFilterClick && !onRowClick); return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} onClick={() => { - if (onFilterClick && !isActivelyFiltered) { + if (onRowClick && !isActivelyFiltered) { + onRowClick(); + } else if (onFilterClick && !isActivelyFiltered) { onFilterClick(); - } else if (href && !onFilterClick) { + } else if (href && !onFilterClick && !onRowClick) { router.push(href); setShowModal(false); } diff --git a/apps/web/ui/analytics/device-section.tsx b/apps/web/ui/analytics/device-section.tsx index fd613b4dae4..6a33a075c06 100644 --- a/apps/web/ui/analytics/device-section.tsx +++ b/apps/web/ui/analytics/device-section.tsx @@ -129,6 +129,7 @@ export function DeviceSection() { onClearFilter={onClearFilter} onClearSelection={() => setSelectedItems([])} onApplyFilterValues={onApplyFilterValues} + onImmediateFilter={(val) => onApplyFilterValues([val])} {...(limit && { limit })} /> ) : ( diff --git a/apps/web/ui/analytics/location-section.tsx b/apps/web/ui/analytics/location-section.tsx index e29da4b3d23..a0139727b00 100644 --- a/apps/web/ui/analytics/location-section.tsx +++ b/apps/web/ui/analytics/location-section.tsx @@ -162,6 +162,7 @@ export function LocationSection() { onClearFilter={onClearFilter} onClearSelection={() => setSelectedItems([])} onApplyFilterValues={onApplyFilterValues} + onImmediateFilter={(val) => onApplyFilterValues([val])} {...(limit && { limit })} /> ) : ( diff --git a/apps/web/ui/analytics/partner-section.tsx b/apps/web/ui/analytics/partner-section.tsx index 78aabef2c53..9a4666713ff 100644 --- a/apps/web/ui/analytics/partner-section.tsx +++ b/apps/web/ui/analytics/partner-section.tsx @@ -258,6 +258,11 @@ export function PartnerSection() { onApplyFilterValues={ filterParamKey ? onApplyFilterValues : undefined } + onImmediateFilter={ + filterParamKey + ? (val) => onApplyFilterValues([val]) + : undefined + } {...(limit && { limit })} /> ) : ( diff --git a/apps/web/ui/analytics/referrers-utms.tsx b/apps/web/ui/analytics/referrers-utms.tsx index 6677c8bcb16..3747e75eead 100644 --- a/apps/web/ui/analytics/referrers-utms.tsx +++ b/apps/web/ui/analytics/referrers-utms.tsx @@ -216,6 +216,7 @@ export function ReferrersUTMs() { onClearFilter={onClearFilter} onClearSelection={() => setSelectedItems([])} onApplyFilterValues={onApplyFilterValues} + onImmediateFilter={(val) => onApplyFilterValues([val])} {...(limit && { limit })} /> ) : ( diff --git a/apps/web/ui/analytics/top-links.tsx b/apps/web/ui/analytics/top-links.tsx index 34525ec0d23..1fd3f92f630 100644 --- a/apps/web/ui/analytics/top-links.tsx +++ b/apps/web/ui/analytics/top-links.tsx @@ -251,6 +251,11 @@ export function TopLinks() { onApplyFilterValues={ filterParamKey ? onApplyFilterValues : undefined } + onImmediateFilter={ + filterParamKey + ? (val) => onApplyFilterValues([val]) + : undefined + } {...(limit && { limit })} /> ) : ( From 803aa8db5f4d543613f8fa13de3bdc07055a5be0 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 27 Mar 2026 08:35:18 +0530 Subject: [PATCH 06/12] E2E: Add workspace onboarding test and reorganize test structure (#3651) Co-authored-by: Pedro Ladeira Co-authored-by: Pedro Ladeira <57876830+pepeladeira@users.noreply.github.com> Co-authored-by: Steven Tey --- .github/workflows/playwright.yaml | 27 +- .../(dashboard)/commissions/page.tsx | 10 +- .../(dashboard)/partners/page.tsx | 2 +- .../admin.dub.co/(dashboard)/payouts/page.tsx | 10 +- .../(dashboard)/payouts/paypal/client.tsx | 246 ----------------- .../(dashboard)/payouts/paypal/page.tsx | 250 +++++++++++++++++- .../admin.dub.co/(dashboard)/revenue/page.tsx | 10 +- apps/web/playwright.config.ts | 28 +- .../playwright/{ => partners}/auth.setup.ts | 2 +- .../login.spec.ts} | 2 +- .../onboarding.spec.ts} | 6 +- apps/web/playwright/workspaces/auth.setup.ts | 45 ++++ .../playwright/workspaces/onboarding.spec.ts | 70 +++++ .../framer/1-process-framer-combined.ts | 2 +- .../customers/framer/3-backfill-tb-events.ts | 4 +- .../customers/perplexity/backfill-leads.ts | 2 +- .../perplexity/backfill-tenantids.ts | 2 +- .../customers/perplexity/ban-partners.ts | 8 +- .../perplexity/deactivate-partners.ts | 4 +- .../customers/perplexity/review-bounties.ts | 4 +- 20 files changed, 457 insertions(+), 277 deletions(-) delete mode 100644 apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/paypal/client.tsx rename apps/web/playwright/{ => partners}/auth.setup.ts (96%) rename apps/web/playwright/{partner-login.spec.ts => partners/login.spec.ts} (98%) rename apps/web/playwright/{partner-onboarding.spec.ts => partners/onboarding.spec.ts} (94%) create mode 100644 apps/web/playwright/workspaces/auth.setup.ts create mode 100644 apps/web/playwright/workspaces/onboarding.spec.ts diff --git a/.github/workflows/playwright.yaml b/.github/workflows/playwright.yaml index 9b1c941c9b5..356f609b2e5 100644 --- a/.github/workflows/playwright.yaml +++ b/.github/workflows/playwright.yaml @@ -29,7 +29,7 @@ jobs: NEXTAUTH_URL: "http://partners.localhost:8888" NEXT_PUBLIC_APP_NAME: "Dub" - NEXT_PUBLIC_APP_DOMAIN: "dub.co" + NEXT_PUBLIC_APP_DOMAIN: "localhost:8888" NEXT_PUBLIC_APP_SHORT_DOMAIN: "dub.sh" E2E_PARTNER_EMAIL: "partner1@dub-internal-test.com" @@ -38,8 +38,10 @@ jobs: TINYBIRD_API_KEY: "xx" TINYBIRD_API_URL: "xx" - UPSTASH_REDIS_REST_URL: "https://sensible-camel-xxxx.upstash.io" - UPSTASH_REDIS_REST_TOKEN: "xx" + # serverless-redis-http (SRH) — must match jobs.e2e.services.srh env + SRH_TOKEN: "e2e_srh_token" + UPSTASH_REDIS_REST_URL: "http://127.0.0.1:8079" + UPSTASH_REDIS_REST_TOKEN: "e2e_srh_token" UPSTASH_VECTOR_REST_URL: "https://sensible-camel-xxxx.upstash.io" UPSTASH_VECTOR_REST_TOKEN: "xx" QSTASH_TOKEN: "xx" @@ -87,6 +89,25 @@ jobs: - 1025:1025 - 8025:8025 + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + + srh: + image: hiett/serverless-redis-http:latest + env: + SRH_MODE: env + SRH_TOKEN: "e2e_srh_token" + SRH_CONNECTION_STRING: redis://redis:6379 + ports: + - 8079:80 + steps: - name: Check out code uses: actions/checkout@v4 diff --git a/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx b/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx index 2589ce6b03d..ae454caaaad 100644 --- a/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx +++ b/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx @@ -22,10 +22,18 @@ import { OG_AVATAR_URL, } from "@dub/utils"; import NumberFlow from "@number-flow/react"; -import { Fragment, useCallback, useMemo } from "react"; +import { Fragment, Suspense, useCallback, useMemo } from "react"; import useSWR from "swr"; export default function CommissionsPage() { + return ( + + + + ); +} + +function CommissionsPageClient() { const { queryParams, getQueryString, searchParamsObj } = useRouterStuff(); const { interval, start, end, programId } = searchParamsObj; diff --git a/apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/page.tsx b/apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/page.tsx index 9f997cdff0c..5960d883d5e 100644 --- a/apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/page.tsx +++ b/apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/page.tsx @@ -42,7 +42,7 @@ function TrustedAtLabel({ ); } -export default function TrustedPartnersPage() { +export default function PartnersPage() { const [partnerIdOrEmail, setPartnerIdOrEmail] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [partnerToRemove, setPartnerToRemove] = useState( diff --git a/apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/page.tsx b/apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/page.tsx index 903c0695954..fa6bc58e0fb 100644 --- a/apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/page.tsx +++ b/apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/page.tsx @@ -26,7 +26,7 @@ import { } from "@dub/utils"; import NumberFlow from "@number-flow/react"; import Link from "next/link"; -import { Fragment, useCallback, useMemo, useState } from "react"; +import { Fragment, Suspense, useCallback, useMemo, useState } from "react"; import useSWR from "swr"; interface TimeseriesData { @@ -54,6 +54,14 @@ type Tab = { }; export default function PayoutsPage() { + return ( + + + + ); +} + +function PayoutsPageClient() { const { queryParams, getQueryString, searchParamsObj } = useRouterStuff(); const { interval, start, end, status, programId } = searchParamsObj; diff --git a/apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/paypal/client.tsx b/apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/paypal/client.tsx deleted file mode 100644 index cb0a6e071d4..00000000000 --- a/apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/paypal/client.tsx +++ /dev/null @@ -1,246 +0,0 @@ -"use client"; - -import type { PaypalPayoutResponse } from "@/lib/paypal/get-pending-payouts"; -import { PartnerAvatar } from "@/ui/partners/partner-avatar"; -import { PayoutStatusBadges } from "@/ui/partners/payout-status-badges"; -import { FilterButtonTableRow } from "@/ui/shared/filter-button-table-row"; -import { - Button, - StatusBadge, - Table, - usePagination, - useRouterStuff, - useTable, -} from "@dub/ui"; -import { Globe } from "@dub/ui/icons"; -import { - cn, - COUNTRIES, - currencyFormatter, - fetcher, - nFormatter, - OG_AVATAR_URL, -} from "@dub/utils"; -import NumberFlow from "@number-flow/react"; -import { ChevronLeft } from "lucide-react"; -import Link from "next/link"; -import { useMemo } from "react"; -import useSWR from "swr"; - -export default function PaypalPayoutsPageClient() { - const { getQueryString } = useRouterStuff(); - - const { data: payouts = [], isLoading } = useSWR( - `/api/admin/payouts/paypal${getQueryString()}`, - fetcher, - { - keepPreviousData: true, - }, - ); - - const { pagination, setPagination } = usePagination(100); - - // Client-side pagination - const paginatedPayouts = useMemo(() => { - const start = (pagination.pageIndex - 1) * pagination.pageSize; - const end = start + pagination.pageSize; - return payouts.slice(start, end); - }, [payouts, pagination.pageIndex, pagination.pageSize]); - - const { table, ...tableProps } = useTable({ - data: paginatedPayouts, - columns: [ - { - id: "partner", - header: "Partner", - cell: ({ row }) => ( -
- - - {row.original.partner.email || "-"} - -
- ), - }, - { - id: "country", - header: "Country", - accessorKey: "partner.country", - meta: { - filterParams: ({ getValue }) => ({ country: getValue() }), - }, - cell: ({ row }) => { - const country = row.original.partner.country; - if (!country || country === "Unknown") { - return ( -
- - Unknown -
- ); - } - return ( -
- {country} - - {COUNTRIES[country] ?? country} - -
- ); - }, - }, - { - id: "program", - header: "Program", - accessorKey: "program.id", - meta: { - filterParams: ({ getValue }) => ({ programId: getValue() }), - }, - cell: ({ row }) => ( -
- {row.original.program.name} - - {row.original.program.name} - -
- ), - }, - { - id: "status", - header: "Status", - cell: ({ row }) => { - const badge = PayoutStatusBadges[row.original.status]; - - return badge ? ( - - {badge.label} - - ) : ( - "-" - ); - }, - }, - { - id: "amount", - header: "Amount", - accessorKey: "amount", - cell: ({ row }) => currencyFormatter(row.original.amount), - }, - ], - pagination, - onPaginationChange: setPagination, - resourceName: (plural) => `payout${plural ? "s" : ""}`, - rowCount: payouts.length, - loading: isLoading, - cellRight: (cell) => { - const meta = cell.column.columnDef.meta as - | { - filterParams?: any; - } - | undefined; - - return ( - meta?.filterParams && ( - - ) - ); - }, - }); - - const stats = useMemo(() => { - const allPayouts = payouts; - const processingPayouts = payouts.filter((p) => p.status === "processing"); - const pendingPayouts = payouts.filter((p) => p.status === "pending"); - - return [ - { - id: "all", - label: "Total payouts", - amount: allPayouts.reduce((acc, p) => acc + p.amount, 0), - count: allPayouts.length, - colorClassName: "bg-blue-500", - }, - { - id: "processing", - label: "Processing payouts", - amount: processingPayouts.reduce((acc, p) => acc + p.amount, 0), - count: processingPayouts.length, - colorClassName: "bg-purple-500", - }, - { - id: "pending", - label: "Pending payouts", - amount: pendingPayouts.reduce((acc, p) => acc + p.amount, 0), - count: pendingPayouts.length, - colorClassName: "bg-orange-500", - }, - ]; - }, [payouts]); - - return ( -
-
- -
-
- {stats.map(({ id, label, amount, count, colorClassName }) => ( -
-
-
- {label} -
-
- {!isLoading ? ( -
- - - ({nFormatter(count, { full: true })}) - -
- ) : ( -
- )} -
-
- ))} -
-
-
- - - ); -} diff --git a/apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/paypal/page.tsx b/apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/paypal/page.tsx index 0975a860bc9..67b4f388820 100644 --- a/apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/paypal/page.tsx +++ b/apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/paypal/page.tsx @@ -1,10 +1,254 @@ -import { Suspense } from "react"; -import PaypalPayoutsPageClient from "./client"; +"use client"; -export default async function PaypalPayoutsPage() { +import type { PaypalPayoutResponse } from "@/lib/paypal/get-pending-payouts"; +import { PartnerAvatar } from "@/ui/partners/partner-avatar"; +import { PayoutStatusBadges } from "@/ui/partners/payout-status-badges"; +import { FilterButtonTableRow } from "@/ui/shared/filter-button-table-row"; +import { + Button, + StatusBadge, + Table, + usePagination, + useRouterStuff, + useTable, +} from "@dub/ui"; +import { Globe } from "@dub/ui/icons"; +import { + cn, + COUNTRIES, + currencyFormatter, + fetcher, + nFormatter, + OG_AVATAR_URL, +} from "@dub/utils"; +import NumberFlow from "@number-flow/react"; +import { ChevronLeft } from "lucide-react"; +import Link from "next/link"; +import { Suspense, useMemo } from "react"; +import useSWR from "swr"; + +export default function PaypalPayoutsPage() { return ( ); } + +function PaypalPayoutsPageClient() { + const { getQueryString } = useRouterStuff(); + + const { data: payouts = [], isLoading } = useSWR( + `/api/admin/payouts/paypal${getQueryString()}`, + fetcher, + { + keepPreviousData: true, + }, + ); + + const { pagination, setPagination } = usePagination(100); + + // Client-side pagination + const paginatedPayouts = useMemo(() => { + const start = (pagination.pageIndex - 1) * pagination.pageSize; + const end = start + pagination.pageSize; + return payouts.slice(start, end); + }, [payouts, pagination.pageIndex, pagination.pageSize]); + + const { table, ...tableProps } = useTable({ + data: paginatedPayouts, + columns: [ + { + id: "partner", + header: "Partner", + cell: ({ row }) => ( +
+ + + {row.original.partner.email || "-"} + +
+ ), + }, + { + id: "country", + header: "Country", + accessorKey: "partner.country", + meta: { + filterParams: ({ getValue }) => ({ country: getValue() }), + }, + cell: ({ row }) => { + const country = row.original.partner.country; + if (!country || country === "Unknown") { + return ( +
+ + Unknown +
+ ); + } + return ( +
+ {country} + + {COUNTRIES[country] ?? country} + +
+ ); + }, + }, + { + id: "program", + header: "Program", + accessorKey: "program.id", + meta: { + filterParams: ({ getValue }) => ({ programId: getValue() }), + }, + cell: ({ row }) => ( +
+ {row.original.program.name} + + {row.original.program.name} + +
+ ), + }, + { + id: "status", + header: "Status", + cell: ({ row }) => { + const badge = PayoutStatusBadges[row.original.status]; + + return badge ? ( + + {badge.label} + + ) : ( + "-" + ); + }, + }, + { + id: "amount", + header: "Amount", + accessorKey: "amount", + cell: ({ row }) => currencyFormatter(row.original.amount), + }, + ], + pagination, + onPaginationChange: setPagination, + resourceName: (plural) => `payout${plural ? "s" : ""}`, + rowCount: payouts.length, + loading: isLoading, + cellRight: (cell) => { + const meta = cell.column.columnDef.meta as + | { + filterParams?: any; + } + | undefined; + + return ( + meta?.filterParams && ( + + ) + ); + }, + }); + + const stats = useMemo(() => { + const allPayouts = payouts; + const processingPayouts = payouts.filter((p) => p.status === "processing"); + const pendingPayouts = payouts.filter((p) => p.status === "pending"); + + return [ + { + id: "all", + label: "Total payouts", + amount: allPayouts.reduce((acc, p) => acc + p.amount, 0), + count: allPayouts.length, + colorClassName: "bg-blue-500", + }, + { + id: "processing", + label: "Processing payouts", + amount: processingPayouts.reduce((acc, p) => acc + p.amount, 0), + count: processingPayouts.length, + colorClassName: "bg-purple-500", + }, + { + id: "pending", + label: "Pending payouts", + amount: pendingPayouts.reduce((acc, p) => acc + p.amount, 0), + count: pendingPayouts.length, + colorClassName: "bg-orange-500", + }, + ]; + }, [payouts]); + + return ( +
+
+ +
+
+ {stats.map(({ id, label, amount, count, colorClassName }) => ( +
+
+
+ {label} +
+
+ {!isLoading ? ( +
+ + + ({nFormatter(count, { full: true })}) + +
+ ) : ( +
+ )} +
+
+ ))} +
+
+
+ + + ); +} diff --git a/apps/web/app/(ee)/admin.dub.co/(dashboard)/revenue/page.tsx b/apps/web/app/(ee)/admin.dub.co/(dashboard)/revenue/page.tsx index f43dba7b04f..128fef9ce50 100644 --- a/apps/web/app/(ee)/admin.dub.co/(dashboard)/revenue/page.tsx +++ b/apps/web/app/(ee)/admin.dub.co/(dashboard)/revenue/page.tsx @@ -10,10 +10,18 @@ import { } from "@dub/ui"; import { cn, currencyFormatter, fetcher, nFormatter } from "@dub/utils"; import NumberFlow from "@number-flow/react"; -import { useMemo } from "react"; +import { Suspense, useMemo } from "react"; import useSWR from "swr"; export default function RevenuePage() { + return ( + + + + ); +} + +function RevenuePageClient() { const { getQueryString } = useRouterStuff(); const { data: { programs } = {}, isLoading } = useSWR<{ diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 54b006a21ef..9cb17074267 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -20,14 +20,36 @@ export default defineConfig({ screenshot: "only-on-failure", }, projects: [ - { name: "setup", testMatch: /auth\.setup\.ts/ }, + // Partner tests { - name: "chromium", + name: "partner-setup", + testMatch: /partners\/auth\.setup\.ts/, + }, + { + name: "partners", use: { ...devices["Desktop Chrome"], storageState: "playwright/.auth/partner.json", }, - dependencies: ["setup"], + testDir: "./playwright/partners", + testIgnore: /auth\.setup\.ts/, + dependencies: ["partner-setup"], + }, + // Workspace tests + { + name: "workspace-setup", + testMatch: /workspaces\/auth\.setup\.ts/, + }, + { + name: "workspaces", + use: { + ...devices["Desktop Chrome"], + baseURL: "http://app.localhost:8888", + storageState: "playwright/.auth/workspace.json", + }, + testDir: "./playwright/workspaces", + testIgnore: /auth\.setup\.ts/, + dependencies: ["workspace-setup"], }, ], webServer: process.env.CI diff --git a/apps/web/playwright/auth.setup.ts b/apps/web/playwright/partners/auth.setup.ts similarity index 96% rename from apps/web/playwright/auth.setup.ts rename to apps/web/playwright/partners/auth.setup.ts index cf3cfe86f49..6c5b8f9b70b 100644 --- a/apps/web/playwright/auth.setup.ts +++ b/apps/web/playwright/partners/auth.setup.ts @@ -1,6 +1,6 @@ import { nanoid } from "@dub/utils"; import { expect, test } from "@playwright/test"; -import { extractOtp, waitForEmail } from "./mailhog"; +import { extractOtp, waitForEmail } from "../mailhog"; // Must satisfy: 8+ chars, uppercase, lowercase, digit const SIGNUP_PASSWORD = "Password123"; diff --git a/apps/web/playwright/partner-login.spec.ts b/apps/web/playwright/partners/login.spec.ts similarity index 98% rename from apps/web/playwright/partner-login.spec.ts rename to apps/web/playwright/partners/login.spec.ts index 6178e08ea13..33cbbe72fe6 100644 --- a/apps/web/playwright/partner-login.spec.ts +++ b/apps/web/playwright/partners/login.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from "@playwright/test"; -import { env } from "./env"; +import { env } from "../env"; test.use({ storageState: { diff --git a/apps/web/playwright/partner-onboarding.spec.ts b/apps/web/playwright/partners/onboarding.spec.ts similarity index 94% rename from apps/web/playwright/partner-onboarding.spec.ts rename to apps/web/playwright/partners/onboarding.spec.ts index cb28d237d93..3e46d57c2fe 100644 --- a/apps/web/playwright/partner-onboarding.spec.ts +++ b/apps/web/playwright/partners/onboarding.spec.ts @@ -16,7 +16,7 @@ test.describe("Partner onboarding", () => { await expect( page.getByRole("heading", { name: "Create your partner profile" }), ).toBeVisible(); - await expect(page.locator('input[name="name"]')).toBeVisible(); + await expect(page.locator('input[name="name"]').first()).toBeVisible(); await expect(page.getByText("Profile image")).toBeVisible(); await expect(page.getByLabel("Country")).toBeVisible(); await expect( @@ -29,7 +29,7 @@ test.describe("Partner onboarding", () => { test("profile submit redirects to platforms", async ({ page }) => { await page.goto("/onboarding"); - const nameInput = page.locator('input[name="name"]'); + const nameInput = page.locator('input[name="name"]').first(); const countryField = page.getByLabel("Country"); const searchCountriesInput = page.getByPlaceholder("Search countries..."); const continueButton = page.getByRole("button", { name: "Continue" }); @@ -67,7 +67,7 @@ test.describe("Partner onboarding", () => { }) => { await page.goto("/onboarding"); - const nameInput = page.locator('input[name="name"]'); + const nameInput = page.locator('input[name="name"]').first(); const countryField = page.getByLabel("Country"); const searchCountriesInput = page.getByPlaceholder("Search countries..."); const continueButton = page.getByRole("button", { name: "Continue" }); diff --git a/apps/web/playwright/workspaces/auth.setup.ts b/apps/web/playwright/workspaces/auth.setup.ts new file mode 100644 index 00000000000..428547239f8 --- /dev/null +++ b/apps/web/playwright/workspaces/auth.setup.ts @@ -0,0 +1,45 @@ +import { nanoid } from "@dub/utils"; +import { expect, test } from "@playwright/test"; +import { extractOtp, waitForEmail } from "../mailhog"; + +// Must satisfy: 8+ chars, uppercase, lowercase, digit +const SIGNUP_PASSWORD = "Password123"; + +const authFile = "playwright/.auth/workspace.json"; + +test("sign up new user for workspace onboarding", async ({ page }) => { + const email = `${nanoid(10)}@dub-internal-test.com`; + + // Go to registration page on the app subdomain + await page.goto(`http://app.localhost:8888/register`); + + // Step 1: Enter email and reveal password field + await page.locator('input[name="email"]').fill(email); + await page.getByRole("button", { name: "Sign Up" }).click(); + + // Step 2: Enter password and submit + const passwordInput = page.locator('input[name="password"]'); + await expect(passwordInput).toBeVisible(); + await passwordInput.fill(SIGNUP_PASSWORD); + await page.getByRole("button", { name: "Sign Up" }).click(); + + // Step 3: Verify email via OTP from MailHog + await expect( + page.getByRole("heading", { name: "Verify your email address" }), + ).toBeVisible(); + + const message = await waitForEmail(email); + const otp = extractOtp(message); + + // The OTP input auto-focuses on desktop — type the digits directly + await page.keyboard.type(otp); + + // Step 4: Wait for redirect to onboarding after auto-submit + await page.waitForURL(/\/onboarding/, { + timeout: process.env.CI ? 30_000 : 15_000, + waitUntil: "domcontentloaded", + }); + + // Save authenticated state + await page.context().storageState({ path: authFile }); +}); diff --git a/apps/web/playwright/workspaces/onboarding.spec.ts b/apps/web/playwright/workspaces/onboarding.spec.ts new file mode 100644 index 00000000000..842e567a102 --- /dev/null +++ b/apps/web/playwright/workspaces/onboarding.spec.ts @@ -0,0 +1,70 @@ +import { nanoid } from "@dub/utils"; +import { expect, test } from "@playwright/test"; + +test("complete workspace onboarding", async ({ page }) => { + const workspaceName = `Test WS ${nanoid(6)}`; + + // Welcome page + await page.goto("/onboarding"); + await expect( + page.getByRole("heading", { name: "Welcome to Dub" }), + ).toBeVisible(); + await page.getByRole("button", { name: "Get started" }).click(); + + // Workspace creation step + await page.waitForURL(/\/onboarding\/workspace/); + await expect( + page.getByRole("heading", { name: "Create your workspace" }), + ).toBeVisible(); + + // Fill workspace name (slug auto-generates) + await page.locator('input[id="name"]').fill(workspaceName); + + // Read the auto-generated slug for later assertions + const slug = await page.locator('input[id="slug"]').inputValue(); + expect(slug).toBeTruthy(); + + // Submit workspace creation + await page.getByRole("button", { name: "Create workspace" }).click(); + + // Products step + await page.waitForURL(/\/onboarding\/products/, { timeout: 15_000 }); + await expect( + page.getByRole("heading", { + name: "What do you want to do with Dub?", + }), + ).toBeVisible(); + + // Select "Dub Links" product + await page.getByRole("button", { name: "Continue with Dub Links" }).click(); + + // Domain step — skip it + await page.waitForURL(/\/onboarding\/domain/); + await expect( + page.getByRole("heading", { name: "Add a custom domain" }), + ).toBeVisible(); + await page.getByRole("button", { name: "I'll do this later" }).click(); + + // Plan step — use free plan + await page.waitForURL(/\/onboarding\/plan/); + await page + .getByRole("button", { name: "Start for free, pick a plan later" }) + .click(); + + // Success page + await page.waitForURL(/\/onboarding\/success/); + await expect( + page.getByText(`The ${workspaceName} workspace has been created`), + ).toBeVisible(); + await expect(page.getByText("Complete setup")).toBeVisible(); + + // Go to dashboard + await page.getByRole("button", { name: "Go to your dashboard" }).click(); + + // Verify redirect to workspace dashboard + await page.waitForURL(new RegExp(`/${slug}`), { + timeout: 15_000, + waitUntil: "domcontentloaded", + }); + expect(page.url()).toContain(`/${slug}`); +}); diff --git a/apps/web/scripts/customers/framer/1-process-framer-combined.ts b/apps/web/scripts/customers/framer/1-process-framer-combined.ts index b6f02e00375..a47971ab711 100644 --- a/apps/web/scripts/customers/framer/1-process-framer-combined.ts +++ b/apps/web/scripts/customers/framer/1-process-framer-combined.ts @@ -3,7 +3,7 @@ import "dotenv-flow/config"; import * as fs from "fs"; import * as Papa from "papaparse"; import * as z from "zod/v4"; -import { tb } from "../../lib/tinybird/client"; +import { tb } from "../../../lib/tinybird/client"; /* Script to convert framer-combined.csv that Framer gave us diff --git a/apps/web/scripts/customers/framer/3-backfill-tb-events.ts b/apps/web/scripts/customers/framer/3-backfill-tb-events.ts index 312a6f36996..19f71fa31b6 100644 --- a/apps/web/scripts/customers/framer/3-backfill-tb-events.ts +++ b/apps/web/scripts/customers/framer/3-backfill-tb-events.ts @@ -6,8 +6,8 @@ import { linkConstructorSimple, nanoid } from "@dub/utils"; import "dotenv-flow/config"; import * as fs from "fs"; import * as Papa from "papaparse"; -import { recordLeadWithTimestamp } from "../../lib/tinybird/record-lead"; -import { recordSaleWithTimestamp } from "../../lib/tinybird/record-sale"; +import { recordLeadWithTimestamp } from "../../../lib/tinybird/record-lead"; +import { recordSaleWithTimestamp } from "../../../lib/tinybird/record-sale"; /* Script to backfill events in Tinybird diff --git a/apps/web/scripts/customers/perplexity/backfill-leads.ts b/apps/web/scripts/customers/perplexity/backfill-leads.ts index 05a16285406..d366116c457 100644 --- a/apps/web/scripts/customers/perplexity/backfill-leads.ts +++ b/apps/web/scripts/customers/perplexity/backfill-leads.ts @@ -12,7 +12,7 @@ import "dotenv-flow/config"; import * as fs from "fs"; import { userAgent } from "next/server"; import * as Papa from "papaparse"; -import { recordLeadWithTimestamp } from "../../lib/tinybird/record-lead"; +import { recordLeadWithTimestamp } from "../../../lib/tinybird/record-lead"; let leadsToBackfill: { customerExternalId: string; diff --git a/apps/web/scripts/customers/perplexity/backfill-tenantids.ts b/apps/web/scripts/customers/perplexity/backfill-tenantids.ts index c3ffd16cd76..229a48658b3 100644 --- a/apps/web/scripts/customers/perplexity/backfill-tenantids.ts +++ b/apps/web/scripts/customers/perplexity/backfill-tenantids.ts @@ -3,7 +3,7 @@ import { prisma } from "@dub/prisma"; import "dotenv-flow/config"; import * as fs from "fs"; import * as Papa from "papaparse"; -import { recordLink } from "../../lib/tinybird/record-link"; +import { recordLink } from "../../../lib/tinybird/record-link"; const programId = "prog_xxx"; diff --git a/apps/web/scripts/customers/perplexity/ban-partners.ts b/apps/web/scripts/customers/perplexity/ban-partners.ts index 5785f182fd1..6466f4a9205 100644 --- a/apps/web/scripts/customers/perplexity/ban-partners.ts +++ b/apps/web/scripts/customers/perplexity/ban-partners.ts @@ -4,10 +4,10 @@ import { prisma } from "@dub/prisma"; import "dotenv-flow/config"; import * as fs from "fs"; import * as Papa from "papaparse"; -import { linkCache } from "../../lib/api/links/cache"; -import { syncTotalCommissions } from "../../lib/api/partners/sync-total-commissions"; -import { queueBatchEmail } from "../../lib/email/queue-batch-email"; -import { recordLink } from "../../lib/tinybird"; +import { linkCache } from "../../../lib/api/links/cache"; +import { syncTotalCommissions } from "../../../lib/api/partners/sync-total-commissions"; +import { queueBatchEmail } from "../../../lib/email/queue-batch-email"; +import { recordLink } from "../../../lib/tinybird"; let partnersToBan: string[] = []; const bannedReason = "fraud"; diff --git a/apps/web/scripts/customers/perplexity/deactivate-partners.ts b/apps/web/scripts/customers/perplexity/deactivate-partners.ts index 6d62c18af62..2fffaa58c47 100644 --- a/apps/web/scripts/customers/perplexity/deactivate-partners.ts +++ b/apps/web/scripts/customers/perplexity/deactivate-partners.ts @@ -1,8 +1,8 @@ import PartnerDeactivated from "@dub/email/templates/partner-deactivated"; import { prisma } from "@dub/prisma"; import "dotenv-flow/config"; -import { linkCache } from "../../lib/api/links/cache"; -import { queueBatchEmail } from "../../lib/email/queue-batch-email"; +import { linkCache } from "../../../lib/api/links/cache"; +import { queueBatchEmail } from "../../../lib/email/queue-batch-email"; async function main() { const programId = "prog_xxx"; diff --git a/apps/web/scripts/customers/perplexity/review-bounties.ts b/apps/web/scripts/customers/perplexity/review-bounties.ts index 3c05fc45aa0..5e35d30d828 100644 --- a/apps/web/scripts/customers/perplexity/review-bounties.ts +++ b/apps/web/scripts/customers/perplexity/review-bounties.ts @@ -4,8 +4,8 @@ import { prisma } from "@dub/prisma"; import { Prisma } from "@dub/prisma/client"; import { chunk } from "@dub/utils"; import "dotenv-flow/config"; -import { syncTotalCommissions } from "../../lib/api/partners/sync-total-commissions"; -import { queueBatchEmail } from "../../lib/email/queue-batch-email"; +import { syncTotalCommissions } from "../../../lib/api/partners/sync-total-commissions"; +import { queueBatchEmail } from "../../../lib/email/queue-batch-email"; const userId = "user_xxx"; From 4e7051d0887f2e42b03915c243c2be443e9d0f17 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Thu, 26 Mar 2026 21:15:38 -0700 Subject: [PATCH 07/12] add requiredRoles to withAdmin --- apps/web/app/(ee)/api/admin/ban/route.ts | 103 ++++---- .../api/admin/delete-partner-account/route.ts | 225 +++++++++--------- .../(ee)/api/admin/links/[linkId]/route.ts | 21 -- .../web/app/(ee)/api/admin/links/ban/route.ts | 71 +++--- apps/web/app/(ee)/api/admin/partners/route.ts | 137 ++++++----- apps/web/lib/auth/admin.ts | 26 +- 6 files changed, 303 insertions(+), 280 deletions(-) delete mode 100644 apps/web/app/(ee)/api/admin/links/[linkId]/route.ts diff --git a/apps/web/app/(ee)/api/admin/ban/route.ts b/apps/web/app/(ee)/api/admin/ban/route.ts index ceaef5d50db..dcccef63292 100644 --- a/apps/web/app/(ee)/api/admin/ban/route.ts +++ b/apps/web/app/(ee)/api/admin/ban/route.ts @@ -8,61 +8,66 @@ import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; // POST /api/admin/ban -export const POST = withAdmin(async ({ req }) => { - const { email } = await req.json(); +export const POST = withAdmin( + async ({ req }) => { + const { email } = await req.json(); - const user = await prisma.user.findUniqueOrThrow({ - where: { - email, - }, - select: { - id: true, - email: true, - image: true, - projects: { - where: { - role: "owner", - }, - select: { - project: { - select: { - id: true, - slug: true, - logo: true, - stripeId: true, + const user = await prisma.user.findUniqueOrThrow({ + where: { + email, + }, + select: { + id: true, + email: true, + image: true, + projects: { + where: { + role: "owner", + }, + select: { + project: { + select: { + id: true, + slug: true, + logo: true, + stripeId: true, + }, }, }, }, }, - }, - }); + }); - console.log( - `Found user ${user.email} with ${user.projects.length} workspaces`, - ); + console.log( + `Found user ${user.email} with ${user.projects.length} workspaces`, + ); - waitUntil( - Promise.all( - user.projects.map(({ project }) => deleteWorkspaceAdmin(project)), - ).then(async () => { - await Promise.all([ - user.image && - isStored(user.image) && - storage.delete({ key: user.image.replace(`${R2_URL}/`, "") }), - updateConfig({ - key: "emails", - value: email, - }), - ]); + waitUntil( + Promise.all( + user.projects.map(({ project }) => deleteWorkspaceAdmin(project)), + ).then(async () => { + await Promise.all([ + user.image && + isStored(user.image) && + storage.delete({ key: user.image.replace(`${R2_URL}/`, "") }), + updateConfig({ + key: "emails", + value: email, + }), + ]); - // delete user - await prisma.user.delete({ - where: { - id: user.id, - }, - }); - }), - ); + // delete user + await prisma.user.delete({ + where: { + id: user.id, + }, + }); + }), + ); - return NextResponse.json({ success: true }); -}); + return NextResponse.json({ success: true }); + }, + { + requiredRoles: ["owner"], + }, +); diff --git a/apps/web/app/(ee)/api/admin/delete-partner-account/route.ts b/apps/web/app/(ee)/api/admin/delete-partner-account/route.ts index 6db80461822..c7c2cc2bcb0 100644 --- a/apps/web/app/(ee)/api/admin/delete-partner-account/route.ts +++ b/apps/web/app/(ee)/api/admin/delete-partner-account/route.ts @@ -7,132 +7,139 @@ import { prettyPrint } from "@dub/utils"; import { NextResponse } from "next/server"; // POST /api/admin/delete-partner-account -export const POST = withAdmin(async ({ req }) => { - const { email, deletePartnerAccount } = await req.json(); +export const POST = withAdmin( + async ({ req }) => { + const { email, deletePartnerAccount } = await req.json(); - const partner = await prisma.partner.findUnique({ - where: { - email, - }, - include: { - commissions: true, - programs: { - select: { - program: true, - links: true, - groupId: true, + const partner = await prisma.partner.findUnique({ + where: { + email, + }, + include: { + commissions: true, + programs: { + select: { + program: true, + links: true, + groupId: true, + }, }, }, - }, - }); + }); + + if (!partner) { + return new Response("Partner not found", { status: 404 }); + } - if (!partner) { - return new Response("Partner not found", { status: 404 }); - } + if (partner.stripeConnectId) { + try { + // check if stripe express account has received payouts before + const transfers = await stripe.transfers.list({ + destination: partner.stripeConnectId, + limit: 1, + }); + + if (transfers.data.length > 0) { + return new Response( + "Stripe express account has received payouts before and cannot be deleted.", + { + status: 400, + }, + ); + } + + const res = await stripe.accounts.del(partner.stripeConnectId); + console.log( + `Deleted Stripe express account for partner ${partner.email}: `, + prettyPrint(res), + ); + } catch (error) { + console.log( + "Error deleting Stripe express account (probably already deleted): ", + error, + ); + } - if (partner.stripeConnectId) { - try { - // check if stripe express account has received payouts before - const transfers = await stripe.transfers.list({ - destination: partner.stripeConnectId, - limit: 1, + await prisma.partner.update({ + where: { + id: partner.id, + }, + data: { + stripeConnectId: null, + payoutsEnabledAt: null, + payoutMethodHash: null, + }, }); + console.log(`Updated partner ${partner.email} with stripeConnectId null`); + } - if (transfers.data.length > 0) { + if (deletePartnerAccount) { + if (partner.commissions.length > 0) { return new Response( - "Stripe express account has received payouts before and cannot be deleted.", + "Partner has already received commissions and cannot be deleted.", + { + status: 400, + }, + ); + } + if ( + partner.programs.some(({ links }) => + links.some((link) => link.leads > 0), + ) + ) { + return new Response( + "Partner has already received leads and cannot be deleted.", { status: 400, }, ); } - const res = await stripe.accounts.del(partner.stripeConnectId); - console.log( - `Deleted Stripe express account for partner ${partner.email}: `, - prettyPrint(res), - ); - } catch (error) { - console.log( - "Error deleting Stripe express account (probably already deleted): ", - error, - ); - } - - await prisma.partner.update({ - where: { - id: partner.id, - }, - data: { - stripeConnectId: null, - payoutsEnabledAt: null, - payoutMethodHash: null, - }, - }); - console.log(`Updated partner ${partner.email} with stripeConnectId null`); - } - - if (deletePartnerAccount) { - if (partner.commissions.length > 0) { - return new Response( - "Partner has already received commissions and cannot be deleted.", - { - status: 400, - }, - ); - } - if ( - partner.programs.some(({ links }) => links.some((link) => link.leads > 0)) - ) { - return new Response( - "Partner has already received leads and cannot be deleted.", - { - status: 400, - }, - ); - } - - if (partner.programs.length > 0) { - for (const { program, links, groupId } of partner.programs) { - if (links.length > 0) { - await Promise.allSettled([ - prisma.link.deleteMany({ - where: { - id: { - in: links.map((link) => link.id), + if (partner.programs.length > 0) { + for (const { program, links, groupId } of partner.programs) { + if (links.length > 0) { + await Promise.allSettled([ + prisma.link.deleteMany({ + where: { + id: { + in: links.map((link) => link.id), + }, }, - }, - }), - recordLink( - links.map((link) => ({ - ...link, - programEnrollment: { groupId }, - })), - { deleted: true }, - ), - ]); - console.log( - `Deleted ${links.length} links for program ${program.name} (${program.slug})`, - ); + }), + recordLink( + links.map((link) => ({ + ...link, + programEnrollment: { groupId }, + })), + { deleted: true }, + ), + ]); + console.log( + `Deleted ${links.length} links for program ${program.name} (${program.slug})`, + ); + } } - } - await prisma.programEnrollment.deleteMany({ - where: { - partnerId: partner.id, - programId: { - in: partner.programs.map(({ program }) => program.id), + await prisma.programEnrollment.deleteMany({ + where: { + partnerId: partner.id, + programId: { + in: partner.programs.map(({ program }) => program.id), + }, }, - }, - }); - console.log( - `Deleted ${partner.programs.length} program enrollments for partner ${partner.email} (${partner.id})`, - ); - } + }); + console.log( + `Deleted ${partner.programs.length} program enrollments for partner ${partner.email} (${partner.id})`, + ); + } - await conn.execute(`DELETE FROM Partner WHERE id = ?`, [partner.id]); - console.log(`Deleted partner ${partner.email} (${partner.id})`); - } + await conn.execute(`DELETE FROM Partner WHERE id = ?`, [partner.id]); + console.log(`Deleted partner ${partner.email} (${partner.id})`); + } - return NextResponse.json({ success: true }); -}); + return NextResponse.json({ success: true }); + }, + { + requiredRoles: ["owner"], + }, +); diff --git a/apps/web/app/(ee)/api/admin/links/[linkId]/route.ts b/apps/web/app/(ee)/api/admin/links/[linkId]/route.ts deleted file mode 100644 index 25e2b3ebe0c..00000000000 --- a/apps/web/app/(ee)/api/admin/links/[linkId]/route.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { transformLink } from "@/lib/api/links"; -import { withAdmin } from "@/lib/auth"; -import { prisma } from "@dub/prisma"; -import { NextResponse } from "next/server"; - -// GET /api/admin/links/[linkId] – get a link as an admin -export const GET = withAdmin(async ({ params }) => { - const { linkId } = params; - - const link = await prisma.link.findUnique({ - where: { - id: linkId, - }, - }); - - if (!link) { - return NextResponse.json({ error: "Link not found" }, { status: 404 }); - } - - return NextResponse.json(transformLink(link)); -}); diff --git a/apps/web/app/(ee)/api/admin/links/ban/route.ts b/apps/web/app/(ee)/api/admin/links/ban/route.ts index 07a8c82bebc..77742138f01 100644 --- a/apps/web/app/(ee)/api/admin/links/ban/route.ts +++ b/apps/web/app/(ee)/api/admin/links/ban/route.ts @@ -11,38 +11,43 @@ import { import { NextResponse } from "next/server"; // DELETE /api/admin/links/ban – ban a dub.sh link by key -export const DELETE = withAdmin(async ({ searchParams }) => { - const { domain, key } = domainKeySchema.parse(searchParams); - - const link = await prisma.link.findUnique({ - where: { domain_key: { domain, key } }, - }); - - if (!link) { - return NextResponse.json({ error: "Link not found" }, { status: 404 }); - } - - const urlDomain = getDomainWithoutWWW(link.url); - - const response = await Promise.all([ - prisma.link.update({ - where: { - id: link.id, - }, - data: { - userId: LEGAL_USER_ID, - projectId: LEGAL_WORKSPACE_ID, - }, - }), - - linkCache.set({ ...link, projectId: LEGAL_WORKSPACE_ID }), - - urlDomain && - updateConfig({ - key: "domains", - value: urlDomain, +export const DELETE = withAdmin( + async ({ searchParams }) => { + const { domain, key } = domainKeySchema.parse(searchParams); + + const link = await prisma.link.findUnique({ + where: { domain_key: { domain, key } }, + }); + + if (!link) { + return NextResponse.json({ error: "Link not found" }, { status: 404 }); + } + + const urlDomain = getDomainWithoutWWW(link.url); + + const response = await Promise.all([ + prisma.link.update({ + where: { + id: link.id, + }, + data: { + userId: LEGAL_USER_ID, + projectId: LEGAL_WORKSPACE_ID, + }, }), - ]); - return NextResponse.json(response); -}); + linkCache.set({ ...link, projectId: LEGAL_WORKSPACE_ID }), + + urlDomain && + updateConfig({ + key: "domains", + value: urlDomain, + }), + ]); + + return NextResponse.json(response); + }, + { + requiredRoles: ["owner"], + }, +); diff --git a/apps/web/app/(ee)/api/admin/partners/route.ts b/apps/web/app/(ee)/api/admin/partners/route.ts index 00af983934a..0e488a1d24c 100644 --- a/apps/web/app/(ee)/api/admin/partners/route.ts +++ b/apps/web/app/(ee)/api/admin/partners/route.ts @@ -27,77 +27,90 @@ export const GET = withAdmin(async () => { }); // POST /api/admin/partners -export const POST = withAdmin(async ({ req }) => { - const { partnerIdOrEmail } = z - .object({ - partnerIdOrEmail: z.string().trim().min(1), - }) - .parse(await req.json()); +export const POST = withAdmin( + async ({ req }) => { + const { partnerIdOrEmail } = z + .object({ + partnerIdOrEmail: z.string().trim().min(1), + }) + .parse(await req.json()); - if (!partnerIdOrEmail.startsWith("pn_") && !partnerIdOrEmail.includes("@")) { - return new Response("Invalid partner ID or email.", { status: 400 }); - } + if ( + !partnerIdOrEmail.startsWith("pn_") && + !partnerIdOrEmail.includes("@") + ) { + return new Response("Invalid partner ID or email.", { status: 400 }); + } - const partner = await prisma.partner.findFirst({ - where: partnerIdOrEmail.startsWith("pn_") - ? { id: partnerIdOrEmail } - : { email: partnerIdOrEmail }, - select: { - id: true, - trustedAt: true, - }, - }); + const partner = await prisma.partner.findFirst({ + where: partnerIdOrEmail.startsWith("pn_") + ? { id: partnerIdOrEmail } + : { email: partnerIdOrEmail }, + select: { + id: true, + trustedAt: true, + }, + }); - if (!partner) { - return new Response("Partner not found.", { status: 404 }); - } + if (!partner) { + return new Response("Partner not found.", { status: 404 }); + } - if (partner.trustedAt) { - return new Response("Partner is already marked as trusted.", { - status: 400, - }); - } + if (partner.trustedAt) { + return new Response("Partner is already marked as trusted.", { + status: 400, + }); + } - const updatedPartner = await prisma.partner.update({ - where: { - id: partner.id, - }, - data: { - trustedAt: new Date(), - }, - select: { - id: true, - name: true, - email: true, - image: true, - trustedAt: true, - }, - }); + const updatedPartner = await prisma.partner.update({ + where: { + id: partner.id, + }, + data: { + trustedAt: new Date(), + }, + select: { + id: true, + name: true, + email: true, + image: true, + trustedAt: true, + }, + }); - return NextResponse.json(updatedPartner); -}); + return NextResponse.json(updatedPartner); + }, + { + requiredRoles: ["owner"], + }, +); // DELETE /api/admin/partners — clears trusted status (removes from trusted list) -export const DELETE = withAdmin(async ({ req }) => { - const { partnerId } = z - .object({ - partnerId: z.string().trim().min(1), - }) - .parse(await req.json()); +export const DELETE = withAdmin( + async ({ req }) => { + const { partnerId } = z + .object({ + partnerId: z.string().trim().min(1), + }) + .parse(await req.json()); - const partner = await prisma.partner.findUnique({ - where: { id: partnerId }, - select: { id: true, trustedAt: true }, - }); + const partner = await prisma.partner.findUnique({ + where: { id: partnerId }, + select: { id: true, trustedAt: true }, + }); - if (!partner) { - return new Response("Partner not found.", { status: 404 }); - } + if (!partner) { + return new Response("Partner not found.", { status: 404 }); + } - await prisma.partner.update({ - where: { id: partner.id }, - data: { trustedAt: null }, - }); + await prisma.partner.update({ + where: { id: partner.id }, + data: { trustedAt: null }, + }); - return NextResponse.json({ success: true }); -}); + return NextResponse.json({ success: true }); + }, + { + requiredRoles: ["owner"], + }, +); diff --git a/apps/web/lib/auth/admin.ts b/apps/web/lib/auth/admin.ts index 011a6f189a5..ff9fe0c2632 100644 --- a/apps/web/lib/auth/admin.ts +++ b/apps/web/lib/auth/admin.ts @@ -1,4 +1,5 @@ import { prisma } from "@dub/prisma"; +import { WorkspaceRole } from "@dub/prisma/client"; import { DUB_WORKSPACE_ID, getSearchParams } from "@dub/utils"; import { getSession } from "./utils"; @@ -15,7 +16,7 @@ interface WithAdminHandler { }): Promise; } -export const isDubAdmin = async (userId: string) => { +const getDubAdminRole = async (userId: string) => { const response = await prisma.projectUsers.findUnique({ where: { userId_projectId: { @@ -23,15 +24,21 @@ export const isDubAdmin = async (userId: string) => { projectId: DUB_WORKSPACE_ID, }, }, + select: { + role: true, + }, }); if (!response) { - return false; + return null; } - return true; + return response.role; }; export const withAdmin = - (handler: WithAdminHandler) => + ( + handler: WithAdminHandler, + { requiredRoles = [] }: { requiredRoles?: WorkspaceRole[] } = {}, + ) => async ( req: Request, { params: initialParams }: { params: Promise> }, @@ -42,11 +49,18 @@ export const withAdmin = return new Response("Unauthorized: Login required.", { status: 401 }); } - const isAdminUser = await isDubAdmin(session.user.id); - if (!isAdminUser) { + const adminRole = await getDubAdminRole(session.user.id); + if (!adminRole) { return new Response("Unauthorized: Not an admin.", { status: 401 }); } + if (requiredRoles.length > 0 && !requiredRoles.includes(adminRole)) { + return new Response( + `Unauthorized: Missing required admin role(s): ${requiredRoles.join(", ")}.`, + { status: 403 }, + ); + } + const searchParams = getSearchParams(req.url); return handler({ req, params, searchParams }); }; From 677e4eed63d8faa1ac80bf68f8d1ca55d94f8926 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Thu, 26 Mar 2026 21:56:57 -0700 Subject: [PATCH 08/12] add platform verification in admin --- .../(dashboard)/partners/page.tsx | 389 +++++++++++++++++- .../api/admin/partners/platforms/route.ts | 142 +++++++ apps/web/app/(ee)/api/admin/partners/route.ts | 23 +- .../[idOrSlug]/billing/upgrade/route.ts | 6 +- apps/web/lib/auth/admin.ts | 2 +- 5 files changed, 543 insertions(+), 19 deletions(-) create mode 100644 apps/web/app/(ee)/api/admin/partners/platforms/route.ts diff --git a/apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/page.tsx b/apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/page.tsx index 5960d883d5e..3efee6f6163 100644 --- a/apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/page.tsx +++ b/apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/page.tsx @@ -1,11 +1,22 @@ "use client"; +import { PARTNER_PLATFORM_FIELDS } from "@/lib/partners/partner-platforms"; import { useConfirmModal } from "@/ui/modals/confirm-modal"; import { PartnerAvatar } from "@/ui/partners/partner-avatar"; -import { Button, CopyText, TimestampTooltip } from "@dub/ui"; -import { Xmark } from "@dub/ui/icons"; +import { PlatformType } from "@dub/prisma/client"; +import { Button, CopyText, Sheet, TimestampTooltip, Tooltip } from "@dub/ui"; +import { + BadgeCheck2Fill, + Instagram, + LinkedIn, + TikTok, + Twitter, + Xmark, + YouTube, +} from "@dub/ui/icons"; import { cn, fetcher, formatDateSmart } from "@dub/utils"; -import { FormEvent, useCallback, useMemo, useState } from "react"; +import Link from "next/link"; +import { FormEvent, useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import useSWR from "swr"; @@ -15,8 +26,57 @@ type TrustedPartner = { email: string | null; image: string | null; trustedAt: Date; + description?: string | null; + platforms: Array<{ + type: PlatformType; + identifier: string; + verifiedAt: Date | string | null; + subscribers: number; + posts: number; + views: number; + }>; }; +const PLATFORM_ORDER: PlatformType[] = [ + "youtube", + "twitter", + "linkedin", + "instagram", + "tiktok", + "website", +]; + +const PLATFORM_LABELS: Record = { + website: "Website", + youtube: "YouTube", + twitter: "X / Twitter", + linkedin: "LinkedIn", + instagram: "Instagram", + tiktok: "TikTok", +}; + +const PLATFORM_PLACEHOLDERS: Record = { + website: "https://example.com", + youtube: "@creator or channel URL", + twitter: "@handle or profile URL", + linkedin: "profile slug or URL", + instagram: "@handle or profile URL", + tiktok: "@handle or profile URL", +}; + +const formatInt = (value: number) => + new Intl.NumberFormat("en-US").format(value); + +function PlatformIcon({ platform }: { platform: PlatformType }) { + const className = "size-4"; + if (platform === "youtube") return ; + if (platform === "twitter") return ; + if (platform === "linkedin") return ; + if (platform === "instagram") return ; + if (platform === "tiktok") return ; + return WEB; +} + function TrustedAtLabel({ trustedAt, className, @@ -48,6 +108,19 @@ export default function PartnersPage() { const [partnerToRemove, setPartnerToRemove] = useState( null, ); + const [activePartner, setActivePartner] = useState( + null, + ); + const [sheetOpen, setSheetOpen] = useState(false); + const [draftHandles, setDraftHandles] = useState< + Partial> + >({}); + const [linkedInPostUrls, setLinkedInPostUrls] = useState< + Partial> + >({}); + const [verifyingPlatforms, setVerifyingPlatforms] = useState< + Partial> + >({}); const { data, isLoading, mutate } = useSWR<{ partners: TrustedPartner[] }>( "/api/admin/partners", @@ -56,6 +129,26 @@ export default function PartnersPage() { ); const trustedPartners = data?.partners ?? []; + const platformByType = useMemo( + () => + Object.fromEntries( + (activePartner?.platforms ?? []).map((platform) => [ + platform.type, + platform, + ]), + ) as Partial>, + [activePartner?.platforms], + ); + + useEffect(() => { + if (!activePartner) return; + const nextDraftHandles: Partial> = {}; + for (const platform of PLATFORM_ORDER) { + nextDraftHandles[platform] = platformByType[platform]?.identifier ?? ""; + } + setDraftHandles(nextDraftHandles); + setLinkedInPostUrls({}); + }, [activePartner, platformByType]); const { setShowConfirmModal, confirmModal } = useConfirmModal({ title: "Remove trusted partner", @@ -135,6 +228,58 @@ export default function PartnersPage() { }); }; + const verifyPlatform = async (platform: PlatformType) => { + if (!activePartner) return; + const identifier = draftHandles[platform]?.trim(); + if (!identifier) { + toast.error(`Please enter a ${PLATFORM_LABELS[platform]} identifier.`); + return; + } + + setVerifyingPlatforms((prev) => ({ ...prev, [platform]: true })); + + const payload: { + partnerId: string; + platform: PlatformType; + identifier: string; + postUrl?: string; + } = { + partnerId: activePartner.id, + platform, + identifier, + }; + + const linkedInPostUrl = linkedInPostUrls.linkedin?.trim(); + if (platform === "linkedin" && linkedInPostUrl) { + payload.postUrl = linkedInPostUrl; + } + + await fetch("/api/admin/partners/platforms", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + .then(async (res) => { + if (!res.ok) { + throw new Error(await res.text()); + } + const refreshed = await mutate(); + const updatedActivePartner = refreshed?.partners?.find( + (p) => p.id === activePartner.id, + ); + if (updatedActivePartner) { + setActivePartner(updatedActivePartner); + } + toast.success(`${PLATFORM_LABELS[platform]} verified manually.`); + }) + .catch((error) => { + toast.error(error.message || "Failed to verify platform."); + }) + .finally(() => { + setVerifyingPlatforms((prev) => ({ ...prev, [platform]: false })); + }); + }; + return (
{confirmModal} @@ -190,9 +335,28 @@ export default function PartnersPage() {
    {trustedPartners.map((partner) => (
  • -
    +
    { + setActivePartner(partner); + setSheetOpen(true); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setActivePartner(partner); + setSheetOpen(true); + } + }} + >
    @@ -208,14 +372,74 @@ export default function PartnersPage() { {partner.id}
    - {partner.email ? ( - - {partner.email} - - ) : null} +
    + {partner.email ? ( + + {partner.email} + + ) : ( + + )} +
    + {(() => { + const platforms = partner.platforms.map((p) => ({ + ...p, + platformId: null, + avatarUrl: null, + subscribers: BigInt(p.subscribers), + posts: BigInt(p.posts), + views: BigInt(p.views), + verifiedAt: p.verifiedAt + ? new Date(p.verifiedAt) + : null, + })); + return PARTNER_PLATFORM_FIELDS.map( + ({ label, icon: Icon, data: getPlatformData }) => { + const { value, href, verified } = + getPlatformData(platforms); + if (!value) return null; + return ( + e.stopPropagation()} + > + + {value} + {verified && ( + + )} + + } + > + e.stopPropagation()} + > + + {label} + {verified && ( + + )} + + + ); + }, + ); + })()} +
    +
    openRemoveModal(partner)} + onClick={(e) => { + e.stopPropagation(); + openRemoveModal(partner); + }} > @@ -245,6 +472,140 @@ export default function PartnersPage() {
)}
+ { + setSheetOpen(open); + if (!open) setActivePartner(null); + }} + > +
+
+ + Partner profile and platforms + + +
+ +
+ {!activePartner ? null : ( + <> +
+
+ {activePartner.name} +
+

+ {activePartner.name} +

+

+ {activePartner.email ?? "No email"} +

+ {activePartner.description ? ( +

+ {activePartner.description} +

+ ) : null} +
+
+
+ +
+ {PLATFORM_ORDER.map((platform) => { + const existing = platformByType[platform]; + const isVerifying = Boolean(verifyingPlatforms[platform]); + return ( +
+
+
+ +

+ {PLATFORM_LABELS[platform]} +

+
+
+ {existing?.verifiedAt ? "Verified" : "Not verified"} +
+
+ +
+ + setDraftHandles((prev) => ({ + ...prev, + [platform]: e.target.value, + })) + } + placeholder={PLATFORM_PLACEHOLDERS[platform]} + className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500" + /> +
+ + {platform === "linkedin" ? ( + + setLinkedInPostUrls((prev) => ({ + ...prev, + linkedin: e.target.value, + })) + } + placeholder="Optional: LinkedIn post URL (for follower metadata)" + className="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500" + /> + ) : null} + +
+ + Subscribers: {formatInt(existing?.subscribers ?? 0)} + + Posts: {formatInt(existing?.posts ?? 0)} + Views: {formatInt(existing?.views ?? 0)} + {existing?.verifiedAt ? ( + + Verified {formatDateSmart(existing.verifiedAt)} + + ) : null} +
+
+ ); + })} +
+ + )} +
+
+
); } diff --git a/apps/web/app/(ee)/api/admin/partners/platforms/route.ts b/apps/web/app/(ee)/api/admin/partners/platforms/route.ts new file mode 100644 index 00000000000..ce72f66a5e7 --- /dev/null +++ b/apps/web/app/(ee)/api/admin/partners/platforms/route.ts @@ -0,0 +1,142 @@ +import { getLinkedInPost } from "@/lib/api/scrape-creators/get-linkedin-post"; +import { getSocialProfile } from "@/lib/api/scrape-creators/get-social-profile"; +import { withAdmin } from "@/lib/auth"; +import { sanitizeSocialHandle, sanitizeWebsite } from "@/lib/social-utils"; +import { prisma } from "@dub/prisma"; +import { PlatformType } from "@dub/prisma/client"; +import { revalidatePath } from "next/cache"; +import { NextResponse } from "next/server"; +import * as z from "zod/v4"; + +const postSchema = z.object({ + partnerId: z.string().trim().min(1), + platform: z.enum(PlatformType), + identifier: z.string().trim().min(1), + postUrl: z.string().trim().url().optional(), +}); + +// POST /api/admin/partners/platforms +export const POST = withAdmin( + async ({ req }) => { + const { partnerId, platform, identifier: rawIdentifier, postUrl } = postSchema + .parse(await req.json()); + + const partner = await prisma.partner.findUnique({ + where: { id: partnerId }, + select: { id: true }, + }); + + if (!partner) { + return new Response("Partner not found.", { status: 404 }); + } + + const identifier = + platform === "website" + ? sanitizeWebsite(rawIdentifier) + : sanitizeSocialHandle(rawIdentifier, platform); + + if (!identifier) { + return new Response("Invalid platform identifier.", { status: 400 }); + } + + let verifiedData: { + platformId: string | null; + subscribers: bigint; + posts: bigint; + views: bigint; + avatarUrl: string | null; + metadata?: Record; + } = { + platformId: null, + subscribers: BigInt(0), + posts: BigInt(0), + views: BigInt(0), + avatarUrl: null, + metadata: {}, + }; + + if (["youtube", "instagram", "twitter", "tiktok"].includes(platform)) { + const socialProfile = await getSocialProfile({ + platform, + handle: identifier, + }); + + verifiedData = { + platformId: socialProfile.platformId, + subscribers: socialProfile.subscribers, + posts: socialProfile.posts, + views: socialProfile.views, + avatarUrl: socialProfile.avatarUrl, + }; + } else if (platform === "linkedin" && postUrl) { + const linkedInPost = await getLinkedInPost(postUrl); + verifiedData = { + platformId: null, + subscribers: BigInt(linkedInPost.author.followers || 0), + posts: BigInt(0), + views: BigInt(0), + avatarUrl: null, + metadata: { + linkedInPostUrl: postUrl, + linkedInAuthorUrl: linkedInPost.author.url ?? "", + }, + }; + } + + const updated = await prisma.partnerPlatform.upsert({ + where: { + partnerId_type: { + partnerId, + type: platform, + }, + }, + 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(), + }, + }); + + revalidatePath("/api/admin/partners"); + revalidatePath("/api/admin/partners/platforms"); + + return NextResponse.json({ + platform: { + ...updated, + subscribers: Number(updated.subscribers), + posts: Number(updated.posts), + views: Number(updated.views), + }, + }); + }, + { + requiredRoles: ["owner"], + }, +); diff --git a/apps/web/app/(ee)/api/admin/partners/route.ts b/apps/web/app/(ee)/api/admin/partners/route.ts index 0e488a1d24c..bbd63a2e760 100644 --- a/apps/web/app/(ee)/api/admin/partners/route.ts +++ b/apps/web/app/(ee)/api/admin/partners/route.ts @@ -19,11 +19,32 @@ export const GET = withAdmin(async () => { name: true, email: true, image: true, + description: true, trustedAt: true, + platforms: { + select: { + type: true, + identifier: true, + verifiedAt: true, + subscribers: true, + posts: true, + views: true, + }, + }, }, }); - return NextResponse.json({ partners }); + return NextResponse.json({ + partners: partners.map((partner) => ({ + ...partner, + platforms: partner.platforms.map((platform) => ({ + ...platform, + subscribers: Number(platform.subscribers), + posts: Number(platform.posts), + views: Number(platform.views), + })), + })), + }); }); // POST /api/admin/partners diff --git a/apps/web/app/api/workspaces/[idOrSlug]/billing/upgrade/route.ts b/apps/web/app/api/workspaces/[idOrSlug]/billing/upgrade/route.ts index 043a2564d3e..6dd32a932da 100644 --- a/apps/web/app/api/workspaces/[idOrSlug]/billing/upgrade/route.ts +++ b/apps/web/app/api/workspaces/[idOrSlug]/billing/upgrade/route.ts @@ -1,5 +1,5 @@ import { DubApiError } from "@/lib/api/errors"; -import { isDubAdmin, withWorkspace } from "@/lib/auth"; +import { getDubAdminRole, withWorkspace } from "@/lib/auth"; import { getDubCustomer } from "@/lib/dub"; import { stripe } from "@/lib/stripe"; import { booleanQuerySchema } from "@/lib/zod/schemas/misc"; @@ -47,8 +47,8 @@ export const POST = withWorkspace( : null; if (process.env.VERCEL === "1" && process.env.VERCEL_ENV === "preview") { - const isAdminUser = await isDubAdmin(session.user.id); - if (!isAdminUser) { + const adminRole = await getDubAdminRole(session.user.id); + if (!adminRole) { throw new DubApiError({ code: "unauthorized", message: "Unauthorized: Not an admin.", diff --git a/apps/web/lib/auth/admin.ts b/apps/web/lib/auth/admin.ts index ff9fe0c2632..84104e66fb0 100644 --- a/apps/web/lib/auth/admin.ts +++ b/apps/web/lib/auth/admin.ts @@ -16,7 +16,7 @@ interface WithAdminHandler { }): Promise; } -const getDubAdminRole = async (userId: string) => { +export const getDubAdminRole = async (userId: string) => { const response = await prisma.projectUsers.findUnique({ where: { userId_projectId: { From 0f4f750244c3869b4ab596ce3434c6e27d212d7c Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Thu, 26 Mar 2026 22:39:44 -0700 Subject: [PATCH 09/12] add app.apollo.io to cal.link domains --- packages/utils/src/constants/dub-domains.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/utils/src/constants/dub-domains.ts b/packages/utils/src/constants/dub-domains.ts index 1a08002ad5e..623560896e2 100644 --- a/packages/utils/src/constants/dub-domains.ts +++ b/packages/utils/src/constants/dub-domains.ts @@ -71,6 +71,7 @@ export const DUB_DOMAINS = [ placeholder: "https://cal.com/steven", allowedHostnames: [ "app.acuityscheduling.com", + "app.apollo.io", "cal.com", "calendly.com", "calendar.app.google", From 98059de46f742585febdbcdb2ae9efe5adb9b3f9 Mon Sep 17 00:00:00 2001 From: Pedro Ladeira <57876830+pepeladeira@users.noreply.github.com> Date: Fri, 27 Mar 2026 13:18:15 -0300 Subject: [PATCH 10/12] Prevent scroll-to-top on filter clicks (#3659) --- .../program/analytics/analytics-partners-table.tsx | 5 +++-- apps/web/ui/analytics/bar-list.tsx | 10 +++++----- apps/web/ui/analytics/device-section.tsx | 9 ++++++--- apps/web/ui/analytics/location-section.tsx | 9 ++++++--- apps/web/ui/analytics/partner-section.tsx | 13 +++++++------ apps/web/ui/analytics/referrers-utms.tsx | 9 ++++++--- apps/web/ui/analytics/top-links.tsx | 13 +++++++------ 7 files changed, 40 insertions(+), 28 deletions(-) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/analytics-partners-table.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/analytics-partners-table.tsx index 028a7a6f8ec..c749b236261 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/analytics-partners-table.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/analytics-partners-table.tsx @@ -59,6 +59,7 @@ export function AnalyticsPartnersTable() { queryParams({ set: { partnerId: stagedPartnerIds.join(",") }, del: "page", + scroll: false, }); setStagedPartnerIds(null); }, [queryParams, stagedPartnerIds]); @@ -66,7 +67,7 @@ export function AnalyticsPartnersTable() { const clearFilter = useCallback(() => { setStagedPartnerIds(null); if (searchParams.has("partnerId")) { - queryParams({ del: ["partnerId", "page"] }); + queryParams({ del: ["partnerId", "page"], scroll: false }); } }, [queryParams, searchParams]); @@ -116,7 +117,7 @@ export function AnalyticsPartnersTable() { isApplied={activePartnerIdsFromUrl.includes(partnerId)} onToggle={() => toggleStagePartner(partnerId)} onApplyImmediate={() => { - queryParams({ set: { partnerId }, del: "page" }); + queryParams({ set: { partnerId }, del: "page", scroll: false }); setStagedPartnerIds(null); }} /> diff --git a/apps/web/ui/analytics/bar-list.tsx b/apps/web/ui/analytics/bar-list.tsx index 67421d3b5f2..17b51512902 100644 --- a/apps/web/ui/analytics/bar-list.tsx +++ b/apps/web/ui/analytics/bar-list.tsx @@ -44,7 +44,7 @@ export function BarList({ onClearFilter, onClearSelection, onApplyFilterValues, - onImmediateFilter, + onRowFilterItem, }: { tab: string; unit: string; @@ -79,7 +79,7 @@ export function BarList({ onClearFilter?: () => void; onClearSelection?: () => void; onApplyFilterValues?: (values: string[]) => void; - onImmediateFilter?: (value: string) => void; + onRowFilterItem?: (value: string) => void; }) { const [search, setSearch] = useState(""); const [modalSelectedValues, setModalSelectedValues] = useState( @@ -164,13 +164,13 @@ export function BarList({ : undefined : undefined, onRowClick: - data.filterValue && onImmediateFilter + data.filterValue && onRowFilterItem ? !limit ? () => { - onImmediateFilter(data.filterValue!); + onRowFilterItem(data.filterValue!); setShowModal(false); } - : () => onImmediateFilter(data.filterValue!) + : () => onRowFilterItem(data.filterValue!) : undefined, })); diff --git a/apps/web/ui/analytics/device-section.tsx b/apps/web/ui/analytics/device-section.tsx index 6a33a075c06..daef3262524 100644 --- a/apps/web/ui/analytics/device-section.tsx +++ b/apps/web/ui/analytics/device-section.tsx @@ -39,9 +39,12 @@ export function DeviceSection() { const onApplyFilterValues = useCallback( (values: string[]) => { if (values.length === 0) { - queryParams({ del: singularTabName }); + queryParams({ del: singularTabName, scroll: false }); } else { - queryParams({ set: { [singularTabName]: values.join(",") } }); + queryParams({ + set: { [singularTabName]: values.join(",") }, + scroll: false, + }); } setSelectedItems([]); }, @@ -129,7 +132,7 @@ export function DeviceSection() { onClearFilter={onClearFilter} onClearSelection={() => setSelectedItems([])} onApplyFilterValues={onApplyFilterValues} - onImmediateFilter={(val) => onApplyFilterValues([val])} + onRowFilterItem={(val) => onApplyFilterValues([val])} {...(limit && { limit })} /> ) : ( diff --git a/apps/web/ui/analytics/location-section.tsx b/apps/web/ui/analytics/location-section.tsx index a0139727b00..a8f5303362d 100644 --- a/apps/web/ui/analytics/location-section.tsx +++ b/apps/web/ui/analytics/location-section.tsx @@ -46,9 +46,12 @@ export function LocationSection() { const onApplyFilterValues = useCallback( (values: string[]) => { if (values.length === 0) { - queryParams({ del: singularTabName }); + queryParams({ del: singularTabName, scroll: false }); } else { - queryParams({ set: { [singularTabName]: values.join(",") } }); + queryParams({ + set: { [singularTabName]: values.join(",") }, + scroll: false, + }); } setSelectedItems([]); }, @@ -162,7 +165,7 @@ export function LocationSection() { onClearFilter={onClearFilter} onClearSelection={() => setSelectedItems([])} onApplyFilterValues={onApplyFilterValues} - onImmediateFilter={(val) => onApplyFilterValues([val])} + onRowFilterItem={(val) => onApplyFilterValues([val])} {...(limit && { limit })} /> ) : ( diff --git a/apps/web/ui/analytics/partner-section.tsx b/apps/web/ui/analytics/partner-section.tsx index 9a4666713ff..4451b437b75 100644 --- a/apps/web/ui/analytics/partner-section.tsx +++ b/apps/web/ui/analytics/partner-section.tsx @@ -112,9 +112,12 @@ export function PartnerSection() { (values: string[]) => { if (!filterParamKey) return; if (values.length === 0) { - queryParams({ del: filterParamKey }); + queryParams({ del: filterParamKey, scroll: false }); } else { - queryParams({ set: { [filterParamKey]: values.join(",") } }); + queryParams({ + set: { [filterParamKey]: values.join(",") }, + scroll: false, + }); } setSelectedItems([]); }, @@ -258,10 +261,8 @@ export function PartnerSection() { onApplyFilterValues={ filterParamKey ? onApplyFilterValues : undefined } - onImmediateFilter={ - filterParamKey - ? (val) => onApplyFilterValues([val]) - : undefined + onRowFilterItem={ + filterParamKey ? (val) => onApplyFilterValues([val]) : undefined } {...(limit && { limit })} /> diff --git a/apps/web/ui/analytics/referrers-utms.tsx b/apps/web/ui/analytics/referrers-utms.tsx index 3747e75eead..bf7bb4bd821 100644 --- a/apps/web/ui/analytics/referrers-utms.tsx +++ b/apps/web/ui/analytics/referrers-utms.tsx @@ -78,9 +78,12 @@ export function ReferrersUTMs() { const onApplyFilterValues = useCallback( (values: string[]) => { if (values.length === 0) { - queryParams({ del: singularTabName }); + queryParams({ del: singularTabName, scroll: false }); } else { - queryParams({ set: { [singularTabName]: values.join(",") } }); + queryParams({ + set: { [singularTabName]: values.join(",") }, + scroll: false, + }); } setSelectedItems([]); }, @@ -216,7 +219,7 @@ export function ReferrersUTMs() { onClearFilter={onClearFilter} onClearSelection={() => setSelectedItems([])} onApplyFilterValues={onApplyFilterValues} - onImmediateFilter={(val) => onApplyFilterValues([val])} + onRowFilterItem={(val) => onApplyFilterValues([val])} {...(limit && { limit })} /> ) : ( diff --git a/apps/web/ui/analytics/top-links.tsx b/apps/web/ui/analytics/top-links.tsx index 1fd3f92f630..5ec86aff539 100644 --- a/apps/web/ui/analytics/top-links.tsx +++ b/apps/web/ui/analytics/top-links.tsx @@ -94,9 +94,12 @@ export function TopLinks() { if (!filterParamKey) return; if (values.length === 0) { - queryParams({ del: filterParamKey }); + queryParams({ del: filterParamKey, scroll: false }); } else { - queryParams({ set: { [filterParamKey]: values.join(",") } }); + queryParams({ + set: { [filterParamKey]: values.join(",") }, + scroll: false, + }); } setSelectedItems([]); @@ -251,10 +254,8 @@ export function TopLinks() { onApplyFilterValues={ filterParamKey ? onApplyFilterValues : undefined } - onImmediateFilter={ - filterParamKey - ? (val) => onApplyFilterValues([val]) - : undefined + onRowFilterItem={ + filterParamKey ? (val) => onApplyFilterValues([val]) : undefined } {...(limit && { limit })} /> From e1b1f8d8b043353cce95a9ff70d9854e972a6e96 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 27 Mar 2026 23:50:43 +0530 Subject: [PATCH 11/12] Enable tsup clean option to prevent stale chunks (#3658) Co-authored-by: Steven Tey --- packages/ui/src/status-badge.tsx | 2 +- packages/ui/tsup.config.ts | 2 +- packages/utils/tsup.config.ts | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/status-badge.tsx b/packages/ui/src/status-badge.tsx index 5435722585f..738e78d225d 100644 --- a/packages/ui/src/status-badge.tsx +++ b/packages/ui/src/status-badge.tsx @@ -11,7 +11,7 @@ import { import { DynamicTooltipWrapper } from "./tooltip"; const statusBadgeVariants = cva( - "flex gap-1.5 items-center max-w-fit rounded-md px-2 py-1 text-xs font-medium whitespace-nowrap", + "flex gap-1.5 items-center max-w-fit max-h-fit rounded-md px-2 py-1 text-xs font-medium whitespace-nowrap", { variants: { variant: { diff --git a/packages/ui/tsup.config.ts b/packages/ui/tsup.config.ts index 9eb078d4240..0a38c590afa 100644 --- a/packages/ui/tsup.config.ts +++ b/packages/ui/tsup.config.ts @@ -6,7 +6,6 @@ export default defineConfig((options: Options) => ({ "icons/index": "src/icons/index.tsx", "charts/index": "src/charts/index.ts", }, - format: ["esm"], esbuildOptions(options) { options.banner = { @@ -15,6 +14,7 @@ export default defineConfig((options: Options) => ({ }, dts: true, minify: true, + clean: true, external: ["react"], ...options, })); diff --git a/packages/utils/tsup.config.ts b/packages/utils/tsup.config.ts index 7a44221ff9a..75950b54657 100644 --- a/packages/utils/tsup.config.ts +++ b/packages/utils/tsup.config.ts @@ -5,6 +5,7 @@ export default defineConfig((options: Options) => ({ format: ["esm"], dts: true, minify: true, + clean: true, external: ["react"], ...options, })); From 7d8b2090267a8e05a60a2b538f6c0963b1953d28 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Fri, 27 Mar 2026 11:20:48 -0700 Subject: [PATCH 12/12] Update crawl-bitly.ts --- apps/web/lib/middleware/utils/crawl-bitly.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/lib/middleware/utils/crawl-bitly.ts b/apps/web/lib/middleware/utils/crawl-bitly.ts index 08c3e74eb32..f5fbc42a894 100644 --- a/apps/web/lib/middleware/utils/crawl-bitly.ts +++ b/apps/web/lib/middleware/utils/crawl-bitly.ts @@ -58,6 +58,7 @@ export const crawlBitly = async (req: NextRequest) => { }, data: { linksUsage: { increment: 1 }, + totalLinks: { increment: 1 }, }, }), ]),