@nordsym/apiclaw 1.3.6 → 1.3.7

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 (46) hide show
  1. package/README.md +33 -0
  2. package/convex/_generated/api.d.ts +12 -0
  3. package/convex/billing.ts +651 -216
  4. package/convex/crons.ts +17 -0
  5. package/convex/email.ts +135 -82
  6. package/convex/feedback.ts +265 -0
  7. package/convex/http.ts +80 -4
  8. package/convex/logs.ts +287 -0
  9. package/convex/providerKeys.ts +209 -0
  10. package/convex/providers.ts +18 -0
  11. package/convex/schema.ts +115 -0
  12. package/convex/stripeActions.ts +512 -0
  13. package/convex/webhooks.ts +494 -0
  14. package/convex/workspaces.ts +74 -1
  15. package/dist/index.js +178 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/metered.d.ts +62 -0
  18. package/dist/metered.d.ts.map +1 -0
  19. package/dist/metered.js +81 -0
  20. package/dist/metered.js.map +1 -0
  21. package/dist/stripe.d.ts +62 -0
  22. package/dist/stripe.d.ts.map +1 -1
  23. package/dist/stripe.js +212 -0
  24. package/dist/stripe.js.map +1 -1
  25. package/docs/PRD-final-polish.md +117 -0
  26. package/docs/PRD-mobile-responsive.md +56 -0
  27. package/docs/PRD-navigation-expansion.md +295 -0
  28. package/docs/PRD-stripe-billing.md +312 -0
  29. package/docs/PRD-workspace-cleanup.md +200 -0
  30. package/landing/src/app/api/billing/checkout/route.ts +109 -0
  31. package/landing/src/app/api/billing/payment-method/route.ts +118 -0
  32. package/landing/src/app/api/billing/portal/route.ts +64 -0
  33. package/landing/src/app/auth/verify/page.tsx +20 -5
  34. package/landing/src/app/earn/page.tsx +6 -6
  35. package/landing/src/app/login/page.tsx +1 -1
  36. package/landing/src/app/page.tsx +70 -70
  37. package/landing/src/app/providers/dashboard/page.tsx +1 -1
  38. package/landing/src/app/workspace/page.tsx +3497 -535
  39. package/landing/src/components/CheckoutButton.tsx +188 -0
  40. package/landing/src/components/Toast.tsx +84 -0
  41. package/landing/src/lib/stats.json +1 -1
  42. package/landing/tsconfig.tsbuildinfo +1 -1
  43. package/package.json +1 -1
  44. package/src/index.ts +205 -0
  45. package/src/metered.ts +149 -0
  46. package/src/stripe.ts +253 -0
@@ -33,6 +33,29 @@ import {
33
33
  Terminal,
34
34
  Copy,
35
35
  BookOpen,
36
+ Mail,
37
+ Send,
38
+ ScrollText,
39
+ Webhook,
40
+ Key,
41
+ MessageSquare,
42
+ Bell,
43
+ User,
44
+ Lock,
45
+ Building,
46
+ ChevronUp,
47
+ Bug,
48
+ Sparkles,
49
+ MessageCircle,
50
+ Search,
51
+ Phone,
52
+ Cpu,
53
+ Activity,
54
+ Globe,
55
+ Database,
56
+ Play,
57
+ Star,
58
+ Twitter,
36
59
  } from "lucide-react";
