Skip to content
178 linesCodeBlameRaw
1/**
2 * One integration's page: who builds it, whether this workspace can
3 * connect it (or has), and exactly what it lets agents and people do
4 * today and what is still Soon, from the connector catalog
5 * (@g1t/contracts/connectors) for each way it is connected: for the
6 * whole workspace, and for each person.
7 */
8import { ArrowLeft, Clock, ShieldCheck, Users } from "lucide-react";
9import { Link, data } from "react-router";
10
11import { connectorById, connectorView } from "@g1t/contracts/connectors";
12
13import type { Route } from "./+types/integration";
14import { ConnectorMark } from "../../../components/connectors";
15import { AvailabilityBadge, IntegrationAction, ListingFacts } from "../../../components/marketplace";
16import { cn } from "../../../lib/cn";
17import { type IntegrationUse, TIERS, categoryTitle, comingListings, integrationListings, integrationUses, marketplacePath, personalListings } from "../../../lib/marketplace";
18import { loadConnected } from "../../../lib/marketplace.server";
19import { page } from "../../../lib/meta";
20import { requireUser, roleIn } from "../../../lib/session.server";
21import { useMarketplace } from "./layout";
22
23export function meta({ params, ...args }: Route.MetaArgs) {
24 const connector = connectorById(params.integration);
25 return page(args, { title: `${connector?.name ?? "Integration"} · Marketplace · ${params.owner} · g1t` });
26}
27
28export async function loader({ params, context, request }: Route.LoaderArgs) {
29 const viewer = requireUser(context, request);
30 const slug = params.owner.toLowerCase();
31 if (!roleIn(viewer, slug)) throw data(null, { status: 404 });
32 if (!connectorById(params.integration)) throw data(null, { status: 404 });
33 return await loadConnected(slug, viewer);
34}
35
36export default function MarketplaceIntegration({ params, loaderData }: Route.ComponentProps) {
37 const { slug, owner, username, requests } = useMarketplace();
38 const connector = connectorById(params.integration)!;
39 const workspaceView = connectorView(connector, "workspace");
40 const personalView = connectorView(connector, "personal");
41 // The listing this page acts on: the workspace's, or each person's when it is only theirs.
42 const listing = workspaceView
43 ? workspaceView.status === "available"
44 ? integrationListings([workspaceView], loaderData.connected, requests?.requests ?? [], username, slug, loaderData.unavailable)[0]!
45 : comingListings([workspaceView], [], slug)[0]!
46 : personalView!.status === "available" && personalView!.href
47 ? personalListings([personalView!], slug, loaderData.unavailable)[0]!
48 : comingListings([], [personalView!], slug)[0]!;
49 const uses = integrationUses(connector);
50 const tier = TIERS[listing.tier];
51 return (
52 <div>
53 <Link to={marketplacePath(slug, "integrations")} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
54 <ArrowLeft size={14} />
55 All integrations
56 </Link>
57 <header className="mt-5 flex flex-wrap items-center gap-4">
58 <span className={cn("shrink-0", (listing.availability === "soon" || listing.availability === "unavailable") && "opacity-60 grayscale")}>
59 <ConnectorMark view={listing.view} size={64} />
60 </span>
61 <div className="min-w-0 grow basis-60">
62 <h2 className="text-xl font-semibold tracking-tight">{connector.name}</h2>
63 <ListingFacts tier={listing.tier} publisher={listing.publisher} category={categoryTitle(connector.category)}>
64 <AvailabilityBadge
65 availability={listing.availability}
66 kind="integration"
67 why={listing.why}
68 hint={listing.availability === "available" && !owner && listing.scope === "workspace" ? "An owner connects it for everyone. Ask one with Request." : undefined}
69 />
70 </ListingFacts>
71 </div>
72 <IntegrationAction listing={listing} slug={slug} owner={owner} className="h-9 px-4 text-sm" />
73 </header>
74 {listing.availability === "unavailable" && listing.why && (
75 <p className="mt-5 max-w-3xl rounded-lg border border-dashed border-line-strong px-4 py-3 text-sm text-muted">
76 <span className="font-medium text-fg-soft">Not available here.</span> {listing.why}
77 </p>
78 )}
79 {listing.availability === "soon" && (
80 <p className="mt-5 max-w-3xl rounded-lg border border-dashed border-line-strong px-4 py-3 text-sm text-muted">
81 Planned, not built yet: nobody can connect it. This page shows what it will do.
82 </p>
83 )}
84 <p className="mt-6 max-w-3xl text-[0.9375rem] leading-relaxed text-fg-soft">{listing.view.description}</p>
85
86 <div className="mt-8 grid gap-8 lg:grid-cols-[minmax(0,1fr)_20rem]">
87 <section aria-labelledby="does" className="min-w-0">
88 <h3 id="does" className="text-sm font-semibold">
89 What it lets agents and people do
90 </h3>
91 <div className="mt-3 grid gap-3 sm:grid-cols-2">
92 <div>
93 <h4 className="mb-2 flex items-center gap-1.5 text-xs font-medium text-success">
94 <ShieldCheck size={13} />
95 {listing.availability === "unavailable" ? "Once it can be connected here" : "Today"}
96 </h4>
97 {uses.today.length === 0 ? (
98 <p className="rounded-xl border border-dashed border-line px-4 py-4 text-sm text-muted">Nothing yet. It can't be connected until it is built.</p>
99 ) : (
100 <div className="space-y-3">
101 {uses.today.map((use) => (
102 <Use key={use.scope} use={use} said={listing.view.description} />
103 ))}
104 </div>
105 )}
106 </div>
107 <div>
108 <h4 className="mb-2 flex items-center gap-1.5 text-xs font-medium text-muted">
109 <Clock size={13} />
110 Soon
111 </h4>
112 {uses.soon.length === 0 ? (
113 <p className="rounded-xl border border-dashed border-line px-4 py-4 text-sm text-muted">Nothing more is planned for it yet.</p>
114 ) : (
115 <div className="space-y-3">
116 {uses.soon.map((use) => (
117 <Use key={use.scope} use={use} said={listing.view.description} soon />
118 ))}
119 </div>
120 )}
121 </div>
122 </div>
123 </section>
124
125 <aside className="text-sm">
126 <dl className="space-y-3 rounded-xl border border-line bg-surface p-4">
127 <div>
128 <dt className="text-xs text-faint">Publisher</dt>
129 <dd className="mt-0.5">
130 {listing.publisher} · {tier.label}
131 <span className="block text-xs text-muted">{tier.about}</span>
132 </dd>
133 </div>
134 <div>
135 <dt className="flex items-center gap-1.5 text-xs text-faint">
136 <Users size={12} />
137 Who connects it
138 </dt>
139 <dd className="mt-0.5">
140 {connector.scopes.includes("workspace") && connector.scopes.includes("personal")
141 ? "An owner, once for the whole workspace; and each person, for their own account."
142 : connector.scopes.includes("workspace")
143 ? "An owner, once for the whole workspace."
144 : "Each person, for their own account. Agents use it only when that person asks."}
145 </dd>
146 </div>
147 {listing.connected && (
148 <div>
149 <dt className="text-xs text-faint">Connected</dt>
150 <dd className="mt-0.5 break-words">{listing.connected.detail}</dd>
151 {listing.connected.problem && <dd className="mt-1 text-warn">{listing.connected.problem}</dd>}
152 </div>
153 )}
154 </dl>
155 </aside>
156 </div>
157 </div>
158 );
159}
160
161/** One way it is connected: who connects it, what it does that way, and its capabilities. */
162function Use({ use, said, soon = false }: { use: IntegrationUse; said: string; soon?: boolean }) {
163 return (
164 <div className={cn("rounded-xl border p-4", soon ? "border-dashed border-line-strong/70" : "border-line bg-surface")}>
165 <p className="text-xs text-faint">{use.who}</p>
166 {use.description !== said && <p className={cn("mt-1 text-sm", soon ? "text-muted" : "text-fg-soft")}>{use.description}</p>}
167 {use.capabilities.length > 0 && (
168 <ul className="mt-2.5 flex flex-wrap gap-1.5" aria-label={soon ? "What it will do" : "What it does"}>
169 {use.capabilities.map((capability) => (
170 <li key={capability} className={cn("rounded-full px-2 py-px text-[0.6875rem]", soon ? "border border-dashed border-line text-faint" : "bg-raised text-fg-soft")}>
171 {capability}
172 </li>
173 ))}
174 </ul>
175 )}
176 </div>
177 );
178}