@greatapps/greatagents-ui 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/index.d.ts +204 -1
  2. package/dist/index.js +1797 -125
  3. package/dist/index.js.map +1 -1
  4. package/package.json +1 -1
  5. package/src/client/index.ts +14 -0
  6. package/src/components/agents/agent-edit-form.tsx +4 -1
  7. package/src/components/agents/agent-form-dialog.tsx +5 -1
  8. package/src/components/agents/agent-objectives-list.tsx +15 -6
  9. package/src/components/agents/agent-prompt-editor.tsx +9 -5
  10. package/src/components/agents/agent-tabs.tsx +4 -4
  11. package/src/components/agents/agent-tools-list.tsx +12 -5
  12. package/src/components/agents/agents-table.tsx +7 -2
  13. package/src/components/capabilities/advanced-tab.tsx +82 -0
  14. package/src/components/capabilities/capabilities-tab.tsx +475 -0
  15. package/src/components/capabilities/integration-card.tsx +162 -0
  16. package/src/components/capabilities/integration-wizard.tsx +537 -0
  17. package/src/components/capabilities/integrations-tab.tsx +61 -0
  18. package/src/components/capabilities/types.ts +48 -0
  19. package/src/components/capabilities/wizard-steps/config-step.tsx +117 -0
  20. package/src/components/capabilities/wizard-steps/confirm-step.tsx +123 -0
  21. package/src/components/capabilities/wizard-steps/credentials-step.tsx +205 -0
  22. package/src/components/capabilities/wizard-steps/info-step.tsx +78 -0
  23. package/src/components/conversations/agent-conversations-table.tsx +13 -2
  24. package/src/components/conversations/conversation-view.tsx +2 -2
  25. package/src/components/tools/tool-credentials-form.tsx +34 -14
  26. package/src/components/tools/tool-form-dialog.tsx +9 -5
  27. package/src/components/tools/tools-table.tsx +8 -3
  28. package/src/data/integrations-registry.ts +23 -0
  29. package/src/hooks/index.ts +10 -0
  30. package/src/hooks/use-capabilities.ts +50 -0
  31. package/src/hooks/use-integrations.ts +114 -0
  32. package/src/index.ts +34 -0
  33. package/src/pages/agent-capabilities-page.tsx +159 -0
  34. package/src/pages/agent-detail-page.tsx +1 -0
  35. package/src/pages/index.ts +2 -0
  36. package/src/pages/integrations-management-page.tsx +166 -0
  37. package/src/types/capabilities.ts +32 -0
  38. package/src/types/index.ts +10 -0
