diff --git a/apps/web/lib/api/links/get-links-count.ts b/apps/web/lib/api/links/get-links-count.ts index fa4fe28adcf..f0e6b40c484 100644 --- a/apps/web/lib/api/links/get-links-count.ts +++ b/apps/web/lib/api/links/get-links-count.ts @@ -2,6 +2,7 @@ import { combineTagIds } from "@/lib/api/tags/combine-tag-ids"; import { getLinksCountQuerySchema } from "@/lib/zod/schemas/links"; import { prisma } from "@dub/prisma"; import { z } from "zod"; +import { buildLinkFeaturesWhere } from "./utils"; interface GetLinksCountParams extends z.infer { workspaceId: string; @@ -22,6 +23,7 @@ export async function getLinksCount({ tenantId, workspaceId, folderIds, + linkFeatures, }: GetLinksCountParams) { const combinedTagIds = combineTagIds({ tagId, tagIds }); @@ -71,6 +73,7 @@ export async function getLinksCount({ userId, }), ...(tenantId && { tenantId }), + ...buildLinkFeaturesWhere(linkFeatures), }; if (groupBy === "tagId") { diff --git a/apps/web/lib/api/links/get-links-for-workspace.ts b/apps/web/lib/api/links/get-links-for-workspace.ts index 92c3959dc96..31cd2b47162 100644 --- a/apps/web/lib/api/links/get-links-for-workspace.ts +++ b/apps/web/lib/api/links/get-links-for-workspace.ts @@ -3,7 +3,7 @@ import { prisma } from "@dub/prisma"; import { z } from "zod"; import { combineTagIds } from "../tags/combine-tag-ids"; import { encodeKeyIfCaseSensitive } from "./case-sensitivity"; -import { transformLink } from "./utils"; +import { buildLinkFeaturesWhere, transformLink } from "./utils"; export interface GetLinksForWorkspaceProps extends z.infer { @@ -39,6 +39,7 @@ export async function getLinksForWorkspace({ partnerId, startDate, endDate, + linkFeatures, }: GetLinksForWorkspaceProps) { const combinedTagIds = combineTagIds({ tagId, tagIds }); @@ -143,6 +144,7 @@ export async function getLinksForWorkspace({ lte: endDate, }, }), + ...buildLinkFeaturesWhere(linkFeatures), }, include: { tags: { diff --git a/apps/web/lib/api/links/utils/build-link-features-where.ts b/apps/web/lib/api/links/utils/build-link-features-where.ts new file mode 100644 index 00000000000..0d2d1ce4e8d --- /dev/null +++ b/apps/web/lib/api/links/utils/build-link-features-where.ts @@ -0,0 +1,52 @@ +import { Prisma } from "@dub/prisma/client"; + +export function buildLinkFeaturesWhere( + linkFeatures?: string[], +): Record | undefined { + if (!linkFeatures || linkFeatures.length === 0) { + return undefined; + } + + return { + OR: linkFeatures.map((feature) => { + switch (feature) { + case "conversionTracking": + return { trackConversion: true }; + case "customLinkPreview": + return { proxy: true }; + case "geoTargeting": + return { geo: { not: Prisma.DbNull } }; + case "utmTags": + return { + OR: [ + { utm_source: { not: null } }, + { utm_medium: { not: null } }, + { utm_campaign: { not: null } }, + { utm_term: { not: null } }, + { utm_content: { not: null } }, + ], + }; + case "abTest": + return { testVariants: { not: Prisma.DbNull } }; + case "tags": + return { tags: { some: {} } }; + case "comments": + return { comments: { not: null } }; + case "iosTargeting": + return { ios: { not: null } }; + case "androidTargeting": + return { android: { not: null } }; + case "expiration": + return { expiresAt: { not: null } }; + case "password": + return { password: { not: null } }; + case "linkCloaking": + return { rewrite: true }; + case "searchEngineIndexing": + return { doIndex: true }; + default: + return {}; + } + }), + }; +} diff --git a/apps/web/lib/api/links/utils/index.ts b/apps/web/lib/api/links/utils/index.ts index 2bb37a624c3..a68605d070e 100644 --- a/apps/web/lib/api/links/utils/index.ts +++ b/apps/web/lib/api/links/utils/index.ts @@ -1,3 +1,4 @@ +export * from "./build-link-features-where"; export * from "./check-if-links-have-tags"; export * from "./check-if-links-have-webhooks"; export * from "./key-checks"; diff --git a/apps/web/lib/swr/use-links.ts b/apps/web/lib/swr/use-links.ts index 58542710e04..2435bc3bc76 100644 --- a/apps/web/lib/swr/use-links.ts +++ b/apps/web/lib/swr/use-links.ts @@ -46,6 +46,7 @@ export default function useLinks( "tagIds", "domain", "userId", + "linkFeatures", "search", "page", "sortBy", diff --git a/apps/web/lib/zod/schemas/links.ts b/apps/web/lib/zod/schemas/links.ts index cfeaf1406b0..da0c2ddc4cc 100644 --- a/apps/web/lib/zod/schemas/links.ts +++ b/apps/web/lib/zod/schemas/links.ts @@ -148,6 +148,43 @@ const LinksQuerySchema = z.object({ "DEPRECATED. Filter for links that have at least one tag assigned to them.", ) .openapi({ deprecated: true }), + linkFeatures: z + .union([z.string(), z.array(z.string())]) + .transform((v) => (Array.isArray(v) ? v : v.split(","))) + .optional() + .describe("Filter links by enabled features (comma-separated)") + .openapi({ + param: { + style: "form", + explode: false, + }, + anyOf: [ + { + type: "string", + }, + { + type: "array", + items: { + type: "string", + enum: [ + "conversionTracking", + "customLinkPreview", + "geoTargeting", + "utmTags", + "abTest", + "tags", + "comments", + "iosTargeting", + "androidTargeting", + "expiration", + "password", + "linkCloaking", + "searchEngineIndexing", + ], + }, + }, + ], + }), }); const sortBy = z diff --git a/apps/web/ui/links/use-link-filters.tsx b/apps/web/ui/links/use-link-filters.tsx index 7a982f2d376..51abbb1b11d 100644 --- a/apps/web/ui/links/use-link-filters.tsx +++ b/apps/web/ui/links/use-link-filters.tsx @@ -6,6 +6,20 @@ import useWorkspaceUsers from "@/lib/swr/use-workspace-users"; import { TagProps } from "@/lib/types"; import { TAGS_MAX_PAGE_SIZE } from "@/lib/zod/schemas/tags"; import { Avatar, BlurImage, Globe, Tag, User, useRouterStuff } from "@dub/ui"; +import { + AndroidLogo, + AppleLogo, + Bolt, + CircleCheck, + CircleHalfDottedClock, + DiamondTurnRight, + Flask, + Incognito, + InputPassword, + Page2, + PenWriting, + WindowSearch, +} from "@dub/ui/icons"; import { GOOGLE_FAVICON_URL, nFormatter } from "@dub/utils"; import { useContext, useMemo, useState } from "react"; import { useDebounce } from "use-debounce"; @@ -31,6 +45,10 @@ export function useLinkFilters() { folderId: folderId ?? "", }); + const linkFeatures = useLinkFeatureFilterOptions({ + folderId: folderId ?? "", + }); + const { queryParams, searchParamsObj } = useRouterStuff(); const filters = useMemo(() => { @@ -99,22 +117,37 @@ export function useLinkFilters() { right: nFormatter(count, { full: true }), })) ?? null, }, + { + key: "linkFeatures", + icon: CircleCheck, + label: "Link feature", + multiple: true, + options: linkFeatures, + }, ]; - }, [domains, tags, users]); + }, [domains, tags, users, linkFeatures]); const selectedTagIds = useMemo( () => searchParamsObj["tagIds"]?.split(",")?.filter(Boolean) ?? [], [searchParamsObj], ); + const selectedLinkFeatures = useMemo( + () => searchParamsObj["linkFeatures"]?.split(",")?.filter(Boolean) ?? [], + [searchParamsObj], + ); + const activeFilters = useMemo(() => { - const { domain, tagIds, userId } = searchParamsObj; + const { domain, tagIds, userId, linkFeatures } = searchParamsObj; return [ ...(domain ? [{ key: "domain", value: domain }] : []), ...(tagIds ? [{ key: "tagIds", value: selectedTagIds }] : []), ...(userId ? [{ key: "userId", value: userId }] : []), + ...(linkFeatures + ? [{ key: "linkFeatures", value: selectedLinkFeatures }] + : []), ]; - }, [searchParamsObj]); + }, [searchParamsObj, selectedTagIds, selectedLinkFeatures]); const onSelect = (key: string, value: any) => { if (key === "tagIds") { @@ -124,6 +157,13 @@ export function useLinkFilters() { }, del: "page", }); + } else if (key === "linkFeatures") { + queryParams({ + set: { + linkFeatures: selectedLinkFeatures.concat(value).join(","), + }, + del: "page", + }); } else { queryParams({ set: { @@ -145,6 +185,18 @@ export function useLinkFilters() { }, del: "page", }); + } else if ( + key === "linkFeatures" && + !(selectedLinkFeatures.length === 1 && selectedLinkFeatures[0] === value) + ) { + queryParams({ + set: { + linkFeatures: selectedLinkFeatures + .filter((feature) => feature !== value) + .join(","), + }, + del: "page", + }); } else { queryParams({ del: [key, "page"], @@ -154,7 +206,7 @@ export function useLinkFilters() { const onRemoveAll = () => { queryParams({ - del: ["domain", "tagIds", "userId", "search"], + del: ["domain", "tagIds", "userId", "linkFeatures", "search"], }); }; @@ -293,3 +345,107 @@ function useUserFilterOptions({ folderId }: { folderId: string }) { [users, usersCount], ); } + +const FEATURE_OPTIONS = [ + { + value: "conversionTracking", + label: "Conversion Tracking", + icon: Bolt, + }, + { + value: "customLinkPreview", + label: "Custom Link Preview", + icon: PenWriting, + }, + { + value: "geoTargeting", + label: "Geo Targeting", + icon: Globe, + }, + { + value: "utmTags", + label: "UTM Tags", + icon: DiamondTurnRight, + }, + { + value: "abTest", + label: "A/B Test", + icon: Flask, + }, + { + value: "tags", + label: "Tags", + icon: Tag, + }, + { + value: "comments", + label: "Comments", + icon: Page2, + }, + { + value: "iosTargeting", + label: "iOS Targeting", + icon: AppleLogo, + }, + { + value: "androidTargeting", + label: "Android Targeting", + icon: AndroidLogo, + }, + { + value: "expiration", + label: "Expiration", + icon: CircleHalfDottedClock, + }, + { + value: "password", + label: "Password", + icon: InputPassword, + }, + { + value: "linkCloaking", + label: "Link Cloaking", + icon: Incognito, + }, + { + value: "searchEngineIndexing", + label: "Search Engine Indexing", + icon: WindowSearch, + }, +] as const; + +function useLinkFeatureFilterOptions({ folderId }: { folderId: string }) { + const { showArchived } = useContext(LinksDisplayContext); + + const counts = FEATURE_OPTIONS.map((feature) => + useLinksCount({ + query: { + linkFeatures: [feature.value], + showArchived, + folderId, + }, + }), + ); + + const isLoading = counts.some(({ loading }) => loading); + const countValues = counts.map(({ data }) => data ?? 0); + + return useMemo(() => { + if (isLoading) return null; + + return FEATURE_OPTIONS.map((feature, index) => { + const count = countValues[index]; + const Icon = feature.icon; + return { + value: feature.value, + label: feature.label, + icon: , + right: nFormatter(count, { full: true }), + }; + }).sort((a, b) => { + const countA = parseInt(a.right?.replace(/,/g, "") || "0"); + const countB = parseInt(b.right?.replace(/,/g, "") || "0"); + return countB - countA; + }); + }, [isLoading, countValues]); +}