| 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 | */ |
| 8 | import { ArrowLeft, Clock, ShieldCheck, Users } from "lucide-react"; |
| 9 | import { Link, data } from "react-router"; |
| 10 | |
| 11 | import { connectorById, connectorView } from "@g1t/contracts/connectors"; |
| 12 | |
| 13 | import type { Route } from "./+types/integration"; |
| 14 | import { ConnectorMark } from "../../../components/connectors"; |
| 15 | import { AvailabilityBadge, IntegrationAction, ListingFacts } from "../../../components/marketplace"; |
| 16 | import { cn } from "../../../lib/cn"; |
| 17 | import { type IntegrationUse, TIERS, categoryTitle, comingListings, integrationListings, integrationUses, marketplacePath, personalListings } from "../../../lib/marketplace"; |
| 18 | import { loadConnected } from "../../../lib/marketplace.server"; |
| 19 | import { page } from "../../../lib/meta"; |
| 20 | import { requireUser, roleIn } from "../../../lib/session.server"; |
| 21 | import { useMarketplace } from "./layout"; |
| 22 | |
| 23 | export 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 | |
| 28 | export 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 | |
| 36 | export 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. */ |
| 162 | function 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 | } |