From 600d69cb752c5a20f7e3b4a5e1299b776357edab Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Wed, 17 Jun 2026 21:32:37 -0300 Subject: [PATCH 01/29] Add external program marketplace on dub.co and app.dub.co. Adds public marketplace pages, API routes, middleware rewrites, and sitemap entries for the external marketplace surface. --- .../api/marketplace/programs/count/route.ts | 18 ++ .../programs/filter-counts/route.ts | 16 ++ .../(ee)/api/marketplace/programs/route.ts | 13 ++ .../marketplace/[[...slug]]/page.tsx | 86 +++++++++ .../web/app/app.dub.co/marketplace/layout.tsx | 33 ++++ ...et-public-network-program-filter-counts.ts | 110 +++++++++++ .../fetchers/get-public-network-programs.ts | 173 ++++++++++++++++++ .../parse-public-marketplace-query.ts | 34 ++++ apps/web/lib/middleware/app.ts | 13 +- apps/web/lib/middleware/utils/app-redirect.ts | 1 - apps/web/middleware.ts | 17 +- .../external/marketplace-external-filters.tsx | 150 +++++++++++++++ .../external/marketplace-external-header.tsx | 36 ++++ .../marketplace-external-home-page.tsx | 46 +++++ .../marketplace-external-list-page-client.tsx | 171 +++++++++++++++++ .../marketplace-external-list-page.tsx | 39 ++++ .../marketplace-external-program-page.tsx | 65 +++++++ .../external/marketplace-external-router.tsx | 33 ++++ .../external/marketplace-external-shell.tsx | 61 ++++++ 19 files changed, 1112 insertions(+), 3 deletions(-) create mode 100644 apps/web/app/(ee)/api/marketplace/programs/count/route.ts create mode 100644 apps/web/app/(ee)/api/marketplace/programs/filter-counts/route.ts create mode 100644 apps/web/app/(ee)/api/marketplace/programs/route.ts create mode 100644 apps/web/app/app.dub.co/marketplace/[[...slug]]/page.tsx create mode 100644 apps/web/app/app.dub.co/marketplace/layout.tsx create mode 100644 apps/web/lib/fetchers/get-public-network-program-filter-counts.ts create mode 100644 apps/web/lib/fetchers/get-public-network-programs.ts create mode 100644 apps/web/lib/marketplace/parse-public-marketplace-query.ts create mode 100644 apps/web/ui/program-marketplace/external/marketplace-external-filters.tsx create mode 100644 apps/web/ui/program-marketplace/external/marketplace-external-header.tsx create mode 100644 apps/web/ui/program-marketplace/external/marketplace-external-home-page.tsx create mode 100644 apps/web/ui/program-marketplace/external/marketplace-external-list-page-client.tsx create mode 100644 apps/web/ui/program-marketplace/external/marketplace-external-list-page.tsx create mode 100644 apps/web/ui/program-marketplace/external/marketplace-external-program-page.tsx create mode 100644 apps/web/ui/program-marketplace/external/marketplace-external-router.tsx create mode 100644 apps/web/ui/program-marketplace/external/marketplace-external-shell.tsx diff --git a/apps/web/app/(ee)/api/marketplace/programs/count/route.ts b/apps/web/app/(ee)/api/marketplace/programs/count/route.ts new file mode 100644 index 00000000000..7268a80b818 --- /dev/null +++ b/apps/web/app/(ee)/api/marketplace/programs/count/route.ts @@ -0,0 +1,18 @@ +import { getPublicNetworkProgramsCount } from "@/lib/fetchers/get-public-network-programs"; +import { parsePublicMarketplaceQuery } from "@/lib/marketplace/parse-public-marketplace-query"; +import { NextResponse } from "next/server"; + +// GET /api/marketplace/programs/count - public marketplace program count +export async function GET(req: Request) { + const searchParams = Object.fromEntries(new URL(req.url).searchParams); + const { category, rewardType, search } = + parsePublicMarketplaceQuery(searchParams); + + const count = await getPublicNetworkProgramsCount({ + category, + rewardType, + search, + }); + + return NextResponse.json(count); +} diff --git a/apps/web/app/(ee)/api/marketplace/programs/filter-counts/route.ts b/apps/web/app/(ee)/api/marketplace/programs/filter-counts/route.ts new file mode 100644 index 00000000000..e86698c1b13 --- /dev/null +++ b/apps/web/app/(ee)/api/marketplace/programs/filter-counts/route.ts @@ -0,0 +1,16 @@ +import { getPublicNetworkProgramFilterCounts } from "@/lib/fetchers/get-public-network-program-filter-counts"; +import { parsePublicMarketplaceQuery } from "@/lib/marketplace/parse-public-marketplace-query"; +import { NextResponse } from "next/server"; + +// GET /api/marketplace/programs/filter-counts - public marketplace filter counts +export async function GET(req: Request) { + const searchParams = Object.fromEntries(new URL(req.url).searchParams); + const { category, rewardType } = parsePublicMarketplaceQuery(searchParams); + + const filterCounts = await getPublicNetworkProgramFilterCounts({ + category, + rewardType, + }); + + return NextResponse.json(filterCounts); +} diff --git a/apps/web/app/(ee)/api/marketplace/programs/route.ts b/apps/web/app/(ee)/api/marketplace/programs/route.ts new file mode 100644 index 00000000000..287712f2f0a --- /dev/null +++ b/apps/web/app/(ee)/api/marketplace/programs/route.ts @@ -0,0 +1,13 @@ +import { getPublicNetworkPrograms } from "@/lib/fetchers/get-public-network-programs"; +import { parsePublicMarketplaceQuery } from "@/lib/marketplace/parse-public-marketplace-query"; +import { NextResponse } from "next/server"; + +// GET /api/marketplace/programs - public marketplace program list +export async function GET(req: Request) { + const searchParams = Object.fromEntries(new URL(req.url).searchParams); + const params = parsePublicMarketplaceQuery(searchParams); + + const programs = await getPublicNetworkPrograms(params); + + return NextResponse.json(programs); +} diff --git a/apps/web/app/app.dub.co/marketplace/[[...slug]]/page.tsx b/apps/web/app/app.dub.co/marketplace/[[...slug]]/page.tsx new file mode 100644 index 00000000000..f0bfafe2224 --- /dev/null +++ b/apps/web/app/app.dub.co/marketplace/[[...slug]]/page.tsx @@ -0,0 +1,86 @@ +import { getNetworkProgram } from "@/lib/fetchers/get-network-program"; +import { PROGRAM_CATEGORIES_MAP } from "@/lib/network/program-categories"; +import { MarketplaceExternalRouter } from "@/ui/program-marketplace/external/marketplace-external-router"; +import { generateMarketplaceProgramStaticParams } from "@/ui/program-marketplace/pages/marketplace-program-page"; +import { + categoryToSlug, + getMarketplaceCanonicalUrl, + getMarketplacePathFromSlug, +} from "@/ui/program-marketplace/utils/urls"; +import { Category } from "@dub/prisma/client"; +import { constructMetadata } from "@dub/utils"; +import { Metadata } from "next"; + +export const revalidate = 3600; + +export async function generateStaticParams() { + const programParams = await generateMarketplaceProgramStaticParams(); + const categoryParams = Object.values(Category).map((category) => ({ + slug: ["c", categoryToSlug(category)], + })); + + return [{ slug: [] }, { slug: ["all"] }, ...categoryParams, ...programParams]; +} + +export async function generateMetadata(props: { + params: Promise<{ slug?: string[] }>; +}): Promise { + const { slug } = await props.params; + const pathname = getMarketplacePathFromSlug(slug); + const segments = slug ?? []; + + const year = new Date().getFullYear(); + + let title = `Best SaaS affiliate programs in ${year}`; + let description = `Browse and apply to the best SaaS affiliate programs on Dub's Partner Network.`; + let image: string | undefined; + + if (segments.length === 1 && segments[0] === "all") { + title = "All Programs"; + description = "Browse all partner programs on Dub."; + } else if ( + segments.length === 1 && + segments[0] !== "all" && + segments[0] !== "popular" + ) { + const program = await getNetworkProgram({ slug: segments[0] }); + + if (program) { + title = program.name; + description = + program.description || + `Join the ${program.name} affiliate program on Dub's Partner Network.`; + image = program.marketplaceHeaderImage || program.logo || undefined; + } else { + title = "Program Details"; + } + } else if (segments.length === 2 && segments[0] === "c") { + const category = Object.values(Category).find( + (value) => categoryToSlug(value) === segments[1], + ); + + if (category) { + const categoryMeta = PROGRAM_CATEGORIES_MAP[category]; + const label = categoryMeta?.label ?? category.replaceAll("_", " "); + title = `${label} Programs`; + description = + categoryMeta?.listPageDescription ?? + `Partner programs in ${label.toLowerCase()}.`; + } + } + + return constructMetadata({ + title, + description, + image, + canonicalUrl: getMarketplaceCanonicalUrl(pathname), + }); +} + +export default async function MarketplaceExternalPage(props: { + params: Promise<{ slug?: string[] }>; +}) { + const { slug } = await props.params; + + return ; +} diff --git a/apps/web/app/app.dub.co/marketplace/layout.tsx b/apps/web/app/app.dub.co/marketplace/layout.tsx new file mode 100644 index 00000000000..1a1f6c4f132 --- /dev/null +++ b/apps/web/app/app.dub.co/marketplace/layout.tsx @@ -0,0 +1,33 @@ +import { MarketplaceExternalHeader } from "@/ui/program-marketplace/external/marketplace-external-header"; +import { Footer } from "@dub/ui"; +import { PropsWithChildren } from "react"; + +export default function MarketplaceExternalLayout({ + children, +}: PropsWithChildren) { + return ( +
+ +
+ +
{children}
+
+
+
+
+ ); +} + +function MarketplaceExternalGridLines() { + return ( +
+
+
+
+
+
+ ); +} diff --git a/apps/web/lib/fetchers/get-public-network-program-filter-counts.ts b/apps/web/lib/fetchers/get-public-network-program-filter-counts.ts new file mode 100644 index 00000000000..7569d204579 --- /dev/null +++ b/apps/web/lib/fetchers/get-public-network-program-filter-counts.ts @@ -0,0 +1,110 @@ +import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups"; +import { getPublicNetworkProgramsQuerySchema } from "@/lib/zod/schemas/program-network"; +import { prisma } from "@dub/prisma"; +import { Category, Prisma } from "@dub/prisma/client"; +import { cache } from "react"; +import * as z from "zod/v4"; + +const rewardTypeMap = { + sale: Prisma.sql`pg.saleRewardId IS NOT NULL`, + lead: Prisma.sql`pg.leadRewardId IS NOT NULL`, + click: Prisma.sql`pg.clickRewardId IS NOT NULL`, + discount: Prisma.sql`pg.discountId IS NOT NULL`, +}; + +export const getPublicNetworkProgramFilterCounts = cache( + async ({ + category, + rewardType, + }: Pick< + z.input, + "category" | "rewardType" + > = {}) => { + const [categories, rewardTypes] = await Promise.all([ + getCategoryCounts(rewardType), + getRewardTypeCounts(category), + ]); + + return { + categories, + rewardTypes, + }; + }, +); + +async function getCategoryCounts( + rewardType?: z.infer< + typeof getPublicNetworkProgramsQuerySchema + >["rewardType"], +) { + const commonWhereSql = buildCommonWhereSql({ rewardType }); + + const categories = (await prisma.$queryRaw` + SELECT pc.category, COUNT(p.id) AS _count + FROM ProgramCategory pc + JOIN Program p ON p.id = pc.programId + WHERE ${commonWhereSql} + GROUP BY pc.category + ORDER BY _count DESC + `) as { category: Category; _count: bigint }[]; + + return categories.map(({ category, _count }) => ({ + category, + count: Number(_count), + })); +} + +async function getRewardTypeCounts(category?: Category) { + const commonWhereSql = buildCommonWhereSql({ category }); + + const rewards = (await prisma.$queryRaw` + SELECT + COUNT(pg.clickRewardId) AS "click", + COUNT(pg.leadRewardId) AS "lead", + COUNT(pg.saleRewardId) AS "sale", + COUNT(pg.discountId) AS "discount" + FROM PartnerGroup pg + JOIN Program p ON p.id = pg.programId + WHERE pg.slug = ${DEFAULT_PARTNER_GROUP.slug} AND ${commonWhereSql} + `) as { click: bigint; lead: bigint; sale: bigint; discount: bigint }[]; + + return (["sale", "lead", "click", "discount"] as const).map((type) => ({ + type, + count: Number(rewards[0][type]), + })); +} + +function buildCommonWhereSql({ + category, + rewardType, +}: { + category?: Category; + rewardType?: z.infer< + typeof getPublicNetworkProgramsQuerySchema + >["rewardType"]; +}) { + return Prisma.sql` + p.addedToMarketplaceAt IS NOT NULL + AND EXISTS ( + SELECT 1 FROM PartnerGroup pg + WHERE + pg.programId = p.id + AND pg.slug = ${DEFAULT_PARTNER_GROUP.slug} + AND pg.applicationFormPublishedAt IS NOT NULL + ${ + rewardType + ? Prisma.sql`AND ${rewardTypeMap[rewardType]}` + : Prisma.sql`` + } + ) + ${ + category + ? Prisma.sql` + AND EXISTS ( + SELECT 1 FROM ProgramCategory pc + WHERE pc.programId = p.id AND pc.category = ${category} + )` + : Prisma.sql`` + } + `; +} diff --git a/apps/web/lib/fetchers/get-public-network-programs.ts b/apps/web/lib/fetchers/get-public-network-programs.ts new file mode 100644 index 00000000000..82e2b6f9bd6 --- /dev/null +++ b/apps/web/lib/fetchers/get-public-network-programs.ts @@ -0,0 +1,173 @@ +import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups"; +import { + getPublicNetworkProgramsQuerySchema, + NetworkProgramSchema, +} from "@/lib/zod/schemas/program-network"; +import { prisma } from "@dub/prisma"; +import { cache } from "react"; +import * as z from "zod/v4"; + +export const getPublicNetworkPrograms = cache( + async (params: z.input = {}) => { + const { + category, + rewardType, + featured, + search, + sortBy = "popularity", + sortOrder = "desc", + page = 1, + pageSize, + } = getPublicNetworkProgramsQuerySchema.parse(params); + + const programs = await prisma.program.findMany({ + where: { + addedToMarketplaceAt: { + not: null, + }, + ...(featured && { + featuredOnMarketplaceAt: { + not: null, + }, + }), + ...(search && { + OR: [ + { name: { contains: search } }, + { slug: { contains: search } }, + { domain: { contains: search } }, + { url: { contains: search } }, + { description: { contains: search } }, + ], + }), + ...(category && { + categories: { + some: { + category, + }, + }, + }), + groups: { + some: { + slug: DEFAULT_PARTNER_GROUP.slug, + applicationFormPublishedAt: { + not: null, + }, + ...(rewardType === "sale" && { + saleRewardId: { not: null }, + }), + ...(rewardType === "lead" && { + leadRewardId: { not: null }, + }), + ...(rewardType === "click" && { + clickRewardId: { not: null }, + }), + ...(rewardType === "discount" && { + discountId: { not: null }, + }), + }, + }, + }, + include: { + groups: { + where: { + slug: DEFAULT_PARTNER_GROUP.slug, + }, + include: { + clickReward: true, + leadReward: true, + saleReward: true, + referralReward: true, + discount: true, + }, + }, + categories: true, + }, + orderBy: + sortBy === "popularity" + ? { marketplaceRanking: "asc" } + : { + [sortBy === "recency" ? "addedToMarketplaceAt" : sortBy]: + sortOrder, + }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + + return z.array(NetworkProgramSchema).parse( + programs.map((program) => ({ + ...program, + rewards: + program.groups.length > 0 + ? [ + program.groups[0].clickReward, + program.groups[0].leadReward, + program.groups[0].saleReward, + ].filter(Boolean) + : [], + discount: program.groups.length > 0 ? program.groups[0].discount : null, + categories: program.categories.map(({ category }) => category), + })), + ); + }, +); + +export const getPublicNetworkProgramsCount = cache( + async ( + params: Pick< + z.input, + "category" | "rewardType" | "search" + > = {}, + ) => { + const { category, rewardType, search } = getPublicNetworkProgramsQuerySchema + .pick({ + category: true, + rewardType: true, + search: true, + }) + .parse(params); + + return prisma.program.count({ + where: { + addedToMarketplaceAt: { + not: null, + }, + ...(search && { + OR: [ + { name: { contains: search } }, + { slug: { contains: search } }, + { domain: { contains: search } }, + { url: { contains: search } }, + { description: { contains: search } }, + ], + }), + ...(category && { + categories: { + some: { + category, + }, + }, + }), + groups: { + some: { + slug: DEFAULT_PARTNER_GROUP.slug, + applicationFormPublishedAt: { + not: null, + }, + ...(rewardType === "sale" && { + saleRewardId: { not: null }, + }), + ...(rewardType === "lead" && { + leadRewardId: { not: null }, + }), + ...(rewardType === "click" && { + clickRewardId: { not: null }, + }), + ...(rewardType === "discount" && { + discountId: { not: null }, + }), + }, + }, + }, + }); + }, +); diff --git a/apps/web/lib/marketplace/parse-public-marketplace-query.ts b/apps/web/lib/marketplace/parse-public-marketplace-query.ts new file mode 100644 index 00000000000..c4bd5f37b75 --- /dev/null +++ b/apps/web/lib/marketplace/parse-public-marketplace-query.ts @@ -0,0 +1,34 @@ +import { getPublicNetworkProgramsQuerySchema } from "@/lib/zod/schemas/program-network"; +import { Category } from "@dub/prisma/client"; + +export const EXTERNAL_MARKETPLACE_PAGE_SIZE = 24; + +function pickString(value: string | string[] | undefined) { + return typeof value === "string" ? value : undefined; +} + +export function parsePublicMarketplaceQuery( + searchParams: Record = {}, + fixedCategory?: Category, +) { + const input = { + rewardType: pickString(searchParams.rewardType), + search: pickString(searchParams.search), + sortBy: pickString(searchParams.sortBy), + sortOrder: pickString(searchParams.sortOrder), + page: pickString(searchParams.page), + pageSize: EXTERNAL_MARKETPLACE_PAGE_SIZE, + ...(fixedCategory ? { category: fixedCategory } : {}), + }; + + const parsed = getPublicNetworkProgramsQuerySchema.safeParse(input); + + if (parsed.success) { + return parsed.data; + } + + return getPublicNetworkProgramsQuerySchema.parse({ + pageSize: EXTERNAL_MARKETPLACE_PAGE_SIZE, + ...(fixedCategory ? { category: fixedCategory } : {}), + }); +} diff --git a/apps/web/lib/middleware/app.ts b/apps/web/lib/middleware/app.ts index 27ec8567075..5dc796960f7 100644 --- a/apps/web/lib/middleware/app.ts +++ b/apps/web/lib/middleware/app.ts @@ -1,3 +1,4 @@ +import { getMarketplacePopularRedirectHref } from "@/ui/program-marketplace/utils/urls"; import { NextRequest, NextResponse } from "next/server"; import { ONBOARDING_WINDOW_SECONDS, @@ -14,12 +15,22 @@ import { parse } from "./utils/parse"; import { WorkspacesMiddleware } from "./workspaces"; export async function AppMiddleware(req: NextRequest) { - const { path, fullPath, searchParamsString } = parse(req); + const { path, fullPath, searchParamsObj, searchParamsString } = parse(req); if (path.startsWith("/embed")) { return EmbedMiddleware(req); } + if (path === "/marketplace/popular") { + return NextResponse.redirect( + new URL(getMarketplacePopularRedirectHref(searchParamsObj), req.url), + ); + } + + if (path === "/marketplace" || path.startsWith("/marketplace/")) { + return NextResponse.rewrite(new URL(`/app.dub.co${fullPath}`, req.url)); + } + const user = await getUserViaToken(req); // if there's no user and the path is not a public page, redirect to /login diff --git a/apps/web/lib/middleware/utils/app-redirect.ts b/apps/web/lib/middleware/utils/app-redirect.ts index 6f5a154c431..b27eb1f1ccf 100644 --- a/apps/web/lib/middleware/utils/app-redirect.ts +++ b/apps/web/lib/middleware/utils/app-redirect.ts @@ -8,7 +8,6 @@ const APP_REDIRECTS = { "/welcome": "/onboarding/welcome", "/campaigns": "/program/campaigns", "/messages": "/program/messages", - "/marketplace": "/program/network", "/fraud": "/program/risks", "/risks": "/program/risks", }; diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 952f48eb820..6f31532cbdd 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -1,4 +1,5 @@ import { logger } from "@/lib/axiom/server"; +import { getMarketplacePopularRedirectHref } from "@/ui/program-marketplace/utils/urls"; import { transformMiddlewareRequest } from "@axiomhq/nextjs"; import { ADMIN_HOSTNAMES, @@ -33,7 +34,7 @@ export const config = { }; export default async function middleware(req: NextRequest, ev: NextFetchEvent) { - const { domain, path, key, fullKey } = parse(req); + const { domain, path, key, fullKey, fullPath, searchParamsObj } = parse(req); // Axiom logging logger.info(...transformMiddlewareRequest(req)); @@ -82,6 +83,20 @@ export default async function middleware(req: NextRequest, ev: NextFetchEvent) { return PartnersMiddleware(req); } + if ( + (path === "/marketplace" || path.startsWith("/marketplace/")) && + (domain === "dub.co" || + domain === `staging.${process.env.NEXT_PUBLIC_APP_DOMAIN}`) + ) { + if (path === "/marketplace/popular") { + return NextResponse.redirect( + new URL(getMarketplacePopularRedirectHref(searchParamsObj), req.url), + ); + } + + return NextResponse.rewrite(new URL(`/app.dub.co${fullPath}`, req.url)); + } + if (isValidUrl(fullKey)) { return CreateLinkMiddleware(req); } diff --git a/apps/web/ui/program-marketplace/external/marketplace-external-filters.tsx b/apps/web/ui/program-marketplace/external/marketplace-external-filters.tsx new file mode 100644 index 00000000000..48d78afc083 --- /dev/null +++ b/apps/web/ui/program-marketplace/external/marketplace-external-filters.tsx @@ -0,0 +1,150 @@ +import { PROGRAM_CATEGORIES_MAP } from "@/lib/network/program-categories"; +import { + MARKETPLACE_REWARD_TYPES, + type MarketplaceRewardType, +} from "@/ui/program-marketplace/constants"; +import { buildExternalMarketplaceFilterHref } from "@/ui/program-marketplace/utils/urls"; +import { Category } from "@dub/prisma/client"; +import { Check } from "@dub/ui"; +import { cn } from "@dub/utils"; +import Link from "next/link"; +import type { ReactNode } from "react"; + +export function MarketplaceExternalFilterSidebar({ + basePath, + activeCategory, + activeRewardType, + categoryCounts, + rewardTypeCounts, + search, + sortBy, + sortOrder, +}: { + basePath: string; + activeCategory?: Category; + activeRewardType?: MarketplaceRewardType; + categoryCounts: { category: Category; count: number }[]; + rewardTypeCounts: { + type: MarketplaceRewardType; + count: number; + }[]; + search?: string; + sortBy?: string; + sortOrder?: string; +}) { + const buildHref = (params: { + category?: Category | null; + rewardType?: MarketplaceRewardType | null; + }) => + buildExternalMarketplaceFilterHref({ + basePath, + activeRewardType, + search, + sortBy, + sortOrder, + ...params, + }); + + return ( + + ); +} + +function FilterSection({ + title, + children, +}: { + title: string; + children: ReactNode; +}) { + return ( +
+ + {title} + +
{children}
+
+ ); +} + +function FilterLink({ + href, + active, + count, + children, +}: { + href: string; + active: boolean; + count: number; + children: ReactNode; +}) { + return ( + +
+ {active ? : null} +
+ {children} + + {count} + + + ); +} + +export function getMarketplaceExternalBasePath({ slug }: { slug?: string[] }) { + const segments = slug ?? []; + + if (segments.length === 1 && segments[0] === "all") { + return "/marketplace/all"; + } + + if (segments.length === 2 && segments[0] === "c") { + return `/marketplace/c/${segments[1]}`; + } + + return "/marketplace/all"; +} diff --git a/apps/web/ui/program-marketplace/external/marketplace-external-header.tsx b/apps/web/ui/program-marketplace/external/marketplace-external-header.tsx new file mode 100644 index 00000000000..db6d9f95c70 --- /dev/null +++ b/apps/web/ui/program-marketplace/external/marketplace-external-header.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { NavMobile, Nav as NavUI, Wordmark } from "@dub/ui"; +import Link from "next/link"; +import { getMarketplaceHref } from "../utils/urls"; + +const DUB_HOME_HREF = "https://dub.co/home"; + +function MarketplaceLogo() { + return ( +
+ + + + | + + Programs + +
+ ); +} + +export function MarketplaceExternalHeader() { + return ( + <> + + } /> + + ); +} diff --git a/apps/web/ui/program-marketplace/external/marketplace-external-home-page.tsx b/apps/web/ui/program-marketplace/external/marketplace-external-home-page.tsx new file mode 100644 index 00000000000..332291dc9d0 --- /dev/null +++ b/apps/web/ui/program-marketplace/external/marketplace-external-home-page.tsx @@ -0,0 +1,46 @@ +import { getPublicNetworkPrograms } from "@/lib/fetchers/get-public-network-programs"; +import { FeaturedPrograms } from "../featured-programs"; +import { MARKETPLACE_HOME_ROWS } from "../home-sections"; +import { MarketplaceCategories } from "../marketplace-categories"; +import { MarketplaceProgramRow } from "../marketplace-program-row"; +import { MarketplaceExternalShell } from "./marketplace-external-shell"; + +async function fetchHomePrograms( + params: Parameters[0], +) { + try { + return await getPublicNetworkPrograms(params); + } catch (error) { + console.error("Failed to fetch marketplace home programs:", error); + return []; + } +} + +export async function MarketplaceExternalHomePage() { + const [featuredPrograms, ...rowPrograms] = await Promise.all([ + fetchHomePrograms({ featured: true, pageSize: 6 }), + ...MARKETPLACE_HOME_ROWS.map((row) => fetchHomePrograms(row.fetchParams)), + ]); + + return ( + +
+
+ +
+ + {MARKETPLACE_HOME_ROWS.map((row, index) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/ui/program-marketplace/external/marketplace-external-list-page-client.tsx b/apps/web/ui/program-marketplace/external/marketplace-external-list-page-client.tsx new file mode 100644 index 00000000000..ca3c1e60b93 --- /dev/null +++ b/apps/web/ui/program-marketplace/external/marketplace-external-list-page-client.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { EXTERNAL_MARKETPLACE_PAGE_SIZE } from "@/lib/marketplace/parse-public-marketplace-query"; +import { NetworkProgramProps } from "@/lib/types"; +import { getPublicNetworkProgramsQuerySchema } from "@/lib/zod/schemas/program-network"; +import { Category } from "@dub/prisma/client"; +import { useRouterStuff } from "@dub/ui"; +import { cn, fetcher } from "@dub/utils"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useMemo } from "react"; +import useSWR from "swr"; +import { type MarketplaceRewardType } from "../constants"; +import { MarketplaceListToolbar } from "../marketplace-list-toolbar"; +import { + MarketplaceProgramGrid, + MarketplaceProgramGridEmpty, + MarketplaceProgramGridSkeleton, +} from "../marketplace-program-grid"; +import { MarketplaceExternalFilterSidebar } from "./marketplace-external-filters"; + +const PAGE_SIZE = EXTERNAL_MARKETPLACE_PAGE_SIZE; + +type FilterCounts = { + categories: { category: Category; count: number }[]; + rewardTypes: { + type: MarketplaceRewardType; + count: number; + }[]; +}; + +function pickString(value: string | string[] | undefined) { + return typeof value === "string" ? value : undefined; +} + +export function MarketplaceExternalListPageClient({ + basePath, + fixedCategory, +}: { + basePath: string; + fixedCategory?: Category; +}) { + const router = useRouter(); + const { getQueryString, searchParamsObj } = useRouterStuff(); + + const parsed = useMemo( + () => + getPublicNetworkProgramsQuerySchema.safeParse({ + rewardType: pickString(searchParamsObj.rewardType), + search: pickString(searchParamsObj.search), + sortBy: pickString(searchParamsObj.sortBy), + sortOrder: pickString(searchParamsObj.sortOrder), + page: pickString(searchParamsObj.page), + pageSize: PAGE_SIZE, + ...(fixedCategory ? { category: fixedCategory } : {}), + }), + [fixedCategory, searchParamsObj], + ); + + useEffect(() => { + if (!parsed.success) { + router.replace(basePath); + } + }, [basePath, parsed.success, router]); + + const queryString = parsed.success + ? getQueryString({ + ...(fixedCategory ? { category: fixedCategory } : {}), + pageSize: String(PAGE_SIZE), + }) + : null; + + const { data: programs, isValidating } = useSWR( + queryString ? `/api/marketplace/programs${queryString}` : null, + fetcher, + { revalidateOnFocus: false, keepPreviousData: true }, + ); + + const { data: totalCount = 0 } = useSWR( + queryString ? `/api/marketplace/programs/count${queryString}` : null, + fetcher, + { revalidateOnFocus: false }, + ); + + const { data: filterCounts } = useSWR( + queryString + ? `/api/marketplace/programs/filter-counts${queryString}` + : null, + fetcher, + { revalidateOnFocus: false }, + ); + + if (!parsed.success) { + return ; + } + + const { rewardType, search, sortBy, sortOrder, page = 1 } = parsed.data; + const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); + + return ( +
+ {filterCounts ? ( +
+ +
+ ) : null} + +
+ + + {!programs ? ( + + ) : programs.length > 0 ? ( + + ) : ( + + )} + + {totalPages > 1 ? ( +
+ {Array.from({ length: totalPages }, (_, index) => { + const pageNumber = index + 1; + const queryParams = new URLSearchParams(); + + if (rewardType) queryParams.set("rewardType", rewardType); + if (search) queryParams.set("search", search); + if (sortBy !== "popularity") queryParams.set("sortBy", sortBy); + if (sortOrder !== "desc") queryParams.set("sortOrder", sortOrder); + if (pageNumber > 1) queryParams.set("page", String(pageNumber)); + + const query = queryParams.toString(); + + return ( + + {pageNumber} + + ); + })} +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/ui/program-marketplace/external/marketplace-external-list-page.tsx b/apps/web/ui/program-marketplace/external/marketplace-external-list-page.tsx new file mode 100644 index 00000000000..5bf28bea74d --- /dev/null +++ b/apps/web/ui/program-marketplace/external/marketplace-external-list-page.tsx @@ -0,0 +1,39 @@ +import { PROGRAM_CATEGORIES_MAP } from "@/lib/network/program-categories"; +import { Category } from "@dub/prisma/client"; +import { getMarketplaceExternalBasePath } from "./marketplace-external-filters"; +import { MarketplaceExternalListPageClient } from "./marketplace-external-list-page-client"; +import { MarketplaceExternalShell } from "./marketplace-external-shell"; + +export function MarketplaceExternalListPage({ + slug, + fixedCategory, +}: { + slug?: string[]; + fixedCategory?: Category; +}) { + const basePath = getMarketplaceExternalBasePath({ slug }); + const categoryMeta = fixedCategory + ? PROGRAM_CATEGORIES_MAP[fixedCategory] + : undefined; + + return ( + + {categoryMeta.label} partner +
+ programs + + ) : undefined + } + description={categoryMeta?.listPageDescription} + > + +
+ ); +} diff --git a/apps/web/ui/program-marketplace/external/marketplace-external-program-page.tsx b/apps/web/ui/program-marketplace/external/marketplace-external-program-page.tsx new file mode 100644 index 00000000000..2db92c4f827 --- /dev/null +++ b/apps/web/ui/program-marketplace/external/marketplace-external-program-page.tsx @@ -0,0 +1,65 @@ +import { getNetworkProgram } from "@/lib/fetchers/get-network-program"; +import { ApplicationAnalytics } from "@/ui/application-analytics"; +import { MarketplaceProgramDetailBody } from "@/ui/program-marketplace/marketplace-program-detail-body"; +import { MarketplaceProgramDetailsLayout } from "@/ui/program-marketplace/marketplace-program-details-layout"; +import { MarketplaceProgramHero } from "@/ui/program-marketplace/marketplace-program-hero"; +import { + getMarketplaceAllHref, + getMarketplaceHref, + getMarketplacePublicApplyHref, +} from "@/ui/program-marketplace/utils/urls"; +import { Button, ChevronLeft } from "@dub/ui"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { MarketplaceExternalShell } from "./marketplace-external-shell"; + +export async function MarketplaceExternalProgramPage({ + programSlug, +}: { + programSlug: string; +}) { + const program = await getNetworkProgram({ + slug: programSlug, + }); + + if (!program) { + redirect(getMarketplaceHref()); + } + + return ( + <> + + + + + All Programs + + } + hero={ + +
From bd9cf5cb64194bd7feea0de3eb8afb6504e7a70f Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Thu, 25 Jun 2026 19:58:32 -0700 Subject: [PATCH 17/29] simplify marketplace url utils --- .../marketplace/[[...segments]]/page.tsx | 8 ++----- .../marketplace-external-apply-button.tsx | 3 +-- .../external/marketplace-external-header.tsx | 3 +-- .../marketplace-external-program-page.tsx | 7 ++---- .../featured-program-card.tsx | 3 +-- .../marketplace-router.tsx | 7 ++---- .../marketplace-sidebar-filters.tsx | 3 +-- .../pages/marketplace-program-page.tsx | 5 ++-- .../ui/program-marketplace/program-card.tsx | 3 +-- .../program-marketplace-banner.tsx | 3 +-- .../program-marketplace-card.tsx | 3 +-- apps/web/ui/program-marketplace/utils/urls.ts | 24 ++----------------- 12 files changed, 17 insertions(+), 55 deletions(-) diff --git a/apps/web/app/app.dub.co/marketplace/[[...segments]]/page.tsx b/apps/web/app/app.dub.co/marketplace/[[...segments]]/page.tsx index 256b94e98df..833bc637b5a 100644 --- a/apps/web/app/app.dub.co/marketplace/[[...segments]]/page.tsx +++ b/apps/web/app/app.dub.co/marketplace/[[...segments]]/page.tsx @@ -1,10 +1,6 @@ import { getNetworkProgram } from "@/lib/fetchers/get-network-program"; import { PROGRAM_CATEGORIES_MAP } from "@/lib/network/program-categories"; import { MarketplaceExternalRouter } from "@/ui/program-marketplace/external/marketplace-external-router"; -import { - getMarketplaceCanonicalUrl, - getMarketplacePathFromSegments, -} from "@/ui/program-marketplace/utils/urls"; import { constructMetadata } from "@dub/utils"; import { Category } from "@prisma/client"; import { Metadata } from "next"; @@ -18,7 +14,7 @@ export async function generateMetadata(props: { params: Promise<{ segments?: string[] }>; }): Promise { const { segments = [] } = await props.params; - const pathname = getMarketplacePathFromSegments(segments); + const pathname = `/marketplace${segments.length > 0 ? `/${segments.join("/")}` : ""}`; const year = new Date().getFullYear(); @@ -64,7 +60,7 @@ export async function generateMetadata(props: { title, description, image, - canonicalUrl: getMarketplaceCanonicalUrl(pathname), + canonicalUrl: `https://dub.co${pathname.startsWith("/") ? pathname : `/${pathname}`}`, }); } diff --git a/apps/web/ui/program-marketplace/external/marketplace-external-apply-button.tsx b/apps/web/ui/program-marketplace/external/marketplace-external-apply-button.tsx index 11f960bedb3..e89c0941be7 100644 --- a/apps/web/ui/program-marketplace/external/marketplace-external-apply-button.tsx +++ b/apps/web/ui/program-marketplace/external/marketplace-external-apply-button.tsx @@ -3,7 +3,6 @@ import { Button } from "@dub/ui"; import { PARTNERS_DOMAIN } from "@dub/utils"; import { useSession } from "next-auth/react"; -import { getMarketplaceProgramHref } from "../utils/urls"; export function MarketplaceExternalApplyButton({ programSlug, @@ -15,7 +14,7 @@ export function MarketplaceExternalApplyButton({ | Programs diff --git a/apps/web/ui/program-marketplace/external/marketplace-external-program-page.tsx b/apps/web/ui/program-marketplace/external/marketplace-external-program-page.tsx index 1565669b77d..7ff3b60b93a 100644 --- a/apps/web/ui/program-marketplace/external/marketplace-external-program-page.tsx +++ b/apps/web/ui/program-marketplace/external/marketplace-external-program-page.tsx @@ -3,10 +3,7 @@ import { ApplicationAnalytics } from "@/ui/application-analytics"; import { MarketplaceProgramDetailBody } from "@/ui/program-marketplace/marketplace-program-detail-body"; import { MarketplaceProgramDetailsLayout } from "@/ui/program-marketplace/marketplace-program-details-layout"; import { MarketplaceProgramHero } from "@/ui/program-marketplace/marketplace-program-hero"; -import { - getMarketplaceAllHref, - getMarketplaceHref, -} from "@/ui/program-marketplace/utils/urls"; +import { getMarketplaceAllHref } from "@/ui/program-marketplace/utils/urls"; import { ChevronLeft } from "@dub/ui"; import Link from "next/link"; import { redirect } from "next/navigation"; @@ -23,7 +20,7 @@ export async function MarketplaceExternalProgramPage({ }); if (!program) { - redirect(getMarketplaceHref()); + redirect("/marketplace"); } return ( diff --git a/apps/web/ui/program-marketplace/featured-program-card.tsx b/apps/web/ui/program-marketplace/featured-program-card.tsx index a2066f6e477..a481c439720 100644 --- a/apps/web/ui/program-marketplace/featured-program-card.tsx +++ b/apps/web/ui/program-marketplace/featured-program-card.tsx @@ -7,7 +7,6 @@ import { ProgramRewardsDisplay } from "@/ui/program-marketplace/program-rewards- import { getMarketplaceAllHref, getMarketplaceCategoryHref, - getMarketplaceProgramHref, } from "@/ui/program-marketplace/utils/urls"; import { Tooltip } from "@dub/ui"; import { ArrowUpRight, Link4 } from "@dub/ui/icons"; @@ -65,7 +64,7 @@ export function FeaturedProgramCard({ /> diff --git a/apps/web/ui/program-marketplace/marketplace-router.tsx b/apps/web/ui/program-marketplace/marketplace-router.tsx index 7d200ba1cc8..2286766f598 100644 --- a/apps/web/ui/program-marketplace/marketplace-router.tsx +++ b/apps/web/ui/program-marketplace/marketplace-router.tsx @@ -1,10 +1,7 @@ import { PROGRAM_CATEGORIES_MAP } from "@/lib/network/program-categories"; import { PageContent } from "@/ui/layout/page-content"; import { PageWidthWrapper } from "@/ui/layout/page-width-wrapper"; -import { - getMarketplaceHref, - slugToCategory, -} from "@/ui/program-marketplace/utils/urls"; +import { slugToCategory } from "@/ui/program-marketplace/utils/urls"; import { ChevronRight, Shop } from "@dub/ui"; import { Category } from "@prisma/client"; import Link from "next/link"; @@ -23,7 +20,7 @@ function MarketplaceListTitle({ category }: { category?: Category }) {
diff --git a/apps/web/ui/program-marketplace/marketplace-sidebar-filters.tsx b/apps/web/ui/program-marketplace/marketplace-sidebar-filters.tsx index e3e051fe508..56455742e84 100644 --- a/apps/web/ui/program-marketplace/marketplace-sidebar-filters.tsx +++ b/apps/web/ui/program-marketplace/marketplace-sidebar-filters.tsx @@ -1,6 +1,5 @@ "use client"; -import { getMarketplaceHref } from "@/ui/program-marketplace/utils/urls"; import { ChevronLeft, Filter, useKeyboardShortcut } from "@dub/ui"; import { cn } from "@dub/utils"; import Link from "next/link"; @@ -24,7 +23,7 @@ export function MarketplaceSidebarFilters() { return (
diff --git a/apps/web/ui/program-marketplace/program-card.tsx b/apps/web/ui/program-marketplace/program-card.tsx index fb3d87dedf1..22b6370b407 100644 --- a/apps/web/ui/program-marketplace/program-card.tsx +++ b/apps/web/ui/program-marketplace/program-card.tsx @@ -6,7 +6,6 @@ import { ProgramRewardsDisplay } from "@/ui/program-marketplace/program-rewards- import { getMarketplaceAllHref, getMarketplaceCategoryHref, - getMarketplaceProgramHref, } from "@/ui/program-marketplace/utils/urls"; import { Tooltip } from "@dub/ui"; import { OG_AVATAR_URL, cn } from "@dub/utils"; @@ -27,7 +26,7 @@ export function MarketplaceProgramCard({ return ( , ) { - return buildMarketplaceHref(`${MARKETPLACE_BASE}/all`, params); + return buildMarketplaceHref("/marketplace/all", params); } export function getPreservedMarketplaceSearchParams( @@ -62,27 +58,11 @@ export function getMarketplaceCategoryHref( params?: Record, ) { return buildMarketplaceHref( - `${MARKETPLACE_BASE}/c/${category.toLowerCase()}`, + `/marketplace/c/${category.toLowerCase()}`, params, ); } -export function getMarketplaceProgramHref(programSlug: string) { - return `${MARKETPLACE_BASE}/${programSlug}`; -} - -export function getMarketplacePathFromSegments(segments: string[]) { - if (segments.length === 0) { - return MARKETPLACE_BASE; - } - - return `${MARKETPLACE_BASE}/${segments.join("/")}`; -} - -export function getMarketplaceCanonicalUrl(pathname: string) { - return `https://dub.co${pathname.startsWith("/") ? pathname : `/${pathname}`}`; -} - export function getMarketplaceCategoryFromPathname( pathname: string, ): Category | null { From 75d28faae49a65ab0c561ee25565f85926192c97 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Thu, 25 Jun 2026 20:07:21 -0700 Subject: [PATCH 18/29] remove console log --- apps/web/ui/program-marketplace/marketplace-program-row.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/web/ui/program-marketplace/marketplace-program-row.tsx b/apps/web/ui/program-marketplace/marketplace-program-row.tsx index ed1d6939032..5b87e9af8b7 100644 --- a/apps/web/ui/program-marketplace/marketplace-program-row.tsx +++ b/apps/web/ui/program-marketplace/marketplace-program-row.tsx @@ -61,8 +61,6 @@ export function MarketplaceProgramRow({ const carouselItemClassName = "pl-0 basis-[310px] sm:basis-[419px]"; const cardClassName = "h-[260px] w-[310px] sm:h-[284px] sm:w-[419px] sm:p-8"; - console.log(JSON.stringify({ programs }, null, 2)); - if (programs && programs.length === 0) { return null; } From bf2351cdcc6e1d056f4ef6abd8c597ca81762449 Mon Sep 17 00:00:00 2001 From: Marcus Farrell Date: Mon, 17 Aug 2026 15:44:27 -0700 Subject: [PATCH 19/29] Adding the marketplace to the nav --- apps/web/app/api/misc/program-logos/route.ts | 54 ++++++ packages/ui/src/content.ts | 52 +++++- packages/ui/src/icons/nucleo/shop.tsx | 120 +++++++------ .../src/nav/content/program-marketplace.tsx | 155 +++++++++++++++++ .../ui/src/nav/content/resources-content.tsx | 162 +++++------------- packages/ui/src/nav/nav.tsx | 1 + 6 files changed, 366 insertions(+), 178 deletions(-) create mode 100644 apps/web/app/api/misc/program-logos/route.ts create mode 100644 packages/ui/src/nav/content/program-marketplace.tsx diff --git a/apps/web/app/api/misc/program-logos/route.ts b/apps/web/app/api/misc/program-logos/route.ts new file mode 100644 index 00000000000..384016cdcab --- /dev/null +++ b/apps/web/app/api/misc/program-logos/route.ts @@ -0,0 +1,54 @@ +import { prisma } from "@/lib/prisma"; +import { NextRequest, NextResponse } from "next/server"; + +const MAX_SLUGS = 24; + +const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + // cache for an hour, serve stale for a day while revalidating + "Vercel-CDN-Cache-Control": "s-maxage=3600, stale-while-revalidate=86400", + "Cache-Control": "public, max-age=3600, stale-while-revalidate=86400", +}; + +// GET /api/misc/program-logos?slugs=perplexity,framer – public name + logo for +// marketplace programs, used by the marketing nav to render real program logos +export async function GET(req: NextRequest) { + const slugs = [ + ...new Set( + (req.nextUrl.searchParams.get("slugs") ?? "") + .split(",") + .map((slug) => slug.trim().toLowerCase()) + .filter(Boolean), + ), + ].slice(0, MAX_SLUGS); + + if (slugs.length === 0) { + return NextResponse.json([], { headers: CORS_HEADERS }); + } + + const programs = await prisma.program.findMany({ + where: { + slug: { + in: slugs, + }, + addedToMarketplaceAt: { + not: null, + }, + }, + select: { + slug: true, + name: true, + logo: true, + }, + }); + + const programsBySlug = new Map( + programs.map((program) => [program.slug, program]), + ); + + // return in the order the slugs were requested + return NextResponse.json( + slugs.map((slug) => programsBySlug.get(slug)).filter(Boolean), + { headers: CORS_HEADERS }, + ); +} diff --git a/packages/ui/src/content.ts b/packages/ui/src/content.ts index 356411b5355..ef7788e9fb8 100644 --- a/packages/ui/src/content.ts +++ b/packages/ui/src/content.ts @@ -17,6 +17,7 @@ import { Php, Python, Ruby, + Shop, Toggle2, Twitter, Typescript, @@ -138,17 +139,20 @@ export const SOLUTIONS: NavItemChildren = [ }, ]; +// TODO: replace with the public program marketplace URL once it's live +export const PROGRAM_MARKETPLACE_HREF = "/marketplace"; + export const RESOURCES = [ { - icon: LifeRing, + icon: withFillVariant(LifeRing), title: "Help Center", description: "Answers to your questions", href: "/help", }, { - icon: Book2, + icon: withFillVariant(Book2), title: "Docs", - description: "Platform documentation", + description: "Developer documentation", href: "/docs", }, { @@ -158,35 +162,67 @@ export const RESOURCES = [ href: "/about", }, { - icon: Briefcase, + icon: withFillVariant(Briefcase), title: "Careers", description: "Join our global, remote team", href: "/careers", }, { - icon: Feather, + icon: withFillVariant(Feather), title: "Blog", description: "Insights and stories", href: "/blog", }, { - icon: BulletList, + icon: withFillVariant(BulletList), title: "Changelog", description: "Releases and updates", href: "/changelog", }, { icon: Logo, - title: "Brand Guidelines", + title: "Dub Brand", description: "Logos, wordmark, etc.", href: "/brand", }, { - icon: Envelope, + icon: withFillVariant(Envelope), title: "Contact", description: "Reach out to support or sales", href: "/contact", }, + { + icon: withFillVariant(Shop), + title: "Program Marketplace", + description: "Browse our available partner programs", + href: PROGRAM_MARKETPLACE_HREF, + }, +]; + +/** + * Programs whose logos are shown in the Program Marketplace section of the + * Resources dropdown, by program slug (i.e. dub.co/{slug}, not the workspace). + * + * Logos are pulled from each program's own logo via /api/misc/program-logos – + * slugs that don't resolve to a marketplace program are skipped. + */ +export const PROGRAM_MARKETPLACE_SLUGS = [ + "framer", + "beehiiv", + "tella", + "buffer", + "superhuman", + "perplexity", + "polymarket", + "fillout", + "copper", + "chatbase", + "flora", + "viktor", + "copper", + "chatbase", + "flora", + "viktor", ]; export const COMPARE_PAGES = [ diff --git a/packages/ui/src/icons/nucleo/shop.tsx b/packages/ui/src/icons/nucleo/shop.tsx index f6791289261..45124f5fad0 100644 --- a/packages/ui/src/icons/nucleo/shop.tsx +++ b/packages/ui/src/icons/nucleo/shop.tsx @@ -1,6 +1,9 @@ import { SVGProps } from "react"; -export function Shop(props: SVGProps) { +export function Shop({ + variant = "outline", + ...props +}: SVGProps & { variant?: "outline" | "fill" }) { return ( ) { {...props} > - - - - - + {variant === "fill" ? ( + <> + + + + ) : ( + <> + + + + + + + )} ); diff --git a/packages/ui/src/nav/content/program-marketplace.tsx b/packages/ui/src/nav/content/program-marketplace.tsx new file mode 100644 index 00000000000..8b7be3b93d0 --- /dev/null +++ b/packages/ui/src/nav/content/program-marketplace.tsx @@ -0,0 +1,155 @@ +import { APP_DOMAIN, cn, createHref, fetcher } from "@dub/utils"; +import { Link as NavigationMenuLink } from "@radix-ui/react-navigation-menu"; +import { motion, useReducedMotion } from "motion/react"; +import Link from "next/link"; +import useSWR from "swr"; +import { + PROGRAM_MARKETPLACE_HREF, + PROGRAM_MARKETPLACE_SLUGS, +} from "../../content"; +import { Grid } from "../../grid"; +import { NAV_UTM_PARAMS } from "./shared"; + +type MarketplaceProgram = { + slug: string; + name: string; + logo: string | null; +}; + +// left, top, size and rotation (in px/deg) within the 614 × 104 logo canvas +const LOGO_SLOTS = [ + { left: 405, top: 22, size: 24, rotate: 0 }, + { left: 461, top: 1, size: 38, rotate: 0 }, + { left: 307.28, top: 24.83, size: 33, rotate: -6.57 }, + { left: 342, top: 55, size: 30, rotate: 0 }, + { left: 410.5, top: 64.5, size: 31, rotate: 0 }, + { left: 573.5, top: 39.5, size: 39, rotate: 0 }, + { left: 448, top: 31, size: 34, rotate: 0 }, + { left: 526.5, top: 26.5, size: 33, rotate: 0 }, + { left: 93.5, top: 19.5, size: 41, rotate: 0 }, + { left: 162.5, top: 11.5, size: 29, rotate: 0 }, + { left: 2.05, top: 28.03, size: 38, rotate: -6.57 }, + { left: 36.5, top: 58.5, size: 35, rotate: 0 }, + { left: 106, top: 69, size: 34, rotate: 0 }, + { left: 268.5, top: 43.5, size: 43, rotate: 0 }, + { left: 146, top: 38, size: 32, rotate: 0 }, + { left: 225.5, top: 34.5, size: 29, rotate: 0 }, +]; + +// deterministic (so it matches between server and client) per-slot drift, to +// make the logos bob around like they're floating in water +function getDrift(index: number) { + const x = Math.sin(index * 12.9898) * 4; + const y = Math.cos(index * 78.233) * 3; + + return { + x: [0, x, -x * 0.6, 0], + y: [0, y, -y, 0], + duration: 9 + (index % 5), + delay: (index % 7) * 0.5, + }; +} + +function ProgramMarketplaceLogos() { + const reducedMotion = useReducedMotion(); + + const { data: programs } = useSWR( + `${APP_DOMAIN}/api/misc/program-logos?slugs=${PROGRAM_MARKETPLACE_SLUGS.join(",")}`, + fetcher, + { + dedupingInterval: 600000, + revalidateOnFocus: false, + keepPreviousData: true, + }, + ); + + const logos = programs?.filter(({ logo }) => Boolean(logo)) ?? []; + + return ( +
+ {LOGO_SLOTS.map(({ left, top, size, rotate }, index) => { + const program = logos.length ? logos[index % logos.length] : null; + const { x, y, duration, delay } = getDrift(index); + + return ( + +
+ {program?.logo && ( + + )} +
+
+ ); + })} +
+ ); +} + +export function ProgramMarketplaceSection({ domain }: { domain: string }) { + return ( + + + {/* offset so a horizontal cell edge sits just below the middle of the bar */} + + + + +
+
+ + Program Marketplace + + + NEW + +
+

+ Browse our available partner programs{" "} + + → + +

+
+ +
+ ); +} diff --git a/packages/ui/src/nav/content/resources-content.tsx b/packages/ui/src/nav/content/resources-content.tsx index 1dc6b7011e7..52ce2356ab5 100644 --- a/packages/ui/src/nav/content/resources-content.tsx +++ b/packages/ui/src/nav/content/resources-content.tsx @@ -1,142 +1,64 @@ import { cn, createHref } from "@dub/utils"; -import { Link as NavigationMenuLink } from "@radix-ui/react-navigation-menu"; -import Link from "next/link"; import { RESOURCES } from "../../content"; -import { Grid } from "../../grid"; -import { Book2, LifeRing } from "../../icons"; +import { ProgramMarketplaceSection } from "./program-marketplace"; import { ContentLinkCard, NAV_UTM_PARAMS, contentHeadingClassName, } from "./shared"; -const mainLinks = [ +const COLUMNS = [ { - icon: Book2, - title: "Docs", - description: "Platform documentation", - thumbnail: "https://assets.dub.co/misc/docs-thumbnail.jpg", - href: "/docs", + heading: "Help and Support", + titles: ["Help Center", "Docs", "Contact"], }, { - icon: LifeRing, - title: "Help Center", - description: "Answers to your questions", - thumbnail: "https://assets.dub.co/misc/help-thumbnail.jpg", - href: "/help", + heading: "Company", + titles: ["About", "Careers", "Dub Brand"], + }, + { + heading: "Updates", + titles: ["Blog", "Changelog"], }, ]; export function ResourcesContent({ domain }: { domain: string }) { return ( -
-
-

Explore

-
- {mainLinks.map(({ icon: Icon, title, description, href }) => ( - - -
-
- -
-
+
+ {COLUMNS.map(({ heading, titles }) => ( +
+

{heading}

+
+ {RESOURCES.filter(({ title }) => titles.includes(title)).map( + ({ icon: Icon, title, description, href }) => ( + + +
+ } + title={title} + description={description} /> -
- -
- - {title} - -

- {description} -

-
- - - ))} -
+ ), + )} +
+
+ ))}
-
-

Company

-
- {RESOURCES.filter(({ title }) => - ["About", "Careers", "Brand Guidelines", "Contact"].includes(title), - ).map(({ icon: Icon, title, description, href }) => ( - - -
- } - title={title} - description={description} - /> - ))} -
-
- -
-

Updates

-
- {RESOURCES.filter(({ title }) => - ["Blog", "Changelog"].includes(title), - ).map(({ icon: Icon, title, description, href }) => ( - - -
- } - title={title} - description={description} - /> - ))} -
-
+
); } diff --git a/packages/ui/src/nav/nav.tsx b/packages/ui/src/nav/nav.tsx index 18f4f74a8c6..4165a91791e 100644 --- a/packages/ui/src/nav/nav.tsx +++ b/packages/ui/src/nav/nav.tsx @@ -68,6 +68,7 @@ export const navItems = [ "/blog", "/changelog", "/contact", + "/marketplace", ], }, { From 11754746703723534b557b8876bd5f25804a731c Mon Sep 17 00:00:00 2001 From: Marcus Farrell Date: Mon, 17 Aug 2026 20:52:02 -0700 Subject: [PATCH 20/29] Commit 2 --- packages/ui/src/content.ts | 22 ++--- .../src/nav/content/program-marketplace.tsx | 82 +++++++++++++++---- packages/ui/src/nav/nav.tsx | 9 +- 3 files changed, 82 insertions(+), 31 deletions(-) diff --git a/packages/ui/src/content.ts b/packages/ui/src/content.ts index ef7788e9fb8..91c248bbb63 100644 --- a/packages/ui/src/content.ts +++ b/packages/ui/src/content.ts @@ -207,22 +207,22 @@ export const RESOURCES = [ * slugs that don't resolve to a marketplace program are skipped. */ export const PROGRAM_MARKETPLACE_SLUGS = [ - "framer", "beehiiv", - "tella", - "buffer", + "flow", + "granola", "superhuman", - "perplexity", "polymarket", - "fillout", - "copper", - "chatbase", - "flora", "viktor", - "copper", - "chatbase", + "framer", "flora", - "viktor", + "dub", + "superpower", + "chatbase", + "copper", + "anything", + "tradezella", + "buffer", + "coderabbit", ]; export const COMPARE_PAGES = [ diff --git a/packages/ui/src/nav/content/program-marketplace.tsx b/packages/ui/src/nav/content/program-marketplace.tsx index 8b7be3b93d0..164c3856c7c 100644 --- a/packages/ui/src/nav/content/program-marketplace.tsx +++ b/packages/ui/src/nav/content/program-marketplace.tsx @@ -2,6 +2,7 @@ import { APP_DOMAIN, cn, createHref, fetcher } from "@dub/utils"; import { Link as NavigationMenuLink } from "@radix-ui/react-navigation-menu"; import { motion, useReducedMotion } from "motion/react"; import Link from "next/link"; +import { useEffect } from "react"; import useSWR from "swr"; import { PROGRAM_MARKETPLACE_HREF, @@ -36,11 +37,26 @@ const LOGO_SLOTS = [ { left: 225.5, top: 34.5, size: 29, rotate: 0 }, ]; +const LOGO_SIZES = LOGO_SLOTS.map(({ size }) => size); +const MIN_LOGO_SIZE = Math.min(...LOGO_SIZES); +const MAX_LOGO_SIZE = Math.max(...LOGO_SIZES); + +/** + * Fake depth: the smallest logos read as furthest away, so they get the most + * blur and the least shadow/movement, and vice versa for the largest ones. + * Returns 0 (furthest back) to 1 (closest to the viewer). + */ +function getDepth(size: number) { + return (size - MIN_LOGO_SIZE) / (MAX_LOGO_SIZE - MIN_LOGO_SIZE); +} + // deterministic (so it matches between server and client) per-slot drift, to -// make the logos bob around like they're floating in water -function getDrift(index: number) { - const x = Math.sin(index * 12.9898) * 4; - const y = Math.cos(index * 78.233) * 3; +// make the logos bob around like they're floating in water – the closer a logo +// is, the more it moves, which sells the parallax +function getDrift(index: number, depth: number) { + const amplitude = 0.6 + depth * 0.8; + const x = Math.sin(index * 12.9898) * 6 * amplitude; + const y = Math.cos(index * 78.233) * 4.5 * amplitude; return { x: [0, x, -x * 0.6, 0], @@ -50,20 +66,46 @@ function getDrift(index: number) { }; } +const PROGRAM_LOGOS_API_URL = `${APP_DOMAIN}/api/misc/program-logos?slugs=${PROGRAM_MARKETPLACE_SLUGS.join(",")}`; + +function useProgramMarketplaceLogos() { + return useSWR(PROGRAM_LOGOS_API_URL, fetcher, { + dedupingInterval: 600000, + revalidateOnFocus: false, + keepPreviousData: true, + }); +} + +/** + * Warms the program logos (both the API response and the images themselves) + * so they're already cached by the time the Resources dropdown first opens. + * Call from a component that's mounted with the nav, not inside the dropdown. + */ +export function usePreloadProgramMarketplaceLogos() { + const { data: programs } = useProgramMarketplaceLogos(); + + useEffect(() => { + programs?.forEach(({ logo }) => { + if (logo) new Image().src = logo; + }); + }, [programs]); +} + function ProgramMarketplaceLogos() { const reducedMotion = useReducedMotion(); - const { data: programs } = useSWR( - `${APP_DOMAIN}/api/misc/program-logos?slugs=${PROGRAM_MARKETPLACE_SLUGS.join(",")}`, - fetcher, - { - dedupingInterval: 600000, - revalidateOnFocus: false, - keepPreviousData: true, - }, - ); + const { data: programs } = useProgramMarketplaceLogos(); - const logos = programs?.filter(({ logo }) => Boolean(logo)) ?? []; + // build from the configured list so order (and any intentional duplicate + // slugs) is preserved even though the API dedupes and filters + const programsBySlug = new Map( + (programs ?? []).map((program) => [program.slug, program]), + ); + const logos = PROGRAM_MARKETPLACE_SLUGS.map((slug) => + programsBySlug.get(slug), + ).filter((program): program is MarketplaceProgram & { logo: string } => + Boolean(program?.logo), + ); return (
{LOGO_SLOTS.map(({ left, top, size, rotate }, index) => { const program = logos.length ? logos[index % logos.length] : null; - const { x, y, duration, delay } = getDrift(index); + const depth = getDepth(size); + const { x, y, duration, delay } = getDrift(index, depth); return ( {program?.logo && ( )} diff --git a/packages/ui/src/nav/nav.tsx b/packages/ui/src/nav/nav.tsx index 4165a91791e..cf6b90263cf 100644 --- a/packages/ui/src/nav/nav.tsx +++ b/packages/ui/src/nav/nav.tsx @@ -20,6 +20,7 @@ import { useScroll } from "../hooks"; import { MaxWidthWrapper } from "../max-width-wrapper"; import { NavWordmark } from "../nav-wordmark"; import { ProductContent } from "./content/product-content"; +import { usePreloadProgramMarketplaceLogos } from "./content/program-marketplace"; import { ResourcesContent } from "./content/resources-content"; import { SolutionsContent } from "./content/solutions-content"; @@ -153,6 +154,9 @@ export function Nav({ }, ); + // warm the marketplace logos so the Resources dropdown opens without a stutter + usePreloadProgramMarketplaceLogos(); + const showHover = (item: HTMLElement) => { const list = navListRef.current; if (!list) return; @@ -213,9 +217,8 @@ export function Nav({ className="group/nav relative flex" onMouseLeave={() => { const list = navListRef.current; - const openTrigger = list?.querySelector( - "[data-state=open]", - ); + const openTrigger = + list?.querySelector("[data-state=open]"); // Keep the pill on the open trigger so it doesn't flicker into the dropdown if (list && openTrigger) { setHoverStyle(getHoverStyle(list, openTrigger)); From 902fba53b1504497adf8f5a24436002865b32c5b Mon Sep 17 00:00:00 2001 From: Marcus Farrell Date: Mon, 17 Aug 2026 20:58:44 -0700 Subject: [PATCH 21/29] Commit 3 --- packages/ui/src/nav/content/program-marketplace.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/ui/src/nav/content/program-marketplace.tsx b/packages/ui/src/nav/content/program-marketplace.tsx index 164c3856c7c..a0c6786243d 100644 --- a/packages/ui/src/nav/content/program-marketplace.tsx +++ b/packages/ui/src/nav/content/program-marketplace.tsx @@ -62,7 +62,7 @@ function getDrift(index: number, depth: number) { x: [0, x, -x * 0.6, 0], y: [0, y, -y, 0], duration: 9 + (index % 5), - delay: (index % 7) * 0.5, + delay: -(1.5 + index * 0.7), }; } @@ -76,11 +76,6 @@ function useProgramMarketplaceLogos() { }); } -/** - * Warms the program logos (both the API response and the images themselves) - * so they're already cached by the time the Resources dropdown first opens. - * Call from a component that's mounted with the nav, not inside the dropdown. - */ export function usePreloadProgramMarketplaceLogos() { const { data: programs } = useProgramMarketplaceLogos(); From 531c652d83c17523836d0819b59638160de8dc00 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Mon, 17 Aug 2026 21:08:24 -0700 Subject: [PATCH 22/29] fix app redirects --- .../marketplace/[[...segments]]/page.tsx | 4 +--- apps/web/lib/middleware/app.ts | 17 +++++++++++------ .../external/marketplace-external-shell.tsx | 10 ++++------ 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/apps/web/app/app.dub.co/marketplace/[[...segments]]/page.tsx b/apps/web/app/app.dub.co/marketplace/[[...segments]]/page.tsx index 833bc637b5a..355a0f9e7b7 100644 --- a/apps/web/app/app.dub.co/marketplace/[[...segments]]/page.tsx +++ b/apps/web/app/app.dub.co/marketplace/[[...segments]]/page.tsx @@ -16,9 +16,7 @@ export async function generateMetadata(props: { const { segments = [] } = await props.params; const pathname = `/marketplace${segments.length > 0 ? `/${segments.join("/")}` : ""}`; - const year = new Date().getFullYear(); - - let title = `Best SaaS affiliate programs in ${year}`; + let title = `Best SaaS affiliate programs in ${new Date().getFullYear()}`; let description = `Browse and apply to the best SaaS affiliate programs on Dub's Partner Network.`; let image: string | undefined; diff --git a/apps/web/lib/middleware/app.ts b/apps/web/lib/middleware/app.ts index 41de66fc3dc..48671d4b7d9 100644 --- a/apps/web/lib/middleware/app.ts +++ b/apps/web/lib/middleware/app.ts @@ -13,6 +13,15 @@ import { isTopLevelSettingsRedirect } from "./utils/is-top-level-settings-redire import { parse } from "./utils/parse"; import { WorkspacesMiddleware } from "./workspaces"; +const isPublicPath = (path: string) => + [ + "/marketplace", + "/share/", + "/deeplink/", + "/unsubscribe/", + "/auth/reset-password/", + ].some((p) => path.startsWith(p)); + export async function AppMiddleware(req: NextRequest) { const { path, fullPath, searchParamsString } = parse(req); @@ -29,11 +38,7 @@ export async function AppMiddleware(req: NextRequest) { path !== "/forgot-password" && path !== "/register" && path !== "/auth/saml" && - !path.startsWith("/auth/reset-password/") && - !path.startsWith("/marketplace") && - !path.startsWith("/share/") && - !path.startsWith("/deeplink/") && - !path.startsWith("/unsubscribe/") + !isPublicPath(path) ) { return NextResponse.redirect( new URL( @@ -43,7 +48,7 @@ export async function AppMiddleware(req: NextRequest) { ); // if there's a user - } else if (user) { + } else if (user && !isPublicPath(path)) { // /new is a special path that creates a new link (or workspace if the user doesn't have one yet) if (path === "/new") { return NewLinkMiddleware(req, user); diff --git a/apps/web/ui/program-marketplace/external/marketplace-external-shell.tsx b/apps/web/ui/program-marketplace/external/marketplace-external-shell.tsx index a7c7a167c4e..cc94e04ad3d 100644 --- a/apps/web/ui/program-marketplace/external/marketplace-external-shell.tsx +++ b/apps/web/ui/program-marketplace/external/marketplace-external-shell.tsx @@ -22,28 +22,26 @@ export function MarketplaceExternalShell({ ); } - const year = new Date().getFullYear(); - const resolvedTitle = title ?? (variant === "home" ? ( <> Best SaaS affiliate
- programs in {year} + programs in {new Date().getFullYear()} ) : ( <> - Find your next + Discover your
- partnership + next partnership )); const resolvedDescription = description ?? (variant === "home" - ? `Browse and apply to the best SaaS affiliate programs on Dub's Partner Network.` + ? `Explore the variety of partner programs in the Dub Program Marketplace and start monetizing your traffic/audience today.` : "Explore the Dub Partner marketplace and start earning."); return ( From a00f085c9a41709d69ceb1d8fc8918399481226f Mon Sep 17 00:00:00 2001 From: Marcus Farrell Date: Mon, 17 Aug 2026 21:26:29 -0700 Subject: [PATCH 23/29] Commit 5 --- packages/ui/src/nav/content/program-marketplace.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/nav/content/program-marketplace.tsx b/packages/ui/src/nav/content/program-marketplace.tsx index a0c6786243d..c2215c2ce53 100644 --- a/packages/ui/src/nav/content/program-marketplace.tsx +++ b/packages/ui/src/nav/content/program-marketplace.tsx @@ -41,11 +41,6 @@ const LOGO_SIZES = LOGO_SLOTS.map(({ size }) => size); const MIN_LOGO_SIZE = Math.min(...LOGO_SIZES); const MAX_LOGO_SIZE = Math.max(...LOGO_SIZES); -/** - * Fake depth: the smallest logos read as furthest away, so they get the most - * blur and the least shadow/movement, and vice versa for the largest ones. - * Returns 0 (furthest back) to 1 (closest to the viewer). - */ function getDepth(size: number) { return (size - MIN_LOGO_SIZE) / (MAX_LOGO_SIZE - MIN_LOGO_SIZE); } @@ -127,12 +122,11 @@ function ProgramMarketplaceLogos() { >
)} +
); From 90d73acde05acd283794da545dfba1ef8c780f19 Mon Sep 17 00:00:00 2001 From: Marcus Farrell Date: Mon, 17 Aug 2026 21:53:47 -0700 Subject: [PATCH 24/29] Commit 6 --- .../src/nav/content/program-marketplace.tsx | 2 +- .../ui/src/nav/content/resources-content.tsx | 71 +++++++++++-------- packages/ui/src/nav/nav.tsx | 5 +- 3 files changed, 45 insertions(+), 33 deletions(-) diff --git a/packages/ui/src/nav/content/program-marketplace.tsx b/packages/ui/src/nav/content/program-marketplace.tsx index c2215c2ce53..2cac917add3 100644 --- a/packages/ui/src/nav/content/program-marketplace.tsx +++ b/packages/ui/src/nav/content/program-marketplace.tsx @@ -158,7 +158,7 @@ export function ProgramMarketplaceSection({ domain }: { domain: string }) { utm_content: "Program Marketplace", })} className={cn( - "group relative isolate flex items-center overflow-hidden border-t border-neutral-100 bg-neutral-50 px-6 py-3 transition-colors duration-75", + "group relative isolate flex items-center overflow-hidden bg-neutral-50 px-6 py-3 transition-colors duration-75", "hover:bg-neutral-100 dark:border-white/[0.15] dark:bg-white/[0.03] dark:hover:bg-white/[0.06]", )} > diff --git a/packages/ui/src/nav/content/resources-content.tsx b/packages/ui/src/nav/content/resources-content.tsx index 52ce2356ab5..296e683c51e 100644 --- a/packages/ui/src/nav/content/resources-content.tsx +++ b/packages/ui/src/nav/content/resources-content.tsx @@ -24,38 +24,47 @@ const COLUMNS = [ export function ResourcesContent({ domain }: { domain: string }) { return ( -
-
- {COLUMNS.map(({ heading, titles }) => ( -
-

{heading}

-
- {RESOURCES.filter(({ title }) => titles.includes(title)).map( - ({ icon: Icon, title, description, href }) => ( - - -
- } - title={title} - description={description} - /> - ), - )} +
+
+
+ {COLUMNS.map(({ heading, titles }) => ( +
+

{heading}

+
+ {RESOURCES.filter(({ title }) => titles.includes(title)).map( + ({ icon: Icon, title, description, href }) => ( + + +
+ } + title={title} + description={description} + /> + ), + )} +
-
- ))} + ))} +
+ + {/* bottom hairline drawn as a clipped full border, so it keeps full + strength around the curled corners (border-b alone tapers there); + kept outside the grid so divide-x doesn't add a border to it */} +
+
+
diff --git a/packages/ui/src/nav/nav.tsx b/packages/ui/src/nav/nav.tsx index cf6b90263cf..ca5300cefef 100644 --- a/packages/ui/src/nav/nav.tsx +++ b/packages/ui/src/nav/nav.tsx @@ -284,7 +284,10 @@ export function Nav({ className={cn( "relative flex origin-top justify-start overflow-hidden rounded-[20px] border border-neutral-200 bg-white shadow-md dark:border-white/[0.15] dark:bg-black", "data-[state=closed]:animate-scale-out-content data-[state=open]:animate-scale-in-content", - "h-[var(--radix-navigation-menu-viewport-height)] w-[var(--radix-navigation-menu-viewport-width)] transition-[width,height]", + // +2px: Radix measures the content's border-box, but this element's + // 1px borders eat into it (border-box sizing), clipping fixed-width + // content by 2px on the right/bottom + "h-[calc(var(--radix-navigation-menu-viewport-height)_+_2px)] w-[calc(var(--radix-navigation-menu-viewport-width)_+_2px)] transition-[width,height]", )} />
From 5c172c71402ab33809426ea779af7a2a98fd560b Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Tue, 18 Aug 2026 15:38:53 -0700 Subject: [PATCH 25/29] fix nav --- packages/ui/src/nav/nav-mobile.tsx | 6 ++++-- packages/ui/src/nav/nav.tsx | 23 ++++++++++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/nav/nav-mobile.tsx b/packages/ui/src/nav/nav-mobile.tsx index d9a40ed7afc..7eee44ae4f6 100644 --- a/packages/ui/src/nav/nav-mobile.tsx +++ b/packages/ui/src/nav/nav-mobile.tsx @@ -15,7 +15,7 @@ import { DubLinksIcon, DubPartnersIcon, } from "../icons"; -import { navItems, type NavTheme } from "./nav"; +import { navItems, type NavItem, type NavTheme } from "./nav"; const specialIcons: Record = { "Dub Links": ( @@ -43,9 +43,11 @@ const specialIcons: Record = { export function NavMobile({ theme = "light", staticDomain, + navItems: items = navItems, }: { theme?: NavTheme; staticDomain?: string; + navItems?: NavItem[]; }) { let { domain = "dub.co" } = useParams() as { domain: string }; if (staticDomain) { @@ -110,7 +112,7 @@ export function NavMobile({ )} >
    - {navItems.map(({ name, href, childItems }, idx) => ( + {items.map(({ name, href, childItems }, idx) => ( ({ theme: "light", }); -export const navItems = [ +export type NavItem = { + name: string; + href?: string; + segments?: string[]; + content?: ComponentType<{ domain: string }>; + childItems?: NavItemChildren; + mobileOnly?: boolean; +}; + +export const navItems: NavItem[] = [ { name: "Product", content: ProductContent, @@ -123,11 +138,13 @@ export function Nav({ theme = "light", staticDomain, maxWidthWrapperClassName, + navItems: items = navItems, logo, }: { theme?: NavTheme; staticDomain?: string; maxWidthWrapperClassName?: string; + navItems?: NavItem[]; logo?: ReactNode; }) { let { domain = "dub.co" } = useParams() as { domain: string }; @@ -228,7 +245,7 @@ export function Nav({ animate={hoverStyle} transition={pillSpring} /> - {navItems + {items .filter(({ mobileOnly }) => !mobileOnly) .map(({ name, href, segments, content: Content }) => { const isActive = (segments ?? []).some((segment) => From de4c244fc3c922965638354f7351aeb84dad2030 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Thu, 3 Sep 2026 14:08:25 -0700 Subject: [PATCH 26/29] finalize copy --- .../marketplace/[[...segments]]/page.tsx | 16 +++---- apps/web/app/sitemap.ts | 2 +- apps/web/lib/network/program-categories.ts | 43 ++++++++++--------- .../external/marketplace-external-header.tsx | 6 +-- .../marketplace-external-list-page.tsx | 2 +- .../external/marketplace-external-shell.tsx | 4 +- 6 files changed, 36 insertions(+), 37 deletions(-) diff --git a/apps/web/app/app.dub.co/marketplace/[[...segments]]/page.tsx b/apps/web/app/app.dub.co/marketplace/[[...segments]]/page.tsx index 355a0f9e7b7..47cb02d25de 100644 --- a/apps/web/app/app.dub.co/marketplace/[[...segments]]/page.tsx +++ b/apps/web/app/app.dub.co/marketplace/[[...segments]]/page.tsx @@ -16,13 +16,14 @@ export async function generateMetadata(props: { const { segments = [] } = await props.params; const pathname = `/marketplace${segments.length > 0 ? `/${segments.join("/")}` : ""}`; - let title = `Best SaaS affiliate programs in ${new Date().getFullYear()}`; + const currentYear = new Date().getFullYear(); + + let title = `Best SaaS affiliate programs in ${currentYear}`; let description = `Browse and apply to the best SaaS affiliate programs on Dub's Partner Network.`; let image: string | undefined; if (segments.length === 1 && segments[0] === "all") { - title = "All Programs"; - description = "Browse all partner programs on Dub."; + title = `Top SaaS affiliate programs in ${currentYear}`; } else if ( segments.length === 1 && segments[0] !== "all" && @@ -34,10 +35,8 @@ export async function generateMetadata(props: { title = program.name; description = program.description || - `Join the ${program.name} affiliate program on Dub's Partner Network.`; + `Join the ${program.name} affiliate program on Dub's Program Marketplace.`; image = program.marketplaceHeaderImage || program.logo || undefined; - } else { - title = "Program Details"; } } else if (segments.length === 2 && segments[0] === "c") { const category = Object.values(Category).find( @@ -45,12 +44,13 @@ export async function generateMetadata(props: { ); if (category) { + // categoryMeta should always return a value, but just in case const categoryMeta = PROGRAM_CATEGORIES_MAP[category]; const label = categoryMeta?.label ?? category.replaceAll("_", " "); - title = `${label} Programs`; + title = `Best ${label.toLowerCase()} affiliate programs in ${currentYear}`; description = categoryMeta?.listPageDescription ?? - `Partner programs in ${label.toLowerCase()}.`; + `Browse the best ${label.toLowerCase()} affiliate programs on Dub's Program Marketplace.`; } } diff --git a/apps/web/app/sitemap.ts b/apps/web/app/sitemap.ts index 29dd44013d5..fe757f9a292 100644 --- a/apps/web/app/sitemap.ts +++ b/apps/web/app/sitemap.ts @@ -8,7 +8,7 @@ export default async function sitemap(): Promise { const headersList = await headers(); let domain = headersList.get("host") as string; - if (domain === "dub.localhost:8888" || domain.endsWith(".vercel.app")) { + if (domain === "dub.localhost:8888") { // for local development and preview URLs domain = SHORT_DOMAIN; } diff --git a/apps/web/lib/network/program-categories.ts b/apps/web/lib/network/program-categories.ts index 9640a15245a..45c4e51cd20 100644 --- a/apps/web/lib/network/program-categories.ts +++ b/apps/web/lib/network/program-categories.ts @@ -9,6 +9,7 @@ import { Icon, MarketingTarget, MoneyBill, + ShieldKeyhole, Sparkle3, User, } from "@dub/ui/icons"; @@ -25,84 +26,84 @@ export const PROGRAM_CATEGORIES: { label: "AI", icon: Sparkle3, listPageDescription: - "Browse partner programs for AI tools and machine learning platforms.", + "Browse affiliate programs for AI tools and machine learning platforms.", }, { id: Category.Development, - label: "Development", + label: "DevTools", icon: Code, listPageDescription: - "Browse partner programs for developer tools and software infrastructure.", + "Browse affiliate programs for developer tools and software infrastructure.", }, { id: Category.Design, label: "Design", icon: Brush, listPageDescription: - "Browse partner programs for design tools and creative software.", + "Browse affiliate programs for design tools and creative software.", }, { id: Category.Productivity, label: "Productivity", icon: CircleHalfDottedClock, listPageDescription: - "Browse partner programs for productivity software and modern work tools.", + "Browse affiliate programs for productivity software and modern work tools.", }, { id: Category.Finance, - label: "Finance", + label: "FinTech", icon: MoneyBill, listPageDescription: - "Browse partner programs for finance software and fintech platforms.", + "Browse affiliate programs for finance software and fintech platforms.", }, { id: Category.Marketing, label: "Marketing", icon: MarketingTarget, listPageDescription: - "Browse partner programs for marketing software and growth tools.", + "Browse affiliate programs for marketing software and growth tools.", }, { id: Category.Ecommerce, label: "Ecommerce", icon: CreditCard, listPageDescription: - "Browse partner programs for ecommerce platforms and online retail tools.", + "Browse affiliate programs for ecommerce platforms and online retail tools.", + }, + { + id: Category.Security, + label: "Security", + icon: ShieldKeyhole, + listPageDescription: + "Browse affiliate programs for security software and privacy tools.", }, - // { - // id: Category.Security, - // label: "Security", - // icon: ShieldKeyhole, - // listPageDescription: - // "Browse partner programs for security software and privacy tools.", - // }, { id: Category.Education, label: "Education", icon: BookOpen, listPageDescription: - "Browse partner programs for education platforms and learning tools.", + "Browse affiliate programs for education platforms and learning tools.", }, { id: Category.Health, - label: "Health", + label: "Healthcare", icon: Heart, listPageDescription: - "Browse partner programs for health software and wellness tools.", + "Browse affiliate programs for healthcare software and wellness tools.", }, { id: Category.Consumer, label: "Consumer", icon: User, listPageDescription: - "Browse partner programs for consumer apps and lifestyle products.", + "Browse affiliate programs for consumer apps and lifestyle products.", }, { id: Category.Support, label: "Support", icon: Headset, listPageDescription: - "Browse partner programs for customer support and help desk tools.", + "Browse affiliate programs for customer support and help desk tools.", }, ]; diff --git a/apps/web/ui/program-marketplace/external/marketplace-external-header.tsx b/apps/web/ui/program-marketplace/external/marketplace-external-header.tsx index 40e2529dced..c76538ece23 100644 --- a/apps/web/ui/program-marketplace/external/marketplace-external-header.tsx +++ b/apps/web/ui/program-marketplace/external/marketplace-external-header.tsx @@ -1,6 +1,6 @@ "use client"; -import { NavMobile, Nav as NavUI, Wordmark } from "@dub/ui"; +import { NavMobile, Nav as NavUI, Shop, Wordmark } from "@dub/ui"; import Link from "next/link"; const DUB_HOME_HREF = "https://dub.co/home"; @@ -17,9 +17,9 @@ function MarketplaceLogo() { | - Programs + Program Marketplace
); diff --git a/apps/web/ui/program-marketplace/external/marketplace-external-list-page.tsx b/apps/web/ui/program-marketplace/external/marketplace-external-list-page.tsx index 5875fd821cb..0ae5526280f 100644 --- a/apps/web/ui/program-marketplace/external/marketplace-external-list-page.tsx +++ b/apps/web/ui/program-marketplace/external/marketplace-external-list-page.tsx @@ -57,7 +57,7 @@ export async function MarketplaceExternalListPage({ title={ categoryMeta ? ( <> - {categoryMeta.label} partner + {categoryMeta.label} affiliate
programs diff --git a/apps/web/ui/program-marketplace/external/marketplace-external-shell.tsx b/apps/web/ui/program-marketplace/external/marketplace-external-shell.tsx index cc94e04ad3d..23757c242f2 100644 --- a/apps/web/ui/program-marketplace/external/marketplace-external-shell.tsx +++ b/apps/web/ui/program-marketplace/external/marketplace-external-shell.tsx @@ -40,9 +40,7 @@ export function MarketplaceExternalShell({ const resolvedDescription = description ?? - (variant === "home" - ? `Explore the variety of partner programs in the Dub Program Marketplace and start monetizing your traffic/audience today.` - : "Explore the Dub Partner marketplace and start earning."); + "Explore the variety of SaaS affiliate programs in the Dub Program Marketplace and start monetizing your traffic/audience today."; return (
From 243873c801dd1ba6a192c36dee413d38dc1efd26 Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Thu, 3 Sep 2026 18:20:55 -0300 Subject: [PATCH 27/29] sitemap --- apps/web/app/sitemap.ts | 48 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/apps/web/app/sitemap.ts b/apps/web/app/sitemap.ts index fe757f9a292..0871ace4bfd 100644 --- a/apps/web/app/sitemap.ts +++ b/apps/web/app/sitemap.ts @@ -1,6 +1,10 @@ import { prisma } from "@/lib/prisma"; -import { PARTNERS_HOSTNAMES, SHORT_DOMAIN } from "@dub/utils"; -import { Prisma } from "@prisma/client"; +import { + getMarketplaceAllHref, + getMarketplaceCategoryHref, +} from "@/ui/program-marketplace/utils/urls"; +import { isAppHostname, PARTNERS_HOSTNAMES, SHORT_DOMAIN } from "@dub/utils"; +import { Category, Prisma } from "@prisma/client"; import { MetadataRoute } from "next"; import { headers } from "next/headers"; @@ -39,10 +43,48 @@ export default async function sitemap(): Promise { })); } - return [ + const entries: MetadataRoute.Sitemap = [ { url: `https://${domain}`, lastModified: new Date(), }, ]; + + if (isAppHostname(domain)) { + const marketplacePrograms = await prisma.program.findMany({ + where: { + addedToMarketplaceAt: { + not: null, + }, + }, + select: { + slug: true, + updatedAt: true, + }, + orderBy: { + slug: "asc", + }, + }); + + entries.push( + { + url: "https://dub.co/marketplace", + lastModified: new Date(), + }, + { + url: `https://dub.co${getMarketplaceAllHref()}`, + lastModified: new Date(), + }, + ...Object.values(Category).map((category) => ({ + url: `https://dub.co${getMarketplaceCategoryHref(category)}`, + lastModified: new Date(), + })), + ...marketplacePrograms.map((program) => ({ + url: `https://dub.co/marketplace/${program.slug}`, + lastModified: program.updatedAt, + })), + ); + } + + return entries; } From 0c3bf9bd6b6121c13be5968e8dac4319340cc8b4 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Thu, 3 Sep 2026 17:15:21 -0700 Subject: [PATCH 28/29] update OG images --- .../app/api/og/program/categories/route.tsx | 142 ++++++++++++++++++ .../marketplace/[[...segments]]/page.tsx | 13 +- apps/web/lib/middleware/app.ts | 3 +- apps/web/lib/network/program-categories.ts | 24 +-- 4 files changed, 164 insertions(+), 18 deletions(-) create mode 100644 apps/web/app/api/og/program/categories/route.tsx diff --git a/apps/web/app/api/og/program/categories/route.tsx b/apps/web/app/api/og/program/categories/route.tsx new file mode 100644 index 00000000000..4567056fc3c --- /dev/null +++ b/apps/web/app/api/og/program/categories/route.tsx @@ -0,0 +1,142 @@ +import { PROGRAM_CATEGORIES } from "@/lib/network/program-categories"; +import { DUB_WORDMARK } from "@dub/utils"; +import { ImageResponse } from "next/og"; +import { NextRequest } from "next/server"; +import { loadGoogleFont } from "../../load-google-font"; + +const DARK_CELLS = [ + [2, 3], + [5, 3], + [56, 7], + [53, 1], +]; + +// GET /api/og/program/categories?categorySlug=ai +export async function GET(req: NextRequest) { + const categorySlug = req.nextUrl.searchParams.get("categorySlug"); + + if (!categorySlug) { + return new Response("Missing 'categorySlug' parameter", { + status: 400, + }); + } + + const category = PROGRAM_CATEGORIES.find( + ({ id }) => id.toLowerCase() === categorySlug.toLowerCase(), + ); + + if (!category) { + return new Response("Category not found", { + status: 404, + }); + } + + const interSemibold = await loadGoogleFont("Inter:wght@600"); + + return new ImageResponse( + ( +
+ {/* @ts-ignore */} + + + + + + + + + + + + + + {DARK_CELLS.map(([x, y]) => ( + + ))} + + + + + +
+
+ +
+
+ Program Marketplace +
+
+
+ {`${category.label} Affiliate Programs`} +
+
+
+ {category.listPageDescription} +
+
+
+
+ ), + { + width: 1200, + height: 630, + fonts: interSemibold + ? [ + { + name: "Inter", + data: interSemibold, + style: "normal", + weight: 600, + }, + ] + : [], + }, + ); +} diff --git a/apps/web/app/app.dub.co/marketplace/[[...segments]]/page.tsx b/apps/web/app/app.dub.co/marketplace/[[...segments]]/page.tsx index 47cb02d25de..9067610d7e4 100644 --- a/apps/web/app/app.dub.co/marketplace/[[...segments]]/page.tsx +++ b/apps/web/app/app.dub.co/marketplace/[[...segments]]/page.tsx @@ -1,7 +1,7 @@ import { getNetworkProgram } from "@/lib/fetchers/get-network-program"; import { PROGRAM_CATEGORIES_MAP } from "@/lib/network/program-categories"; import { MarketplaceExternalRouter } from "@/ui/program-marketplace/external/marketplace-external-router"; -import { constructMetadata } from "@dub/utils"; +import { APP_DOMAIN, constructMetadata } from "@dub/utils"; import { Category } from "@prisma/client"; import { Metadata } from "next"; @@ -20,7 +20,7 @@ export async function generateMetadata(props: { let title = `Best SaaS affiliate programs in ${currentYear}`; let description = `Browse and apply to the best SaaS affiliate programs on Dub's Partner Network.`; - let image: string | undefined; + let image = "https://assets.dub.co/og/marketplace.jpg"; if (segments.length === 1 && segments[0] === "all") { title = `Top SaaS affiliate programs in ${currentYear}`; @@ -36,7 +36,9 @@ export async function generateMetadata(props: { description = program.description || `Join the ${program.name} affiliate program on Dub's Program Marketplace.`; - image = program.marketplaceHeaderImage || program.logo || undefined; + image = + program.marketplaceHeaderImage || + `${APP_DOMAIN}/api/og/program?slug=${program.slug}`; } } else if (segments.length === 2 && segments[0] === "c") { const category = Object.values(Category).find( @@ -47,10 +49,11 @@ export async function generateMetadata(props: { // categoryMeta should always return a value, but just in case const categoryMeta = PROGRAM_CATEGORIES_MAP[category]; const label = categoryMeta?.label ?? category.replaceAll("_", " "); - title = `Best ${label.toLowerCase()} affiliate programs in ${currentYear}`; + title = `Best ${label} Affiliate Programs in ${currentYear}`; description = categoryMeta?.listPageDescription ?? - `Browse the best ${label.toLowerCase()} affiliate programs on Dub's Program Marketplace.`; + `Browse the best ${label} affiliate programs on Dub's Program Marketplace.`; + image = `${APP_DOMAIN}/api/og/program/categories?categorySlug=${category.toLowerCase()}`; } } diff --git a/apps/web/lib/middleware/app.ts b/apps/web/lib/middleware/app.ts index 48671d4b7d9..634e0c0b85d 100644 --- a/apps/web/lib/middleware/app.ts +++ b/apps/web/lib/middleware/app.ts @@ -14,8 +14,9 @@ import { parse } from "./utils/parse"; import { WorkspacesMiddleware } from "./workspaces"; const isPublicPath = (path: string) => + ["/marketplace"].some((p) => path === p) || [ - "/marketplace", + "/marketplace/", "/share/", "/deeplink/", "/unsubscribe/", diff --git a/apps/web/lib/network/program-categories.ts b/apps/web/lib/network/program-categories.ts index 45c4e51cd20..fbcd1103598 100644 --- a/apps/web/lib/network/program-categories.ts +++ b/apps/web/lib/network/program-categories.ts @@ -26,84 +26,84 @@ export const PROGRAM_CATEGORIES: { label: "AI", icon: Sparkle3, listPageDescription: - "Browse affiliate programs for AI tools and machine learning platforms.", + "Browse the best affiliate programs for AI tools, agents, and chatbots.", }, { id: Category.Development, label: "DevTools", icon: Code, listPageDescription: - "Browse affiliate programs for developer tools and software infrastructure.", + "Browse the best affiliate programs for developer tools, APIs, and no-code platforms.", }, { id: Category.Design, label: "Design", icon: Brush, listPageDescription: - "Browse affiliate programs for design tools and creative software.", + "Browse the best affiliate programs for graphic design tools and creative software.", }, { id: Category.Productivity, label: "Productivity", icon: CircleHalfDottedClock, listPageDescription: - "Browse affiliate programs for productivity software and modern work tools.", + "Browse the best affiliate programs for productivity apps, email tools, and collaboration software.", }, { id: Category.Finance, label: "FinTech", icon: MoneyBill, listPageDescription: - "Browse affiliate programs for finance software and fintech platforms.", + "Browse the best affiliate programs for trading, crypto, and finance apps.", }, { id: Category.Marketing, label: "Marketing", icon: MarketingTarget, listPageDescription: - "Browse affiliate programs for marketing software and growth tools.", + "Browse the best affiliate programs for marketing software, email marketing, and SEO tools.", }, { id: Category.Ecommerce, label: "Ecommerce", icon: CreditCard, listPageDescription: - "Browse affiliate programs for ecommerce platforms and online retail tools.", + "Browse the best affiliate programs for online stores, newsletters, and ecommerce platforms.", }, { id: Category.Security, label: "Security", icon: ShieldKeyhole, listPageDescription: - "Browse affiliate programs for security software and privacy tools.", + "Browse the best affiliate programs for cybersecurity software and privacy tools.", }, { id: Category.Education, label: "Education", icon: BookOpen, listPageDescription: - "Browse affiliate programs for education platforms and learning tools.", + "Browse the best affiliate programs for edtech, learning tools, and education software.", }, { id: Category.Health, label: "Healthcare", icon: Heart, listPageDescription: - "Browse affiliate programs for healthcare software and wellness tools.", + "Browse the best affiliate programs for healthtech, wellness apps, and health software.", }, { id: Category.Consumer, label: "Consumer", icon: User, listPageDescription: - "Browse affiliate programs for consumer apps and lifestyle products.", + "Browse the best affiliate programs for consumer apps and lifestyle software.", }, { id: Category.Support, label: "Support", icon: Headset, listPageDescription: - "Browse affiliate programs for customer support and help desk tools.", + "Browse the best affiliate programs for customer support software and agentic tools.", }, ]; From c2978215b6285d8d8341c34c71af71c130e5ca4a Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Thu, 3 Sep 2026 17:47:52 -0700 Subject: [PATCH 29/29] fix program logos --- apps/web/app/api/misc/program-logos/route.ts | 54 --------- packages/ui/src/content.ts | 43 ++----- .../src/nav/content/program-marketplace.tsx | 105 +++++------------- .../ui/src/nav/content/resources-content.tsx | 2 +- packages/ui/src/nav/nav.tsx | 4 - 5 files changed, 35 insertions(+), 173 deletions(-) delete mode 100644 apps/web/app/api/misc/program-logos/route.ts diff --git a/apps/web/app/api/misc/program-logos/route.ts b/apps/web/app/api/misc/program-logos/route.ts deleted file mode 100644 index 384016cdcab..00000000000 --- a/apps/web/app/api/misc/program-logos/route.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { prisma } from "@/lib/prisma"; -import { NextRequest, NextResponse } from "next/server"; - -const MAX_SLUGS = 24; - -const CORS_HEADERS = { - "Access-Control-Allow-Origin": "*", - // cache for an hour, serve stale for a day while revalidating - "Vercel-CDN-Cache-Control": "s-maxage=3600, stale-while-revalidate=86400", - "Cache-Control": "public, max-age=3600, stale-while-revalidate=86400", -}; - -// GET /api/misc/program-logos?slugs=perplexity,framer – public name + logo for -// marketplace programs, used by the marketing nav to render real program logos -export async function GET(req: NextRequest) { - const slugs = [ - ...new Set( - (req.nextUrl.searchParams.get("slugs") ?? "") - .split(",") - .map((slug) => slug.trim().toLowerCase()) - .filter(Boolean), - ), - ].slice(0, MAX_SLUGS); - - if (slugs.length === 0) { - return NextResponse.json([], { headers: CORS_HEADERS }); - } - - const programs = await prisma.program.findMany({ - where: { - slug: { - in: slugs, - }, - addedToMarketplaceAt: { - not: null, - }, - }, - select: { - slug: true, - name: true, - logo: true, - }, - }); - - const programsBySlug = new Map( - programs.map((program) => [program.slug, program]), - ); - - // return in the order the slugs were requested - return NextResponse.json( - slugs.map((slug) => programsBySlug.get(slug)).filter(Boolean), - { headers: CORS_HEADERS }, - ); -} diff --git a/packages/ui/src/content.ts b/packages/ui/src/content.ts index 91c248bbb63..9b4da99926b 100644 --- a/packages/ui/src/content.ts +++ b/packages/ui/src/content.ts @@ -139,22 +139,19 @@ export const SOLUTIONS: NavItemChildren = [ }, ]; -// TODO: replace with the public program marketplace URL once it's live -export const PROGRAM_MARKETPLACE_HREF = "/marketplace"; - export const RESOURCES = [ - { - icon: withFillVariant(LifeRing), - title: "Help Center", - description: "Answers to your questions", - href: "/help", - }, { icon: withFillVariant(Book2), title: "Docs", description: "Developer documentation", href: "/docs", }, + { + icon: withFillVariant(LifeRing), + title: "Help Center", + description: "Answers to your questions", + href: "/help", + }, { icon: withFillVariant(Users), title: "About", @@ -195,36 +192,10 @@ export const RESOURCES = [ icon: withFillVariant(Shop), title: "Program Marketplace", description: "Browse our available partner programs", - href: PROGRAM_MARKETPLACE_HREF, + href: "/marketplace", }, ]; -/** - * Programs whose logos are shown in the Program Marketplace section of the - * Resources dropdown, by program slug (i.e. dub.co/{slug}, not the workspace). - * - * Logos are pulled from each program's own logo via /api/misc/program-logos – - * slugs that don't resolve to a marketplace program are skipped. - */ -export const PROGRAM_MARKETPLACE_SLUGS = [ - "beehiiv", - "flow", - "granola", - "superhuman", - "polymarket", - "viktor", - "framer", - "flora", - "dub", - "superpower", - "chatbase", - "copper", - "anything", - "tradezella", - "buffer", - "coderabbit", -]; - export const COMPARE_PAGES = [ { name: "Bitly", slug: "bitly" }, { name: "Rebrandly", slug: "rebrandly" }, diff --git a/packages/ui/src/nav/content/program-marketplace.tsx b/packages/ui/src/nav/content/program-marketplace.tsx index 2cac917add3..eba1b1bf265 100644 --- a/packages/ui/src/nav/content/program-marketplace.tsx +++ b/packages/ui/src/nav/content/program-marketplace.tsx @@ -1,40 +1,28 @@ -import { APP_DOMAIN, cn, createHref, fetcher } from "@dub/utils"; +import { cn, createHref } from "@dub/utils"; import { Link as NavigationMenuLink } from "@radix-ui/react-navigation-menu"; import { motion, useReducedMotion } from "motion/react"; import Link from "next/link"; -import { useEffect } from "react"; -import useSWR from "swr"; -import { - PROGRAM_MARKETPLACE_HREF, - PROGRAM_MARKETPLACE_SLUGS, -} from "../../content"; import { Grid } from "../../grid"; import { NAV_UTM_PARAMS } from "./shared"; -type MarketplaceProgram = { - slug: string; - name: string; - logo: string | null; -}; - // left, top, size and rotation (in px/deg) within the 614 × 104 logo canvas const LOGO_SLOTS = [ - { left: 405, top: 22, size: 24, rotate: 0 }, - { left: 461, top: 1, size: 38, rotate: 0 }, - { left: 307.28, top: 24.83, size: 33, rotate: -6.57 }, - { left: 342, top: 55, size: 30, rotate: 0 }, - { left: 410.5, top: 64.5, size: 31, rotate: 0 }, - { left: 573.5, top: 39.5, size: 39, rotate: 0 }, - { left: 448, top: 31, size: 34, rotate: 0 }, - { left: 526.5, top: 26.5, size: 33, rotate: 0 }, - { left: 93.5, top: 19.5, size: 41, rotate: 0 }, - { left: 162.5, top: 11.5, size: 29, rotate: 0 }, - { left: 2.05, top: 28.03, size: 38, rotate: -6.57 }, - { left: 36.5, top: 58.5, size: 35, rotate: 0 }, - { left: 106, top: 69, size: 34, rotate: 0 }, - { left: 268.5, top: 43.5, size: 43, rotate: 0 }, - { left: 146, top: 38, size: 32, rotate: 0 }, - { left: 225.5, top: 34.5, size: 29, rotate: 0 }, + { left: 405, top: 22, size: 24, rotate: 0, logo: "beehiiv" }, + { left: 461, top: 1, size: 38, rotate: 0, logo: "wisprflow" }, + { left: 307.28, top: 24.83, size: 33, rotate: -6.57, logo: "granola" }, + { left: 342, top: 55, size: 30, rotate: 0, logo: "superhuman" }, + { left: 410.5, top: 64.5, size: 31, rotate: 0, logo: "polymarket" }, + { left: 573.5, top: 39.5, size: 39, rotate: 0, logo: "viktor" }, + { left: 448, top: 31, size: 34, rotate: 0, logo: "framer" }, + { left: 526.5, top: 26.5, size: 33, rotate: 0, logo: "flora" }, + { left: 93.5, top: 19.5, size: 41, rotate: 0, logo: "dub" }, + { left: 162.5, top: 11.5, size: 29, rotate: 0, logo: "superpower" }, + { left: 2.05, top: 28.03, size: 38, rotate: -6.57, logo: "chatbase" }, + { left: 36.5, top: 58.5, size: 35, rotate: 0, logo: "copper" }, + { left: 106, top: 69, size: 34, rotate: 0, logo: "anything" }, + { left: 268.5, top: 43.5, size: 43, rotate: 0, logo: "tradezella" }, + { left: 146, top: 38, size: 32, rotate: 0, logo: "buffer" }, + { left: 225.5, top: 34.5, size: 29, rotate: 0, logo: "coderabbit" }, ]; const LOGO_SIZES = LOGO_SLOTS.map(({ size }) => size); @@ -61,49 +49,15 @@ function getDrift(index: number, depth: number) { }; } -const PROGRAM_LOGOS_API_URL = `${APP_DOMAIN}/api/misc/program-logos?slugs=${PROGRAM_MARKETPLACE_SLUGS.join(",")}`; - -function useProgramMarketplaceLogos() { - return useSWR(PROGRAM_LOGOS_API_URL, fetcher, { - dedupingInterval: 600000, - revalidateOnFocus: false, - keepPreviousData: true, - }); -} - -export function usePreloadProgramMarketplaceLogos() { - const { data: programs } = useProgramMarketplaceLogos(); - - useEffect(() => { - programs?.forEach(({ logo }) => { - if (logo) new Image().src = logo; - }); - }, [programs]); -} - function ProgramMarketplaceLogos() { const reducedMotion = useReducedMotion(); - const { data: programs } = useProgramMarketplaceLogos(); - - // build from the configured list so order (and any intentional duplicate - // slugs) is preserved even though the API dedupes and filters - const programsBySlug = new Map( - (programs ?? []).map((program) => [program.slug, program]), - ); - const logos = PROGRAM_MARKETPLACE_SLUGS.map((slug) => - programsBySlug.get(slug), - ).filter((program): program is MarketplaceProgram & { logo: string } => - Boolean(program?.logo), - ); - return (
- {LOGO_SLOTS.map(({ left, top, size, rotate }, index) => { - const program = logos.length ? logos[index % logos.length] : null; + {LOGO_SLOTS.map(({ left, top, size, rotate, logo }, index) => { const depth = getDepth(size); const { x, y, duration, delay } = getDrift(index, depth); @@ -121,24 +75,19 @@ function ProgramMarketplaceLogos() { }} >
- {program?.logo && ( - - )} + {`${logo}
@@ -152,7 +101,7 @@ export function ProgramMarketplaceSection({ domain }: { domain: string }) { return (

- Browse our available partner programs{" "} + Browse our available affiliate programs{" "} → diff --git a/packages/ui/src/nav/content/resources-content.tsx b/packages/ui/src/nav/content/resources-content.tsx index 296e683c51e..139260976f5 100644 --- a/packages/ui/src/nav/content/resources-content.tsx +++ b/packages/ui/src/nav/content/resources-content.tsx @@ -10,7 +10,7 @@ import { const COLUMNS = [ { heading: "Help and Support", - titles: ["Help Center", "Docs", "Contact"], + titles: ["Docs", "Help Center", "Contact"], }, { heading: "Company", diff --git a/packages/ui/src/nav/nav.tsx b/packages/ui/src/nav/nav.tsx index e57cbbe4024..7426ba7ddbb 100644 --- a/packages/ui/src/nav/nav.tsx +++ b/packages/ui/src/nav/nav.tsx @@ -26,7 +26,6 @@ import { useScroll } from "../hooks"; import { MaxWidthWrapper } from "../max-width-wrapper"; import { NavWordmark } from "../nav-wordmark"; import { ProductContent } from "./content/product-content"; -import { usePreloadProgramMarketplaceLogos } from "./content/program-marketplace"; import { ResourcesContent } from "./content/resources-content"; import { SolutionsContent } from "./content/solutions-content"; @@ -171,9 +170,6 @@ export function Nav({ }, ); - // warm the marketplace logos so the Resources dropdown opens without a stutter - usePreloadProgramMarketplaceLogos(); - const showHover = (item: HTMLElement) => { const list = navListRef.current; if (!list) return;