@@ -0,0 +1,475 @@
1
+ 'use client';
2
+
3
+ import { useState, useCallback, useRef, useEffect, useMemo } from "react";
4
+ import type { GagentsHookConfig } from "../../hooks/types";
5
+ import {
6
+ useCapabilities,
7
+ useAgentCapabilities,
8
+ useUpdateAgentCapabilities,
9
+ } from "../../hooks";
10
+ import type {
11
+ CapabilityCategory,
12
+ CapabilityModule,
13
+ AgentCapability,
14
+ AgentCapabilitiesPayload,
15
+ } from "../../types/capabilities";
16
+ import {
17
+ Accordion,
18
+ AccordionItem,
19
+ AccordionTrigger,
20
+ AccordionContent,
21
+ Switch,
22
+ Checkbox,
23
+ Badge,
24
+ Button,
25
+ Skeleton,
26
+ } from "@greatapps/greatauth-ui/ui";
27
+ import {
28
+ Calendar,
29
+ Users,
30
+ Settings,
31
+ HeartHandshake,
32
+ Package,
33
+ ChevronDown,
34
+ } from "lucide-react";
35
+ import { toast } from "sonner";
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Operation label mapping (pt-BR)
39
+ // ---------------------------------------------------------------------------
40
+
41
+ const OPERATION_LABELS: Record<string, string> = {
42
+ list: "Listar",
43
+ view: "Visualizar",
44
+ create: "Criar",
45
+ update: "Atualizar",
46
+ };
47
+
48
+ function getOperationLabel(slug: string): string {
49
+ return OPERATION_LABELS[slug] ?? slug;
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Category icon mapping
54
+ // ---------------------------------------------------------------------------
55
+
56
+ const CATEGORY_ICONS: Record<string, React.ElementType> = {
57
+ agenda: Calendar,
58
+ cadastros: Users,
59
+ infraestrutura: Settings,
60
+ relacionamentos: HeartHandshake,
61
+ };
62
+
63
+ function getCategoryIcon(slug: string): React.ElementType {
64
+ return CATEGORY_ICONS[slug] ?? Package;
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Internal state helpers
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /** Map from module slug -> set of enabled operation slugs */
72
+ type CapabilityState = Map<string, Set<string>>;
73
+
74
+ function buildStateFromAgent(agentCaps: AgentCapability[]): CapabilityState {
75
+ const state: CapabilityState = new Map();
76
+ for (const cap of agentCaps) {
77
+ state.set(cap.module, new Set(cap.operations));
78
+ }
79
+ return state;
80
+ }
81
+
82
+ function stateToPayload(state: CapabilityState): AgentCapabilitiesPayload {
83
+ const capabilities: AgentCapability[] = [];
84
+ state.forEach((ops, mod) => {
85
+ if (ops.size > 0) {
86
+ capabilities.push({ module: mod, operations: Array.from(ops) });
87
+ }
88
+ });
89
+ return { capabilities };
90
+ }
91
+
92
+ function cloneState(state: CapabilityState): CapabilityState {
93
+ const next: CapabilityState = new Map();
94
+ state.forEach((ops, mod) => next.set(mod, new Set(ops)));
95
+ return next;
96
+ }
97
+
98
+ function statesEqual(a: CapabilityState, b: CapabilityState): boolean {
99
+ if (a.size !== b.size) return false;
100
+ for (const [mod, opsA] of a) {
101
+ const opsB = b.get(mod);
102
+ if (!opsB || opsA.size !== opsB.size) return false;
103
+ for (const op of opsA) {
104
+ if (!opsB.has(op)) return false;
105
+ }
106
+ }
107
+ return true;
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Props
112
+ // ---------------------------------------------------------------------------
113
+
114
+ export interface CapabilitiesTabProps {
115
+ config: GagentsHookConfig;
116
+ agentId: number;
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Component
121
+ // ---------------------------------------------------------------------------
122
+
123
+ export function CapabilitiesTab({ config, agentId }: CapabilitiesTabProps) {
124
+ const { data: registry, isLoading: isLoadingRegistry } = useCapabilities(config);
125
+ const { data: agentCaps, isLoading: isLoadingAgent } = useAgentCapabilities(config, agentId);
126
+ const updateMutation = useUpdateAgentCapabilities(config);
127
+
128
+ // Local state for optimistic updates
129
+ const [localState, setLocalState] = useState<CapabilityState>(new Map());
130
+ const [serverState, setServerState] = useState<CapabilityState>(new Map());
131
+ const [initialized, setInitialized] = useState(false);
132
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
133
+
134
+ // Sync server data into local state on first load / refetch
135
+ useEffect(() => {
136
+ if (agentCaps && !initialized) {
137
+ const state = buildStateFromAgent(agentCaps);
138
+ setLocalState(state);
139
+ setServerState(cloneState(state));
140
+ setInitialized(true);
141
+ }
142
+ }, [agentCaps, initialized]);
143
+
144
+ // Reset initialized when agentId changes
145
+ useEffect(() => {
146
+ setInitialized(false);
147
+ }, [agentId]);
148
+
149
+ const hasChanges = useMemo(
150
+ () => initialized && !statesEqual(localState, serverState),
151
+ [localState, serverState, initialized],
152
+ );
153
+
154
+ // ------ Debounced save ------
155
+ const scheduleSave = useCallback(
156
+ (nextState: CapabilityState) => {
157
+ if (debounceRef.current) clearTimeout(debounceRef.current);
158
+ debounceRef.current = setTimeout(() => {
159
+ const payload = stateToPayload(nextState);
160
+ updateMutation.mutate(
161
+ { agentId, payload },
162
+ {
163
+ onSuccess: () => {
164
+ setServerState(cloneState(nextState));
165
+ toast.success("Capacidades salvas");
166
+ },
167
+ onError: () => {
168
+ // Rollback to server state
169
+ setLocalState(cloneState(serverState));
170
+ toast.error("Erro ao salvar capacidades");
171
+ },
172
+ },
173
+ );
174
+ }, 500);
175
+ },
176
+ [agentId, updateMutation, serverState],
177
+ );
178
+
179
+ // ------ State mutation helpers ------
180
+ const updateState = useCallback(
181
+ (updater: (prev: CapabilityState) => CapabilityState) => {
182
+ setLocalState((prev) => {
183
+ const next = updater(prev);
184
+ scheduleSave(next);
185
+ return next;
186
+ });
187
+ },
188
+ [scheduleSave],
189
+ );
190
+
191
+ const toggleModule = useCallback(
192
+ (mod: CapabilityModule, enabled: boolean) => {
193
+ updateState((prev) => {
194
+ const next = cloneState(prev);
195
+ if (enabled) {
196
+ next.set(mod.slug, new Set(mod.operations));
197
+ } else {
198
+ next.delete(mod.slug);
199
+ }
200
+ return next;
201
+ });
202
+ },
203
+ [updateState],
204
+ );
205
+
206
+ const toggleOperation = useCallback(
207
+ (mod: CapabilityModule, opSlug: string, enabled: boolean) => {
208
+ updateState((prev) => {
209
+ const next = cloneState(prev);
210
+ const ops = new Set(next.get(mod.slug) ?? []);
211
+ if (enabled) {
212
+ ops.add(opSlug);
213
+ } else {
214
+ ops.delete(opSlug);
215
+ }
216
+ if (ops.size > 0) {
217
+ next.set(mod.slug, ops);
218
+ } else {
219
+ next.delete(mod.slug);
220
+ }
221
+ return next;
222
+ });
223
+ },
224
+ [updateState],
225
+ );
226
+
227
+ const enableAll = useCallback(() => {
228
+ if (!registry) return;
229
+ updateState(() => {
230
+ const next: CapabilityState = new Map();
231
+ for (const cat of registry.categories) {
232
+ for (const mod of cat.modules) {
233
+ next.set(mod.slug, new Set(mod.operations));
234
+ }
235
+ }
236
+ return next;
237
+ });
238
+ }, [registry, updateState]);
239
+
240
+ const disableAll = useCallback(() => {
241
+ updateState(() => new Map());
242
+ }, [updateState]);
243
+
244
+ const discard = useCallback(() => {
245
+ if (debounceRef.current) clearTimeout(debounceRef.current);
246
+ setLocalState(cloneState(serverState));
247
+ }, [serverState]);
248
+
249
+ const saveNow = useCallback(() => {
250
+ if (debounceRef.current) clearTimeout(debounceRef.current);
251
+ const payload = stateToPayload(localState);
252
+ updateMutation.mutate(
253
+ { agentId, payload },
254
+ {
255
+ onSuccess: () => {
256
+ setServerState(cloneState(localState));
257
+ toast.success("Capacidades salvas");
258
+ },
259
+ onError: () => {
260
+ setLocalState(cloneState(serverState));
261
+ toast.error("Erro ao salvar capacidades");
262
+ },
263
+ },
264
+ );
265
+ }, [agentId, localState, serverState, updateMutation]);
266
+
267
+ // ------ Counting helpers ------
268
+ function countActiveModules(cat: CapabilityCategory): number {
269
+ return cat.modules.filter((m) => (localState.get(m.slug)?.size ?? 0) > 0).length;
270
+ }
271
+
272
+ // ------ Loading state ------
273
+ if (isLoadingRegistry || isLoadingAgent) {
274
+ return (
275
+ <div className="space-y-3">
276
+ {[1, 2, 3, 4].map((i) => (
277
+ <Skeleton key={i} className="h-14 w-full" />
278
+ ))}
279
+ </div>
280
+ );
281
+ }
282
+
283
+ // ------ Empty state ------
284
+ if (!registry || !registry.categories.length) {
285
+ return (
286
+ <div className="flex flex-col items-center justify-center py-12 text-center">
287
+ <Package className="h-12 w-12 text-muted-foreground mb-3" />
288
+ <h3 className="text-base font-medium">Nenhuma capacidade disponível</h3>
289
+ <p className="text-sm text-muted-foreground mt-1 max-w-sm">
290
+ Este produto ainda não possui capacidades registadas. As capacidades serão
291
+ adicionadas automaticamente quando o produto for configurado.
292
+ </p>
293
+ </div>
294
+ );
295
+ }
296
+
297
+ // ------ Render ------
298
+ return (
299
+ <div className="space-y-4">
300
+ {/* Header with global actions */}
301
+ <div className="flex items-center justify-between">
302
+ <div>
303
+ <h3 className="text-sm font-medium">Capacidades do agente</h3>
304
+ <p className="text-xs text-muted-foreground mt-0.5">
305
+ Ative ou desative módulos e operações disponíveis para este agente.
306
+ </p>
307
+ </div>
308
+ <div className="flex items-center gap-2">
309
+ <Button variant="outline" size="sm" onClick={enableAll}>
310
+ Ativar tudo
311
+ </Button>
312
+ <Button variant="outline" size="sm" onClick={disableAll}>
313
+ Desativar tudo
314
+ </Button>
315
+ </div>
316
+ </div>
317
+
318
+ {/* Category accordions */}
319
+ <Accordion type="multiple" className="space-y-2">
320
+ {registry.categories.map((cat) => {
321
+ const Icon = getCategoryIcon(cat.slug);
322
+ const activeCount = countActiveModules(cat);
323
+ const totalModules = cat.modules.length;
324
+
325
+ return (
326
+ <AccordionItem
327
+ key={cat.slug}
328
+ value={cat.slug}
329
+ className="border rounded-lg px-4"
330
+ >
331
+ <AccordionTrigger className="hover:no-underline py-3">
332
+ <div className="flex items-center gap-3 flex-1">
333
+ <Icon className="h-4 w-4 text-muted-foreground" />
334
+ <span className="font-medium text-sm">{cat.label}</span>
335
+ <Badge variant="secondary" className="text-xs">
336
+ {activeCount} de {totalModules} módulos ativos
337
+ </Badge>
338
+ </div>
339
+ </AccordionTrigger>
340
+ <AccordionContent className="pb-3">
341
+ <div className="space-y-1">
342
+ {cat.modules.map((mod) => {
343
+ const enabledOps = localState.get(mod.slug);
344
+ const isModuleOn = (enabledOps?.size ?? 0) > 0;
345
+ const allOpsEnabled =
346
+ enabledOps?.size === mod.operations.length;
347
+
348
+ return (
349
+ <ModuleRow
350
+ key={mod.slug}
351
+ module={mod}
352
+ isOn={isModuleOn}
353
+ allOpsEnabled={allOpsEnabled}
354
+ enabledOps={enabledOps ?? new Set()}
355
+ onToggleModule={(on) => toggleModule(mod, on)}
356
+ onToggleOperation={(op, on) =>
357
+ toggleOperation(mod, op, on)
358
+ }
359
+ />
360
+ );
361
+ })}
362
+ </div>
363
+ </AccordionContent>
364
+ </AccordionItem>
365
+ );
366
+ })}
367
+ </Accordion>
368
+
369
+ {/* Save bar */}
370
+ {hasChanges && (
371
+ <div className="sticky bottom-0 bg-background border-t py-3 px-4 -mx-4 flex items-center justify-between">
372
+ <span className="text-sm text-muted-foreground">
373
+ Você tem alterações não salvas.
374
+ </span>
375
+ <div className="flex items-center gap-2">
376
+ <Button variant="outline" size="sm" onClick={discard}>
377
+ Descartar
378
+ </Button>
379
+ <Button
380
+ size="sm"
381
+ onClick={saveNow}
382
+ disabled={updateMutation.isPending}
383
+ >
384
+ {updateMutation.isPending ? "Salvando..." : "Salvar"}
385
+ </Button>
386
+ </div>
387
+ </div>
388
+ )}
389
+ </div>
390
+ );
391
+ }
392
+
393
+ // ---------------------------------------------------------------------------
394
+ // ModuleRow sub-component
395
+ // ---------------------------------------------------------------------------
396
+
397
+ interface ModuleRowProps {
398
+ module: CapabilityModule;
399
+ isOn: boolean;
400
+ allOpsEnabled: boolean;
401
+ enabledOps: Set<string>;
402
+ onToggleModule: (on: boolean) => void;
403
+ onToggleOperation: (op: string, on: boolean) => void;
404
+ }
405
+
406
+ function ModuleRow({
407
+ module: mod,
408
+ isOn,
409
+ enabledOps,
410
+ onToggleModule,
411
+ onToggleOperation,
412
+ }: ModuleRowProps) {
413
+ const [expanded, setExpanded] = useState(false);
414
+
415
+ return (
416
+ <div className="rounded-md border px-3 py-2">
417
+ <div className="flex items-center justify-between">
418
+ <button
419
+ type="button"
420
+ className="flex items-center gap-2 flex-1 text-left"
421
+ onClick={() => setExpanded(!expanded)}
422
+ aria-expanded={expanded}
423
+ aria-label={`Expandir ${mod.label}`}
424
+ >
425
+ <ChevronDown
426
+ className={`h-3.5 w-3.5 text-muted-foreground transition-transform ${
427
+ expanded ? "rotate-0" : "-rotate-90"
428
+ }`}
429
+ />
430
+ <span className="text-sm font-medium">{mod.label}</span>
431
+ {mod.description && (
432
+ <span className="text-xs text-muted-foreground hidden sm:inline">
433
+ — {mod.description}
434
+ </span>
435
+ )}
436
+ {isOn && (
437
+ <Badge variant="secondary" className="text-xs ml-1">
438
+ {enabledOps.size}/{mod.operations.length}
439
+ </Badge>
440
+ )}
441
+ </button>
442
+ <Switch
443
+ checked={isOn}
444
+ onCheckedChange={onToggleModule}
445
+ aria-label={`Ativar módulo ${mod.label}`}
446
+ />
447
+ </div>
448
+
449
+ {expanded && (
450
+ <div className="mt-2 ml-6 flex flex-wrap gap-x-5 gap-y-1.5 pb-1">
451
+ {mod.operations.map((op) => {
452
+ const checked = enabledOps.has(op);
453
+ return (
454
+ <label
455
+ key={op}
456
+ className="flex items-center gap-1.5 text-sm cursor-pointer"
457
+ >
458
+ <Checkbox
459
+ checked={checked}
460
+ onCheckedChange={(val) =>
461
+ onToggleOperation(op, val === true)
462
+ }
463
+ aria-label={`${getOperationLabel(op)} em ${mod.label}`}
464
+ />
465
+ <span className={checked ? "" : "text-muted-foreground"}>
466
+ {getOperationLabel(op)}
467
+ </span>
468
+ </label>
469
+ );
470
+ })}
471
+ </div>
472
+ )}
473
+ </div>
474
+ );
475
+ }
@@ -0,0 +1,162 @@
1
+ 'use client';
2
+
3
+ import type { IntegrationCardData, IntegrationCardState } from "../../hooks/use-integrations";
4
+ import { Badge, Button, Tooltip, TooltipContent, TooltipTrigger } from "@greatapps/greatauth-ui/ui";
5
+ import {
6
+ CalendarSync,
7
+ Plug,
8
+ Settings,
9
+ RefreshCw,
10
+ Users,
11
+ Clock,
12
+ } from "lucide-react";
13
+ import type { LucideIcon } from "lucide-react";
14
+ import { cn } from "../../lib";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Icon mapping — extend as new integrations are added
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const ICON_MAP: Record<string, LucideIcon> = {
21
+ CalendarSync,
22
+ Plug,
23
+ Settings,
24
+ RefreshCw,
25
+ Users,
26
+ Clock,
27
+ };
28
+
29
+ function resolveIcon(name: string): LucideIcon {
30
+ return ICON_MAP[name] ?? Plug;
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Badge helpers
35
+ // ---------------------------------------------------------------------------
36
+
37
+ interface BadgeVariant {
38
+ label: string;
39
+ className: string;
40
+ }
41
+
42
+ const STATE_BADGES: Record<IntegrationCardState, BadgeVariant> = {
43
+ available: {
44
+ label: "Disponível",
45
+ className: "bg-muted text-muted-foreground",
46
+ },
47
+ connected: {
48
+ label: "Conectado",
49
+ className: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400",
50
+ },
51
+ expired: {
52
+ label: "Expirado",
53
+ className: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400",
54
+ },
55
+ coming_soon: {
56
+ label: "Em breve",
57
+ className: "bg-muted text-muted-foreground",
58
+ },
59
+ };
60
+
61
+ function getActionLabel(state: IntegrationCardState): string {
62
+ switch (state) {
63
+ case "available":
64
+ return "Conectar";
65
+ case "connected":
66
+ return "Configurar";
67
+ case "expired":
68
+ return "Reconectar";
69
+ default:
70
+ return "";
71
+ }
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Component
76
+ // ---------------------------------------------------------------------------
77
+
78
+ export interface IntegrationCardProps {
79
+ card: IntegrationCardData;
80
+ onConnect: (card: IntegrationCardData) => void;
81
+ }
82
+
83
+ export function IntegrationCard({ card, onConnect }: IntegrationCardProps) {
84
+ const { definition, state, sharedByAgentsCount } = card;
85
+ const Icon = resolveIcon(definition.icon);
86
+ const badge = STATE_BADGES[state];
87
+ const actionLabel = getActionLabel(state);
88
+ const isComingSoon = state === "coming_soon";
89
+
90
+ return (
91
+ <div
92
+ className={cn(
93
+ "group relative flex flex-col gap-3 rounded-xl border bg-card p-5 transition-shadow",
94
+ isComingSoon
95
+ ? "opacity-60 cursor-default"
96
+ : "hover:shadow-md cursor-pointer",
97
+ )}
98
+ role="button"
99
+ tabIndex={isComingSoon ? -1 : 0}
100
+ aria-label={`${definition.name} — ${badge.label}`}
101
+ aria-disabled={isComingSoon}
102
+ onClick={() => !isComingSoon && onConnect(card)}
103
+ onKeyDown={(e) => {
104
+ if (!isComingSoon && (e.key === "Enter" || e.key === " ")) {
105
+ e.preventDefault();
106
+ onConnect(card);
107
+ }
108
+ }}
109
+ >
110
+ {/* Header row */}
111
+ <div className="flex items-start justify-between gap-2">
112
+ <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
113
+ <Icon className="h-5 w-5" />
114
+ </div>
115
+ <Badge variant="outline" className={cn("text-xs", badge.className)}>
116
+ {badge.label}
117
+ </Badge>
118
+ </div>
119
+
120
+ {/* Name + description */}
121
+ <div className="space-y-1">
122
+ <h3 className="text-sm font-semibold leading-tight">{definition.name}</h3>
123
+ <p className="text-xs text-muted-foreground leading-relaxed">
124
+ {definition.description}
125
+ </p>
126
+ </div>
127
+
128
+ {/* Footer */}
129
+ <div className="mt-auto flex items-center justify-between gap-2 pt-1">
130
+ {sharedByAgentsCount > 0 ? (
131
+ <Tooltip>
132
+ <TooltipTrigger asChild>
133
+ <span className="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400">
134
+ <Users className="h-3.5 w-3.5" />
135
+ Compartilhada
136
+ </span>
137
+ </TooltipTrigger>
138
+ <TooltipContent>
139
+ Esta credencial está disponível para todos os agentes da conta
140
+ </TooltipContent>
141
+ </Tooltip>
142
+ ) : (
143
+ <span />
144
+ )}
145
+
146
+ {!isComingSoon && (
147
+ <Button
148
+ variant={state === "expired" ? "destructive" : "outline"}
149
+ size="sm"
150
+ className="text-xs"
151
+ onClick={(e) => {
152
+ e.stopPropagation();
153
+ onConnect(card);
154
+ }}
155
+ >
156
+ {actionLabel}
157
+ </Button>
158
+ )}
159
+ </div>
160
+ </div>
161
+ );
162
+ }