Skip to content
118 linesCodeBlameRaw
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 */
7import { Link } from "react-router";
8
9import { type ExtensionManifest, FIRST_PARTY_EXTENSIONS, extensionById } from "@g1t/contracts/marketplace";
10import { connectorsFor } from "@g1t/contracts/connectors";
11
12import type { Route } from "./+types/discover";
13import { ComingBadge, ExtensionCard, ExtensionMark, IntegrationCard, SectionHead, SeeAll, StarterKitCard, TierBadge } from "../../../components/marketplace";
14import { STARTER_KITS, extensionListings, extensionPath, integrationListings, isConnectedSystem, marketplacePath } from "../../../lib/marketplace";
15import { loadConnected } from "../../../lib/marketplace.server";
16import { requireUser, roleIn } from "../../../lib/session.server";
17import { useMarketplace } from "./layout";
18
19export 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". */
27const INTEGRATIONS_SHOWN = 6;
28/** The extensions the front page leads with: the first the largest. */
29const FEATURED = ["mail", "support", "crm"];
30
31export 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. */
91function 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}