From 9d57df012b30f795b66b87fc2ce199501019a1e9 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Mon, 23 Dec 2024 19:43:04 -0800 Subject: [PATCH] EmbedLeaderboard --- apps/web/app/api/embed/leaderboard/route.ts | 40 +++++++++ .../app.dub.co/embed/inline/page-client.tsx | 7 +- apps/web/app/app.dub.co/embed/leaderboard.tsx | 86 +++++++++++++++++++ apps/web/lib/zod/schemas/partners.ts | 21 +++++ packages/ui/src/icons/nucleo/crown.tsx | 27 ++++++ packages/ui/src/icons/nucleo/index.ts | 1 + 6 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 apps/web/app/api/embed/leaderboard/route.ts create mode 100644 apps/web/app/app.dub.co/embed/leaderboard.tsx create mode 100644 packages/ui/src/icons/nucleo/crown.tsx diff --git a/apps/web/app/api/embed/leaderboard/route.ts b/apps/web/app/api/embed/leaderboard/route.ts new file mode 100644 index 0000000000..09b59449d2 --- /dev/null +++ b/apps/web/app/api/embed/leaderboard/route.ts @@ -0,0 +1,40 @@ +import { withEmbedToken } from "@/lib/embed/auth"; +import { LeaderboardPartnerSchema } from "@/lib/zod/schemas/partners"; +import { prisma } from "@dub/prisma"; +import { NextResponse } from "next/server"; +import z from "node_modules/zod/lib"; + +// GET /api/embed/sales – get sales for a link from an embed token +export const GET = withEmbedToken(async ({ program, searchParams }) => { + const programEnrollments = await prisma.programEnrollment.findMany({ + where: { + programId: program.id, + }, + orderBy: [ + { + link: { + saleAmount: "desc", + }, + }, + { + link: { + leads: "desc", + }, + }, + { + link: { + clicks: "desc", + }, + }, + ], + select: { + partner: true, + link: true, + }, + take: 10, + }); + + return NextResponse.json( + z.array(LeaderboardPartnerSchema).parse(programEnrollments), + ); +}); diff --git a/apps/web/app/app.dub.co/embed/inline/page-client.tsx b/apps/web/app/app.dub.co/embed/inline/page-client.tsx index 2a5d128a5e..a8ef5fc09f 100644 --- a/apps/web/app/app.dub.co/embed/inline/page-client.tsx +++ b/apps/web/app/app.dub.co/embed/inline/page-client.tsx @@ -15,6 +15,7 @@ import { import { cn, getPrettyUrl } from "@dub/utils"; import { CSSProperties, useState } from "react"; import { EmbedActivity } from "../activity"; +import { EmbedLeaderboard } from "../leaderboard"; import { EmbedPayouts } from "../payouts"; import { EmbedSales } from "../sales"; import { LinkToken } from "../token"; @@ -113,7 +114,11 @@ export function EmbedInlinePageClient({ }} className="w-full rounded-lg" /> - + {selectedTab === "Leaderboard" ? ( + + ) : ( + + )} diff --git a/apps/web/app/app.dub.co/embed/leaderboard.tsx b/apps/web/app/app.dub.co/embed/leaderboard.tsx new file mode 100644 index 0000000000..68235bff53 --- /dev/null +++ b/apps/web/app/app.dub.co/embed/leaderboard.tsx @@ -0,0 +1,86 @@ +import z from "@/lib/zod"; +import { LeaderboardPartnerSchema } from "@/lib/zod/schemas/partners"; +import { AnimatedEmptyState } from "@/ui/shared/animated-empty-state"; +import { Crown, Table, Users, useTable } from "@dub/ui"; +import { currencyFormatter, fetcher } from "@dub/utils"; +import { cn } from "@dub/utils/src/functions"; +import useSWR from "swr"; + +export function EmbedLeaderboard() { + const { data: partners, isLoading } = useSWR< + z.infer[] + >("/api/embed/leaderboard", fetcher, { + keepPreviousData: true, + }); + + const { table, ...tableProps } = useTable({ + data: partners || [], + loading: isLoading, + columns: [ + { + id: "position", + header: "Position", + cell: ({ row }) => { + return ( +
+ {row.index + 1} + {row.index <= 2 && ( + + )} +
+ ); + }, + }, + { + id: "name", + header: "Name", + cell: ({ row }) => { + return row.original.partner.name; + }, + }, + { + id: "sales", + header: "Sales", + cell: ({ row }) => { + return currencyFormatter(row.original.link?.saleAmount / 100 ?? 0, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + }, + }, + ], + emptyState: ( + ( + <> + +
+ + )} + className="border-none md:min-h-fit" + /> + ), + thClassName: "border-l-0", + tdClassName: "border-l-0", + resourceName: (plural) => `partner${plural ? "s" : ""}`, + }); + + return ( +
+ +
+
+ ); +} diff --git a/apps/web/lib/zod/schemas/partners.ts b/apps/web/lib/zod/schemas/partners.ts index 2f8823fd38..ba9b9758a4 100644 --- a/apps/web/lib/zod/schemas/partners.ts +++ b/apps/web/lib/zod/schemas/partners.ts @@ -7,6 +7,7 @@ import { import { COUNTRY_CODES } from "@dub/utils"; import { z } from "zod"; import { CustomerSchema } from "./customers"; +import { LinkSchema } from "./links"; import { getPaginationQuerySchema } from "./misc"; import { ProgramEnrollmentSchema } from "./programs"; import { parseDateSchema } from "./utils"; @@ -66,6 +67,26 @@ export const EnrolledPartnerSchema = PartnerSchema.omit({ earnings: z.number(), }); +export const LeaderboardPartnerSchema = z.object({ + partner: z.object({ + id: z.string(), + name: z.string().transform((name) => { + const parts = name.trim().split(/\s+/); + if (parts.length < 2) return name; // Return original if single word + const firstName = parts[0]; + const lastInitial = parts[parts.length - 1][0]; + return `${firstName} ${lastInitial}.`; + }), + }), + link: LinkSchema.pick({ + shortLink: true, + clicks: true, + leads: true, + sales: true, + saleAmount: true, + }), +}); + export const SaleSchema = z.object({ id: z.string(), amount: z.number(), diff --git a/packages/ui/src/icons/nucleo/crown.tsx b/packages/ui/src/icons/nucleo/crown.tsx new file mode 100644 index 0000000000..7101399753 --- /dev/null +++ b/packages/ui/src/icons/nucleo/crown.tsx @@ -0,0 +1,27 @@ +import { SVGProps } from "react"; + +export function Crown(props: SVGProps) { + return ( + + + + + + + + + + ); +} diff --git a/packages/ui/src/icons/nucleo/index.ts b/packages/ui/src/icons/nucleo/index.ts index a67a0756c2..7aa5f82758 100644 --- a/packages/ui/src/icons/nucleo/index.ts +++ b/packages/ui/src/icons/nucleo/index.ts @@ -49,6 +49,7 @@ export * from "./connected-dots4"; export * from "./connections3"; export * from "./credit-card"; export * from "./crosshairs3"; +export * from "./crown"; export * from "./cube"; export * from "./cube-settings"; export * from "./cube-settings-fill";