diff --git a/apps/web/app/(ee)/api/marketplace/programs/count/route.ts b/apps/web/app/(ee)/api/marketplace/programs/count/route.ts index 7268a80b818..68d4a1e3061 100644 --- a/apps/web/app/(ee)/api/marketplace/programs/count/route.ts +++ b/apps/web/app/(ee)/api/marketplace/programs/count/route.ts @@ -1,5 +1,6 @@ import { getPublicNetworkProgramsCount } from "@/lib/fetchers/get-public-network-programs"; import { parsePublicMarketplaceQuery } from "@/lib/marketplace/parse-public-marketplace-query"; +import { PUBLIC_MARKETPLACE_CACHE_HEADERS } from "@/lib/marketplace/public-marketplace-api"; import { NextResponse } from "next/server"; // GET /api/marketplace/programs/count - public marketplace program count @@ -14,5 +15,7 @@ export async function GET(req: Request) { search, }); - return NextResponse.json(count); + return NextResponse.json(count, { + headers: PUBLIC_MARKETPLACE_CACHE_HEADERS, + }); } 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 index e86698c1b13..7da4b6161a2 100644 --- a/apps/web/app/(ee)/api/marketplace/programs/filter-counts/route.ts +++ b/apps/web/app/(ee)/api/marketplace/programs/filter-counts/route.ts @@ -1,5 +1,6 @@ import { getPublicNetworkProgramFilterCounts } from "@/lib/fetchers/get-public-network-program-filter-counts"; import { parsePublicMarketplaceQuery } from "@/lib/marketplace/parse-public-marketplace-query"; +import { PUBLIC_MARKETPLACE_CACHE_HEADERS } from "@/lib/marketplace/public-marketplace-api"; import { NextResponse } from "next/server"; // GET /api/marketplace/programs/filter-counts - public marketplace filter counts @@ -12,5 +13,7 @@ export async function GET(req: Request) { rewardType, }); - return NextResponse.json(filterCounts); + return NextResponse.json(filterCounts, { + headers: PUBLIC_MARKETPLACE_CACHE_HEADERS, + }); } diff --git a/apps/web/app/(ee)/api/marketplace/programs/route.ts b/apps/web/app/(ee)/api/marketplace/programs/route.ts index 287712f2f0a..e99b0730ec9 100644 --- a/apps/web/app/(ee)/api/marketplace/programs/route.ts +++ b/apps/web/app/(ee)/api/marketplace/programs/route.ts @@ -1,5 +1,6 @@ import { getPublicNetworkPrograms } from "@/lib/fetchers/get-public-network-programs"; import { parsePublicMarketplaceQuery } from "@/lib/marketplace/parse-public-marketplace-query"; +import { PUBLIC_MARKETPLACE_CACHE_HEADERS } from "@/lib/marketplace/public-marketplace-api"; import { NextResponse } from "next/server"; // GET /api/marketplace/programs - public marketplace program list @@ -9,5 +10,7 @@ export async function GET(req: Request) { const programs = await getPublicNetworkPrograms(params); - return NextResponse.json(programs); + return NextResponse.json(programs, { + headers: PUBLIC_MARKETPLACE_CACHE_HEADERS, + }); } diff --git a/apps/web/app/app.dub.co/marketplace/[[...slug]]/page.tsx b/apps/web/app/app.dub.co/marketplace/[[...slug]]/page.tsx deleted file mode 100644 index f0bfafe2224..00000000000 --- a/apps/web/app/app.dub.co/marketplace/[[...slug]]/page.tsx +++ /dev/null @@ -1,86 +0,0 @@ -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/marketplace/[programSlug]/page.tsx b/apps/web/app/marketplace/[programSlug]/page.tsx new file mode 100644 index 00000000000..513c66d6ae4 --- /dev/null +++ b/apps/web/app/marketplace/[programSlug]/page.tsx @@ -0,0 +1,48 @@ +import { getNetworkProgram } from "@/lib/fetchers/get-network-program"; +import { MarketplaceExternalProgramPage } from "@/ui/program-marketplace/external/marketplace-external-program-page"; +import { generateMarketplaceProgramStaticParams } from "@/ui/program-marketplace/pages/marketplace-program-page"; +import { + getMarketplaceCanonicalUrl, + getMarketplaceProgramHref, +} from "@/ui/program-marketplace/utils/urls"; +import { constructMetadata } from "@dub/utils"; +import { Metadata } from "next"; + +export const revalidate = 3600; + +export async function generateStaticParams() { + const programParams = await generateMarketplaceProgramStaticParams(); + + return programParams.map(({ slug }) => ({ programSlug: slug[0] })); +} + +export async function generateMetadata(props: { + params: Promise<{ programSlug: string }>; +}): Promise { + const { programSlug } = await props.params; + + const program = await getNetworkProgram({ slug: programSlug }); + + return constructMetadata({ + title: program ? program.name : "Program Details", + description: + program?.description || + (program + ? `Join the ${program.name} affiliate program on Dub's Partner Network.` + : `Browse and apply to the best SaaS affiliate programs on Dub's Partner Network.`), + image: program + ? program.marketplaceHeaderImage || program.logo || undefined + : undefined, + canonicalUrl: getMarketplaceCanonicalUrl( + getMarketplaceProgramHref(programSlug), + ), + }); +} + +export default async function MarketplaceProgramDetailPage(props: { + params: Promise<{ programSlug: string }>; +}) { + const { programSlug } = await props.params; + + return ; +} diff --git a/apps/web/app/marketplace/all/page.tsx b/apps/web/app/marketplace/all/page.tsx new file mode 100644 index 00000000000..aa410793ef0 --- /dev/null +++ b/apps/web/app/marketplace/all/page.tsx @@ -0,0 +1,26 @@ +import { MarketplaceExternalListPage } from "@/ui/program-marketplace/external/marketplace-external-list-page"; +import { getMarketplaceCanonicalUrl } from "@/ui/program-marketplace/utils/urls"; +import { constructMetadata } from "@dub/utils"; +import { Metadata } from "next"; + +// Rendered dynamically so the actual (filtered/sorted/paged) view is produced +// server-side and hydrates in place — no fallback swap. +export const dynamic = "force-dynamic"; + +export function generateMetadata(): Metadata { + return constructMetadata({ + title: "All Affiliate Programs", + description: "Browse all affiliate programs on Dub.", + canonicalUrl: getMarketplaceCanonicalUrl("/marketplace/all"), + }); +} + +export default async function MarketplaceAllPage(props: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { + const searchParams = await props.searchParams; + + return ( + + ); +} diff --git a/apps/web/app/marketplace/c/[categorySlug]/page.tsx b/apps/web/app/marketplace/c/[categorySlug]/page.tsx new file mode 100644 index 00000000000..780f4a4f223 --- /dev/null +++ b/apps/web/app/marketplace/c/[categorySlug]/page.tsx @@ -0,0 +1,58 @@ +import { PROGRAM_CATEGORIES_MAP } from "@/lib/network/program-categories"; +import { MarketplaceExternalListPage } from "@/ui/program-marketplace/external/marketplace-external-list-page"; +import { + getMarketplaceCanonicalUrl, + slugToCategory, +} from "@/ui/program-marketplace/utils/urls"; +import { constructMetadata } from "@dub/utils"; +import { Metadata } from "next"; +import { notFound } from "next/navigation"; + +// Rendered dynamically so the actual (filtered/sorted/paged) view is produced +// server-side and hydrates in place — no fallback swap. +export const dynamic = "force-dynamic"; + +export async function generateMetadata(props: { + params: Promise<{ categorySlug: string }>; +}): Promise { + const year = new Date().getFullYear(); + const { categorySlug } = await props.params; + const category = slugToCategory(categorySlug); + + if (!category) { + return constructMetadata({ title: "Programs" }); + } + + const categoryMeta = PROGRAM_CATEGORIES_MAP[category]; + const label = categoryMeta?.label ?? category.replaceAll("_", " "); + + return constructMetadata({ + title: `Best ${label} Affiliate Programs in ${year}`, + description: + categoryMeta?.listPageDescription ?? + `Browse and apply to ${label.toLowerCase()} affiliate programs on Dub's Partner Network.`, + canonicalUrl: getMarketplaceCanonicalUrl(`/marketplace/c/${categorySlug}`), + }); +} + +export default async function MarketplaceCategoryPage(props: { + params: Promise<{ categorySlug: string }>; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { + const { categorySlug } = await props.params; + const searchParams = await props.searchParams; + + const category = slugToCategory(categorySlug); + + if (!category) { + notFound(); + } + + return ( + + ); +} diff --git a/apps/web/app/app.dub.co/marketplace/layout.tsx b/apps/web/app/marketplace/layout.tsx similarity index 80% rename from apps/web/app/app.dub.co/marketplace/layout.tsx rename to apps/web/app/marketplace/layout.tsx index 1a1f6c4f132..346c9a8d7f0 100644 --- a/apps/web/app/app.dub.co/marketplace/layout.tsx +++ b/apps/web/app/marketplace/layout.tsx @@ -1,4 +1,5 @@ import { MarketplaceExternalHeader } from "@/ui/program-marketplace/external/marketplace-external-header"; +import { Analytics as DubAnalytics } from "@dub/analytics/react"; import { Footer } from "@dub/ui"; import { PropsWithChildren } from "react"; @@ -7,6 +8,15 @@ export default function MarketplaceExternalLayout({ }: PropsWithChildren) { return (
+
diff --git a/apps/web/app/marketplace/page.tsx b/apps/web/app/marketplace/page.tsx new file mode 100644 index 00000000000..11e3a8a6b00 --- /dev/null +++ b/apps/web/app/marketplace/page.tsx @@ -0,0 +1,20 @@ +import { MarketplaceExternalHomePage } from "@/ui/program-marketplace/external/marketplace-external-home-page"; +import { getMarketplaceCanonicalUrl } from "@/ui/program-marketplace/utils/urls"; +import { constructMetadata } from "@dub/utils"; +import { Metadata } from "next"; + +export const revalidate = 3600; + +export function generateMetadata(): Metadata { + const year = new Date().getFullYear(); + + return constructMetadata({ + title: `Best SaaS Affiliate Programs in ${year}`, + description: `Browse and apply to the best SaaS affiliate programs on Dub's Partner Network.`, + canonicalUrl: getMarketplaceCanonicalUrl("/marketplace"), + }); +} + +export default function MarketplaceHomePage() { + return ; +} diff --git a/apps/web/app/sitemap.ts b/apps/web/app/sitemap.ts index b7af30dc281..a837281a44b 100644 --- a/apps/web/app/sitemap.ts +++ b/apps/web/app/sitemap.ts @@ -1,3 +1,4 @@ +import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups"; import { getMarketplaceAllHref, getMarketplaceCanonicalUrl, @@ -62,6 +63,14 @@ export default async function sitemap(): Promise { addedToMarketplaceAt: { not: null, }, + groups: { + some: { + slug: DEFAULT_PARTNER_GROUP.slug, + applicationFormPublishedAt: { + not: null, + }, + }, + }, }, select: { slug: true, diff --git a/apps/web/lib/fetchers/get-network-program.ts b/apps/web/lib/fetchers/get-network-program.ts index 1f4b7ea7797..23450db6017 100644 --- a/apps/web/lib/fetchers/get-network-program.ts +++ b/apps/web/lib/fetchers/get-network-program.ts @@ -12,6 +12,14 @@ export const getNetworkProgram = cache(async ({ slug }: { slug: string }) => { addedToMarketplaceAt: { not: null, }, + groups: { + some: { + slug: DEFAULT_PARTNER_GROUP.slug, + applicationFormPublishedAt: { + not: null, + }, + }, + }, }, include: { groups: { diff --git a/apps/web/lib/marketplace/parse-public-marketplace-query.ts b/apps/web/lib/marketplace/parse-public-marketplace-query.ts index c4bd5f37b75..705f7ef0871 100644 --- a/apps/web/lib/marketplace/parse-public-marketplace-query.ts +++ b/apps/web/lib/marketplace/parse-public-marketplace-query.ts @@ -7,21 +7,37 @@ function pickString(value: string | string[] | undefined) { return typeof value === "string" ? value : undefined; } -export function parsePublicMarketplaceQuery( - searchParams: Record = {}, +function toQueryInput( + searchParams: Record, fixedCategory?: Category, ) { - const input = { + return { 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 } : {}), + category: fixedCategory ?? pickString(searchParams.category), }; +} - const parsed = getPublicNetworkProgramsQuerySchema.safeParse(input); +export function isValidPublicMarketplaceQuery( + searchParams: Record = {}, + fixedCategory?: Category, +) { + return getPublicNetworkProgramsQuerySchema.safeParse( + toQueryInput(searchParams, fixedCategory), + ).success; +} + +export function parsePublicMarketplaceQuery( + searchParams: Record = {}, + fixedCategory?: Category, +) { + const parsed = getPublicNetworkProgramsQuerySchema.safeParse( + toQueryInput(searchParams, fixedCategory), + ); if (parsed.success) { return parsed.data; diff --git a/apps/web/lib/marketplace/public-marketplace-api.ts b/apps/web/lib/marketplace/public-marketplace-api.ts new file mode 100644 index 00000000000..4ec26970fa9 --- /dev/null +++ b/apps/web/lib/marketplace/public-marketplace-api.ts @@ -0,0 +1,7 @@ +// Cache the public marketplace endpoints at the edge so repeated queries (each +// filter combination keys on its URL) are absorbed by the CDN instead of hitting +// the DB. Short browser TTL, longer edge TTL — same pattern as `/api/links/metatags`. +export const PUBLIC_MARKETPLACE_CACHE_HEADERS = { + "Cache-Control": "public, max-age=300", + "Vercel-CDN-Cache-Control": "s-maxage=3600, stale-while-revalidate=86400", +}; diff --git a/apps/web/lib/middleware/app.ts b/apps/web/lib/middleware/app.ts index 5dc796960f7..260d761e415 100644 --- a/apps/web/lib/middleware/app.ts +++ b/apps/web/lib/middleware/app.ts @@ -27,8 +27,9 @@ export async function AppMiddleware(req: NextRequest) { ); } + // Keep app.dub.co/marketplace public and let it resolve the top-level marketplace route. if (path === "/marketplace" || path.startsWith("/marketplace/")) { - return NextResponse.rewrite(new URL(`/app.dub.co${fullPath}`, req.url)); + return NextResponse.next(); } const user = await getUserViaToken(req); diff --git a/apps/web/lib/zod/schemas/program-network.ts b/apps/web/lib/zod/schemas/program-network.ts index bf46c704d08..9ce2b021a95 100644 --- a/apps/web/lib/zod/schemas/program-network.ts +++ b/apps/web/lib/zod/schemas/program-network.ts @@ -2,7 +2,7 @@ import { Category, ProgramEnrollmentStatus } from "@dub/prisma/client"; import * as z from "zod/v4"; import { DiscountSchema } from "./discount"; import { GroupBountySummarySchema } from "./group-bounties"; -import { getPaginationQuerySchema } from "./misc"; +import { booleanQuerySchema, getPaginationQuerySchema } from "./misc"; import { programLanderSchema } from "./program-lander"; import { ProgramSchema } from "./programs"; @@ -32,11 +32,13 @@ export const NetworkProgramExtendedSchema = NetworkProgramSchema.extend({ export const PROGRAM_NETWORK_MAX_PAGE_SIZE = 100; +const queryBooleanSchema = z.union([z.boolean(), booleanQuerySchema]); + export const getPublicNetworkProgramsQuerySchema = z .object({ category: z.enum(Category).optional(), rewardType: z.enum(["sale", "lead", "click", "discount"]).optional(), - featured: z.coerce.boolean().optional(), + featured: queryBooleanSchema.optional(), search: z.string().optional(), sortBy: z.enum(["name", "recency", "popularity"]).default("popularity"), sortOrder: z.enum(["asc", "desc"]).default("desc"), @@ -53,7 +55,7 @@ export const getNetworkProgramsQuerySchema = z (v) => (v === "null" ? null : v), z.enum(ProgramEnrollmentStatus).nullish(), ), - featured: z.coerce.boolean().optional(), + featured: queryBooleanSchema.optional(), search: z.string().optional(), sortBy: z.enum(["name", "recency", "popularity"]).default("popularity"), sortOrder: z.enum(["asc", "desc"]).default("desc"), diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 6f31532cbdd..afbb82c83aa 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -34,7 +34,7 @@ export const config = { }; export default async function middleware(req: NextRequest, ev: NextFetchEvent) { - const { domain, path, key, fullKey, fullPath, searchParamsObj } = parse(req); + const { domain, path, key, fullKey, searchParamsObj } = parse(req); // Axiom logging logger.info(...transformMiddlewareRequest(req)); @@ -94,7 +94,7 @@ export default async function middleware(req: NextRequest, ev: NextFetchEvent) { ); } - return NextResponse.rewrite(new URL(`/app.dub.co${fullPath}`, req.url)); + return NextResponse.next(); } if (isValidUrl(fullKey)) { 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 index ca3c1e60b93..3b6f746c39d 100644 --- 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 @@ -29,6 +29,19 @@ type FilterCounts = { }[]; }; +export type MarketplaceExternalListInitialData = { + programs: NetworkProgramProps[]; + totalCount: number; + filterCounts: FilterCounts; + params: { + rewardType?: string; + search?: string; + sortBy: string; + sortOrder: string; + page: number; + }; +}; + function pickString(value: string | string[] | undefined) { return typeof value === "string" ? value : undefined; } @@ -36,9 +49,11 @@ function pickString(value: string | string[] | undefined) { export function MarketplaceExternalListPageClient({ basePath, fixedCategory, + initialData, }: { basePath: string; fixedCategory?: Category; + initialData: MarketplaceExternalListInitialData; }) { const router = useRouter(); const { getQueryString, searchParamsObj } = useRouterStuff(); @@ -69,17 +84,35 @@ export function MarketplaceExternalListPageClient({ pageSize: String(PAGE_SIZE), }) : null; - + const shouldUseInitialData = + parsed.success && + parsed.data.rewardType === initialData.params.rewardType && + parsed.data.search === initialData.params.search && + parsed.data.sortBy === initialData.params.sortBy && + parsed.data.sortOrder === initialData.params.sortOrder && + (parsed.data.page ?? 1) === initialData.params.page; + + // The server renders the initial URL state; SWR fetches after it changes. const { data: programs, isValidating } = useSWR( queryString ? `/api/marketplace/programs${queryString}` : null, fetcher, - { revalidateOnFocus: false, keepPreviousData: true }, + { + revalidateOnFocus: false, + revalidateOnMount: !shouldUseInitialData, + keepPreviousData: true, + fallbackData: shouldUseInitialData ? initialData.programs : undefined, + }, ); const { data: totalCount = 0 } = useSWR( queryString ? `/api/marketplace/programs/count${queryString}` : null, fetcher, - { revalidateOnFocus: false }, + { + revalidateOnFocus: false, + revalidateOnMount: !shouldUseInitialData, + keepPreviousData: true, + fallbackData: shouldUseInitialData ? initialData.totalCount : undefined, + }, ); const { data: filterCounts } = useSWR( @@ -87,7 +120,12 @@ export function MarketplaceExternalListPageClient({ ? `/api/marketplace/programs/filter-counts${queryString}` : null, fetcher, - { revalidateOnFocus: false }, + { + revalidateOnFocus: false, + revalidateOnMount: !shouldUseInitialData, + keepPreviousData: true, + fallbackData: shouldUseInitialData ? initialData.filterCounts : undefined, + }, ); if (!parsed.success) { @@ -96,31 +134,34 @@ export function MarketplaceExternalListPageClient({ const { rewardType, search, sortBy, sortOrder, page = 1 } = parsed.data; const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); + const resolvedFilterCounts = + filterCounts ?? + (shouldUseInitialData + ? initialData.filterCounts + : { categories: [], rewardTypes: [] }); return (
- {filterCounts ? ( -
- -
- ) : null} +
+ +
{!programs ? ( 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 5bf28bea74d..0f65339865e 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 @@ -1,38 +1,91 @@ +import { getPublicNetworkProgramFilterCounts } from "@/lib/fetchers/get-public-network-program-filter-counts"; +import { + getPublicNetworkPrograms, + getPublicNetworkProgramsCount, +} from "@/lib/fetchers/get-public-network-programs"; +import { + isValidPublicMarketplaceQuery, + parsePublicMarketplaceQuery, +} from "@/lib/marketplace/parse-public-marketplace-query"; import { PROGRAM_CATEGORIES_MAP } from "@/lib/network/program-categories"; import { Category } from "@dub/prisma/client"; +import { unstable_cache } from "next/cache"; +import { redirect } from "next/navigation"; import { getMarketplaceExternalBasePath } from "./marketplace-external-filters"; -import { MarketplaceExternalListPageClient } from "./marketplace-external-list-page-client"; +import { + MarketplaceExternalListPageClient, + type MarketplaceExternalListInitialData, +} from "./marketplace-external-list-page-client"; import { MarketplaceExternalShell } from "./marketplace-external-shell"; -export function MarketplaceExternalListPage({ +// Cache the list payload (keyed by parsed query) so this dynamic route doesn't +// hit the DB on every request. +const getMarketplaceListData = unstable_cache( + async (params: ReturnType) => { + const [programs, totalCount, filterCounts] = await Promise.all([ + getPublicNetworkPrograms(params), + getPublicNetworkProgramsCount(params), + getPublicNetworkProgramFilterCounts(params), + ]); + + return { programs, totalCount, filterCounts }; + }, + ["marketplace-external-list"], + { revalidate: 3600 }, +); + +export async function MarketplaceExternalListPage({ slug, fixedCategory, + searchParams, }: { slug?: string[]; fixedCategory?: Category; + searchParams?: { [key: string]: string | string[] | undefined }; }) { const basePath = getMarketplaceExternalBasePath({ slug }); + + if (!isValidPublicMarketplaceQuery(searchParams ?? {}, fixedCategory)) { + redirect(basePath); + } + const categoryMeta = fixedCategory ? PROGRAM_CATEGORIES_MAP[fixedCategory] : undefined; + const year = new Date().getFullYear(); + + const params = parsePublicMarketplaceQuery(searchParams ?? {}, fixedCategory); + + const { programs, totalCount, filterCounts } = + await getMarketplaceListData(params); + + const initialData: MarketplaceExternalListInitialData = { + programs, + totalCount, + filterCounts, + params: { + rewardType: params.rewardType, + search: params.search, + sortBy: params.sortBy, + sortOrder: params.sortOrder, + page: params.page ?? 1, + }, + }; return ( - {categoryMeta.label} partner -
- programs - - ) : undefined + categoryMeta + ? `Best ${categoryMeta.label} Affiliate Programs in ${year}` + : 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 index 2db92c4f827..11222f90b7f 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 @@ -5,12 +5,11 @@ import { MarketplaceProgramDetailsLayout } from "@/ui/program-marketplace/market 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 { notFound } from "next/navigation"; import { MarketplaceExternalShell } from "./marketplace-external-shell"; export async function MarketplaceExternalProgramPage({ @@ -23,7 +22,7 @@ export async function MarketplaceExternalProgramPage({ }); if (!program) { - redirect(getMarketplaceHref()); + notFound(); } return ( diff --git a/apps/web/ui/program-marketplace/external/marketplace-external-router.tsx b/apps/web/ui/program-marketplace/external/marketplace-external-router.tsx deleted file mode 100644 index 4346d7665fd..00000000000 --- a/apps/web/ui/program-marketplace/external/marketplace-external-router.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { notFound } from "next/navigation"; -import { slugToCategory } from "../utils/urls"; -import { MarketplaceExternalHomePage } from "./marketplace-external-home-page"; -import { MarketplaceExternalListPage } from "./marketplace-external-list-page"; -import { MarketplaceExternalProgramPage } from "./marketplace-external-program-page"; - -export async function MarketplaceExternalRouter({ slug }: { slug?: string[] }) { - const segments = slug ?? []; - - if (segments.length === 0) { - return ; - } - - if (segments.length === 1 && segments[0] === "all") { - return ; - } - - if (segments.length === 2 && segments[0] === "c") { - const category = slugToCategory(segments[1]); - - if (category) { - return ( - - ); - } - } - - if (segments.length === 1) { - return ; - } - - notFound(); -} 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 67cbe53e77c..3235f7c0fe1 100644 --- a/apps/web/ui/program-marketplace/external/marketplace-external-shell.tsx +++ b/apps/web/ui/program-marketplace/external/marketplace-external-shell.tsx @@ -28,9 +28,9 @@ export function MarketplaceExternalShell({ title ?? (variant === "home" ? ( <> - Best SaaS affiliate + Best SaaS Affiliate
- programs in {year} + Programs in {year} ) : ( "Find your next partnership" diff --git a/apps/web/ui/program-marketplace/marketplace-filter-control.tsx b/apps/web/ui/program-marketplace/marketplace-filter-control.tsx index 6fbfc7361b8..6d18eefb299 100644 --- a/apps/web/ui/program-marketplace/marketplace-filter-control.tsx +++ b/apps/web/ui/program-marketplace/marketplace-filter-control.tsx @@ -2,7 +2,6 @@ import { FilterBars } from "@dub/ui/icons"; import { cn } from "@dub/utils"; -import { motion } from "motion/react"; export function MarketplaceFilterControl({ activeFilterCount, @@ -18,10 +17,7 @@ export function MarketplaceFilterControl({ } return ( - Clear - +
); } diff --git a/apps/web/ui/program-marketplace/pages/marketplace-program-page.tsx b/apps/web/ui/program-marketplace/pages/marketplace-program-page.tsx index 10fd56804ca..68d84aca8ab 100644 --- a/apps/web/ui/program-marketplace/pages/marketplace-program-page.tsx +++ b/apps/web/ui/program-marketplace/pages/marketplace-program-page.tsx @@ -1,4 +1,5 @@ import { getNetworkProgram } from "@/lib/fetchers/get-network-program"; +import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups"; import { ApplicationAnalytics } from "@/ui/application-analytics"; import { PageContent } from "@/ui/layout/page-content"; import { PageWidthWrapper } from "@/ui/layout/page-width-wrapper"; @@ -21,6 +22,14 @@ export async function generateMarketplaceProgramStaticParams() { addedToMarketplaceAt: { not: null, }, + groups: { + some: { + slug: DEFAULT_PARTNER_GROUP.slug, + applicationFormPublishedAt: { + not: null, + }, + }, + }, }, select: { slug: true, diff --git a/apps/web/ui/program-marketplace/program-card.tsx b/apps/web/ui/program-marketplace/program-card.tsx index ab8870b8381..42ff6c17263 100644 --- a/apps/web/ui/program-marketplace/program-card.tsx +++ b/apps/web/ui/program-marketplace/program-card.tsx @@ -1,19 +1,19 @@ -"use client"; - +import { PROGRAM_CATEGORIES_MAP } from "@/lib/network/program-categories"; import { NetworkProgramProps } from "@/lib/types"; +import { formatDiscountDescription } from "@/ui/partners/format-discount-description"; +import { formatRewardDescription } from "@/ui/partners/format-reward-description"; +import { REWARD_EVENT_ICON } from "@/ui/partners/rewards/reward-event-icon"; import { ProgramCategory } from "@/ui/program-marketplace/program-category"; -import { ProgramRewardsDisplay } from "@/ui/program-marketplace/program-rewards-display"; -import { - getMarketplaceAllHref, - getMarketplaceCategoryHref, - getMarketplaceProgramHref, -} from "@/ui/program-marketplace/utils/urls"; -import { Tooltip } from "@dub/ui"; +import { getMarketplaceProgramHref } from "@/ui/program-marketplace/utils/urls"; +import { Gift, type Icon } from "@dub/ui"; import { OG_AVATAR_URL, cn } from "@dub/utils"; import Link from "next/link"; -import { useRouter } from "next/navigation"; import { ProgramStatusBadge } from "./program-status-badge"; +// Non-interactive, server-renderable card. The whole card is a single ; +// the category/reward/`+N` are plain, non-interactive labels. Do NOT add nested +// links/buttons/Radix triggers inside the card — interactive content inside an +// is invalid HTML and breaks server rendering (the browser reparents it). export function MarketplaceProgramCard({ program, showStatus = true, @@ -23,7 +23,9 @@ export function MarketplaceProgramCard({ showStatus?: boolean; className?: string; }) { - const router = useRouter(); + const hiddenCategoryLabels = program.categories + .slice(1) + .map(getProgramCategoryLabel); return ( Rewards - - router.push( - getMarketplaceAllHref({ rewardType: reward.event }), - ) - } - onDiscountClick={() => - router.push(getMarketplaceAllHref({ rewardType: "discount" })) - } - className="mt-2" - /> +
)} {Boolean(program.categories.length) && ( @@ -80,37 +70,17 @@ export function MarketplaceProgramCard({ Category
- {program.categories - .slice(0, 1) - ?.map((category) => ( - - router.push(getMarketplaceCategoryHref(category)) - } - /> - ))} + {program.categories.length > 1 && ( - - {program.categories.slice(1).map((category) => ( - - router.push(getMarketplaceCategoryHref(category)) - } - /> - ))} -
- } +
-
- +{program.categories.length - 1} -
- + +{program.categories.length - 1} +
)}
@@ -121,6 +91,80 @@ export function MarketplaceProgramCard({ ); } +function getProgramCategoryLabel( + category: NetworkProgramProps["categories"][number], +) { + return ( + PROGRAM_CATEGORIES_MAP[category]?.label ?? category.replaceAll("_", " ") + ); +} + +// Mirrors ProgramRewardsDisplay's resting appearance (reward icon + truncated +// description) but without the Radix HoverCard trigger, so it's a valid, +// non-interactive descendant of the card's . +function MarketplaceProgramCardRewards({ + program, +}: { + program: NetworkProgramProps; +}) { + const items: { id: string; icon: Icon; description: string }[] = []; + + program.rewards?.forEach((reward) => { + items.push({ + id: reward.id, + icon: REWARD_EVENT_ICON[reward.event], + description: formatRewardDescription(reward, { + includeEarnPrefix: false, + }), + }); + }); + + if (program.discount) { + items.push({ + id: "discount", + icon: Gift, + description: formatDiscountDescription(program.discount), + }); + } + + if (items.length === 0) { + return null; + } + + if (items.length === 1) { + const item = items[0]; + const RewardIcon = item.icon; + + return ( +
+
+ +
+ + {item.description} + +
+ ); + } + + return ( +
+ {items.map((item) => { + const RewardIcon = item.icon; + + return ( +
+ +
+ ); + })} +
+ ); +} + export function MarketplaceProgramCardSkeleton({ className, }: {