@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.
- package/README.md +33 -0
- package/convex/_generated/api.d.ts +12 -0
- package/convex/billing.ts +651 -216
- package/convex/crons.ts +17 -0
- package/convex/email.ts +135 -82
- package/convex/feedback.ts +265 -0
- package/convex/http.ts +80 -4
- package/convex/logs.ts +287 -0
- package/convex/providerKeys.ts +209 -0
- package/convex/providers.ts +18 -0
- package/convex/schema.ts +115 -0
- package/convex/stripeActions.ts +512 -0
- package/convex/webhooks.ts +494 -0
- package/convex/workspaces.ts +74 -1
- package/dist/index.js +178 -0
- package/dist/index.js.map +1 -1
- package/dist/metered.d.ts +62 -0
- package/dist/metered.d.ts.map +1 -0
- package/dist/metered.js +81 -0
- package/dist/metered.js.map +1 -0
- package/dist/stripe.d.ts +62 -0
- package/dist/stripe.d.ts.map +1 -1
- package/dist/stripe.js +212 -0
- package/dist/stripe.js.map +1 -1
- package/docs/PRD-final-polish.md +117 -0
- package/docs/PRD-mobile-responsive.md +56 -0
- package/docs/PRD-navigation-expansion.md +295 -0
- package/docs/PRD-stripe-billing.md +312 -0
- package/docs/PRD-workspace-cleanup.md +200 -0
- package/landing/src/app/api/billing/checkout/route.ts +109 -0
- package/landing/src/app/api/billing/payment-method/route.ts +118 -0
- package/landing/src/app/api/billing/portal/route.ts +64 -0
- package/landing/src/app/auth/verify/page.tsx +20 -5
- package/landing/src/app/earn/page.tsx +6 -6
- package/landing/src/app/login/page.tsx +1 -1
- package/landing/src/app/page.tsx +70 -70
- package/landing/src/app/providers/dashboard/page.tsx +1 -1
- package/landing/src/app/workspace/page.tsx +3497 -535
- package/landing/src/components/CheckoutButton.tsx +188 -0
- package/landing/src/components/Toast.tsx +84 -0
- package/landing/src/lib/stats.json +1 -1
- package/landing/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/index.ts +205 -0
- package/src/metered.ts +149 -0
- 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" | "
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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,
|
|
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
|
|
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: "
|
|
408
|
-
{ id: "
|
|
409
|
-
{ id: "
|
|
410
|
-
{ id: "
|
|
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-
|
|
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
|
|
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
|
-
|
|
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("
|
|
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=
|
|
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 === "
|
|
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
|
-
<
|
|
536
|
-
<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("
|
|
723
|
+
setAnalyticsSubtab("logs");
|
|
542
724
|
setSidebarOpen(false);
|
|
543
|
-
router.push(`/workspace?tab=analytics&sub=
|
|
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 === "
|
|
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
|
-
<
|
|
552
|
-
<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
|
-
|
|
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 === "
|
|
633
|
-
<
|
|
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 === "
|
|
647
|
-
<
|
|
897
|
+
{activeTab === "webhooks" && (
|
|
898
|
+
<WebhooksTab />
|
|
648
899
|
)}
|
|
649
|
-
{activeTab === "
|
|
650
|
-
<
|
|
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-
|
|
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)]">
|
|
948
|
+
<span className="text-[var(--text-muted)]">Available APIs</span>
|
|
684
949
|
</div>
|
|
685
|
-
<p className="text-4xl font-bold text-[#ef4444]">{
|
|
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)]">
|
|
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
|
-
<
|
|
714
|
-
<span className="text-[var(--text-muted)]">
|
|
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-
|
|
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
|
-
{/*
|
|
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">
|
|
768
|
-
<button onClick={() => setActiveTab("apis")} className="text-sm text-[#ef4444] hover:underline">
|
|
769
|
-
|
|
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
|
-
<
|
|
1069
|
+
<div
|
|
775
1070
|
key={api._id}
|
|
776
|
-
|
|
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
|
-
</
|
|
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">
|
|
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
|
-
//
|
|
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
|
|
1250
|
+
function MyAPIsTab({ apis }: { apis: ProviderAPI[] }) {
|
|
845
1251
|
if (!apis || apis.length === 0) {
|
|
846
1252
|
return (
|
|
847
|
-
<div className="
|
|
848
|
-
<
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
<
|
|
854
|
-
<
|
|
855
|
-
|
|
856
|
-
|
|
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'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'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
|
-
<
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
<
|
|
934
|
-
|
|
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
|
-
|
|
991
|
-
<
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
1632
|
+
// ANALYTICS TAB (with subtabs)
|
|
1030
1633
|
// ============================================
|
|
1031
1634
|
|
|
1032
|
-
function
|
|
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
|
|
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={() =>
|
|
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 === "
|
|
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
|
-
<
|
|
1326
|
-
|
|
1717
|
+
<TrendingUp className="w-4 h-4" />
|
|
1718
|
+
Usage
|
|
1327
1719
|
</button>
|
|
1328
1720
|
<button
|
|
1329
|
-
onClick={() =>
|
|
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 === "
|
|
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
|
-
<
|
|
1337
|
-
|
|
1731
|
+
<ScrollText className="w-4 h-4" />
|
|
1732
|
+
Logs
|
|
1338
1733
|
</button>
|
|
1339
1734
|
</div>
|
|
1340
1735
|
|
|
1341
1736
|
{/* Subtab Content */}
|
|
1342
|
-
{activeSubtab === "
|
|
1343
|
-
<
|
|
1737
|
+
{activeSubtab === "overview" && (
|
|
1738
|
+
<AnalyticsOverviewTab apis={apis} analytics={analytics} workspace={workspace} agents={agents} usage={usage} />
|
|
1344
1739
|
)}
|
|
1345
|
-
{activeSubtab === "
|
|
1346
|
-
<
|
|
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
|
-
//
|
|
1751
|
+
// ANALYTICS OVERVIEW TAB
|
|
1354
1752
|
// ============================================
|
|
1355
1753
|
|
|
1356
|
-
function
|
|
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
|
|
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-
|
|
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="
|
|
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
|
|
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
|
|
1820
|
+
<h3 className="font-semibold mb-4">Top Agents</h3>
|
|
1422
1821
|
<div className="space-y-3">
|
|
1423
|
-
{analytics
|
|
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
|
|
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
|
-
{/*
|
|
1457
|
-
|
|
1458
|
-
<
|
|
1459
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1883
|
+
// USAGE TAB
|
|
1493
1884
|
// ============================================
|
|
1494
1885
|
|
|
1495
|
-
function
|
|
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
|
|
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
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
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
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
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
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
<
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
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
|
-
{/*
|
|
1554
|
-
|
|
1555
|
-
<
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
<
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
<
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
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
|
-
|
|
2193
|
+
)}
|
|
1575
2194
|
|
|
1576
|
-
{/*
|
|
1577
|
-
|
|
1578
|
-
<
|
|
1579
|
-
|
|
1580
|
-
<
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
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'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
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
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
|
-
|
|
1626
|
-
<
|
|
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
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
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'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'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'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
|
+
}
|