Skip to content
378 linesCodeBlameRaw
1/**
2 * The Marketplace's pieces (routes/workspace/marketplace/): listings for
3 * extensions and integrations with what the workspace has of each, the
4 * button that adds one (owners) or asks an owner to (everyone else),
5 * starter kits, and a request as owners and askers see it.
6 */
7import { ArrowRight, Check, CircleAlert, Clock, Plus, Send, X } from "lucide-react";
8import { type ReactNode, useEffect, useState } from "react";
9import { Link, useFetcher } from "react-router";
10
11import type { ExtensionManifest, InstallRequest, ListingTier } from "@g1t/contracts";
12
13import { ConnectorMark } from "./connectors";
14import { Badge } from "./ui/badge";
15import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
16import { Hint } from "./ui/hint";
17import { ErrorText, SubmitButton, Textarea, TimeAgo } from "./ui";
18import { type ExtensionListing, type IntegrationListing, type StarterKit, TIERS, extensionPath, marketplacePath } from "../lib/marketplace";
19import { cn } from "../lib/cn";
20
21const SMALL = "inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-md px-3 text-[0.8125rem] font-medium transition-colors";
22export const ACTION = {
23 primary: `${SMALL} bg-fg text-bg hover:bg-fg-hover`,
24 quiet: `${SMALL} border border-line text-fg/85 hover:border-line-strong hover:bg-raised hover:text-fg`,
25};
26
27/** Who stands behind a listing: a small badge, with the sentence on hover. */
28export function TierBadge({ tier }: { tier: ListingTier }) {
29 return (
30 <Hint label={TIERS[tier].about}>
31 <span tabIndex={0} className="inline-flex rounded outline-none focus-visible:ring-2 focus-visible:ring-accent">
32 <Badge tone={tier === "official" ? "accent" : tier === "verified" ? "success" : tier === "community" ? "warn" : "info"}>{TIERS[tier].label}</Badge>
33 </span>
34 </Hint>
35 );
36}
37
38/** "Soon", on what is planned and not built. */
39export function ComingBadge() {
40 return <Badge tone="neutral">Soon</Badge>;
41}
42
43/** A section's heading, with a count or a link at its end. */
44export function SectionHead({ id, title, aside, children }: { id: string; title: string; aside?: ReactNode; children?: ReactNode }) {
45 return (
46 <div className="mb-3">
47 <div className="flex items-baseline justify-between gap-3">
48 <h2 id={id} className="text-base font-semibold tracking-tight">
49 {title}
50 </h2>
51 {aside && <div className="shrink-0 text-xs text-muted">{aside}</div>}
52 </div>
53 {children && <p className="mt-1 max-w-2xl text-sm text-muted">{children}</p>}
54 </div>
55 );
56}
57
58/** A link at the end of a section's heading. */
59export function SeeAll({ to, children }: { to: string; children: ReactNode }) {
60 return (
61 <Link to={to} prefetch="intent" className="inline-flex items-center gap-1 text-[0.8125rem] text-muted hover:text-fg">
62 {children}
63 <ArrowRight size={13} />
64 </Link>
65 );
66}
67
68/**
69 * Asking the workspace's owners to add something: a dialog with an
70 * optional note, sent to the requests route. Once sent, it says so.
71 */
72export function RequestButton({ slug, listing, name, requested, className }: { slug: string; listing: string; name: string; requested: boolean; className?: string }) {
73 const fetcher = useFetcher<{ error: string | null }>({ key: `request:${listing}` });
74 const [open, setOpen] = useState(false);
75 const sent = fetcher.state === "idle" && fetcher.data?.error === null;
76 useEffect(() => {
77 if (sent) setOpen(false);
78 }, [sent]);
79 if (requested || sent) {
80 return (
81 <Hint label="The workspace's owners have your request. You'll hear when they answer.">
82 <span tabIndex={0} className={cn(SMALL, "border border-line text-muted outline-none focus-visible:ring-2 focus-visible:ring-accent", className)}>
83 <Clock size={13} />
84 Requested
85 </span>
86 </Hint>
87 );
88 }
89 return (
90 <Dialog open={open} onOpenChange={setOpen}>
91 <DialogTrigger asChild>
92 <button type="button" className={cn(ACTION.quiet, className)}>
93 <Send size={13} />
94 Request
95 </button>
96 </DialogTrigger>
97 <DialogContent>
98 <fetcher.Form method="post" action={marketplacePath(slug, "requests")} className="grid gap-4">
99 <DialogHeader>
100 <DialogTitle>Ask to add {name}</DialogTitle>
101 <DialogDescription>Only the workspace's owners add extensions and integrations. Each of them is notified, and you hear back when one answers.</DialogDescription>
102 </DialogHeader>
103 <input type="hidden" name="intent" value="request" />
104 <input type="hidden" name="listing" value={listing} />
105 <label className="grid gap-1.5 text-sm">
106 <span className="font-medium">
107 Why you want it <span className="font-normal text-faint">(optional)</span>
108 </span>
109 <Textarea name="note" rows={3} maxLength={280} placeholder="What it would help with" />
110 </label>
111 {fetcher.data?.error && <ErrorText>{fetcher.data.error}</ErrorText>}
112 <DialogFooter>
113 <SubmitButton fetcher={fetcher} pending="Sending…">
114 <Send size={14} />
115 Send request
116 </SubmitButton>
117 </DialogFooter>
118 </fetcher.Form>
119 </DialogContent>
120 </Dialog>
121 );
122}
123
124/** "3 waiting": open requests for a listing, for owners. */
125function Waiting({ slug, count }: { slug: string; count: number }) {
126 if (count === 0) return null;
127 return (
128 <Link to={marketplacePath(slug, "requests")} className="text-xs text-warn hover:underline">
129 {count} {count === 1 ? "request" : "requests"}
130 </Link>
131 );
132}
133
134/** What an owner or a member can do with an integration. */
135export function IntegrationAction({ listing, slug, owner }: { listing: IntegrationListing; slug: string; owner: boolean }) {
136 if (listing.connected) {
137 return listing.href ? (
138 <Link to={listing.href} className={ACTION.quiet}>
139 Manage
140 </Link>
141 ) : null;
142 }
143 if (owner) {
144 return listing.href ? (
145 <Link to={listing.href} className={ACTION.primary}>
146 <Plus size={14} />
147 Connect
148 </Link>
149 ) : null;
150 }
151 return <RequestButton slug={slug} listing={listing.ref} name={listing.view.name} requested={listing.requested} />;
152}
153
154/** An integration as a card: its mark, what it does for agents, and whether the workspace has it. */
155export function IntegrationCard({ listing, slug, owner }: { listing: IntegrationListing; slug: string; owner: boolean }) {
156 const { view, connected } = listing;
157 return (
158 <article className="flex flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong">
159 <div className="flex items-start gap-3">
160 <ConnectorMark view={view} size={40} />
161 <div className="min-w-0 grow">
162 <h3 className="truncate text-sm font-semibold">{view.name}</h3>
163 <p className="mt-0.5 line-clamp-2 text-[0.8125rem] leading-snug text-muted">{view.description}</p>
164 </div>
165 </div>
166 {view.capabilities.length > 0 && (
167 <ul className="mt-3 flex grow flex-wrap content-start gap-1.5" aria-label="What it does">
168 {view.capabilities.map((capability) => (
169 <li key={capability} className="rounded-full bg-raised px-2 py-px text-[0.6875rem] text-muted">
170 {capability}
171 </li>
172 ))}
173 </ul>
174 )}
175 <div className="mt-4 flex items-center justify-between gap-3 border-t border-line pt-3">
176 <span className="flex min-w-0 items-center gap-1.5 text-xs">
177 {connected ? (
178 connected.problem ? (
179 <Hint label={connected.problem}>
180 <span tabIndex={0} className="inline-flex min-w-0 items-center gap-1 text-warn outline-none">
181 <CircleAlert size={13} className="shrink-0" />
182 <span className="truncate">Needs attention</span>
183 </span>
184 </Hint>
185 ) : (
186 <span className="inline-flex min-w-0 items-center gap-1 text-success">
187 <Check size={13} className="shrink-0" />
188 <span className="truncate">Connected</span>
189 </span>
190 )
191 ) : (
192 <span className="truncate text-faint">Not connected</span>
193 )}
194 {owner && listing.waiting > 0 && (
195 <>
196 <span className="text-faint">·</span>
197 <Waiting slug={slug} count={listing.waiting} />
198 </>
199 )}
200 </span>
201 <IntegrationAction listing={listing} slug={slug} owner={owner} />
202 </div>
203 </article>
204 );
205}
206
207/** An extension's mark: its initials on a steady colour, until listings bring their own icon. */
208export function ExtensionMark({ manifest, size = 40 }: { manifest: Pick<ExtensionManifest, "id" | "name">; size?: number }) {
209 return <ConnectorMark view={{ id: manifest.id, name: manifest.name, provider: null }} size={size} />;
210}
211
212/** What an owner or a member can do with an extension: install or ask, once it is published; nothing before. */
213export function ExtensionAction({ listing, slug, owner, className }: { listing: ExtensionListing; slug: string; owner: boolean; className?: string }) {
214 const fetcher = useFetcher<{ error: string | null }>();
215 const { manifest, install } = listing;
216 if (manifest.status !== "available") {
217 return (
218 <Hint label="Not published yet. It can be installed from its first release.">
219 <span tabIndex={0} className={cn(SMALL, "border border-dashed border-line-strong text-muted outline-none focus-visible:ring-2 focus-visible:ring-accent", className)}>
220 Soon
221 </span>
222 </Hint>
223 );
224 }
225 if (install) return <Badge tone={install.enabled ? "success" : "warn"}>{install.enabled ? `Installed · ${install.version}` : "Switched off"}</Badge>;
226 if (!owner) return <RequestButton slug={slug} listing={listing.ref} name={manifest.name} requested={listing.requested} className={className} />;
227 return (
228 <fetcher.Form method="post" action={marketplacePath(slug, "requests")}>
229 <input type="hidden" name="intent" value="install" />
230 <input type="hidden" name="extension" value={manifest.id} />
231 <SubmitButton fetcher={fetcher} className={cn(ACTION.primary, className)} pending="Installing…">
232 <Plus size={14} />
233 Install
234 </SubmitButton>
235 </fetcher.Form>
236 );
237}
238
239/** Where an extension's data goes, in a few words for a card. */
240function dataLine(manifest: Pick<ExtensionManifest, "domains" | "bridges">): string {
241 // "the CRM you connect" reads, on a card, as "Works with your CRM".
242 if (manifest.bridges) return `Works with ${manifest.bridges.replace(/^the (.+) you connect$/, "your $1")}`;
243 return manifest.domains.length === 0 ? "Data stays in g1t" : `Data goes to ${manifest.domains[0]}`;
244}
245
246/** An extension as a card: its mark, publisher and tier, what it does, and where its data goes. */
247export function ExtensionCard({ listing, slug, owner }: { listing: ExtensionListing; slug: string; owner: boolean }) {
248 const { manifest } = listing;
249 return (
250 <article className="relative flex flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong">
251 <div className="flex items-start gap-3">
252 <ExtensionMark manifest={manifest} />
253 <div className="min-w-0 grow">
254 <h3 className="truncate text-sm font-semibold">
255 <Link to={extensionPath(slug, manifest.id)} prefetch="intent" className="outline-none after:absolute after:inset-0 after:rounded-xl focus-visible:after:ring-2 focus-visible:after:ring-accent">
256 {manifest.name}
257 </Link>
258 </h3>
259 <p className="truncate text-xs text-muted">
260 {manifest.publisher.name} · {manifest.category}
261 </p>
262 </div>
263 <span className="relative">
264 <TierBadge tier={manifest.publisher.tier} />
265 </span>
266 </div>
267 <p className="mt-3 line-clamp-2 grow text-[0.8125rem] leading-snug text-muted">{manifest.tagline}</p>
268 <div className="relative mt-4 flex items-center justify-between gap-3 border-t border-line pt-3">
269 <span className="min-w-0 truncate text-xs text-faint">{dataLine(manifest)}</span>
270 <ExtensionAction listing={listing} slug={slug} owner={owner} />
271 </div>
272 </article>
273 );
274}
275
276/** A request's status, in a word. */
277export function RequestStatus({ request }: { request: InstallRequest }) {
278 if (request.status === "open") return <Badge tone="warn">Waiting</Badge>;
279 if (request.status === "done") return <Badge tone="success">Added</Badge>;
280 return <Badge tone="neutral">Turned down</Badge>;
281}
282
283/**
284 * One request as a row: what was asked for, by whom, why and when; for an
285 * owner, while it waits, the way to add it, mark it added, or turn it down.
286 */
287export function RequestRow({ request, slug, owner, addTo }: { request: InstallRequest; slug: string; owner: boolean; addTo: string | null }) {
288 const fetcher = useFetcher<{ error: string | null }>();
289 const action = marketplacePath(slug, "requests");
290 return (
291 <li className="flex flex-wrap items-start gap-x-4 gap-y-3 px-4 py-3.5 sm:flex-nowrap">
292 <div className="min-w-0 grow">
293 <p className="flex flex-wrap items-center gap-2 text-sm">
294 <span className="font-medium">{request.name}</span>
295 <span className="text-xs text-faint">{request.kind === "extension" ? "Extension" : "Integration"}</span>
296 <RequestStatus request={request} />
297 </p>
298 <p className="mt-0.5 text-xs text-muted">
299 {owner ? `@${request.requested_by} asked ` : "You asked "}
300 <TimeAgo at={request.requested_at} />
301 {request.resolved_by && request.status !== "open" && (
302 <>
303 {" · "}
304 {request.status === "done" ? "added" : "turned down"} by @{request.resolved_by}
305 </>
306 )}
307 </p>
308 {request.note && <p className="mt-1.5 max-w-xl rounded-md border-l-2 border-line-strong pl-2.5 text-[0.8125rem] text-fg-soft">{request.note}</p>}
309 {fetcher.data?.error && (
310 <div className="mt-2">
311 <ErrorText>{fetcher.data.error}</ErrorText>
312 </div>
313 )}
314 </div>
315 {owner && request.status === "open" && (
316 <fetcher.Form method="post" action={action} className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:shrink-0">
317 <input type="hidden" name="intent" value="resolve" />
318 <input type="hidden" name="id" value={request.id} />
319 {addTo && (
320 <Link to={addTo} className={ACTION.primary}>
321 <Plus size={14} />
322 {request.kind === "extension" ? "Install" : "Connect"}
323 </Link>
324 )}
325 <SubmitButton fetcher={fetcher} name="status" value="done" className={ACTION.quiet} pending="Saving…">
326 <Check size={14} />
327 Mark added
328 </SubmitButton>
329 <SubmitButton fetcher={fetcher} name="status" value="declined" className={ACTION.quiet} pending="Saving…">
330 <X size={14} />
331 Turn down
332 </SubmitButton>
333 </fetcher.Form>
334 )}
335 </li>
336 );
337}
338
339/**
340 * A starter kit as a card: the extensions it brings, overlapped, its name
341 * and what it is for. Installed together once every one is published;
342 * until then it says Soon.
343 */
344export function StarterKitCard({ kit, extensions, slug }: { kit: StarterKit; extensions: ExtensionManifest[]; slug: string }) {
345 const ready = extensions.length > 0 && extensions.every((extension) => extension.status === "available");
346 return (
347 <article className="flex flex-col rounded-xl border border-line bg-surface p-4">
348 <div className="flex items-center">
349 {extensions.map((extension, index) => (
350 <span key={extension.id} className={cn("rounded-[0.6rem] ring-2 ring-surface", index > 0 && "-ml-2")}>
351 <ExtensionMark manifest={extension} size={30} />
352 </span>
353 ))}
354 </div>
355 <h3 className="mt-3 text-sm font-semibold">{kit.name}</h3>
356 <p className="mt-0.5 grow text-[0.8125rem] leading-snug text-muted">{kit.about}</p>
357 <div className="mt-4 flex items-center justify-between gap-3 border-t border-line pt-3">
358 <p className="min-w-0 truncate text-xs text-faint">
359 {extensions.map((extension, index) => (
360 <span key={extension.id}>
361 {index > 0 && ", "}
362 <Link to={extensionPath(slug, extension.id)} className="hover:text-fg hover:underline">
363 {extension.name}
364 </Link>
365 </span>
366 ))}
367 </p>
368 {!ready && (
369 <Hint label="Its extensions aren't all published yet. The kit installs them together once they are.">
370 <span tabIndex={0} className={cn(SMALL, "border border-dashed border-line-strong text-muted outline-none focus-visible:ring-2 focus-visible:ring-accent")}>
371 Soon
372 </span>
373 </Hint>
374 )}
375 </div>
376 </article>
377 );
378}