(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 }) {
{/* 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}
+ >
+
+ );
+}
diff --git a/apps/web/lib/actions/partners/update-program-enrollment-preferences.ts b/apps/web/lib/actions/partners/update-program-enrollment-preferences.ts
new file mode 100644
index 00000000000..c89dbd21ca3
--- /dev/null
+++ b/apps/web/lib/actions/partners/update-program-enrollment-preferences.ts
@@ -0,0 +1,59 @@
+"use server";
+
+import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw";
+import { prisma } from "@/lib/prisma";
+import { programEnrollmentPreferencesValueSchemas } from "@/lib/zod/schemas/programs";
+import { Prisma } from "@prisma/client";
+import * as z from "zod/v4";
+import { authPartnerActionClient } from "../safe-action";
+
+const schema = z.object({
+ programId: z.string(),
+ key: z.string(),
+ value: z.any(),
+});
+
+// Update preferences for a partner's program enrollment
+export const updateProgramEnrollmentPreferences = authPartnerActionClient
+ .inputSchema(schema)
+ .action(async ({ ctx, parsedInput }) => {
+ const { partner } = ctx;
+ const { programId, key, value } = parsedInput;
+
+ const valueSchema =
+ programEnrollmentPreferencesValueSchemas[
+ key as keyof typeof programEnrollmentPreferencesValueSchemas
+ ];
+
+ if (!valueSchema) {
+ throw new Error(`Invalid program enrollment preference key: ${key}`);
+ }
+
+ const parsedValue = valueSchema.parse(value);
+
+ const programEnrollment = await getProgramEnrollmentOrThrow({
+ partnerId: partner.id,
+ programId,
+ include: {},
+ });
+
+ const partnerPreferences =
+ (programEnrollment.partnerPreferences as Record<
+ string,
+ unknown
+ > | null) ?? {};
+
+ await prisma.programEnrollment.update({
+ where: {
+ id: programEnrollment.id,
+ },
+ data: {
+ partnerPreferences: {
+ ...partnerPreferences,
+ [key]: parsedValue,
+ } as Prisma.InputJsonValue,
+ },
+ });
+
+ return { ok: true };
+ });
diff --git a/apps/web/lib/analytics/get-analytics.ts b/apps/web/lib/analytics/get-analytics.ts
index 2ea19ff6aed..51f176a7369 100644
--- a/apps/web/lib/analytics/get-analytics.ts
+++ b/apps/web/lib/analytics/get-analytics.ts
@@ -41,6 +41,7 @@ export const getAnalytics = async (params: AnalyticsFilters) => {
isDeprecatedClicksEndpoint = false,
dataAvailableFrom,
query,
+ partnerId,
} = params;
const normalizedLinkId = ensureParsedFilter(linkId);
@@ -217,6 +218,7 @@ export const getAnalytics = async (params: AnalyticsFilters) => {
key: true,
url: true,
title: true,
+ ...(partnerId ? { partnerLinkTitle: true as const } : {}),
comments: true,
folderId: true,
partnerId: true,
@@ -236,7 +238,7 @@ export const getAnalytics = async (params: AnalyticsFilters) => {
key: link.key,
});
- return analyticsResponse[groupBy].parse({
+ const parsed = analyticsResponse[groupBy].parse({
...link,
link: link.id,
key: punyEncode(link.key),
@@ -247,6 +249,15 @@ export const getAnalytics = async (params: AnalyticsFilters) => {
createdAt: link.createdAt.toISOString(),
...item,
});
+
+ if (partnerId && "partnerLinkTitle" in link) {
+ return {
+ ...parsed,
+ partnerLinkTitle: link.partnerLinkTitle,
+ };
+ }
+
+ return parsed;
})
.filter((d) => d !== null);
} else if (groupBy === "top_partners") {
diff --git a/apps/web/lib/api/links/create-link.ts b/apps/web/lib/api/links/create-link.ts
index f5b2e9ec576..5453a125f58 100644
--- a/apps/web/lib/api/links/create-link.ts
+++ b/apps/web/lib/api/links/create-link.ts
@@ -60,6 +60,9 @@ export async function createLink(link: ProcessedLinkProps) {
shortLink: linkConstructorSimple({ domain: link.domain, key }),
title: truncate(title, 120),
description: truncate(description, 240),
+ partnerLinkTitle: rest.partnerLinkTitle
+ ? truncate(rest.partnerLinkTitle, 120)
+ : rest.partnerLinkTitle,
// if it's an uploaded image, make this null first because we'll update it later
image: proxy && image && isNotHostedImage(image) ? null : image,
utm_source,
diff --git a/apps/web/lib/api/links/update-link.ts b/apps/web/lib/api/links/update-link.ts
index 6f19f679f88..c0efcaa0c7a 100644
--- a/apps/web/lib/api/links/update-link.ts
+++ b/apps/web/lib/api/links/update-link.ts
@@ -91,6 +91,9 @@ export async function updateLink({
}),
title: truncate(title, 120),
description: truncate(description, 240),
+ partnerLinkTitle: rest.partnerLinkTitle
+ ? truncate(rest.partnerLinkTitle, 120)
+ : rest.partnerLinkTitle,
image:
proxy && image && isNotHostedImage(image)
? `${R2_URL}/images/${id}_${imageUrlNonce}`
diff --git a/apps/web/lib/swr/use-partner-links-display.tsx b/apps/web/lib/swr/use-partner-links-display.tsx
new file mode 100644
index 00000000000..4137a506c7c
--- /dev/null
+++ b/apps/web/lib/swr/use-partner-links-display.tsx
@@ -0,0 +1,149 @@
+"use client";
+
+import { updateProgramEnrollmentPreferences } from "@/lib/actions/partners/update-program-enrollment-preferences";
+import useProgramEnrollment from "@/lib/swr/use-program-enrollment";
+import { useAction } from "next-safe-action/hooks";
+import {
+ createContext,
+ Dispatch,
+ PropsWithChildren,
+ SetStateAction,
+ useEffect,
+ useMemo,
+ useState,
+} from "react";
+import { mutate } from "swr";
+
+type PartnerLinksDisplayOption = "full" | "cards";
+type PartnerLinksDisplayProperty = "link" | "title";
+
+type PartnerLinksDisplayPreferences = {
+ displayOption: PartnerLinksDisplayOption;
+ displayProperties: PartnerLinksDisplayProperty[];
+};
+
+const DEFAULT_DISPLAY_PROPERTIES: PartnerLinksDisplayProperty[] = ["link"];
+
+/** Read persisted partner links display prefs (for Analytics / Events / filters). */
+export function usePartnerLinksDisplay() {
+ const { programEnrollment } = useProgramEnrollment();
+
+ return {
+ displayProperties:
+ programEnrollment?.partnerPreferences?.linksDisplay?.displayProperties ??
+ DEFAULT_DISPLAY_PROPERTIES,
+ };
+}
+
+type PartnerLinksDisplayContextValue = {
+ displayOption: PartnerLinksDisplayOption;
+ setDisplayOption: Dispatch>;
+ displayProperties: PartnerLinksDisplayProperty[];
+ setDisplayProperties: Dispatch<
+ SetStateAction
+ >;
+ isDirty: boolean;
+ persist: () => void | Promise;
+ reset: () => void;
+};
+
+export const PartnerLinksDisplayContext =
+ createContext({
+ displayOption: "full",
+ setDisplayOption: () => {},
+ displayProperties: DEFAULT_DISPLAY_PROPERTIES,
+ setDisplayProperties: () => {},
+ isDirty: false,
+ persist: () => {},
+ reset: () => {},
+ });
+
+export function PartnerLinksDisplayProvider({
+ children,
+ linksCount,
+ showDetailedAnalytics,
+}: PropsWithChildren<{
+ linksCount?: number;
+ showDetailedAnalytics?: boolean;
+}>) {
+ const { programEnrollment } = useProgramEnrollment();
+ const { executeAsync } = useAction(updateProgramEnrollmentPreferences);
+
+ const persisted = programEnrollment?.partnerPreferences?.linksDisplay ?? null;
+
+ const resolvedPersisted = useMemo(() => {
+ if (persisted) return persisted;
+ return {
+ displayOption:
+ (linksCount && linksCount > 5) || !showDetailedAnalytics
+ ? "cards"
+ : "full",
+ displayProperties: DEFAULT_DISPLAY_PROPERTIES,
+ };
+ }, [persisted, linksCount, showDetailedAnalytics]);
+
+ const [displayOption, setDisplayOption] = useState(
+ resolvedPersisted.displayOption,
+ );
+ const [displayProperties, setDisplayProperties] = useState<
+ PartnerLinksDisplayProperty[]
+ >(resolvedPersisted.displayProperties);
+
+ // Sync draft when persisted defaults resolve (e.g. after enrollment/links load)
+ useEffect(() => {
+ setDisplayOption(resolvedPersisted.displayOption);
+ setDisplayProperties(resolvedPersisted.displayProperties);
+ }, [resolvedPersisted]);
+
+ const isDirty = useMemo(() => {
+ if (displayOption !== resolvedPersisted.displayOption) return true;
+ if (
+ displayProperties.slice().sort().join(",") !==
+ resolvedPersisted.displayProperties.slice().sort().join(",")
+ ) {
+ return true;
+ }
+ return false;
+ }, [displayOption, displayProperties, resolvedPersisted]);
+
+ const effectiveDisplayOption = !showDetailedAnalytics
+ ? "cards"
+ : displayOption;
+
+ const persist = async () => {
+ if (!programEnrollment?.programId) return;
+
+ await executeAsync({
+ programId: programEnrollment.programId,
+ key: "linksDisplay",
+ value: {
+ displayOption,
+ displayProperties,
+ },
+ });
+
+ const programSlug = programEnrollment.program?.slug;
+ if (programSlug) {
+ mutate(`/api/partner-profile/programs/${programSlug}`);
+ }
+ };
+
+ return (
+ {
+ setDisplayOption(resolvedPersisted.displayOption);
+ setDisplayProperties(resolvedPersisted.displayProperties);
+ },
+ }}
+ >
+ {children}
+
+ );
+}
diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts
index a81a6a161ba..2fab098749d 100644
--- a/apps/web/lib/types.ts
+++ b/apps/web/lib/types.ts
@@ -388,7 +388,10 @@ export interface SAMLProviderProps {
};
}
-export type NewLinkProps = z.infer;
+export type NewLinkProps = z.infer & {
+ partnerLinkTitle?: string | null;
+ partnerLinkComments?: string | null;
+};
type ProcessedLinkOverrides = "domain" | "key" | "url" | "projectId";
diff --git a/apps/web/lib/zod/schemas/partner-profile.ts b/apps/web/lib/zod/schemas/partner-profile.ts
index baa1d344f7f..ac4f6ead3fb 100644
--- a/apps/web/lib/zod/schemas/partner-profile.ts
+++ b/apps/web/lib/zod/schemas/partner-profile.ts
@@ -27,7 +27,7 @@ import { LinkSchema } from "./links";
import { getPaginationQuerySchema } from "./misc";
import { payoutsQuerySchema } from "./payouts";
import { submittedLeadFormDataSchema } from "./submitted-lead-form";
-import { centsSchema } from "./utils";
+import { centsSchema, parseUrlSchema } from "./utils";
export const PartnerEarningsSchema = CommissionSchema.omit({
userId: true,
@@ -44,7 +44,11 @@ export const PartnerEarningsSchema = CommissionSchema.omit({
id: true,
shortLink: true,
url: true,
- }).nullish(),
+ })
+ .extend({
+ partnerLinkTitle: z.string().nullish(),
+ })
+ .nullish(),
});
export const getPartnerEarningsQuerySchema = getCommissionsQuerySchema
@@ -82,6 +86,32 @@ export const getPartnerEarningsTimeseriesSchema =
timezone: z.string().optional(),
});
+export const createPartnerProfileLinkSchema = z.object({
+ url: parseUrlSchema
+ .nullish()
+ .describe(
+ "The URL to shorten (if not provided, the program's default URL will be used).",
+ ),
+ key: z
+ .string()
+ .max(190)
+ .optional()
+ .describe(
+ "The short link slug. If not provided, a random 7-character slug will be generated.",
+ ),
+ partnerLinkTitle: z
+ .string()
+ .nullish()
+ .describe("The partner's private display title for the short link."),
+ partnerLinkComments: z
+ .string()
+ .nullish()
+ .describe("The partner's private comments for the short link."),
+});
+
+export const updatePartnerProfileLinkSchema =
+ createPartnerProfileLinkSchema.partial();
+
export const PartnerProfileLinkSchema = LinkSchema.pick({
id: true,
domain: true,
@@ -92,8 +122,9 @@ export const PartnerProfileLinkSchema = LinkSchema.pick({
leads: true,
sales: true,
saleAmount: true,
- comments: true,
}).extend({
+ partnerLinkTitle: z.string().nullish().default(null),
+ partnerLinkComments: z.string().nullish().default(null),
createdAt: z.string().or(z.date()),
partnerGroupDefaultLinkId: z.string().nullish(),
discountCode: z.string().nullable().default(null),
@@ -108,7 +139,18 @@ export const PartnerProfileCustomerSchema = CustomerEnrichedSchema.pick({
firstSaleAt: true,
subscriptionCanceledAt: true,
}).extend({
- activity: customerActivityResponseSchema,
+ activity: customerActivityResponseSchema.extend({
+ link: LinkSchema.pick({
+ id: true,
+ domain: true,
+ key: true,
+ shortLink: true,
+ })
+ .extend({
+ partnerLinkTitle: z.string().nullish(),
+ })
+ .nullish(),
+ }),
});
export const partnerProfileAnalyticsQuerySchema = analyticsQuerySchema.omit({
diff --git a/apps/web/lib/zod/schemas/programs.ts b/apps/web/lib/zod/schemas/programs.ts
index 0c1a5d10769..e0b4eb7e469 100644
--- a/apps/web/lib/zod/schemas/programs.ts
+++ b/apps/web/lib/zod/schemas/programs.ts
@@ -137,6 +137,15 @@ export const ProgramEnrollmentApplicationSchema = z.object({
.describe("When the application was approved or rejected."),
});
+const partnerLinksDisplaySchema = z.object({
+ displayOption: z.enum(["full", "cards"]),
+ displayProperties: z.array(z.enum(["link", "title"])),
+});
+
+export const programEnrollmentPreferencesValueSchemas = {
+ linksDisplay: partnerLinksDisplaySchema.nullish(),
+} as const;
+
export const ProgramEnrollmentSchema = z.object({
programId: z.string().describe("The program's unique ID on Dub."),
groupId: z.string().nullish().describe("The partner's group ID on Dub."),
@@ -199,6 +208,11 @@ export const ProgramEnrollmentSchema = z.object({
"Linked program application, including review outcome when applicable.",
),
riskMonitoringDisabledAt: z.date().nullable(),
+ partnerPreferences: z
+ .object({
+ linksDisplay: partnerLinksDisplaySchema.nullish(),
+ })
+ .nullish(),
});
export const ProgramInviteSchema = z.object({
diff --git a/apps/web/prisma/schema/link.prisma b/apps/web/prisma/schema/link.prisma
index 881a004bf4a..91ab43f7bc2 100644
--- a/apps/web/prisma/schema/link.prisma
+++ b/apps/web/prisma/schema/link.prisma
@@ -78,6 +78,10 @@ model Link {
// Comments on the particular shortlink
comments String? @db.Text
+ // Partner-owned display label and notes
+ partnerLinkTitle String?
+ partnerLinkComments String? @db.Text
+
dashboard Dashboard?
programId String?
diff --git a/apps/web/prisma/schema/program.prisma b/apps/web/prisma/schema/program.prisma
index 7d88029486e..08a9421c981 100644
--- a/apps/web/prisma/schema/program.prisma
+++ b/apps/web/prisma/schema/program.prisma
@@ -147,6 +147,8 @@ model ProgramEnrollment {
reapplicationTimeframe ReapplicationTimeframe @default(standard)
riskMonitoringDisabledAt DateTime?
+ partnerPreferences Json? // UI prefs for this enrollment (e.g. linksDisplay)
+
partner Partner @relation(fields: [partnerId], references: [id], onUpdate: Cascade, onDelete: Cascade)
program Program @relation(fields: [programId], references: [id], onUpdate: Cascade, onDelete: Cascade)
links Link[]
diff --git a/apps/web/scripts/migrations/backfill-partner-link-comments.ts b/apps/web/scripts/migrations/backfill-partner-link-comments.ts
new file mode 100644
index 00000000000..83de0b5b8e6
--- /dev/null
+++ b/apps/web/scripts/migrations/backfill-partner-link-comments.ts
@@ -0,0 +1,55 @@
+import { prisma } from "@/lib/prisma";
+import "dotenv-flow/config";
+
+const BATCH_SIZE = 500;
+
+async function main() {
+ let totalProcessed = 0;
+
+ while (true) {
+ const links = await prisma.link.findMany({
+ where: {
+ programId: { not: null },
+ partnerId: { not: null },
+ comments: { not: null },
+ partnerLinkComments: null,
+ NOT: {
+ comments: "",
+ },
+ },
+ select: {
+ id: true,
+ comments: true,
+ },
+ take: BATCH_SIZE,
+ orderBy: {
+ createdAt: "asc",
+ },
+ });
+
+ if (links.length === 0) {
+ break;
+ }
+
+ await Promise.all(
+ links.map(({ id, comments }) =>
+ prisma.link.updateMany({
+ where: { id, partnerLinkComments: null },
+ data: { partnerLinkComments: comments },
+ }),
+ ),
+ );
+
+ totalProcessed += links.length;
+
+ console.log(
+ `Backfilled ${links.length} partner link comments (processed=${totalProcessed})`,
+ );
+ }
+
+ console.log(
+ `Done backfilling partner link comments (processed=${totalProcessed})`,
+ );
+}
+
+main();
diff --git a/apps/web/scripts/misc/restore-program-enrollments.ts b/apps/web/scripts/misc/restore-program-enrollments.ts
index f3059816156..034064022ca 100644
--- a/apps/web/scripts/misc/restore-program-enrollments.ts
+++ b/apps/web/scripts/misc/restore-program-enrollments.ts
@@ -27,6 +27,7 @@ import { includeProgramEnrollment } from "@/lib/api/links/include-program-enroll
import { includeTags } from "@/lib/api/links/include-tags";
// import { prisma, prismaOld } from "@/lib/prisma";
import { prisma, prisma as prismaOld } from "@/lib/prisma";
+import { Prisma } from "@prisma/client";
import "dotenv-flow/config";
import { stripeAppClient } from "../../lib/stripe";
import { recordLink } from "../../lib/tinybird";
@@ -42,7 +43,13 @@ async function main() {
});
const res = await prisma.programEnrollment.createMany({
- data: programEnrollmentsToRestore,
+ data: programEnrollmentsToRestore.map(
+ ({ partnerPreferences, ...enrollment }) => ({
+ ...enrollment,
+ partnerPreferences:
+ partnerPreferences === null ? Prisma.DbNull : partnerPreferences,
+ }),
+ ),
});
console.log(`Restored ${res.count} program enrollments`);
diff --git a/apps/web/ui/analytics/events/events-table.tsx b/apps/web/ui/analytics/events/events-table.tsx
index 32ffa96fb4a..d0a458ea7e3 100644
--- a/apps/web/ui/analytics/events/events-table.tsx
+++ b/apps/web/ui/analytics/events/events-table.tsx
@@ -1,6 +1,7 @@
"use client";
import { editQueryString } from "@/lib/analytics/utils";
+import { usePartnerLinksDisplay } from "@/lib/swr/use-partner-links-display";
import useWorkspace from "@/lib/swr/use-workspace";
import { useWorkspacePreferences } from "@/lib/swr/use-workspace-preferences";
import { ClickEvent, LeadEvent, SaleEvent } from "@/lib/types";
@@ -167,18 +168,32 @@ export default function EventsTable({
);
const [persisted] = useWorkspacePreferences("linksDisplay");
+ const { displayProperties: partnerDisplayProperties } =
+ usePartnerLinksDisplay();
const shortLinkTitle = useCallback(
- (d: { url?: string; title?: string; shortLink?: string }) => {
+ (d: {
+ url?: string;
+ title?: string;
+ partnerLinkTitle?: string | null;
+ shortLink?: string;
+ }) => {
+ if (partnerPage) {
+ if (partnerDisplayProperties.includes("title") && d.partnerLinkTitle) {
+ return d.partnerLinkTitle;
+ }
+ return d.shortLink ? getPrettyUrl(d.shortLink) : "Unknown";
+ }
+
const displayProperties = persisted?.displayProperties;
if (displayProperties?.includes("title") && d.title) {
return d.title;
}
- return d.shortLink || "Unknown";
+ return d.shortLink ? getPrettyUrl(d.shortLink) : "Unknown";
},
- [persisted],
+ [persisted, partnerPage, partnerDisplayProperties],
);
const sortBy = searchParams.get("sortBy") || "timestamp";
@@ -336,6 +351,7 @@ export default function EventsTable({
filterParams: ({ getValue }) => ({ linkId: getValue().id }),
},
cell: ({ getValue }) => {
+ const label = shortLinkTitle(getValue());
const content = (
-
- {getPrettyUrl(shortLinkTitle(getValue()))}
+
+ {label}
);
@@ -697,7 +713,7 @@ export default function EventsTable({
minSize: col.minSize || 100,
maxSize: col.maxSize || 1000,
})),
- [tab, product, partnerPage],
+ [tab, product, partnerPage, shortLinkTitle],
);
const { pagination, setPagination } = usePagination();
diff --git a/apps/web/ui/analytics/top-links.tsx b/apps/web/ui/analytics/top-links.tsx
index b81a76df91b..920b04cb093 100644
--- a/apps/web/ui/analytics/top-links.tsx
+++ b/apps/web/ui/analytics/top-links.tsx
@@ -1,4 +1,5 @@
import { AnalyticsGroupByOptions } from "@/lib/analytics/types";
+import { usePartnerLinksDisplay } from "@/lib/swr/use-partner-links-display";
import { useWorkspacePreferences } from "@/lib/swr/use-workspace-preferences";
import { LinkLogo, useRouterStuff } from "@dub/ui";
import { Globe, Hyperlink } from "@dub/ui/icons";
@@ -133,6 +134,8 @@ export function TopLinks() {
});
const [persisted] = useWorkspacePreferences("linksDisplay");
+ const { displayProperties: partnerDisplayProperties } =
+ usePartnerLinksDisplay();
const getItemTitle = useCallback(
(d: Record) => {
@@ -148,6 +151,16 @@ export function TopLinks() {
return d.tag?.name || "Unknown";
}
+ if (partnerPage) {
+ if (
+ partnerDisplayProperties.includes("title") &&
+ d.partnerLinkTitle
+ ) {
+ return d.partnerLinkTitle;
+ }
+ return d.shortLink || "Unknown";
+ }
+
// For links subtab
const displayProperties = persisted?.displayProperties;
@@ -157,7 +170,7 @@ export function TopLinks() {
return d.shortLink || "Unknown";
},
- [persisted, tab, subtab],
+ [persisted, tab, subtab, partnerPage, partnerDisplayProperties],
);
const mapItem = useCallback(
diff --git a/apps/web/ui/analytics/use-analytics-filters.tsx b/apps/web/ui/analytics/use-analytics-filters.tsx
index e9041b57bec..e0044ce8a27 100644
--- a/apps/web/ui/analytics/use-analytics-filters.tsx
+++ b/apps/web/ui/analytics/use-analytics-filters.tsx
@@ -3,6 +3,7 @@ import { VALID_ANALYTICS_FILTERS } from "@/lib/analytics/constants";
import useCustomer from "@/lib/swr/use-customer";
import usePartner from "@/lib/swr/use-partner";
import usePartnerCustomer from "@/lib/swr/use-partner-customer";
+import { usePartnerLinksDisplay } from "@/lib/swr/use-partner-links-display";
import { usePartnerTags } from "@/lib/swr/use-partner-tags";
import { CustomerAvatar } from "@/ui/customers/customer-avatar";
import { PartnerAvatar } from "@/ui/partners/partner-avatar";
@@ -422,6 +423,9 @@ export function useAnalyticsFilters({
const [streaming, setStreaming] = useState(false);
+ const { displayProperties: partnerDisplayProperties } =
+ usePartnerLinksDisplay();
+
const LinkFilterItem = {
key: "linkId",
icon: Hyperlink,
@@ -432,11 +436,16 @@ export function useAnalyticsFilters({
return ;
},
options:
- links?.map(({ id, domain, key, url, ...rest }) => ({
+ links?.map(({ id, domain, key, url, partnerLinkTitle, ...rest }) => ({
value: id,
- label: linkConstructor({ domain, key, pretty: true }),
+ label:
+ partnerPage &&
+ partnerDisplayProperties.includes("title") &&
+ partnerLinkTitle
+ ? partnerLinkTitle
+ : linkConstructor({ domain, key, pretty: true }),
right: getFilterOptionTotal(rest),
- data: { url, domain, key },
+ data: { url, domain, key, partnerLinkTitle },
})) ?? null,
};
@@ -901,6 +910,7 @@ export function useAnalyticsFilters({
utmData,
searchParamsObj.tagId,
searchParamsObj.domain,
+ partnerDisplayProperties,
],
);
diff --git a/apps/web/ui/modals/partner-link-modal.tsx b/apps/web/ui/modals/partner-link-modal.tsx
index ce228bc3efd..71a315da075 100644
--- a/apps/web/ui/modals/partner-link-modal.tsx
+++ b/apps/web/ui/modals/partner-link-modal.tsx
@@ -53,7 +53,8 @@ import { usePartnerLinkQRModal } from "./partner-link-qr-modal";
interface PartnerLinkFormData {
key: string;
pathname: string;
- comments?: string;
+ partnerLinkTitle?: string;
+ partnerLinkComments?: string;
}
interface PartnerLinkModalProps {
@@ -218,7 +219,8 @@ function PartnerLinkModalContent({
? {
pathname: getPathnameFromUrl(link.url),
key: link.key,
- comments: link.comments ?? "",
+ partnerLinkTitle: link.partnerLinkTitle ?? "",
+ partnerLinkComments: link.partnerLinkComments ?? "",
}
: undefined,
});
@@ -470,16 +472,35 @@ function PartnerLinkModalContent({
+
+