Skip to content
789 linesCodeBlameRaw
1/**
2 * The Marketplace's pieces (routes/workspace/marketplace/): listings for
3 * extensions and integrations, each with who stands behind it (its tier)
4 * and whether it can be added here (its availability), the button that
5 * adds one (owners) or asks an owner to (everyone else), the filters and
6 * the legend for both, starter kits, and a request as owners and askers
7 * see it.
8 */
9import { ArrowRight, Ban, Check, CircleAlert, Clock, Plus, Power, Send, X } from "lucide-react";
10import { type ReactNode, useEffect, useState } from "react";
11import { Link, useFetcher, useLocation, useSearchParams } from "react-router";
12
13import type { ExtensionManifest, InstallRequest, ListingKind, ListingTier } from "@g1t/contracts";
14import { LISTING_TIERS } from "@g1t/contracts/marketplace";
15
16import { ConnectorMark } from "./connectors";
17import { Badge } from "./ui/badge";
18import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
19import { Hint } from "./ui/hint";
20import { ErrorText, SubmitButton, Textarea, TimeAgo } from "./ui";
21import {
22 AVAILABILITIES,
23 AVAILABILITY,
24 type Availability,
25 type ExtensionListing,
26 type IntegrationListing,
27 type ListingFilters,
28 type StarterKit,
29 TIERS,
30 addedWord,
31 extensionPath,
32 marketplacePath,
33} from "../lib/marketplace";
34import { cn } from "../lib/cn";
35
36const 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";
37export const ACTION = {
38 primary: `${SMALL} bg-fg text-bg hover:bg-fg-hover`,
39 quiet: `${SMALL} border border-line text-fg/85 hover:border-line-strong hover:bg-raised hover:text-fg`,
40};
41
42/** Each tier's colour: Official the accent, Verified green, Community amber, Internal blue. */
43const TIER_TONE: Record<ListingTier, string> = {
44 official: "bg-accent/12 text-accent",
45 verified: "bg-success/12 text-success",
46 community: "bg-warn/12 text-warn",
47 internal: "bg-info/12 text-info",
48};
49
50/**
51 * Who stands behind a listing: a small square badge in its tier's colour,
52 * with the sentence on hover or focus. Inside a link, `focusable={false}`
53 * keeps it from being a second stop for the keyboard.
54 */
55export function TierBadge({ tier, focusable = true, className }: { tier: ListingTier; focusable?: boolean; className?: string }) {
56 return (
57 <Hint label={`${TIERS[tier].label}: ${TIERS[tier].about}`}>
58 <span
59 tabIndex={focusable ? 0 : undefined}
60 className={cn(
61 "inline-flex shrink-0 items-center rounded-[5px] px-1.5 py-px font-mono text-[0.625rem] leading-4 font-medium tracking-[0.08em] whitespace-nowrap uppercase outline-none focus-visible:ring-2 focus-visible:ring-accent",
62 TIER_TONE[tier],
63 className,
64 )}
65 >
66 {TIERS[tier].label}
67 </span>
68 </Hint>
69 );
70}
71
72/**
73 * Whether a listing can be added here: Available, Connected or Installed,
74 * Soon, or Not available here, as an icon and a word (never a button),
75 * with what it means, or why, on hover or focus.
76 */
77export function AvailabilityBadge({
78 availability,
79 kind,
80 why,
81 off = false,
82 hint,
83 focusable = true,
84 className,
85}: {
86 availability: Availability;
87 kind: ListingKind;
88 /** Why it isn't available here. */
89 why?: string | null;
90 /** An installed extension that was switched off. */
91 off?: boolean;
92 /** A hint in place of the usual sentence. */
93 hint?: string;
94 /** False inside a link, so it isn't a second stop for the keyboard. */
95 focusable?: boolean;
96 className?: string;
97}) {
98 const label = availability === "added" ? (off ? "Switched off" : addedWord(kind)) : AVAILABILITY[availability].label;
99 const about =
100 hint ??
101 (availability === "unavailable" && why
102 ? why
103 : availability === "added"
104 ? off
105 ? "Installed, and switched off: its token doesn't work and its page doesn't load until an owner switches it on."
106 : `This workspace has it ${kind === "integration" ? "connected" : "installed"}.`
107 : AVAILABILITY[availability].about);
108 const icon =
109 availability === "available" ? (
110 <span aria-hidden="true" className="size-1.5 shrink-0 rounded-full bg-success" />
111 ) : availability === "added" ? (
112 off ? (
113 <Power size={12} className="shrink-0" />
114 ) : (
115 <Check size={13} className="shrink-0" />
116 )
117 ) : availability === "soon" ? (
118 <Clock size={12} className="shrink-0" />
119 ) : (
120 <Ban size={12} className="shrink-0" />
121 );
122 return (
123 <Hint label={about}>
124 <span
125 tabIndex={focusable ? 0 : undefined}
126 className={cn(
127 "inline-flex min-w-0 shrink-0 items-center gap-1.5 rounded text-xs whitespace-nowrap outline-none focus-visible:ring-2 focus-visible:ring-accent",
128 availability === "available" ? "text-fg-soft" : availability === "added" ? (off ? "text-warn" : "text-success") : "text-muted",
129 className,
130 )}
131 >
132 {icon}
133 <span className="truncate">{label}</span>
134 </span>
135 </Hint>
136 );
137}
138
139/** "Soon", on what is planned and not built. */
140export function ComingBadge() {
141 return <AvailabilityBadge availability="soon" kind="extension" />;
142}
143
144/**
145 * What the labels mean: who builds what (the four tiers) and whether a
146 * listing can be added here. On Discover, and at the end of each tab.
147 */
148export function ListingLegend({ id = "legend", kind }: { id?: string; kind?: ListingKind }) {
149 return (
150 <section aria-labelledby={id} className="rounded-2xl border border-line bg-surface p-5 sm:p-6">
151 <h2 id={id} className="text-base font-semibold tracking-tight">
152 Who builds what
153 </h2>
154 <p className="mt-1 max-w-2xl text-sm text-muted">Every listing says who stands behind it, and whether it can be added here. Today, everything listed is g1t's own.</p>
155 <ul className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
156 {LISTING_TIERS.map((tier) => (
157 <li key={tier} className="rounded-xl border border-line bg-bg p-4">
158 <TierBadge tier={tier} />
159 <p className="mt-2.5 text-[0.8125rem] leading-snug text-muted">{TIERS[tier].about}</p>
160 </li>
161 ))}
162 </ul>
163 <h3 className="mt-6 text-sm font-semibold">Whether you can add it</h3>
164 <ul className="mt-3 grid gap-x-6 gap-y-2.5 sm:grid-cols-2">
165 {AVAILABILITIES.map((availability) => (
166 <li key={availability} className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-[0.8125rem] text-muted">
167 <span className="w-36 shrink-0">
168 <AvailabilityBadge availability={availability} kind={kind ?? "integration"} />
169 </span>
170 <span className="min-w-0 basis-56 grow">
171 {availability === "added"
172 ? kind === "extension"
173 ? "Installed in this workspace."
174 : kind === "integration"
175 ? "Connected for this workspace."
176 : "Connected or installed in this workspace."
177 : availability === "unavailable"
178 ? "This workspace or this g1t lacks something it needs, and the listing says what."
179 : AVAILABILITY[availability].about}
180 </span>
181 </li>
182 ))}
183 </ul>
184 </section>
185 );
186}
187
188/** A tier's section while nothing in it is listed: said plainly, so the structure shows. */
189export function TierEmpty({ tier, kind, filtered = false }: { tier: ListingTier; kind: ListingKind; filtered?: boolean }) {
190 return (
191 <p className="rounded-xl border border-dashed border-line px-4 py-4 text-sm text-muted">
192 <span>{filtered && tier === "official" ? TIERS.official.none[kind] : filtered ? `None match. ${TIERS[tier].none[kind]}` : TIERS[tier].none[kind]}</span>
193 </p>
194 );
195}
196
197/**
198 * Filters by tier and by availability, kept in the address
199 * (`?tier=verified&availability=soon`) so a filtered page can be shared.
200 * Each option shows how many listings it has before filtering.
201 */
202export function ListingFilterBar({
203 kind,
204 filters,
205 counts,
206}: {
207 kind: ListingKind;
208 filters: ListingFilters;
209 counts: { tier: Record<ListingTier, number>; availability: Record<Availability, number>; all: number };
210}) {
211 const [params] = useSearchParams();
212 const { pathname } = useLocation();
213 const href = (key: "tier" | "availability", value: string) => {
214 const next = new URLSearchParams(params);
215 if (value === "all") next.delete(key);
216 else next.set(key, value);
217 const query = next.toString();
218 return query ? `${pathname}?${query}` : pathname;
219 };
220 const Option = ({ group, value, current, count, children }: { group: "tier" | "availability"; value: string; current: string; count: number; children: ReactNode }) => (
221 <Link
222 to={href(group, value)}
223 replace
224 preventScrollReset
225 aria-current={current === value ? "true" : undefined}
226 className={cn(
227 "inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full border px-2.5 text-xs whitespace-nowrap transition-colors outline-none focus-visible:ring-2 focus-visible:ring-accent",
228 current === value ? "border-line-strong bg-raised font-medium text-fg" : "border-line text-muted hover:border-line-strong hover:text-fg",
229 )}
230 >
231 {children}
232 <span className={cn("tabular-nums", current === value ? "text-muted" : "text-faint")}>{count}</span>
233 </Link>
234 );
235 return (
236 <div className="grid gap-2.5" role="group" aria-label={`Filter ${kind === "extension" ? "extensions" : "integrations"}`}>
237 <div className="flex flex-wrap items-center gap-1.5">
238 <span className="w-24 shrink-0 text-xs text-faint max-sm:w-full">Who builds it</span>
239 <Option group="tier" value="all" current={filters.tier} count={counts.all}>
240 All
241 </Option>
242 {LISTING_TIERS.map((tier) => (
243 <Option key={tier} group="tier" value={tier} current={filters.tier} count={counts.tier[tier]}>
244 <span aria-hidden="true" className={cn("size-1.5 rounded-full", TIER_DOT[tier])} />
245 {TIERS[tier].label}
246 </Option>
247 ))}
248 </div>
249 <div className="flex flex-wrap items-center gap-1.5">
250 <span className="w-24 shrink-0 text-xs text-faint max-sm:w-full">Availability</span>
251 <Option group="availability" value="all" current={filters.availability} count={counts.all}>
252 All
253 </Option>
254 {AVAILABILITIES.map((availability) => (
255 <Option key={availability} group="availability" value={availability} current={filters.availability} count={counts.availability[availability]}>
256 {availability === "added" ? addedWord(kind) : AVAILABILITY[availability].label}
257 </Option>
258 ))}
259 </div>
260 </div>
261 );
262}
263
264const TIER_DOT: Record<ListingTier, string> = { official: "bg-accent", verified: "bg-success", community: "bg-warn", internal: "bg-info" };
265
266/** How many listings each filter option has. */
267export function filterCounts(listings: { tier: ListingTier; availability: Availability }[]) {
268 const tier = Object.fromEntries(LISTING_TIERS.map((t) => [t, listings.filter((l) => l.tier === t).length])) as Record<ListingTier, number>;
269 const availability = Object.fromEntries(AVAILABILITIES.map((a) => [a, listings.filter((l) => l.availability === a).length])) as Record<Availability, number>;
270 return { tier, availability, all: listings.length };
271}
272
273/**
274 * A listing page's header line: its tier, who publishes it, whether it can
275 * be added here (`children`), and its category, each labelled.
276 */
277export function ListingFacts({ tier, publisher, category, children }: { tier: ListingTier; publisher: string; category: string; children: ReactNode }) {
278 const Dot = () => (
279 <span aria-hidden="true" className="text-faint">
280 ·
281 </span>
282 );
283 return (
284 <p className="mt-1.5 flex flex-wrap items-center gap-x-2.5 gap-y-1.5 text-sm text-muted">
285 <TierBadge tier={tier} />
286 <span>
287 by <span className="text-fg-soft">{publisher}</span>
288 </span>
289 <span className="inline-flex items-center gap-2.5">
290 <Dot />
291 {children}
292 </span>
293 <span className="inline-flex items-center gap-2.5">
294 <Dot />
295 {category}
296 </span>
297 </p>
298 );
299}
300
301/** A section's heading, with a count or a link at its end. */
302export function SectionHead({ id, title, aside, sub = false, children }: { id: string; title: ReactNode; aside?: ReactNode; sub?: boolean; children?: ReactNode }) {
303 const Heading = sub ? "h3" : "h2";
304 return (
305 <div className="mb-3">
306 <div className="flex items-baseline justify-between gap-3">
307 <Heading id={id} className={cn("flex items-center gap-2 font-semibold tracking-tight", sub ? "text-sm" : "text-base")}>
308 {title}
309 </Heading>
310 {aside && <div className="shrink-0 text-xs text-muted">{aside}</div>}
311 </div>
312 {children && <p className="mt-1 max-w-2xl text-sm text-muted">{children}</p>}
313 </div>
314 );
315}
316
317/** A link at the end of a section's heading. */
318export function SeeAll({ to, children }: { to: string; children: ReactNode }) {
319 return (
320 <Link to={to} prefetch="intent" className="inline-flex items-center gap-1 text-[0.8125rem] text-muted hover:text-fg">
321 {children}
322 <ArrowRight size={13} />
323 </Link>
324 );
325}
326
327/** What a request's dialog shows of the listing asked for: its mark, tier, publisher and availability. */
328export type RequestedListing = { kind: ListingKind; tier: ListingTier; publisher: string; mark: ReactNode };
329
330/**
331 * Asking the workspace's owners to add something: a dialog that says
332 * exactly what is asked for (who builds it, and that it can be added),
333 * with an optional note, sent to the requests route. Once sent, it says so.
334 */
335export function RequestButton({
336 slug,
337 listing,
338 name,
339 requested,
340 about,
341 className,
342}: {
343 slug: string;
344 listing: string;
345 name: string;
346 requested: boolean;
347 about?: RequestedListing;
348 className?: string;
349}) {
350 const fetcher = useFetcher<{ error: string | null }>({ key: `request:${listing}` });
351 const [open, setOpen] = useState(false);
352 const sent = fetcher.state === "idle" && fetcher.data?.error === null;
353 useEffect(() => {
354 if (sent) setOpen(false);
355 }, [sent]);
356 if (requested || sent) {
357 return (
358 <Hint label="The workspace's owners have your request. You'll hear when they answer.">
359 <span tabIndex={0} className={cn(SMALL, "border border-line text-muted outline-none focus-visible:ring-2 focus-visible:ring-accent", className)}>
360 <Clock size={13} />
361 Requested
362 </span>
363 </Hint>
364 );
365 }
366 return (
367 <Dialog open={open} onOpenChange={setOpen}>
368 <DialogTrigger asChild>
369 <button type="button" className={cn(ACTION.quiet, className)}>
370 <Send size={13} />
371 Request
372 </button>
373 </DialogTrigger>
374 <DialogContent>
375 <fetcher.Form method="post" action={marketplacePath(slug, "requests")} className="grid gap-4">
376 <DialogHeader>
377 <DialogTitle>Ask to add {name}</DialogTitle>
378 <DialogDescription>Only the workspace's owners add extensions and integrations. Each of them is notified, and you hear back when one answers.</DialogDescription>
379 </DialogHeader>
380 {about && (
381 <div className="flex items-center gap-3 rounded-lg border border-line bg-bg px-3 py-2.5">
382 {about.mark}
383 <div className="min-w-0">
384 <p className="truncate text-sm font-medium">{name}</p>
385 <p className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted">
386 <TierBadge tier={about.tier} />
387 <span>by {about.publisher}</span>
388 <span aria-hidden="true" className="text-faint">
389 ·
390 </span>
391 <AvailabilityBadge availability="available" kind={about.kind} hint="An owner can add it as soon as they agree." />
392 </p>
393 </div>
394 </div>
395 )}
396 <input type="hidden" name="intent" value="request" />
397 <input type="hidden" name="listing" value={listing} />
398 <label className="grid gap-1.5 text-sm">
399 <span className="font-medium">
400 Why you want it <span className="font-normal text-faint">(optional)</span>
401 </span>
402 <Textarea name="note" rows={3} maxLength={280} placeholder="What it would help with" />
403 </label>
404 {fetcher.data?.error && <ErrorText>{fetcher.data.error}</ErrorText>}
405 <DialogFooter>
406 <SubmitButton fetcher={fetcher} pending="Sending…">
407 <Send size={14} />
408 Send request
409 </SubmitButton>
410 </DialogFooter>
411 </fetcher.Form>
412 </DialogContent>
413 </Dialog>
414 );
415}
416
417/** "3 waiting": open requests for a listing, for owners. */
418function Waiting({ slug, count }: { slug: string; count: number }) {
419 if (count === 0) return null;
420 return (
421 <Link to={marketplacePath(slug, "requests")} className="text-xs text-warn hover:underline">
422 {count} {count === 1 ? "request" : "requests"}
423 </Link>
424 );
425}
426
427/**
428 * What an owner or a member can do with an integration: connect it, ask
429 * for it, or manage it once connected; nothing while it is Soon or not
430 * available here, where the availability says why instead.
431 */
432export function IntegrationAction({ listing, slug, owner, className }: { listing: IntegrationListing; slug: string; owner: boolean; className?: string }) {
433 if (listing.availability === "soon" || listing.availability === "unavailable") return null;
434 if (listing.scope === "personal") {
435 return listing.href ? (
436 <Link to={listing.href} className={cn(ACTION.quiet, className)}>
437 Connect yours
438 </Link>
439 ) : null;
440 }
441 if (listing.connected) {
442 return listing.href ? (
443 <Link to={listing.href} className={cn(ACTION.quiet, className)}>
444 Manage
445 </Link>
446 ) : null;
447 }
448 if (owner) {
449 return listing.href ? (
450 <Link to={listing.href} className={cn(ACTION.primary, className)}>
451 <Plus size={14} />
452 Connect
453 </Link>
454 ) : null;
455 }
456 return (
457 <RequestButton
458 slug={slug}
459 listing={listing.ref}
460 name={listing.view.name}
461 requested={listing.requested}
462 about={{ kind: "integration", tier: listing.tier, publisher: listing.publisher, mark: <ConnectorMark view={listing.view} size={32} /> }}
463 className={className}
464 />
465 );
466}
467
468/** Whether a listing can't be added by anyone here: drawn muted and dashed, with nothing to press. */
469function inert(availability: Availability): boolean {
470 return availability === "soon" || availability === "unavailable";
471}
472
473/** The card every listing shares: a link to its page over the whole card, with its badges and actions above it. */
474function ListingShell({ to, name, muted, children }: { to: string; name: string; muted: boolean; children: ReactNode }) {
475 return (
476 <article
477 className={cn(
478 "relative flex flex-col rounded-xl border p-4 transition-colors",
479 muted ? "border-dashed border-line-strong/70 bg-transparent hover:border-line-strong" : "border-line bg-surface hover:border-line-strong",
480 )}
481 >
482 <Link to={to} prefetch="intent" aria-label={name} className="absolute inset-0 rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-accent" />
483 {children}
484 </article>
485 );
486}
487
488/**
489 * An integration as a card: its mark, who builds it, what it does for
490 * agents, whether the workspace can add it or has it, and the way to.
491 */
492export function IntegrationCard({ listing, slug, owner }: { listing: IntegrationListing; slug: string; owner: boolean }) {
493 const { view, connected } = listing;
494 const muted = inert(listing.availability);
495 return (
496 <ListingShell to={listing.path} name={view.name} muted={muted}>
497 <div className="flex items-start gap-3">
498 <span className={cn("shrink-0", muted && "opacity-60 grayscale")}>
499 <ConnectorMark view={view} size={40} />
500 </span>
501 <div className="min-w-0 grow">
502 <div className="flex min-w-0 items-center justify-between gap-2">
503 <h3 className={cn("truncate text-sm font-semibold", muted && "text-fg-soft")}>{view.name}</h3>
504 <span className="relative shrink-0">
505 <TierBadge tier={listing.tier} />
506 </span>
507 </div>
508 <p className="mt-0.5 line-clamp-2 text-[0.8125rem] leading-snug text-muted">{view.description}</p>
509 </div>
510 </div>
511 {view.capabilities.length > 0 && (
512 <ul className="mt-3 flex grow flex-wrap content-start gap-1.5" aria-label={muted ? "What it will do" : "What it does"}>
513 {view.capabilities.map((capability) => (
514 <li key={capability} className={cn("rounded-full px-2 py-px text-[0.6875rem]", muted ? "border border-dashed border-line text-faint" : "bg-raised text-muted")}>
515 {capability}
516 </li>
517 ))}
518 </ul>
519 )}
520 <div className="relative mt-4 flex min-h-8 items-center justify-between gap-3 border-t border-line pt-3">
521 <span className="flex min-w-0 items-center gap-1.5 text-xs">
522 {connected?.problem ? (
523 <Hint label={connected.problem}>
524 <span tabIndex={0} className="inline-flex min-w-0 items-center gap-1 text-warn outline-none focus-visible:ring-2 focus-visible:ring-accent">
525 <CircleAlert size={13} className="shrink-0" />
526 <span className="truncate">Needs attention</span>
527 </span>
528 </Hint>
529 ) : (
530 <AvailabilityBadge
531 availability={listing.availability}
532 kind="integration"
533 why={listing.why}
534 hint={
535 listing.scope === "personal" && listing.availability === "available"
536 ? "Each person connects their own. Agents use it only when that person asks."
537 : listing.availability === "available" && !owner
538 ? "An owner connects it for everyone. Ask one with Request."
539 : undefined
540 }
541 />
542 )}
543 {listing.scope === "personal" && <span className="truncate text-faint">· Each person's own</span>}
544 {owner && listing.waiting > 0 && (
545 <>
546 <span className="text-faint">·</span>
547 <Waiting slug={slug} count={listing.waiting} />
548 </>
549 )}
550 </span>
551 <IntegrationAction listing={listing} slug={slug} owner={owner} />
552 </div>
553 </ListingShell>
554 );
555}
556
557/**
558 * An integration as a compact row, for the long list of coming ones:
559 * muted and dashed, its tier and Soon, and nothing to press but its page.
560 */
561export function IntegrationRow({ listing }: { listing: IntegrationListing }) {
562 const { view } = listing;
563 return (
564 <li className="relative flex items-center gap-3 rounded-lg border border-dashed border-line-strong/70 px-3 py-2.5 transition-colors hover:border-line-strong">
565 <Link to={listing.path} prefetch="intent" aria-label={view.name} className="absolute inset-0 rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-accent" />
566 <span className="shrink-0 opacity-60 grayscale">
567 <ConnectorMark view={view} size={28} />
568 </span>
569 <span className="min-w-0 grow">
570 <span className="block truncate text-sm text-fg-soft">{view.name}</span>
571 <span className="block truncate text-xs text-faint">{view.description}</span>
572 </span>
573 <span className="relative flex shrink-0 flex-col items-end gap-1">
574 <TierBadge tier={listing.tier} />
575 <AvailabilityBadge availability={listing.availability} kind="integration" why={listing.why} />
576 </span>
577 </li>
578 );
579}
580
581/** An extension's mark: its initials on a steady colour, until listings bring their own icon. */
582export function ExtensionMark({ manifest, size = 40 }: { manifest: Pick<ExtensionManifest, "id" | "name">; size?: number }) {
583 return <ConnectorMark view={{ id: manifest.id, name: manifest.name, provider: null }} size={size} />;
584}
585
586/** What an owner or a member can do with an extension: install or ask, once it is published; nothing before, or once installed. */
587export function ExtensionAction({ listing, slug, owner, className }: { listing: ExtensionListing; slug: string; owner: boolean; className?: string }) {
588 const fetcher = useFetcher<{ error: string | null }>();
589 const { manifest } = listing;
590 if (listing.availability !== "available") return null;
591 if (!owner) {
592 return (
593 <RequestButton
594 slug={slug}
595 listing={listing.ref}
596 name={manifest.name}
597 requested={listing.requested}
598 about={{ kind: "extension", tier: listing.tier, publisher: manifest.publisher.name, mark: <ExtensionMark manifest={manifest} size={32} /> }}
599 className={className}
600 />
601 );
602 }
603 return (
604 <fetcher.Form method="post" action={marketplacePath(slug, "requests")}>
605 <input type="hidden" name="intent" value="install" />
606 <input type="hidden" name="extension" value={manifest.id} />
607 <SubmitButton fetcher={fetcher} className={cn(ACTION.primary, className)} pending="Installing…">
608 <Plus size={14} />
609 Install
610 </SubmitButton>
611 </fetcher.Form>
612 );
613}
614
615/** An extension's availability, as its card and page show it. */
616export function ExtensionAvailability({ listing, owner }: { listing: ExtensionListing; owner: boolean }) {
617 return (
618 <AvailabilityBadge
619 availability={listing.availability}
620 kind="extension"
621 off={listing.install ? !listing.install.enabled : false}
622 hint={
623 listing.availability === "soon"
624 ? "Not published yet. It can be installed from its first release."
625 : listing.availability === "available" && !owner
626 ? "An owner installs it for everyone. Ask one with Request."
627 : listing.install?.enabled
628 ? `Version ${listing.install.version} is installed.`
629 : undefined
630 }
631 />
632 );
633}
634
635/** Where an extension's data goes, in a few words for a card. */
636function dataLine(manifest: Pick<ExtensionManifest, "domains" | "bridges">): string {
637 // "the CRM you connect" reads, on a card, as "Works with your CRM".
638 if (manifest.bridges) return `Works with ${manifest.bridges.replace(/^the (.+) you connect$/, "your $1")}`;
639 return manifest.domains.length === 0 ? "Data stays in g1t" : `Data goes to ${manifest.domains[0]}`;
640}
641
642/** An extension as a card: its mark, publisher and tier, what it does, where its data goes, and whether it can be added. */
643export function ExtensionCard({ listing, slug, owner }: { listing: ExtensionListing; slug: string; owner: boolean }) {
644 const { manifest } = listing;
645 const muted = inert(listing.availability);
646 return (
647 <ListingShell to={extensionPath(slug, manifest.id)} name={manifest.name} muted={muted}>
648 <div className="flex items-start gap-3">
649 <span className={cn("shrink-0", muted && "opacity-60 grayscale")}>
650 <ExtensionMark manifest={manifest} />
651 </span>
652 <div className="min-w-0 grow">
653 <h3 className={cn("truncate text-sm font-semibold", muted && "text-fg-soft")}>{manifest.name}</h3>
654 <p className="truncate text-xs text-muted">
655 by {manifest.publisher.name} · {manifest.category}
656 </p>
657 </div>
658 <span className="relative shrink-0">
659 <TierBadge tier={listing.tier} />
660 </span>
661 </div>
662 <p className="mt-3 line-clamp-2 grow text-[0.8125rem] leading-snug text-muted">{manifest.tagline}</p>
663 <p className="mt-2 truncate text-xs text-faint">{dataLine(manifest)}</p>
664 <div className="relative mt-3 flex min-h-8 items-center justify-between gap-3 border-t border-line pt-3">
665 <span className="flex min-w-0 items-center gap-1.5 text-xs">
666 <ExtensionAvailability listing={listing} owner={owner} />
667 {owner && listing.waiting > 0 && (
668 <>
669 <span className="text-faint">·</span>
670 <Waiting slug={slug} count={listing.waiting} />
671 </>
672 )}
673 </span>
674 <ExtensionAction listing={listing} slug={slug} owner={owner} />
675 </div>
676 </ListingShell>
677 );
678}
679
680/** A request's status, in a word. */
681export function RequestStatus({ request }: { request: InstallRequest }) {
682 if (request.status === "open") return <Badge tone="warn">Waiting</Badge>;
683 if (request.status === "done") return <Badge tone="success">Added</Badge>;
684 return <Badge tone="neutral">Turned down</Badge>;
685}
686
687/**
688 * One request as a row: what was asked for, by whom, why and when; for an
689 * owner, while it waits, the way to add it, mark it added, or turn it down.
690 */
691export function RequestRow({ request, slug, owner, addTo }: { request: InstallRequest; slug: string; owner: boolean; addTo: string | null }) {
692 const fetcher = useFetcher<{ error: string | null }>();
693 const action = marketplacePath(slug, "requests");
694 return (
695 <li className="flex flex-wrap items-start gap-x-4 gap-y-3 px-4 py-3.5 sm:flex-nowrap">
696 <div className="min-w-0 grow">
697 <p className="flex flex-wrap items-center gap-2 text-sm">
698 <span className="font-medium">{request.name}</span>
699 <span className="text-xs text-faint">{request.kind === "extension" ? "Extension" : "Integration"}</span>
700 <RequestStatus request={request} />
701 </p>
702 <p className="mt-0.5 text-xs text-muted">
703 {owner ? `@${request.requested_by} asked ` : "You asked "}
704 <TimeAgo at={request.requested_at} />
705 {request.resolved_by && request.status !== "open" && (
706 <>
707 {" · "}
708 {request.status === "done" ? "added" : "turned down"} by @{request.resolved_by}
709 </>
710 )}
711 </p>
712 {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>}
713 {fetcher.data?.error && (
714 <div className="mt-2">
715 <ErrorText>{fetcher.data.error}</ErrorText>
716 </div>
717 )}
718 </div>
719 {owner && request.status === "open" && (
720 <fetcher.Form method="post" action={action} className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:shrink-0">
721 <input type="hidden" name="intent" value="resolve" />
722 <input type="hidden" name="id" value={request.id} />
723 {addTo && (
724 <Link to={addTo} className={ACTION.primary}>
725 <Plus size={14} />
726 {request.kind === "extension" ? "Install" : "Connect"}
727 </Link>
728 )}
729 <SubmitButton fetcher={fetcher} name="status" value="done" className={ACTION.quiet} pending="Saving…">
730 <Check size={14} />
731 Mark added
732 </SubmitButton>
733 <SubmitButton fetcher={fetcher} name="status" value="declined" className={ACTION.quiet} pending="Saving…">
734 <X size={14} />
735 Turn down
736 </SubmitButton>
737 </fetcher.Form>
738 )}
739 </li>
740 );
741}
742
743/**
744 * A starter kit as a card: the extensions it brings, overlapped, its name
745 * and what it is for, who builds them, and whether it can be added.
746 * Installed together once every one is published; until then it is Soon,
747 * drawn muted with nothing to press.
748 */
749export function StarterKitCard({ kit, extensions, slug }: { kit: StarterKit; extensions: ExtensionManifest[]; slug: string }) {
750 const ready = extensions.length > 0 && extensions.every((extension) => extension.status === "available");
751 const tiers = [...new Set(extensions.map((extension) => extension.publisher.tier))];
752 return (
753 <article className={cn("flex flex-col rounded-xl border p-4", ready ? "border-line bg-surface" : "border-dashed border-line-strong/70")}>
754 <div className="flex items-center justify-between gap-3">
755 <div className={cn("flex items-center", !ready && "opacity-60 grayscale")}>
756 {extensions.map((extension, index) => (
757 <span key={extension.id} className={cn("rounded-[0.6rem] ring-2 ring-bg", index > 0 && "-ml-2")}>
758 <ExtensionMark manifest={extension} size={30} />
759 </span>
760 ))}
761 </div>
762 <span className="flex gap-1">
763 {tiers.map((tier) => (
764 <TierBadge key={tier} tier={tier} />
765 ))}
766 </span>
767 </div>
768 <h3 className={cn("mt-3 text-sm font-semibold", !ready && "text-fg-soft")}>{kit.name}</h3>
769 <p className="mt-0.5 grow text-[0.8125rem] leading-snug text-muted">{kit.about}</p>
770 <p className="mt-2 truncate text-xs text-faint">
771 {extensions.map((extension, index) => (
772 <span key={extension.id}>
773 {index > 0 && ", "}
774 <Link to={extensionPath(slug, extension.id)} className="hover:text-fg hover:underline">
775 {extension.name}
776 </Link>
777 </span>
778 ))}
779 </p>
780 <div className="mt-3 flex min-h-8 items-center border-t border-line pt-3">
781 <AvailabilityBadge
782 availability={ready ? "available" : "soon"}
783 kind="extension"
784 hint={ready ? undefined : "Its extensions aren't all published yet. The kit installs them together once they are."}
785 />
786 </div>
787 </article>
788 );
789}