diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/count/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/count/route.ts index e6b2eb963dc..9941c8cb145 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/count/route.ts @@ -103,6 +103,7 @@ export const GET = withPartnerProfile( key: true, shortLink: true, url: true, + partnerLinkTitle: true, }, }); @@ -116,6 +117,7 @@ export const GET = withPartnerProfile( key: link.key, shortLink: link.shortLink, url: link.url, + partnerLinkTitle: link?.partnerLinkTitle, }; }) .filter(Boolean); diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts index ce4a31c8f25..ca472458f62 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts @@ -86,6 +86,7 @@ export const GET = withPartnerProfile( domain: link?.domain, key: link?.key, url: link?.url, + partnerLinkTitle: link?.partnerLinkTitle, _count, }; }) as any[]; // TODO: find a better fix for types diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts index 0fcfafa048a..75f49ce5d9e 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts @@ -7,17 +7,18 @@ import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; import { withPartnerProfile } from "@/lib/auth/partner"; import { prisma } from "@/lib/prisma"; import { NewLinkProps } from "@/lib/types"; -import { PartnerProfileLinkSchema } from "@/lib/zod/schemas/partner-profile"; -import { createPartnerLinkSchema } from "@/lib/zod/schemas/partners"; +import { + PartnerProfileLinkSchema, + updatePartnerProfileLinkSchema, +} from "@/lib/zod/schemas/partner-profile"; import { getPrettyUrl, toCentsNumber } from "@dub/utils"; import { NextResponse } from "next/server"; // PATCH /api/partner-profile/[programId]/links/[linkId] - update a link for a partner export const PATCH = withPartnerProfile( async ({ partner, params, req, session }) => { - const { url, key, comments } = createPartnerLinkSchema - .pick({ url: true, key: true, comments: true }) - .parse(await parseRequestBody(req)); + const { url, key, partnerLinkTitle, partnerLinkComments } = + updatePartnerProfileLinkSchema.parse(await parseRequestBody(req)); const { programId, linkId } = params; @@ -68,7 +69,9 @@ export const PATCH = withPartnerProfile( }); } - const linkUrlChanged = getPrettyUrl(link.url) !== getPrettyUrl(url); + const nextUrl = url ?? link.url; + const nextKey = key ?? link.key; + const linkUrlChanged = getPrettyUrl(link.url) !== getPrettyUrl(nextUrl); if (linkUrlChanged) { if (link.partnerGroupDefaultLinkId) { @@ -78,7 +81,7 @@ export const PATCH = withPartnerProfile( "You cannot update the destination URL of your default link.", }); } else { - validatePartnerLinkUrl({ group, url }); + validatePartnerLinkUrl({ group, url: nextUrl }); } } @@ -92,7 +95,7 @@ export const PATCH = withPartnerProfile( : null; // if domain and key are the same, we don't need to check if the key exists - const skipKeyChecks = link.key.toLowerCase() === key?.toLowerCase(); + const skipKeyChecks = link.key.toLowerCase() === nextKey.toLowerCase(); const { link: processedLink, @@ -118,10 +121,16 @@ export const PATCH = withPartnerProfile( ? link.testStartedAt.toISOString() : link.testStartedAt, - // merge in new props - key: key || undefined, - url: url || program.url, - comments, + key: nextKey, + url: nextUrl, + partnerLinkTitle: + partnerLinkTitle !== undefined + ? partnerLinkTitle + : link.partnerLinkTitle, + partnerLinkComments: + partnerLinkComments !== undefined + ? partnerLinkComments + : link.partnerLinkComments, }, workspace: { id: program.workspaceId, diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts index fb1f32983df..c6837de1993 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts @@ -6,11 +6,11 @@ import { parseRequestBody } from "@/lib/api/utils"; import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; import { withPartnerProfile } from "@/lib/auth/partner"; import { prisma } from "@/lib/prisma"; -import { PartnerProfileLinkSchema } from "@/lib/zod/schemas/partner-profile"; import { - createPartnerLinkSchema, - INACTIVE_ENROLLMENT_STATUSES, -} from "@/lib/zod/schemas/partners"; + createPartnerProfileLinkSchema, + PartnerProfileLinkSchema, +} from "@/lib/zod/schemas/partner-profile"; +import { INACTIVE_ENROLLMENT_STATUSES } from "@/lib/zod/schemas/partners"; import { getUTMParamsFromURL } from "@dub/utils"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; @@ -47,9 +47,8 @@ export const GET = withPartnerProfile(async ({ partner, params }) => { // POST /api/partner-profile/[programId]/links - create a link for a partner export const POST = withPartnerProfile( async ({ partner, params, req, session }) => { - const { url, key, comments } = createPartnerLinkSchema - .pick({ url: true, key: true, comments: true }) - .parse(await parseRequestBody(req)); + const { url, key, partnerLinkTitle, partnerLinkComments } = + createPartnerProfileLinkSchema.parse(await parseRequestBody(req)); const { program, @@ -125,7 +124,8 @@ export const POST = withPartnerProfile( tenantId, partnerId: partner.id, folderId: program.defaultFolderId, - comments, + partnerLinkTitle, + partnerLinkComments, trackConversion: true, }, workspace: { diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/customers/(index)/page.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/customers/(index)/page.tsx index 6b4ba8dcfdd..7eab2353628 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/customers/(index)/page.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/customers/(index)/page.tsx @@ -3,6 +3,7 @@ import { PARTNER_CUSTOMERS_MAX_PAGE_SIZE } from "@/lib/constants/partner-profile"; import usePartnerCustomers from "@/lib/swr/use-partner-customers"; import usePartnerCustomersCount from "@/lib/swr/use-partner-customers-count"; +import { usePartnerLinksDisplay } from "@/lib/swr/use-partner-links-display"; import useProgramEnrollment from "@/lib/swr/use-program-enrollment"; import { CustomerRowItem } from "@/ui/customers/customer-row-item"; import { AnimatedEmptyState } from "@/ui/shared/animated-empty-state"; @@ -32,6 +33,7 @@ export default function PartnerProgramCustomersPage() { const { programSlug } = useParams<{ programSlug: string }>(); const { programEnrollment } = useProgramEnrollment(); + const { displayProperties } = usePartnerLinksDisplay(); const { data: customersCount, error: countError } = usePartnerCustomersCount(); @@ -126,9 +128,17 @@ export default function PartnerProgramCustomersPage() { /> - {getPrettyUrl(row.original.activity.link.shortLink)} + {displayProperties.includes("title") && + row.original.activity.link.partnerLinkTitle + ? row.original.activity.link.partnerLinkTitle + : getPrettyUrl(row.original.activity.link.shortLink)} ) : ( @@ -209,7 +219,7 @@ export default function PartnerProgramCustomersPage() { header: () => , }, ].filter((c) => c.id === "menu" || customersColumns.all.includes(c.id)), - [programSlug], + [programSlug, displayProperties], ); const { table, ...tableProps } = useTable({ diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/customers/(index)/use-partner-customer-filters.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/customers/(index)/use-partner-customer-filters.tsx index 8a8be3fd8e2..372af2983d0 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/customers/(index)/use-partner-customer-filters.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/customers/(index)/use-partner-customer-filters.tsx @@ -1,4 +1,5 @@ import usePartnerCustomersCount from "@/lib/swr/use-partner-customers-count"; +import { usePartnerLinksDisplay } from "@/lib/swr/use-partner-links-display"; import { CountryFlag } from "@/ui/shared/country-flag"; import { useRouterStuff } from "@dub/ui"; import { Globe, Hyperlink } from "@dub/ui/icons"; @@ -8,6 +9,7 @@ import { useCallback, useMemo, useState } from "react"; export function usePartnerCustomerFilters() { const { searchParamsObj, queryParams } = useRouterStuff(); const [selectedFilter, setSelectedFilter] = useState(null); + const { displayProperties } = usePartnerLinksDisplay(); const { data: countriesCount } = usePartnerCustomersCount< | { @@ -28,6 +30,7 @@ export function usePartnerCustomerFilters() { domain: string; key: string; shortLink: string; + partnerLinkTitle?: string | null; _count: number; }[] | undefined @@ -55,15 +58,20 @@ export function usePartnerCustomerFilters() { icon: Hyperlink, label: "Link", options: - linksCount?.map(({ linkId, domain, key, shortLink, _count }) => ({ - value: linkId, - label: linkConstructor({ domain, key, pretty: true }), - data: { shortLink }, - right: nFormatter(_count, { full: true }), - })) ?? null, + linksCount?.map( + ({ linkId, domain, key, shortLink, partnerLinkTitle, _count }) => ({ + value: linkId, + label: + displayProperties.includes("title") && partnerLinkTitle + ? partnerLinkTitle + : linkConstructor({ domain, key, pretty: true }), + data: { shortLink }, + right: nFormatter(_count, { full: true }), + }), + ) ?? null, }, ], - [countriesCount, linksCount], + [countriesCount, linksCount, displayProperties], ); const activeFilters = useMemo(() => { diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/earnings/earnings-composite-chart.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/earnings/earnings-composite-chart.tsx index 26b7fc6ec04..9250072ff0b 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/earnings/earnings-composite-chart.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/earnings/earnings-composite-chart.tsx @@ -6,6 +6,7 @@ import { IntervalOptions } from "@/lib/analytics/types"; import usePartnerEarningsCount from "@/lib/swr/use-partner-earnings-count"; import { usePartnerEarningsTimeseries } from "@/lib/swr/use-partner-earnings-timeseries"; import usePartnerLinks from "@/lib/swr/use-partner-links"; +import { usePartnerLinksDisplay } from "@/lib/swr/use-partner-links-display"; import { LinkIcon } from "@/ui/links/link-icon"; import { CommissionTypeIcon } from "@/ui/partners/comission-type-icon"; import { CommissionStatusBadges } from "@/ui/partners/commission-status-badges"; @@ -61,6 +62,7 @@ export function EarningsCompositeChart() { }; const { links } = usePartnerLinks(); + const { displayProperties } = usePartnerLinksDisplay(); const { data } = usePartnerEarningsTimeseries({ interval, @@ -208,9 +210,12 @@ export function EarningsCompositeChart() { )} /> - {link?.shortLink - ? getPrettyUrl(link.shortLink) - : capitalize(id)} + {displayProperties.includes("title") && + link?.partnerLinkTitle + ? link.partnerLinkTitle + : link?.shortLink + ? getPrettyUrl(link.shortLink) + : capitalize(id)}

