diff --git a/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx b/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx index ef51ec9b253..c29c7c299e6 100644 --- a/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx +++ b/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx @@ -221,7 +221,7 @@ function CommissionsPageClient() { } > { }, orderBy: sortBy === "popularity" - ? {} + ? { marketplaceRanking: "asc" } : { [sortBy === "recency" ? "addedToMarketplaceAt" : sortBy]: sortOrder, }, diff --git a/apps/web/app/(ee)/api/track/application/route.ts b/apps/web/app/(ee)/api/track/application/route.ts index a1191a4fd36..5e83026d011 100644 --- a/apps/web/app/(ee)/api/track/application/route.ts +++ b/apps/web/app/(ee)/api/track/application/route.ts @@ -18,6 +18,7 @@ import { recordClickZodSchema, } from "@/lib/tinybird/record-click-zod"; import { ratelimit } from "@/lib/upstash"; +import { MARKETPLACE_RESERVED_SLUGS } from "@/ui/program-marketplace/utils/urls"; import { prisma } from "@dub/prisma"; import { Partner, Program } from "@dub/prisma/client"; import { @@ -419,8 +420,16 @@ async function getRequestContext( // Supports: // - https://partners.dub.co/{programSlug} // - https://partners.dub.co/programs/{programSlug}/apply -// - https://partners.dub.co/programs/marketplace/{programSlug} +// - https://partners.dub.co/marketplace/{programSlug} +// - https://dub.co/marketplace/{programSlug} +// - https://partners.dub.co/programs/marketplace/{programSlug} (legacy) // - https://partners.dub.co/register (platform-level signup -> network program) +// +// Marketplace list routes (no program slug): +// - /marketplace +// - /marketplace/all +// - /marketplace/popular +// - /marketplace/c/{category} function identityProgramSlug(url: string) { try { const urlObj = new URL(url); @@ -430,23 +439,60 @@ function identityProgramSlug(url: string) { return { programSlug: null, isMarketplace: false }; } - // Platform-level /register page is associated with the network program if (parts[0] === "register") { return { programSlug: NETWORK_PROGRAM_SLUG, isMarketplace: false }; } - const isMarketplace = parts[0] === "programs" && parts[1] === "marketplace"; - const programSlug = isMarketplace // e.g. https://partners.dub.co/programs/marketplace/acme - ? parts[2] - : parts[0] === "programs" // e.g. https://partners.dub.co/programs/acme/apply - ? parts[1] - : parts[0]; // e.g. https://partners.dub.co/acme, or https://partners.dub.co/acme/apply, or https://partners.dub.co/acme/group/apply + if (parts[0] === "marketplace") { + if (parts.length === 1) { + return { programSlug: null, isMarketplace: false }; + } + + if (parts[1] === "c") { + return { programSlug: null, isMarketplace: false }; + } + + if (parts.length === 2 && MARKETPLACE_RESERVED_SLUGS.has(parts[1])) { + return { programSlug: null, isMarketplace: false }; + } + + if (parts.length === 2) { + return { + programSlug: parts[1].toLowerCase(), + isMarketplace: true, + }; + } + + return { programSlug: null, isMarketplace: false }; + } + + if (parts[0] === "programs" && parts[1] === "marketplace") { + if (!parts[2] || MARKETPLACE_RESERVED_SLUGS.has(parts[2])) { + return { programSlug: null, isMarketplace: false }; + } + + return { + programSlug: parts[2].toLowerCase(), + isMarketplace: true, + }; + } + + if (parts[0] === "programs") { + if (!parts[1]) { + return { programSlug: null, isMarketplace: false }; + } + + return { + programSlug: parts[1].toLowerCase(), + isMarketplace: false, + }; + } return { - programSlug: programSlug.toLowerCase(), - isMarketplace, + programSlug: parts[0].toLowerCase(), + isMarketplace: false, }; - } catch (error) { + } catch { return { programSlug: null, isMarketplace: false }; } } diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/[programSlug]/loading.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/marketplace/[[...slug]]/loading.tsx similarity index 100% rename from apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/[programSlug]/loading.tsx rename to apps/web/app/(ee)/partners.dub.co/(dashboard)/marketplace/[[...slug]]/loading.tsx diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/marketplace/[[...slug]]/page.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/marketplace/[[...slug]]/page.tsx new file mode 100644 index 00000000000..db71c4ea01e --- /dev/null +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/marketplace/[[...slug]]/page.tsx @@ -0,0 +1,19 @@ +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 default async function MarketplacePage(props: { + params: Promise<{ slug?: string[] }>; +}) { + const { slug } = await props.params; + + return ; +} diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/layout.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/marketplace/layout.tsx similarity index 100% rename from apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/layout.tsx rename to apps/web/app/(ee)/partners.dub.co/(dashboard)/marketplace/layout.tsx diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/[programSlug]/page.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/[programSlug]/page.tsx deleted file mode 100644 index 00f65e0bbbc..00000000000 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/[programSlug]/page.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import { getNetworkProgram } from "@/lib/fetchers/get-network-program"; -import { ApplicationAnalytics } from "@/ui/application-analytics"; -import { PageContent } from "@/ui/layout/page-content"; -import { PageWidthWrapper } from "@/ui/layout/page-width-wrapper"; -import { BLOCK_COMPONENTS } from "@/ui/partners/lander/blocks"; -import { LanderHero } from "@/ui/partners/lander/lander-hero"; -import { LanderRewards } from "@/ui/partners/lander/lander-rewards"; -import { ProgramEligibilityCard } from "@/ui/partners/program-eligibility-card"; -import { ProgramCategory } from "@/ui/partners/program-marketplace/program-category"; -import { ProgramRewardsDisplay } from "@/ui/partners/program-marketplace/program-rewards-display"; -import { prisma } from "@dub/prisma"; -import { ChevronRight, Shop, Tooltip } from "@dub/ui"; -import { Globe } from "@dub/ui/icons"; -import { OG_AVATAR_URL, cn, getDomainWithoutWWW } from "@dub/utils"; -import Link from "next/link"; -import { redirect } from "next/navigation"; -import { ProgramStatusBadge } from "../program-status-badge"; -import { MarketplaceProgramHeaderControls } from "./header-controls"; - -export const revalidate = 3600; // 1 hour - -export async function generateStaticParams() { - const programs = await prisma.program.findMany({ - where: { - addedToMarketplaceAt: { - not: null, - }, - }, - select: { - slug: true, - }, - }); - - return programs.map((program) => ({ - programSlug: program.slug, - })); -} - -export default async function MarketplaceProgramPage(props: { - params: Promise<{ programSlug: string }>; -}) { - const params = await props.params; - const { programSlug } = params; - - const program = await getNetworkProgram({ - slug: programSlug, - }); - - if (!program) { - redirect("/programs/marketplace"); - } - - const isDarkImage = program.marketplaceHeaderImage?.includes("dark"); - - return ( - -
- - - - -
- -
- - Program details - - -
- - } - controls={} - > - - -
- {program.featuredOnMarketplaceAt && - program.marketplaceHeaderImage && ( - <> - {program.name} - {!isDarkImage && ( -
- )} - - )} -
- {program.name} - -
- - {program.name} - - -
- {program.description || - `${program.name} is a program in the Dub Partner Network. Join the network to start partnering with them.`} -
-
- -
- {Boolean(program.rewards?.length || program.discount) && ( -
- - Rewards - - -
- )} - {Boolean(program.categories?.length) && ( -
- - Category - -
- {program.categories - .slice(0, 1) - ?.map((category) => ( - - ))} - {program.categories.length > 1 && ( - - {program.categories.slice(1).map((category) => ( - - ))} -
- } - > -
- +{program.categories.length - 1} -
- - )} -
-
- )} - {program.url && ( -
- - Website - - - - - {getDomainWithoutWWW(program.url)} ↗ - - -
- )} -
-
-
- -
- - - - - {program.applicationRequirements?.length ? ( - - ) : null} - - {program.landerData && ( -
- {program.landerData.blocks.map((block, idx) => { - const Component = BLOCK_COMPONENTS[block.type]; - return Component ? ( - - ) : null; - })} -
- )} -
-
-
- ); -} diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/featured-program-card.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/featured-program-card.tsx deleted file mode 100644 index a3e514a14e5..00000000000 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/featured-program-card.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import { NetworkProgramProps } from "@/lib/types"; -import { ProgramCategory } from "@/ui/partners/program-marketplace/program-category"; -import { ProgramRewardsDisplay } from "@/ui/partners/program-marketplace/program-rewards-display"; -import { Tooltip, useRouterStuff } from "@dub/ui"; -import { OG_AVATAR_URL, cn } from "@dub/utils"; -import Link from "next/link"; -import { ProgramStatusBadge } from "./program-status-badge"; - -export function FeaturedProgramCard({ - program, -}: { - program: NetworkProgramProps; -}) { - const { queryParams } = useRouterStuff(); - - const isDarkImage = program.marketplaceHeaderImage?.includes("-dark"); - - return ( - - {program.marketplaceHeaderImage && ( - <> - {program.name} -
- - )} - -
-
- {program.name} - - -
- -
- - {program.name} - - -
- {program.description || - `${program.name} is a program in the Dub Partner Network. Join the network to start partnering with them.`} -
- -
- {Boolean(program.rewards?.length) && ( -
- - Rewards - - - queryParams({ - set: { - rewardType: reward.event, - }, - del: "page", - }) - } - className="hover:bg-bg-default/10 active:bg-bg-default/20 mt-2" - iconClassName="hover:bg-bg-default/10 active:bg-bg-default/20" - descriptionClassName="max-w-[240px]" - /> -
- )} - {Boolean(program.categories.length) && ( -
- - Category - -
- {program.categories.slice(0, 1)?.map((category) => ( - - queryParams({ - set: { - category, - }, - del: "page", - }) - } - className={cn( - "hover:bg-bg-default/10 active:bg-bg-default/20", - isDarkImage && "text-content-inverted", - )} - /> - ))} - {program.categories.length > 1 && ( - - {program.categories.slice(1).map((category) => ( - - queryParams({ - set: { - category, - }, - del: "page", - }) - } - /> - ))} -
- } - > -
- +{program.categories.length - 1} -
- - )} -
-
- )} -
-
-
- - ); -} - -export function FeaturedProgramCardSkeleton() { - return ( -
-
-
-
-
- -
- {/* Name - text-3xl font-semibold is typically ~36px height */} -
- - {/* Description - text-sm single line, ~20px height */} -
-
-
- - {/* Rewards/Category section - matches actual card structure with mt-5 */} -
-
-
-
-
-
-
-
-
- ); -} diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/featured-programs.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/featured-programs.tsx deleted file mode 100644 index 564f70b4b43..00000000000 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/featured-programs.tsx +++ /dev/null @@ -1,63 +0,0 @@ -"use client"; - -import { NetworkProgramProps } from "@/lib/types"; -import { - Carousel, - CarouselContent, - CarouselItem, - CarouselNavBar, -} from "@dub/ui"; -import { fetcher } from "@dub/utils"; -import { ComponentProps } from "react"; -import useSWR from "swr"; -import { - FeaturedProgramCard, - FeaturedProgramCardSkeleton, -} from "./featured-program-card"; - -export function FeaturedPrograms() { - const { data: programs, error } = useSWR( - `/api/network/programs?featured=true`, - fetcher, - { revalidateOnFocus: false, keepPreviousData: true }, - ); - - return programs?.length === 0 || error ? null : ( -
-

- Featured programs -

-
- - - {programs ? ( - programs.map((program) => ( - - )) - ) : ( - <> - - - - - - - - )} - -
- -
-
-
-
- ); -} - -const CarouselCard = (props: ComponentProps) => { - return ( - - - - ); -}; diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/page-client.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/page-client.tsx deleted file mode 100644 index a746224471a..00000000000 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/page-client.tsx +++ /dev/null @@ -1,130 +0,0 @@ -"use client"; - -import useNetworkProgramsCount from "@/lib/swr/use-network-programs-count"; -import { NetworkProgramProps } from "@/lib/types"; -import { PROGRAM_NETWORK_MAX_PAGE_SIZE } from "@/lib/zod/schemas/program-network"; -import { SearchBoxPersisted } from "@/ui/shared/search-box"; -import { - AnimatedSizeContainer, - Filter, - PaginationControls, - usePagination, - useRouterStuff, -} from "@dub/ui"; -import { cn, fetcher } from "@dub/utils"; -import useSWR from "swr"; -import { FeaturedPrograms } from "./featured-programs"; -import { MarketplaceEmptyState } from "./marketplace-empty-state"; -import { - MarketplaceProgramCard, - MarketplaceProgramCardSkeleton, -} from "./program-card"; -import ProgramSort from "./program-sort"; -import { useProgramNetworkFilters } from "./use-program-network-filters"; - -export function ProgramMarketplacePageClient() { - const { getQueryString } = useRouterStuff(); - - const { data: programsCount, error: countError } = useNetworkProgramsCount(); - - const { - data: programs, - error, - isValidating, - } = useSWR( - `/api/network/programs${getQueryString()}`, - fetcher, - { revalidateOnFocus: false, keepPreviousData: true }, - ); - - const { pagination, setPagination } = usePagination( - PROGRAM_NETWORK_MAX_PAGE_SIZE, - ); - - const { - filters, - activeFilters, - isFiltered, - onSelect, - onRemove, - onRemoveAll, - } = useProgramNetworkFilters(); - - return ( -
- -
-
-
- - -
- -
- -
-
- -
-
-
-
- - {error || countError ? ( -
- Failed to load programs -
- ) : !programs || programs?.length ? ( -
-
-
- {programs - ? programs.map((program) => ( - - )) - : [...Array(5)].map((_, idx) => ( - - ))} -
-
-
- `program${p ? "s" : ""}`} - /> -
-
- ) : ( - - )} -
- ); -} diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/page.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/page.tsx deleted file mode 100644 index ff973e6489d..00000000000 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/page.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { PageContent } from "@/ui/layout/page-content"; -import { PageWidthWrapper } from "@/ui/layout/page-width-wrapper"; -import { ProgramMarketplacePageClient } from "./page-client"; - -export default function PartnersDashboard() { - return ( - - - - - - ); -} diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/program-sort.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/program-sort.tsx deleted file mode 100644 index 9956afe2157..00000000000 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/program-sort.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { - Calendar6, - IconMenu, - Popover, - SortAlphaAscending, - SortAlphaDescending, - Star, - Tick, - useRouterStuff, -} from "@dub/ui"; -import { cn } from "@dub/utils"; -import { ChevronDown } from "lucide-react"; -import { useState } from "react"; - -const programSortOptions = [ - { - icon: Star, - label: "Most popular", - value: "popularity", - order: "desc", - }, - { - icon: Calendar6, - label: "Newest", - value: "recency", - order: "desc", - }, - { - icon: SortAlphaDescending, - label: "Name A-Z", - value: "name", - order: "asc", - }, - { - icon: SortAlphaAscending, - label: "Name Z-A", - value: "name", - order: "desc", - }, -] as const; - -export default function ProgramSort() { - const { queryParams, searchParams } = useRouterStuff(); - - const [openPopover, setOpenPopover] = useState(false); - - const sortOrder = searchParams.get("sortOrder") === "asc" ? "asc" : "desc"; - - const selectedSort = - programSortOptions.find( - (s) => s.value === searchParams.get("sortBy") && s.order === sortOrder, - ) ?? programSortOptions[0]; - - return ( - - {programSortOptions.map(({ label, value, order, icon: Icon }) => ( - - ))} -
- } - openPopover={openPopover} - setOpenPopover={setOpenPopover} - > - - - ); -} diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/page-client.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/page-client.tsx index 1d21da2c09c..e4543b7adcf 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/page-client.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/page-client.tsx @@ -3,7 +3,7 @@ import useProgramEnrollments from "@/lib/swr/use-program-enrollments"; import { PageWidthWrapper } from "@/ui/layout/page-width-wrapper"; import { ProgramCard, ProgramCardSkeleton } from "@/ui/partners/program-card"; -import { ProgramsPromoBanner } from "@/ui/partners/program-marketplace/programs-promo-banner"; +import { ProgramsPromoBanner } from "@/ui/program-marketplace/programs-promo-banner"; import { SimpleEmptyState } from "@/ui/shared/simple-empty-state"; import { HexadecagonStar } from "@dub/ui/icons"; import { useId } from "react"; 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/app/sitemap.ts b/apps/web/app/sitemap.ts index 9313131c3d5..b7af30dc281 100644 --- a/apps/web/app/sitemap.ts +++ b/apps/web/app/sitemap.ts @@ -1,5 +1,12 @@ +import { + getMarketplaceAllHref, + getMarketplaceCanonicalUrl, + getMarketplaceCategoryHref, + getMarketplaceHref, + getMarketplaceProgramHref, +} from "@/ui/program-marketplace/utils/urls"; import { prisma } from "@dub/prisma"; -import { Prisma } from "@dub/prisma/client"; +import { Category, Prisma } from "@dub/prisma/client"; import { PARTNERS_HOSTNAMES, SHORT_DOMAIN } from "@dub/utils"; import { MetadataRoute } from "next"; import { headers } from "next/headers"; @@ -39,10 +46,53 @@ export default async function sitemap(): Promise { })); } - return [ + const entries: MetadataRoute.Sitemap = [ { url: `https://${domain}`, lastModified: new Date(), }, ]; + + if ( + process.env.NEXT_PUBLIC_APP_DOMAIN && + domain === process.env.NEXT_PUBLIC_APP_DOMAIN + ) { + const marketplacePrograms = await prisma.program.findMany({ + where: { + addedToMarketplaceAt: { + not: null, + }, + }, + select: { + slug: true, + updatedAt: true, + }, + orderBy: { + slug: "asc", + }, + }); + + entries.push( + { + url: getMarketplaceCanonicalUrl(getMarketplaceHref()), + lastModified: new Date(), + }, + { + url: getMarketplaceCanonicalUrl(getMarketplaceAllHref()), + lastModified: new Date(), + }, + ...Object.values(Category).map((category) => ({ + url: getMarketplaceCanonicalUrl(getMarketplaceCategoryHref(category)), + lastModified: new Date(), + })), + ...marketplacePrograms.map((program) => ({ + url: getMarketplaceCanonicalUrl( + getMarketplaceProgramHref(program.slug), + ), + lastModified: program.updatedAt, + })), + ); + } + + return entries; } diff --git a/apps/web/lib/actions/partners/update-discount.ts b/apps/web/lib/actions/partners/update-discount.ts index c9803c69fd8..cf1f6a6fdce 100644 --- a/apps/web/lib/actions/partners/update-discount.ts +++ b/apps/web/lib/actions/partners/update-discount.ts @@ -72,7 +72,7 @@ export const updateDiscountAction = authActionClient revalidatePath(`/partners.dub.co/${program.slug}/apply`), program.addedToMarketplaceAt && revalidatePath( - `/partners.dub.co/programs/marketplace/${program.slug}`, + `/partners.dub.co/marketplace/${program.slug}`, ), ] : []), diff --git a/apps/web/lib/actions/partners/update-group-branding.ts b/apps/web/lib/actions/partners/update-group-branding.ts index 1b04bd994d4..bb756b69510 100644 --- a/apps/web/lib/actions/partners/update-group-branding.ts +++ b/apps/web/lib/actions/partners/update-group-branding.ts @@ -129,7 +129,7 @@ export const updateGroupBrandingAction = authActionClient ), program.addedToMarketplaceAt && revalidatePath( - `/partners.dub.co/programs/marketplace/${program.slug}`, + `/partners.dub.co/marketplace/${program.slug}`, ), ] : []), diff --git a/apps/web/lib/actions/partners/update-reward.ts b/apps/web/lib/actions/partners/update-reward.ts index 47c9411f57e..10590b22fc1 100644 --- a/apps/web/lib/actions/partners/update-reward.ts +++ b/apps/web/lib/actions/partners/update-reward.ts @@ -169,9 +169,7 @@ export const updateRewardAction = authActionClient revalidatePath(`/partners.dub.co/${program.slug}`), revalidatePath(`/partners.dub.co/${program.slug}/apply`), program.addedToMarketplaceAt && - revalidatePath( - `/partners.dub.co/programs/marketplace/${program.slug}`, - ), + revalidatePath(`/partners.dub.co/marketplace/${program.slug}`), ] : []), ]), 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/partners.ts b/apps/web/lib/middleware/partners.ts index 91c5e5badd1..fc487771f40 100644 --- a/apps/web/lib/middleware/partners.ts +++ b/apps/web/lib/middleware/partners.ts @@ -4,6 +4,7 @@ import { getUserViaToken } from "./utils/get-user-via-token"; import { isValidInternalRedirect } from "./utils/is-valid-internal-redirect"; import { parse } from "./utils/parse"; import { + partnersMarketplaceRedirects, partnersProgramRedirects, partnersRedirect, } from "./utils/partners-redirect"; @@ -45,7 +46,19 @@ export async function PartnersMiddleware(req: NextRequest) { status: 301, }, ); - } else if (!user && isAuthenticatedPath) { + } + + const marketplaceDestination = partnersMarketplaceRedirects( + path, + searchParamsObj, + ); + if (marketplaceDestination) { + return NextResponse.redirect(new URL(marketplaceDestination, req.url), { + status: 301, + }); + } + + if (!user && isAuthenticatedPath) { if (path.startsWith("/programs/")) { const programSlug = path.split("/")[2]; return NextResponse.redirect(new URL(`/${programSlug}/login`, req.url)); 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/middleware/utils/partners-redirect.ts b/apps/web/lib/middleware/utils/partners-redirect.ts index bf371c35fff..d258c3312d9 100644 --- a/apps/web/lib/middleware/utils/partners-redirect.ts +++ b/apps/web/lib/middleware/utils/partners-redirect.ts @@ -1,10 +1,11 @@ +import { getMarketplacePopularRedirectHref } from "@/ui/program-marketplace/utils/urls"; + const PARTNERS_REDIRECTS = { "/settings": "/profile", "/settings/payouts": "/payouts", "/settings/notifications": "/profile/notifications", "/account/settings/notifications": "/profile/notifications", "/profile/sites": "/profile", - "/marketplace": "/programs/marketplace", "/rewind": "/rewind/2025", "/onboarding/online-presence": "/onboarding/platforms", "/onboarding/verify": "/onboarding/payouts", @@ -14,6 +15,62 @@ export const partnersRedirect = (path: string) => { return PARTNERS_REDIRECTS[path] || null; }; +function withQuery( + targetPath: string, + searchParams: Record, +) { + const params = new URLSearchParams(); + + for (const [key, value] of Object.entries(searchParams)) { + if (Array.isArray(value)) { + value.forEach((v) => params.append(key, v)); + } else if (typeof value === "string") { + params.set(key, value); + } + } + + const query = params.toString(); + return query ? `${targetPath}?${query}` : targetPath; +} + +export const partnersMarketplaceRedirects = ( + path: string, + searchParams: Record = {}, +) => { + if (path === "/programs/marketplace") { + return withQuery("/marketplace", searchParams); + } + + if (path === "/programs/marketplace/all") { + return withQuery("/marketplace/all", searchParams); + } + + if ( + path === "/programs/marketplace/popular" || + path === "/marketplace/popular" + ) { + return getMarketplacePopularRedirectHref(searchParams); + } + + const match = path.match(/^\/programs\/marketplace\/([^/]+)$/); + + if (match) { + const slug = match[1]; + + if (slug === "all") { + return withQuery("/marketplace/all", searchParams); + } + + if (slug === "popular") { + return getMarketplacePopularRedirectHref(searchParams); + } + + return withQuery(`/marketplace/${slug}`, searchParams); + } + + return null; +}; + const PARTNERS_PROGRAM_REDIRECTS = { florafauna: "flora", "ship-30": "dwp", diff --git a/apps/web/lib/network/program-categories.ts b/apps/web/lib/network/program-categories.ts index 716fb4ca830..ca5bb03730b 100644 --- a/apps/web/lib/network/program-categories.ts +++ b/apps/web/lib/network/program-categories.ts @@ -19,71 +19,96 @@ export const PROGRAM_CATEGORIES: { id: Category; icon: Icon; label: string; + listPageDescription: string; }[] = [ { id: Category.Artificial_Intelligence, label: "AI", icon: Sparkle3, + listPageDescription: + "Browse partner programs for AI tools and machine learning platforms.", }, { id: Category.Development, label: "Development", icon: Code, + listPageDescription: + "Browse partner programs for developer tools and software infrastructure.", }, { id: Category.Design, label: "Design", icon: Brush, + listPageDescription: + "Browse partner 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.", }, { id: Category.Finance, label: "Finance", icon: MoneyBill, + listPageDescription: + "Browse partner programs for finance software and fintech platforms.", }, { id: Category.Marketing, label: "Marketing", icon: MarketingTarget, + listPageDescription: + "Browse partner 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.", }, { 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.", }, { id: Category.Health, label: "Health", icon: Heart, + listPageDescription: + "Browse partner programs for health software and wellness tools.", }, { id: Category.Consumer, label: "Consumer", icon: User, + listPageDescription: + "Browse partner 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.", }, ]; export const PROGRAM_CATEGORIES_MAP: Partial< - Record + Record > = Object.fromEntries( PROGRAM_CATEGORIES.map((category) => [category.id, category]), ); diff --git a/apps/web/lib/zod/schemas/program-network.ts b/apps/web/lib/zod/schemas/program-network.ts index 00e6d772cb1..bf46c704d08 100644 --- a/apps/web/lib/zod/schemas/program-network.ts +++ b/apps/web/lib/zod/schemas/program-network.ts @@ -32,6 +32,19 @@ export const NetworkProgramExtendedSchema = NetworkProgramSchema.extend({ export const PROGRAM_NETWORK_MAX_PAGE_SIZE = 100; +export const getPublicNetworkProgramsQuerySchema = z + .object({ + category: z.enum(Category).optional(), + rewardType: z.enum(["sale", "lead", "click", "discount"]).optional(), + featured: z.coerce.boolean().optional(), + search: z.string().optional(), + sortBy: z.enum(["name", "recency", "popularity"]).default("popularity"), + sortOrder: z.enum(["asc", "desc"]).default("desc"), + }) + .extend( + getPaginationQuerySchema({ pageSize: PROGRAM_NETWORK_MAX_PAGE_SIZE }), + ); + export const getNetworkProgramsQuerySchema = z .object({ category: z.enum(Category).optional(), 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/layout/sidebar/partner-program-dropdown.tsx b/apps/web/ui/layout/sidebar/partner-program-dropdown.tsx index c53018ea6b7..aed090d0a8b 100644 --- a/apps/web/ui/layout/sidebar/partner-program-dropdown.tsx +++ b/apps/web/ui/layout/sidebar/partner-program-dropdown.tsx @@ -87,7 +87,7 @@ export function PartnerProgramDropdown() { + {newsContent} + + + + ); +} + const NAV_GROUPS: SidebarNavGroups = ({ pathname, unreadMessagesCount, @@ -62,7 +75,8 @@ const NAV_GROUPS: SidebarNavGroups = ({ "View all your enrolled programs and review invitations to other programs.", icon: GridIcon, href: "/programs", - active: pathname.startsWith("/programs"), + active: + pathname.startsWith("/programs") || pathname.startsWith("/marketplace"), }, { name: "Payouts", @@ -92,42 +106,21 @@ const NAV_GROUPS: SidebarNavGroups = ({ const NAV_AREAS: SidebarNavAreas = { // Top-level - programs: ({ invitationsCount }) => ({ - title: ( -
- -
- ), + programs: ({ invitationsCount, newsContent }) => ({ showNews: true, direction: "left", content: [ { - items: [ - { - name: "Programs", - icon: GridIcon, - href: "/programs", - isActive: (pathname, href) => - pathname.startsWith(href) && - ["invitations", "marketplace"].every( - (k) => !pathname.startsWith(`${href}/${k}`), - ), - }, - { - name: "Marketplace", - icon: Shop, - href: "/programs/marketplace" as `/${string}`, - badge: "New", - }, - { - name: "Invitations", - icon: UserCheck, - href: "/programs/invitations", - badge: invitationsCount || undefined, - }, - ], + items: getProgramsAreaNavItems(invitationsCount), }, ], + footer: , + }), + + programsMarketplace: ({ invitationsCount }) => ({ + direction: "right", + content: [], + panel: , }), profile: ({ postbacksEnabled }) => ({ @@ -326,6 +319,9 @@ export function PartnersSidebarNav({ enabled: isEnrolledProgramPage, }); + const isMarketplaceFilterSidebarPage = + isMarketplaceFilterSidebarPath(pathname); + const currentArea = useMemo(() => { return pathname.startsWith("/account/settings") ? "userSettings" @@ -335,8 +331,10 @@ export function PartnersSidebarNav({ ? null : isEnrolledProgramPage ? "program" - : "programs"; - }, [pathname, programSlug, isEnrolledProgramPage]); + : isMarketplaceFilterSidebarPage + ? "programsMarketplace" + : "programs"; + }, [pathname, isEnrolledProgramPage, isMarketplaceFilterSidebarPage]); const { count: invitationsCount } = useProgramEnrollmentsCount({ status: "invited", @@ -390,6 +388,13 @@ export function PartnersSidebarNav({ groups={NAV_GROUPS} areas={NAV_AREAS} currentArea={currentArea} + persistentAreaHeader={ + currentArea === "programs" || currentArea === "programsMarketplace" ? ( +
+ +
+ ) : undefined + } data={{ pathname, queryString: getQueryString(), @@ -401,19 +406,11 @@ export function PartnersSidebarNav({ showDetailedAnalytics, postbacksEnabled: partner?.featureFlags?.postbacks, hasReferralReward: !!programEnrollment?.referralRewardId, + newsContent, }} toolContent={composedToolContent} newsContent={newsContent} - bottom={ - isEnrolledProgramPage ? ( - - ) : ( - <> - - - - ) - } + bottom={isEnrolledProgramPage ? : undefined} /> ); } diff --git a/apps/web/ui/layout/sidebar/programs-area-nav.tsx b/apps/web/ui/layout/sidebar/programs-area-nav.tsx new file mode 100644 index 00000000000..3362aa0f3dc --- /dev/null +++ b/apps/web/ui/layout/sidebar/programs-area-nav.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { GridIcon, Shop, UserCheck } from "@dub/ui/icons"; +import { NavItem, type NavItemType } from "./sidebar-nav"; + +export function getProgramsAreaNavItems( + invitationsCount?: number, +): NavItemType[] { + return [ + { + name: "Programs", + icon: GridIcon, + href: "/programs", + isActive: (pathname, href) => + pathname.startsWith(href) && + !pathname.startsWith(`${href}/invitations`), + }, + { + name: "Marketplace", + icon: Shop, + href: "/marketplace", + isActive: (pathname) => pathname.startsWith("/marketplace"), + }, + { + name: "Invitations", + icon: UserCheck, + href: "/programs/invitations", + badge: invitationsCount || undefined, + }, + ]; +} + +export function ProgramsAreaNav({ + invitationsCount, +}: { + invitationsCount?: number; +}) { + return ( +
+ {getProgramsAreaNavItems(invitationsCount).map((item) => ( + + ))} +
+ ); +} diff --git a/apps/web/ui/layout/sidebar/sidebar-nav.tsx b/apps/web/ui/layout/sidebar/sidebar-nav.tsx index 8d17bf5b2fb..2f6051828cf 100644 --- a/apps/web/ui/layout/sidebar/sidebar-nav.tsx +++ b/apps/web/ui/layout/sidebar/sidebar-nav.tsx @@ -11,7 +11,7 @@ import { useScrollProgress, } from "@dub/ui"; import { cn } from "@dub/utils"; -import { ChevronDown } from "lucide-react"; +import { ChevronDown, ChevronRight } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import Link from "next/link"; import { usePathname } from "next/navigation"; @@ -34,6 +34,9 @@ export type NavItemCommon = { isActive?: (pathname: string, href: string) => boolean; badge?: ReactNode; arrow?: boolean; + chevronRight?: boolean; + showChevronRight?: boolean; + onChevronClick?: () => void; locked?: boolean; }; @@ -71,6 +74,8 @@ export type SidebarNavAreas> = Record< showNews?: boolean; // show news segment – TODO: enable this for Partner Program too hideSwitcherIcons?: boolean; // hide workspace switcher + product icons for this area direction?: "left" | "right"; + panel?: ReactNode; // custom panel content (replaces nav items when set) + footer?: ReactNode; // area footer content (animates with the area body) content: { name?: string; items: NavItemType[]; @@ -91,6 +96,7 @@ export function SidebarNav>({ newsContent, switcher, bottom, + persistentAreaHeader, }: { groups: SidebarNavGroups; areas: SidebarNavAreas; @@ -100,6 +106,7 @@ export function SidebarNav>({ newsContent?: ReactNode; switcher?: ReactNode; bottom?: ReactNode; + persistentAreaHeader?: ReactNode; }) { return (
>({
{(!currentArea || - !areas[currentArea](data).hideSwitcherIcons) && ( + !areas[currentArea]?.(data)?.hideSwitcherIcons) && (
{switcher} {groups(data).map((group) => ( @@ -155,6 +162,7 @@ export function SidebarNav>({ currentArea={currentArea} newsContent={newsContent} bottom={bottom} + persistentAreaHeader={persistentAreaHeader} />
@@ -169,99 +177,142 @@ function SidebarAreasPanel>({ currentArea, newsContent, bottom, + persistentAreaHeader, }: { areas: SidebarNavAreas; data: T; currentArea: string | null; newsContent?: ReactNode; bottom?: ReactNode; + persistentAreaHeader?: ReactNode; }) { const scrollRef = useRef(null); const { scrollProgress, updateScrollProgress } = useScrollProgress(scrollRef); - const showNews = currentArea && areas[currentArea]?.(data).showNews; + const currentAreaConfig = useMemo( + () => (currentArea ? areas[currentArea]?.(data) : undefined), + [currentArea, areas, data], + ); + const showNews = currentAreaConfig?.showNews; + const currentAreaHasFooter = Boolean(currentAreaConfig?.footer); const hasOverflow = useMemo(() => { - if (!currentArea) return false; - const { content } = areas[currentArea](data); + if (!currentAreaConfig) return false; + const { content, panel } = currentAreaConfig; + if (panel) return true; const totalItems = content.flatMap((c) => c.items).length; return totalItems > 10; - }, [currentArea, areas, data]); + }, [currentAreaConfig]); return (
- {/* Scrollable content with gradient overlay */} -
-
-
-
- {Object.entries(areas).map(([area, areaConfig]) => { - const { title, backHref, content, direction } = - areaConfig(data); - - const TitleContainer = backHref ? Link : "div"; - - return ( - - {title && - (typeof title === "string" ? ( - - {backHref && ( -
- -
- )} - - {title} - -
+ {persistentAreaHeader && ( +
{persistentAreaHeader}
+ )} + +
+ {/* Scrollable body with gradient overlay */} +
+
+
+
+ {Object.entries(areas).map(([area, areaConfig]) => { + const { title, backHref, content, direction, panel } = + areaConfig(data); + + const TitleContainer = backHref ? Link : "div"; + + return ( + + {title && + (typeof title === "string" ? ( + + {backHref && ( +
+ +
+ )} + + {title} + +
+ ) : ( + title + ))} + {panel ? ( + panel ) : ( - title - ))} -
- {content.map(({ name, items }, idx) => ( -
- {name && ( -
- {name} +
+ {content.map(({ name, items }, idx) => ( +
+ {name && ( +
+ {name} +
+ )} + {items.map((item) => ( + + ))}
- )} - {items.map((item) => ( - ))}
- ))} -
- - ); - })} + )} + + ); + })} +
+ {/* Bottom scroll fade - shows when content overflows */} + {hasOverflow && ( +
+ )}
- {/* Bottom scroll fade - shows when content overflows */} - {hasOverflow && ( -
+ + {/* Area footers - pinned below scroll, animate with area transitions */} + {Object.entries(areas).some( + ([, areaConfig]) => areaConfig(data).footer, + ) && ( +
+ {Object.entries(areas).map(([area, areaConfig]) => { + const { direction, footer } = areaConfig(data); + + if (!footer) { + return null; + } + + return ( + +
{footer}
+ + ); + })} +
)}
@@ -279,21 +330,23 @@ function SidebarAreasPanel>({
)} - - {showNews && ( - - {newsContent} - - )} - + {!currentAreaHasFooter && ( + + {showNews && ( + + {newsContent} + + )} + + )} {bottom &&
{bottom}
}
@@ -410,8 +463,17 @@ function NavGroupItem({ ); } -function NavItem({ item }: { item: NavItemType | NavSubItemType }) { - const { name, href, exact, isActive: customIsActive, locked } = item; +export function NavItem({ item }: { item: NavItemType | NavSubItemType }) { + const { + name, + href, + exact, + isActive: customIsActive, + locked, + chevronRight, + showChevronRight, + onChevronClick, + } = item; const Icon = "icon" in item ? item.icon : undefined; const items = "items" in item ? item.items : undefined; @@ -431,11 +493,21 @@ function NavItem({ item }: { item: NavItemType | NavSubItemType }) { : pathname.startsWith(hrefWithoutQuery); }, [pathname, href, exact, customIsActive]); + const showChevron = Boolean( + chevronRight && isActive && (showChevronRight ?? true), + ); + return (
{ + if (showChevron) { + e.preventDefault(); + onChevronClick?.(); + } + }} onPointerEnter={() => !locked && setHovered(true)} onPointerLeave={() => !locked && setHovered(false)} className={cn( @@ -484,6 +556,22 @@ function NavItem({ item }: { item: NavItemType | NavSubItemType }) { {item.arrow && ( )} + {chevronRight && ( + + {showChevron && ( + + + + )} + + )} {items && ( diff --git a/apps/web/ui/partners/program-application-sheet.tsx b/apps/web/ui/partners/program-application-sheet.tsx index 18717f92c57..3969911e6bc 100644 --- a/apps/web/ui/partners/program-application-sheet.tsx +++ b/apps/web/ui/partners/program-application-sheet.tsx @@ -315,9 +315,7 @@ function ProgramApplicationSheetForm({

void; - className?: string; -}) => { - const categoryData = PROGRAM_CATEGORIES_MAP[category]; - const { icon: Icon, label } = categoryData ?? { - icon: CircleInfo, - label: category.replaceAll("_", " "), - }; - - const As = onClick ? "button" : "div"; - - return ( - { - e.preventDefault(); - e.stopPropagation(); - onClick?.(); - }, - })} - className={cn( - "text-content-default -ml-1 flex h-6 min-w-0 items-center gap-1 rounded-md px-1", - onClick && "hover:bg-bg-subtle active:bg-bg-emphasis", - className, - )} - > - - {label} - - ); -}; diff --git a/apps/web/ui/partners/program-marketplace/program-marketplace-logos.tsx b/apps/web/ui/partners/program-marketplace/program-marketplace-logos.tsx deleted file mode 100644 index 0bf53c92d4e..00000000000 --- a/apps/web/ui/partners/program-marketplace/program-marketplace-logos.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { cn } from "@dub/utils"; - -// x, y, rotation, size, z (/shadow) -const LOGOS = [ - // Perplexity - { x: 0, y: 16, r: 22, s: 1 }, - // Dub - { x: 23, y: 0, r: -7, s: 0.7 }, - // Tella - { x: 44, y: 24, r: 13, s: 0.8 }, - // Buffer - { x: 62, y: 0, r: -49, s: 0.8 }, - // Superhuman - { x: 82, y: 28, r: -26, s: 0.75 }, - // Framer - { x: 12, y: 50, r: -11, s: 0.75 }, - // Polymarket - { x: 26, y: 34, r: -10, s: 0.9, z: 1 }, - // Fillout - { x: 40, y: 48, r: -11, s: 0.75 }, - // Copper - { x: 60, y: 54, r: -18, s: 0.9 }, - // Firecrawl - { x: 78, y: 50, r: -37, s: 0.75, z: 1 }, - // Wispr Flow - { x: 0, y: 72, r: 15, s: 0.8, z: 1 }, - // Granola - { x: 26, y: 70, r: 4, s: 0.8 }, -]; -export const PROGRAM_MARKETPLACE_LOGO_COUNT = LOGOS.length; - -export function ProgramMarketplaceLogos({ className }: { className?: string }) { - return ( -
- {LOGOS.map(({ x, y, r, s, z }, index) => { - return ( -
-
-
- ); - })} -
- ); -} diff --git a/apps/web/ui/partners/rewind/partner-rewind-banner.tsx b/apps/web/ui/partners/rewind/partner-rewind-banner.tsx index f2cd06947fc..efa2f9856f8 100644 --- a/apps/web/ui/partners/rewind/partner-rewind-banner.tsx +++ b/apps/web/ui/partners/rewind/partner-rewind-banner.tsx @@ -1,12 +1,12 @@ "use client"; import usePartnerRewind from "@/lib/swr/use-partner-rewind"; +import { ProgramMarketplaceBanner } from "@/ui/program-marketplace/program-marketplace-banner"; import { X } from "@/ui/shared/icons"; import { Button, Grid, buttonVariants } from "@dub/ui"; import { cn } from "@dub/utils"; import { AnimatePresence, motion } from "motion/react"; import Link from "next/link"; -import { ProgramMarketplaceBanner } from "../program-marketplace/program-marketplace-banner"; import { usePartnerRewindStatus } from "./use-partner-rewind-status"; export function PartnerRewindBanner() { diff --git a/apps/web/ui/program-marketplace/constants.ts b/apps/web/ui/program-marketplace/constants.ts new file mode 100644 index 00000000000..3bd0bf0364d --- /dev/null +++ b/apps/web/ui/program-marketplace/constants.ts @@ -0,0 +1,56 @@ +import { + Calendar6, + SortAlphaAscending, + SortAlphaDescending, + Star, +} from "@dub/ui/icons"; + +export const MARKETPLACE_REWARD_TYPES = { + sale: "Sale reward (CPS)", + lead: "Lead reward (CPL)", + click: "Click reward (CPC)", + discount: "Dual-sided incentives", +} as const; + +export type MarketplaceRewardType = keyof typeof MARKETPLACE_REWARD_TYPES; + +export const MARKETPLACE_SORT_OPTIONS = [ + { + icon: Star, + label: "Most popular", + value: "popularity", + order: "desc", + }, + { + icon: Calendar6, + label: "Newest", + value: "recency", + order: "desc", + }, + { + icon: SortAlphaAscending, + label: "Name A-Z", + value: "name", + order: "asc", + }, + { + icon: SortAlphaDescending, + label: "Name Z-A", + value: "name", + order: "desc", + }, +] as const; + +export function isDefaultMarketplaceSort(sortBy: string, sortOrder: string) { + return sortBy === "popularity" && sortOrder === "desc"; +} + +export function getMarketplaceToolbarBadgeCount( + activeFilterCount: number, + sortBy: string, + sortOrder: string, +) { + return ( + activeFilterCount + (isDefaultMarketplaceSort(sortBy, sortOrder) ? 0 : 1) + ); +} 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={ + + +
+ )} +
+
+
+ + {program.marketplaceHeaderImage && ( +
+ {program.name} +
+ )} + + ); +} + +export function FeaturedProgramCardSkeleton() { + return ( +
+
+
+
+
+ +
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/apps/web/ui/program-marketplace/featured-programs.tsx b/apps/web/ui/program-marketplace/featured-programs.tsx new file mode 100644 index 00000000000..c8ac44b354b --- /dev/null +++ b/apps/web/ui/program-marketplace/featured-programs.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { NetworkProgramProps } from "@/lib/types"; +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNavBar, +} from "@dub/ui"; +import { fetcher } from "@dub/utils"; +import useSWR from "swr"; +import { + FeaturedProgramCard, + FeaturedProgramCardSkeleton, +} from "./featured-program-card"; + +const FEATURED_PROGRAMS_API_PATH = "/api/network/programs?featured=true"; + +type FeaturedProgramsProps = { + showStatus?: boolean; +} & ( + | { programs: NetworkProgramProps[]; apiPath?: never } + | { apiPath?: string; programs?: never } +); + +export function FeaturedPrograms({ + showStatus = true, + ...props +}: FeaturedProgramsProps) { + const apiPath = + "apiPath" in props && props.apiPath !== undefined + ? props.apiPath + : "programs" in props + ? null + : FEATURED_PROGRAMS_API_PATH; + + const { data: fetchedPrograms, error } = useSWR( + apiPath, + fetcher, + { revalidateOnFocus: false, keepPreviousData: true }, + ); + + const programs = "programs" in props ? props.programs : fetchedPrograms; + + if (error || programs?.length === 0) { + return null; + } + + return ( +
+ + + {programs ? ( + programs.map((program, index) => ( + + + + )) + ) : ( + + )} + +
+ +
+
+
+ ); +} + +export function FeaturedProgramsSkeleton() { + return ( +
+ + + + + +
+ ); +} + +function FeaturedProgramsSkeletonItems() { + return ( + <> + + + + + + + + ); +} diff --git a/apps/web/ui/program-marketplace/home-sections.ts b/apps/web/ui/program-marketplace/home-sections.ts new file mode 100644 index 00000000000..9398620b4ed --- /dev/null +++ b/apps/web/ui/program-marketplace/home-sections.ts @@ -0,0 +1,91 @@ +import { PROGRAM_CATEGORIES_MAP } from "@/lib/network/program-categories"; +import { Category } from "@dub/prisma/client"; +import { + getMarketplaceAllHref, + getMarketplaceCategoryHref, +} from "./utils/urls"; + +export const MARKETPLACE_HOME_ROW_PAGE_SIZE = 5; + +export const MARKETPLACE_HOME_CATEGORIES = [ + Category.Productivity, + Category.Artificial_Intelligence, + Category.Marketing, + Category.Development, + Category.Design, + Category.Finance, + Category.Ecommerce, + Category.Health, + Category.Consumer, + Category.Education, +] as const; + +type MarketplaceHomeRowFetchParams = { + sortBy?: "name" | "recency" | "popularity"; + category?: Category; + pageSize: number; +}; + +export type MarketplaceHomeRow = { + key: string; + title: string; + viewAllHref: string; + apiPath: string; + fetchParams: MarketplaceHomeRowFetchParams; + showViewAllCard: true; +}; + +const featuredHomeRows: MarketplaceHomeRow[] = [ + { + key: "most-popular", + title: "Most popular", + viewAllHref: getMarketplaceAllHref({ + sortBy: "popularity", + sortOrder: "desc", + }), + apiPath: `/api/network/programs?sortBy=popularity&pageSize=${MARKETPLACE_HOME_ROW_PAGE_SIZE}`, + fetchParams: { + sortBy: "popularity", + pageSize: MARKETPLACE_HOME_ROW_PAGE_SIZE, + }, + showViewAllCard: true, + }, + { + key: "new", + title: "New", + viewAllHref: getMarketplaceAllHref({ + sortBy: "recency", + sortOrder: "desc", + }), + apiPath: `/api/network/programs?sortBy=recency&pageSize=${MARKETPLACE_HOME_ROW_PAGE_SIZE}`, + fetchParams: { + sortBy: "recency", + pageSize: MARKETPLACE_HOME_ROW_PAGE_SIZE, + }, + showViewAllCard: true, + }, +]; + +const categoryHomeRows: MarketplaceHomeRow[] = MARKETPLACE_HOME_CATEGORIES.map( + (category) => { + const label = + PROGRAM_CATEGORIES_MAP[category]?.label ?? category.replaceAll("_", " "); + + return { + key: category, + title: label, + viewAllHref: getMarketplaceCategoryHref(category), + apiPath: `/api/network/programs?category=${category}&pageSize=${MARKETPLACE_HOME_ROW_PAGE_SIZE}`, + fetchParams: { + category, + pageSize: MARKETPLACE_HOME_ROW_PAGE_SIZE, + }, + showViewAllCard: true, + }; + }, +); + +export const MARKETPLACE_HOME_ROWS: MarketplaceHomeRow[] = [ + ...featuredHomeRows, + ...categoryHomeRows, +]; diff --git a/apps/web/ui/program-marketplace/marketplace-categories.tsx b/apps/web/ui/program-marketplace/marketplace-categories.tsx new file mode 100644 index 00000000000..05670fc7459 --- /dev/null +++ b/apps/web/ui/program-marketplace/marketplace-categories.tsx @@ -0,0 +1,48 @@ +import { PROGRAM_CATEGORIES_MAP } from "@/lib/network/program-categories"; +import { MARKETPLACE_HOME_CATEGORIES } from "@/ui/program-marketplace/home-sections"; +import { + getMarketplaceAllHref, + getMarketplaceCategoryHref, +} from "@/ui/program-marketplace/utils/urls"; +import { CircleInfo } from "@dub/ui"; +import Link from "next/link"; + +export function MarketplaceCategories() { + return ( +
+
+

+ Categories +

+ + View all programs + +
+ +
+ {MARKETPLACE_HOME_CATEGORIES.map((category) => { + const { icon: Icon, label } = PROGRAM_CATEGORIES_MAP[category] ?? { + icon: CircleInfo, + label: category.replaceAll("_", " "), + }; + + return ( + + + + {label} + + + ); + })} +
+
+ ); +} diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/marketplace-empty-state.tsx b/apps/web/ui/program-marketplace/marketplace-empty-state.tsx similarity index 100% rename from apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/marketplace-empty-state.tsx rename to apps/web/ui/program-marketplace/marketplace-empty-state.tsx diff --git a/apps/web/ui/program-marketplace/marketplace-filter-control.tsx b/apps/web/ui/program-marketplace/marketplace-filter-control.tsx new file mode 100644 index 00000000000..6fbfc7361b8 --- /dev/null +++ b/apps/web/ui/program-marketplace/marketplace-filter-control.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { FilterBars } from "@dub/ui/icons"; +import { cn } from "@dub/utils"; +import { motion } from "motion/react"; + +export function MarketplaceFilterControl({ + activeFilterCount, + onClear, + className, +}: { + activeFilterCount: number; + onClear: () => void; + className?: string; +}) { + if (activeFilterCount === 0) { + return null; + } + + return ( + +
+ +
+ {activeFilterCount} +
+
+ +
+ ); +} diff --git a/apps/web/ui/program-marketplace/marketplace-filter-sort-sheet.tsx b/apps/web/ui/program-marketplace/marketplace-filter-sort-sheet.tsx new file mode 100644 index 00000000000..9ffb4ef036b --- /dev/null +++ b/apps/web/ui/program-marketplace/marketplace-filter-sort-sheet.tsx @@ -0,0 +1,230 @@ +"use client"; + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + type ActiveFilterInput, + Button, + type FilterConfig, + type FilterOption, + FilterOptionRow, + normalizeActiveFilter, +} from "@dub/ui"; +import { cn } from "@dub/utils"; +import { Drawer } from "vaul"; +import { MARKETPLACE_SORT_OPTIONS } from "./constants"; + +export type MarketplaceFilterSortSheetProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + filters: FilterConfig[]; + activeFilters?: ActiveFilterInput[]; + onSelect: (key: string, value: string) => void; + onRemove: (key: string, value?: string) => void; + onClearAll: () => void; + sortBy: string; + sortOrder: string; + onSortChange: (sortBy: string, sortOrder: string) => void; + badgeCount: number; +}; + +function getSectionBadgeCount( + filterKey: string, + activeFilters?: ActiveFilterInput[], +) { + if (!activeFilters?.length) return 0; + const raw = activeFilters.find((f) => f.key === filterKey); + if (!raw) return 0; + return normalizeActiveFilter(raw).values.length; +} + +export function MarketplaceFilterSortSheet({ + open, + onOpenChange, + filters, + activeFilters, + onSelect, + onRemove, + onClearAll, + sortBy, + sortOrder, + onSortChange, + badgeCount, +}: MarketplaceFilterSortSheetProps) { + const visibleFilters = filters.filter( + (filter) => !filter.hideInFilterDropdown && filter.type !== "range", + ); + + const isOptionSelected = ( + filterKey: string, + value: FilterOption["value"], + ) => { + const rawActiveFilter = activeFilters?.find( + (filter) => filter.key === filterKey, + ); + if (!rawActiveFilter) return false; + + return normalizeActiveFilter(rawActiveFilter).values.some((v) => { + if (typeof v === "string" && typeof value === "string") { + return v.toLowerCase() === value.toLowerCase(); + } + return v === value; + }); + }; + + const toggleOption = (filter: FilterConfig, value: FilterOption["value"]) => { + if (isOptionSelected(filter.key, value)) { + onRemove(filter.key, String(value)); + return; + } + onSelect(filter.key, String(value)); + }; + + return ( + + + + +
+
+
+ +
+
+

+ Filter and sort +

+ {badgeCount > 0 ? ( +
+ {badgeCount} +
+ ) : null} +
+ +
+ +
+ f.key)]} + className="flex flex-col" + > + + svg]:size-4 [&>svg]:text-neutral-400", + )} + > + Sort by + + +
+ {MARKETPLACE_SORT_OPTIONS.map(({ label, value, order }) => { + const selected = sortBy === value && sortOrder === order; + + return ( + + ); + })} +
+
+
+ + {visibleFilters.map((filter) => { + const sectionBadge = getSectionBadgeCount( + filter.key, + activeFilters, + ); + + return ( + + svg]:size-4 [&>svg]:text-neutral-400", + )} + > + + {filter.label} + {sectionBadge > 0 ? ( + + {sectionBadge} + + ) : null} + + + + {filter.options === null ? ( +

+ Loading... +

+ ) : filter.options.length === 0 ? ( +

+ No options +

+ ) : ( +
+ {filter.options.map((option) => ( + + toggleOption(filter, option.value) + } + /> + ))} +
+ )} +
+
+ ); + })} +
+
+ + + + ); +} diff --git a/apps/web/ui/program-marketplace/marketplace-list-toolbar.tsx b/apps/web/ui/program-marketplace/marketplace-list-toolbar.tsx new file mode 100644 index 00000000000..f3cda602ef0 --- /dev/null +++ b/apps/web/ui/program-marketplace/marketplace-list-toolbar.tsx @@ -0,0 +1,262 @@ +"use client"; + +import { SearchBoxPersisted } from "@/ui/shared/search-box"; +import { Category } from "@dub/prisma/client"; +import { useRouterStuff } from "@dub/ui"; +import { FilterBars } from "@dub/ui/icons"; +import { cn } from "@dub/utils"; +import { usePathname, useRouter } from "next/navigation"; +import { useCallback, useState } from "react"; +import { + getMarketplaceToolbarBadgeCount, + type MarketplaceRewardType, +} from "./constants"; +import { MarketplaceFilterControl } from "./marketplace-filter-control"; +import { MarketplaceFilterSortSheet } from "./marketplace-filter-sort-sheet"; +import ProgramSort from "./program-sort"; +import { useProgramNetworkFilters } from "./use-program-network-filters"; +import { usePublicMarketplaceFilters } from "./use-public-marketplace-filters"; +import { + getMarketplaceAllHref, + getMarketplaceCategoryFromPathname, +} from "./utils/urls"; + +function MarketplaceListToolbarInternal() { + const router = useRouter(); + const pathname = usePathname(); + const { searchParamsObj, queryParams } = useRouterStuff(); + const [sheetOpen, setSheetOpen] = useState(false); + + const { filters, activeFilters, onSelect, onRemove, onClearFilters } = + useProgramNetworkFilters(); + + const sortBy = + typeof searchParamsObj.sortBy === "string" + ? searchParamsObj.sortBy + : "popularity"; + const sortOrder = + typeof searchParamsObj.sortOrder === "string" + ? searchParamsObj.sortOrder + : "desc"; + + const badgeCount = getMarketplaceToolbarBadgeCount( + activeFilters.length, + sortBy, + sortOrder, + ); + + const routeCategory = getMarketplaceCategoryFromPathname(pathname); + + const onClearAll = useCallback(() => { + const search = + typeof searchParamsObj.search === "string" + ? searchParamsObj.search + : undefined; + + if (routeCategory) { + router.replace(getMarketplaceAllHref({ search })); + return; + } + + queryParams({ + del: ["rewardType", "status", "page", "sortBy", "sortOrder"], + }); + }, [queryParams, routeCategory, router, searchParamsObj]); + + const onSortChange = useCallback( + (nextSortBy: string, nextSortOrder: string) => { + queryParams({ + set: { sortBy: nextSortBy, sortOrder: nextSortOrder }, + del: "page", + }); + }, + [queryParams], + ); + + return ( + <> + + setSheetOpen(true)} + onClearFilters={onClearFilters} + sortBy={sortBy} + sortOrder={sortOrder} + /> + + ); +} + +function MarketplaceListToolbarExternal({ + basePath, + activeCategory, + categoryCounts, + rewardTypeCounts, +}: { + basePath: string; + activeCategory?: Category; + categoryCounts: { category: Category; count: number }[]; + rewardTypeCounts: { + type: MarketplaceRewardType; + count: number; + }[]; +}) { + const { searchParamsObj } = useRouterStuff(); + const [sheetOpen, setSheetOpen] = useState(false); + + const { + filters, + activeFilters, + onSelect, + onRemove, + onClearFilters, + onSortChange, + } = usePublicMarketplaceFilters({ + basePath, + activeCategory, + categoryCounts, + rewardTypeCounts, + }); + + const sortBy = + typeof searchParamsObj.sortBy === "string" + ? searchParamsObj.sortBy + : "popularity"; + const sortOrder = + typeof searchParamsObj.sortOrder === "string" + ? searchParamsObj.sortOrder + : "desc"; + + const badgeCount = getMarketplaceToolbarBadgeCount( + activeFilters.length, + sortBy, + sortOrder, + ); + + const onClearAll = useCallback(() => { + onClearFilters(); + }, [onClearFilters]); + + return ( + <> + + setSheetOpen(true)} + onClearFilters={onClearFilters} + sortBy={sortBy} + sortOrder={sortOrder} + /> + + ); +} + +function ToolbarLayout({ + badgeCount, + activeFilterCount, + onOpenSheet, + onClearFilters, +}: { + badgeCount: number; + activeFilterCount: number; + onOpenSheet: () => void; + onClearFilters: () => void; + sortBy: string; + sortOrder: string; +}) { + return ( +
+ + +
+ + +
+ +
+ +
+
+ ); +} + +export function MarketplaceListToolbar( + props: + | { variant: "internal" } + | { + variant: "external"; + basePath: string; + activeCategory?: Category; + categoryCounts: { category: Category; count: number }[]; + rewardTypeCounts: { + type: MarketplaceRewardType; + count: number; + }[]; + }, +) { + if (props.variant === "internal") { + return ; + } + + return ( + + ); +} diff --git a/apps/web/ui/program-marketplace/marketplace-program-detail-body.tsx b/apps/web/ui/program-marketplace/marketplace-program-detail-body.tsx new file mode 100644 index 00000000000..8f522579d53 --- /dev/null +++ b/apps/web/ui/program-marketplace/marketplace-program-detail-body.tsx @@ -0,0 +1,54 @@ +import { NetworkProgramExtendedProps } from "@/lib/types"; +import { BLOCK_COMPONENTS } from "@/ui/partners/lander/blocks"; +import { LanderHero } from "@/ui/partners/lander/lander-hero"; +import { LanderRewards } from "@/ui/partners/lander/lander-rewards"; +import { ProgramEligibilityCard } from "@/ui/partners/program-eligibility-card"; + +export function MarketplaceProgramDetailBody({ + program, + showEligibilityCard = false, +}: { + program: NetworkProgramExtendedProps; + showEligibilityCard?: boolean; +}) { + return ( + <> + + + + + {showEligibilityCard && program.applicationRequirements?.length ? ( + + ) : null} + + {program.landerData ? ( +
+ {program.landerData.blocks.map((block, idx) => { + const Component = BLOCK_COMPONENTS[block.type]; + return Component ? ( + + ) : null; + })} +
+ ) : null} + + ); +} diff --git a/apps/web/ui/program-marketplace/marketplace-program-details-layout.tsx b/apps/web/ui/program-marketplace/marketplace-program-details-layout.tsx new file mode 100644 index 00000000000..fe650552399 --- /dev/null +++ b/apps/web/ui/program-marketplace/marketplace-program-details-layout.tsx @@ -0,0 +1,22 @@ +import { ReactNode } from "react"; + +export const marketplaceProgramDetailsColumnClassName = + "mx-auto w-full max-w-screen-md"; + +export function MarketplaceProgramDetailsLayout({ + header, + hero, + children, +}: { + header?: ReactNode; + hero: ReactNode; + children: ReactNode; +}) { + return ( +
+ {header ?
{header}
: null} + {hero} +
{children}
+
+ ); +} diff --git a/apps/web/ui/program-marketplace/marketplace-program-grid.tsx b/apps/web/ui/program-marketplace/marketplace-program-grid.tsx new file mode 100644 index 00000000000..3c4acbb1736 --- /dev/null +++ b/apps/web/ui/program-marketplace/marketplace-program-grid.tsx @@ -0,0 +1,64 @@ +import { NetworkProgramProps } from "@/lib/types"; +import { cn } from "@dub/utils"; +import { + MarketplaceProgramCard, + MarketplaceProgramCardSkeleton, +} from "./program-card"; + +export function MarketplaceProgramGrid({ + programs, + showStatus = true, + className, +}: { + programs: NetworkProgramProps[]; + showStatus?: boolean; + className?: string; +}) { + return ( +
+ {programs.map((program) => ( + + ))} +
+ ); +} + +export function MarketplaceProgramGridSkeleton({ + count = 5, + className, +}: { + count?: number; + className?: string; +}) { + return ( +
+ {[...Array(count)].map((_, idx) => ( + + ))} +
+ ); +} + +export function MarketplaceProgramGridEmpty({ + message = "No programs match these filters.", +}: { + message?: string; +}) { + return
{message}
; +} diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/[programSlug]/header-controls.tsx b/apps/web/ui/program-marketplace/marketplace-program-header-controls.tsx similarity index 100% rename from apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/[programSlug]/header-controls.tsx rename to apps/web/ui/program-marketplace/marketplace-program-header-controls.tsx diff --git a/apps/web/ui/program-marketplace/marketplace-program-hero.tsx b/apps/web/ui/program-marketplace/marketplace-program-hero.tsx new file mode 100644 index 00000000000..c63238addda --- /dev/null +++ b/apps/web/ui/program-marketplace/marketplace-program-hero.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { NetworkProgramExtendedProps } from "@/lib/types"; +import { marketplaceProgramDetailsColumnClassName } from "@/ui/program-marketplace/marketplace-program-details-layout"; +import { ProgramCategory } from "@/ui/program-marketplace/program-category"; +import { getMarketplaceCategoryHref } from "@/ui/program-marketplace/utils/urls"; +import { Globe } from "@dub/ui/icons"; +import { OG_AVATAR_URL, cn, getDomainWithoutWWW } from "@dub/utils"; +import Link from "next/link"; +import { ReactNode } from "react"; +import { useImageAccentColor } from "./use-image-accent-color"; + +export function MarketplaceProgramHero({ + program, + applySlot, + className, +}: { + program: NetworkProgramExtendedProps; + applySlot?: ReactNode; + className?: string; +}) { + const hasBanner = Boolean(program.marketplaceHeaderImage); + + const { color: accentColor, ready: accentReady } = useImageAccentColor( + hasBanner ? program.marketplaceHeaderImage : null, + ); + + return ( +
+
+ + {hasBanner && program.marketplaceHeaderImage && ( +
+ {program.name} +
+ )} + +
+ {program.name} + +
+

+ {program.name} +

+ +

+ {program.description || + `${program.name} is a program in the Dub Partner Network. Join the network to start partnering with them.`} +

+
+ +
+ {Boolean(program.categories?.length) && ( +
+ + Category + +
+ {program.categories.map((category) => ( + + ))} +
+
+ )} + + {program.url && ( +
+ + Website + + + + + {getDomainWithoutWWW(program.url)} ↗ + + +
+ )} +
+ + {applySlot ?
{applySlot}
: null} +
+
+ ); +} diff --git a/apps/web/ui/program-marketplace/marketplace-program-row.tsx b/apps/web/ui/program-marketplace/marketplace-program-row.tsx new file mode 100644 index 00000000000..4972095b703 --- /dev/null +++ b/apps/web/ui/program-marketplace/marketplace-program-row.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { NetworkProgramProps } from "@/lib/types"; +import { Carousel, CarouselContent, CarouselItem, useCarousel } from "@dub/ui"; +import { ChevronLeft, ChevronRight } from "@dub/ui/icons"; +import { cn, fetcher } from "@dub/utils"; +import Link from "next/link"; +import useSWR from "swr"; +import { MarketplaceViewAllCard } from "./marketplace-view-all-card"; +import { + MarketplaceProgramCard, + MarketplaceProgramCardSkeleton, +} from "./program-card"; + +type MarketplaceProgramRowProps = { + title: string; + viewAllHref: string; + showViewAllCard?: boolean; + showStatus?: boolean; + variant?: "default" | "home"; +} & ( + | { programs: NetworkProgramProps[]; apiPath?: never } + | { apiPath: string; programs?: never } +); + +const marketplaceCarouselNavButtonClassName = + "flex size-9 shrink-0 items-center justify-center rounded-lg text-neutral-800 transition-colors hover:bg-neutral-900/5 disabled:pointer-events-none disabled:opacity-40"; + +function MarketplaceCarouselNav() { + const { scrollPrev, scrollNext, canScrollPrev, canScrollNext } = + useCarousel(); + + return ( +
+ + +
+ ); +} + +export function MarketplaceProgramRow({ + title, + viewAllHref, + showViewAllCard = false, + showStatus = true, + variant = "default", + ...props +}: MarketplaceProgramRowProps) { + const { data: fetchedPrograms, error } = useSWR( + "apiPath" in props && props.apiPath ? props.apiPath : null, + fetcher, + { revalidateOnFocus: false, keepPreviousData: true }, + ); + + const programs = "programs" in props ? props.programs : fetchedPrograms; + const isHome = variant === "home"; + + const carouselItemClassName = cn( + "pl-0", + isHome + ? "basis-[310px] sm:basis-[419px]" + : "basis-[280px] md:basis-[320px]", + ); + + const cardClassName = isHome + ? "h-[260px] w-[310px] sm:h-[284px] sm:w-[419px] sm:p-8" + : undefined; + + if (error || programs?.length === 0) { + return null; + } + + return ( +
+ +
+

+ {title} +

+
+ + View all + + + +
+
+ + + {programs ? ( + <> + {programs.map((program) => ( + + + + ))} + {showViewAllCard ? ( + + + + ) : null} + + ) : ( + [...Array(3)].map((_, idx) => ( + + + + )) + )} + +
+
+ ); +} diff --git a/apps/web/ui/program-marketplace/marketplace-router.tsx b/apps/web/ui/program-marketplace/marketplace-router.tsx new file mode 100644 index 00000000000..c8a34594a4e --- /dev/null +++ b/apps/web/ui/program-marketplace/marketplace-router.tsx @@ -0,0 +1,80 @@ +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 { Category } from "@dub/prisma/client"; +import { ChevronRight, Shop } from "@dub/ui"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ReactNode } from "react"; +import { MarketplaceHomePage } from "./pages/marketplace-home-page"; +import { MarketplaceProgramPage } from "./pages/marketplace-program-page"; +import { MarketplaceProgramsListPage } from "./pages/marketplace-programs-list-page"; + +function MarketplaceListTitle({ category }: { category?: Category }) { + const title = category + ? PROGRAM_CATEGORIES_MAP[category]?.label ?? category.replaceAll("_", " ") + : "All Programs"; + + return ( +
+
+ + + + +
+ + {title} + +
+ ); +} + +export function MarketplaceRouter({ 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(); +} + +function ListPage({ title }: { title: ReactNode }) { + return ( + + + + + + ); +} diff --git a/apps/web/ui/program-marketplace/marketplace-sidebar-filters.tsx b/apps/web/ui/program-marketplace/marketplace-sidebar-filters.tsx new file mode 100644 index 00000000000..a311f4f13ee --- /dev/null +++ b/apps/web/ui/program-marketplace/marketplace-sidebar-filters.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { getMarketplaceHref } from "@/ui/program-marketplace/utils/urls"; +import { ChevronLeft, Filter } from "@dub/ui"; +import { cn } from "@dub/utils"; +import Link from "next/link"; +import { useProgramNetworkFilters } from "./use-program-network-filters"; + +export function MarketplaceSidebarFilters() { + const { filters, activeFilters, onSelect, onRemove } = + useProgramNetworkFilters(); + + return ( +
+ + + Program marketplace + + +
+ ); +} diff --git a/apps/web/ui/program-marketplace/marketplace-sidebar-panel.tsx b/apps/web/ui/program-marketplace/marketplace-sidebar-panel.tsx new file mode 100644 index 00000000000..229ccbc3ff4 --- /dev/null +++ b/apps/web/ui/program-marketplace/marketplace-sidebar-panel.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { ProgramsAreaNav } from "@/ui/layout/sidebar/programs-area-nav"; +import { useMediaQuery } from "@dub/ui"; +import { MarketplaceSidebarFilters } from "./marketplace-sidebar-filters"; + +export function MarketplaceSidebarPanel({ + invitationsCount, +}: { + invitationsCount?: number; +}) { + const { isDesktop } = useMediaQuery(); + + if (!isDesktop) { + return ; + } + + return ; +} diff --git a/apps/web/ui/program-marketplace/marketplace-view-all-card.tsx b/apps/web/ui/program-marketplace/marketplace-view-all-card.tsx new file mode 100644 index 00000000000..68c34e59791 --- /dev/null +++ b/apps/web/ui/program-marketplace/marketplace-view-all-card.tsx @@ -0,0 +1,50 @@ +import { ProgramMarketplaceLogosCluster } from "@/ui/program-marketplace/program-marketplace-logos"; +import { getMarketplaceAllHref } from "@/ui/program-marketplace/utils/urls"; +import { Grid } from "@dub/ui"; +import { cn } from "@dub/utils"; +import Link from "next/link"; +import type { CSSProperties } from "react"; + +const VIEW_ALL_GRID_MASK = + "radial-gradient(ellipse 85% 70% at 50% 44%, white 10%, rgba(255,255,255,0.2) 60%, transparent 62%)"; + +export function MarketplaceViewAllCard({ + href = getMarketplaceAllHref(), + className, +}: { + href?: string; + className?: string; +}) { + return ( + +
+ +
+ +
+ +
+ + + View all + + + ); +} diff --git a/apps/web/ui/program-marketplace/pages/marketplace-home-page.tsx b/apps/web/ui/program-marketplace/pages/marketplace-home-page.tsx new file mode 100644 index 00000000000..87b3398dc47 --- /dev/null +++ b/apps/web/ui/program-marketplace/pages/marketplace-home-page.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { FeaturedPrograms } from "../featured-programs"; +import { MARKETPLACE_HOME_ROWS } from "../home-sections"; +import { MarketplaceCategories } from "../marketplace-categories"; +import { MarketplaceProgramRow } from "../marketplace-program-row"; + +export function MarketplaceHomePage() { + return ( +
+ + + {MARKETPLACE_HOME_ROWS.map((row) => ( + + ))} +
+ ); +} diff --git a/apps/web/ui/program-marketplace/pages/marketplace-program-page.tsx b/apps/web/ui/program-marketplace/pages/marketplace-program-page.tsx new file mode 100644 index 00000000000..10fd56804ca --- /dev/null +++ b/apps/web/ui/program-marketplace/pages/marketplace-program-page.tsx @@ -0,0 +1,83 @@ +import { getNetworkProgram } from "@/lib/fetchers/get-network-program"; +import { ApplicationAnalytics } from "@/ui/application-analytics"; +import { PageContent } from "@/ui/layout/page-content"; +import { PageWidthWrapper } from "@/ui/layout/page-width-wrapper"; +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 { prisma } from "@dub/prisma"; +import { ChevronRight, Shop } from "@dub/ui"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { MarketplaceProgramHeaderControls } from "../marketplace-program-header-controls"; +import { ProgramStatusBadge } from "../program-status-badge"; +import { getMarketplaceHref } from "../utils/urls"; + +export const revalidate = 3600; // 1 hour + +export async function generateMarketplaceProgramStaticParams() { + const programs = await prisma.program.findMany({ + where: { + addedToMarketplaceAt: { + not: null, + }, + }, + select: { + slug: true, + }, + }); + + return programs.map((program) => ({ + slug: [program.slug], + })); +} + +export async function MarketplaceProgramPage({ + programSlug, +}: { + programSlug: string; +}) { + const program = await getNetworkProgram({ + slug: programSlug, + }); + + if (!program) { + redirect(getMarketplaceHref()); + } + + return ( + +
+ + + + +
+ +
+ + Program details + + +
+
+ } + controls={} + > + + + } + > + + + + + ); +} diff --git a/apps/web/ui/program-marketplace/pages/marketplace-programs-list-page.tsx b/apps/web/ui/program-marketplace/pages/marketplace-programs-list-page.tsx new file mode 100644 index 00000000000..83b598f2235 --- /dev/null +++ b/apps/web/ui/program-marketplace/pages/marketplace-programs-list-page.tsx @@ -0,0 +1,89 @@ +"use client"; + +import useNetworkProgramsCount from "@/lib/swr/use-network-programs-count"; +import { NetworkProgramProps } from "@/lib/types"; +import { PROGRAM_NETWORK_MAX_PAGE_SIZE } from "@/lib/zod/schemas/program-network"; +import { PaginationControls, usePagination, useRouterStuff } from "@dub/ui"; +import { cn, fetcher } from "@dub/utils"; +import { usePathname } from "next/navigation"; +import useSWR from "swr"; +import { MarketplaceEmptyState } from "../marketplace-empty-state"; +import { MarketplaceListToolbar } from "../marketplace-list-toolbar"; +import { + MarketplaceProgramGrid, + MarketplaceProgramGridSkeleton, +} from "../marketplace-program-grid"; +import { useProgramNetworkFilters } from "../use-program-network-filters"; +import { getMarketplaceCategoryFromPathname } from "../utils/urls"; + +export function MarketplaceProgramsListPage() { + const pathname = usePathname(); + const { getQueryString, searchParamsObj } = useRouterStuff(); + + const categoryParam = getMarketplaceCategoryFromPathname(pathname); + + const queryString = getQueryString( + categoryParam ? { category: categoryParam } : undefined, + ); + + const { data: programsCount, error: countError } = useNetworkProgramsCount({ + query: categoryParam ? { category: categoryParam } : undefined, + }); + + const { + data: programs, + error, + isValidating, + } = useSWR( + `/api/network/programs${queryString}`, + fetcher, + { revalidateOnFocus: false, keepPreviousData: true }, + ); + + const { pagination, setPagination } = usePagination( + PROGRAM_NETWORK_MAX_PAGE_SIZE, + ); + + const { activeFilters, isFiltered, onRemoveAll } = useProgramNetworkFilters(); + + const hasActiveFilters = + activeFilters.length > 0 || Boolean(searchParamsObj.search); + + return ( +
+ + + {error || countError ? ( +
+ Failed to load programs +
+ ) : !programs || programs?.length ? ( +
+
+ {programs ? ( + + ) : ( + + )} +
+
+ `program${p ? "s" : ""}`} + /> +
+
+ ) : ( + + )} +
+ ); +} diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/program-card.tsx b/apps/web/ui/program-marketplace/program-card.tsx similarity index 60% rename from apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/program-card.tsx rename to apps/web/ui/program-marketplace/program-card.tsx index c4e1154b245..fb3d87dedf1 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/program-card.tsx +++ b/apps/web/ui/program-marketplace/program-card.tsx @@ -1,44 +1,59 @@ +"use client"; + import { NetworkProgramProps } from "@/lib/types"; -import { ProgramCategory } from "@/ui/partners/program-marketplace/program-category"; -import { ProgramRewardsDisplay } from "@/ui/partners/program-marketplace/program-rewards-display"; -import { Tooltip, useRouterStuff } from "@dub/ui"; -import { OG_AVATAR_URL } from "@dub/utils"; +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 { OG_AVATAR_URL, cn } from "@dub/utils"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { ProgramStatusBadge } from "./program-status-badge"; export function MarketplaceProgramCard({ program, + showStatus = true, + className, }: { program: NetworkProgramProps; + showStatus?: boolean; + className?: string; }) { - const { queryParams } = useRouterStuff(); + const router = useRouter(); return (
{program.name} - + {showStatus ? : null}
-
- +
+

{program.name} - +

{program.description || `${program.name} is a program in the Dub Partner Network. Join the network to start partnering with them.`}
-
+
{Boolean(program.rewards?.length) && (
@@ -47,37 +62,31 @@ export function MarketplaceProgramCard({ - queryParams({ - set: { - rewardType: reward.event, - }, - del: "page", - }) + router.push( + getMarketplaceAllHref({ rewardType: reward.event }), + ) } - className="mt-1" + className="mt-2" />
)} {Boolean(program.categories.length) && ( -
+
Category -
- {program.categories.slice(0, 1)?.map((category) => ( - - queryParams({ - set: { - category, - }, - del: "page", - }) - } - /> - ))} +
+ {program.categories + .slice(0, 1) + ?.map((category) => ( + + router.push(getMarketplaceCategoryHref(category)) + } + /> + ))} {program.categories.length > 1 && ( - queryParams({ - set: { - category, - }, - del: "page", - }) + router.push(getMarketplaceCategoryHref(category)) } /> ))} @@ -113,9 +117,18 @@ export function MarketplaceProgramCard({ ); } -export function MarketplaceProgramCardSkeleton() { +export function MarketplaceProgramCardSkeleton({ + className, +}: { + className?: string; +} = {}) { return ( -
+
diff --git a/apps/web/ui/program-marketplace/program-category.tsx b/apps/web/ui/program-marketplace/program-category.tsx new file mode 100644 index 00000000000..2d81fdf0a31 --- /dev/null +++ b/apps/web/ui/program-marketplace/program-category.tsx @@ -0,0 +1,96 @@ +import { PROGRAM_CATEGORIES_MAP } from "@/lib/network/program-categories"; +import { Category } from "@dub/prisma/client"; +import { CircleInfo } from "@dub/ui"; +import { cn } from "@dub/utils"; +import Link from "next/link"; + +export const programCategorySurfaceClassName = + "inline-flex h-5 max-h-5 min-w-0 items-center gap-1 rounded-full bg-neutral-900/[0.06] px-2 text-xs font-medium text-neutral-900 shadow-[0_0.5px_1px_0_rgba(0,0,0,0.03)] ring-1 ring-inset ring-neutral-900/[0.05] backdrop-blur-[3px] backdrop-saturate-150 transition-[background-color,transform] duration-150 ease-out [@media(hover:hover)]:hover:bg-neutral-900/[0.09] active:scale-[0.97] motion-reduce:transition-none motion-reduce:active:scale-100 [@media(hover:none)]:hover:bg-neutral-900/[0.06]"; + +export const ProgramCategory = ({ + category, + onClick, + href, + variant = "default", + className, +}: { + category: Category; + onClick?: () => void; + href?: string; + variant?: "default" | "pill" | "surface"; + className?: string; +}) => { + const categoryData = PROGRAM_CATEGORIES_MAP[category]; + const { icon: Icon, label } = categoryData ?? { + icon: CircleInfo, + label: category.replaceAll("_", " "), + }; + + const sharedClassName = cn( + variant === "surface" + ? programCategorySurfaceClassName + : variant === "pill" + ? "inline-flex min-w-0 items-center gap-1.5 rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-600" + : "text-content-default -ml-1 flex h-6 min-w-0 items-center gap-1 rounded-md px-1", + (href || onClick) && + (variant === "pill" + ? "hover:bg-neutral-200/80 active:bg-neutral-200" + : variant === "default" + ? "hover:bg-bg-subtle active:bg-bg-emphasis" + : undefined), + className, + ); + + const content = ( + <> + + + {label} + + + ); + + if (href) { + return ( + + {content} + + ); + } + + if (onClick) { + return ( + + ); + } + + return
{content}
; +}; diff --git a/apps/web/ui/partners/program-marketplace/program-marketplace-banner.tsx b/apps/web/ui/program-marketplace/program-marketplace-banner.tsx similarity index 95% rename from apps/web/ui/partners/program-marketplace/program-marketplace-banner.tsx rename to apps/web/ui/program-marketplace/program-marketplace-banner.tsx index 3f0e4fa7e6e..872502285d2 100644 --- a/apps/web/ui/partners/program-marketplace/program-marketplace-banner.tsx +++ b/apps/web/ui/program-marketplace/program-marketplace-banner.tsx @@ -1,7 +1,8 @@ "use client"; import usePartnerProfile from "@/lib/swr/use-partner-profile"; -import { useProgramMarketplacePromo } from "@/ui/partners/program-marketplace/use-program-marketplace-promo"; +import { useProgramMarketplacePromo } from "@/ui/program-marketplace/use-program-marketplace-promo"; +import { getMarketplaceHref } from "@/ui/program-marketplace/utils/urls"; import { X } from "@/ui/shared/icons"; import { Button, Grid, buttonVariants } from "@dub/ui"; import { cn } from "@dub/utils"; @@ -68,7 +69,7 @@ export function ProgramMarketplaceBanner() {
- {!pathname.endsWith("/programs/marketplace") && ( + {!pathname.endsWith("/marketplace") && ( + ); +} + +// Framer, Perplexity, Wispr Flow +const VIEW_ALL_CLUSTER = { + originLeft: 138.29, + originTop: 56, + width: 137.03, + height: 120.75, +} as const; + +const VIEW_ALL_LOGOS = [ + { + index: 10, + left: 156 - VIEW_ALL_CLUSTER.originLeft, + top: 56 - VIEW_ALL_CLUSTER.originTop, + size: 71, + rotation: 11.4, + ringClassName: "ring-[1px]", + zIndex: 0, + hoverClassName: "group-hover:-translate-y-1", + }, + { + index: 0, + left: 204.32 - VIEW_ALL_CLUSTER.originLeft, + top: 74.82 - VIEW_ALL_CLUSTER.originTop, + size: 71, + rotation: -22.07, + ringClassName: "ring-4", + zIndex: 10, + hoverClassName: "group-hover:translate-x-1 group-hover:translate-y-0.5", + }, + { + index: 5, + left: 138.29 - VIEW_ALL_CLUSTER.originLeft, + top: 96 - VIEW_ALL_CLUSTER.originTop, + size: 80.75, + rotation: 4.84, + ringClassName: "ring-4", + zIndex: 20, + hoverClassName: "group-hover:-translate-x-1 group-hover:translate-y-0.5", + }, +] as const; + +export function ProgramMarketplaceLogosCluster({ + className, +}: { + className?: string; +}) { + return ( +
+ {VIEW_ALL_LOGOS.map( + ({ + index, + left, + top, + size, + rotation, + ringClassName, + zIndex, + hoverClassName, + }) => ( +
+ +
+ ), + )} +
+ ); +} + +export function ProgramMarketplaceLogos({ className }: { className?: string }) { + return ( +
+ {LOGOS.map(({ x, y, r, s, z }, index) => { + return ( +
+ +
+ ); + })} +
+ ); +} diff --git a/apps/web/ui/partners/program-marketplace/program-reward-icon.tsx b/apps/web/ui/program-marketplace/program-reward-icon.tsx similarity index 52% rename from apps/web/ui/partners/program-marketplace/program-reward-icon.tsx rename to apps/web/ui/program-marketplace/program-reward-icon.tsx index 1316989a32b..3463e12f2d8 100644 --- a/apps/web/ui/partners/program-marketplace/program-reward-icon.tsx +++ b/apps/web/ui/program-marketplace/program-reward-icon.tsx @@ -7,13 +7,24 @@ export const ProgramRewardIcon = ({ description, onClick, className, + iconClassName, }: { icon: Icon; description: string; onClick?: () => void; className?: string; + iconClassName?: string; }) => { - const As = onClick ? "button" : "div"; + const iconSurface = ( + + + + ); return ( @@ -27,24 +38,27 @@ export const ProgramRewardIcon = ({ {description} - - { + + {onClick ? ( + + ) : ( +
+ {iconSurface} +
+ )}
); diff --git a/apps/web/ui/partners/program-marketplace/program-rewards-display.tsx b/apps/web/ui/program-marketplace/program-rewards-display.tsx similarity index 85% rename from apps/web/ui/partners/program-marketplace/program-rewards-display.tsx rename to apps/web/ui/program-marketplace/program-rewards-display.tsx index 4232b60462c..9fe4a7f359d 100644 --- a/apps/web/ui/partners/program-marketplace/program-rewards-display.tsx +++ b/apps/web/ui/program-marketplace/program-rewards-display.tsx @@ -1,10 +1,10 @@ import { DiscountProps, RewardProps } 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 { Gift, Icon } from "@dub/ui"; import { cn } from "@dub/utils"; import * as HoverCard from "@radix-ui/react-hover-card"; -import { REWARD_EVENT_ICON } from "../rewards/reward-event-icon"; import { ProgramRewardIcon } from "./program-reward-icon"; type RewardItem = { @@ -18,6 +18,7 @@ interface ProgramRewardsDisplayProps { rewards?: RewardProps[] | null; discount?: DiscountProps | null; isDarkImage?: boolean; + iconsOnly?: boolean; className?: string; onRewardClick?: (reward: RewardProps) => void; onDiscountClick?: (discount: DiscountProps) => void; @@ -29,6 +30,7 @@ export function ProgramRewardsDisplay({ rewards, discount, isDarkImage = false, + iconsOnly = false, className, onRewardClick, onDiscountClick, @@ -65,6 +67,23 @@ export function ProgramRewardsDisplay({ // shouldn't happen, but just in case if (items.length === 0) return null; + if (iconsOnly) { + return ( +
+ {items.map((item) => ( + + ))} +
+ ); + } + // If there's only one item, show the full description if (items.length === 1) { const item = items[0]; @@ -108,7 +127,7 @@ export function ProgramRewardsDisplay({
s.value === searchParams.get("sortBy") && s.order === sortOrder, + ) ?? MARKETPLACE_SORT_OPTIONS[0]; + + return ( + + {MARKETPLACE_SORT_OPTIONS.map( + ({ label, value, order, icon: Icon }) => ( + + ), + )} +
+ } + openPopover={openPopover} + setOpenPopover={setOpenPopover} + > + + + ); +} diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/program-status-badge.tsx b/apps/web/ui/program-marketplace/program-status-badge.tsx similarity index 100% rename from apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/program-status-badge.tsx rename to apps/web/ui/program-marketplace/program-status-badge.tsx diff --git a/apps/web/ui/partners/program-marketplace/programs-promo-banner.tsx b/apps/web/ui/program-marketplace/programs-promo-banner.tsx similarity index 84% rename from apps/web/ui/partners/program-marketplace/programs-promo-banner.tsx rename to apps/web/ui/program-marketplace/programs-promo-banner.tsx index 577d8bac9a8..0debb0c3bde 100644 --- a/apps/web/ui/partners/program-marketplace/programs-promo-banner.tsx +++ b/apps/web/ui/program-marketplace/programs-promo-banner.tsx @@ -2,7 +2,7 @@ import usePartnerProfile from "@/lib/swr/use-partner-profile"; import { IdentityVerificationBanner } from "@/ui/partners/identity-verification/identity-verification-banner"; -import { ProgramMarketplaceBanner } from "@/ui/partners/program-marketplace/program-marketplace-banner"; +import { ProgramMarketplaceBanner } from "@/ui/program-marketplace/program-marketplace-banner"; // Single promo banner slot for the programs page export function ProgramsPromoBanner() { diff --git a/apps/web/ui/partners/program-marketplace/programs-promo-card.tsx b/apps/web/ui/program-marketplace/programs-promo-card.tsx similarity index 84% rename from apps/web/ui/partners/program-marketplace/programs-promo-card.tsx rename to apps/web/ui/program-marketplace/programs-promo-card.tsx index 81ddf9f8b50..93023641740 100644 --- a/apps/web/ui/partners/program-marketplace/programs-promo-card.tsx +++ b/apps/web/ui/program-marketplace/programs-promo-card.tsx @@ -2,7 +2,7 @@ import usePartnerProfile from "@/lib/swr/use-partner-profile"; import { IdentityVerificationCard } from "@/ui/partners/identity-verification/identity-verification-card"; -import { ProgramMarketplaceCard } from "@/ui/partners/program-marketplace/program-marketplace-card"; +import { ProgramMarketplaceCard } from "@/ui/program-marketplace/program-marketplace-card"; // Single promo card slot for the sidebar export function ProgramsPromoCard() { diff --git a/apps/web/ui/program-marketplace/use-image-accent-color.ts b/apps/web/ui/program-marketplace/use-image-accent-color.ts new file mode 100644 index 00000000000..f157b151a4d --- /dev/null +++ b/apps/web/ui/program-marketplace/use-image-accent-color.ts @@ -0,0 +1,128 @@ +"use client"; + +import { useEffect, useState } from "react"; + +// Mix a color toward white to produce a soft, "50"-level tint that keeps +// dark content readable on top. +function rgbToTint(r: number, g: number, b: number, whiteMix = 0.88) { + const mix = (c: number) => Math.round(c + (255 - c) * whiteMix); + return `rgb(${mix(r)}, ${mix(g)}, ${mix(b)})`; +} + +function saturation(r: number, g: number, b: number) { + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + return max === 0 ? 0 : (max - min) / max; +} + +function extractTint(img: HTMLImageElement): string | null { + const size = 32; + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext("2d", { willReadFrequently: true }); + if (!ctx) return null; + + ctx.drawImage(img, 0, 0, size, size); + const { data } = ctx.getImageData(0, 0, size, size); + + const buckets = new Map< + string, + { count: number; r: number; g: number; b: number } + >(); + + for (let i = 0; i < data.length; i += 4) { + if (data[i + 3] < 125) continue; // skip transparent + + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + + // Ignore near-white and near-black so backgrounds/sky don't win. + if (r > 232 && g > 232 && b > 232) continue; + if (r < 24 && g < 24 && b < 24) continue; + + const key = `${r >> 5}-${g >> 5}-${b >> 5}`; + const bucket = buckets.get(key) ?? { count: 0, r: 0, g: 0, b: 0 }; + bucket.count++; + bucket.r += r; + bucket.g += g; + bucket.b += b; + buckets.set(key, bucket); + } + + let best: { r: number; g: number; b: number } | null = null; + let bestScore = -1; + + for (const bucket of buckets.values()) { + const r = bucket.r / bucket.count; + const g = bucket.g / bucket.count; + const b = bucket.b / bucket.count; + // Favor vivid colors over merely frequent muddy ones. + const score = bucket.count * (0.2 + saturation(r, g, b)); + if (score > bestScore) { + bestScore = score; + best = { r, g, b }; + } + } + + return best ? rgbToTint(best.r, best.g, best.b) : null; +} + +// Persist results across mounts so an image is only decoded once per session. +// `null` means "extracted but no usable color"; `undefined` means "not computed". +const cache = new Map(); + +export type ImageAccentColor = { + color: string | null; + ready: boolean; +}; + +export function useImageAccentColor(src?: string | null): ImageAccentColor { + const [state, setState] = useState(() => { + if (src && cache.has(src)) { + return { color: cache.get(src) ?? null, ready: true }; + } + return { color: null, ready: false }; + }); + + useEffect(() => { + if (!src) { + setState({ color: null, ready: true }); + return; + } + + if (cache.has(src)) { + setState({ color: cache.get(src) ?? null, ready: true }); + return; + } + + setState({ color: null, ready: false }); + + let cancelled = false; + const img = new Image(); + img.crossOrigin = "anonymous"; + + const finish = (color: string | null) => { + cache.set(src, color); + if (!cancelled) setState({ color, ready: true }); + }; + + img.onload = () => { + try { + finish(extractTint(img)); + } catch { + finish(null); + } + }; + img.onerror = () => finish(null); + + img.src = src; + + return () => { + cancelled = true; + }; + }, [src]); + + return state; +} diff --git a/apps/web/ui/partners/program-marketplace/use-program-marketplace-promo.tsx b/apps/web/ui/program-marketplace/use-program-marketplace-promo.tsx similarity index 100% rename from apps/web/ui/partners/program-marketplace/use-program-marketplace-promo.tsx rename to apps/web/ui/program-marketplace/use-program-marketplace-promo.tsx diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/use-program-network-filters.tsx b/apps/web/ui/program-marketplace/use-program-network-filters.tsx similarity index 53% rename from apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/use-program-network-filters.tsx rename to apps/web/ui/program-marketplace/use-program-network-filters.tsx index 9111483d1e5..2dc782bf9fe 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/marketplace/use-program-network-filters.tsx +++ b/apps/web/ui/program-marketplace/use-program-network-filters.tsx @@ -1,34 +1,27 @@ import { PROGRAM_CATEGORIES_MAP } from "@/lib/network/program-categories"; import useNetworkProgramsCount from "@/lib/swr/use-network-programs-count"; -import { REWARD_EVENT_ICON } from "@/ui/partners/rewards/reward-event-icon"; +import { Category } from "@dub/prisma/client"; import { useRouterStuff } from "@dub/ui"; import { CircleDotted, Gift, Suitcase } from "@dub/ui/icons"; -import { capitalize, cn, nFormatter } from "@dub/utils"; +import { capitalize, nFormatter } from "@dub/utils"; +import { usePathname, useRouter } from "next/navigation"; import { useCallback, useMemo } from "react"; +import { MARKETPLACE_REWARD_TYPES } from "./constants"; import { ProgramNetworkStatusBadges } from "./program-status-badge"; - -const REWARD_TYPES = { - sale: { - icon: REWARD_EVENT_ICON.sale, - label: "Sale reward (CPS)", - }, - lead: { - icon: REWARD_EVENT_ICON.lead, - label: "Lead reward (CPL)", - }, - click: { - icon: REWARD_EVENT_ICON.click, - label: "Click reward (CPC)", - }, - discount: { - icon: Gift, - label: "Dual-sided incentives", - }, -}; +import { + getMarketplaceAllHref, + getMarketplaceCategoryFromPathname, + getMarketplaceCategoryHref, + getPreservedMarketplaceSearchParams, +} from "./utils/urls"; export function useProgramNetworkFilters() { + const router = useRouter(); + const pathname = usePathname(); const { searchParamsObj, queryParams } = useRouterStuff(); + const routeCategory = getMarketplaceCategoryFromPathname(pathname); + const { data: categoriesCount } = useNetworkProgramsCount< | { category: string; @@ -74,11 +67,11 @@ export function useProgramNetworkFilters() { key: "rewardType", icon: Gift, label: "Reward type", - options: Object.entries(REWARD_TYPES).map( - ([key, { label, icon: Icon }]) => ({ + singleSelect: true, + options: Object.entries(MARKETPLACE_REWARD_TYPES).map( + ([key, label]) => ({ value: key, label, - icon: , right: nFormatter( rewardTypesCount?.find(({ type }) => type === key)?._count || 0, { full: true }, @@ -91,10 +84,7 @@ export function useProgramNetworkFilters() { icon: Suitcase, label: "Category", labelPlural: "categories", - getOptionIcon: (value) => { - const Icon = PROGRAM_CATEGORIES_MAP[value]?.icon || Suitcase; - return ; - }, + singleSelect: true, getOptionLabel: (value) => PROGRAM_CATEGORIES_MAP[value]?.label || value.replaceAll("_", " "), options: @@ -108,25 +98,18 @@ export function useProgramNetworkFilters() { key: "status", icon: CircleDotted, label: "Status", + singleSelect: true, options: statusCount?.map(({ status, _count }) => { - const { - label, - icon: Icon, - className, - } = status - ? ProgramNetworkStatusBadges[status] - : { - label: "Not applied", - icon: CircleDotted, - className: "text-neutral-500", - }; + const label = status + ? ProgramNetworkStatusBadges[ + status as keyof typeof ProgramNetworkStatusBadges + ]?.label ?? capitalize(status) + : "Not applied"; + return { value: status ?? "null", - label: label || capitalize(status), - icon: ( - - ), + label, right: nFormatter(_count, { full: true }), }; }) ?? null, @@ -136,42 +119,89 @@ export function useProgramNetworkFilters() { ); const activeFilters = useMemo(() => { - const { rewardType, category, status } = searchParamsObj; + const { rewardType, status } = searchParamsObj; return [ ...(rewardType ? [{ key: "rewardType", value: rewardType }] : []), - ...(category ? [{ key: "category", value: category }] : []), + ...(routeCategory ? [{ key: "category", value: routeCategory }] : []), ...(status ? [{ key: "status", value: status }] : []), ]; - }, [searchParamsObj]); + }, [routeCategory, searchParamsObj]); + + const setCategoryFilter = useCallback( + (category: Category | null) => { + const preserved = getPreservedMarketplaceSearchParams(searchParamsObj); + + router.replace( + category + ? getMarketplaceCategoryHref(category, preserved) + : getMarketplaceAllHref(preserved), + ); + }, + [router, searchParamsObj], + ); const onSelect = useCallback( - (key: string, value: any) => + (key: string, value: string) => { + if (key === "category") { + setCategoryFilter(value as Category); + return; + } + queryParams({ set: { [key]: value, }, del: "page", - }), - [queryParams], + }); + }, + [queryParams, setCategoryFilter], ); const onRemove = useCallback( - (key: string) => { + (key: string, _value?: string) => { + if (key === "category") { + setCategoryFilter(null); + return; + } + queryParams({ del: [key, "page"], }); }, - [queryParams], + [queryParams, setCategoryFilter], ); - const onRemoveAll = useCallback( - () => - queryParams({ - del: ["rewardType", "category", "status", "search", "page"], - }), - [queryParams], - ); + const onClearFilters = useCallback(() => { + const preserved = getPreservedMarketplaceSearchParams(searchParamsObj); + + if (routeCategory) { + router.replace( + getMarketplaceAllHref({ + rewardType: preserved.rewardType, + search: preserved.search, + sortBy: preserved.sortBy, + sortOrder: preserved.sortOrder, + }), + ); + return; + } + + queryParams({ + del: ["rewardType", "category", "status", "page"], + }); + }, [queryParams, routeCategory, router, searchParamsObj]); + + const onRemoveAll = useCallback(() => { + if (routeCategory) { + router.replace(getMarketplaceAllHref()); + return; + } + + queryParams({ + del: ["rewardType", "category", "status", "search", "page"], + }); + }, [queryParams, routeCategory, router]); const isFiltered = Boolean( activeFilters.length > 0 || searchParamsObj.search, @@ -182,6 +212,7 @@ export function useProgramNetworkFilters() { activeFilters, onSelect, onRemove, + onClearFilters, onRemoveAll, isFiltered, }; diff --git a/apps/web/ui/program-marketplace/use-public-marketplace-filters.tsx b/apps/web/ui/program-marketplace/use-public-marketplace-filters.tsx new file mode 100644 index 00000000000..272057fc234 --- /dev/null +++ b/apps/web/ui/program-marketplace/use-public-marketplace-filters.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { PROGRAM_CATEGORIES_MAP } from "@/lib/network/program-categories"; +import { Category } from "@dub/prisma/client"; +import { useRouterStuff } from "@dub/ui"; +import { Gift, Suitcase } from "@dub/ui/icons"; +import { useRouter } from "next/navigation"; +import { useCallback, useMemo } from "react"; +import { + MARKETPLACE_REWARD_TYPES, + type MarketplaceRewardType, +} from "./constants"; +import { + buildExternalMarketplaceFilterHref, + getMarketplaceAllHref, +} from "./utils/urls"; + +export function usePublicMarketplaceFilters({ + basePath, + activeCategory, + categoryCounts, + rewardTypeCounts, +}: { + basePath: string; + activeCategory?: Category; + categoryCounts: { category: Category; count: number }[]; + rewardTypeCounts: { + type: MarketplaceRewardType; + count: number; + }[]; +}) { + const router = useRouter(); + const { searchParamsObj } = useRouterStuff(); + + const search = + typeof searchParamsObj.search === "string" + ? searchParamsObj.search + : undefined; + const sortBy = + typeof searchParamsObj.sortBy === "string" + ? searchParamsObj.sortBy + : undefined; + const sortOrder = + typeof searchParamsObj.sortOrder === "string" + ? searchParamsObj.sortOrder + : undefined; + const activeRewardType = searchParamsObj.rewardType as + | MarketplaceRewardType + | undefined; + + const filters = useMemo( + () => [ + { + key: "rewardType", + icon: Gift, + label: "Reward type", + singleSelect: true, + options: rewardTypeCounts.map(({ type, count }) => ({ + value: type, + label: MARKETPLACE_REWARD_TYPES[type], + right: String(count), + })), + }, + { + key: "category", + icon: Suitcase, + label: "Category", + singleSelect: true, + options: categoryCounts.map(({ category, count }) => ({ + value: category, + label: + PROGRAM_CATEGORIES_MAP[category]?.label ?? + category.replaceAll("_", " "), + right: String(count), + })), + }, + ], + [categoryCounts, rewardTypeCounts], + ); + + const activeFilters = useMemo(() => { + return [ + ...(activeRewardType + ? [{ key: "rewardType", value: activeRewardType }] + : []), + ...(activeCategory ? [{ key: "category", value: activeCategory }] : []), + ]; + }, [activeCategory, activeRewardType]); + + const buildHref = useCallback( + (params: { + category?: Category | null; + rewardType?: MarketplaceRewardType | null; + }) => + buildExternalMarketplaceFilterHref({ + basePath, + activeRewardType, + search, + sortBy, + sortOrder, + ...params, + }), + [activeRewardType, basePath, search, sortBy, sortOrder], + ); + + const onSelect = useCallback( + (key: string, value: string) => { + if (key === "category") { + router.push(buildHref({ category: value as Category })); + return; + } + + if (key === "rewardType") { + router.push( + buildHref({ + rewardType: value as MarketplaceRewardType, + }), + ); + } + }, + [activeRewardType, buildHref, router], + ); + + const onRemove = useCallback( + (key: string) => { + if (key === "category") { + router.push(buildHref({ category: null })); + return; + } + + if (key === "rewardType") { + router.push(buildHref({ rewardType: null })); + } + }, + [buildHref, router], + ); + + const onClearFilters = useCallback(() => { + router.push( + getMarketplaceAllHref({ + search, + }), + ); + }, [router, search]); + + const onSortChange = useCallback( + (nextSortBy: string, nextSortOrder: string) => { + router.push( + buildExternalMarketplaceFilterHref({ + basePath, + activeRewardType, + search, + sortBy: nextSortBy, + sortOrder: nextSortOrder, + category: activeCategory, + rewardType: activeRewardType, + }), + ); + }, + [activeCategory, activeRewardType, basePath, router, search], + ); + + return { + filters, + activeFilters, + onSelect, + onRemove, + onClearFilters, + onSortChange, + }; +} diff --git a/apps/web/ui/program-marketplace/utils/urls.ts b/apps/web/ui/program-marketplace/utils/urls.ts new file mode 100644 index 00000000000..93e6968c508 --- /dev/null +++ b/apps/web/ui/program-marketplace/utils/urls.ts @@ -0,0 +1,224 @@ +import { MarketplaceRewardType } from "@/ui/program-marketplace/constants"; +import { Category } from "@dub/prisma/client"; + +const MARKETPLACE_BASE = "/marketplace"; + +export const MARKETPLACE_RESERVED_SLUGS = new Set(["all", "popular", "c"]); + +export function categoryToSlug(category: Category): string { + return category.toLowerCase().replaceAll("_", "-"); +} + +export function slugToCategory(slug: string): Category | null { + if (MARKETPLACE_RESERVED_SLUGS.has(slug)) { + return null; + } + + const normalizedSlug = slug.toLowerCase(); + + return ( + (Object.values(Category) as Category[]).find( + (category) => categoryToSlug(category) === normalizedSlug, + ) ?? null + ); +} + +function buildMarketplaceHref( + path: string, + params?: Record, +) { + const searchParams = new URLSearchParams(); + + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value) { + searchParams.set(key, value); + } + }); + } + + const queryString = searchParams.toString(); + + return `${path}${queryString ? `?${queryString}` : ""}`; +} + +export function getMarketplaceHref() { + return MARKETPLACE_BASE; +} + +export function getMarketplaceAllHref( + params?: Record, +) { + return buildMarketplaceHref(`${MARKETPLACE_BASE}/all`, params); +} + +export function getPreservedMarketplaceSearchParams( + searchParamsObj: Record, +) { + const { rewardType, search, sortBy, sortOrder } = searchParamsObj; + + return { + rewardType: typeof rewardType === "string" ? rewardType : undefined, + search: typeof search === "string" ? search : undefined, + sortBy: typeof sortBy === "string" ? sortBy : undefined, + sortOrder: typeof sortOrder === "string" ? sortOrder : undefined, + }; +} + +function parseMarketplaceSearchParam( + searchParams: Record, + key: string, +) { + const value = searchParams[key]; + return typeof value === "string" ? value : undefined; +} + +export function getMarketplacePopularRedirectHref( + searchParams: Record = {}, +) { + const rewardType = parseMarketplaceSearchParam(searchParams, "rewardType"); + const search = parseMarketplaceSearchParam(searchParams, "search"); + const sortBy = parseMarketplaceSearchParam(searchParams, "sortBy"); + const sortOrder = parseMarketplaceSearchParam(searchParams, "sortOrder"); + + if (!sortBy || sortBy === "popularity") { + return getMarketplaceAllHref({ + rewardType, + search, + sortBy: "popularity", + sortOrder: sortOrder ?? "desc", + }); + } + + return getMarketplaceAllHref({ + rewardType, + search, + sortBy, + sortOrder: + sortOrder ?? + (sortBy === "recency" || sortBy === "popularity" ? "desc" : undefined), + }); +} + +export function getMarketplaceCategoryHref( + category: Category, + params?: Record, +) { + return buildMarketplaceHref( + `${MARKETPLACE_BASE}/c/${categoryToSlug(category)}`, + params, + ); +} + +export function getMarketplaceProgramHref(programSlug: string) { + return `${MARKETPLACE_BASE}/${programSlug}`; +} + +export function getMarketplacePathFromSlug(slug?: string[]) { + const segments = slug ?? []; + + 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}`}`; +} + +function getMarketplacePartnersProgramUrl(programSlug: string) { + return `https://partners.dub.co${getMarketplaceProgramHref(programSlug)}`; +} + +export function getMarketplacePublicApplyHref(programSlug: string) { + return getMarketplacePartnersProgramUrl(programSlug); +} + +export function getMarketplaceCategoryFromPathname( + pathname: string, +): Category | null { + const segments = pathname.split("/").filter(Boolean); + + if ( + segments[0] === "marketplace" && + segments.length === 3 && + segments[1] === "c" + ) { + return slugToCategory(segments[2]); + } + + return null; +} + +export function isMarketplaceFilterSidebarPath(pathname: string): boolean { + const segments = pathname.split("/").filter(Boolean); + + if (segments[0] !== "marketplace") { + return false; + } + + if (segments.length === 1) { + return false; + } + + if (segments[1] === "all") { + return true; + } + + if ( + segments.length === 3 && + segments[1] === "c" && + slugToCategory(segments[2]) + ) { + return true; + } + + return false; +} + +export function buildExternalMarketplaceFilterHref({ + basePath, + activeRewardType, + search, + sortBy, + sortOrder, + category, + rewardType, +}: { + basePath: string; + activeRewardType?: MarketplaceRewardType; + search?: string; + sortBy?: string; + sortOrder?: string; + category?: Category | null; + rewardType?: MarketplaceRewardType | null; +}) { + const resolvedRewardType = + rewardType === undefined ? activeRewardType : rewardType || undefined; + + const queryParams = { + rewardType: resolvedRewardType, + search, + sortBy, + sortOrder, + }; + + if (category === null) { + return getMarketplaceAllHref(queryParams); + } + + if (category) { + return getMarketplaceCategoryHref(category, queryParams); + } + + const query = new URLSearchParams(); + if (resolvedRewardType) query.set("rewardType", resolvedRewardType); + if (search) query.set("search", search); + if (sortBy && sortBy !== "popularity") query.set("sortBy", sortBy); + if (sortOrder && sortOrder !== "desc") query.set("sortOrder", sortOrder); + const queryString = query.toString(); + + return `${basePath}${queryString ? `?${queryString}` : ""}`; +} diff --git a/packages/email/src/templates/network-partner-application-approved.tsx b/packages/email/src/templates/network-partner-application-approved.tsx index f38b72fdcd3..729e0c073ea 100644 --- a/packages/email/src/templates/network-partner-application-approved.tsx +++ b/packages/email/src/templates/network-partner-application-approved.tsx @@ -70,7 +70,7 @@ export default function NetworkPartnerApplicationApproved({
View the marketplace diff --git a/packages/email/src/templates/notify-partner-reapply.tsx b/packages/email/src/templates/notify-partner-reapply.tsx index 838ec383819..25067c07dd9 100644 --- a/packages/email/src/templates/notify-partner-reapply.tsx +++ b/packages/email/src/templates/notify-partner-reapply.tsx @@ -91,7 +91,7 @@ export default function NotifyPartnerReapply({ Reapply diff --git a/packages/ui/src/filter/filter-option-row.tsx b/packages/ui/src/filter/filter-option-row.tsx new file mode 100644 index 00000000000..e4196cff1fb --- /dev/null +++ b/packages/ui/src/filter/filter-option-row.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { cn, truncate } from "@dub/utils"; +import { ComponentType, isValidElement, SVGProps } from "react"; +import { Check } from "../icons"; +import { Filter, FilterOption } from "./types"; + +type FilterOptionRowProps = { + filter: Filter; + option: FilterOption; + checked: boolean; + onToggle: () => void; +}; + +export function FilterOptionRow({ + filter, + option, + checked, + onToggle, +}: FilterOptionRowProps) { + const Icon = + option.icon ?? + filter.getOptionIcon?.(option.value, { key: filter.key, option }); + + const label = + option.label ?? + filter.getOptionLabel?.(option.value, { key: filter.key, option }) ?? + String(option.value); + + return ( + + ); +} + +function renderFilterIcon( + icon: NonNullable< + FilterOption["icon"] | ReturnType> + >, +) { + if (isValidElement(icon)) { + return icon; + } + + const IconComponent = icon as ComponentType>; + return ; +} diff --git a/packages/ui/src/filter/filter-sidebar.tsx b/packages/ui/src/filter/filter-sidebar.tsx new file mode 100644 index 00000000000..ecbe1e7c5ae --- /dev/null +++ b/packages/ui/src/filter/filter-sidebar.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { cn } from "@dub/utils"; +import { useCallback, useMemo } from "react"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "../accordion"; +import { LoadingSpinner } from "../icons"; +import { FilterOptionRow } from "./filter-option-row"; +import { + ActiveFilterInput, + Filter, + FilterOption, + normalizeActiveFilter, +} from "./types"; + +type FilterSidebarProps = { + filters: Filter[]; + activeFilters?: ActiveFilterInput[]; + onSelect: (key: string, value: FilterOption["value"]) => void; + onRemove: (key: string, value: FilterOption["value"]) => void; + className?: string; + defaultOpen?: string[]; +}; + +export function FilterSidebar({ + filters, + activeFilters, + onSelect, + onRemove, + className, + defaultOpen, +}: FilterSidebarProps) { + const visibleFilters = useMemo( + () => + filters.filter( + (filter) => !filter.hideInFilterDropdown && filter.type !== "range", + ), + [filters], + ); + + const defaultOpenSections = useMemo( + () => defaultOpen ?? visibleFilters.map(({ key }) => key), + [defaultOpen, visibleFilters], + ); + + const isOptionSelected = useCallback( + (filterKey: string, value: FilterOption["value"]) => { + const rawActiveFilter = activeFilters?.find( + (filter) => filter.key === filterKey, + ); + if (!rawActiveFilter) return false; + + return normalizeActiveFilter(rawActiveFilter).values.some((v) => + valuesMatch(v, value), + ); + }, + [activeFilters], + ); + + const toggleOption = useCallback( + (filter: Filter, value: FilterOption["value"]) => { + const filterKey = filter.key; + + if (isOptionSelected(filterKey, value)) { + onRemove(filterKey, value); + return; + } + + if (filter.singleSelect) { + onSelect(filterKey, value); + return; + } + + onSelect(filterKey, value); + }, + [isOptionSelected, onRemove, onSelect], + ); + + return ( + + {visibleFilters.map((filter) => ( + + svg]:size-4 [&>svg]:text-neutral-400", + )} + > + {filter.label} + + + {filter.options === null ? ( +
+ +
+ ) : filter.options.length === 0 ? ( +

No options

+ ) : ( +
+ {filter.options.map((option) => ( + toggleOption(filter, option.value)} + /> + ))} +
+ )} +
+
+ ))} +
+ ); +} + +function valuesMatch(a: FilterOption["value"], b: FilterOption["value"]) { + if (typeof a === "string" && typeof b === "string") { + return a.toLowerCase() === b.toLowerCase(); + } + + return a === b; +} diff --git a/packages/ui/src/filter/index.ts b/packages/ui/src/filter/index.ts index 968e8202744..41f06ac4349 100644 --- a/packages/ui/src/filter/index.ts +++ b/packages/ui/src/filter/index.ts @@ -1,7 +1,22 @@ import { FilterList } from "./filter-list"; import { FilterSelect } from "./filter-select"; +import { FilterSidebar } from "./filter-sidebar"; -const Filter = { Select: FilterSelect, List: FilterList }; +const Filter = { + Select: FilterSelect, + List: FilterList, + Sidebar: FilterSidebar, +}; -export { encodeRangeToken, parseRangeToken } from "./types"; +export { FilterOptionRow } from "./filter-option-row"; +export { + encodeRangeToken, + normalizeActiveFilter, + parseRangeToken, +} from "./types"; +export type { + ActiveFilterInput, + Filter as FilterConfig, + FilterOption, +} from "./types"; export { Filter }; 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 type NavItem = { + name: string; + href?: string; + segments?: string[]; + content?: ComponentType<{ domain: string }>; + childItems?: NavItemChildren; +}; + export const navItems = [ { name: "Product", @@ -88,10 +108,14 @@ 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 }; if (staticDomain) { @@ -130,65 +154,65 @@ export function Nav({
    - - - + {logo ?? ( + + + + )}
    - {navItems.map( - ({ name, href, segments, content: Content }) => { - const isActive = segments.some((segment) => - pathname?.startsWith(segment), - ); - return ( - - - {href !== undefined ? ( - - {name} - - ) : ( - - )} - - - {Content && ( - - - + {items.map(({ name, href, segments, content: Content }) => { + const isActive = (segments ?? []).some((segment) => + pathname?.startsWith(segment), + ); + return ( + + + {href !== undefined ? ( + + {name} + + ) : ( + )} - - ); - }, - )} + + + {Content && ( + + + + )} + + ); + })}