37
60
  import {
38
61
  LineChart,
@@ -45,6 +68,12 @@ import {
45
68
  BarChart,
46
69
  Bar,
47
70
  } from "recharts";
71
+ import {
72
+ CheckoutButton,
73
+ UsageWarningBanner,
74
+ UsageExceededBanner,
75
+ } from "@/components/CheckoutButton";
76
+ import { Toast, useToast } from "@/components/Toast";
48
77
 
49
78
  const CONVEX_URL = process.env.NEXT_PUBLIC_CONVEX_URL || "https://adventurous-avocet-799.convex.cloud";
50
79
 
@@ -63,6 +92,8 @@ interface Workspace {
63
92
  interface Agent {
64
93
  id: string;
65
94
  fingerprint: string;
95
+ name?: string;
96
+ customName?: string | null;
66
97
  lastUsedAt: number;
67
98
  createdAt: number;
68
99
  isCurrent: boolean;
@@ -84,6 +115,16 @@ interface ProviderAPI {
84
115
  hasDirectCall?: boolean;
85
116
  }
86
117
 
118
+ interface ApprovedAPI {
119
+ _id: string;
120
+ name: string;
121
+ description: string;
122
+ category: string;
123
+ status: string;
124
+ hasDirectCall?: boolean;
125
+ icon?: string;
126
+ }
127
+
87
128
  interface ProviderAnalytics {
88
129
  totalCalls: number;
89
130
  uniqueAgents: number;
@@ -95,7 +136,8 @@ interface ProviderAnalytics {
95
136
  topActions: { actionName: string; calls: number }[];
96
137
  }
97
138
 
98
- type TabType = "overview" | "apis" | "analytics" | "agents" | "usage" | "billing";
139
+ type TabType = "overview" | "api-catalog" | "my-agents" | "my-apis" | "analytics" | "webhooks" | "api-keys" | "earn" | "docs" | "feedback" | "settings" | "billing";
140
+ type AnalyticsSubtab = "overview" | "usage" | "logs";
99
141
 
100
142
  // Generate preview analytics data for demo
101
143
  function generatePreviewAnalytics(): ProviderAnalytics {
@@ -138,11 +180,12 @@ export default function WorkspacePage() {
138
180
  const router = useRouter();
139
181
  const searchParams = useSearchParams();
140
182
  const tabFromUrl = searchParams.get("tab") as TabType | null;
183
+ const subFromUrl = searchParams.get("sub") as AnalyticsSubtab | null;
141
184
 
142
185
  const [isLoading, setIsLoading] = useState(true);
143
186
  const [error, setError] = useState<string | null>(null);
144
187
  const [activeTab, setActiveTab] = useState<TabType>(tabFromUrl || "overview");
145
- const [analyticsSubtab, setAnalyticsSubtab] = useState<"apis" | "agents">("apis");
188
+ const [analyticsSubtab, setAnalyticsSubtab] = useState<AnalyticsSubtab>(subFromUrl || "overview");
146
189
  const [analyticsExpanded, setAnalyticsExpanded] = useState(tabFromUrl === "analytics");
147
190
  const [sidebarOpen, setSidebarOpen] = useState(false);
148
191
  const [isDark, setIsDark] = useState(true);
@@ -155,24 +198,53 @@ export default function WorkspacePage() {
155
198
 
156
199
  // Provider data
157
200
  const [providerApis, setProviderApis] = useState<ProviderAPI[]>([]);
201
+ const [approvedApis, setApprovedApis] = useState<ApprovedAPI[]>([]);
158
202
  const [providerAnalytics, setProviderAnalytics] = useState<ProviderAnalytics | null>(null);
159
203
  const [providerName, setProviderName] = useState<string | null>(null);
160
204
  const [isProvider, setIsProvider] = useState(false);
205
+
206
+ // Toast notifications
207
+ const { toast, showToast, hideToast } = useToast();
208
+
209
+ // Handle billing and portal return params
210
+ useEffect(() => {
211
+ const billingParam = searchParams.get("billing");
212
+ const portalParam = searchParams.get("portal");
213
+
214
+ if (billingParam === "success") {
215
+ showToast("Payment method added! You now have unlimited API calls.", "success");
216
+ // Clean up URL
217
+ const newUrl = new URL(window.location.href);
218
+ newUrl.searchParams.delete("billing");
219
+ window.history.replaceState({}, "", newUrl.toString());
220
+ } else if (billingParam === "cancel") {
221
+ showToast("Checkout cancelled. You can try again anytime.", "info");
222
+ const newUrl = new URL(window.location.href);
223
+ newUrl.searchParams.delete("billing");
224
+ window.history.replaceState({}, "", newUrl.toString());
225
+ }
226
+
227
+ // Handle portal return
228
+ if (portalParam === "success") {
229
+ showToast("Billing settings updated successfully.", "success");
230
+ const newUrl = new URL(window.location.href);
231
+ newUrl.searchParams.delete("portal");
232
+ window.history.replaceState({}, "", newUrl.toString());
233
+ }
234
+ }, [searchParams, showToast]);
161
235
 
162
236
  useEffect(() => {
163
- if (tabFromUrl && ["overview", "apis", "analytics", "agents", "usage", "billing"].includes(tabFromUrl)) {
237
+ const validTabs: TabType[] = ["overview", "api-catalog", "my-agents", "my-apis", "analytics", "webhooks", "api-keys", "earn", "docs", "feedback", "settings", "billing"];
238
+ if (tabFromUrl && validTabs.includes(tabFromUrl)) {
164
239
  setActiveTab(tabFromUrl);
165
240
  if (tabFromUrl === "analytics") {
166
241
  setAnalyticsExpanded(true);
167
- const subParam = searchParams.get("sub");
168
- if (subParam === "agents") {
169
- setAnalyticsSubtab("agents");
170
- } else {
171
- setAnalyticsSubtab("apis");
242
+ if (subFromUrl && ["overview", "usage", "logs"].includes(subFromUrl)) {
243
+ setAnalyticsSubtab(subFromUrl);
172
244
  }
173
245
  }
174
246
  }
175
- }, [tabFromUrl, searchParams]);
247
+ }, [tabFromUrl, subFromUrl]);
176
248
 
177
249
  const fetchWorkspaceData = useCallback(async (token: string) => {
178
250
  try {
@@ -220,6 +292,26 @@ export default function WorkspacePage() {
220
292
  }
221
293
  }, []);
222
294
 
295
+ const fetchApprovedAPIs = useCallback(async () => {
296
+ try {
297
+ const res = await fetch(`${CONVEX_URL}/api/query`, {
298
+ method: "POST",
299
+ headers: { "Content-Type": "application/json" },
300
+ body: JSON.stringify({
301
+ path: "providers:getApprovedAPIs",
302
+ args: {},
303
+ }),
304
+ });
305
+ const data = await res.json();
306
+ const apis = data.value || data || [];
307
+ if (Array.isArray(apis)) {
308
+ setApprovedApis(apis);
309
+ }
310
+ } catch (err) {
311
+ console.error("Fetch approved APIs error:", err);
312
+ }
313
+ }, []);
314
+
223
315
  const fetchProviderData = useCallback(async () => {
224
316
  try {
225
317
  const providerData = localStorage.getItem("apiclaw_provider");
@@ -284,6 +376,7 @@ export default function WorkspacePage() {
284
376
  // Set provider APIs (even empty array is OK)
285
377
  if (Array.isArray(apis)) {
286
378
  setProviderApis(apis);
379
+ setIsProvider(true);
287
380
  console.log("Provider APIs loaded:", apis.length);
288
381
 
289
382
  // Fetch provider analytics
@@ -326,6 +419,9 @@ export default function WorkspacePage() {
326
419
  await fetchWorkspaceData(token);
327
420
  }
328
421
 
422
+ // Fetch all approved APIs for the catalog
423
+ await fetchApprovedAPIs();
424
+
329
425
  // Check provider session and fetch APIs
330
426
  await fetchProviderData();
331
427
 
@@ -352,7 +448,7 @@ export default function WorkspacePage() {
352
448
  document.documentElement.classList.toggle("dark", saved !== "light");
353
449
 
354
450
  init();
355
- }, [router, fetchWorkspaceData, fetchProviderData]);
451
+ }, [router, fetchWorkspaceData, fetchProviderData, fetchApprovedAPIs]);
356
452
 
357
453
  const toggleTheme = () => {
358
454
  const newTheme = !isDark;
@@ -377,6 +473,7 @@ export default function WorkspacePage() {
377
473
  setIsLoading(true);
378
474
  try {
379
475
  if (sessionToken) await fetchWorkspaceData(sessionToken);
476
+ await fetchApprovedAPIs();
380
477
  await fetchProviderData();
381
478
  } catch (err) {
382
479
  setError("Failed to refresh");
@@ -402,15 +499,63 @@ export default function WorkspacePage() {
402
499
  }
403
500
  };
404
501
 
405
- const tabs = [
502
+ const handleRenameAgent = async (agentId: string, name: string) => {
503
+ if (!sessionToken) return;
504
+ try {
505
+ await fetch(`${CONVEX_URL}/api/mutation`, {
506
+ method: "POST",
507
+ headers: { "Content-Type": "application/json" },
508
+ body: JSON.stringify({
509
+ path: "workspaces:renameAgent",
510
+ args: { token: sessionToken, sessionId: agentId, name },
511
+ }),
512
+ });
513
+ setAgents(agents.map(a => a.id === agentId ? { ...a, name, customName: name } : a));
514
+ } catch (err) {
515
+ console.error("Rename error:", err);
516
+ }
517
+ };
518
+
519
+ // Main navigation tabs
520
+ const mainTabs = [
406
521
  { id: "overview" as TabType, label: "Overview", icon: Home },
407
- { id: "apis" as TabType, label: "APIs", icon: Zap },
408
- { id: "analytics" as TabType, label: "Analytics", icon: BarChart3 },
409
- { id: "agents" as TabType, label: "Agents", icon: Users },
410
- { id: "usage" as TabType, label: "Usage", icon: TrendingUp },
522
+ { id: "api-catalog" as TabType, label: "Direct Call", icon: Zap },
523
+ { id: "my-agents" as TabType, label: "My Agents", icon: Users },
524
+ { id: "my-apis" as TabType, label: "My APIs", icon: Terminal },
525
+ { id: "analytics" as TabType, label: "Analytics", icon: BarChart3, hasDropdown: true },
526
+ { id: "webhooks" as TabType, label: "Webhooks", icon: Webhook },
527
+ { id: "api-keys" as TabType, label: "API Keys", icon: Key },
528
+ ];
529
+
530
+ // Secondary navigation tabs
531
+ const secondaryTabs = [
532
+ { id: "earn" as TabType, label: "Earn Credits", icon: Crown },
533
+ { id: "docs" as TabType, label: "Docs", icon: BookOpen },
534
+ { id: "feedback" as TabType, label: "Feedback", icon: MessageSquare },
535
+ ];
536
+
537
+ // Bottom navigation tabs (before theme/logout)
538
+ const bottomTabs = [
411
539
  { id: "billing" as TabType, label: "Billing", icon: CreditCard },
540
+ { id: "settings" as TabType, label: "Settings", icon: Settings },
412
541
  ];
413
542
 
543
+ // All tabs for lookup
544
+ const tabs = [...mainTabs, ...secondaryTabs, ...bottomTabs, { id: "billing" as TabType, label: "Billing", icon: CreditCard }];
545
+
546
+ // Get display name for current tab
547
+ const getTabLabel = () => {
548
+ if (activeTab === "analytics") {
549
+ const subLabels: Record<AnalyticsSubtab, string> = {
550
+ overview: "Analytics Overview",
551
+ usage: "Usage",
552
+ logs: "Logs",
553
+ };
554
+ return subLabels[analyticsSubtab] || "Analytics";
555
+ }
556
+ return tabs.find(t => t.id === activeTab)?.label || "Workspace";
557
+ };
558
+
414
559
  if (isLoading) {
415
560
  return (
416
561
  <div className="min-h-screen flex items-center justify-center bg-[var(--background)]">
@@ -440,9 +585,17 @@ export default function WorkspacePage() {
440
585
 
441
586
  const displayEmail = workspace?.email || providerName || "User";
442
587
  const displayTier = workspace?.tier || "free";
588
+
589
+ // Usage thresholds for banners
590
+ const showUsageWarning = workspace && workspace.tier === "free" && workspace.usagePercentage >= 80 && workspace.usagePercentage < 100;
591
+ const showUsageExceeded = workspace && workspace.tier === "free" && workspace.usagePercentage >= 100;
443
592
 
444
593
  return (
445
594
  <div className="min-h-screen bg-[var(--background)]">
595
+ {/* Toast notification */}
596
+ {toast && (
597
+ <Toast message={toast.message} type={toast.type} onClose={hideToast} />
598
+ )}
446
599
  {/* Mobile header */}
447
600
  <header className="lg:hidden fixed top-0 w-full z-50 bg-[var(--background)]/90 backdrop-blur-xl border-b border-[var(--border)]">
448
601
  <div className="flex items-center justify-between px-4 py-3">
@@ -482,16 +635,28 @@ export default function WorkspacePage() {
482
635
  <div className="px-4 py-3 border-b border-[var(--border)]">
483
636
  <p className="text-sm text-[var(--text-muted)]">Workspace</p>
484
637
  <p className="font-medium truncate">{displayEmail}</p>
485
- <div className="flex items-center gap-2 mt-1">
638
+ <div className="flex items-center gap-2 mt-2">
486
639
  <span className="px-2 py-0.5 rounded-full bg-[#ef4444]/20 text-[#ef4444] text-xs font-medium capitalize">
487
640
  {displayTier}
488
641
  </span>
642
+ {workspace && (
643
+ <span className="text-xs text-[var(--text-muted)]">
644
+ {workspace.usageRemaining}/{workspace.usageLimit} calls
645
+ </span>
646
+ )}
489
647
  </div>
648
+ {workspace && workspace.usagePercentage > 80 && (
649
+ <div className="mt-2 text-xs text-yellow-500 flex items-center gap-1">
650
+ <AlertCircle className="w-3 h-3" />
651
+ Running low on calls
652
+ </div>
653
+ )}
490
654
  </div>
491
655
 
492
656
  {/* Navigation */}
493
- <nav className="flex-1 p-4 space-y-1">
494
- {tabs.map((tab) => {
657
+ <nav className="flex-1 p-4 space-y-1 overflow-y-auto">
658
+ {/* Main tabs */}
659
+ {mainTabs.map((tab) => {
495
660
  // Special handling for Analytics with dropdown
496
661
  if (tab.id === "analytics") {
497
662
  return (
@@ -501,7 +666,8 @@ export default function WorkspacePage() {
501
666
  setAnalyticsExpanded(!analyticsExpanded);
502
667
  if (!analyticsExpanded) {
503
668
  setActiveTab("analytics");
504
- router.push(`/workspace?tab=analytics`);
669
+ setAnalyticsSubtab("overview");
670
+ router.push(`/workspace?tab=analytics&sub=overview`);
505
671
  }
506
672
  }}
507
673
  className={`w-full flex items-center justify-between px-3 py-2 rounded-lg transition ${
@@ -522,34 +688,50 @@ export default function WorkspacePage() {
522
688
  <button
523
689
  onClick={() => {
524
690
  setActiveTab("analytics");
525
- setAnalyticsSubtab("apis");
691
+ setAnalyticsSubtab("overview");
692
+ setSidebarOpen(false);
693
+ router.push(`/workspace?tab=analytics&sub=overview`);
694
+ }}
695
+ className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition ${
696
+ activeTab === "analytics" && analyticsSubtab === "overview"
697
+ ? "bg-[#ef4444]/20 text-[#ef4444]"
698
+ : "text-[var(--text-secondary)] hover:bg-[var(--surface)] hover:text-[var(--text-primary)]"
699
+ }`}
700
+ >
701
+ <BarChart3 className="w-4 h-4" />
702
+ <span>Overview</span>
703
+ </button>
704
+ <button
705
+ onClick={() => {
706
+ setActiveTab("analytics");
707
+ setAnalyticsSubtab("usage");
526
708
  setSidebarOpen(false);
527
- router.push(`/workspace?tab=analytics&sub=apis`);
709
+ router.push(`/workspace?tab=analytics&sub=usage`);
528
710
  }}
529
711
  className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition ${
530
- activeTab === "analytics" && analyticsSubtab === "apis"
712
+ activeTab === "analytics" && analyticsSubtab === "usage"
531
713
  ? "bg-[#ef4444]/20 text-[#ef4444]"
532
714
  : "text-[var(--text-secondary)] hover:bg-[var(--surface)] hover:text-[var(--text-primary)]"
533
715
  }`}
534
716
  >
535
- <Zap className="w-4 h-4" />
536
- <span>My APIs</span>
717
+ <TrendingUp className="w-4 h-4" />
718
+ <span>Usage</span>
537
719
  </button>
538
720
  <button
539
721
  onClick={() => {
540
722
  setActiveTab("analytics");
541
- setAnalyticsSubtab("agents");
723
+ setAnalyticsSubtab("logs");
542
724
  setSidebarOpen(false);
543
- router.push(`/workspace?tab=analytics&sub=agents`);
725
+ router.push(`/workspace?tab=analytics&sub=logs`);
544
726
  }}
545
727
  className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition ${
546
- activeTab === "analytics" && analyticsSubtab === "agents"
728
+ activeTab === "analytics" && analyticsSubtab === "logs"
547
729
  ? "bg-[#ef4444]/20 text-[#ef4444]"
548
730
  : "text-[var(--text-secondary)] hover:bg-[var(--surface)] hover:text-[var(--text-primary)]"
549
731
  }`}
550
732
  >
551
- <Users className="w-4 h-4" />
552
- <span>My Agents</span>
733
+ <ScrollText className="w-4 h-4" />
734
+ <span>Logs</span>
553
735
  </button>
554
736
  </div>
555
737
  )}
@@ -577,6 +759,52 @@ export default function WorkspacePage() {
577
759
  </button>
578
760
  );
579
761
  })}
762
+
763
+ {/* Separator */}
764
+ <div className="border-t border-[var(--border)] my-3" />
765
+
766
+ {/* Secondary tabs */}
767
+ {secondaryTabs.map((tab) => (
768
+ <button
769
+ key={tab.id}
770
+ onClick={() => {
771
+ setActiveTab(tab.id);
772
+ setSidebarOpen(false);
773
+ router.push(`/workspace?tab=${tab.id}`);
774
+ }}
775
+ className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg transition ${
776
+ activeTab === tab.id
777
+ ? "bg-[#ef4444] text-white"
778
+ : "text-[var(--text-secondary)] hover:bg-[var(--surface)] hover:text-[var(--text-primary)]"
779
+ }`}
780
+ >
781
+ <tab.icon className="w-5 h-5" />
782
+ <span>{tab.label}</span>
783
+ </button>
784
+ ))}
785
+
786
+ {/* Separator */}
787
+ <div className="border-t border-[var(--border)] my-3" />
788
+
789
+ {/* Bottom tabs (Settings) */}
790
+ {bottomTabs.map((tab) => (
791
+ <button
792
+ key={tab.id}
793
+ onClick={() => {
794
+ setActiveTab(tab.id);
795
+ setSidebarOpen(false);
796
+ router.push(`/workspace?tab=${tab.id}`);
797
+ }}
798
+ className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg transition ${
799
+ activeTab === tab.id
800
+ ? "bg-[#ef4444] text-white"
801
+ : "text-[var(--text-secondary)] hover:bg-[var(--surface)] hover:text-[var(--text-primary)]"
802
+ }`}
803
+ >
804
+ <tab.icon className="w-5 h-5" />
805
+ <span>{tab.label}</span>
806
+ </button>
807
+ ))}
580
808
  </nav>
581
809
 
582
810
  {/* Bottom section */}
@@ -603,17 +831,15 @@ export default function WorkspacePage() {
603
831
  <main className="lg:ml-64 min-h-screen pt-14 lg:pt-0">
604
832
  {/* Desktop header */}
605
833
  <header className="hidden lg:flex items-center justify-between px-8 py-4 border-b border-[var(--border)] bg-[var(--background)]/90 backdrop-blur-xl sticky top-0 z-40">
606
- <h1 className="text-xl font-bold">
607
- {tabs.find(t => t.id === activeTab)?.label || "Workspace"}
608
- </h1>
834
+ <h1 className="text-xl font-bold">{getTabLabel()}</h1>
609
835
  <div className="flex items-center gap-4">
610
836
  <button onClick={handleRefresh} className="p-2 rounded-lg hover:bg-[var(--surface)] transition" title="Refresh">
611
837
  <RefreshCw className="w-5 h-5 text-[var(--text-muted)]" />
612
838
  </button>
613
- {activeTab === "apis" && (
839
+ {activeTab === "my-apis" && (
614
840
  <Link href="/providers/register" className="btn-primary !py-2 !px-4 text-sm">
615
841
  <Plus className="w-4 h-4" />
616
- Add API
842
+ List New API
617
843
  </Link>
618
844
  )}
619
845
  </div>
@@ -621,16 +847,40 @@ export default function WorkspacePage() {
621
847
 
622
848
  {/* Page content */}
623
849
  <div className="p-4 lg:p-8">
850
+ {/* Usage warning/exceeded banners */}
851
+ {showUsageWarning && sessionToken && (
852
+ <UsageWarningBanner
853
+ usagePercentage={workspace!.usagePercentage}
854
+ usageCount={workspace!.usageCount}
855
+ usageLimit={workspace!.usageLimit}
856
+ sessionToken={sessionToken}
857
+ />
858
+ )}
859
+ {showUsageExceeded && sessionToken && (
860
+ <UsageExceededBanner
861
+ usageCount={workspace!.usageCount}
862
+ usageLimit={workspace!.usageLimit}
863
+ sessionToken={sessionToken}
864
+ />
865
+ )}
866
+
624
867
  {activeTab === "overview" && (
625
868
  <OverviewTab
626
869
  workspace={workspace}
627
870
  agents={agents}
628
871
  providerApis={providerApis}
872
+ approvedApis={approvedApis}
629
873
  setActiveTab={setActiveTab}
630
874
  />
631
875
  )}
632
- {activeTab === "apis" && (
633
- <ApisTab apis={providerApis} />
876
+ {activeTab === "api-catalog" && (
877
+ <APICatalogTab apis={approvedApis} />
878
+ )}
879
+ {activeTab === "my-agents" && (
880
+ <AgentsTab agents={agents} onRevoke={handleRevokeAgent} onRename={handleRenameAgent} workspaceEmail={workspace?.email} sessionToken={sessionToken || undefined} />
881
+ )}
882
+ {activeTab === "my-apis" && (
883
+ <MyAPIsTab apis={providerApis} />
634
884
  )}
635
885
  {activeTab === "analytics" && (
636
886
  <AnalyticsTab
@@ -641,16 +891,29 @@ export default function WorkspacePage() {
641
891
  usage={usage}
642
892
  activeSubtab={analyticsSubtab}
643
893
  setActiveSubtab={setAnalyticsSubtab}
894
+ sessionToken={sessionToken}
644
895
  />
645
896
  )}
646
- {activeTab === "agents" && (
647
- <AgentsTab agents={agents} onRevoke={handleRevokeAgent} />
897
+ {activeTab === "webhooks" && (
898
+ <WebhooksTab />
648
899
  )}
649
- {activeTab === "usage" && (
650
- <UsageTab workspace={workspace} usage={usage} />
900
+ {activeTab === "api-keys" && (
901
+ <ApiKeysTab />
651
902
  )}
652
903
  {activeTab === "billing" && (
653
- <BillingTab workspace={workspace} />
904
+ <BillingTab workspace={workspace} sessionToken={sessionToken} />
905
+ )}
906
+ {activeTab === "earn" && (
907
+ <EarnTab />
908
+ )}
909
+ {activeTab === "docs" && (
910
+ <DocsTab />
911
+ )}
912
+ {activeTab === "feedback" && (
913
+ <FeedbackTab />
914
+ )}
915
+ {activeTab === "settings" && (
916
+ <SettingsTab workspace={workspace} sessionToken={sessionToken} />
654
917
  )}
655
918
  </div>
656
919
  </main>
@@ -666,23 +929,25 @@ function OverviewTab({
666
929
  workspace,
667
930
  agents,
668
931
  providerApis,
932
+ approvedApis,
669
933
  setActiveTab,
670
934
  }: {
671
935
  workspace: Workspace | null;
672
936
  agents: Agent[];
673
937
  providerApis: ProviderAPI[];
938
+ approvedApis: ApprovedAPI[];
674
939
  setActiveTab: (tab: TabType) => void;
675
940
  }) {
676
941
  return (
677
942
  <div className="space-y-8">
678
943
  {/* Quick Stats */}
679
- <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
944
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4">
680
945
  <div className="rounded-2xl border border-[#ef4444]/30 bg-[#ef4444]/10 p-6">
681
946
  <div className="flex items-center gap-3 mb-3">
682
947
  <Zap className="w-6 h-6 text-[#ef4444]" />
683
- <span className="text-[var(--text-muted)]">Listed APIs</span>
948
+ <span className="text-[var(--text-muted)]">Available APIs</span>
684
949
  </div>
685
- <p className="text-4xl font-bold text-[#ef4444]">{providerApis.length}</p>
950
+ <p className="text-4xl font-bold text-[#ef4444]">{approvedApis.length}</p>
686
951
  </div>
687
952
 
688
953
  <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-6">
@@ -703,19 +968,17 @@ function OverviewTab({
703
968
  <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-6">
704
969
  <div className="flex items-center gap-3 mb-3">
705
970
  <Users className="w-6 h-6 text-[var(--text-muted)]" />
706
- <span className="text-[var(--text-muted)]">Connected Agents</span>
971
+ <span className="text-[var(--text-muted)]">My Agents</span>
707
972
  </div>
708
973
  <p className="text-4xl font-bold">{agents.length}</p>
709
974
  </div>
710
975
 
711
976
  <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-6">
712
977
  <div className="flex items-center gap-3 mb-3">
713
- <Check className="w-6 h-6 text-green-500" />
714
- <span className="text-[var(--text-muted)]">Status</span>
978
+ <Terminal className="w-6 h-6 text-[var(--text-muted)]" />
979
+ <span className="text-[var(--text-muted)]">My APIs</span>
715
980
  </div>
716
- <p className="text-xl font-bold text-green-500 capitalize">
717
- {workspace?.status || "Active"}
718
- </p>
981
+ <p className="text-4xl font-bold">{providerApis.length}</p>
719
982
  </div>
720
983
  </div>
721
984
 
@@ -760,21 +1023,52 @@ function OverviewTab({
760
1023
  </div>
761
1024
  )}
762
1025
 
763
- {/* Provider APIs Preview */}
1026
+ {/* Available APIs Preview */}
1027
+ <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-6">
1028
+ <div className="flex items-center justify-between mb-4">
1029
+ <h3 className="font-bold text-lg">Direct Call</h3>
1030
+ <button onClick={() => setActiveTab("api-catalog")} className="text-sm text-[#ef4444] hover:underline">
1031
+ View all {approvedApis.length} APIs
1032
+ </button>
1033
+ </div>
1034
+ <div className="grid md:grid-cols-3 gap-3">
1035
+ {approvedApis.slice(0, 3).map((api) => (
1036
+ <div
1037
+ key={api._id}
1038
+ className="p-4 rounded-xl bg-[var(--surface)] hover:bg-[var(--surface-elevated)] transition"
1039
+ >
1040
+ <div className="flex items-center gap-2 mb-2">
1041
+ <Zap className="w-5 h-5 text-[#ef4444]" />
1042
+ <p className="font-medium">{api.name}</p>
1043
+ </div>
1044
+ <p className="text-sm text-[var(--text-muted)] line-clamp-2">{api.description}</p>
1045
+ <div className="flex items-center gap-2 mt-2">
1046
+ <span className="px-2 py-0.5 rounded-full bg-[var(--background)] text-xs text-[var(--text-muted)]">
1047
+ {api.category}
1048
+ </span>
1049
+ <span className="px-2 py-0.5 rounded-full bg-green-500/20 text-green-500 text-xs font-medium">
1050
+ Direct Call
1051
+ </span>
1052
+ </div>
1053
+ </div>
1054
+ ))}
1055
+ </div>
1056
+ </div>
1057
+
1058
+ {/* My APIs Preview */}
764
1059
  {providerApis.length > 0 && (
765
1060
  <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-6">
766
1061
  <div className="flex items-center justify-between mb-4">
767
- <h3 className="font-bold text-lg">Your APIs</h3>
768
- <button onClick={() => setActiveTab("apis")} className="text-sm text-[#ef4444] hover:underline">
769
- View all
1062
+ <h3 className="font-bold text-lg">My APIs</h3>
1063
+ <button onClick={() => setActiveTab("my-apis")} className="text-sm text-[#ef4444] hover:underline">
1064
+ Manage APIs
770
1065
  </button>
771
1066
  </div>
772
1067
  <div className="space-y-3">
773
1068
  {providerApis.slice(0, 3).map((api) => (
774
- <Link
1069
+ <div
775
1070
  key={api._id}
776
- href={`/providers/dashboard/${api._id}`}
777
- className="flex items-center justify-between p-4 rounded-xl bg-[var(--surface)] hover:bg-[var(--surface-elevated)] transition"
1071
+ className="flex items-center justify-between p-4 rounded-xl bg-[var(--surface)]"
778
1072
  >
779
1073
  <div>
780
1074
  <p className="font-medium">{api.name}</p>
@@ -792,7 +1086,7 @@ function OverviewTab({
792
1086
  {api.status}
793
1087
  </span>
794
1088
  </div>
795
- </Link>
1089
+ </div>
796
1090
  ))}
797
1091
  </div>
798
1092
  </div>
@@ -801,8 +1095,8 @@ function OverviewTab({
801
1095
  {/* Recent Agents */}
802
1096
  <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-6">
803
1097
  <div className="flex items-center justify-between mb-4">
804
- <h3 className="font-bold text-lg">Recent Agents</h3>
805
- <button onClick={() => setActiveTab("agents")} className="text-sm text-[#ef4444] hover:underline">
1098
+ <h3 className="font-bold text-lg">My Agents</h3>
1099
+ <button onClick={() => setActiveTab("my-agents")} className="text-sm text-[#ef4444] hover:underline">
806
1100
  View all
807
1101
  </button>
808
1102
  </div>
@@ -838,22 +1132,169 @@ function OverviewTab({
838
1132
  }
839
1133
 
840
1134
  // ============================================
841
- // APIS TAB (Provider)
1135
+ // API CATALOG TAB (All Approved APIs)
1136
+ // ============================================
1137
+
1138
+ function APICatalogTab({ apis }: { apis: ApprovedAPI[] }) {
1139
+ const [searchQuery, setSearchQuery] = useState("");
1140
+ const [selectedCategory, setSelectedCategory] = useState<string>("all");
1141
+
1142
+ // Get unique categories
1143
+ const categories = ["all", ...Array.from(new Set(apis.map(a => a.category)))];
1144
+
1145
+ // Filter APIs
1146
+ const filteredApis = apis.filter(api => {
1147
+ const matchesSearch = !searchQuery ||
1148
+ api.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
1149
+ api.description.toLowerCase().includes(searchQuery.toLowerCase());
1150
+ const matchesCategory = selectedCategory === "all" || api.category === selectedCategory;
1151
+ return matchesSearch && matchesCategory;
1152
+ });
1153
+
1154
+ // Get icon component for category
1155
+ const CategoryIcon = ({ category }: { category: string }) => {
1156
+ const iconClass = "w-5 h-5 text-[#ef4444]";
1157
+ switch (category) {
1158
+ case "Search": return <Search className={iconClass} />;
1159
+ case "AI & LLM": return <Cpu className={iconClass} />;
1160
+ case "Communication": return <MessageSquare className={iconClass} />;
1161
+ case "Email": return <Mail className={iconClass} />;
1162
+ case "Voice & Audio": return <Activity className={iconClass} />;
1163
+ case "Code Execution": return <Terminal className={iconClass} />;
1164
+ case "Web Scraping": return <Globe className={iconClass} />;
1165
+ case "Image": return <Sparkles className={iconClass} />;
1166
+ case "Media": return <Play className={iconClass} />;
1167
+ case "SMS & Messaging": return <MessageSquare className={iconClass} />;
1168
+ case "Voice & TTS": return <Activity className={iconClass} />;
1169
+ case "Crypto & Blockchain": return <Database className={iconClass} />;
1170
+ default: return <Zap className={iconClass} />;
1171
+ }
1172
+ };
1173
+
1174
+ return (
1175
+ <div className="space-y-6">
1176
+ <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
1177
+ <div>
1178
+ <h2 className="text-2xl font-bold">Direct Call</h2>
1179
+ <p className="text-[var(--text-muted)]">{apis.length} APIs available for Direct Call</p>
1180
+ </div>
1181
+ </div>
1182
+
1183
+ {/* Search and Filter */}
1184
+ <div className="flex flex-col sm:flex-row gap-3">
1185
+ <div className="relative flex-1">
1186
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--text-muted)]" />
1187
+ <input
1188
+ type="text"
1189
+ value={searchQuery}
1190
+ onChange={(e) => setSearchQuery(e.target.value)}
1191
+ placeholder="Search APIs..."
1192
+ className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-[var(--border)] bg-[var(--background)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:outline-none focus:ring-2 focus:ring-[#ef4444]/50"
1193
+ />
1194
+ </div>
1195
+ <select
1196
+ value={selectedCategory}
1197
+ onChange={(e) => setSelectedCategory(e.target.value)}
1198
+ className="px-4 py-2.5 rounded-xl border border-[var(--border)] bg-[var(--background)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[#ef4444]/50"
1199
+ >
1200
+ {categories.map(cat => (
1201
+ <option key={cat} value={cat}>
1202
+ {cat === "all" ? "All Categories" : cat}
1203
+ </option>
1204
+ ))}
1205
+ </select>
1206
+ </div>
1207
+
1208
+ {/* API Grid */}
1209
+ {filteredApis.length === 0 ? (
1210
+ <div className="text-center py-12 rounded-2xl border border-dashed border-[var(--border)] bg-[var(--surface)]/50">
1211
+ <Search className="w-12 h-12 text-[var(--text-muted)] mx-auto mb-4" />
1212
+ <h3 className="font-semibold text-lg mb-2">No APIs Found</h3>
1213
+ <p className="text-[var(--text-muted)]">Try adjusting your search or filter.</p>
1214
+ </div>
1215
+ ) : (
1216
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
1217
+ {filteredApis.map((api) => (
1218
+ <div
1219
+ key={api._id}
1220
+ className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-5 hover:border-[#ef4444]/50 transition group"
1221
+ >
1222
+ <div className="flex items-start gap-3 mb-3">
1223
+ <div className="w-8 h-8 rounded-lg bg-[#ef4444]/10 flex items-center justify-center">
1224
+ <CategoryIcon category={api.category} />
1225
+ </div>
1226
+ <div className="flex-1 min-w-0">
1227
+ <h3 className="font-semibold text-lg truncate">{api.name}</h3>
1228
+ <span className="text-sm text-[var(--text-muted)]">{api.category}</span>
1229
+ </div>
1230
+ </div>
1231
+ <p className="text-sm text-[var(--text-muted)] line-clamp-2 mb-4">{api.description}</p>
1232
+ <div className="flex items-center">
1233
+ <span className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-green-500/20 text-green-500 text-xs font-medium">
1234
+ <Check className="w-3 h-3" />
1235
+ Direct Call Ready
1236
+ </span>
1237
+ </div>
1238
+ </div>
1239
+ ))}
1240
+ </div>
1241
+ )}
1242
+ </div>
1243
+ );
1244
+ }
1245
+
1246
+ // ============================================
1247
+ // MY APIs TAB (Provider)
842
1248
  // ============================================
843
1249
 
844
- function ApisTab({ apis }: { apis: ProviderAPI[] }) {
1250
+ function MyAPIsTab({ apis }: { apis: ProviderAPI[] }) {
845
1251
  if (!apis || apis.length === 0) {
846
1252
  return (
847
- <div className="text-center py-16 rounded-2xl border border-dashed border-[var(--border)] bg-[var(--surface)]/50">
848
- <Zap className="w-12 h-12 text-[var(--text-muted)] mx-auto mb-4" />
849
- <h3 className="font-semibold text-lg mb-2">No APIs Listed</h3>
850
- <p className="text-[var(--text-muted)] max-w-md mx-auto mb-6">
851
- List your first API to start getting discovered by AI agents.
852
- </p>
853
- <Link href="/providers/register" className="btn-primary">
854
- <Plus className="w-5 h-5" />
855
- Add API
856
- </Link>
1253
+ <div className="space-y-6">
1254
+ <div>
1255
+ <h2 className="text-2xl font-bold">My APIs</h2>
1256
+ <p className="text-[var(--text-muted)]">APIs you&apos;ve listed for other agents to discover and use.</p>
1257
+ </div>
1258
+
1259
+ <div className="text-center py-16 rounded-2xl border border-dashed border-[var(--border)] bg-[var(--surface)]/50">
1260
+ <Terminal className="w-16 h-16 text-[var(--text-muted)] mx-auto mb-4" />
1261
+ <h3 className="font-semibold text-xl mb-2">Get Your API in Front of AI Agents</h3>
1262
+ <p className="text-[var(--text-muted)] max-w-md mx-auto mb-6">
1263
+ List your API and let AI agents discover and use it — no integration work for them.
1264
+ </p>
1265
+ <Link href="/providers/register" className="btn-primary">
1266
+ <Plus className="w-5 h-5" />
1267
+ List New API
1268
+ </Link>
1269
+
1270
+ {/* Benefits */}
1271
+ <div className="mt-8 pt-8 border-t border-[var(--border)] max-w-lg mx-auto">
1272
+ <h4 className="font-medium mb-4 text-left">Why list your API?</h4>
1273
+ <div className="space-y-3 text-left">
1274
+ <div className="flex items-start gap-3">
1275
+ <Check className="w-5 h-5 text-green-500 flex-shrink-0 mt-0.5" />
1276
+ <div>
1277
+ <p className="font-medium">Get discovered by AI agents</p>
1278
+ <p className="text-sm text-[var(--text-muted)]">Agents query APIClaw to find APIs matching their needs.</p>
1279
+ </div>
1280
+ </div>
1281
+ <div className="flex items-start gap-3">
1282
+ <Check className="w-5 h-5 text-green-500 flex-shrink-0 mt-0.5" />
1283
+ <div>
1284
+ <p className="font-medium">Direct Call = we handle keys</p>
1285
+ <p className="text-sm text-[var(--text-muted)]">No need for agents to manage API keys.</p>
1286
+ </div>
1287
+ </div>
1288
+ <div className="flex items-start gap-3">
1289
+ <Check className="w-5 h-5 text-green-500 flex-shrink-0 mt-0.5" />
1290
+ <div>
1291
+ <p className="font-medium">Analytics on who&apos;s using your API</p>
1292
+ <p className="text-sm text-[var(--text-muted)]">See which agents call your API and how often.</p>
1293
+ </div>
1294
+ </div>
1295
+ </div>
1296
+ </div>
1297
+ </div>
857
1298
  </div>
858
1299
  );
859
1300
  }
@@ -861,22 +1302,24 @@ function ApisTab({ apis }: { apis: ProviderAPI[] }) {
861
1302
  return (
862
1303
  <div className="space-y-6">
863
1304
  <div className="flex items-center justify-between">
864
- <h2 className="text-2xl font-bold">Your APIs</h2>
1305
+ <div>
1306
+ <h2 className="text-2xl font-bold">My APIs</h2>
1307
+ <p className="text-[var(--text-muted)]">{apis.length} API{apis.length !== 1 ? "s" : ""} listed</p>
1308
+ </div>
865
1309
  <Link href="/providers/register" className="btn-primary !py-2 !px-4 text-sm">
866
1310
  <Plus className="w-4 h-4" />
867
- Add API
1311
+ List New API
868
1312
  </Link>
869
1313
  </div>
870
1314
 
871
1315
  <div className="grid gap-4">
872
1316
  {apis.map((api) => (
873
- <Link
1317
+ <div
874
1318
  key={api._id}
875
- href={`/providers/dashboard/${api._id}`}
876
1319
  className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-6 hover:border-[#ef4444]/50 transition"
877
1320
  >
878
1321
  <div className="flex items-center justify-between">
879
- <div>
1322
+ <div className="flex-1">
880
1323
  <div className="flex items-center gap-3 mb-2">
881
1324
  <h3 className="font-semibold text-lg">{api.name}</h3>
882
1325
  {api.hasDirectCall && (
@@ -896,9 +1339,22 @@ function ApisTab({ apis }: { apis: ProviderAPI[] }) {
896
1339
  <span>{api.discoveryCount || 0} discoveries</span>
897
1340
  </div>
898
1341
  </div>
899
- <ChevronRight className="w-5 h-5 text-[var(--text-muted)]" />
1342
+ <div className="flex items-center gap-2 ml-4">
1343
+ <Link
1344
+ href={`/providers/dashboard/${api._id}`}
1345
+ className="px-4 py-2 rounded-lg text-sm font-medium text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--surface)] transition"
1346
+ >
1347
+ Edit
1348
+ </Link>
1349
+ <Link
1350
+ href={`/providers/dashboard/${api._id}`}
1351
+ className="px-4 py-2 rounded-lg text-sm font-medium bg-[#ef4444] text-white hover:bg-[#dc2626] transition"
1352
+ >
1353
+ Analytics
1354
+ </Link>
1355
+ </div>
900
1356
  </div>
901
- </Link>
1357
+ </div>
902
1358
  ))}
903
1359
  </div>
904
1360
  </div>
@@ -912,26 +1368,68 @@ function ApisTab({ apis }: { apis: ProviderAPI[] }) {
912
1368
  function AgentsTab({
913
1369
  agents,
914
1370
  onRevoke,
1371
+ onRename,
1372
+ workspaceEmail,
1373
+ sessionToken,
915
1374
  }: {
916
1375
  agents: Agent[];
917
1376
  onRevoke: (agentId: string) => void;
1377
+ onRename: (agentId: string, name: string) => void;
1378
+ workspaceEmail?: string;
1379
+ sessionToken?: string;
918
1380
  }) {
919
1381
  const [confirmRevoke, setConfirmRevoke] = useState<string | null>(null);
1382
+ const [sendingLink, setSendingLink] = useState(false);
1383
+ const [linkSent, setLinkSent] = useState(false);
1384
+ const [email, setEmail] = useState(workspaceEmail || "");
1385
+ const [editingAgent, setEditingAgent] = useState<string | null>(null);
1386
+ const [editName, setEditName] = useState("");
920
1387
 
921
1388
  const handleRevoke = (agentId: string) => {
1389
+ const agent = agents.find(a => a.id === agentId);
922
1390
  if (confirmRevoke === agentId) {
923
1391
  onRevoke(agentId);
924
1392
  setConfirmRevoke(null);
1393
+ // If revoking current session, clear localStorage and redirect
1394
+ if (agent?.isCurrent) {
1395
+ localStorage.removeItem("apiclaw_workspace_session");
1396
+ window.location.href = "/login";
1397
+ }
925
1398
  } else {
926
1399
  setConfirmRevoke(agentId);
927
1400
  }
928
1401
  };
929
1402
 
1403
+ const handleSendMagicLink = async () => {
1404
+ if (!email || !email.includes("@")) return;
1405
+
1406
+ setSendingLink(true);
1407
+ try {
1408
+ const response = await fetch(`${CONVEX_URL.replace('.cloud', '.site')}/workspace/magic-link`, {
1409
+ method: "POST",
1410
+ headers: { "Content-Type": "application/json" },
1411
+ body: JSON.stringify({ email }),
1412
+ });
1413
+
1414
+ const result = await response.json();
1415
+ if (result.success) {
1416
+ setLinkSent(true);
1417
+ setTimeout(() => setLinkSent(false), 5000);
1418
+ }
1419
+ } catch (err) {
1420
+ console.error("Failed to send magic link:", err);
1421
+ } finally {
1422
+ setSendingLink(false);
1423
+ }
1424
+ };
1425
+
930
1426
  return (
931
1427
  <div className="space-y-6">
932
1428
  <div className="flex items-center justify-between">
933
- <h2 className="text-2xl font-bold">Connected Agents</h2>
934
- <p className="text-[var(--text-muted)]">{agents.length} total</p>
1429
+ <div>
1430
+ <h2 className="text-2xl font-bold">My Agents</h2>
1431
+ <p className="text-[var(--text-muted)]">{agents.length} connected agent{agents.length !== 1 ? "s" : ""}</p>
1432
+ </div>
935
1433
  </div>
936
1434
 
937
1435
  {/* How to Connect Agents */}
@@ -969,6 +1467,57 @@ function AgentsTab({
969
1467
  </div>
970
1468
  </div>
971
1469
 
1470
+ {/* Quick Connect via Email */}
1471
+ <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-6">
1472
+ <div className="flex items-start gap-4">
1473
+ <div className="w-10 h-10 rounded-xl bg-green-500/20 flex items-center justify-center flex-shrink-0">
1474
+ <Mail className="w-5 h-5 text-green-500" />
1475
+ </div>
1476
+ <div className="flex-1">
1477
+ <h3 className="font-semibold mb-2">Quick Connect via Email</h3>
1478
+ <p className="text-sm text-[var(--text-muted)] mb-4">
1479
+ Send a magic link to connect your agent without using the CLI.
1480
+ </p>
1481
+ <div className="flex flex-col sm:flex-row gap-3">
1482
+ <input
1483
+ type="email"
1484
+ value={email}
1485
+ onChange={(e) => setEmail(e.target.value)}
1486
+ placeholder="your@email.com"
1487
+ className="w-full sm:flex-1 px-4 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[#ef4444]/50"
1488
+ />
1489
+ <button
1490
+ onClick={handleSendMagicLink}
1491
+ disabled={sendingLink || !email}
1492
+ className="w-full sm:w-auto px-6 py-2 bg-[#ef4444] text-white rounded-lg font-medium hover:bg-[#dc2626] transition disabled:opacity-50 flex items-center justify-center gap-2"
1493
+ >
1494
+ {sendingLink ? (
1495
+ <>
1496
+ <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
1497
+ Sending...
1498
+ </>
1499
+ ) : linkSent ? (
1500
+ <>
1501
+ <Check className="w-4 h-4" />
1502
+ Sent!
1503
+ </>
1504
+ ) : (
1505
+ <>
1506
+ <Send className="w-4 h-4" />
1507
+ Send Link
1508
+ </>
1509
+ )}
1510
+ </button>
1511
+ </div>
1512
+ {linkSent && (
1513
+ <p className="text-sm text-green-500 mt-2">
1514
+ ✓ Magic link sent! Check your email and click to connect.
1515
+ </p>
1516
+ )}
1517
+ </div>
1518
+ </div>
1519
+ </div>
1520
+
972
1521
  {agents.length === 0 ? (
973
1522
  <div className="text-center py-12 rounded-2xl border border-dashed border-[var(--border)] bg-[var(--surface)]/50">
974
1523
  <Users className="w-12 h-12 text-[var(--text-muted)] mx-auto mb-4" />
@@ -980,42 +1529,96 @@ function AgentsTab({
980
1529
  ) : (
981
1530
  <div className="grid gap-4">
982
1531
  {agents.map((agent) => (
983
- <div key={agent.id} className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-6">
984
- <div className="flex items-center justify-between">
985
- <div className="flex items-center gap-4">
986
- <div className="w-12 h-12 rounded-full bg-[#ef4444]/20 flex items-center justify-center">
987
- <Users className="w-6 h-6 text-[#ef4444]" />
1532
+ <div key={agent.id} className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-4 sm:p-6">
1533
+ <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
1534
+ <div className="flex items-center gap-3 sm:gap-4">
1535
+ <div className="w-10 h-10 sm:w-12 sm:h-12 rounded-full bg-[#ef4444]/20 flex items-center justify-center flex-shrink-0">
1536
+ <Users className="w-5 h-5 sm:w-6 sm:h-6 text-[#ef4444]" />
988
1537
  </div>
989
- <div>
990
- <div className="flex items-center gap-2">
991
- <h3 className="font-semibold">{agent.fingerprint}</h3>
992
- {agent.isCurrent && (
993
- <span className="px-2 py-0.5 rounded-full bg-green-500/20 text-green-500 text-xs font-medium">
994
- Current
995
- </span>
996
- )}
997
- </div>
998
- <div className="flex items-center gap-4 mt-1 text-sm text-[var(--text-muted)]">
1538
+ <div className="flex-1 min-w-0">
1539
+ {editingAgent === agent.id ? (
1540
+ <div className="flex flex-wrap items-center gap-2">
1541
+ <input
1542
+ type="text"
1543
+ value={editName}
1544
+ onChange={(e) => setEditName(e.target.value)}
1545
+ placeholder="Agent name..."
1546
+ className="w-full sm:w-auto px-3 py-1 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm focus:outline-none focus:ring-2 focus:ring-[#ef4444]/50"
1547
+ autoFocus
1548
+ />
1549
+ <div className="flex gap-2">
1550
+ <button
1551
+ onClick={() => {
1552
+ onRename(agent.id, editName);
1553
+ setEditingAgent(null);
1554
+ }}
1555
+ className="px-3 py-1 bg-[#ef4444] text-white rounded-lg text-sm hover:bg-[#dc2626]"
1556
+ >
1557
+ Save
1558
+ </button>
1559
+ <button
1560
+ onClick={() => setEditingAgent(null)}
1561
+ className="px-3 py-1 text-[var(--text-muted)] hover:text-[var(--text-primary)]"
1562
+ >
1563
+ Cancel
1564
+ </button>
1565
+ </div>
1566
+ </div>
1567
+ ) : (
1568
+ <div className="flex flex-wrap items-center gap-2">
1569
+ <h3 className="font-semibold truncate">{agent.name || agent.fingerprint}</h3>
1570
+ <button
1571
+ onClick={() => {
1572
+ setEditingAgent(agent.id);
1573
+ setEditName(agent.name || agent.fingerprint || "");
1574
+ }}
1575
+ className="text-[var(--text-muted)] hover:text-[var(--text-primary)] opacity-0 group-hover:opacity-100 transition"
1576
+ title="Rename"
1577
+ >
1578
+ <Settings className="w-4 h-4" />
1579
+ </button>
1580
+ {agent.isCurrent && (
1581
+ <span className="px-2 py-0.5 rounded-full bg-green-500/20 text-green-500 text-xs font-medium">
1582
+ Current
1583
+ </span>
1584
+ )}
1585
+ </div>
1586
+ )}
1587
+ {agent.fingerprint !== agent.name && agent.name && (
1588
+ <p className="text-xs text-[var(--text-muted)] mt-0.5 truncate">{agent.fingerprint}</p>
1589
+ )}
1590
+ <div className="flex items-center gap-2 sm:gap-4 mt-1 text-xs sm:text-sm text-[var(--text-muted)]">
999
1591
  <span className="flex items-center gap-1">
1000
- <Clock className="w-4 h-4" />
1001
- Last active: {new Date(agent.lastUsedAt).toLocaleString()}
1592
+ <Clock className="w-3 h-3 sm:w-4 sm:h-4" />
1593
+ <span className="hidden sm:inline">Last active:</span> {new Date(agent.lastUsedAt).toLocaleDateString()}
1002
1594
  </span>
1003
1595
  </div>
1004
1596
  </div>
1005
1597
  </div>
1006
- {!agent.isCurrent && (
1598
+ <div className="flex items-center gap-2 w-full sm:w-auto">
1599
+ <button
1600
+ onClick={() => {
1601
+ setEditingAgent(agent.id);
1602
+ setEditName(agent.name || agent.fingerprint || "");
1603
+ }}
1604
+ className="flex-1 sm:flex-none px-3 py-2 rounded-lg text-sm text-[var(--text-muted)] hover:bg-[var(--surface)] transition text-center"
1605
+ >
1606
+ Rename
1607
+ </button>
1007
1608
  <button
1008
1609
  onClick={() => handleRevoke(agent.id)}
1009
- className={`px-4 py-2 rounded-lg text-sm font-medium transition ${
1610
+ className={`flex-1 sm:flex-none px-3 sm:px-4 py-2 rounded-lg text-sm font-medium transition flex items-center justify-center gap-1 ${
1010
1611
  confirmRevoke === agent.id
1011
1612
  ? "bg-red-500 text-white"
1012
1613
  : "bg-red-500/10 text-red-500 hover:bg-red-500/20"
1013
1614
  }`}
1615
+ title={agent.isCurrent ? "This will log you out" : "Remove this agent"}
1014
1616
  >
1015
- <Trash2 className="w-4 h-4 inline-block mr-1" />
1016
- {confirmRevoke === agent.id ? "Confirm" : "Revoke"}
1617
+ <Trash2 className="w-4 h-4" />
1618
+ <span className="hidden sm:inline">{confirmRevoke === agent.id ? (agent.isCurrent ? "Logout & Remove" : "Confirm") : "Revoke"}</span>
1619
+ <span className="sm:hidden">{confirmRevoke === agent.id ? "Confirm" : "Revoke"}</span>
1017
1620
  </button>
1018
- )}
1621
+ </div>
1019
1622
  </div>
1020
1623
  </div>
1021
1624
  ))}
@@ -1026,342 +1629,143 @@ function AgentsTab({
1026
1629
  }
1027
1630
 
1028
1631
  // ============================================
1029
- // USAGE TAB
1632
+ // ANALYTICS TAB (with subtabs)
1030
1633
  // ============================================
1031
1634
 
1032
- function UsageTab({
1635
+ function StatCard({
1636
+ title,
1637
+ value,
1638
+ change,
1639
+ icon: Icon,
1640
+ accent,
1641
+ }: {
1642
+ title: string;
1643
+ value: string;
1644
+ change?: number;
1645
+ icon: typeof Zap;
1646
+ accent?: boolean;
1647
+ }) {
1648
+ return (
1649
+ <div className={`rounded-xl sm:rounded-2xl border p-3 sm:p-5 ${accent ? "bg-[#ef4444]/10 border-[#ef4444]/30" : "bg-[var(--surface-elevated)] border-[var(--border)]"}`}>
1650
+ <div className="flex items-center justify-between mb-2 sm:mb-3">
1651
+ <span className="text-xs sm:text-sm text-[var(--text-muted)] truncate pr-2">{title}</span>
1652
+ <Icon className={`w-4 h-4 sm:w-5 sm:h-5 flex-shrink-0 ${accent ? "text-[#ef4444]" : "text-[var(--text-muted)]"}`} />
1653
+ </div>
1654
+ <div className="flex items-end justify-between">
1655
+ <span className={`text-xl sm:text-3xl font-bold ${accent ? "text-[#ef4444]" : ""}`}>{value}</span>
1656
+ {change !== undefined && (
1657
+ <div className={`flex items-center gap-1 text-xs sm:text-sm ${change >= 0 ? "text-green-500" : "text-red-500"}`}>
1658
+ {change >= 0 ? <ArrowUpRight className="w-3 h-3 sm:w-4 sm:h-4" /> : <ArrowDownRight className="w-3 h-3 sm:w-4 sm:h-4" />}
1659
+ {Math.abs(change).toFixed(1)}%
1660
+ </div>
1661
+ )}
1662
+ </div>
1663
+ </div>
1664
+ );
1665
+ }
1666
+
1667
+ function AnalyticsTab({
1668
+ apis,
1669
+ analytics,
1033
1670
  workspace,
1671
+ agents,
1034
1672
  usage,
1673
+ activeSubtab,
1674
+ setActiveSubtab,
1675
+ sessionToken,
1035
1676
  }: {
1677
+ apis: ProviderAPI[];
1678
+ analytics: ProviderAnalytics | null;
1036
1679
  workspace: Workspace | null;
1680
+ agents: Agent[];
1037
1681
  usage: UsageData | null;
1682
+ activeSubtab: AnalyticsSubtab;
1683
+ setActiveSubtab: (tab: AnalyticsSubtab) => void;
1684
+ sessionToken: string | null;
1038
1685
  }) {
1039
- const hasData = usage && (usage.byProvider.length > 0 || usage.byDay.length > 0);
1686
+ const router = useRouter();
1040
1687
 
1041
- return (
1042
- <div className="space-y-8">
1043
- <h2 className="text-2xl font-bold">Usage Analytics</h2>
1044
-
1045
- <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
1046
- <div className="rounded-2xl border border-[#ef4444]/30 bg-[#ef4444]/10 p-6">
1047
- <div className="flex items-center gap-3 mb-3">
1048
- <Zap className="w-6 h-6 text-[#ef4444]" />
1049
- <span className="text-[var(--text-muted)]">Total Calls</span>
1050
- </div>
1051
- <p className="text-4xl font-bold text-[#ef4444]">
1052
- {(usage?.total || workspace?.usageCount || 0).toLocaleString()}
1053
- </p>
1054
- </div>
1055
-
1056
- <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-6">
1057
- <div className="flex items-center gap-3 mb-3">
1058
- <TrendingUp className="w-6 h-6 text-[var(--text-muted)]" />
1059
- <span className="text-[var(--text-muted)]">Providers Used</span>
1060
- </div>
1061
- <p className="text-4xl font-bold">{usage?.byProvider.length || 0}</p>
1062
- </div>
1063
-
1064
- <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-6">
1065
- <div className="flex items-center gap-3 mb-3">
1066
- <Shield className="w-6 h-6 text-[var(--text-muted)]" />
1067
- <span className="text-[var(--text-muted)]">Remaining</span>
1068
- </div>
1069
- <p className="text-4xl font-bold">{workspace?.usageRemaining.toLocaleString() || "∞"}</p>
1070
- </div>
1071
- </div>
1072
-
1073
- {hasData ? (
1074
- <>
1075
- {usage!.byDay.length > 0 && (
1076
- <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-6">
1077
- <h3 className="font-semibold mb-4">Usage Over Time</h3>
1078
- <div className="h-80">
1079
- <ResponsiveContainer width="100%" height="100%">
1080
- <LineChart data={usage!.byDay}>
1081
- <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
1082
- <XAxis
1083
- dataKey="date"
1084
- tick={{ fontSize: 12, fill: "var(--text-muted)" }}
1085
- tickFormatter={(d) => new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric" })}
1086
- />
1087
- <YAxis tick={{ fontSize: 12, fill: "var(--text-muted)" }} />
1088
- <Tooltip
1089
- contentStyle={{
1090
- background: "var(--surface-elevated)",
1091
- border: "1px solid var(--border)",
1092
- borderRadius: "8px",
1093
- }}
1094
- />
1095
- <Line type="monotone" dataKey="calls" stroke="#ef4444" strokeWidth={2} dot={false} />
1096
- </LineChart>
1097
- </ResponsiveContainer>
1098
- </div>
1099
- </div>
1100
- )}
1101
-
1102
- {usage!.byProvider.length > 0 && (
1103
- <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-6">
1104
- <h3 className="font-semibold mb-4">Usage by Provider</h3>
1105
- <div className="space-y-3">
1106
- {usage!.byProvider.map((p, i) => (
1107
- <div key={p.provider} className="flex items-center justify-between p-4 rounded-xl bg-[var(--surface)]">
1108
- <div className="flex items-center gap-3">
1109
- <span className="w-8 h-8 rounded-full bg-[#ef4444]/20 text-[#ef4444] flex items-center justify-center text-sm font-medium">
1110
- {i + 1}
1111
- </span>
1112
- <span className="font-medium">{p.provider}</span>
1113
- </div>
1114
- <div className="text-right">
1115
- <p className="font-semibold">{p.calls.toLocaleString()} calls</p>
1116
- {p.cost > 0 && <p className="text-sm text-[var(--text-muted)]">${p.cost.toFixed(2)}</p>}
1117
- </div>
1118
- </div>
1119
- ))}
1120
- </div>
1121
- </div>
1122
- )}
1123
- </>
1124
- ) : (
1125
- <div className="rounded-2xl border border-dashed border-[var(--border)] bg-[var(--surface)]/50 p-12 text-center">
1126
- <TrendingUp className="w-12 h-12 text-[var(--text-muted)] mx-auto mb-4" />
1127
- <h3 className="font-semibold text-lg mb-2">No Usage Data Yet</h3>
1128
- <p className="text-[var(--text-muted)] max-w-md mx-auto">
1129
- When your agents start making API calls, usage analytics will appear here.
1130
- </p>
1131
- </div>
1132
- )}
1133
- </div>
1134
- );
1135
- }
1136
-
1137
- // ============================================
1138
- // BILLING TAB
1139
- // ============================================
1140
-
1141
- function BillingTab({ workspace }: { workspace: Workspace | null }) {
1142
- const tier = workspace?.tier || "free";
1143
- const PAYMENT_LINK = "https://buy.stripe.com/aFabJ32S0h185GI2GQcMM0h";
1144
-
1145
- return (
1146
- <div className="space-y-8">
1147
- <h2 className="text-2xl font-bold">Billing</h2>
1148
-
1149
- {/* Current Plan */}
1150
- <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-6">
1151
- <div className="flex items-center justify-between mb-6">
1152
- <div>
1153
- <h3 className="font-bold text-lg">Current Plan</h3>
1154
- <p className="text-[var(--text-muted)]">Your workspace subscription</p>
1155
- </div>
1156
- <div className="px-4 py-2 rounded-full bg-[#ef4444]/20 text-[#ef4444] font-semibold capitalize">
1157
- {tier}
1158
- </div>
1159
- </div>
1160
-
1161
- {tier === "free" ? (
1162
- <div className="space-y-4">
1163
- <div className="flex items-center justify-between py-3 border-b border-[var(--border)]">
1164
- <span className="text-[var(--text-muted)]">API Calls</span>
1165
- <span className="font-medium">{workspace?.usageLimit.toLocaleString() || "1,000"} / month</span>
1166
- </div>
1167
- <div className="flex items-center justify-between py-3 border-b border-[var(--border)]">
1168
- <span className="text-[var(--text-muted)]">Support</span>
1169
- <span className="font-medium">Community</span>
1170
- </div>
1171
- <div className="flex items-center justify-between py-3">
1172
- <span className="text-[var(--text-muted)]">Price</span>
1173
- <span className="font-medium">Free</span>
1174
- </div>
1175
- </div>
1176
- ) : (
1177
- <div className="space-y-4">
1178
- <div className="flex items-center justify-between py-3 border-b border-[var(--border)]">
1179
- <span className="text-[var(--text-muted)]">API Calls</span>
1180
- <span className="font-medium">10,000 / month</span>
1181
- </div>
1182
- <div className="flex items-center justify-between py-3 border-b border-[var(--border)]">
1183
- <span className="text-[var(--text-muted)]">Support</span>
1184
- <span className="font-medium">Priority</span>
1185
- </div>
1186
- <div className="flex items-center justify-between py-3">
1187
- <span className="text-[var(--text-muted)]">Price</span>
1188
- <span className="font-medium">$99 / month</span>
1189
- </div>
1190
- </div>
1191
- )}
1192
- </div>
1193
-
1194
- {/* Upgrade CTA */}
1195
- {tier === "free" && (
1196
- <div className="rounded-2xl border border-[#ef4444]/30 bg-gradient-to-br from-[#ef4444]/10 to-[#ef4444]/5 p-8">
1197
- <div className="flex items-start gap-4 mb-6">
1198
- <div className="w-12 h-12 rounded-xl bg-[#ef4444]/20 flex items-center justify-center">
1199
- <Crown className="w-6 h-6 text-[#ef4444]" />
1200
- </div>
1201
- <div>
1202
- <h3 className="font-bold text-xl mb-2">Upgrade to Pro</h3>
1203
- <p className="text-[var(--text-muted)]">
1204
- Get 10x more API calls and priority support.
1205
- </p>
1206
- </div>
1207
- </div>
1208
-
1209
- <div className="grid md:grid-cols-2 gap-4 mb-6">
1210
- <div className="flex items-center gap-3">
1211
- <Check className="w-5 h-5 text-green-500" />
1212
- <span>10,000 API calls / month</span>
1213
- </div>
1214
- <div className="flex items-center gap-3">
1215
- <Check className="w-5 h-5 text-green-500" />
1216
- <span>Priority support</span>
1217
- </div>
1218
- <div className="flex items-center gap-3">
1219
- <Check className="w-5 h-5 text-green-500" />
1220
- <span>Advanced analytics</span>
1221
- </div>
1222
- <div className="flex items-center gap-3">
1223
- <Check className="w-5 h-5 text-green-500" />
1224
- <span>Custom integrations</span>
1225
- </div>
1226
- </div>
1227
-
1228
- <div className="flex items-center gap-4">
1229
- <a href={PAYMENT_LINK} className="btn-primary">
1230
- Upgrade for $99/month
1231
- <ChevronRight className="w-5 h-5" />
1232
- </a>
1233
- </div>
1234
- </div>
1235
- )}
1236
-
1237
- {/* Usage This Month */}
1238
- {workspace && (
1239
- <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-6">
1240
- <h3 className="font-bold text-lg mb-4">Usage This Month</h3>
1241
- <div className="h-4 bg-[var(--surface)] rounded-full overflow-hidden mb-4">
1242
- <div
1243
- className={`h-full rounded-full ${
1244
- workspace.usagePercentage > 90 ? "bg-red-500" :
1245
- workspace.usagePercentage > 70 ? "bg-yellow-500" : "bg-[#ef4444]"
1246
- }`}
1247
- style={{ width: `${Math.min(workspace.usagePercentage, 100)}%` }}
1248
- />
1249
- </div>
1250
- <div className="flex items-center justify-between text-sm text-[var(--text-muted)]">
1251
- <span>{workspace.usageCount.toLocaleString()} of {workspace.usageLimit.toLocaleString()} calls used</span>
1252
- <span>{workspace.usagePercentage.toFixed(1)}%</span>
1253
- </div>
1254
- </div>
1255
- )}
1256
- </div>
1257
- );
1258
- }
1259
-
1260
- // ============================================
1261
- // ANALYTICS TAB (Provider)
1262
- // ============================================
1263
-
1264
- function StatCard({
1265
- title,
1266
- value,
1267
- change,
1268
- icon: Icon,
1269
- accent,
1270
- }: {
1271
- title: string;
1272
- value: string;
1273
- change?: number;
1274
- icon: typeof Zap;
1275
- accent?: boolean;
1276
- }) {
1277
- return (
1278
- <div className={`rounded-2xl border p-5 ${accent ? "bg-[#ef4444]/10 border-[#ef4444]/30" : "bg-[var(--surface-elevated)] border-[var(--border)]"}`}>
1279
- <div className="flex items-center justify-between mb-3">
1280
- <span className="text-sm text-[var(--text-muted)]">{title}</span>
1281
- <Icon className={`w-5 h-5 ${accent ? "text-[#ef4444]" : "text-[var(--text-muted)]"}`} />
1282
- </div>
1283
- <div className="flex items-end justify-between">
1284
- <span className={`text-3xl font-bold ${accent ? "text-[#ef4444]" : ""}`}>{value}</span>
1285
- {change !== undefined && (
1286
- <div className={`flex items-center gap-1 text-sm ${change >= 0 ? "text-green-500" : "text-red-500"}`}>
1287
- {change >= 0 ? <ArrowUpRight className="w-4 h-4" /> : <ArrowDownRight className="w-4 h-4" />}
1288
- {Math.abs(change).toFixed(1)}%
1289
- </div>
1290
- )}
1291
- </div>
1292
- </div>
1293
- );
1294
- }
1295
-
1296
- function AnalyticsTab({
1297
- apis,
1298
- analytics,
1299
- workspace,
1300
- agents,
1301
- usage,
1302
- activeSubtab,
1303
- setActiveSubtab,
1304
- }: {
1305
- apis: ProviderAPI[];
1306
- analytics: ProviderAnalytics | null;
1307
- workspace: Workspace | null;
1308
- agents: Agent[];
1309
- usage: UsageData | null;
1310
- activeSubtab: "apis" | "agents";
1311
- setActiveSubtab: (tab: "apis" | "agents") => void;
1312
- }) {
1313
1688
  return (
1314
1689
  <div className="space-y-6">
1315
1690
  {/* Subtab Navigation */}
1316
1691
  <div className="flex items-center gap-1 p-1 bg-[var(--surface)] rounded-xl w-fit">
1317
1692
  <button
1318
- onClick={() => setActiveSubtab("apis")}
1693
+ onClick={() => {
1694
+ setActiveSubtab("overview");
1695
+ router.push("/workspace?tab=analytics&sub=overview");
1696
+ }}
1697
+ className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
1698
+ activeSubtab === "overview"
1699
+ ? "bg-[#ef4444] text-white"
1700
+ : "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
1701
+ }`}
1702
+ >
1703
+ <BarChart3 className="w-4 h-4" />
1704
+ Overview
1705
+ </button>
1706
+ <button
1707
+ onClick={() => {
1708
+ setActiveSubtab("usage");
1709
+ router.push("/workspace?tab=analytics&sub=usage");
1710
+ }}
1319
1711
  className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
1320
- activeSubtab === "apis"
1712
+ activeSubtab === "usage"
1321
1713
  ? "bg-[#ef4444] text-white"
1322
1714
  : "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
1323
1715
  }`}
1324
1716
  >
1325
- <Zap className="w-4 h-4" />
1326
- My APIs
1717
+ <TrendingUp className="w-4 h-4" />
1718
+ Usage
1327
1719
  </button>
1328
1720
  <button
1329
- onClick={() => setActiveSubtab("agents")}
1721
+ onClick={() => {
1722
+ setActiveSubtab("logs");
1723
+ router.push("/workspace?tab=analytics&sub=logs");
1724
+ }}
1330
1725
  className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
1331
- activeSubtab === "agents"
1726
+ activeSubtab === "logs"
1332
1727
  ? "bg-[#ef4444] text-white"
1333
1728
  : "text-[var(--text-muted)] hover:text-[var(--text-primary)]"
1334
1729
  }`}
1335
1730
  >
1336
- <Users className="w-4 h-4" />
1337
- My Agents
1731
+ <ScrollText className="w-4 h-4" />
1732
+ Logs
1338
1733
  </button>
1339
1734
  </div>
1340
1735
 
1341
1736
  {/* Subtab Content */}
1342
- {activeSubtab === "apis" && (
1343
- <MyAPIsAnalytics apis={apis} analytics={analytics} />
1737
+ {activeSubtab === "overview" && (
1738
+ <AnalyticsOverviewTab apis={apis} analytics={analytics} workspace={workspace} agents={agents} usage={usage} />
1344
1739
  )}
1345
- {activeSubtab === "agents" && (
1346
- <MyAgentsAnalytics workspace={workspace} agents={agents} usage={usage} />
1740
+ {activeSubtab === "usage" && (
1741
+ <UsageTab workspace={workspace} usage={usage} />
1742
+ )}
1743
+ {activeSubtab === "logs" && (
1744
+ <LogsTab sessionToken={sessionToken} />
1347
1745
  )}
1348
1746
  </div>
1349
1747
  );
1350
1748
  }
1351
1749
 
1352
1750
  // ============================================
1353
- // MY APIs ANALYTICS (Provider view)
1751
+ // ANALYTICS OVERVIEW TAB
1354
1752
  // ============================================
1355
1753
 
1356
- function MyAPIsAnalytics({
1754
+ function AnalyticsOverviewTab({
1357
1755
  apis,
1358
1756
  analytics,
1757
+ workspace,
1758
+ agents,
1759
+ usage,
1359
1760
  }: {
1360
1761
  apis: ProviderAPI[];
1361
1762
  analytics: ProviderAnalytics | null;
1763
+ workspace: Workspace | null;
1764
+ agents: Agent[];
1765
+ usage: UsageData | null;
1362
1766
  }) {
1363
- const totalCalls = analytics?.totalCalls || 0;
1364
- const uniqueAgents = analytics?.uniqueAgents || 0;
1767
+ const totalCalls = analytics?.totalCalls || workspace?.usageCount || 0;
1768
+ const uniqueAgents = analytics?.uniqueAgents || agents.length || 0;
1365
1769
  const hasChartData = analytics && analytics.callsByDay && analytics.callsByDay.length > 0;
1366
1770
 
1367
1771
  return (
@@ -1372,20 +1776,15 @@ function MyAPIsAnalytics({
1372
1776
  <AlertCircle className="w-5 h-5 text-[#ef4444] flex-shrink-0" />
1373
1777
  <div>
1374
1778
  <p className="font-medium text-[#ef4444]">Preview Mode</p>
1375
- <p className="text-sm text-[var(--text-muted)]">This is sample data. Real analytics will appear once agents start using your APIs.</p>
1779
+ <p className="text-sm text-[var(--text-muted)]">This is sample data. Real analytics will appear once your agents start making API calls.</p>
1376
1780
  </div>
1377
1781
  </div>
1378
1782
  )}
1379
1783
 
1380
- <div>
1381
- <h2 className="text-2xl font-bold">My APIs Analytics</h2>
1382
- <p className="text-[var(--text-muted)]">How other agents are using your listed APIs</p>
1383
- </div>
1384
-
1385
1784
  {/* Stats Grid */}
1386
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
1785
+ <div className="grid grid-cols-2 lg:grid-cols-4 gap-3 md:gap-4">
1387
1786
  <StatCard title="Total Calls" value={totalCalls.toLocaleString()} icon={Zap} accent />
1388
- <StatCard title="Unique Agents" value={uniqueAgents.toString()} icon={Users} />
1787
+ <StatCard title="Connected Agents" value={uniqueAgents.toString()} icon={Users} />
1389
1788
  <StatCard title="Avg Latency" value={`${analytics?.avgLatency || 145}ms`} icon={Clock} />
1390
1789
  <StatCard title="Success Rate" value={`${(analytics?.successRate || 98.2).toFixed(1)}%`} icon={Check} />
1391
1790
  </div>
@@ -1416,11 +1815,11 @@ function MyAPIsAnalytics({
1416
1815
  </div>
1417
1816
  </div>
1418
1817
 
1419
- {/* Top Agents using your APIs */}
1818
+ {/* Top Agents */}
1420
1819
  <div className="bg-[var(--surface-elevated)] rounded-2xl border border-[var(--border)] p-6">
1421
- <h3 className="font-semibold mb-4">Top Consumers</h3>
1820
+ <h3 className="font-semibold mb-4">Top Agents</h3>
1422
1821
  <div className="space-y-3">
1423
- {analytics!.topAgents.slice(0, 6).map((agent, i) => (
1822
+ {(analytics?.topAgents || []).slice(0, 6).map((agent, i) => (
1424
1823
  <div key={agent.agentId} className="flex items-center justify-between">
1425
1824
  <div className="flex items-center gap-3">
1426
1825
  <span className="w-6 h-6 rounded-full bg-[var(--surface)] flex items-center justify-center text-xs font-medium text-[var(--text-muted)]">{i + 1}</span>
@@ -1429,7 +1828,7 @@ function MyAPIsAnalytics({
1429
1828
  <span className="text-sm text-[var(--text-muted)]">{agent.calls.toLocaleString()}</span>
1430
1829
  </div>
1431
1830
  ))}
1432
- {analytics!.topAgents.length === 0 && <p className="text-[var(--text-muted)] text-sm">No agent activity yet</p>}
1831
+ {(!analytics?.topAgents || analytics.topAgents.length === 0) && <p className="text-[var(--text-muted)] text-sm">No agent activity yet</p>}
1433
1832
  </div>
1434
1833
  </div>
1435
1834
  </div>
@@ -1453,10 +1852,10 @@ function MyAPIsAnalytics({
1453
1852
  </div>
1454
1853
  )}
1455
1854
 
1456
- {/* Usage by API */}
1457
- <div className="bg-[var(--surface-elevated)] border border-[var(--border)] rounded-2xl p-6">
1458
- <h3 className="font-semibold text-lg mb-4">Performance by API</h3>
1459
- {apis.length > 0 ? (
1855
+ {/* My APIs Performance */}
1856
+ {apis.length > 0 && (
1857
+ <div className="bg-[var(--surface-elevated)] border border-[var(--border)] rounded-2xl p-6">
1858
+ <h3 className="font-semibold text-lg mb-4">My APIs Performance</h3>
1460
1859
  <div className="space-y-4">
1461
1860
  {apis.map((api) => (
1462
1861
  <div key={api._id} className="flex items-center justify-between p-4 rounded-xl bg-[var(--surface)]">
@@ -1474,164 +1873,2727 @@ function MyAPIsAnalytics({
1474
1873
  </div>
1475
1874
  ))}
1476
1875
  </div>
1477
- ) : (
1478
- <div className="text-center py-8">
1479
- <p className="text-[var(--text-muted)] mb-4">No APIs listed yet</p>
1480
- <Link href="/providers/register" className="btn-primary !py-2 !px-4 text-sm">
1481
- <Plus className="w-4 h-4" />
1482
- Add Your First API
1483
- </Link>
1484
- </div>
1485
- )}
1486
- </div>
1876
+ </div>
1877
+ )}
1487
1878
  </div>
1488
1879
  );
1489
1880
  }
1490
1881
 
1491
1882
  // ============================================
1492
- // MY AGENTS ANALYTICS (Consumer view)
1883
+ // USAGE TAB
1493
1884
  // ============================================
1494
1885
 
1495
- function generateAgentPreviewData() {
1496
- const days = [];
1497
- const baseDate = new Date();
1498
- for (let i = 29; i >= 0; i--) {
1499
- const date = new Date(baseDate);
1500
- date.setDate(date.getDate() - i);
1501
- days.push({
1502
- date: date.toISOString().split("T")[0],
1503
- calls: Math.floor(Math.random() * 80) + 20 + Math.floor(i * 1.5),
1504
- });
1505
- }
1506
- return days;
1507
- }
1508
-
1509
- function MyAgentsAnalytics({
1886
+ function UsageTab({
1510
1887
  workspace,
1511
- agents,
1512
1888
  usage,
1513
1889
  }: {
1514
1890
  workspace: Workspace | null;
1515
- agents: Agent[];
1516
1891
  usage: UsageData | null;
1517
1892
  }) {
1518
- const totalCalls = workspace?.usageCount || usage?.total || 0;
1519
- const hasUsageData = usage && usage.byDay && usage.byDay.length > 0;
1520
- const isPreview = !hasUsageData && totalCalls === 0;
1521
- const chartData = hasUsageData ? usage!.byDay : generateAgentPreviewData();
1893
+ const hasData = usage && (usage.byProvider.length > 0 || usage.byDay.length > 0);
1522
1894
 
1523
1895
  return (
1524
1896
  <div className="space-y-8">
1525
- {/* Preview Banner */}
1526
- {isPreview && (
1527
- <div className="bg-[#ef4444]/10 border border-[#ef4444]/30 rounded-xl p-4 flex items-center gap-3">
1528
- <AlertCircle className="w-5 h-5 text-[#ef4444] flex-shrink-0" />
1529
- <div>
1530
- <p className="font-medium text-[#ef4444]">Preview Mode</p>
1531
- <p className="text-sm text-[var(--text-muted)]">This is sample data. Real analytics will appear once your agents start making API calls.</p>
1897
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-3 md:gap-4">
1898
+ <div className="rounded-2xl border border-[#ef4444]/30 bg-[#ef4444]/10 p-4 sm:p-6">
1899
+ <div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-3">
1900
+ <Zap className="w-5 h-5 sm:w-6 sm:h-6 text-[#ef4444]" />
1901
+ <span className="text-sm sm:text-base text-[var(--text-muted)]">Total Calls</span>
1902
+ </div>
1903
+ <p className="text-2xl sm:text-4xl font-bold text-[#ef4444]">
1904
+ {(usage?.total || workspace?.usageCount || 0).toLocaleString()}
1905
+ </p>
1906
+ </div>
1907
+
1908
+ <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-4 sm:p-6">
1909
+ <div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-3">
1910
+ <TrendingUp className="w-5 h-5 sm:w-6 sm:h-6 text-[var(--text-muted)]" />
1911
+ <span className="text-sm sm:text-base text-[var(--text-muted)]">Providers Used</span>
1532
1912
  </div>
1913
+ <p className="text-2xl sm:text-4xl font-bold">{usage?.byProvider.length || 0}</p>
1914
+ </div>
1915
+
1916
+ <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-4 sm:p-6">
1917
+ <div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-3">
1918
+ <Shield className="w-5 h-5 sm:w-6 sm:h-6 text-[var(--text-muted)]" />
1919
+ <span className="text-sm sm:text-base text-[var(--text-muted)]">Remaining</span>
1920
+ </div>
1921
+ <p className="text-2xl sm:text-4xl font-bold">{workspace?.usageRemaining.toLocaleString() || "∞"}</p>
1922
+ </div>
1923
+ </div>
1924
+
1925
+ {hasData ? (
1926
+ <>
1927
+ {usage!.byDay.length > 0 && (
1928
+ <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-6">
1929
+ <h3 className="font-semibold mb-4">Usage Over Time</h3>
1930
+ <div className="h-80">
1931
+ <ResponsiveContainer width="100%" height="100%">
1932
+ <LineChart data={usage!.byDay}>
1933
+ <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
1934
+ <XAxis
1935
+ dataKey="date"
1936
+ tick={{ fontSize: 12, fill: "var(--text-muted)" }}
1937
+ tickFormatter={(d) => new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric" })}
1938
+ />
1939
+ <YAxis tick={{ fontSize: 12, fill: "var(--text-muted)" }} />
1940
+ <Tooltip
1941
+ contentStyle={{
1942
+ background: "var(--surface-elevated)",
1943
+ border: "1px solid var(--border)",
1944
+ borderRadius: "8px",
1945
+ }}
1946
+ />
1947
+ <Line type="monotone" dataKey="calls" stroke="#ef4444" strokeWidth={2} dot={false} />
1948
+ </LineChart>
1949
+ </ResponsiveContainer>
1950
+ </div>
1951
+ </div>
1952
+ )}
1953
+
1954
+ {usage!.byProvider.length > 0 && (
1955
+ <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-6">
1956
+ <h3 className="font-semibold mb-4">Usage by Provider</h3>
1957
+ <div className="space-y-3">
1958
+ {usage!.byProvider.map((p, i) => (
1959
+ <div key={p.provider} className="flex items-center justify-between p-4 rounded-xl bg-[var(--surface)]">
1960
+ <div className="flex items-center gap-3">
1961
+ <span className="w-8 h-8 rounded-full bg-[#ef4444]/20 text-[#ef4444] flex items-center justify-center text-sm font-medium">
1962
+ {i + 1}
1963
+ </span>
1964
+ <span className="font-medium">{p.provider}</span>
1965
+ </div>
1966
+ <div className="text-right">
1967
+ <p className="font-semibold">{p.calls.toLocaleString()} calls</p>
1968
+ {p.cost > 0 && <p className="text-sm text-[var(--text-muted)]">${p.cost.toFixed(2)}</p>}
1969
+ </div>
1970
+ </div>
1971
+ ))}
1972
+ </div>
1973
+ </div>
1974
+ )}
1975
+ </>
1976
+ ) : (
1977
+ <div className="rounded-2xl border border-dashed border-[var(--border)] bg-[var(--surface)]/50 p-12 text-center">
1978
+ <TrendingUp className="w-12 h-12 text-[var(--text-muted)] mx-auto mb-4" />
1979
+ <h3 className="font-semibold text-lg mb-2">No Usage Data Yet</h3>
1980
+ <p className="text-[var(--text-muted)] max-w-md mx-auto">
1981
+ When your agents start making API calls, usage analytics will appear here.
1982
+ </p>
1533
1983
  </div>
1534
1984
  )}
1985
+ </div>
1986
+ );
1987
+ }
1535
1988
 
1536
- <div>
1537
- <h2 className="text-2xl font-bold">My Agents Analytics</h2>
1538
- <p className="text-[var(--text-muted)]">How your agents are using APIs through APIClaw</p>
1989
+ // ============================================
1990
+ // LOGS TAB
1991
+ // ============================================
1992
+
1993
+ interface LogEntry {
1994
+ id: string;
1995
+ provider: string;
1996
+ action: string;
1997
+ status: "success" | "error";
1998
+ latencyMs: number;
1999
+ errorMessage?: string;
2000
+ createdAt: number;
2001
+ }
2002
+
2003
+ interface LogStats {
2004
+ totalCalls: number;
2005
+ successCount: number;
2006
+ errorCount: number;
2007
+ successRate: number;
2008
+ avgLatency: number;
2009
+ providers: string[];
2010
+ }
2011
+
2012
+ function LogsTab({ sessionToken }: { sessionToken: string | null }) {
2013
+ const [logs, setLogs] = useState<LogEntry[]>([]);
2014
+ const [stats, setStats] = useState<LogStats | null>(null);
2015
+ const [isLoading, setIsLoading] = useState(true);
2016
+ const [statusFilter, setStatusFilter] = useState<"all" | "success" | "error">("all");
2017
+ const [providerFilter, setProviderFilter] = useState<string>("all");
2018
+ const [providers, setProviders] = useState<string[]>([]);
2019
+ const [hasMore, setHasMore] = useState(false);
2020
+ const [nextCursor, setNextCursor] = useState<number | undefined>();
2021
+ const [loadingMore, setLoadingMore] = useState(false);
2022
+
2023
+ const fetchLogs = useCallback(async (append = false) => {
2024
+ if (!sessionToken) return;
2025
+
2026
+ if (append) {
2027
+ setLoadingMore(true);
2028
+ } else {
2029
+ setIsLoading(true);
2030
+ }
2031
+
2032
+ try {
2033
+ const cursor = append ? nextCursor : undefined;
2034
+
2035
+ const logsRes = await fetch(`${CONVEX_URL}/api/query`, {
2036
+ method: "POST",
2037
+ headers: { "Content-Type": "application/json" },
2038
+ body: JSON.stringify({
2039
+ path: "logs:getLogs",
2040
+ args: {
2041
+ token: sessionToken,
2042
+ limit: 50,
2043
+ cursor,
2044
+ status: statusFilter,
2045
+ provider: providerFilter === "all" ? undefined : providerFilter,
2046
+ },
2047
+ }),
2048
+ });
2049
+
2050
+ const logsData = await logsRes.json();
2051
+ const result = logsData.value || logsData;
2052
+
2053
+ if (append) {
2054
+ setLogs(prev => [...prev, ...(result.logs || [])]);
2055
+ } else {
2056
+ setLogs(result.logs || []);
2057
+ }
2058
+ setHasMore(result.hasMore || false);
2059
+ setNextCursor(result.nextCursor);
2060
+ } catch (err) {
2061
+ console.error("Error fetching logs:", err);
2062
+ } finally {
2063
+ setIsLoading(false);
2064
+ setLoadingMore(false);
2065
+ }
2066
+ }, [sessionToken, statusFilter, providerFilter, nextCursor]);
2067
+
2068
+ const fetchStats = useCallback(async () => {
2069
+ if (!sessionToken) return;
2070
+
2071
+ try {
2072
+ const statsRes = await fetch(`${CONVEX_URL}/api/query`, {
2073
+ method: "POST",
2074
+ headers: { "Content-Type": "application/json" },
2075
+ body: JSON.stringify({
2076
+ path: "logs:getLogStats",
2077
+ args: { token: sessionToken, periodDays: 7 },
2078
+ }),
2079
+ });
2080
+
2081
+ const statsData = await statsRes.json();
2082
+ const result = statsData.value || statsData;
2083
+ setStats(result);
2084
+ setProviders(result.providers || []);
2085
+ } catch (err) {
2086
+ console.error("Error fetching stats:", err);
2087
+ }
2088
+ }, [sessionToken]);
2089
+
2090
+ useEffect(() => {
2091
+ fetchLogs();
2092
+ fetchStats();
2093
+ }, [fetchLogs, fetchStats]);
2094
+
2095
+ // Reset and refetch when filters change
2096
+ useEffect(() => {
2097
+ setNextCursor(undefined);
2098
+ fetchLogs(false);
2099
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2100
+ }, [statusFilter, providerFilter]);
2101
+
2102
+ const formatTime = (timestamp: number) => {
2103
+ const date = new Date(timestamp);
2104
+ const now = new Date();
2105
+ const diff = now.getTime() - date.getTime();
2106
+
2107
+ if (diff < 60000) return "Just now";
2108
+ if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
2109
+ if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
2110
+ if (diff < 604800000) return `${Math.floor(diff / 86400000)}d ago`;
2111
+
2112
+ return date.toLocaleDateString("en-US", {
2113
+ month: "short",
2114
+ day: "numeric",
2115
+ hour: "2-digit",
2116
+ minute: "2-digit",
2117
+ });
2118
+ };
2119
+
2120
+ if (!sessionToken) {
2121
+ return (
2122
+ <div className="space-y-6">
2123
+ <div className="rounded-2xl border border-dashed border-[var(--border)] bg-[var(--surface)]/50 p-12 text-center">
2124
+ <ScrollText className="w-16 h-16 text-[var(--text-muted)] mx-auto mb-4" />
2125
+ <h3 className="font-semibold text-xl mb-2">Not Logged In</h3>
2126
+ <p className="text-[var(--text-muted)]">Please log in to view your API logs.</p>
2127
+ </div>
1539
2128
  </div>
2129
+ );
2130
+ }
1540
2131
 
1541
- {/* Stats Grid */}
1542
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
1543
- <StatCard title="Total API Calls" value={isPreview ? "1,247" : totalCalls.toLocaleString()} icon={Zap} accent />
1544
- <StatCard title="Connected Agents" value={isPreview ? "3" : agents.length.toString()} icon={Users} />
1545
- <StatCard title="APIs Used" value={isPreview ? "8" : (usage?.byProvider.length || 0).toString()} icon={BarChart3} />
1546
- <StatCard
1547
- title="Remaining Calls"
1548
- value={isPreview ? "8,753" : (workspace?.usageRemaining || 0).toLocaleString()}
1549
- icon={Shield}
1550
- />
2132
+ return (
2133
+ <div className="space-y-6">
2134
+ {/* Filters */}
2135
+ <div className="flex flex-wrap gap-3">
2136
+ <select
2137
+ value={statusFilter}
2138
+ onChange={(e) => setStatusFilter(e.target.value as "all" | "success" | "error")}
2139
+ className="px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm focus:outline-none focus:ring-2 focus:ring-[#ef4444]/50"
2140
+ >
2141
+ <option value="all">All Status</option>
2142
+ <option value="success">Success</option>
2143
+ <option value="error">Error</option>
2144
+ </select>
2145
+
2146
+ <select
2147
+ value={providerFilter}
2148
+ onChange={(e) => setProviderFilter(e.target.value)}
2149
+ className="px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm focus:outline-none focus:ring-2 focus:ring-[#ef4444]/50"
2150
+ >
2151
+ <option value="all">All Providers</option>
2152
+ {providers.map((p) => (
2153
+ <option key={p} value={p}>{p}</option>
2154
+ ))}
2155
+ </select>
1551
2156
  </div>
1552
2157
 
1553
- {/* Usage Chart */}
1554
- <div className="bg-[var(--surface-elevated)] rounded-2xl border border-[var(--border)] p-6">
1555
- <h3 className="font-semibold mb-4">Your Agents&apos; API Calls Over Time</h3>
1556
- <div className="h-80">
1557
- <ResponsiveContainer width="100%" height="100%">
1558
- <LineChart data={chartData}>
1559
- <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
1560
- <XAxis
1561
- dataKey="date"
1562
- tick={{ fontSize: 12, fill: "var(--text-muted)" }}
1563
- tickFormatter={(d) => new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric" })}
1564
- />
1565
- <YAxis tick={{ fontSize: 12, fill: "var(--text-muted)" }} />
1566
- <Tooltip
1567
- contentStyle={{ background: "var(--surface-elevated)", border: "1px solid var(--border)", borderRadius: "8px" }}
1568
- labelFormatter={(d) => new Date(d).toLocaleDateString()}
1569
- />
1570
- <Line type="monotone" dataKey="calls" stroke="#ef4444" strokeWidth={2} dot={false} activeDot={{ r: 4, fill: "#ef4444" }} />
1571
- </LineChart>
1572
- </ResponsiveContainer>
2158
+ {/* Stats Cards */}
2159
+ {stats && stats.totalCalls > 0 && (
2160
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4">
2161
+ <div className="rounded-xl border border-[var(--border)] bg-[var(--surface-elevated)] p-4">
2162
+ <div className="flex items-center gap-2 mb-2">
2163
+ <BarChart3 className="w-4 h-4 text-[var(--text-muted)]" />
2164
+ <span className="text-sm text-[var(--text-muted)]">Total Calls</span>
2165
+ </div>
2166
+ <p className="text-2xl font-bold">{stats.totalCalls.toLocaleString()}</p>
2167
+ </div>
2168
+
2169
+ <div className="rounded-xl border border-[var(--border)] bg-[var(--surface-elevated)] p-4">
2170
+ <div className="flex items-center gap-2 mb-2">
2171
+ <Check className="w-4 h-4 text-green-500" />
2172
+ <span className="text-sm text-[var(--text-muted)]">Success Rate</span>
2173
+ </div>
2174
+ <p className="text-2xl font-bold text-green-500">{stats.successRate}%</p>
2175
+ </div>
2176
+
2177
+ <div className="rounded-xl border border-[var(--border)] bg-[var(--surface-elevated)] p-4">
2178
+ <div className="flex items-center gap-2 mb-2">
2179
+ <AlertCircle className="w-4 h-4 text-red-500" />
2180
+ <span className="text-sm text-[var(--text-muted)]">Errors</span>
2181
+ </div>
2182
+ <p className="text-2xl font-bold text-red-500">{stats.errorCount}</p>
2183
+ </div>
2184
+
2185
+ <div className="rounded-xl border border-[var(--border)] bg-[var(--surface-elevated)] p-4">
2186
+ <div className="flex items-center gap-2 mb-2">
2187
+ <Clock className="w-4 h-4 text-[var(--text-muted)]" />
2188
+ <span className="text-sm text-[var(--text-muted)]">Avg Latency</span>
2189
+ </div>
2190
+ <p className="text-2xl font-bold">{stats.avgLatency}ms</p>
2191
+ </div>
1573
2192
  </div>
1574
- </div>
2193
+ )}
1575
2194
 
1576
- {/* APIs Your Agents Use */}
1577
- <div className="bg-[var(--surface-elevated)] rounded-2xl border border-[var(--border)] p-6">
1578
- <h3 className="font-semibold mb-4">APIs Your Agents Use</h3>
1579
- {(usage?.byProvider && usage.byProvider.length > 0) || isPreview ? (
1580
- <div className="space-y-3">
1581
- {(isPreview ? [
1582
- { provider: "OpenRouter", calls: 523, cost: 0 },
1583
- { provider: "Replicate", calls: 312, cost: 0 },
1584
- { provider: "ElevenLabs", calls: 189, cost: 0 },
1585
- { provider: "Brave Search", calls: 156, cost: 0 },
1586
- { provider: "46elks", calls: 67, cost: 0 },
1587
- ] : usage!.byProvider).map((p, i) => (
1588
- <div key={p.provider} className="flex items-center justify-between p-4 rounded-xl bg-[var(--surface)]">
1589
- <div className="flex items-center gap-3">
1590
- <span className="w-8 h-8 rounded-full bg-[#ef4444]/20 text-[#ef4444] flex items-center justify-center text-sm font-medium">{i + 1}</span>
1591
- <span className="font-medium">{p.provider}</span>
1592
- </div>
1593
- <div className="text-right">
1594
- <p className="font-semibold">{p.calls.toLocaleString()} calls</p>
1595
- {p.cost > 0 && <p className="text-sm text-[var(--text-muted)]">${p.cost.toFixed(2)}</p>}
1596
- </div>
1597
- </div>
1598
- ))}
2195
+ {/* Logs Table */}
2196
+ {isLoading ? (
2197
+ <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-12 text-center">
2198
+ <Loader2 className="w-8 h-8 text-[#ef4444] animate-spin mx-auto mb-4" />
2199
+ <p className="text-[var(--text-muted)]">Loading logs...</p>
2200
+ </div>
2201
+ ) : logs.length === 0 ? (
2202
+ <div className="rounded-2xl border border-dashed border-[var(--border)] bg-[var(--surface)]/50 p-12 text-center">
2203
+ <ScrollText className="w-16 h-16 text-[var(--text-muted)] mx-auto mb-4" />
2204
+ <h3 className="font-semibold text-xl mb-2">No API calls logged yet</h3>
2205
+ <p className="text-[var(--text-muted)] max-w-md mx-auto">
2206
+ When your agents start making Direct Call API requests, they&apos;ll appear here with timestamps, latency, and status information.
2207
+ </p>
2208
+ </div>
2209
+ ) : (
2210
+ <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] overflow-hidden">
2211
+ {/* Desktop Table */}
2212
+ <div className="hidden md:block overflow-x-auto">
2213
+ <table className="w-full">
2214
+ <thead className="bg-[var(--surface)]">
2215
+ <tr>
2216
+ <th className="px-4 py-3 text-left text-sm font-medium text-[var(--text-muted)]">Time</th>
2217
+ <th className="px-4 py-3 text-left text-sm font-medium text-[var(--text-muted)]">Provider</th>
2218
+ <th className="px-4 py-3 text-left text-sm font-medium text-[var(--text-muted)]">Action</th>
2219
+ <th className="px-4 py-3 text-left text-sm font-medium text-[var(--text-muted)]">Status</th>
2220
+ <th className="px-4 py-3 text-left text-sm font-medium text-[var(--text-muted)]">Latency</th>
2221
+ </tr>
2222
+ </thead>
2223
+ <tbody className="divide-y divide-[var(--border)]">
2224
+ {logs.map((log) => (
2225
+ <tr key={log.id} className="hover:bg-[var(--surface)] transition">
2226
+ <td className="px-4 py-3 text-sm text-[var(--text-muted)]">
2227
+ {formatTime(log.createdAt)}
2228
+ </td>
2229
+ <td className="px-4 py-3">
2230
+ <span className="font-medium">{log.provider}</span>
2231
+ </td>
2232
+ <td className="px-4 py-3">
2233
+ <code className="px-2 py-1 rounded bg-[var(--surface)] text-sm font-mono">
2234
+ {log.action}
2235
+ </code>
2236
+ </td>
2237
+ <td className="px-4 py-3">
2238
+ {log.status === "success" ? (
2239
+ <span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-green-500/20 text-green-500 text-xs font-medium">
2240
+ <Check className="w-3 h-3" />
2241
+ Success
2242
+ </span>
2243
+ ) : (
2244
+ <span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-red-500/20 text-red-500 text-xs font-medium" title={log.errorMessage}>
2245
+ <AlertCircle className="w-3 h-3" />
2246
+ Error
2247
+ </span>
2248
+ )}
2249
+ </td>
2250
+ <td className="px-4 py-3 text-sm">
2251
+ <span className={log.latencyMs > 1000 ? "text-yellow-500" : "text-[var(--text-muted)]"}>
2252
+ {log.latencyMs}ms
2253
+ </span>
2254
+ </td>
2255
+ </tr>
2256
+ ))}
2257
+ </tbody>
2258
+ </table>
1599
2259
  </div>
1600
- ) : (
1601
- <p className="text-[var(--text-muted)] text-center py-8">No API usage data yet</p>
1602
- )}
1603
- </div>
1604
2260
 
1605
- {/* Connected Agents */}
1606
- <div className="bg-[var(--surface-elevated)] rounded-2xl border border-[var(--border)] p-6">
1607
- <h3 className="font-semibold mb-4">Your Connected Agents</h3>
1608
- {agents.length > 0 || isPreview ? (
1609
- <div className="space-y-3">
1610
- {(isPreview ? [
1611
- { id: "1", fingerprint: "claude_prod_main", lastUsedAt: Date.now() - 3600000, isCurrent: true },
1612
- { id: "2", fingerprint: "cursor_dev_local", lastUsedAt: Date.now() - 86400000, isCurrent: false },
1613
- { id: "3", fingerprint: "aider_ci_runner", lastUsedAt: Date.now() - 172800000, isCurrent: false },
1614
- ] as Agent[] : agents).map((agent) => (
1615
- <div key={agent.id} className="flex items-center justify-between p-4 rounded-xl bg-[var(--surface)]">
1616
- <div className="flex items-center gap-3">
1617
- <div className="w-10 h-10 rounded-full bg-[#ef4444]/20 flex items-center justify-center">
1618
- <Users className="w-5 h-5 text-[#ef4444]" />
1619
- </div>
1620
- <div>
1621
- <p className="font-medium">{agent.fingerprint}</p>
1622
- <p className="text-sm text-[var(--text-muted)]">Last active: {new Date(agent.lastUsedAt).toLocaleDateString()}</p>
1623
- </div>
2261
+ {/* Mobile Cards */}
2262
+ <div className="md:hidden divide-y divide-[var(--border)]">
2263
+ {logs.map((log) => (
2264
+ <div key={log.id} className="p-4 space-y-2">
2265
+ <div className="flex items-center justify-between">
2266
+ <span className="font-medium">{log.provider}</span>
2267
+ {log.status === "success" ? (
2268
+ <span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-green-500/20 text-green-500 text-xs font-medium">
2269
+ <Check className="w-3 h-3" />
2270
+ Success
2271
+ </span>
2272
+ ) : (
2273
+ <span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-red-500/20 text-red-500 text-xs font-medium">
2274
+ <AlertCircle className="w-3 h-3" />
2275
+ Error
2276
+ </span>
2277
+ )}
1624
2278
  </div>
1625
- {agent.isCurrent && (
1626
- <span className="px-2 py-1 rounded-full bg-green-500/20 text-green-500 text-xs font-medium">Current</span>
2279
+ <div className="flex items-center justify-between text-sm">
2280
+ <code className="px-2 py-1 rounded bg-[var(--surface)] font-mono text-xs">
2281
+ {log.action}
2282
+ </code>
2283
+ <span className="text-[var(--text-muted)]">{log.latencyMs}ms</span>
2284
+ </div>
2285
+ <p className="text-xs text-[var(--text-muted)]">{formatTime(log.createdAt)}</p>
2286
+ {log.errorMessage && (
2287
+ <p className="text-xs text-red-500 truncate">{log.errorMessage}</p>
1627
2288
  )}
1628
2289
  </div>
1629
2290
  ))}
1630
2291
  </div>
1631
- ) : (
1632
- <p className="text-[var(--text-muted)] text-center py-8">No agents connected yet</p>
1633
- )}
1634
- </div>
2292
+
2293
+ {/* Load More */}
2294
+ {hasMore && (
2295
+ <div className="p-4 border-t border-[var(--border)] text-center">
2296
+ <button
2297
+ onClick={() => fetchLogs(true)}
2298
+ disabled={loadingMore}
2299
+ className="px-6 py-2 rounded-lg bg-[var(--surface)] hover:bg-[var(--surface-elevated)] transition text-sm font-medium disabled:opacity-50"
2300
+ >
2301
+ {loadingMore ? (
2302
+ <span className="flex items-center gap-2">
2303
+ <Loader2 className="w-4 h-4 animate-spin" />
2304
+ Loading...
2305
+ </span>
2306
+ ) : (
2307
+ "Load More"
2308
+ )}
2309
+ </button>
2310
+ </div>
2311
+ )}
2312
+ </div>
2313
+ )}
1635
2314
  </div>
1636
2315
  );
1637
2316
  }
2317
+
2318
+ // ============================================
2319
+ // BILLING TAB
2320
+ // ============================================
2321
+
2322
+ interface BillingInfo {
2323
+ plan: string;
2324
+ tier: string;
2325
+ usage: number;
2326
+ currentPeriodUsage: number;
2327
+ limit: number;
2328
+ creditBalance: number;
2329
+ stripeCustomerId?: string;
2330
+ stripeSubscriptionId?: string;
2331
+ lastBillingDate?: number;
2332
+ needsPaymentMethod: boolean;
2333
+ invoices: Invoice[];
2334
+ }
2335
+
2336
+ interface Invoice {
2337
+ id: string;
2338
+ stripeInvoiceId: string;
2339
+ amount: number;
2340
+ amountFormatted?: string;
2341
+ status: string;
2342
+ periodStart: number;
2343
+ periodEnd: number;
2344
+ callCount: number;
2345
+ pdfUrl?: string;
2346
+ createdAt: number;
2347
+ }
2348
+
2349
+ interface PaymentMethod {
2350
+ brand: string;
2351
+ last4: string;
2352
+ expMonth: number;
2353
+ expYear: number;
2354
+ }
2355
+
2356
+ function BillingTab({ workspace, sessionToken }: { workspace: Workspace | null; sessionToken: string | null }) {
2357
+ const [billingInfo, setBillingInfo] = useState<BillingInfo | null>(null);
2358
+ const [invoices, setInvoices] = useState<Invoice[]>([]);
2359
+ const [paymentMethod, setPaymentMethod] = useState<PaymentMethod | null>(null);
2360
+ const [isLoading, setIsLoading] = useState(true);
2361
+ const [isLoadingPortal, setIsLoadingPortal] = useState(false);
2362
+ const [error, setError] = useState<string | null>(null);
2363
+
2364
+ // Fetch billing info
2365
+ useEffect(() => {
2366
+ const fetchBillingData = async () => {
2367
+ if (!sessionToken || !workspace?.id) {
2368
+ setIsLoading(false);
2369
+ return;
2370
+ }
2371
+
2372
+ try {
2373
+ // Fetch billing info
2374
+ const infoRes = await fetch(`${CONVEX_URL}/api/query`, {
2375
+ method: "POST",
2376
+ headers: { "Content-Type": "application/json" },
2377
+ body: JSON.stringify({
2378
+ path: "billing:getInfo",
2379
+ args: { workspaceId: workspace.id },
2380
+ }),
2381
+ });
2382
+ const infoData = await infoRes.json();
2383
+ const info = infoData.value || infoData;
2384
+
2385
+ if (info && !info.error) {
2386
+ setBillingInfo(info);
2387
+ setInvoices(info.invoices || []);
2388
+ }
2389
+
2390
+ // If user has Stripe customer, try to get payment method
2391
+ if (info?.stripeCustomerId) {
2392
+ try {
2393
+ const pmRes = await fetch("/api/billing/payment-method", {
2394
+ method: "POST",
2395
+ headers: { "Content-Type": "application/json" },
2396
+ body: JSON.stringify({ token: sessionToken }),
2397
+ });
2398
+ const pmData = await pmRes.json();
2399
+ if (pmData.paymentMethod) {
2400
+ setPaymentMethod(pmData.paymentMethod);
2401
+ }
2402
+ } catch {
2403
+ // Payment method fetch is optional
2404
+ }
2405
+ }
2406
+ } catch (err) {
2407
+ console.error("Failed to fetch billing info:", err);
2408
+ setError("Failed to load billing information");
2409
+ } finally {
2410
+ setIsLoading(false);
2411
+ }
2412
+ };
2413
+
2414
+ fetchBillingData();
2415
+ }, [sessionToken, workspace?.id]);
2416
+
2417
+ // Open Stripe billing portal
2418
+ const openBillingPortal = async () => {
2419
+ if (!sessionToken) return;
2420
+
2421
+ setIsLoadingPortal(true);
2422
+ try {
2423
+ const res = await fetch("/api/billing/portal", {
2424
+ method: "POST",
2425
+ headers: { "Content-Type": "application/json" },
2426
+ body: JSON.stringify({ token: sessionToken }),
2427
+ });
2428
+ const data = await res.json();
2429
+
2430
+ if (data.url) {
2431
+ window.location.href = data.url;
2432
+ } else {
2433
+ setError(data.error || "Failed to open billing portal");
2434
+ }
2435
+ } catch {
2436
+ setError("Failed to open billing portal");
2437
+ } finally {
2438
+ setIsLoadingPortal(false);
2439
+ }
2440
+ };
2441
+
2442
+ const tier = billingInfo?.tier || workspace?.tier || "free";
2443
+ const plan = billingInfo?.plan || "free";
2444
+ const hasPaymentMethod = !!billingInfo?.stripeCustomerId;
2445
+
2446
+ // Calculate estimated cost
2447
+ const FREE_CALLS = 100;
2448
+ const COST_PER_CALL = 0.01;
2449
+ const currentUsage = billingInfo?.currentPeriodUsage || workspace?.usageCount || 0;
2450
+ const billableCalls = Math.max(0, currentUsage - FREE_CALLS);
2451
+ const estimatedCost = billableCalls * COST_PER_CALL;
2452
+
2453
+ // Plan display names
2454
+ const planDisplayNames: Record<string, string> = {
2455
+ free: "Free",
2456
+ usage_based: "Usage-Based",
2457
+ starter: "Starter",
2458
+ pro: "Pro",
2459
+ scale: "Scale",
2460
+ };
2461
+
2462
+ if (isLoading) {
2463
+ return (
2464
+ <div className="space-y-8">
2465
+ <h2 className="text-2xl font-bold">Billing</h2>
2466
+ <div className="flex items-center justify-center py-16">
2467
+ <Loader2 className="w-8 h-8 text-[#ef4444] animate-spin" />
2468
+ </div>
2469
+ </div>
2470
+ );
2471
+ }
2472
+
2473
+ return (
2474
+ <div className="space-y-8">
2475
+ <h2 className="text-2xl font-bold">Billing</h2>
2476
+
2477
+ {error && (
2478
+ <div className="rounded-xl bg-red-500/10 border border-red-500/30 p-4 flex items-center gap-3">
2479
+ <AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0" />
2480
+ <p className="text-red-500">{error}</p>
2481
+ </div>
2482
+ )}
2483
+
2484
+ {/* Current Plan */}
2485
+ <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-6">
2486
+ <div className="flex items-center justify-between mb-6">
2487
+ <div>
2488
+ <h3 className="font-bold text-lg">Current Plan</h3>
2489
+ <p className="text-[var(--text-muted)]">Your workspace subscription</p>
2490
+ </div>
2491
+ <div className={`px-4 py-2 rounded-full font-semibold ${
2492
+ plan === "usage_based"
2493
+ ? "bg-green-500/20 text-green-500"
2494
+ : plan === "free"
2495
+ ? "bg-[var(--surface)] text-[var(--text-muted)]"
2496
+ : "bg-[#ef4444]/20 text-[#ef4444]"
2497
+ }`}>
2498
+ {planDisplayNames[plan] || plan}
2499
+ </div>
2500
+ </div>
2501
+
2502
+ {plan === "free" ? (
2503
+ <div className="space-y-4">
2504
+ <div className="flex items-center justify-between py-3 border-b border-[var(--border)]">
2505
+ <span className="text-[var(--text-muted)]">API Calls</span>
2506
+ <span className="font-medium">{workspace?.usageLimit?.toLocaleString() || "100"} / month</span>
2507
+ </div>
2508
+ <div className="flex items-center justify-between py-3 border-b border-[var(--border)]">
2509
+ <span className="text-[var(--text-muted)]">Support</span>
2510
+ <span className="font-medium">Community</span>
2511
+ </div>
2512
+ <div className="flex items-center justify-between py-3">
2513
+ <span className="text-[var(--text-muted)]">Price</span>
2514
+ <span className="font-medium">Free</span>
2515
+ </div>
2516
+ </div>
2517
+ ) : plan === "usage_based" ? (
2518
+ <div className="space-y-4">
2519
+ <div className="flex items-center justify-between py-3 border-b border-[var(--border)]">
2520
+ <span className="text-[var(--text-muted)]">API Calls</span>
2521
+ <span className="font-medium flex items-center gap-2">
2522
+ Unlimited
2523
+ <span className="px-2 py-0.5 rounded-full bg-green-500/20 text-green-500 text-xs">Active</span>
2524
+ </span>
2525
+ </div>
2526
+ <div className="flex items-center justify-between py-3 border-b border-[var(--border)]">
2527
+ <span className="text-[var(--text-muted)]">Free Tier</span>
2528
+ <span className="font-medium">100 calls / month</span>
2529
+ </div>
2530
+ <div className="flex items-center justify-between py-3 border-b border-[var(--border)]">
2531
+ <span className="text-[var(--text-muted)]">Rate</span>
2532
+ <span className="font-medium">$0.01 / call (after free tier)</span>
2533
+ </div>
2534
+ <div className="flex items-center justify-between py-3">
2535
+ <span className="text-[var(--text-muted)]">Support</span>
2536
+ <span className="font-medium">Priority</span>
2537
+ </div>
2538
+ </div>
2539
+ ) : (
2540
+ <div className="space-y-4">
2541
+ <div className="flex items-center justify-between py-3 border-b border-[var(--border)]">
2542
+ <span className="text-[var(--text-muted)]">API Calls</span>
2543
+ <span className="font-medium">{workspace?.usageLimit?.toLocaleString() || "10,000"} / month</span>
2544
+ </div>
2545
+ <div className="flex items-center justify-between py-3 border-b border-[var(--border)]">
2546
+ <span className="text-[var(--text-muted)]">Support</span>
2547
+ <span className="font-medium">Priority</span>
2548
+ </div>
2549
+ <div className="flex items-center justify-between py-3">
2550
+ <span className="text-[var(--text-muted)]">Price</span>
2551
+ <span className="font-medium">$99 / month</span>
2552
+ </div>
2553
+ </div>
2554
+ )}
2555
+ </div>
2556
+
2557
+ {/* Usage This Month */}
2558
+ {workspace && (
2559
+ <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-6">
2560
+ <div className="flex items-center justify-between mb-4">
2561
+ <h3 className="font-bold text-lg">Usage This Month</h3>
2562
+ {plan === "usage_based" && (
2563
+ <div className="text-right">
2564
+ <p className="text-sm text-[var(--text-muted)]">Estimated Cost</p>
2565
+ <p className="text-2xl font-bold text-[#ef4444]">${estimatedCost.toFixed(2)}</p>
2566
+ </div>
2567
+ )}
2568
+ </div>
2569
+
2570
+ <div className="h-4 bg-[var(--surface)] rounded-full overflow-hidden mb-4">
2571
+ <div
2572
+ className={`h-full rounded-full transition-all duration-500 ${
2573
+ plan === "usage_based"
2574
+ ? "bg-green-500"
2575
+ : workspace.usagePercentage > 90
2576
+ ? "bg-red-500"
2577
+ : workspace.usagePercentage > 70
2578
+ ? "bg-yellow-500"
2579
+ : "bg-[#ef4444]"
2580
+ }`}
2581
+ style={{ width: plan === "usage_based" ? "100%" : `${Math.min(workspace.usagePercentage, 100)}%` }}
2582
+ />
2583
+ </div>
2584
+
2585
+ <div className="flex items-center justify-between text-sm">
2586
+ <span className="text-[var(--text-muted)]">
2587
+ {currentUsage.toLocaleString()} {plan === "usage_based" ? "calls this period" : `of ${workspace.usageLimit.toLocaleString()} calls used`}
2588
+ </span>
2589
+ {plan === "usage_based" ? (
2590
+ <span className="text-green-500 font-medium">
2591
+ {billableCalls > 0 ? `${billableCalls.toLocaleString()} billable` : "Within free tier"}
2592
+ </span>
2593
+ ) : (
2594
+ <span className="text-[var(--text-muted)]">{workspace.usagePercentage.toFixed(1)}%</span>
2595
+ )}
2596
+ </div>
2597
+
2598
+ {plan === "usage_based" && (
2599
+ <div className="mt-4 pt-4 border-t border-[var(--border)] grid grid-cols-3 gap-4 text-center">
2600
+ <div>
2601
+ <p className="text-2xl font-bold">{currentUsage.toLocaleString()}</p>
2602
+ <p className="text-sm text-[var(--text-muted)]">Total Calls</p>
2603
+ </div>
2604
+ <div>
2605
+ <p className="text-2xl font-bold text-green-500">{Math.min(currentUsage, FREE_CALLS)}</p>
2606
+ <p className="text-sm text-[var(--text-muted)]">Free Calls</p>
2607
+ </div>
2608
+ <div>
2609
+ <p className="text-2xl font-bold text-[#ef4444]">{billableCalls.toLocaleString()}</p>
2610
+ <p className="text-sm text-[var(--text-muted)]">Billable</p>
2611
+ </div>
2612
+ </div>
2613
+ )}
2614
+ </div>
2615
+ )}
2616
+
2617
+ {/* Payment Method */}
2618
+ <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-6">
2619
+ <div className="flex items-center justify-between mb-4">
2620
+ <h3 className="font-bold text-lg">Payment Method</h3>
2621
+ {hasPaymentMethod && (
2622
+ <button
2623
+ onClick={openBillingPortal}
2624
+ disabled={isLoadingPortal}
2625
+ className="text-sm text-[#ef4444] hover:underline flex items-center gap-1"
2626
+ >
2627
+ {isLoadingPortal ? (
2628
+ <Loader2 className="w-4 h-4 animate-spin" />
2629
+ ) : (
2630
+ <>
2631
+ Manage
2632
+ <ExternalLink className="w-3 h-3" />
2633
+ </>
2634
+ )}
2635
+ </button>
2636
+ )}
2637
+ </div>
2638
+
2639
+ {paymentMethod ? (
2640
+ <div className="flex items-center gap-4 p-4 rounded-xl bg-[var(--surface)]">
2641
+ <div className="w-12 h-8 rounded bg-gradient-to-br from-blue-600 to-blue-800 flex items-center justify-center">
2642
+ <CreditCard className="w-6 h-4 text-white" />
2643
+ </div>
2644
+ <div className="flex-1">
2645
+ <p className="font-medium capitalize">{paymentMethod.brand} •••• {paymentMethod.last4}</p>
2646
+ <p className="text-sm text-[var(--text-muted)]">Expires {paymentMethod.expMonth}/{paymentMethod.expYear}</p>
2647
+ </div>
2648
+ <Check className="w-5 h-5 text-green-500" />
2649
+ </div>
2650
+ ) : hasPaymentMethod ? (
2651
+ <div className="flex items-center gap-4 p-4 rounded-xl bg-[var(--surface)]">
2652
+ <div className="w-12 h-8 rounded bg-green-500/20 flex items-center justify-center">
2653
+ <Check className="w-5 h-5 text-green-500" />
2654
+ </div>
2655
+ <div className="flex-1">
2656
+ <p className="font-medium">Payment method on file</p>
2657
+ <p className="text-sm text-[var(--text-muted)]">Managed through Stripe</p>
2658
+ </div>
2659
+ <button
2660
+ onClick={openBillingPortal}
2661
+ disabled={isLoadingPortal}
2662
+ className="text-sm text-[#ef4444] hover:underline"
2663
+ >
2664
+ View details
2665
+ </button>
2666
+ </div>
2667
+ ) : (
2668
+ <div className="text-center py-6">
2669
+ <div className="w-16 h-16 rounded-2xl bg-[var(--surface)] flex items-center justify-center mx-auto mb-4">
2670
+ <CreditCard className="w-8 h-8 text-[var(--text-muted)]" />
2671
+ </div>
2672
+ <p className="text-[var(--text-muted)] mb-4">No payment method on file</p>
2673
+ <p className="text-sm text-[var(--text-muted)] mb-4">
2674
+ Add a payment method to unlock unlimited API calls
2675
+ </p>
2676
+ {sessionToken && (
2677
+ <CheckoutButton sessionToken={sessionToken} variant="outline">
2678
+ <CreditCard className="w-4 h-4" />
2679
+ Add Payment Method
2680
+ </CheckoutButton>
2681
+ )}
2682
+ </div>
2683
+ )}
2684
+ </div>
2685
+
2686
+ {/* Upgrade CTA - Only for free tier */}
2687
+ {plan === "free" && sessionToken && (
2688
+ <div className="rounded-2xl border border-[#ef4444]/30 bg-gradient-to-br from-[#ef4444]/10 to-[#ef4444]/5 p-8">
2689
+ <div className="flex items-start gap-4 mb-6">
2690
+ <div className="w-12 h-12 rounded-xl bg-[#ef4444]/20 flex items-center justify-center">
2691
+ <Zap className="w-6 h-6 text-[#ef4444]" />
2692
+ </div>
2693
+ <div>
2694
+ <h3 className="font-bold text-xl mb-2">Unlock Unlimited API Calls</h3>
2695
+ <p className="text-[var(--text-muted)]">
2696
+ Pay only for what you use. First 100 calls free every month, then just $0.01 per call.
2697
+ </p>
2698
+ </div>
2699
+ </div>
2700
+
2701
+ <div className="grid md:grid-cols-2 gap-4 mb-6">
2702
+ <div className="flex items-center gap-3">
2703
+ <Check className="w-5 h-5 text-green-500" />
2704
+ <span>100 free calls / month</span>
2705
+ </div>
2706
+ <div className="flex items-center gap-3">
2707
+ <Check className="w-5 h-5 text-green-500" />
2708
+ <span>$0.01 per additional call</span>
2709
+ </div>
2710
+ <div className="flex items-center gap-3">
2711
+ <Check className="w-5 h-5 text-green-500" />
2712
+ <span>No monthly minimum</span>
2713
+ </div>
2714
+ <div className="flex items-center gap-3">
2715
+ <Check className="w-5 h-5 text-green-500" />
2716
+ <span>Cancel anytime</span>
2717
+ </div>
2718
+ </div>
2719
+
2720
+ <CheckoutButton sessionToken={sessionToken} variant="primary">
2721
+ <CreditCard className="w-5 h-5" />
2722
+ Add Payment Method
2723
+ <ChevronRight className="w-5 h-5" />
2724
+ </CheckoutButton>
2725
+
2726
+ <p className="mt-4 text-sm text-[var(--text-muted)]">
2727
+ You&apos;ll only be charged for usage beyond 100 free calls. Billed monthly.
2728
+ </p>
2729
+ </div>
2730
+ )}
2731
+
2732
+ {/* Invoices */}
2733
+ <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-6">
2734
+ <div className="flex items-center justify-between mb-6">
2735
+ <h3 className="font-bold text-lg">Invoices</h3>
2736
+ {invoices.length > 0 && hasPaymentMethod && (
2737
+ <button
2738
+ onClick={openBillingPortal}
2739
+ disabled={isLoadingPortal}
2740
+ className="text-sm text-[#ef4444] hover:underline flex items-center gap-1"
2741
+ >
2742
+ View all in Stripe
2743
+ <ExternalLink className="w-3 h-3" />
2744
+ </button>
2745
+ )}
2746
+ </div>
2747
+
2748
+ {invoices.length > 0 ? (
2749
+ <div className="space-y-3">
2750
+ {invoices.map((invoice) => (
2751
+ <div
2752
+ key={invoice.id}
2753
+ className="flex items-center justify-between p-4 rounded-xl bg-[var(--surface)] hover:bg-[var(--surface-elevated)] transition"
2754
+ >
2755
+ <div className="flex items-center gap-4">
2756
+ <div className="w-10 h-10 rounded-lg bg-[var(--background)] flex items-center justify-center">
2757
+ <ScrollText className="w-5 h-5 text-[var(--text-muted)]" />
2758
+ </div>
2759
+ <div>
2760
+ <p className="font-medium">
2761
+ {new Date(invoice.periodStart).toLocaleDateString("en-US", { month: "short", year: "numeric" })}
2762
+ </p>
2763
+ <p className="text-sm text-[var(--text-muted)]">
2764
+ {invoice.callCount?.toLocaleString() || 0} API calls
2765
+ </p>
2766
+ </div>
2767
+ </div>
2768
+ <div className="flex items-center gap-4">
2769
+ <div className="text-right">
2770
+ <p className="font-semibold">${(invoice.amount / 100).toFixed(2)}</p>
2771
+ <span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
2772
+ invoice.status === "paid"
2773
+ ? "bg-green-500/20 text-green-500"
2774
+ : invoice.status === "open" || invoice.status === "pending"
2775
+ ? "bg-yellow-500/20 text-yellow-500"
2776
+ : "bg-red-500/20 text-red-500"
2777
+ }`}>
2778
+ {invoice.status === "paid" && <Check className="w-3 h-3" />}
2779
+ {invoice.status === "paid" ? "Paid" : invoice.status === "open" ? "Pending" : invoice.status}
2780
+ </span>
2781
+ </div>
2782
+ {invoice.pdfUrl && (
2783
+ <a
2784
+ href={invoice.pdfUrl}
2785
+ target="_blank"
2786
+ rel="noopener noreferrer"
2787
+ className="p-2 rounded-lg hover:bg-[var(--background)] transition"
2788
+ title="View PDF"
2789
+ >
2790
+ <ExternalLink className="w-4 h-4 text-[var(--text-muted)]" />
2791
+ </a>
2792
+ )}
2793
+ </div>
2794
+ </div>
2795
+ ))}
2796
+ </div>
2797
+ ) : (
2798
+ <div className="text-center py-8">
2799
+ <div className="w-16 h-16 rounded-2xl bg-[var(--surface)] flex items-center justify-center mx-auto mb-4">
2800
+ <ScrollText className="w-8 h-8 text-[var(--text-muted)]" />
2801
+ </div>
2802
+ <p className="text-[var(--text-muted)] mb-2">No invoices yet</p>
2803
+ <p className="text-sm text-[var(--text-muted)]">
2804
+ {plan === "free"
2805
+ ? "Invoices will appear here once you upgrade to a paid plan"
2806
+ : "Your first invoice will appear at the end of the billing period"}
2807
+ </p>
2808
+ </div>
2809
+ )}
2810
+ </div>
2811
+
2812
+ {/* Credit Balance (if applicable) */}
2813
+ {billingInfo?.creditBalance && billingInfo.creditBalance > 0 && (
2814
+ <div className="rounded-2xl border border-green-500/30 bg-green-500/10 p-6">
2815
+ <div className="flex items-center gap-4">
2816
+ <div className="w-12 h-12 rounded-xl bg-green-500/20 flex items-center justify-center">
2817
+ <Crown className="w-6 h-6 text-green-500" />
2818
+ </div>
2819
+ <div>
2820
+ <p className="text-sm text-green-500 font-medium">Credit Balance</p>
2821
+ <p className="text-2xl font-bold text-green-500">${(billingInfo.creditBalance / 100).toFixed(2)}</p>
2822
+ </div>
2823
+ </div>
2824
+ <p className="mt-4 text-sm text-[var(--text-muted)]">
2825
+ This credit will be applied to your next invoice automatically.
2826
+ </p>
2827
+ </div>
2828
+ )}
2829
+ </div>
2830
+ );
2831
+ }
2832
+
2833
+ // ============================================
2834
+ // WEBHOOKS TAB
2835
+ // ============================================
2836
+
2837
+ interface WebhookData {
2838
+ id: string;
2839
+ url: string;
2840
+ events: string[];
2841
+ enabled: boolean;
2842
+ lastTriggeredAt?: number;
2843
+ lastStatus?: string;
2844
+ failCount: number;
2845
+ createdAt: number;
2846
+ secretHint: string;
2847
+ }
2848
+
2849
+ const WEBHOOK_EVENTS = [
2850
+ { id: "usage.threshold.80", label: "Usage at 80%", description: "Triggered when usage reaches 80% of limit" },
2851
+ { id: "usage.threshold.100", label: "Usage at 100%", description: "Triggered when usage reaches limit" },
2852
+ { id: "api.error", label: "API Error", description: "Triggered when an API call fails" },
2853
+ { id: "agent.connected", label: "Agent Connected", description: "Triggered when a new agent connects" },
2854
+ { id: "agent.revoked", label: "Agent Revoked", description: "Triggered when an agent is revoked" },
2855
+ ];
2856
+
2857
+ function WebhooksTab() {
2858
+ const [webhooks, setWebhooks] = useState<WebhookData[]>([]);
2859
+ const [isLoading, setIsLoading] = useState(true);
2860
+ const [showAddModal, setShowAddModal] = useState(false);
2861
+ const [showEditModal, setShowEditModal] = useState<WebhookData | null>(null);
2862
+ const [showSecretModal, setShowSecretModal] = useState<{ id: string; secret: string } | null>(null);
2863
+ const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
2864
+ const [testingWebhook, setTestingWebhook] = useState<string | null>(null);
2865
+ const [testResult, setTestResult] = useState<{ id: string; success: boolean; message: string } | null>(null);
2866
+
2867
+ // Add modal state
2868
+ const [newUrl, setNewUrl] = useState("");
2869
+ const [newEvents, setNewEvents] = useState<string[]>([]);
2870
+ const [isSaving, setIsSaving] = useState(false);
2871
+ const [error, setError] = useState<string | null>(null);
2872
+
2873
+ // Fetch webhooks on mount
2874
+ useEffect(() => {
2875
+ fetchWebhooks();
2876
+ }, []);
2877
+
2878
+ const fetchWebhooks = async () => {
2879
+ const token = localStorage.getItem("apiclaw_workspace_session");
2880
+ if (!token) {
2881
+ setIsLoading(false);
2882
+ return;
2883
+ }
2884
+
2885
+ try {
2886
+ const res = await fetch(`${CONVEX_URL}/api/query`, {
2887
+ method: "POST",
2888
+ headers: { "Content-Type": "application/json" },
2889
+ body: JSON.stringify({
2890
+ path: "webhooks:getWebhooks",
2891
+ args: { token },
2892
+ }),
2893
+ });
2894
+ const data = await res.json();
2895
+ const result = data.value || data;
2896
+ if (result.webhooks) {
2897
+ setWebhooks(result.webhooks);
2898
+ }
2899
+ } catch (err) {
2900
+ console.error("Failed to fetch webhooks:", err);
2901
+ } finally {
2902
+ setIsLoading(false);
2903
+ }
2904
+ };
2905
+
2906
+ const handleCreateWebhook = async () => {
2907
+ if (!newUrl.trim() || newEvents.length === 0) {
2908
+ setError("URL and at least one event are required");
2909
+ return;
2910
+ }
2911
+
2912
+ const token = localStorage.getItem("apiclaw_workspace_session");
2913
+ if (!token) return;
2914
+
2915
+ setIsSaving(true);
2916
+ setError(null);
2917
+
2918
+ try {
2919
+ const res = await fetch(`${CONVEX_URL}/api/mutation`, {
2920
+ method: "POST",
2921
+ headers: { "Content-Type": "application/json" },
2922
+ body: JSON.stringify({
2923
+ path: "webhooks:createWebhook",
2924
+ args: { token, url: newUrl, events: newEvents },
2925
+ }),
2926
+ });
2927
+ const data = await res.json();
2928
+ const result = data.value || data;
2929
+
2930
+ if (result.error) {
2931
+ setError(result.error);
2932
+ } else if (result.success) {
2933
+ // Show secret modal
2934
+ setShowSecretModal({ id: result.webhookId, secret: result.secret });
2935
+ setShowAddModal(false);
2936
+ setNewUrl("");
2937
+ setNewEvents([]);
2938
+ // Refresh webhooks
2939
+ await fetchWebhooks();
2940
+ }
2941
+ } catch (err) {
2942
+ setError("Failed to create webhook");
2943
+ } finally {
2944
+ setIsSaving(false);
2945
+ }
2946
+ };
2947
+
2948
+ const handleUpdateWebhook = async (webhookId: string, updates: { enabled?: boolean; events?: string[] }) => {
2949
+ const token = localStorage.getItem("apiclaw_workspace_session");
2950
+ if (!token) return;
2951
+
2952
+ try {
2953
+ const res = await fetch(`${CONVEX_URL}/api/mutation`, {
2954
+ method: "POST",
2955
+ headers: { "Content-Type": "application/json" },
2956
+ body: JSON.stringify({
2957
+ path: "webhooks:updateWebhook",
2958
+ args: { token, webhookId, ...updates },
2959
+ }),
2960
+ });
2961
+ const data = await res.json();
2962
+ const result = data.value || data;
2963
+
2964
+ if (result.success) {
2965
+ await fetchWebhooks();
2966
+ setShowEditModal(null);
2967
+ }
2968
+ } catch (err) {
2969
+ console.error("Failed to update webhook:", err);
2970
+ }
2971
+ };
2972
+
2973
+ const handleDeleteWebhook = async (webhookId: string) => {
2974
+ if (confirmDelete !== webhookId) {
2975
+ setConfirmDelete(webhookId);
2976
+ return;
2977
+ }
2978
+
2979
+ const token = localStorage.getItem("apiclaw_workspace_session");
2980
+ if (!token) return;
2981
+
2982
+ try {
2983
+ await fetch(`${CONVEX_URL}/api/mutation`, {
2984
+ method: "POST",
2985
+ headers: { "Content-Type": "application/json" },
2986
+ body: JSON.stringify({
2987
+ path: "webhooks:deleteWebhook",
2988
+ args: { token, webhookId },
2989
+ }),
2990
+ });
2991
+ setWebhooks(webhooks.filter((w) => w.id !== webhookId));
2992
+ setConfirmDelete(null);
2993
+ } catch (err) {
2994
+ console.error("Failed to delete webhook:", err);
2995
+ }
2996
+ };
2997
+
2998
+ const handleTestWebhook = async (webhookId: string) => {
2999
+ const token = localStorage.getItem("apiclaw_workspace_session");
3000
+ if (!token) return;
3001
+
3002
+ setTestingWebhook(webhookId);
3003
+ setTestResult(null);
3004
+
3005
+ try {
3006
+ const res = await fetch(`${CONVEX_URL}/api/action`, {
3007
+ method: "POST",
3008
+ headers: { "Content-Type": "application/json" },
3009
+ body: JSON.stringify({
3010
+ path: "webhooks:testWebhook",
3011
+ args: { token, webhookId },
3012
+ }),
3013
+ });
3014
+ const data = await res.json();
3015
+ const result = data.value || data;
3016
+
3017
+ setTestResult({
3018
+ id: webhookId,
3019
+ success: result.success,
3020
+ message: result.message || (result.success ? "Delivered successfully" : "Failed to deliver"),
3021
+ });
3022
+
3023
+ // Clear result after 5 seconds
3024
+ setTimeout(() => setTestResult(null), 5000);
3025
+ } catch (err) {
3026
+ setTestResult({
3027
+ id: webhookId,
3028
+ success: false,
3029
+ message: "Failed to test webhook",
3030
+ });
3031
+ } finally {
3032
+ setTestingWebhook(null);
3033
+ }
3034
+ };
3035
+
3036
+ const toggleEvent = (eventId: string, currentEvents: string[], setEvents: (events: string[]) => void) => {
3037
+ if (currentEvents.includes(eventId)) {
3038
+ setEvents(currentEvents.filter((e) => e !== eventId));
3039
+ } else {
3040
+ setEvents([...currentEvents, eventId]);
3041
+ }
3042
+ };
3043
+
3044
+ const copyToClipboard = (text: string) => {
3045
+ navigator.clipboard.writeText(text);
3046
+ };
3047
+
3048
+ if (isLoading) {
3049
+ return (
3050
+ <div className="flex items-center justify-center py-16">
3051
+ <Loader2 className="w-8 h-8 text-[#ef4444] animate-spin" />
3052
+ </div>
3053
+ );
3054
+ }
3055
+
3056
+ return (
3057
+ <div className="space-y-6">
3058
+ <div className="flex items-center justify-between">
3059
+ <div>
3060
+ <h2 className="text-2xl font-bold mb-2">Webhooks</h2>
3061
+ <p className="text-[var(--text-muted)]">Get notified when events happen in your workspace</p>
3062
+ </div>
3063
+ <button
3064
+ onClick={() => setShowAddModal(true)}
3065
+ className="btn-primary !py-2 !px-4 text-sm flex items-center gap-2"
3066
+ >
3067
+ <Plus className="w-4 h-4" />
3068
+ Add Webhook
3069
+ </button>
3070
+ </div>
3071
+
3072
+ {/* Webhooks list */}
3073
+ {webhooks.length === 0 ? (
3074
+ <div className="rounded-2xl border border-dashed border-[var(--border)] bg-[var(--surface)]/50 p-12 text-center">
3075
+ <Webhook className="w-16 h-16 text-[var(--text-muted)] mx-auto mb-4" />
3076
+ <h3 className="font-semibold text-xl mb-2">No Webhooks Configured</h3>
3077
+ <p className="text-[var(--text-muted)] max-w-md mx-auto mb-6">
3078
+ Add a webhook to get notified about events in your workspace.
3079
+ </p>
3080
+ <button
3081
+ onClick={() => setShowAddModal(true)}
3082
+ className="btn-primary"
3083
+ >
3084
+ <Plus className="w-5 h-5" />
3085
+ Add Webhook
3086
+ </button>
3087
+ </div>
3088
+ ) : (
3089
+ <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] overflow-hidden">
3090
+ <div className="overflow-x-auto">
3091
+ <table className="w-full">
3092
+ <thead>
3093
+ <tr className="border-b border-[var(--border)]">
3094
+ <th className="text-left px-4 py-3 text-sm font-medium text-[var(--text-muted)]">URL</th>
3095
+ <th className="text-left px-4 py-3 text-sm font-medium text-[var(--text-muted)]">Events</th>
3096
+ <th className="text-left px-4 py-3 text-sm font-medium text-[var(--text-muted)]">Status</th>
3097
+ <th className="text-right px-4 py-3 text-sm font-medium text-[var(--text-muted)]">Actions</th>
3098
+ </tr>
3099
+ </thead>
3100
+ <tbody>
3101
+ {webhooks.map((webhook) => (
3102
+ <tr key={webhook.id} className="border-b border-[var(--border)] last:border-0 hover:bg-[var(--surface)]">
3103
+ <td className="px-4 py-4">
3104
+ <div className="flex items-center gap-2 max-w-xs">
3105
+ <span className="truncate font-mono text-sm">{webhook.url}</span>
3106
+ </div>
3107
+ {webhook.lastTriggeredAt && (
3108
+ <p className="text-xs text-[var(--text-muted)] mt-1">
3109
+ Last triggered: {new Date(webhook.lastTriggeredAt).toLocaleDateString()}
3110
+ </p>
3111
+ )}
3112
+ </td>
3113
+ <td className="px-4 py-4">
3114
+ <div className="flex flex-wrap gap-1">
3115
+ {webhook.events.slice(0, 2).map((event) => (
3116
+ <span key={event} className="px-2 py-0.5 rounded-full bg-[var(--surface)] text-xs">
3117
+ {event.split(".").slice(-1)[0]}
3118
+ </span>
3119
+ ))}
3120
+ {webhook.events.length > 2 && (
3121
+ <span className="px-2 py-0.5 rounded-full bg-[var(--surface)] text-xs text-[var(--text-muted)]">
3122
+ +{webhook.events.length - 2}
3123
+ </span>
3124
+ )}
3125
+ </div>
3126
+ </td>
3127
+ <td className="px-4 py-4">
3128
+ {webhook.enabled ? (
3129
+ <span className="flex items-center gap-1 text-green-500 text-sm">
3130
+ <Check className="w-4 h-4" />
3131
+ Active
3132
+ </span>
3133
+ ) : (
3134
+ <span className="flex items-center gap-1 text-[var(--text-muted)] text-sm">
3135
+ <AlertCircle className="w-4 h-4" />
3136
+ Disabled
3137
+ </span>
3138
+ )}
3139
+ {webhook.failCount > 0 && (
3140
+ <p className="text-xs text-red-500 mt-1">
3141
+ {webhook.failCount} failure{webhook.failCount > 1 ? "s" : ""}
3142
+ </p>
3143
+ )}
3144
+ </td>
3145
+ <td className="px-4 py-4">
3146
+ <div className="flex items-center justify-end gap-2">
3147
+ {/* Test result indicator */}
3148
+ {testResult?.id === webhook.id && (
3149
+ <span className={`text-xs px-2 py-1 rounded ${testResult.success ? "bg-green-500/20 text-green-500" : "bg-red-500/20 text-red-500"}`}>
3150
+ {testResult.success ? "✓ Delivered" : `✗ ${testResult.message}`}
3151
+ </span>
3152
+ )}
3153
+ <button
3154
+ onClick={() => handleTestWebhook(webhook.id)}
3155
+ disabled={testingWebhook === webhook.id}
3156
+ className="px-3 py-1.5 rounded-lg text-sm font-medium text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--surface)] transition disabled:opacity-50"
3157
+ >
3158
+ {testingWebhook === webhook.id ? (
3159
+ <Loader2 className="w-4 h-4 animate-spin" />
3160
+ ) : (
3161
+ "Test"
3162
+ )}
3163
+ </button>
3164
+ <button
3165
+ onClick={() => setShowEditModal(webhook)}
3166
+ className="px-3 py-1.5 rounded-lg text-sm font-medium text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--surface)] transition"
3167
+ >
3168
+ Edit
3169
+ </button>
3170
+ <button
3171
+ onClick={() => handleDeleteWebhook(webhook.id)}
3172
+ className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
3173
+ confirmDelete === webhook.id
3174
+ ? "bg-red-500 text-white"
3175
+ : "text-red-500 hover:bg-red-500/10"
3176
+ }`}
3177
+ >
3178
+ {confirmDelete === webhook.id ? "Confirm" : "Delete"}
3179
+ </button>
3180
+ </div>
3181
+ </td>
3182
+ </tr>
3183
+ ))}
3184
+ </tbody>
3185
+ </table>
3186
+ </div>
3187
+ </div>
3188
+ )}
3189
+
3190
+ {/* Add Webhook Modal */}
3191
+ {showAddModal && (
3192
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
3193
+ <div className="bg-[var(--surface-elevated)] rounded-2xl border border-[var(--border)] w-full max-w-lg p-6">
3194
+ <div className="flex items-center justify-between mb-6">
3195
+ <h3 className="font-bold text-lg">Add Webhook</h3>
3196
+ <button onClick={() => { setShowAddModal(false); setError(null); }} className="p-2 rounded-lg hover:bg-[var(--surface)]">
3197
+ <X className="w-5 h-5" />
3198
+ </button>
3199
+ </div>
3200
+
3201
+ <div className="space-y-4">
3202
+ <div>
3203
+ <label className="block text-sm font-medium mb-2">Webhook URL</label>
3204
+ <input
3205
+ type="url"
3206
+ value={newUrl}
3207
+ onChange={(e) => setNewUrl(e.target.value)}
3208
+ placeholder="https://your-server.com/webhook"
3209
+ className="w-full px-4 py-3 rounded-xl border border-[var(--border)] bg-[var(--background)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:outline-none focus:ring-2 focus:ring-[#ef4444]/50"
3210
+ />
3211
+ <p className="text-xs text-[var(--text-muted)] mt-1">Must use HTTPS</p>
3212
+ </div>
3213
+
3214
+ <div>
3215
+ <label className="block text-sm font-medium mb-2">Events</label>
3216
+ <div className="space-y-2">
3217
+ {WEBHOOK_EVENTS.map((event) => (
3218
+ <label key={event.id} className="flex items-start gap-3 p-3 rounded-xl bg-[var(--surface)] hover:bg-[var(--surface-elevated)] cursor-pointer transition">
3219
+ <input
3220
+ type="checkbox"
3221
+ checked={newEvents.includes(event.id)}
3222
+ onChange={() => toggleEvent(event.id, newEvents, setNewEvents)}
3223
+ className="mt-0.5 w-4 h-4 rounded border-[var(--border)] text-[#ef4444] focus:ring-[#ef4444]"
3224
+ />
3225
+ <div>
3226
+ <p className="font-medium text-sm">{event.label}</p>
3227
+ <p className="text-xs text-[var(--text-muted)]">{event.description}</p>
3228
+ </div>
3229
+ </label>
3230
+ ))}
3231
+ </div>
3232
+ </div>
3233
+
3234
+ {error && (
3235
+ <div className="p-3 rounded-xl bg-red-500/10 border border-red-500/30 text-red-500 text-sm">
3236
+ {error}
3237
+ </div>
3238
+ )}
3239
+
3240
+ <div className="flex gap-3 pt-2">
3241
+ <button
3242
+ onClick={() => { setShowAddModal(false); setError(null); setNewUrl(""); setNewEvents([]); }}
3243
+ className="flex-1 px-4 py-2.5 rounded-xl border border-[var(--border)] text-[var(--text-secondary)] font-medium hover:bg-[var(--surface)] transition"
3244
+ >
3245
+ Cancel
3246
+ </button>
3247
+ <button
3248
+ onClick={handleCreateWebhook}
3249
+ disabled={!newUrl.trim() || newEvents.length === 0 || isSaving}
3250
+ className="flex-1 px-4 py-2.5 rounded-xl bg-[#ef4444] text-white font-medium hover:bg-[#dc2626] transition disabled:opacity-50 flex items-center justify-center gap-2"
3251
+ >
3252
+ {isSaving ? (
3253
+ <>
3254
+ <Loader2 className="w-4 h-4 animate-spin" />
3255
+ Creating...
3256
+ </>
3257
+ ) : (
3258
+ "Create Webhook"
3259
+ )}
3260
+ </button>
3261
+ </div>
3262
+ </div>
3263
+ </div>
3264
+ </div>
3265
+ )}
3266
+
3267
+ {/* Edit Webhook Modal */}
3268
+ {showEditModal && (
3269
+ <EditWebhookModal
3270
+ webhook={showEditModal}
3271
+ onClose={() => setShowEditModal(null)}
3272
+ onUpdate={handleUpdateWebhook}
3273
+ />
3274
+ )}
3275
+
3276
+ {/* Secret Display Modal */}
3277
+ {showSecretModal && (
3278
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
3279
+ <div className="bg-[var(--surface-elevated)] rounded-2xl border border-[var(--border)] w-full max-w-lg p-6">
3280
+ <div className="flex items-center gap-3 mb-4">
3281
+ <div className="w-12 h-12 rounded-xl bg-green-500/20 flex items-center justify-center">
3282
+ <Check className="w-6 h-6 text-green-500" />
3283
+ </div>
3284
+ <div>
3285
+ <h3 className="font-bold text-lg">Webhook Created!</h3>
3286
+ <p className="text-sm text-[var(--text-muted)]">Save your signing secret now</p>
3287
+ </div>
3288
+ </div>
3289
+
3290
+ <div className="rounded-xl bg-[var(--background)] border border-[var(--border)] p-4 mb-4">
3291
+ <div className="flex items-center justify-between mb-2">
3292
+ <span className="text-sm text-[var(--text-muted)]">Signing Secret</span>
3293
+ <button
3294
+ onClick={() => copyToClipboard(showSecretModal.secret)}
3295
+ className="flex items-center gap-1 text-sm text-[#ef4444] hover:underline"
3296
+ >
3297
+ <Copy className="w-4 h-4" />
3298
+ Copy
3299
+ </button>
3300
+ </div>
3301
+ <code className="block font-mono text-sm break-all">{showSecretModal.secret}</code>
3302
+ </div>
3303
+
3304
+ <div className="rounded-xl bg-yellow-500/10 border border-yellow-500/30 p-4 mb-6">
3305
+ <div className="flex items-start gap-2">
3306
+ <AlertCircle className="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" />
3307
+ <div>
3308
+ <p className="font-medium text-yellow-500">Save this secret now!</p>
3309
+ <p className="text-sm text-[var(--text-muted)]">
3310
+ This is the only time you&apos;ll see this secret. Use it to verify webhook signatures.
3311
+ </p>
3312
+ </div>
3313
+ </div>
3314
+ </div>
3315
+
3316
+ <button
3317
+ onClick={() => setShowSecretModal(null)}
3318
+ className="w-full px-4 py-2.5 rounded-xl bg-[#ef4444] text-white font-medium hover:bg-[#dc2626] transition"
3319
+ >
3320
+ I&apos;ve Saved My Secret
3321
+ </button>
3322
+ </div>
3323
+ </div>
3324
+ )}
3325
+ </div>
3326
+ );
3327
+ }
3328
+
3329
+ function EditWebhookModal({
3330
+ webhook,
3331
+ onClose,
3332
+ onUpdate,
3333
+ }: {
3334
+ webhook: WebhookData;
3335
+ onClose: () => void;
3336
+ onUpdate: (id: string, updates: { enabled?: boolean; events?: string[] }) => void;
3337
+ }) {
3338
+ const [enabled, setEnabled] = useState(webhook.enabled);
3339
+ const [events, setEvents] = useState(webhook.events);
3340
+ const [isSaving, setIsSaving] = useState(false);
3341
+
3342
+ const handleSave = async () => {
3343
+ setIsSaving(true);
3344
+ await onUpdate(webhook.id, { enabled, events });
3345
+ setIsSaving(false);
3346
+ };
3347
+
3348
+ const toggleEvent = (eventId: string) => {
3349
+ if (events.includes(eventId)) {
3350
+ setEvents(events.filter((e) => e !== eventId));
3351
+ } else {
3352
+ setEvents([...events, eventId]);
3353
+ }
3354
+ };
3355
+
3356
+ return (
3357
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
3358
+ <div className="bg-[var(--surface-elevated)] rounded-2xl border border-[var(--border)] w-full max-w-lg p-6">
3359
+ <div className="flex items-center justify-between mb-6">
3360
+ <h3 className="font-bold text-lg">Edit Webhook</h3>
3361
+ <button onClick={onClose} className="p-2 rounded-lg hover:bg-[var(--surface)]">
3362
+ <X className="w-5 h-5" />
3363
+ </button>
3364
+ </div>
3365
+
3366
+ <div className="space-y-4">
3367
+ <div>
3368
+ <label className="block text-sm font-medium mb-2">Webhook URL</label>
3369
+ <input
3370
+ type="url"
3371
+ value={webhook.url}
3372
+ disabled
3373
+ className="w-full px-4 py-3 rounded-xl border border-[var(--border)] bg-[var(--background)] text-[var(--text-muted)] opacity-60"
3374
+ />
3375
+ <p className="text-xs text-[var(--text-muted)] mt-1">URL cannot be changed for security reasons</p>
3376
+ </div>
3377
+
3378
+ <div className="flex items-center justify-between p-4 rounded-xl bg-[var(--surface)]">
3379
+ <div>
3380
+ <p className="font-medium">Enabled</p>
3381
+ <p className="text-sm text-[var(--text-muted)]">Receive webhook notifications</p>
3382
+ </div>
3383
+ <button
3384
+ onClick={() => setEnabled(!enabled)}
3385
+ className={`w-12 h-6 rounded-full transition relative ${enabled ? "bg-[#ef4444]" : "bg-[var(--border)]"}`}
3386
+ >
3387
+ <div className={`w-5 h-5 rounded-full bg-white absolute top-0.5 transition-all shadow ${enabled ? "left-6" : "left-0.5"}`} />
3388
+ </button>
3389
+ </div>
3390
+
3391
+ <div>
3392
+ <label className="block text-sm font-medium mb-2">Events</label>
3393
+ <div className="space-y-2">
3394
+ {WEBHOOK_EVENTS.map((event) => (
3395
+ <label key={event.id} className="flex items-start gap-3 p-3 rounded-xl bg-[var(--surface)] hover:bg-[var(--surface-elevated)] cursor-pointer transition">
3396
+ <input
3397
+ type="checkbox"
3398
+ checked={events.includes(event.id)}
3399
+ onChange={() => toggleEvent(event.id)}
3400
+ className="mt-0.5 w-4 h-4 rounded border-[var(--border)] text-[#ef4444] focus:ring-[#ef4444]"
3401
+ />
3402
+ <div>
3403
+ <p className="font-medium text-sm">{event.label}</p>
3404
+ <p className="text-xs text-[var(--text-muted)]">{event.description}</p>
3405
+ </div>
3406
+ </label>
3407
+ ))}
3408
+ </div>
3409
+ </div>
3410
+
3411
+ <div className="flex gap-3 pt-2">
3412
+ <button
3413
+ onClick={onClose}
3414
+ className="flex-1 px-4 py-2.5 rounded-xl border border-[var(--border)] text-[var(--text-secondary)] font-medium hover:bg-[var(--surface)] transition"
3415
+ >
3416
+ Cancel
3417
+ </button>
3418
+ <button
3419
+ onClick={handleSave}
3420
+ disabled={events.length === 0 || isSaving}
3421
+ className="flex-1 px-4 py-2.5 rounded-xl bg-[#ef4444] text-white font-medium hover:bg-[#dc2626] transition disabled:opacity-50 flex items-center justify-center gap-2"
3422
+ >
3423
+ {isSaving ? (
3424
+ <>
3425
+ <Loader2 className="w-4 h-4 animate-spin" />
3426
+ Saving...
3427
+ </>
3428
+ ) : (
3429
+ "Save Changes"
3430
+ )}
3431
+ </button>
3432
+ </div>
3433
+ </div>
3434
+ </div>
3435
+ </div>
3436
+ );
3437
+ }
3438
+
3439
+ // ============================================
3440
+ // API KEYS TAB (BYOK)
3441
+ // ============================================
3442
+
3443
+ interface ProviderKey {
3444
+ provider: string;
3445
+ keyHint: string;
3446
+ isCustom: boolean;
3447
+ createdAt: number;
3448
+ updatedAt: number;
3449
+ }
3450
+
3451
+ interface BYOKProvider {
3452
+ id: string;
3453
+ name: string;
3454
+ icon: string;
3455
+ }
3456
+
3457
+ const BYOK_PROVIDERS: BYOKProvider[] = [
3458
+ { id: "brave_search", name: "Brave Search", icon: "search" },
3459
+ { id: "openrouter", name: "OpenRouter", icon: "cpu" },
3460
+ { id: "elevenlabs", name: "ElevenLabs", icon: "activity" },
3461
+ { id: "twilio", name: "Twilio", icon: "phone" },
3462
+ { id: "resend", name: "Resend", icon: "mail" },
3463
+ { id: "e2b", name: "E2B", icon: "terminal" },
3464
+ ];
3465
+
3466
+ const ProviderIcon = ({ iconName, className = "w-6 h-6" }: { iconName: string; className?: string }) => {
3467
+ switch (iconName) {
3468
+ case "search": return <Search className={className} />;
3469
+ case "cpu": return <Cpu className={className} />;
3470
+ case "activity": return <Activity className={className} />;
3471
+ case "phone": return <Phone className={className} />;
3472
+ case "mail": return <Mail className={className} />;
3473
+ case "terminal": return <Terminal className={className} />;
3474
+ default: return <Zap className={className} />;
3475
+ }
3476
+ };
3477
+
3478
+ function ApiKeysTab() {
3479
+ const [keys, setKeys] = useState<ProviderKey[]>([]);
3480
+ const [isLoading, setIsLoading] = useState(true);
3481
+ const [showAddModal, setShowAddModal] = useState(false);
3482
+ const [selectedProvider, setSelectedProvider] = useState<BYOKProvider | null>(null);
3483
+ const [apiKeyInput, setApiKeyInput] = useState("");
3484
+ const [isSaving, setIsSaving] = useState(false);
3485
+ const [confirmRemove, setConfirmRemove] = useState<string | null>(null);
3486
+ const [successMessage, setSuccessMessage] = useState<string | null>(null);
3487
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
3488
+
3489
+ useEffect(() => {
3490
+ const fetchKeys = async () => {
3491
+ const token = localStorage.getItem("apiclaw_workspace_session");
3492
+ if (!token) {
3493
+ setIsLoading(false);
3494
+ return;
3495
+ }
3496
+
3497
+ try {
3498
+ const res = await fetch(`${CONVEX_URL}/api/query`, {
3499
+ method: "POST",
3500
+ headers: { "Content-Type": "application/json" },
3501
+ body: JSON.stringify({
3502
+ path: "providerKeys:getKeys",
3503
+ args: { token },
3504
+ }),
3505
+ });
3506
+ const data = await res.json();
3507
+ setKeys(data.value?.keys || data.keys || []);
3508
+ } catch (err) {
3509
+ console.error("Failed to fetch keys:", err);
3510
+ } finally {
3511
+ setIsLoading(false);
3512
+ }
3513
+ };
3514
+
3515
+ fetchKeys();
3516
+ }, []);
3517
+
3518
+ const handleAddKey = async () => {
3519
+ if (!selectedProvider || !apiKeyInput.trim()) return;
3520
+
3521
+ const token = localStorage.getItem("apiclaw_workspace_session");
3522
+ if (!token) return;
3523
+
3524
+ setIsSaving(true);
3525
+ setErrorMessage(null);
3526
+
3527
+ try {
3528
+ const res = await fetch(`${CONVEX_URL}/api/mutation`, {
3529
+ method: "POST",
3530
+ headers: { "Content-Type": "application/json" },
3531
+ body: JSON.stringify({
3532
+ path: "providerKeys:addKey",
3533
+ args: {
3534
+ token,
3535
+ provider: selectedProvider.id,
3536
+ apiKey: apiKeyInput,
3537
+ },
3538
+ }),
3539
+ });
3540
+ const data = await res.json();
3541
+
3542
+ if (data.value?.success || data.success) {
3543
+ const keysRes = await fetch(`${CONVEX_URL}/api/query`, {
3544
+ method: "POST",
3545
+ headers: { "Content-Type": "application/json" },
3546
+ body: JSON.stringify({
3547
+ path: "providerKeys:getKeys",
3548
+ args: { token },
3549
+ }),
3550
+ });
3551
+ const keysData = await keysRes.json();
3552
+ setKeys(keysData.value?.keys || keysData.keys || []);
3553
+
3554
+ setSuccessMessage(`Key saved! Using your key for ${selectedProvider.name}`);
3555
+ setTimeout(() => setSuccessMessage(null), 3000);
3556
+ setShowAddModal(false);
3557
+ setApiKeyInput("");
3558
+ setSelectedProvider(null);
3559
+ } else {
3560
+ setErrorMessage("Failed to save key. Please try again.");
3561
+ }
3562
+ } catch (err) {
3563
+ console.error("Failed to add key:", err);
3564
+ setErrorMessage("Failed to save key. Please try again.");
3565
+ } finally {
3566
+ setIsSaving(false);
3567
+ }
3568
+ };
3569
+
3570
+ const handleRemoveKey = async (providerId: string) => {
3571
+ if (confirmRemove !== providerId) {
3572
+ setConfirmRemove(providerId);
3573
+ return;
3574
+ }
3575
+
3576
+ const token = localStorage.getItem("apiclaw_workspace_session");
3577
+ if (!token) return;
3578
+
3579
+ try {
3580
+ await fetch(`${CONVEX_URL}/api/mutation`, {
3581
+ method: "POST",
3582
+ headers: { "Content-Type": "application/json" },
3583
+ body: JSON.stringify({
3584
+ path: "providerKeys:removeKey",
3585
+ args: { token, provider: providerId },
3586
+ }),
3587
+ });
3588
+
3589
+ setKeys(keys.filter((k) => k.provider !== providerId));
3590
+ setConfirmRemove(null);
3591
+ setSuccessMessage("Key removed. Back to Direct Call.");
3592
+ setTimeout(() => setSuccessMessage(null), 3000);
3593
+ } catch (err) {
3594
+ console.error("Failed to remove key:", err);
3595
+ setErrorMessage("Failed to remove key. Please try again.");
3596
+ }
3597
+ };
3598
+
3599
+ const getKeyForProvider = (providerId: string) => {
3600
+ return keys.find((k) => k.provider === providerId);
3601
+ };
3602
+
3603
+ const openAddModal = (provider: BYOKProvider) => {
3604
+ setSelectedProvider(provider);
3605
+ setApiKeyInput("");
3606
+ setErrorMessage(null);
3607
+ setShowAddModal(true);
3608
+ };
3609
+
3610
+ if (isLoading) {
3611
+ return (
3612
+ <div className="flex items-center justify-center py-16">
3613
+ <Loader2 className="w-8 h-8 text-[#ef4444] animate-spin" />
3614
+ </div>
3615
+ );
3616
+ }
3617
+
3618
+ return (
3619
+ <div className="space-y-6">
3620
+ <div>
3621
+ <h2 className="text-2xl font-bold mb-2">API Keys</h2>
3622
+ <p className="text-[var(--text-muted)]">
3623
+ Direct Call works without keys. Add your own for unlimited calls and direct provider access.
3624
+ </p>
3625
+ </div>
3626
+
3627
+ {successMessage && (
3628
+ <div className="rounded-xl border border-green-500/30 bg-green-500/10 p-4 flex items-center gap-3">
3629
+ <Check className="w-5 h-5 text-green-500 flex-shrink-0" />
3630
+ <p className="text-green-500">{successMessage}</p>
3631
+ </div>
3632
+ )}
3633
+
3634
+ {errorMessage && (
3635
+ <div className="rounded-xl border border-red-500/30 bg-red-500/10 p-4 flex items-center gap-3">
3636
+ <AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0" />
3637
+ <p className="text-red-500">{errorMessage}</p>
3638
+ </div>
3639
+ )}
3640
+
3641
+ <div className="rounded-2xl border border-[#ef4444]/30 bg-[#ef4444]/10 p-6">
3642
+ <div className="flex items-start gap-4">
3643
+ <div className="w-10 h-10 rounded-xl bg-[#ef4444]/20 flex items-center justify-center flex-shrink-0">
3644
+ <Key className="w-5 h-5 text-[#ef4444]" />
3645
+ </div>
3646
+ <div>
3647
+ <h3 className="font-semibold mb-2">Direct Call is Default</h3>
3648
+ <p className="text-sm text-[var(--text-muted)]">
3649
+ No API keys needed — APIClaw handles authentication for you. Add your own keys to bypass usage limits and route requests directly to providers.
3650
+ </p>
3651
+ </div>
3652
+ </div>
3653
+ </div>
3654
+
3655
+ <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] overflow-hidden">
3656
+ <div className="p-4 border-b border-[var(--border)]">
3657
+ <h3 className="font-semibold">Providers</h3>
3658
+ </div>
3659
+ <div className="divide-y divide-[var(--border)]">
3660
+ {BYOK_PROVIDERS.map((provider) => {
3661
+ const userKey = getKeyForProvider(provider.id);
3662
+ const hasKey = !!userKey;
3663
+
3664
+ return (
3665
+ <div
3666
+ key={provider.id}
3667
+ className="flex flex-col sm:flex-row sm:items-center justify-between p-4 hover:bg-[var(--surface)] transition gap-3"
3668
+ >
3669
+ <div className="flex items-center gap-3">
3670
+ <div className="w-8 h-8 rounded-lg bg-[#ef4444]/10 flex items-center justify-center">
3671
+ <ProviderIcon iconName={provider.icon} className="w-5 h-5 text-[#ef4444]" />
3672
+ </div>
3673
+ <span className="font-medium">{provider.name}</span>
3674
+ </div>
3675
+ <div className="flex items-center gap-3 ml-10 sm:ml-0">
3676
+ {hasKey ? (
3677
+ <>
3678
+ <span className="px-3 py-1 rounded-full bg-[#ef4444]/20 text-[#ef4444] text-sm font-medium">
3679
+ Your Key (•••• {userKey.keyHint})
3680
+ </span>
3681
+ <button
3682
+ onClick={() => openAddModal(provider)}
3683
+ className="px-3 py-1.5 rounded-lg text-sm font-medium text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--surface)] transition"
3684
+ >
3685
+ Edit
3686
+ </button>
3687
+ <button
3688
+ onClick={() => handleRemoveKey(provider.id)}
3689
+ className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
3690
+ confirmRemove === provider.id
3691
+ ? "bg-red-500 text-white"
3692
+ : "text-red-500 hover:bg-red-500/10"
3693
+ }`}
3694
+ >
3695
+ {confirmRemove === provider.id ? "Confirm" : "Remove"}
3696
+ </button>
3697
+ </>
3698
+ ) : (
3699
+ <>
3700
+ <span className="px-3 py-1 rounded-full bg-green-500/20 text-green-500 text-sm font-medium">
3701
+ Direct Call
3702
+ </span>
3703
+ <button
3704
+ onClick={() => openAddModal(provider)}
3705
+ className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm font-medium hover:bg-[var(--surface)] hover:border-[#ef4444]/50 transition"
3706
+ >
3707
+ Add Your Key
3708
+ </button>
3709
+ </>
3710
+ )}
3711
+ </div>
3712
+ </div>
3713
+ );
3714
+ })}
3715
+ </div>
3716
+ </div>
3717
+
3718
+ <div className="relative group">
3719
+ <button
3720
+ className="w-full rounded-2xl border border-dashed border-[var(--border)] bg-[var(--surface)]/50 p-6 text-center hover:border-[#ef4444]/50 transition opacity-50 cursor-not-allowed"
3721
+ disabled
3722
+ >
3723
+ <Plus className="w-8 h-8 text-[var(--text-muted)] mx-auto mb-2" />
3724
+ <p className="font-medium text-[var(--text-muted)]">+ Add Custom Provider</p>
3725
+ <p className="text-sm text-[var(--text-muted)] mt-1">Connect any REST API with custom authentication</p>
3726
+ </button>
3727
+ <div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition">
3728
+ <span className="px-3 py-1.5 rounded-lg bg-[var(--background)] border border-[var(--border)] text-sm font-medium shadow-lg">
3729
+ Coming soon
3730
+ </span>
3731
+ </div>
3732
+ </div>
3733
+
3734
+ {showAddModal && selectedProvider && (
3735
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
3736
+ <div className="bg-[var(--surface-elevated)] rounded-2xl border border-[var(--border)] w-full max-w-md p-6">
3737
+ <div className="flex items-center gap-3 mb-6">
3738
+ <div className="w-12 h-12 rounded-xl bg-[#ef4444]/10 flex items-center justify-center">
3739
+ <ProviderIcon iconName={selectedProvider.icon} className="w-7 h-7 text-[#ef4444]" />
3740
+ </div>
3741
+ <div>
3742
+ <h3 className="font-bold text-lg">
3743
+ {getKeyForProvider(selectedProvider.id) ? "Update" : "Add"} {selectedProvider.name} Key
3744
+ </h3>
3745
+ <p className="text-sm text-[var(--text-muted)]">
3746
+ Your key will be encrypted and stored securely.
3747
+ </p>
3748
+ </div>
3749
+ </div>
3750
+
3751
+ <div className="space-y-4">
3752
+ <div>
3753
+ <label className="block text-sm font-medium mb-2">API Key</label>
3754
+ <div className="relative">
3755
+ <input
3756
+ type="password"
3757
+ value={apiKeyInput}
3758
+ onChange={(e) => setApiKeyInput(e.target.value)}
3759
+ placeholder="Enter your API key..."
3760
+ className="w-full px-4 py-3 rounded-xl border border-[var(--border)] bg-[var(--background)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:outline-none focus:ring-2 focus:ring-[#ef4444]/50 pr-10"
3761
+ autoFocus
3762
+ />
3763
+ <Lock className="absolute right-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--text-muted)]" />
3764
+ </div>
3765
+ </div>
3766
+
3767
+ {errorMessage && (
3768
+ <p className="text-sm text-red-500">{errorMessage}</p>
3769
+ )}
3770
+
3771
+ <div className="flex gap-3 pt-2">
3772
+ <button
3773
+ onClick={() => {
3774
+ setShowAddModal(false);
3775
+ setApiKeyInput("");
3776
+ setSelectedProvider(null);
3777
+ setErrorMessage(null);
3778
+ }}
3779
+ className="flex-1 px-4 py-2.5 rounded-xl border border-[var(--border)] text-[var(--text-secondary)] font-medium hover:bg-[var(--surface)] transition"
3780
+ >
3781
+ Cancel
3782
+ </button>
3783
+ <button
3784
+ onClick={handleAddKey}
3785
+ disabled={!apiKeyInput.trim() || isSaving}
3786
+ className="flex-1 px-4 py-2.5 rounded-xl bg-[#ef4444] text-white font-medium hover:bg-[#dc2626] transition disabled:opacity-50 flex items-center justify-center gap-2"
3787
+ >
3788
+ {isSaving ? (
3789
+ <>
3790
+ <Loader2 className="w-4 h-4 animate-spin" />
3791
+ Saving...
3792
+ </>
3793
+ ) : (
3794
+ <>
3795
+ <Check className="w-4 h-4" />
3796
+ Save
3797
+ </>
3798
+ )}
3799
+ </button>
3800
+ </div>
3801
+ </div>
3802
+ </div>
3803
+ </div>
3804
+ )}
3805
+ </div>
3806
+ );
3807
+ }
3808
+
3809
+ // ============================================
3810
+ // EARN TAB
3811
+ // ============================================
3812
+
3813
+ function EarnTab() {
3814
+ const [email, setEmail] = useState("");
3815
+ const [subscribed, setSubscribed] = useState(false);
3816
+ const [copied, setCopied] = useState(false);
3817
+ const referralCode = "CLAW-" + Math.random().toString(36).substring(2, 8).toUpperCase();
3818
+
3819
+ const earnChannels = [
3820
+ { id: "github", title: "Star on GitHub", credits: 20, href: "https://github.com/nordsym/apiclaw", icon: "star" },
3821
+ { id: "twitter", title: "Follow @NordSym", credits: 15, href: "https://x.com/NordSym", icon: "twitter" },
3822
+ { id: "newsletter", title: "Join Newsletter", credits: 15, href: "#newsletter", icon: "mail" },
3823
+ ];
3824
+
3825
+ const EarnIcon = ({ iconName }: { iconName: string }) => {
3826
+ const iconClass = "w-8 h-8 text-[#ef4444]";
3827
+ switch (iconName) {
3828
+ case "star": return <Star className={iconClass} />;
3829
+ case "twitter": return <Twitter className={iconClass} />;
3830
+ case "mail": return <Mail className={iconClass} />;
3831
+ default: return <Zap className={iconClass} />;
3832
+ }
3833
+ };
3834
+
3835
+ const handleCopyReferral = () => {
3836
+ navigator.clipboard.writeText("https://apiclaw.nordsym.com?ref=" + referralCode);
3837
+ setCopied(true);
3838
+ setTimeout(() => setCopied(false), 2000);
3839
+ };
3840
+
3841
+ return (
3842
+ <div className="space-y-6">
3843
+ <div>
3844
+ <h2 className="text-2xl font-bold mb-2">Earn Credits</h2>
3845
+ <p className="text-[var(--text-muted)]">Complete tasks to earn free API calls. Max 50 extra calls.</p>
3846
+ </div>
3847
+
3848
+ <div className="grid gap-4 md:grid-cols-3">
3849
+ {earnChannels.map((channel) => (
3850
+ <a
3851
+ key={channel.id}
3852
+ href={channel.href}
3853
+ target="_blank"
3854
+ rel="noopener noreferrer"
3855
+ className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-6 hover:border-[#ef4444]/50 transition group"
3856
+ >
3857
+ <div className="w-12 h-12 rounded-xl bg-[#ef4444]/10 flex items-center justify-center mb-3">
3858
+ <EarnIcon iconName={channel.icon} />
3859
+ </div>
3860
+ <h3 className="font-semibold mb-1">{channel.title}</h3>
3861
+ <p className="text-sm text-[#ef4444] font-medium">+{channel.credits} calls</p>
3862
+ </a>
3863
+ ))}
3864
+ </div>
3865
+
3866
+ <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-4 sm:p-6">
3867
+ <h3 className="font-semibold mb-2">Invite Friends</h3>
3868
+ <p className="text-sm text-[var(--text-muted)] mb-4">Earn 10 calls for each friend who joins.</p>
3869
+ <div className="flex flex-col sm:flex-row gap-3">
3870
+ <input
3871
+ type="text"
3872
+ value={"https://apiclaw.nordsym.com?ref=" + referralCode}
3873
+ readOnly
3874
+ className="w-full sm:flex-1 px-4 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm"
3875
+ />
3876
+ <button
3877
+ onClick={handleCopyReferral}
3878
+ className="w-full sm:w-auto px-4 py-2 bg-[#ef4444] text-white rounded-lg font-medium hover:bg-[#dc2626] transition"
3879
+ >
3880
+ {copied ? "Copied!" : "Copy"}
3881
+ </button>
3882
+ </div>
3883
+ </div>
3884
+
3885
+ <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-4 sm:p-6">
3886
+ <h3 className="font-semibold mb-2">Newsletter (+15 calls)</h3>
3887
+ <p className="text-sm text-[var(--text-muted)] mb-4">Get weekly updates, tips, and new API announcements.</p>
3888
+ <div className="flex flex-col sm:flex-row gap-3">
3889
+ <input
3890
+ type="email"
3891
+ value={email}
3892
+ onChange={(e) => setEmail(e.target.value)}
3893
+ placeholder="your@email.com"
3894
+ className="w-full sm:flex-1 px-4 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)]"
3895
+ />
3896
+ <button
3897
+ onClick={() => setSubscribed(true)}
3898
+ disabled={subscribed}
3899
+ className="w-full sm:w-auto px-4 py-2 bg-[#ef4444] text-white rounded-lg font-medium hover:bg-[#dc2626] transition disabled:opacity-50"
3900
+ >
3901
+ {subscribed ? "Subscribed!" : "Subscribe"}
3902
+ </button>
3903
+ </div>
3904
+ </div>
3905
+ </div>
3906
+ );
3907
+ }
3908
+
3909
+ // ============================================
3910
+ // DOCS TAB
3911
+ // ============================================
3912
+
3913
+ function DocsTab() {
3914
+ return (
3915
+ <div className="space-y-6">
3916
+ <div>
3917
+ <h2 className="text-2xl font-bold mb-2">Documentation</h2>
3918
+ <p className="text-[var(--text-muted)]">Everything you need to integrate APIClaw with your AI agent.</p>
3919
+ </div>
3920
+
3921
+ <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-6">
3922
+ <h3 className="font-semibold mb-4">Quick Start</h3>
3923
+ <div className="space-y-4">
3924
+ <div>
3925
+ <p className="text-sm text-[var(--text-muted)] mb-2">1. Add to your MCP config:</p>
3926
+ <pre className="bg-[var(--background)] rounded-lg p-4 text-sm overflow-x-auto">
3927
+ {`{
3928
+ "mcpServers": {
3929
+ "apiclaw": {
3930
+ "command": "npx",
3931
+ "args": ["@nordsym/apiclaw"]
3932
+ }
3933
+ }
3934
+ }`}
3935
+ </pre>
3936
+ </div>
3937
+ <div>
3938
+ <p className="text-sm text-[var(--text-muted)] mb-2">2. Or run directly:</p>
3939
+ <pre className="bg-[var(--background)] rounded-lg p-4 text-sm">npx @nordsym/apiclaw</pre>
3940
+ </div>
3941
+ <div>
3942
+ <p className="text-sm text-[var(--text-muted)] mb-2">3. Interactive CLI mode:</p>
3943
+ <pre className="bg-[var(--background)] rounded-lg p-4 text-sm">npx @nordsym/apiclaw --cli</pre>
3944
+ </div>
3945
+ </div>
3946
+ </div>
3947
+
3948
+ <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-6">
3949
+ <h3 className="font-semibold mb-4">MCP Tools</h3>
3950
+ <div className="space-y-3">
3951
+ {[
3952
+ { name: "discover_apis", desc: "Search 19,000+ APIs by capability" },
3953
+ { name: "get_api_details", desc: "Get full details for a specific API" },
3954
+ { name: "call_api", desc: "Execute a Direct Call API" },
3955
+ { name: "list_connected", desc: "Show available Direct Call providers" },
3956
+ { name: "get_categories", desc: "List all API categories" },
3957
+ { name: "register_owner", desc: "Authenticate workspace via magic link" },
3958
+ ].map((tool) => (
3959
+ <div key={tool.name} className="flex items-start gap-3 p-3 rounded-lg bg-[var(--surface)]">
3960
+ <code className="px-2 py-1 rounded bg-[#ef4444]/20 text-[#ef4444] text-sm font-mono">{tool.name}</code>
3961
+ <p className="text-sm text-[var(--text-muted)]">{tool.desc}</p>
3962
+ </div>
3963
+ ))}
3964
+ </div>
3965
+ </div>
3966
+
3967
+ <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-6">
3968
+ <h3 className="font-semibold mb-4">Direct Call Providers (No API Key Needed)</h3>
3969
+ <div className="grid gap-2 md:grid-cols-2">
3970
+ {["Brave Search", "46elks SMS", "Resend Email", "OpenRouter LLM", "ElevenLabs TTS", "Twilio", "E2B Code", "Web Scraper", "Screenshot"].map((p) => (
3971
+ <div key={p} className="px-3 py-2 rounded-lg bg-[var(--surface)] text-sm">{p}</div>
3972
+ ))}
3973
+ </div>
3974
+ </div>
3975
+
3976
+ <div className="flex gap-4">
3977
+ <a href="https://github.com/nordsym/apiclaw" target="_blank" rel="noopener noreferrer" className="text-[#ef4444] hover:underline">
3978
+ GitHub Repository →
3979
+ </a>
3980
+ <a href="https://npmjs.com/package/@nordsym/apiclaw" target="_blank" rel="noopener noreferrer" className="text-[#ef4444] hover:underline">
3981
+ NPM Package →
3982
+ </a>
3983
+ </div>
3984
+ </div>
3985
+ );
3986
+ }
3987
+
3988
+ // ============================================
3989
+ // FEEDBACK TAB
3990
+ // ============================================
3991
+
3992
+ interface FeedbackItem {
3993
+ _id: string;
3994
+ workspaceId: string;
3995
+ type: "bug" | "feature" | "general";
3996
+ content: string;
3997
+ votes: number;
3998
+ votedBy: string[];
3999
+ status: "new" | "reviewing" | "planned" | "shipped";
4000
+ createdAt: number;
4001
+ hasVoted: boolean;
4002
+ isOwn: boolean;
4003
+ }
4004
+
4005
+ function FeedbackTab() {
4006
+ const [content, setContent] = useState("");
4007
+ const [feedbackType, setFeedbackType] = useState<"bug" | "feature" | "general">("feature");
4008
+ const [submitted, setSubmitted] = useState(false);
4009
+ const [submitting, setSubmitting] = useState(false);
4010
+ const [feedbackList, setFeedbackList] = useState<FeedbackItem[]>([]);
4011
+ const [loading, setLoading] = useState(true);
4012
+ const [filterType, setFilterType] = useState<"all" | "bug" | "feature" | "general">("all");
4013
+ const [sortBy, setSortBy] = useState<"votes" | "recent">("votes");
4014
+ const [votingId, setVotingId] = useState<string | null>(null);
4015
+
4016
+ const sessionToken = typeof window !== "undefined" ? localStorage.getItem("apiclaw_workspace_session") : null;
4017
+
4018
+ useEffect(() => {
4019
+ if (sessionToken) {
4020
+ fetchFeedback();
4021
+ }
4022
+ }, [sessionToken, filterType, sortBy]);
4023
+
4024
+ const fetchFeedback = async () => {
4025
+ if (!sessionToken) return;
4026
+
4027
+ try {
4028
+ const response = await fetch(`${CONVEX_URL}/api/query`, {
4029
+ method: "POST",
4030
+ headers: { "Content-Type": "application/json" },
4031
+ body: JSON.stringify({
4032
+ path: "feedback:getFeedback",
4033
+ args: {
4034
+ token: sessionToken,
4035
+ filterType: filterType === "all" ? undefined : filterType,
4036
+ sortBy,
4037
+ },
4038
+ }),
4039
+ });
4040
+
4041
+ const data = await response.json();
4042
+ const result = data.value || data;
4043
+
4044
+ if (result.feedback) {
4045
+ setFeedbackList(result.feedback);
4046
+ }
4047
+ } catch (err) {
4048
+ console.error("Failed to fetch feedback:", err);
4049
+ } finally {
4050
+ setLoading(false);
4051
+ }
4052
+ };
4053
+
4054
+ const handleSubmit = async (e: React.FormEvent) => {
4055
+ e.preventDefault();
4056
+ if (!content.trim() || !sessionToken) return;
4057
+
4058
+ setSubmitting(true);
4059
+ try {
4060
+ const response = await fetch(`${CONVEX_URL}/api/mutation`, {
4061
+ method: "POST",
4062
+ headers: { "Content-Type": "application/json" },
4063
+ body: JSON.stringify({
4064
+ path: "feedback:submitFeedback",
4065
+ args: {
4066
+ token: sessionToken,
4067
+ type: feedbackType,
4068
+ content: content.trim(),
4069
+ },
4070
+ }),
4071
+ });
4072
+
4073
+ const data = await response.json();
4074
+ if (data.value?.success || data.success) {
4075
+ setSubmitted(true);
4076
+ setContent("");
4077
+ setTimeout(() => setSubmitted(false), 3000);
4078
+ fetchFeedback();
4079
+ }
4080
+ } catch (err) {
4081
+ console.error("Failed to submit feedback:", err);
4082
+ } finally {
4083
+ setSubmitting(false);
4084
+ }
4085
+ };
4086
+
4087
+ const handleVote = async (feedbackId: string, direction: "up" | "down") => {
4088
+ if (!sessionToken || votingId) return;
4089
+
4090
+ setVotingId(feedbackId);
4091
+ try {
4092
+ const response = await fetch(`${CONVEX_URL}/api/mutation`, {
4093
+ method: "POST",
4094
+ headers: { "Content-Type": "application/json" },
4095
+ body: JSON.stringify({
4096
+ path: "feedback:voteFeedback",
4097
+ args: {
4098
+ token: sessionToken,
4099
+ feedbackId,
4100
+ direction,
4101
+ },
4102
+ }),
4103
+ });
4104
+
4105
+ const data = await response.json();
4106
+ const result = data.value || data;
4107
+
4108
+ if (result.success) {
4109
+ setFeedbackList((prev) =>
4110
+ prev.map((f) =>
4111
+ f._id === feedbackId
4112
+ ? { ...f, votes: result.votes, hasVoted: result.hasVoted }
4113
+ : f
4114
+ )
4115
+ );
4116
+ }
4117
+ } catch (err) {
4118
+ console.error("Failed to vote:", err);
4119
+ } finally {
4120
+ setVotingId(null);
4121
+ }
4122
+ };
4123
+
4124
+ const getStatusBadge = (status: string) => {
4125
+ switch (status) {
4126
+ case "new": return "bg-gray-500/20 text-gray-400";
4127
+ case "reviewing": return "bg-yellow-500/20 text-yellow-500";
4128
+ case "planned": return "bg-blue-500/20 text-blue-500";
4129
+ case "shipped": return "bg-green-500/20 text-green-500";
4130
+ default: return "bg-gray-500/20 text-gray-400";
4131
+ }
4132
+ };
4133
+
4134
+ const getTypeBadge = (type: string) => {
4135
+ switch (type) {
4136
+ case "bug": return "bg-red-500/20 text-red-500";
4137
+ case "feature": return "bg-purple-500/20 text-purple-500";
4138
+ case "general": return "bg-gray-500/20 text-gray-400";
4139
+ default: return "bg-gray-500/20 text-gray-400";
4140
+ }
4141
+ };
4142
+
4143
+ const formatDate = (timestamp: number) => {
4144
+ const date = new Date(timestamp);
4145
+ const now = new Date();
4146
+ const diffMs = now.getTime() - date.getTime();
4147
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
4148
+
4149
+ if (diffDays === 0) return "Today";
4150
+ if (diffDays === 1) return "Yesterday";
4151
+ if (diffDays < 7) return `${diffDays} days ago`;
4152
+ return date.toLocaleDateString();
4153
+ };
4154
+
4155
+ return (
4156
+ <div className="space-y-6">
4157
+ <div>
4158
+ <h2 className="text-2xl font-bold mb-2">Feedback</h2>
4159
+ <p className="text-[var(--text-muted)]">Your feedback helps us improve APIClaw.</p>
4160
+ </div>
4161
+
4162
+ <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-6">
4163
+ <h3 className="font-semibold mb-4">Share Your Feedback</h3>
4164
+ <form onSubmit={handleSubmit} className="space-y-4">
4165
+ <div>
4166
+ <label className="block text-sm text-[var(--text-muted)] mb-2">Type</label>
4167
+ <div className="flex flex-wrap gap-2">
4168
+ {(["bug", "feature", "general"] as const).map((type) => (
4169
+ <button
4170
+ key={type}
4171
+ type="button"
4172
+ onClick={() => setFeedbackType(type)}
4173
+ className={`px-4 py-2 rounded-lg text-sm font-medium capitalize transition ${
4174
+ feedbackType === type
4175
+ ? type === "bug"
4176
+ ? "bg-red-500 text-white"
4177
+ : type === "feature"
4178
+ ? "bg-purple-500 text-white"
4179
+ : "bg-gray-500 text-white"
4180
+ : "bg-[var(--surface)] text-[var(--text-muted)] hover:bg-[var(--background)]"
4181
+ }`}
4182
+ >
4183
+ {type === "bug" ? <><Bug className="w-4 h-4 inline mr-1" /> Bug</> : type === "feature" ? <><Sparkles className="w-4 h-4 inline mr-1" /> Feature</> : <><MessageCircle className="w-4 h-4 inline mr-1" /> General</>}
4184
+ </button>
4185
+ ))}
4186
+ </div>
4187
+ </div>
4188
+
4189
+ <div>
4190
+ <label className="block text-sm text-[var(--text-muted)] mb-2">Your Feedback</label>
4191
+ <textarea
4192
+ value={content}
4193
+ onChange={(e) => setContent(e.target.value)}
4194
+ placeholder={
4195
+ feedbackType === "bug"
4196
+ ? "Describe the bug you encountered..."
4197
+ : feedbackType === "feature"
4198
+ ? "Describe the feature you'd like to see..."
4199
+ : "Tell us what you think..."
4200
+ }
4201
+ className="w-full h-32 px-4 py-3 rounded-xl border border-[var(--border)] bg-[var(--background)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:outline-none focus:ring-2 focus:ring-[#ef4444]/50 resize-none"
4202
+ />
4203
+ </div>
4204
+
4205
+ <div className="flex items-center justify-between">
4206
+ <p className="text-sm text-[var(--text-muted)]">
4207
+ We read every piece of feedback.
4208
+ </p>
4209
+ <button
4210
+ type="submit"
4211
+ disabled={!content.trim() || submitting || submitted}
4212
+ className="px-6 py-2 bg-[#ef4444] text-white rounded-lg font-medium hover:bg-[#dc2626] transition disabled:opacity-50 flex items-center gap-2"
4213
+ >
4214
+ {submitted ? (
4215
+ <><Check className="w-4 h-4" /> Sent!</>
4216
+ ) : submitting ? (
4217
+ <><Loader2 className="w-4 h-4 animate-spin" /> Submitting...</>
4218
+ ) : (
4219
+ <><Send className="w-4 h-4" /> Submit</>
4220
+ )}
4221
+ </button>
4222
+ </div>
4223
+ </form>
4224
+ </div>
4225
+
4226
+ <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] p-6">
4227
+ <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
4228
+ <h3 className="font-semibold text-lg">Community Feedback</h3>
4229
+ <div className="flex flex-wrap gap-2">
4230
+ <select
4231
+ value={filterType}
4232
+ onChange={(e) => setFilterType(e.target.value as typeof filterType)}
4233
+ className="px-3 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm focus:outline-none focus:ring-2 focus:ring-[#ef4444]/50"
4234
+ >
4235
+ <option value="all">All Types</option>
4236
+ <option value="bug">Bugs</option>
4237
+ <option value="feature">Features</option>
4238
+ <option value="general">General</option>
4239
+ </select>
4240
+
4241
+ <select
4242
+ value={sortBy}
4243
+ onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
4244
+ className="px-3 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm focus:outline-none focus:ring-2 focus:ring-[#ef4444]/50"
4245
+ >
4246
+ <option value="votes">Most Votes</option>
4247
+ <option value="recent">Most Recent</option>
4248
+ </select>
4249
+ </div>
4250
+ </div>
4251
+
4252
+ {loading ? (
4253
+ <div className="text-center py-8">
4254
+ <Loader2 className="w-8 h-8 text-[#ef4444] animate-spin mx-auto mb-2" />
4255
+ <p className="text-sm text-[var(--text-muted)]">Loading feedback...</p>
4256
+ </div>
4257
+ ) : feedbackList.length === 0 ? (
4258
+ <div className="text-center py-12 rounded-xl border border-dashed border-[var(--border)] bg-[var(--surface)]/50">
4259
+ <MessageSquare className="w-12 h-12 text-[var(--text-muted)] mx-auto mb-3" />
4260
+ <h4 className="font-semibold mb-1">No Feedback Yet</h4>
4261
+ <p className="text-sm text-[var(--text-muted)]">
4262
+ Be the first to share your thoughts!
4263
+ </p>
4264
+ </div>
4265
+ ) : (
4266
+ <div className="space-y-3">
4267
+ {feedbackList.map((item) => (
4268
+ <div
4269
+ key={item._id}
4270
+ className={`flex gap-3 p-4 rounded-xl border transition ${
4271
+ item.isOwn
4272
+ ? "border-[#ef4444]/30 bg-[#ef4444]/5"
4273
+ : "border-[var(--border)] bg-[var(--surface)]"
4274
+ }`}
4275
+ >
4276
+ <div className="flex flex-col items-center gap-1 min-w-[40px]">
4277
+ <button
4278
+ onClick={() => handleVote(item._id, "up")}
4279
+ disabled={votingId === item._id}
4280
+ className={`p-1 rounded hover:bg-[var(--background)] transition ${
4281
+ item.hasVoted ? "text-[#ef4444]" : "text-[var(--text-muted)]"
4282
+ }`}
4283
+ >
4284
+ <ChevronUp className="w-5 h-5" />
4285
+ </button>
4286
+ <span className={`text-sm font-bold ${item.votes > 0 ? "text-[#ef4444]" : item.votes < 0 ? "text-red-500" : "text-[var(--text-muted)]"}`}>
4287
+ {item.votes}
4288
+ </span>
4289
+ <button
4290
+ onClick={() => handleVote(item._id, "down")}
4291
+ disabled={votingId === item._id}
4292
+ className="p-1 rounded text-[var(--text-muted)] hover:bg-[var(--background)] transition"
4293
+ >
4294
+ <ChevronDown className="w-5 h-5" />
4295
+ </button>
4296
+ </div>
4297
+
4298
+ <div className="flex-1 min-w-0">
4299
+ <p className="text-[var(--text-primary)] mb-2">{item.content}</p>
4300
+ <div className="flex flex-wrap items-center gap-2 text-xs">
4301
+ <span className={`px-2 py-0.5 rounded-full capitalize ${getTypeBadge(item.type)}`}>
4302
+ {item.type}
4303
+ </span>
4304
+ <span className={`px-2 py-0.5 rounded-full capitalize ${getStatusBadge(item.status)}`}>
4305
+ {item.status}
4306
+ </span>
4307
+ <span className="text-[var(--text-muted)]">
4308
+ {formatDate(item.createdAt)}
4309
+ </span>
4310
+ {item.isOwn && (
4311
+ <span className="text-[#ef4444]">• You</span>
4312
+ )}
4313
+ </div>
4314
+ </div>
4315
+ </div>
4316
+ ))}
4317
+ </div>
4318
+ )}
4319
+ </div>
4320
+ </div>
4321
+ );
4322
+ }
4323
+
4324
+ // ============================================
4325
+ // SETTINGS TAB
4326
+ // ============================================
4327
+
4328
+ interface SettingsSectionProps {
4329
+ title: string;
4330
+ icon: React.ElementType;
4331
+ children: React.ReactNode;
4332
+ defaultOpen?: boolean;
4333
+ }
4334
+
4335
+ function SettingsSection({ title, icon: Icon, children, defaultOpen = false }: SettingsSectionProps) {
4336
+ const [isOpen, setIsOpen] = useState(defaultOpen);
4337
+
4338
+ return (
4339
+ <div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-elevated)] overflow-hidden">
4340
+ <button
4341
+ onClick={() => setIsOpen(!isOpen)}
4342
+ className="w-full flex items-center justify-between p-4 hover:bg-[var(--surface)] transition"
4343
+ >
4344
+ <div className="flex items-center gap-3">
4345
+ <div className="w-10 h-10 rounded-xl bg-[var(--surface)] flex items-center justify-center">
4346
+ <Icon className="w-5 h-5 text-[var(--text-muted)]" />
4347
+ </div>
4348
+ <span className="font-semibold">{title}</span>
4349
+ </div>
4350
+ {isOpen ? (
4351
+ <ChevronUp className="w-5 h-5 text-[var(--text-muted)]" />
4352
+ ) : (
4353
+ <ChevronDown className="w-5 h-5 text-[var(--text-muted)]" />
4354
+ )}
4355
+ </button>
4356
+ {isOpen && (
4357
+ <div className="p-4 pt-0 border-t border-[var(--border)]">
4358
+ {children}
4359
+ </div>
4360
+ )}
4361
+ </div>
4362
+ );
4363
+ }
4364
+
4365
+ function SettingsTab({ workspace, sessionToken }: { workspace: Workspace | null; sessionToken: string | null }) {
4366
+ const [isLoadingPortal, setIsLoadingPortal] = useState(false);
4367
+ const [portalError, setPortalError] = useState<string | null>(null);
4368
+ const router = useRouter();
4369
+
4370
+ // Open Stripe billing portal
4371
+ const openBillingPortal = async () => {
4372
+ if (!sessionToken) return;
4373
+
4374
+ setIsLoadingPortal(true);
4375
+ setPortalError(null);
4376
+
4377
+ try {
4378
+ const res = await fetch("/api/billing/portal", {
4379
+ method: "POST",
4380
+ headers: { "Content-Type": "application/json" },
4381
+ body: JSON.stringify({ token: sessionToken }),
4382
+ });
4383
+ const data = await res.json();
4384
+
4385
+ if (data.url) {
4386
+ window.location.href = data.url;
4387
+ } else {
4388
+ setPortalError(data.error || "Failed to open billing portal");
4389
+ }
4390
+ } catch {
4391
+ setPortalError("Failed to open billing portal");
4392
+ } finally {
4393
+ setIsLoadingPortal(false);
4394
+ }
4395
+ };
4396
+
4397
+ const hasStripeCustomer = workspace && (workspace as any).stripeCustomerId;
4398
+
4399
+ return (
4400
+ <div className="space-y-6">
4401
+ <div>
4402
+ <h2 className="text-2xl font-bold mb-2">Settings</h2>
4403
+ <p className="text-[var(--text-muted)]">Manage your account and workspace settings.</p>
4404
+ </div>
4405
+
4406
+ <SettingsSection title="Profile" icon={User} defaultOpen={true}>
4407
+ <div className="space-y-4 pt-4">
4408
+ <div>
4409
+ <label className="block text-sm text-[var(--text-muted)] mb-2">Email</label>
4410
+ <input
4411
+ type="email"
4412
+ value={workspace?.email || ""}
4413
+ disabled
4414
+ className="w-full px-4 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--text-primary)] opacity-60"
4415
+ />
4416
+ </div>
4417
+ <div>
4418
+ <label className="block text-sm text-[var(--text-muted)] mb-2">Display Name</label>
4419
+ <input
4420
+ type="text"
4421
+ placeholder="Your name"
4422
+ disabled
4423
+ className="w-full px-4 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--text-primary)] opacity-60"
4424
+ />
4425
+ <p className="text-xs text-[var(--text-muted)] mt-1">Coming soon</p>
4426
+ </div>
4427
+ </div>
4428
+ </SettingsSection>
4429
+
4430
+ <SettingsSection title="Security" icon={Lock}>
4431
+ <div className="space-y-4 pt-4">
4432
+ <div className="flex items-center justify-between p-4 rounded-xl bg-[var(--surface)]">
4433
+ <div>
4434
+ <p className="font-medium">Two-Factor Authentication</p>
4435
+ <p className="text-sm text-[var(--text-muted)]">Add an extra layer of security</p>
4436
+ </div>
4437
+ <button
4438
+ disabled
4439
+ className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm font-medium opacity-50 cursor-not-allowed"
4440
+ >
4441
+ Enable
4442
+ </button>
4443
+ </div>
4444
+ <div className="flex items-center justify-between p-4 rounded-xl bg-[var(--surface)]">
4445
+ <div>
4446
+ <p className="font-medium">Active Sessions</p>
4447
+ <p className="text-sm text-[var(--text-muted)]">Manage your active login sessions</p>
4448
+ </div>
4449
+ <button
4450
+ disabled
4451
+ className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm font-medium opacity-50 cursor-not-allowed"
4452
+ >
4453
+ View
4454
+ </button>
4455
+ </div>
4456
+ </div>
4457
+ </SettingsSection>
4458
+
4459
+ <SettingsSection title="Notifications" icon={Bell}>
4460
+ <div className="space-y-4 pt-4">
4461
+ <div className="flex items-center justify-between p-4 rounded-xl bg-[var(--surface)]">
4462
+ <div>
4463
+ <p className="font-medium">Email Notifications</p>
4464
+ <p className="text-sm text-[var(--text-muted)]">Usage alerts, updates, and announcements</p>
4465
+ </div>
4466
+ <div className="w-12 h-6 rounded-full bg-[var(--border)] relative opacity-50 cursor-not-allowed">
4467
+ <div className="w-5 h-5 rounded-full bg-white absolute top-0.5 left-0.5 shadow" />
4468
+ </div>
4469
+ </div>
4470
+ <div className="flex items-center justify-between p-4 rounded-xl bg-[var(--surface)]">
4471
+ <div>
4472
+ <p className="font-medium">Usage Threshold Alerts</p>
4473
+ <p className="text-sm text-[var(--text-muted)]">Get notified at 80% and 100% usage</p>
4474
+ </div>
4475
+ <div className="w-12 h-6 rounded-full bg-[var(--border)] relative opacity-50 cursor-not-allowed">
4476
+ <div className="w-5 h-5 rounded-full bg-white absolute top-0.5 left-0.5 shadow" />
4477
+ </div>
4478
+ </div>
4479
+ </div>
4480
+ </SettingsSection>
4481
+
4482
+ <SettingsSection title="Workspace" icon={Building}>
4483
+ <div className="space-y-4 pt-4">
4484
+ <div>
4485
+ <label className="block text-sm text-[var(--text-muted)] mb-2">Workspace Name</label>
4486
+ <input
4487
+ type="text"
4488
+ placeholder="My Workspace"
4489
+ disabled
4490
+ className="w-full px-4 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--text-primary)] opacity-60"
4491
+ />
4492
+ <p className="text-xs text-[var(--text-muted)] mt-1">Coming soon</p>
4493
+ </div>
4494
+ <div className="flex items-center justify-between p-4 rounded-xl bg-[var(--surface)]">
4495
+ <div>
4496
+ <p className="font-medium">Tier</p>
4497
+ <p className="text-sm text-[var(--text-muted)]">Current subscription plan</p>
4498
+ </div>
4499
+ <span className="px-3 py-1 rounded-full bg-[#ef4444]/20 text-[#ef4444] text-sm font-medium capitalize">
4500
+ {workspace?.tier || "Free"}
4501
+ </span>
4502
+ </div>
4503
+ </div>
4504
+ </SettingsSection>
4505
+
4506
+ <SettingsSection title="Billing" icon={CreditCard}>
4507
+ <div className="space-y-4 pt-4">
4508
+ {portalError && (
4509
+ <div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-500 text-sm flex items-center gap-2">
4510
+ <AlertCircle className="w-4 h-4" />
4511
+ {portalError}
4512
+ </div>
4513
+ )}
4514
+
4515
+ <div className="flex items-center justify-between p-4 rounded-xl bg-[var(--surface)]">
4516
+ <div>
4517
+ <p className="font-medium">Current Plan</p>
4518
+ <p className="text-sm text-[var(--text-muted)]">
4519
+ {workspace?.tier === "pro" || workspace?.tier === "usage_based" ? "Usage-Based" : "Free Tier"}
4520
+ </p>
4521
+ </div>
4522
+ <span className={`px-3 py-1 rounded-full text-sm font-medium ${
4523
+ workspace?.tier === "pro" || workspace?.tier === "usage_based"
4524
+ ? "bg-green-500/20 text-green-500"
4525
+ : "bg-[var(--surface-elevated)] text-[var(--text-muted)]"
4526
+ }`}>
4527
+ {workspace?.tier === "pro" || workspace?.tier === "usage_based" ? "Active" : "Free"}
4528
+ </span>
4529
+ </div>
4530
+
4531
+ {hasStripeCustomer ? (
4532
+ <button
4533
+ onClick={openBillingPortal}
4534
+ disabled={isLoadingPortal}
4535
+ className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl bg-[#ef4444] text-white font-medium hover:bg-[#dc2626] transition disabled:opacity-50"
4536
+ >
4537
+ {isLoadingPortal ? (
4538
+ <>
4539
+ <Loader2 className="w-4 h-4 animate-spin" />
4540
+ Opening Portal...
4541
+ </>
4542
+ ) : (
4543
+ <>
4544
+ <CreditCard className="w-4 h-4" />
4545
+ Manage Billing
4546
+ <ExternalLink className="w-4 h-4" />
4547
+ </>
4548
+ )}
4549
+ </button>
4550
+ ) : (
4551
+ <button
4552
+ onClick={() => router.push("/workspace?tab=billing")}
4553
+ className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl border border-[var(--border)] text-[var(--text-primary)] font-medium hover:bg-[var(--surface)] transition"
4554
+ >
4555
+ <CreditCard className="w-4 h-4" />
4556
+ Add Payment Method
4557
+ </button>
4558
+ )}
4559
+
4560
+ <p className="text-xs text-[var(--text-muted)] text-center">
4561
+ {hasStripeCustomer
4562
+ ? "Update card, view invoices, or cancel subscription via Stripe"
4563
+ : "Add a payment method to unlock unlimited API calls"
4564
+ }
4565
+ </p>
4566
+ </div>
4567
+ </SettingsSection>
4568
+
4569
+ <SettingsSection title="API Tokens" icon={Key}>
4570
+ <div className="space-y-4 pt-4">
4571
+ <p className="text-sm text-[var(--text-muted)]">
4572
+ Generate API tokens for programmatic access to your workspace.
4573
+ </p>
4574
+ <button
4575
+ disabled
4576
+ className="w-full px-4 py-3 rounded-xl border border-dashed border-[var(--border)] text-sm font-medium text-[var(--text-muted)] hover:border-[#ef4444]/50 transition opacity-50 cursor-not-allowed flex items-center justify-center gap-2"
4577
+ >
4578
+ <Plus className="w-4 h-4" />
4579
+ Generate New Token
4580
+ </button>
4581
+ <p className="text-xs text-[var(--text-muted)] text-center">Coming soon</p>
4582
+ </div>
4583
+ </SettingsSection>
4584
+
4585
+ <div className="rounded-2xl border border-red-500/30 bg-red-500/5 p-6">
4586
+ <h3 className="font-semibold text-red-500 mb-2">Danger Zone</h3>
4587
+ <p className="text-sm text-[var(--text-muted)] mb-4">
4588
+ Irreversible actions. Proceed with caution.
4589
+ </p>
4590
+ <button
4591
+ disabled
4592
+ className="px-4 py-2 rounded-lg bg-red-500/20 text-red-500 font-medium opacity-50 cursor-not-allowed"
4593
+ >
4594
+ Delete Workspace
4595
+ </button>
4596
+ </div>
4597
+ </div>
4598
+ );
4599
+ }