| 1 | /** |
| 2 | * The Marketplace's front page: featured extensions, integrations |
| 3 | * (connected first), starter kits, and connected systems. Every listing is |
| 4 | * real: integrations a workspace can connect today, with their real state, |
| 5 | * and g1t's own extensions marked Soon until their first release. |
| 6 | */ |
| 7 | import { Link } from "react-router"; |
| 8 | |
| 9 | import { type ExtensionManifest, FIRST_PARTY_EXTENSIONS, extensionById } from "@g1t/contracts/marketplace"; |
| 10 | import { connectorsFor } from "@g1t/contracts/connectors"; |
| 11 | |
| 12 | import type { Route } from "./+types/discover"; |
| 13 | import { ComingBadge, ExtensionCard, ExtensionMark, IntegrationCard, SectionHead, SeeAll, StarterKitCard, TierBadge } from "../../../components/marketplace"; |
| 14 | import { STARTER_KITS, extensionListings, extensionPath, integrationListings, isConnectedSystem, marketplacePath } from "../../../lib/marketplace"; |
| 15 | import { loadConnected } from "../../../lib/marketplace.server"; |
| 16 | import { requireUser, roleIn } from "../../../lib/session.server"; |
| 17 | import { useMarketplace } from "./layout"; |
| 18 | |
| 19 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 20 | const viewer = requireUser(context, request); |
| 21 | const slug = params.owner.toLowerCase(); |
| 22 | if (!roleIn(viewer, slug)) throw new Response(null, { status: 404 }); |
| 23 | return { connected: await loadConnected(slug, viewer) }; |
| 24 | } |
| 25 | |
| 26 | /** How many integrations the front page shows before "All". */ |
| 27 | const INTEGRATIONS_SHOWN = 6; |
| 28 | /** The extensions the front page leads with: the first the largest. */ |
| 29 | const FEATURED = ["mail", "support", "crm"]; |
| 30 | |
| 31 | export default function MarketplaceDiscover({ loaderData }: Route.ComponentProps) { |
| 32 | const { slug, owner, username, requests, installs } = useMarketplace(); |
| 33 | const asked = requests?.requests ?? []; |
| 34 | const integrations = integrationListings(connectorsFor("workspace"), loaderData.connected, asked, username, slug); |
| 35 | const extensions = extensionListings(FIRST_PARTY_EXTENSIONS, installs, asked, username); |
| 36 | const featured = FEATURED.map((id) => extensionById(id)).filter((manifest) => manifest != null); |
| 37 | const bridges = extensions.filter((listing) => isConnectedSystem(listing.manifest)); |
| 38 | return ( |
| 39 | <div className="space-y-12"> |
| 40 | <section aria-labelledby="featured"> |
| 41 | <h2 id="featured" className="sr-only"> |
| 42 | Featured extensions |
| 43 | </h2> |
| 44 | <div className="grid gap-3 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]"> |
| 45 | {featured[0] && <Feature manifest={featured[0]} slug={slug} large />} |
| 46 | <div className="grid gap-3"> |
| 47 | {featured.slice(1).map((manifest) => ( |
| 48 | <Feature key={manifest.id} manifest={manifest} slug={slug} /> |
| 49 | ))} |
| 50 | </div> |
| 51 | </div> |
| 52 | </section> |
| 53 | |
| 54 | <section aria-labelledby="integrations"> |
| 55 | <SectionHead id="integrations" title="Integrations" aside={<SeeAll to={marketplacePath(slug, "integrations")}>All integrations</SeeAll>}> |
| 56 | Connect what your team already uses, so agents can read it and act in it. Each lists, in plain words, what it lets agents do. |
| 57 | </SectionHead> |
| 58 | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> |
| 59 | {integrations.slice(0, INTEGRATIONS_SHOWN).map((listing) => ( |
| 60 | <IntegrationCard key={listing.ref} listing={listing} slug={slug} owner={owner} /> |
| 61 | ))} |
| 62 | </div> |
| 63 | </section> |
| 64 | |
| 65 | <section aria-labelledby="kits"> |
| 66 | <SectionHead id="kits" title="Starter kits" aside="Several extensions, one install"> |
| 67 | A set of extensions for one kind of team, installed together. |
| 68 | </SectionHead> |
| 69 | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> |
| 70 | {STARTER_KITS.map((kit) => ( |
| 71 | <StarterKitCard key={kit.id} kit={kit} slug={slug} extensions={kit.extensions.map((id) => extensionById(id)).filter((manifest) => manifest != null)} /> |
| 72 | ))} |
| 73 | </div> |
| 74 | </section> |
| 75 | |
| 76 | <section aria-labelledby="systems"> |
| 77 | <SectionHead id="systems" title="Connected systems" aside={<SeeAll to={marketplacePath(slug, "extensions")}>All extensions</SeeAll>}> |
| 78 | Extensions that bridge a helpdesk, CRM or ERP you already run, so agents work across it and g1t together. |
| 79 | </SectionHead> |
| 80 | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> |
| 81 | {bridges.map((listing) => ( |
| 82 | <ExtensionCard key={listing.ref} listing={listing} slug={slug} owner={owner} /> |
| 83 | ))} |
| 84 | </div> |
| 85 | </section> |
| 86 | </div> |
| 87 | ); |
| 88 | } |
| 89 | |
| 90 | /** A featured extension: its mark, name and tagline, and the pages it adds. */ |
| 91 | function Feature({ manifest, slug, large = false }: { manifest: ExtensionManifest; slug: string; large?: boolean }) { |
| 92 | return ( |
| 93 | <Link |
| 94 | to={extensionPath(slug, manifest.id)} |
| 95 | prefetch="intent" |
| 96 | className={`group relative flex flex-col justify-end overflow-hidden rounded-2xl border border-line bg-surface transition-colors hover:border-line-strong ${large ? "min-h-64 p-6" : "min-h-36 p-4"}`} |
| 97 | > |
| 98 | {/* A wash of the accent, from the corner. */} |
| 99 | <span aria-hidden="true" className="pointer-events-none absolute inset-0 bg-[radial-gradient(120%_120%_at_100%_0%,var(--color-accent)_0%,transparent_55%)] opacity-[0.12]" /> |
| 100 | <div className="relative flex items-center gap-2"> |
| 101 | <ExtensionMark manifest={manifest} size={large ? 36 : 28} /> |
| 102 | <TierBadge tier={manifest.publisher.tier} /> |
| 103 | {manifest.status !== "available" && <ComingBadge />} |
| 104 | </div> |
| 105 | <h3 className={`relative mt-3 font-semibold tracking-tight ${large ? "text-2xl" : "text-base"}`}>{manifest.name}</h3> |
| 106 | <p className={`relative mt-1 max-w-xl text-muted ${large ? "text-[0.9375rem]" : "line-clamp-2 text-[0.8125rem]"}`}>{large ? manifest.description : manifest.tagline}</p> |
| 107 | {large && ( |
| 108 | <ul className="relative mt-4 flex flex-wrap gap-1.5" aria-label="Pages it adds"> |
| 109 | {manifest.adds.pages.map((name) => ( |
| 110 | <li key={name} className="rounded-full border border-line bg-bg/60 px-2.5 py-0.5 text-xs text-fg-soft"> |
| 111 | {name} |
| 112 | </li> |
| 113 | ))} |
| 114 | </ul> |
| 115 | )} |
| 116 | </Link> |
| 117 | ); |
| 118 | } |