@nordsym/apiclaw 1.3.3 → 1.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/convex/_generated/api.d.ts +6 -0
- package/convex/billing.ts +341 -0
- package/convex/email.ts +276 -0
- package/convex/http.ts +154 -0
- package/convex/schema.ts +43 -0
- package/convex/workspaces.ts +663 -0
- package/dist/index.js +387 -4
- package/dist/index.js.map +1 -1
- package/dist/session.d.ts +29 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +87 -0
- package/dist/session.js.map +1 -0
- package/docs/PRD-agent-first-billing.md +525 -0
- package/landing/package-lock.json +21 -3
- package/landing/package.json +2 -1
- package/landing/src/app/api/stripe/webhook/route.ts +178 -0
- package/landing/src/app/api/workspace-auth/magic-link/route.ts +84 -0
- package/landing/src/app/api/workspace-auth/session/route.ts +73 -0
- package/landing/src/app/api/workspace-auth/verify/route.ts +57 -0
- package/landing/src/app/auth/verify/page.tsx +292 -0
- package/landing/src/app/dashboard/layout.tsx +22 -0
- package/landing/src/app/dashboard/page.tsx +692 -0
- package/landing/src/app/dashboard/verify/page.tsx +108 -0
- package/landing/src/app/login/page.tsx +204 -0
- package/landing/src/app/upgrade/page.tsx +288 -0
- package/landing/src/lib/stats.json +14 -15
- package/landing/src/middleware.ts +50 -0
- package/landing/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/index.ts +434 -4
- package/src/session.ts +103 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, Suspense } from "react";
|
|
4
|
+
import { useRouter, useSearchParams } from "next/navigation";
|
|
5
|
+
import { Loader2, AlertCircle, Check } from "lucide-react";
|
|
6
|
+
import Link from "next/link";
|
|
7
|
+
|
|
8
|
+
function VerifyContent() {
|
|
9
|
+
const router = useRouter();
|
|
10
|
+
const searchParams = useSearchParams();
|
|
11
|
+
const token = searchParams.get("token");
|
|
12
|
+
|
|
13
|
+
const [status, setStatus] = useState<"verifying" | "success" | "error">("verifying");
|
|
14
|
+
const [error, setError] = useState<string | null>(null);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!token) {
|
|
18
|
+
setStatus("error");
|
|
19
|
+
setError("No verification token provided");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const verify = async () => {
|
|
24
|
+
try {
|
|
25
|
+
const response = await fetch("/api/workspace-auth/verify", {
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: { "Content-Type": "application/json" },
|
|
28
|
+
body: JSON.stringify({ token }),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const data = await response.json();
|
|
32
|
+
|
|
33
|
+
if (!response.ok || !data.success) {
|
|
34
|
+
throw new Error(data.error || "Verification failed");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Store session token in localStorage as backup
|
|
38
|
+
if (data.sessionToken) {
|
|
39
|
+
localStorage.setItem("apiclaw_workspace_session", data.sessionToken);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
setStatus("success");
|
|
43
|
+
|
|
44
|
+
// Redirect to dashboard after short delay
|
|
45
|
+
setTimeout(() => {
|
|
46
|
+
router.push("/dashboard");
|
|
47
|
+
}, 1500);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
setStatus("error");
|
|
50
|
+
setError(err instanceof Error ? err.message : "Verification failed");
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
verify();
|
|
55
|
+
}, [token, router]);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<main className="min-h-screen flex items-center justify-center px-6 bg-[var(--background)]">
|
|
59
|
+
<div className="max-w-md w-full text-center">
|
|
60
|
+
{status === "verifying" && (
|
|
61
|
+
<>
|
|
62
|
+
<Loader2 className="w-16 h-16 text-accent animate-spin mx-auto mb-6" />
|
|
63
|
+
<h1 className="text-2xl font-bold mb-2">Verifying...</h1>
|
|
64
|
+
<p className="text-[var(--text-muted)]">Please wait while we sign you in.</p>
|
|
65
|
+
</>
|
|
66
|
+
)}
|
|
67
|
+
|
|
68
|
+
{status === "success" && (
|
|
69
|
+
<>
|
|
70
|
+
<div className="w-20 h-20 rounded-full bg-green-500/20 flex items-center justify-center mx-auto mb-6">
|
|
71
|
+
<Check className="w-10 h-10 text-green-500" />
|
|
72
|
+
</div>
|
|
73
|
+
<h1 className="text-2xl font-bold mb-2">Success!</h1>
|
|
74
|
+
<p className="text-[var(--text-muted)]">Redirecting to your dashboard...</p>
|
|
75
|
+
</>
|
|
76
|
+
)}
|
|
77
|
+
|
|
78
|
+
{status === "error" && (
|
|
79
|
+
<>
|
|
80
|
+
<div className="w-20 h-20 rounded-full bg-red-500/20 flex items-center justify-center mx-auto mb-6">
|
|
81
|
+
<AlertCircle className="w-10 h-10 text-red-500" />
|
|
82
|
+
</div>
|
|
83
|
+
<h1 className="text-2xl font-bold mb-2">Verification Failed</h1>
|
|
84
|
+
<p className="text-red-500 mb-6">{error}</p>
|
|
85
|
+
<Link href="/login" className="btn-primary">
|
|
86
|
+
Try Again
|
|
87
|
+
</Link>
|
|
88
|
+
</>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
</main>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export default function VerifyPage() {
|
|
96
|
+
return (
|
|
97
|
+
<Suspense fallback={
|
|
98
|
+
<main className="min-h-screen flex items-center justify-center px-6 bg-[var(--background)]">
|
|
99
|
+
<div className="text-center">
|
|
100
|
+
<Loader2 className="w-16 h-16 text-accent animate-spin mx-auto mb-6" />
|
|
101
|
+
<p className="text-[var(--text-muted)]">Loading...</p>
|
|
102
|
+
</div>
|
|
103
|
+
</main>
|
|
104
|
+
}>
|
|
105
|
+
<VerifyContent />
|
|
106
|
+
</Suspense>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from "react";
|
|
4
|
+
import { Mail, Loader2, Check, ArrowRight, Sun, Moon, Zap } from "lucide-react";
|
|
5
|
+
import Link from "next/link";
|
|
6
|
+
import { useRouter } from "next/navigation";
|
|
7
|
+
|
|
8
|
+
export default function LoginPage() {
|
|
9
|
+
const router = useRouter();
|
|
10
|
+
const [isDark, setIsDark] = useState(true);
|
|
11
|
+
const [email, setEmail] = useState("");
|
|
12
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
13
|
+
const [isSent, setIsSent] = useState(false);
|
|
14
|
+
const [error, setError] = useState<string | null>(null);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
// Check if already logged in
|
|
18
|
+
const checkSession = async () => {
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch("/api/workspace-auth/session");
|
|
21
|
+
const data = await res.json();
|
|
22
|
+
if (data.session) {
|
|
23
|
+
router.push("/dashboard");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
} catch {
|
|
27
|
+
// Not logged in, continue
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
checkSession();
|
|
31
|
+
|
|
32
|
+
const saved = localStorage.getItem("theme");
|
|
33
|
+
const prefersDark = saved ? saved === "dark" : true;
|
|
34
|
+
setIsDark(prefersDark);
|
|
35
|
+
document.documentElement.classList.toggle("dark", prefersDark);
|
|
36
|
+
}, [router]);
|
|
37
|
+
|
|
38
|
+
const toggleTheme = () => {
|
|
39
|
+
const newTheme = !isDark;
|
|
40
|
+
setIsDark(newTheme);
|
|
41
|
+
document.documentElement.classList.toggle("dark", newTheme);
|
|
42
|
+
localStorage.setItem("theme", newTheme ? "dark" : "light");
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
setIsLoading(true);
|
|
48
|
+
setError(null);
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const response = await fetch("/api/workspace-auth/magic-link", {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: { "Content-Type": "application/json" },
|
|
54
|
+
body: JSON.stringify({ email }),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
const data = await response.json();
|
|
59
|
+
throw new Error(data.error || "Failed to send magic link");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
setIsSent(true);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
setError(err instanceof Error ? err.message : "Something went wrong");
|
|
65
|
+
} finally {
|
|
66
|
+
setIsLoading(false);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (isSent) {
|
|
71
|
+
return (
|
|
72
|
+
<main className="min-h-screen flex items-center justify-center px-6 bg-[var(--background)]">
|
|
73
|
+
<div className="max-w-md w-full text-center">
|
|
74
|
+
<div className="w-20 h-20 rounded-full bg-accent/20 flex items-center justify-center mx-auto mb-8">
|
|
75
|
+
<Check className="w-10 h-10 text-accent" />
|
|
76
|
+
</div>
|
|
77
|
+
<h1 className="text-3xl font-bold mb-4">Check Your Email</h1>
|
|
78
|
+
<p className="text-[var(--text-secondary)] mb-2">
|
|
79
|
+
We've sent a magic link to:
|
|
80
|
+
</p>
|
|
81
|
+
<p className="font-semibold text-lg mb-8">{email}</p>
|
|
82
|
+
<p className="text-sm text-[var(--text-muted)]">
|
|
83
|
+
Click the link in the email to sign in. The link expires in 15 minutes.
|
|
84
|
+
</p>
|
|
85
|
+
<button
|
|
86
|
+
onClick={() => setIsSent(false)}
|
|
87
|
+
className="mt-8 text-accent hover:underline"
|
|
88
|
+
>
|
|
89
|
+
Use a different email
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
</main>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<main className="min-h-screen bg-[var(--background)]">
|
|
98
|
+
{/* Header */}
|
|
99
|
+
<header className="fixed top-0 w-full z-50 bg-[var(--background)]/90 backdrop-blur-xl border-b border-[var(--border-subtle)]">
|
|
100
|
+
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
|
101
|
+
<Link href="/" className="flex items-center gap-3">
|
|
102
|
+
<div className="w-9 h-9 rounded-xl bg-accent/20 flex items-center justify-center text-xl">
|
|
103
|
+
🦞
|
|
104
|
+
</div>
|
|
105
|
+
<span className="font-bold text-lg tracking-tight">APIClaw</span>
|
|
106
|
+
</Link>
|
|
107
|
+
<button
|
|
108
|
+
onClick={toggleTheme}
|
|
109
|
+
className="p-2 rounded-lg hover:bg-[var(--surface)] transition"
|
|
110
|
+
aria-label="Toggle theme"
|
|
111
|
+
>
|
|
112
|
+
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
115
|
+
</header>
|
|
116
|
+
|
|
117
|
+
<div className="pt-32 pb-24 px-6">
|
|
118
|
+
<div className="max-w-md mx-auto">
|
|
119
|
+
<div className="text-center mb-8">
|
|
120
|
+
<div className="w-16 h-16 rounded-2xl bg-accent/20 flex items-center justify-center mx-auto mb-6">
|
|
121
|
+
<Zap className="w-8 h-8 text-accent" />
|
|
122
|
+
</div>
|
|
123
|
+
<h1 className="text-3xl font-bold mb-2">Agent Workspace</h1>
|
|
124
|
+
<p className="text-[var(--text-secondary)]">
|
|
125
|
+
Sign in to manage your AI agents and view usage
|
|
126
|
+
</p>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<div className="rounded-2xl bg-[var(--surface-elevated)] border border-[var(--border)] p-8">
|
|
130
|
+
<form onSubmit={handleSubmit} className="space-y-6">
|
|
131
|
+
<div>
|
|
132
|
+
<label className="block text-sm font-medium mb-2">Email</label>
|
|
133
|
+
<div className="relative">
|
|
134
|
+
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--text-muted)]" />
|
|
135
|
+
<input
|
|
136
|
+
type="email"
|
|
137
|
+
value={email}
|
|
138
|
+
onChange={(e) => {
|
|
139
|
+
setEmail(e.target.value);
|
|
140
|
+
setError(null);
|
|
141
|
+
}}
|
|
142
|
+
placeholder="you@company.com"
|
|
143
|
+
required
|
|
144
|
+
className="w-full pl-12 pr-4 py-3 rounded-xl bg-[var(--surface)] border border-[var(--border)] focus:border-accent focus:outline-none transition"
|
|
145
|
+
/>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
{error && (
|
|
150
|
+
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-500 text-sm">
|
|
151
|
+
{error}
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
|
|
155
|
+
<button
|
|
156
|
+
type="submit"
|
|
157
|
+
disabled={isLoading || !email}
|
|
158
|
+
className="btn-primary w-full justify-center disabled:opacity-50"
|
|
159
|
+
>
|
|
160
|
+
{isLoading ? (
|
|
161
|
+
<>
|
|
162
|
+
<Loader2 className="w-5 h-5 animate-spin" />
|
|
163
|
+
Sending...
|
|
164
|
+
</>
|
|
165
|
+
) : (
|
|
166
|
+
<>
|
|
167
|
+
Send Magic Link
|
|
168
|
+
<ArrowRight className="w-5 h-5" />
|
|
169
|
+
</>
|
|
170
|
+
)}
|
|
171
|
+
</button>
|
|
172
|
+
</form>
|
|
173
|
+
|
|
174
|
+
<div className="mt-6 pt-6 border-t border-[var(--border)]">
|
|
175
|
+
<div className="space-y-3 text-sm text-[var(--text-muted)]">
|
|
176
|
+
<div className="flex items-center gap-2">
|
|
177
|
+
<Check className="w-4 h-4 text-green-500" />
|
|
178
|
+
<span>1,000 free API calls per month</span>
|
|
179
|
+
</div>
|
|
180
|
+
<div className="flex items-center gap-2">
|
|
181
|
+
<Check className="w-4 h-4 text-green-500" />
|
|
182
|
+
<span>Connect unlimited AI agents</span>
|
|
183
|
+
</div>
|
|
184
|
+
<div className="flex items-center gap-2">
|
|
185
|
+
<Check className="w-4 h-4 text-green-500" />
|
|
186
|
+
<span>Usage analytics & monitoring</span>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
<div className="mt-6 text-center">
|
|
193
|
+
<p className="text-sm text-[var(--text-muted)]">
|
|
194
|
+
Are you an API provider?{" "}
|
|
195
|
+
<Link href="/providers/dashboard/login" className="text-accent hover:underline">
|
|
196
|
+
Provider Dashboard
|
|
197
|
+
</Link>
|
|
198
|
+
</p>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
</main>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { useSearchParams, useRouter } from "next/navigation";
|
|
5
|
+
import { Suspense } from "react";
|
|
6
|
+
|
|
7
|
+
const CONVEX_URL = process.env.NEXT_PUBLIC_CONVEX_URL || "https://adventurous-avocet-799.convex.cloud";
|
|
8
|
+
|
|
9
|
+
interface BillingStatus {
|
|
10
|
+
tier: string;
|
|
11
|
+
status: string;
|
|
12
|
+
usageCount: number;
|
|
13
|
+
usageLimit: number;
|
|
14
|
+
usageRemaining: number;
|
|
15
|
+
usagePercent: number;
|
|
16
|
+
hasStripe: boolean;
|
|
17
|
+
email: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function queryConvex<T>(path: string, args: Record<string, unknown>): Promise<T> {
|
|
21
|
+
const response = await fetch(`${CONVEX_URL}/api/query`, {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: { "Content-Type": "application/json" },
|
|
24
|
+
body: JSON.stringify({ path, args }),
|
|
25
|
+
});
|
|
26
|
+
const result = await response.json();
|
|
27
|
+
return result.value !== undefined ? result.value : result;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function actionConvex<T>(path: string, args: Record<string, unknown>): Promise<T> {
|
|
31
|
+
const response = await fetch(`${CONVEX_URL}/api/action`, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: { "Content-Type": "application/json" },
|
|
34
|
+
body: JSON.stringify({ path, args }),
|
|
35
|
+
});
|
|
36
|
+
const result = await response.json();
|
|
37
|
+
return result.value !== undefined ? result.value : result;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function UpgradeContent() {
|
|
41
|
+
const searchParams = useSearchParams();
|
|
42
|
+
const router = useRouter();
|
|
43
|
+
const workspaceId = searchParams.get("ws");
|
|
44
|
+
const success = searchParams.get("success");
|
|
45
|
+
|
|
46
|
+
const [billing, setBilling] = useState<BillingStatus | null>(null);
|
|
47
|
+
const [loading, setLoading] = useState(true);
|
|
48
|
+
const [upgrading, setUpgrading] = useState(false);
|
|
49
|
+
const [error, setError] = useState<string | null>(null);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!workspaceId) {
|
|
53
|
+
setError("Missing workspace ID");
|
|
54
|
+
setLoading(false);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function loadBilling() {
|
|
59
|
+
try {
|
|
60
|
+
const status = await queryConvex<BillingStatus | null>("billing:getBillingStatus", {
|
|
61
|
+
workspaceId,
|
|
62
|
+
});
|
|
63
|
+
setBilling(status);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
setError("Failed to load billing status");
|
|
66
|
+
} finally {
|
|
67
|
+
setLoading(false);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
loadBilling();
|
|
72
|
+
}, [workspaceId]);
|
|
73
|
+
|
|
74
|
+
const handleUpgrade = async () => {
|
|
75
|
+
if (!workspaceId) return;
|
|
76
|
+
|
|
77
|
+
setUpgrading(true);
|
|
78
|
+
setError(null);
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const result = await actionConvex<{
|
|
82
|
+
success: boolean;
|
|
83
|
+
url?: string;
|
|
84
|
+
error?: string;
|
|
85
|
+
}>("billing:createCheckoutSession", {
|
|
86
|
+
workspaceId,
|
|
87
|
+
successUrl: `${window.location.origin}/upgrade?ws=${workspaceId}&success=true`,
|
|
88
|
+
cancelUrl: `${window.location.origin}/upgrade?ws=${workspaceId}`,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if (result.success && result.url) {
|
|
92
|
+
window.location.href = result.url;
|
|
93
|
+
} else {
|
|
94
|
+
setError(result.error || "Failed to create checkout session");
|
|
95
|
+
setUpgrading(false);
|
|
96
|
+
}
|
|
97
|
+
} catch (err) {
|
|
98
|
+
setError("Failed to start upgrade process");
|
|
99
|
+
setUpgrading(false);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
if (loading) {
|
|
104
|
+
return (
|
|
105
|
+
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
|
|
106
|
+
<div className="animate-pulse text-gray-400">Loading...</div>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (error && !billing) {
|
|
112
|
+
return (
|
|
113
|
+
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
|
|
114
|
+
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-6 max-w-md text-center">
|
|
115
|
+
<div className="text-red-400 text-lg font-medium mb-2">Error</div>
|
|
116
|
+
<div className="text-gray-400">{error}</div>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const isPro = billing?.tier === "pro" || billing?.tier === "enterprise";
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div className="min-h-screen bg-gray-950 py-12 px-4">
|
|
126
|
+
<div className="max-w-2xl mx-auto">
|
|
127
|
+
{/* Header */}
|
|
128
|
+
<div className="text-center mb-12">
|
|
129
|
+
<h1 className="text-3xl font-bold text-white mb-2">
|
|
130
|
+
APIClaw Workspace
|
|
131
|
+
</h1>
|
|
132
|
+
<p className="text-gray-400">
|
|
133
|
+
{billing?.email}
|
|
134
|
+
</p>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
{/* Success Message */}
|
|
138
|
+
{success && (
|
|
139
|
+
<div className="bg-green-900/20 border border-green-500/30 rounded-lg p-4 mb-8 text-center">
|
|
140
|
+
<div className="text-green-400 font-medium">
|
|
141
|
+
🎉 Upgrade successful! Welcome to APIClaw Pro.
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
|
|
146
|
+
{/* Current Plan Card */}
|
|
147
|
+
<div className="bg-gray-900 border border-gray-800 rounded-xl p-6 mb-8">
|
|
148
|
+
<div className="flex items-center justify-between mb-6">
|
|
149
|
+
<div>
|
|
150
|
+
<div className="text-sm text-gray-400 mb-1">Current Plan</div>
|
|
151
|
+
<div className="text-2xl font-bold text-white capitalize">
|
|
152
|
+
{billing?.tier || "Free"}
|
|
153
|
+
{isPro && <span className="ml-2 text-sm text-emerald-400">✓ Active</span>}
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
<div className={`px-4 py-2 rounded-full text-sm font-medium ${
|
|
157
|
+
isPro ? "bg-emerald-500/20 text-emerald-400" : "bg-gray-800 text-gray-400"
|
|
158
|
+
}`}>
|
|
159
|
+
{isPro ? "Pro" : "Free Tier"}
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
{/* Usage Bar */}
|
|
164
|
+
<div className="mb-6">
|
|
165
|
+
<div className="flex justify-between text-sm mb-2">
|
|
166
|
+
<span className="text-gray-400">API Calls This Month</span>
|
|
167
|
+
<span className="text-white font-medium">
|
|
168
|
+
{billing?.usageCount.toLocaleString()} / {billing?.usageLimit === -1 ? "∞" : billing?.usageLimit.toLocaleString()}
|
|
169
|
+
</span>
|
|
170
|
+
</div>
|
|
171
|
+
<div className="h-3 bg-gray-800 rounded-full overflow-hidden">
|
|
172
|
+
<div
|
|
173
|
+
className={`h-full transition-all ${
|
|
174
|
+
(billing?.usagePercent || 0) > 80 ? "bg-red-500" :
|
|
175
|
+
(billing?.usagePercent || 0) > 50 ? "bg-yellow-500" : "bg-emerald-500"
|
|
176
|
+
}`}
|
|
177
|
+
style={{ width: `${Math.min(billing?.usagePercent || 0, 100)}%` }}
|
|
178
|
+
/>
|
|
179
|
+
</div>
|
|
180
|
+
{!isPro && billing && billing.usageRemaining > 0 && billing.usageRemaining < 20 && (
|
|
181
|
+
<div className="text-sm text-yellow-400 mt-2">
|
|
182
|
+
⚠️ Only {billing.usageRemaining} calls remaining
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
{/* Plan Features */}
|
|
188
|
+
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
189
|
+
<div className="flex items-center gap-2 text-gray-400">
|
|
190
|
+
<span className={isPro ? "text-emerald-400" : "text-gray-600"}>✓</span>
|
|
191
|
+
<span>{isPro ? "10,000" : "100"} API calls/month</span>
|
|
192
|
+
</div>
|
|
193
|
+
<div className="flex items-center gap-2 text-gray-400">
|
|
194
|
+
<span className={isPro ? "text-emerald-400" : "text-gray-600"}>✓</span>
|
|
195
|
+
<span>Priority support</span>
|
|
196
|
+
</div>
|
|
197
|
+
<div className="flex items-center gap-2 text-gray-400">
|
|
198
|
+
<span className={isPro ? "text-emerald-400" : "text-gray-600"}>✓</span>
|
|
199
|
+
<span>Advanced analytics</span>
|
|
200
|
+
</div>
|
|
201
|
+
<div className="flex items-center gap-2 text-gray-400">
|
|
202
|
+
<span className={isPro ? "text-emerald-400" : "text-gray-600"}>✓</span>
|
|
203
|
+
<span>All providers</span>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
{/* Upgrade Card (only show for free tier) */}
|
|
209
|
+
{!isPro && (
|
|
210
|
+
<div className="bg-gradient-to-r from-red-600/20 to-orange-600/20 border border-red-500/30 rounded-xl p-6">
|
|
211
|
+
<div className="flex items-center justify-between mb-4">
|
|
212
|
+
<div>
|
|
213
|
+
<div className="text-xl font-bold text-white mb-1">
|
|
214
|
+
Upgrade to Pro
|
|
215
|
+
</div>
|
|
216
|
+
<div className="text-gray-400">
|
|
217
|
+
100x more API calls, priority support
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
<div className="text-right">
|
|
221
|
+
<div className="text-3xl font-bold text-white">$99</div>
|
|
222
|
+
<div className="text-gray-400 text-sm">/month</div>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
{error && (
|
|
227
|
+
<div className="text-red-400 text-sm mb-4 bg-red-900/20 p-3 rounded">
|
|
228
|
+
{error}
|
|
229
|
+
</div>
|
|
230
|
+
)}
|
|
231
|
+
|
|
232
|
+
<button
|
|
233
|
+
onClick={handleUpgrade}
|
|
234
|
+
disabled={upgrading}
|
|
235
|
+
className={`w-full py-3 px-6 rounded-lg font-medium transition-all ${
|
|
236
|
+
upgrading
|
|
237
|
+
? "bg-gray-700 text-gray-400 cursor-not-allowed"
|
|
238
|
+
: "bg-red-600 hover:bg-red-500 text-white"
|
|
239
|
+
}`}
|
|
240
|
+
>
|
|
241
|
+
{upgrading ? (
|
|
242
|
+
<span className="flex items-center justify-center gap-2">
|
|
243
|
+
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
|
244
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
|
245
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
|
246
|
+
</svg>
|
|
247
|
+
Processing...
|
|
248
|
+
</span>
|
|
249
|
+
) : (
|
|
250
|
+
"Add Payment Method →"
|
|
251
|
+
)}
|
|
252
|
+
</button>
|
|
253
|
+
|
|
254
|
+
<div className="text-center text-gray-500 text-xs mt-4">
|
|
255
|
+
Secure payment via Stripe. Cancel anytime.
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
)}
|
|
259
|
+
|
|
260
|
+
{/* Already Pro Message */}
|
|
261
|
+
{isPro && !success && (
|
|
262
|
+
<div className="text-center text-gray-400">
|
|
263
|
+
You're on the Pro plan. Enjoy unlimited API calls!
|
|
264
|
+
</div>
|
|
265
|
+
)}
|
|
266
|
+
|
|
267
|
+
{/* Back Link */}
|
|
268
|
+
<div className="text-center mt-8">
|
|
269
|
+
<a href="/" className="text-gray-500 hover:text-gray-400 text-sm">
|
|
270
|
+
← Back to APIClaw
|
|
271
|
+
</a>
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export default function UpgradePage() {
|
|
279
|
+
return (
|
|
280
|
+
<Suspense fallback={
|
|
281
|
+
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
|
|
282
|
+
<div className="animate-pulse text-gray-400">Loading...</div>
|
|
283
|
+
</div>
|
|
284
|
+
}>
|
|
285
|
+
<UpgradeContent />
|
|
286
|
+
</Suspense>
|
|
287
|
+
);
|
|
288
|
+
}
|
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"apiCount": 22392,
|
|
3
|
-
"categoryCount":
|
|
4
|
-
"lastUpdated": "2026-02-
|
|
5
|
-
"generatedAt": "2026-02-
|
|
3
|
+
"categoryCount": 13,
|
|
4
|
+
"lastUpdated": "2026-02-27T09:10:41.344767",
|
|
5
|
+
"generatedAt": "2026-02-28T09:32:15.722Z",
|
|
6
6
|
"categoryBreakdown": {
|
|
7
|
-
"
|
|
8
|
-
"
|
|
7
|
+
"Finance": 1179,
|
|
8
|
+
"Auth & Security": 491,
|
|
9
|
+
"Cloud & Infrastructure": 1463,
|
|
9
10
|
"Development": 2278,
|
|
10
|
-
"
|
|
11
|
+
"Business": 923,
|
|
12
|
+
"Commerce": 1151,
|
|
13
|
+
"Utilities": 7069,
|
|
11
14
|
"AI & ML": 1259,
|
|
15
|
+
"Location & Maps": 976,
|
|
16
|
+
"Social & Communication": 1051,
|
|
17
|
+
"Data & Analytics": 2600,
|
|
12
18
|
"Entertainment": 1212,
|
|
13
|
-
"
|
|
14
|
-
"Commerce": 1151,
|
|
15
|
-
"Location": 976,
|
|
16
|
-
"Communication": 939,
|
|
17
|
-
"Business": 923,
|
|
18
|
-
"Health": 740,
|
|
19
|
-
"Security": 491,
|
|
20
|
-
"Social": 112
|
|
19
|
+
"Health & Fitness": 740
|
|
21
20
|
}
|
|
22
|
-
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import type { NextRequest } from "next/server";
|
|
3
|
+
|
|
4
|
+
const CONVEX_URL = process.env.NEXT_PUBLIC_CONVEX_URL || "https://adventurous-avocet-799.convex.cloud";
|
|
5
|
+
|
|
6
|
+
export async function middleware(request: NextRequest) {
|
|
7
|
+
const pathname = request.nextUrl.pathname;
|
|
8
|
+
|
|
9
|
+
// Only protect /dashboard routes (not /dashboard/verify)
|
|
10
|
+
if (pathname.startsWith("/dashboard") && !pathname.startsWith("/dashboard/verify")) {
|
|
11
|
+
const sessionToken = request.cookies.get("apiclaw_workspace_session")?.value;
|
|
12
|
+
|
|
13
|
+
if (!sessionToken) {
|
|
14
|
+
// Redirect to login if no session
|
|
15
|
+
return NextResponse.redirect(new URL("/login", request.url));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Optionally verify session with Convex
|
|
19
|
+
// For performance, we do a lightweight check - full validation happens in the page
|
|
20
|
+
try {
|
|
21
|
+
const response = await fetch(`${CONVEX_URL}/api/query`, {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: { "Content-Type": "application/json" },
|
|
24
|
+
body: JSON.stringify({
|
|
25
|
+
path: "workspaces:getSession",
|
|
26
|
+
args: { token: sessionToken },
|
|
27
|
+
}),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const result = await response.json();
|
|
31
|
+
const session = result.value || result;
|
|
32
|
+
|
|
33
|
+
if (!session) {
|
|
34
|
+
// Invalid session - clear cookie and redirect
|
|
35
|
+
const res = NextResponse.redirect(new URL("/login", request.url));
|
|
36
|
+
res.cookies.delete("apiclaw_workspace_session");
|
|
37
|
+
return res;
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// On error, let the page handle it
|
|
41
|
+
return NextResponse.next();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return NextResponse.next();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const config = {
|
|
49
|
+
matcher: ["/dashboard/:path*"],
|
|
50
|
+
};
|