diff --git a/apps/web/app/(ee)/api/marketplace/programs/counts/route.ts b/apps/web/app/(ee)/api/marketplace/programs/counts/route.ts new file mode 100644 index 00000000000..91c59fc0597 --- /dev/null +++ b/apps/web/app/(ee)/api/marketplace/programs/counts/route.ts @@ -0,0 +1,21 @@ +import { getNetworkProgramCounts } from "@/lib/fetchers/get-network-program-counts"; +import { parsePublicMarketplaceQuery } from "@/lib/marketplace/parse-public-marketplace-query"; +import { NextResponse } from "next/server"; + +// cache filtered/searched count responses at the edge (public, not partner-scoped) +export const revalidate = 3600; + +// GET /api/marketplace/programs/counts - public marketplace total + filter counts +export async function GET(req: Request) { + const searchParams = Object.fromEntries(new URL(req.url).searchParams); + const { category, rewardType, search } = + parsePublicMarketplaceQuery(searchParams); + + const counts = await getNetworkProgramCounts({ + category, + rewardType, + search, + }); + + return NextResponse.json(counts); +} 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..fb5361b9270 --- /dev/null +++ b/apps/web/app/(ee)/api/marketplace/programs/route.ts @@ -0,0 +1,16 @@ +import { getPublicNetworkPrograms } from "@/lib/fetchers/get-public-network-programs"; +import { parsePublicMarketplaceQuery } from "@/lib/marketplace/parse-public-marketplace-query"; +import { NextResponse } from "next/server"; + +// cache filtered/searched list responses at the edge (public, not partner-scoped) +export const revalidate = 3600; + +// 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/(ee)/api/network/programs/count/route.ts b/apps/web/app/(ee)/api/network/programs/count/route.ts index 8c55c42c9e6..1a98bd5a646 100644 --- a/apps/web/app/(ee)/api/network/programs/count/route.ts +++ b/apps/web/app/(ee)/api/network/programs/count/route.ts @@ -1,62 +1,23 @@ import { withPartnerProfile } from "@/lib/auth/partner"; +import { buildNetworkProgramCountWhereSql } from "@/lib/fetchers/get-network-program-counts"; import { prisma } from "@/lib/prisma"; import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups"; import { getNetworkProgramsCountQuerySchema } from "@/lib/zod/schemas/program-network"; -import { Prisma } from "@prisma/client"; import { NextResponse } from "next/server"; -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`, -}; - // GET /api/network/programs/count - get the number of available programs in the network export const GET = withPartnerProfile(async ({ partner, searchParams }) => { const { groupBy, category, rewardType, status, featured, search } = getNetworkProgramsCountQuerySchema.parse(searchParams); - const searchSql = search ? Prisma.sql`CONCAT('%', ${search}, '%')` : null; - const commonWhereSql = 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 && groupBy !== "rewardType" - ? Prisma.sql` - AND ${rewardTypeMap[rewardType]}` - : Prisma.sql`` - } - ) - ${ - category && groupBy !== "category" - ? Prisma.sql` - AND EXISTS ( - SELECT 1 FROM ProgramCategory pc - WHERE pc.programId = p.id AND pc.category = ${category} - )` - : Prisma.sql`` - } - ${ - status !== undefined && groupBy !== "status" - ? Prisma.sql` - AND ${status === null ? Prisma.sql`NOT` : Prisma.sql``} EXISTS ( - SELECT 1 FROM ProgramEnrollment pe - WHERE - pe.programId = p.id - AND pe.partnerId = ${partner.id} - ${status === null ? Prisma.sql`` : Prisma.sql`AND pe.status = ${status}`} - )` - : Prisma.sql`` - } - ${featured !== undefined ? Prisma.sql`AND p.featuredOnMarketplaceAt IS ${featured ? Prisma.sql`NOT` : Prisma.sql``} NULL` : Prisma.sql``} - ${searchSql ? Prisma.sql`AND (p.name LIKE ${searchSql} OR p.slug LIKE ${searchSql} OR p.domain LIKE ${searchSql})` : Prisma.sql``} - `; + const commonWhereSql = buildNetworkProgramCountWhereSql({ + category: groupBy === "category" ? undefined : category, + rewardType: groupBy === "rewardType" ? undefined : rewardType, + status: groupBy === "status" ? undefined : status, + partnerId: partner.id, + featured, + search, + }); if (groupBy === "category") { const categories = (await prisma.$queryRaw` @@ -97,7 +58,7 @@ export const GET = withPartnerProfile(async ({ partner, searchParams }) => { SELECT pe.status, COUNT(p.id) AS _count FROM Program p LEFT JOIN ProgramEnrollment pe ON p.id = pe.programId AND pe.partnerId = ${partner.id} - WHERE p.addedToMarketplaceAt IS NOT NULL AND ${commonWhereSql} + WHERE ${commonWhereSql} GROUP BY pe.status ORDER BY _count DESC `) as { status: string | null; _count: bigint }[]; diff --git a/apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/apply/page.tsx b/apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/apply/page.tsx index 47657b02801..8e337257765 100644 --- a/apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/apply/page.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/apply/page.tsx @@ -29,8 +29,14 @@ export default async function ApplicationPage(props: { !program.group.applicationFormData || !program.group.applicationFormPublishedAt ) { - // throw 404 if it's the default group, else redirect to the default group page + // for the default group: + // - if the program is found, redirect to the marketplace program page + // - otherwise, throw 404 + // for other groups: redirect to the default group variant if (partnerGroupSlug === DEFAULT_PARTNER_GROUP.slug) { + if (program) { + redirect(`/marketplace/${programSlug}`); + } notFound(); } else { redirect(`/${programSlug}/apply`); diff --git a/apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/apply/success/page.tsx b/apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/apply/success/page.tsx index dfd8a24f0f1..6b1405d1786 100644 --- a/apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/apply/success/page.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/apply/success/page.tsx @@ -65,8 +65,14 @@ export default async function SuccessPage(props: { !program.group.applicationFormData || !program.group.applicationFormPublishedAt ) { - // throw 404 if it's the default group, else redirect to the default group page + // for the default group: + // - if the program is found, redirect to the marketplace program page + // - otherwise, throw 404 + // for other groups: redirect to the default group variant if (partnerGroupSlug === DEFAULT_PARTNER_GROUP.slug) { + if (program) { + redirect(`/marketplace/${programSlug}`); + } notFound(); } else { redirect(`/${programSlug}/apply`); diff --git a/apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/page.tsx b/apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/page.tsx index 0f0ef770540..bb084d4277e 100644 --- a/apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/page.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/page.tsx @@ -28,8 +28,14 @@ export default async function ApplyPage(props: { !program.group.landerData || !program.group.landerPublishedAt ) { - // throw 404 if it's the default group, else redirect to the default group page + // for the default group: + // - if the program is found, redirect to the marketplace program page + // - otherwise, throw 404 + // for other groups: redirect to the default group variant if (partnerGroupSlug === DEFAULT_PARTNER_GROUP.slug) { + if (program) { + redirect(`/marketplace/${programSlug}`); + } notFound(); } else { redirect(`/${programSlug}`); diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/marketplace/[[...segments]]/page.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/marketplace/[[...segments]]/page.tsx index fbdbd564eb1..7d43179f193 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/marketplace/[[...segments]]/page.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/marketplace/[[...segments]]/page.tsx @@ -1,14 +1,9 @@ import { MarketplaceRouter } from "@/ui/program-marketplace/marketplace-router"; -import { - generateMarketplaceProgramStaticParams, - revalidate, -} from "@/ui/program-marketplace/pages/marketplace-program-page"; - -export { revalidate }; -export async function generateStaticParams() { - return generateMarketplaceProgramStaticParams(); -} +export { + generateStaticParams, + revalidate, +} from "@/ui/program-marketplace/utils/default-exports"; export default async function MarketplacePage(props: { params: Promise<{ segments?: string[] }>; diff --git a/apps/web/app/app.dub.co/(dashboard)/layout.tsx b/apps/web/app/app.dub.co/(dashboard)/layout.tsx index 588a3e39d3f..a9047292847 100644 --- a/apps/web/app/app.dub.co/(dashboard)/layout.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/layout.tsx @@ -5,6 +5,7 @@ import { NewsRSC } from "@/ui/layout/sidebar/news-rsc"; import { ReferButton } from "@/ui/layout/sidebar/refer-button"; import Toolbar from "@/ui/layout/toolbar/toolbar"; import { UpgradeBanner } from "@/ui/layout/upgrade-banner"; +import { ModalProvider } from "@/ui/modals/modal-provider"; import { constructMetadata } from "@dub/utils"; import { ReactNode } from "react"; @@ -13,7 +14,7 @@ export const metadata = constructMetadata(); export default async function Layout({ children }: { children: ReactNode }) { return ( - <> +
- +
); } diff --git a/apps/web/app/app.dub.co/layout.tsx b/apps/web/app/app.dub.co/layout.tsx index c9900f4fa06..80208e877f1 100644 --- a/apps/web/app/app.dub.co/layout.tsx +++ b/apps/web/app/app.dub.co/layout.tsx @@ -1,9 +1,8 @@ "use client"; -import { ModalProvider } from "@/ui/modals/modal-provider"; import { Analytics as DubAnalytics } from "@dub/analytics/react"; import { SessionProvider } from "next-auth/react"; -import { ReactNode } from "react"; +import { ReactNode, Suspense } from "react"; export default function AppLayout({ children }: { children: ReactNode }) { return ( @@ -17,7 +16,7 @@ export default function AppLayout({ children }: { children: ReactNode }) { refer: "refer.dub.co", }} /> - {children} + {children} ); } diff --git a/apps/web/app/app.dub.co/marketplace/[[...segments]]/page.tsx b/apps/web/app/app.dub.co/marketplace/[[...segments]]/page.tsx new file mode 100644 index 00000000000..833bc637b5a --- /dev/null +++ b/apps/web/app/app.dub.co/marketplace/[[...segments]]/page.tsx @@ -0,0 +1,73 @@ +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 { Category } from "@prisma/client"; +import { Metadata } from "next"; + +export { + generateStaticParams, + revalidate, +} from "@/ui/program-marketplace/utils/default-exports"; + +export async function generateMetadata(props: { + params: Promise<{ segments?: string[] }>; +}): Promise { + 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 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) => value.toLowerCase() === 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: `https://dub.co${pathname.startsWith("/") ? pathname : `/${pathname}`}`, + }); +} + +export default async function MarketplaceExternalPage(props: { + params: Promise<{ segments?: string[] }>; +}) { + const { segments = [] } = 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-network-program-counts.ts b/apps/web/lib/fetchers/get-network-program-counts.ts new file mode 100644 index 00000000000..9a4cdd0712e --- /dev/null +++ b/apps/web/lib/fetchers/get-network-program-counts.ts @@ -0,0 +1,176 @@ +import { prisma } from "@/lib/prisma"; +import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups"; +import { getPublicNetworkProgramsQuerySchema } from "@/lib/zod/schemas/program-network"; +import { Category, Prisma, ProgramEnrollmentStatus } from "@prisma/client"; +import { cache } from "react"; +import * as z from "zod/v4"; + +type RewardType = NonNullable< + z.infer["rewardType"] +>; + +const rewardTypeMap: Record = { + 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`, +}; + +type NetworkProgramWhereParams = { + category?: Category; + rewardType?: RewardType; + search?: string; + // partner-scoped (network) filters only + status?: ProgramEnrollmentStatus | null; + partnerId?: string; + featured?: boolean; +}; + +export function buildNetworkProgramCountWhereSql({ + category, + rewardType, + search, + status, + partnerId, + featured, +}: NetworkProgramWhereParams) { + const searchSql = search ? Prisma.sql`CONCAT('%', ${search}, '%')` : null; + + 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`` + } + ${ + status !== undefined && partnerId + ? Prisma.sql` + AND ${status === null ? Prisma.sql`NOT` : Prisma.sql``} EXISTS ( + SELECT 1 FROM ProgramEnrollment pe + WHERE + pe.programId = p.id + AND pe.partnerId = ${partnerId} + ${status === null ? Prisma.sql`` : Prisma.sql`AND pe.status = ${status}`} + )` + : Prisma.sql`` + } + ${ + featured !== undefined + ? Prisma.sql`AND p.featuredOnMarketplaceAt IS ${featured ? Prisma.sql`NOT` : Prisma.sql``} NULL` + : Prisma.sql`` + } + ${ + searchSql + ? Prisma.sql`AND (p.name LIKE ${searchSql} OR p.slug LIKE ${searchSql} OR p.domain LIKE ${searchSql} OR p.url LIKE ${searchSql} OR p.description LIKE ${searchSql})` + : Prisma.sql`` + } + `; +} + +export async function getNetworkProgramTotalCount( + params: NetworkProgramWhereParams = {}, +) { + const whereSql = buildNetworkProgramCountWhereSql(params); + + const result = (await prisma.$queryRaw` + SELECT COUNT(*) AS count FROM Program p + WHERE ${whereSql} + `) as { count: bigint }[]; + + return Number(result[0].count); +} + +export async function getNetworkProgramCategoryCounts( + params: NetworkProgramWhereParams = {}, +) { + // category counts ignore the active category filter + const whereSql = buildNetworkProgramCountWhereSql({ + ...params, + category: undefined, + }); + + 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 ${whereSql} + GROUP BY pc.category + ORDER BY _count DESC + `) as { category: Category; _count: bigint }[]; + + return categories.map(({ category, _count }) => ({ + category, + count: Number(_count), + })); +} + +export async function getNetworkProgramRewardTypeCounts( + params: NetworkProgramWhereParams = {}, +) { + // reward type counts ignore the active reward type filter + const whereSql = buildNetworkProgramCountWhereSql({ + ...params, + rewardType: undefined, + }); + + 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 ${whereSql} + `) as { click: bigint; lead: bigint; sale: bigint; discount: bigint }[]; + + return (["sale", "lead", "click", "discount"] as const).map((type) => ({ + type, + count: Number(rewards[0][type]), + })); +} + +/** + * Public marketplace counts: total program count + per-category and + * per-reward-type filter counts in a single call. Wrapped in React `cache()` + * so it dedupes when invoked multiple times within one server render. + */ +export const getNetworkProgramCounts = cache( + async ({ + category, + rewardType, + search, + }: Pick< + NetworkProgramWhereParams, + "category" | "rewardType" | "search" + > = {}) => { + const [total, categories, rewardTypes] = await Promise.all([ + getNetworkProgramTotalCount({ category, rewardType, search }), + getNetworkProgramCategoryCounts({ rewardType, search }), + getNetworkProgramRewardTypeCounts({ category, search }), + ]); + + return { + total, + categories, + rewardTypes, + }; + }, +); + +export type NetworkProgramCounts = Awaited< + ReturnType +>; 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..1f060a52281 --- /dev/null +++ b/apps/web/lib/fetchers/get-public-network-programs.ts @@ -0,0 +1,114 @@ +import { prisma } from "@/lib/prisma"; +import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups"; +import { + getPublicNetworkProgramsQuerySchema, + NetworkProgramSchema, + PROGRAM_NETWORK_MAX_PAGE_SIZE, +} from "@/lib/zod/schemas/program-network"; +import { cache } from "react"; +import * as z from "zod/v4"; + +export const getPublicNetworkPrograms = cache( + async ( + params: Partial> = {}, + ) => { + const { + category, + rewardType, + featured, + search, + sortBy = "popularity", + sortOrder = "desc", + page = 1, + pageSize = PROGRAM_NETWORK_MAX_PAGE_SIZE, + } = 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, + }, + }, + }), + ...(rewardType && { + groups: { + some: { + slug: DEFAULT_PARTNER_GROUP.slug, + ...(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), + })), + ); + }, +); 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..334993e4f2e --- /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 "@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 = { + category: fixedCategory ?? pickString(searchParams.category), + 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, + }; + + 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..41de66fc3dc 100644 --- a/apps/web/lib/middleware/app.ts +++ b/apps/web/lib/middleware/app.ts @@ -30,6 +30,7 @@ export async function AppMiddleware(req: NextRequest) { path !== "/register" && path !== "/auth/saml" && !path.startsWith("/auth/reset-password/") && + !path.startsWith("/marketplace") && !path.startsWith("/share/") && !path.startsWith("/deeplink/") && !path.startsWith("/unsubscribe/") 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/lib/swr/use-workspace.ts b/apps/web/lib/swr/use-workspace.ts index bdc652b8227..c404e59e883 100644 --- a/apps/web/lib/swr/use-workspace.ts +++ b/apps/web/lib/swr/use-workspace.ts @@ -10,8 +10,13 @@ export default function useWorkspace({ }: { swrOpts?: SWRConfiguration; } = {}) { - let { slug } = useParams() as { slug: string | null }; + let { slug } = useParams() as { slug: string | string[] | null }; const searchParams = useSearchParams(); + // catch-all routes (e.g. /marketplace/[[...slug]]) surface `slug` as an array, + // which is never a workspace slug - ignore it so we don't fetch /api/workspaces/ + if (Array.isArray(slug)) { + slug = null; + } if (!slug) { slug = searchParams.get("slug") || searchParams.get("workspace"); } 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 new file mode 100644 index 00000000000..e89c0941be7 --- /dev/null +++ b/apps/web/ui/program-marketplace/external/marketplace-external-apply-button.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { Button } from "@dub/ui"; +import { PARTNERS_DOMAIN } from "@dub/utils"; +import { useSession } from "next-auth/react"; + +export function MarketplaceExternalApplyButton({ + programSlug, +}: { + programSlug: string; +}) { + const { data: session } = useSession(); + return ( + +