@@ -253,9 +258,17 @@ export function EarningsCompositeChart() { function EarningsTableControls() { const { queryParams, searchParamsObj } = useRouterStuff(); const [selectedFilter, setSelectedFilter] = useState(null); + const { displayProperties } = usePartnerLinksDisplay(); const { earningsCount: links } = usePartnerEarningsCount< - { id: string; domain: string; key: string; url: string; _count: number }[] + { + id: string; + domain: string; + key: string; + url: string; + partnerLinkTitle?: string | null; + _count: number; + }[] >({ groupBy: "linkId", enabled: @@ -300,13 +313,16 @@ function EarningsTableControls() { return ; }, options: - links?.map(({ id, domain, key, url }) => ({ + links?.map(({ id, domain, key, url, partnerLinkTitle }) => ({ value: id, - label: linkConstructor({ - domain, - key, - pretty: true, - }), + label: + displayProperties.includes("title") && partnerLinkTitle + ? partnerLinkTitle + : linkConstructor({ + domain, + key, + pretty: true, + }), data: { url }, })) ?? null, }, @@ -342,7 +358,7 @@ function EarningsTableControls() { }), }, ], - [links, customers, statuses], + [links, customers, statuses, displayProperties], ); const activeFilters = useMemo(() => { diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/earnings/earnings-table.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/earnings/earnings-table.tsx index 2ce16ea3b28..f307c9283f3 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/earnings/earnings-table.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/earnings/earnings-table.tsx @@ -1,6 +1,7 @@ "use client"; import usePartnerEarningsCount from "@/lib/swr/use-partner-earnings-count"; +import { usePartnerLinksDisplay } from "@/lib/swr/use-partner-links-display"; import usePartnerProfile from "@/lib/swr/use-partner-profile"; import useProgramEnrollment from "@/lib/swr/use-program-enrollment"; import { PartnerEarningsResponse } from "@/lib/types"; @@ -48,6 +49,7 @@ export function EarningsTablePartner({ limit }: { limit?: number }) { const { partner } = usePartnerProfile(); const { programEnrollment, showDetailedAnalytics } = useProgramEnrollment(); const { queryParams, searchParamsObj, getQueryString } = useRouterStuff(); + const { displayProperties } = usePartnerLinksDisplay(); const { sortBy = "createdAt", sortOrder = "desc" } = searchParamsObj as { sortBy?: "createdAt"; @@ -113,15 +115,23 @@ export function EarningsTablePartner({ limit }: { limit?: number }) { row.original.link ? { linkId: row.original.link.id } : null, }, cell: ({ row }) => { - const referralLink = row.original.link + const link = row.original.link; + const referralLink = link ? { - apexDomain: getApexDomain(row.original.link.url), - shortLink: row.original.link.shortLink, + apexDomain: getApexDomain(link.url), + shortLink: link.shortLink, + label: + displayProperties.includes("title") && link.partnerLinkTitle + ? link.partnerLinkTitle + : getPrettyUrl(link.shortLink), } : row.original.type === CommissionType.referral && partner?.username ? { apexDomain: "dub.co", shortLink: `https://partners.dub.co/${programSlug}/apply?via=${partner.username}`, + label: getPrettyUrl( + `https://partners.dub.co/${programSlug}/apply?via=${partner.username}`, + ), } : null; @@ -138,8 +148,8 @@ export function EarningsTablePartner({ limit }: { limit?: number }) { successMessage="Copied link to clipboard!" className="truncate" > - - {getPrettyUrl(referralLink.shortLink)} + + {referralLink.label} diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/links/page-client.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/links/page-client.tsx index 5132e3823cd..6121c658367 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/links/page-client.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/links/page-client.tsx @@ -6,21 +6,20 @@ import { } from "@/lib/analytics/constants"; import { IntervalOptions } from "@/lib/analytics/types"; import usePartnerLinks from "@/lib/swr/use-partner-links"; +import { + PartnerLinksDisplayContext, + PartnerLinksDisplayProvider, +} from "@/lib/swr/use-partner-links-display"; import useProgramEnrollment from "@/lib/swr/use-program-enrollment"; import { usePartnerLinkModal } from "@/ui/modals/partner-link-modal"; import { AnimatedEmptyState } from "@/ui/shared/animated-empty-state"; import SimpleDateRangePicker from "@/ui/shared/simple-date-range-picker"; -import { - Button, - CardList, - ToggleGroup, - useKeyboardShortcut, - useRouterStuff, -} from "@dub/ui"; +import { Button, CardList, useKeyboardShortcut, useRouterStuff } from "@dub/ui"; import { ChartTooltipSync } from "@dub/ui/charts"; -import { CursorRays, GridIcon, GridLayoutRows, Hyperlink } from "@dub/ui/icons"; -import { createContext, useContext, useEffect, useState } from "react"; +import { CursorRays, Hyperlink } from "@dub/ui/icons"; +import { createContext, useContext, useState } from "react"; import { PartnerLinkCard } from "./partner-link-card"; +import { PartnerLinkDisplay } from "./partner-link-display"; const PartnerLinksContext = createContext<{ start?: Date; @@ -42,21 +41,47 @@ export function usePartnerLinksContext() { } export function PartnerProgramLinksPageClient() { - const { searchParamsObj } = useRouterStuff(); const { links, error, loading, isValidating } = usePartnerLinks(); const { programEnrollment, showDetailedAnalytics } = useProgramEnrollment(); - const { setShowPartnerLinkModal, PartnerLinkModal } = usePartnerLinkModal(); - const [openMenuLinkId, setOpenMenuLinkId] = useState(null); - const [displayOption, setDisplayOption] = useState<"full" | "cards">("full"); + return ( + + + + ); +} - useEffect(() => { - if ((links && links.length > 5) || !showDetailedAnalytics) { - setDisplayOption("cards"); - } else { - setDisplayOption("full"); - } - }, [links, showDetailedAnalytics]); +function PartnerProgramLinksPageInner({ + links, + error, + loading, + isValidating, + programEnrollment, + showDetailedAnalytics, +}: { + links: ReturnType["links"]; + error: ReturnType["error"]; + loading: boolean; + isValidating: boolean; + programEnrollment: ReturnType< + typeof useProgramEnrollment + >["programEnrollment"]; + showDetailedAnalytics?: boolean; +}) { + const { searchParamsObj } = useRouterStuff(); + const { setShowPartnerLinkModal, PartnerLinkModal } = usePartnerLinkModal(); + const [openMenuLinkId, setOpenMenuLinkId] = useState(null); + const { displayOption } = useContext(PartnerLinksDisplayContext); const { start, @@ -89,61 +114,35 @@ export function PartnerProgramLinksPageClient() {

-
- {!!showDetailedAnalytics && ( - - -
- ), - }, - { - value: "cards", - label: ( -
- -
- ), - }, - ]} - selected={displayOption} - selectAction={(option) => - setDisplayOption(option as "full" | "cards") - } - /> - )} -
+
@@ -111,44 +115,78 @@ export function PartnerLinkCard({ link }: { link: PartnerProfileLinkProps }) {
- e.preventDefault() : undefined - } - > - {getPrettyUrl(partnerLink)} - + {showTitle ? ( + + {link.partnerLinkTitle} + + ) : ( + e.preventDefault() : undefined + } + > + {getPrettyUrl(partnerLink)} + + )} {!isDeactivated && ( )} - {link.comments && } + {link.partnerLinkComments && ( + + )}
{/* The max width implementation here is a bit hacky, we should improve in the future */}
diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/links/partner-link-display.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/links/partner-link-display.tsx new file mode 100644 index 00000000000..add3354e65b --- /dev/null +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/links/partner-link-display.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { PartnerLinksDisplayContext } from "@/lib/swr/use-partner-links-display"; +import useProgramEnrollment from "@/lib/swr/use-program-enrollment"; +import { Button, Popover } from "@dub/ui"; +import { GridIcon, GridLayoutRows, Sliders } from "@dub/ui/icons"; +import { cn } from "@dub/utils"; +import { ChevronDown } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useContext, useState } from "react"; + +const DISPLAY_PROPERTIES = [ + { id: "link" as const, label: "Short link", switch: "title" as const }, + { id: "title" as const, label: "Title", switch: "link" as const }, +]; + +export function PartnerLinkDisplay() { + const { showDetailedAnalytics } = useProgramEnrollment(); + const { + displayOption, + setDisplayOption, + displayProperties, + setDisplayProperties, + isDirty, + persist, + reset, + } = useContext(PartnerLinksDisplayContext); + const [openPopover, setOpenPopover] = useState(false); + + return ( + + {!!showDetailedAnalytics && ( +
+ {[ + { id: "full" as const, label: "Full", icon: GridIcon }, + { id: "cards" as const, label: "Cards", icon: GridLayoutRows }, + ].map(({ id, label, icon: Icon }) => { + const selected = displayOption === id; + return ( + + ); + })} +
+ )} +
+ + Display Properties + +
+ {DISPLAY_PROPERTIES.map((property) => { + const active = displayProperties.includes(property.id); + return ( + + ); + })} +
+
+ + {isDirty && ( + +
+
+
+ )} +
+ + } + openPopover={openPopover} + setOpenPopover={setOpenPopover} + > +