stagent 0.8.0 → 0.9.0
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 +41 -0
- package/dist/cli.js +19 -2
- package/package.json +2 -1
- package/src/app/analytics/page.tsx +60 -0
- package/src/app/api/license/checkout/route.ts +28 -0
- package/src/app/api/license/portal/route.ts +26 -0
- package/src/app/api/license/route.ts +88 -0
- package/src/app/api/license/usage/route.ts +63 -0
- package/src/app/api/marketplace/browse/route.ts +15 -0
- package/src/app/api/marketplace/import/route.ts +28 -0
- package/src/app/api/marketplace/publish/route.ts +40 -0
- package/src/app/api/memory/route.ts +11 -0
- package/src/app/api/onboarding/email/route.ts +53 -0
- package/src/app/api/onboarding/progress/route.ts +60 -0
- package/src/app/api/schedules/route.ts +11 -0
- package/src/app/api/settings/telemetry/route.ts +14 -0
- package/src/app/api/sync/export/route.ts +54 -0
- package/src/app/api/sync/restore/route.ts +37 -0
- package/src/app/api/sync/sessions/route.ts +24 -0
- package/src/app/api/tasks/[id]/execute/route.ts +21 -0
- package/src/app/auth/callback/route.ts +79 -0
- package/src/app/marketplace/page.tsx +19 -0
- package/src/app/page.tsx +6 -2
- package/src/app/settings/page.tsx +8 -0
- package/src/components/analytics/analytics-dashboard.tsx +200 -0
- package/src/components/analytics/analytics-gate-card.tsx +101 -0
- package/src/components/marketplace/blueprint-card.tsx +61 -0
- package/src/components/marketplace/marketplace-browser.tsx +131 -0
- package/src/components/onboarding/activation-checklist.tsx +64 -0
- package/src/components/onboarding/donut-ring.tsx +52 -0
- package/src/components/onboarding/email-capture-card.tsx +104 -0
- package/src/components/settings/activation-form.tsx +95 -0
- package/src/components/settings/cloud-account-section.tsx +145 -0
- package/src/components/settings/cloud-sync-section.tsx +155 -0
- package/src/components/settings/subscription-section.tsx +410 -0
- package/src/components/settings/telemetry-section.tsx +80 -0
- package/src/components/shared/app-sidebar.tsx +136 -29
- package/src/components/shared/premium-gate-overlay.tsx +50 -0
- package/src/components/shared/schedule-gate-dialog.tsx +64 -0
- package/src/components/shared/upgrade-banner.tsx +112 -0
- package/src/hooks/use-snoozed-banners.ts +73 -0
- package/src/hooks/use-supabase-auth.ts +79 -0
- package/src/instrumentation.ts +34 -0
- package/src/lib/agents/__tests__/execution-manager.test.ts +28 -1
- package/src/lib/agents/__tests__/learned-context.test.ts +13 -0
- package/src/lib/agents/execution-manager.ts +35 -0
- package/src/lib/agents/learned-context.ts +13 -0
- package/src/lib/analytics/queries.ts +207 -0
- package/src/lib/billing/email.ts +54 -0
- package/src/lib/billing/products.ts +80 -0
- package/src/lib/billing/stripe.ts +101 -0
- package/src/lib/cloud/supabase-browser.ts +38 -0
- package/src/lib/cloud/supabase-client.ts +49 -0
- package/src/lib/constants/settings.ts +18 -0
- package/src/lib/data/clear.ts +5 -0
- package/src/lib/db/bootstrap.ts +16 -0
- package/src/lib/db/schema.ts +24 -0
- package/src/lib/license/__tests__/features.test.ts +56 -0
- package/src/lib/license/__tests__/key-format.test.ts +88 -0
- package/src/lib/license/__tests__/tier-limits.test.ts +79 -0
- package/src/lib/license/cloud-validation.ts +64 -0
- package/src/lib/license/features.ts +44 -0
- package/src/lib/license/key-format.ts +101 -0
- package/src/lib/license/limit-check.ts +111 -0
- package/src/lib/license/limit-queries.ts +51 -0
- package/src/lib/license/manager.ts +290 -0
- package/src/lib/license/notifications.ts +59 -0
- package/src/lib/license/tier-limits.ts +71 -0
- package/src/lib/marketplace/marketplace-client.ts +107 -0
- package/src/lib/sync/cloud-sync.ts +237 -0
- package/src/lib/telemetry/conversion-events.ts +73 -0
- package/src/lib/telemetry/queue.ts +122 -0
- package/src/lib/usage/ledger.ts +18 -0
- package/src/lib/validators/license.ts +33 -0
- package/tsconfig.json +2 -1
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { licenseManager } from "@/lib/license/manager";
|
|
3
|
+
import { exportAndUpload } from "@/lib/sync/cloud-sync";
|
|
4
|
+
import { getSettingSync } from "@/lib/settings/helpers";
|
|
5
|
+
import { SETTINGS_KEYS } from "@/lib/constants/settings";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* POST /api/sync/export
|
|
9
|
+
* Export and encrypt the database, upload to Supabase Storage.
|
|
10
|
+
* Requires Operator+ tier and an authenticated Supabase session.
|
|
11
|
+
* Body: { accessToken: string } — the user's Supabase JWT
|
|
12
|
+
*/
|
|
13
|
+
export async function POST(req: NextRequest) {
|
|
14
|
+
if (!licenseManager.isFeatureAllowed("cloud-sync")) {
|
|
15
|
+
return NextResponse.json(
|
|
16
|
+
{ error: "Cloud sync requires Operator tier or above" },
|
|
17
|
+
{ status: 402 }
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const email = licenseManager.getStatus().email;
|
|
22
|
+
if (!email) {
|
|
23
|
+
return NextResponse.json(
|
|
24
|
+
{ error: "Sign in with your email first (Settings → Cloud Account)" },
|
|
25
|
+
{ status: 400 }
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Get the user's access token for authenticated Storage uploads
|
|
30
|
+
const body = await req.json().catch(() => ({}));
|
|
31
|
+
const accessToken = body.accessToken as string | undefined;
|
|
32
|
+
|
|
33
|
+
let deviceId = getSettingSync(SETTINGS_KEYS.DEVICE_ID);
|
|
34
|
+
if (!deviceId) {
|
|
35
|
+
deviceId = crypto.randomUUID();
|
|
36
|
+
const { setSetting } = await import("@/lib/settings/helpers");
|
|
37
|
+
await setSetting(SETTINGS_KEYS.DEVICE_ID, deviceId);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const result = await exportAndUpload(email, deviceId, accessToken);
|
|
41
|
+
|
|
42
|
+
if (!result.success) {
|
|
43
|
+
return NextResponse.json({ error: result.error }, { status: 500 });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { setSetting } = await import("@/lib/settings/helpers");
|
|
47
|
+
await setSetting(SETTINGS_KEYS.LAST_SYNC_AT, new Date().toISOString());
|
|
48
|
+
|
|
49
|
+
return NextResponse.json({
|
|
50
|
+
success: true,
|
|
51
|
+
blobPath: result.blobPath,
|
|
52
|
+
sizeBytes: result.sizeBytes,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { licenseManager } from "@/lib/license/manager";
|
|
3
|
+
import { downloadAndRestore } from "@/lib/sync/cloud-sync";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* POST /api/sync/restore
|
|
7
|
+
* Download the latest snapshot, decrypt, and restore.
|
|
8
|
+
* Requires Operator+ tier. Creates a safety backup first.
|
|
9
|
+
*/
|
|
10
|
+
export async function POST() {
|
|
11
|
+
if (!licenseManager.isFeatureAllowed("cloud-sync")) {
|
|
12
|
+
return NextResponse.json(
|
|
13
|
+
{ error: "Cloud sync requires Operator tier or above" },
|
|
14
|
+
{ status: 402 }
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const email = licenseManager.getStatus().email;
|
|
19
|
+
if (!email) {
|
|
20
|
+
return NextResponse.json(
|
|
21
|
+
{ error: "No license email — activate your license first" },
|
|
22
|
+
{ status: 400 }
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const result = await downloadAndRestore(email);
|
|
27
|
+
|
|
28
|
+
if (!result.success) {
|
|
29
|
+
return NextResponse.json({ error: result.error }, { status: 500 });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return NextResponse.json({
|
|
33
|
+
success: true,
|
|
34
|
+
sizeBytes: result.sizeBytes,
|
|
35
|
+
message: "Database restored. Restart the app to apply changes.",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { licenseManager } from "@/lib/license/manager";
|
|
3
|
+
import { listSyncSessions } from "@/lib/sync/cloud-sync";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GET /api/sync/sessions
|
|
7
|
+
* List recent sync sessions for the current user.
|
|
8
|
+
*/
|
|
9
|
+
export async function GET() {
|
|
10
|
+
if (!licenseManager.isFeatureAllowed("cloud-sync")) {
|
|
11
|
+
return NextResponse.json(
|
|
12
|
+
{ error: "Cloud sync requires Operator tier or above" },
|
|
13
|
+
{ status: 402 }
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const email = licenseManager.getStatus().email;
|
|
18
|
+
if (!email) {
|
|
19
|
+
return NextResponse.json({ sessions: [] });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const sessions = await listSyncSessions(email);
|
|
23
|
+
return NextResponse.json({ sessions });
|
|
24
|
+
}
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
enforceTaskBudgetGuardrails,
|
|
11
11
|
} from "@/lib/settings/budget-guardrails";
|
|
12
12
|
import { ensureFreshScan } from "@/lib/environment/auto-scan";
|
|
13
|
+
import { getAllExecutions } from "@/lib/agents/execution-manager";
|
|
14
|
+
import { licenseManager } from "@/lib/license/manager";
|
|
13
15
|
|
|
14
16
|
export async function POST(
|
|
15
17
|
_req: NextRequest,
|
|
@@ -93,6 +95,25 @@ export async function POST(
|
|
|
93
95
|
return NextResponse.json({ error: compatibilityError }, { status: 400 });
|
|
94
96
|
}
|
|
95
97
|
|
|
98
|
+
// Pre-check parallel workflow limit before fire-and-forget
|
|
99
|
+
const parallelLimit = licenseManager.getLimit("parallelWorkflows");
|
|
100
|
+
if (Number.isFinite(parallelLimit) && getAllExecutions().size >= parallelLimit) {
|
|
101
|
+
// Revert task to queued since we can't execute it
|
|
102
|
+
db.update(tasks)
|
|
103
|
+
.set({ status: "queued", updatedAt: new Date() })
|
|
104
|
+
.where(eq(tasks.id, id))
|
|
105
|
+
.run();
|
|
106
|
+
return NextResponse.json(
|
|
107
|
+
{
|
|
108
|
+
error: `Parallel workflow limit reached (${getAllExecutions().size}/${parallelLimit}). Wait for a running task to finish or upgrade.`,
|
|
109
|
+
limitType: "parallelWorkflows",
|
|
110
|
+
current: getAllExecutions().size,
|
|
111
|
+
max: parallelLimit,
|
|
112
|
+
},
|
|
113
|
+
{ status: 429 }
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
96
117
|
// Fire-and-forget — task already marked as running
|
|
97
118
|
executeTaskWithAgent(id, task.assignedAgent ?? DEFAULT_AGENT_RUNTIME).catch(
|
|
98
119
|
(err) => console.error(`Task ${id} execution error:`, err)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { createClient } from "@supabase/supabase-js";
|
|
3
|
+
import { licenseManager } from "@/lib/license/manager";
|
|
4
|
+
import { validateLicenseWithCloud } from "@/lib/license/cloud-validation";
|
|
5
|
+
import { sendUpgradeConfirmation } from "@/lib/billing/email";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_SUPABASE_URL = "https://yznantjbmacbllhcyzwc.supabase.co";
|
|
8
|
+
const DEFAULT_SUPABASE_ANON_KEY =
|
|
9
|
+
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl6bmFudGpibWFjYmxsaGN5endjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzI1MDg1ODMsImV4cCI6MjA4ODA4NDU4M30.i-P7MXpR1_emBjhUkzbFeSX7fgjgPDv90_wkqF7sW3Y";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* GET /auth/callback
|
|
13
|
+
*
|
|
14
|
+
* Handles the Supabase magic link redirect. Exchanges the auth code
|
|
15
|
+
* for a session, validates the user's license, and redirects to settings.
|
|
16
|
+
*
|
|
17
|
+
* Flow:
|
|
18
|
+
* 1. User clicks magic link in email
|
|
19
|
+
* 2. Supabase redirects here with ?code=...
|
|
20
|
+
* 3. We exchange the code for a session (gets user email)
|
|
21
|
+
* 4. Validate license against cloud (check if they have a paid subscription)
|
|
22
|
+
* 5. Activate the license locally if found
|
|
23
|
+
* 6. Redirect to /settings with success indicator
|
|
24
|
+
*/
|
|
25
|
+
export async function GET(req: NextRequest) {
|
|
26
|
+
const { searchParams } = req.nextUrl;
|
|
27
|
+
const code = searchParams.get("code");
|
|
28
|
+
|
|
29
|
+
if (!code) {
|
|
30
|
+
return NextResponse.redirect(new URL("/settings?auth=error", req.url));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const url = process.env.NEXT_PUBLIC_SUPABASE_URL || DEFAULT_SUPABASE_URL;
|
|
34
|
+
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || DEFAULT_SUPABASE_ANON_KEY;
|
|
35
|
+
|
|
36
|
+
const supabase = createClient(url, anonKey, {
|
|
37
|
+
auth: { persistSession: false },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Exchange the code for a session
|
|
41
|
+
const { data, error } = await supabase.auth.exchangeCodeForSession(code);
|
|
42
|
+
|
|
43
|
+
if (error || !data.session) {
|
|
44
|
+
console.error("[auth/callback] Code exchange failed:", error?.message);
|
|
45
|
+
return NextResponse.redirect(new URL("/settings?auth=error", req.url));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const email = data.session.user.email;
|
|
49
|
+
if (!email) {
|
|
50
|
+
return NextResponse.redirect(new URL("/settings?auth=error", req.url));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Validate license against cloud — check if this email has a paid subscription
|
|
54
|
+
const validation = await validateLicenseWithCloud(email);
|
|
55
|
+
|
|
56
|
+
if (validation.valid && validation.tier !== "community") {
|
|
57
|
+
// User has a paid subscription — activate locally
|
|
58
|
+
licenseManager.activate({
|
|
59
|
+
tier: validation.tier,
|
|
60
|
+
email,
|
|
61
|
+
expiresAt: validation.expiresAt,
|
|
62
|
+
});
|
|
63
|
+
// Send upgrade confirmation email (fire-and-forget)
|
|
64
|
+
sendUpgradeConfirmation(email, validation.tier).catch(() => {});
|
|
65
|
+
} else {
|
|
66
|
+
// No paid subscription — still link the email for future activation
|
|
67
|
+
// This sets the email so cloud sync and marketplace work when they upgrade
|
|
68
|
+
const currentTier = licenseManager.getTierFromDb();
|
|
69
|
+
if (currentTier === "community") {
|
|
70
|
+
// Just store the email association without changing tier
|
|
71
|
+
licenseManager.activate({
|
|
72
|
+
tier: "community",
|
|
73
|
+
email,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return NextResponse.redirect(new URL("/settings?auth=success", req.url));
|
|
79
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { PageShell } from "@/components/shared/page-shell";
|
|
2
|
+
import { MarketplaceBrowser } from "@/components/marketplace/marketplace-browser";
|
|
3
|
+
import { licenseManager } from "@/lib/license/manager";
|
|
4
|
+
import { canAccessFeature } from "@/lib/license/features";
|
|
5
|
+
|
|
6
|
+
export const dynamic = "force-dynamic";
|
|
7
|
+
|
|
8
|
+
export default function MarketplacePage() {
|
|
9
|
+
// Use getTierFromDb() for Server Components (Turbopack module instance separation)
|
|
10
|
+
const tier = licenseManager.getTierFromDb();
|
|
11
|
+
const canImport = canAccessFeature(tier, "marketplace-import");
|
|
12
|
+
const canPublish = canAccessFeature(tier, "marketplace-publish");
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<PageShell title="Marketplace" description="Browse and import workflow blueprints">
|
|
16
|
+
<MarketplaceBrowser canImport={canImport} canPublish={canPublish} />
|
|
17
|
+
</PageShell>
|
|
18
|
+
);
|
|
19
|
+
}
|
package/src/app/page.tsx
CHANGED
|
@@ -12,6 +12,8 @@ import { QuickActions } from "@/components/dashboard/quick-actions";
|
|
|
12
12
|
import { RecentProjects } from "@/components/dashboard/recent-projects";
|
|
13
13
|
import type { RecentProject } from "@/components/dashboard/recent-projects";
|
|
14
14
|
import { WelcomeLanding } from "@/components/dashboard/welcome-landing";
|
|
15
|
+
import { EmailCaptureCard } from "@/components/onboarding/email-capture-card";
|
|
16
|
+
import { ActivationChecklist } from "@/components/onboarding/activation-checklist";
|
|
15
17
|
import {
|
|
16
18
|
getCompletionsByDay,
|
|
17
19
|
getTaskCreationsByDay,
|
|
@@ -104,7 +106,8 @@ export default async function HomePage() {
|
|
|
104
106
|
if (isFreshInstance) {
|
|
105
107
|
return (
|
|
106
108
|
<div className="bg-background min-h-screen p-4 sm:p-6">
|
|
107
|
-
<div className="surface-page-shell min-h-[calc(100dvh-2rem)] rounded-xl p-5 sm:p-6 lg:p-7">
|
|
109
|
+
<div className="surface-page-shell min-h-[calc(100dvh-2rem)] rounded-xl p-5 sm:p-6 lg:p-7 space-y-6">
|
|
110
|
+
<EmailCaptureCard />
|
|
108
111
|
<WelcomeLanding />
|
|
109
112
|
</div>
|
|
110
113
|
</div>
|
|
@@ -231,8 +234,9 @@ export default async function HomePage() {
|
|
|
231
234
|
<div className="lg:col-span-3">
|
|
232
235
|
<RecentProjects projects={recentProjectData} />
|
|
233
236
|
</div>
|
|
234
|
-
<div className="lg:col-span-2">
|
|
237
|
+
<div className="lg:col-span-2 space-y-6">
|
|
235
238
|
<QuickActions />
|
|
239
|
+
<ActivationChecklist />
|
|
236
240
|
</div>
|
|
237
241
|
</div>
|
|
238
242
|
</div>
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { SubscriptionSection } from "@/components/settings/subscription-section";
|
|
2
|
+
import { CloudAccountSection } from "@/components/settings/cloud-account-section";
|
|
3
|
+
import { CloudSyncSection } from "@/components/settings/cloud-sync-section";
|
|
4
|
+
import { TelemetrySection } from "@/components/settings/telemetry-section";
|
|
1
5
|
import { ProvidersAndRuntimesSection } from "@/components/settings/providers-runtimes-section";
|
|
2
6
|
import { PermissionsSections } from "@/components/settings/permissions-sections";
|
|
3
7
|
import { DataManagementSection } from "@/components/settings/data-management-section";
|
|
@@ -18,6 +22,9 @@ export default function SettingsPage() {
|
|
|
18
22
|
return (
|
|
19
23
|
<PageShell title="Settings" description="Manage your Stagent configuration">
|
|
20
24
|
<div className="space-y-6">
|
|
25
|
+
<SubscriptionSection />
|
|
26
|
+
<CloudAccountSection />
|
|
27
|
+
<CloudSyncSection />
|
|
21
28
|
<ProvidersAndRuntimesSection />
|
|
22
29
|
<OllamaSection />
|
|
23
30
|
<ChatSettingsSection />
|
|
@@ -29,6 +36,7 @@ export default function SettingsPage() {
|
|
|
29
36
|
<BudgetGuardrailsSection />
|
|
30
37
|
<PermissionsSections />
|
|
31
38
|
<DatabaseSnapshotsSection />
|
|
39
|
+
<TelemetrySection />
|
|
32
40
|
<DataManagementSection />
|
|
33
41
|
</div>
|
|
34
42
|
</PageShell>
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
Card,
|
|
6
|
+
CardContent,
|
|
7
|
+
CardHeader,
|
|
8
|
+
CardTitle,
|
|
9
|
+
} from "@/components/ui/card";
|
|
10
|
+
import {
|
|
11
|
+
AreaChart,
|
|
12
|
+
Area,
|
|
13
|
+
LineChart,
|
|
14
|
+
Line,
|
|
15
|
+
XAxis,
|
|
16
|
+
YAxis,
|
|
17
|
+
CartesianGrid,
|
|
18
|
+
Tooltip,
|
|
19
|
+
ResponsiveContainer,
|
|
20
|
+
} from "recharts";
|
|
21
|
+
import type {
|
|
22
|
+
OutcomeCount,
|
|
23
|
+
TrendPoint,
|
|
24
|
+
CostTrendPoint,
|
|
25
|
+
ProfileStats,
|
|
26
|
+
} from "@/lib/analytics/queries";
|
|
27
|
+
|
|
28
|
+
interface AnalyticsDashboardProps {
|
|
29
|
+
outcomes: OutcomeCount;
|
|
30
|
+
successTrend: TrendPoint[];
|
|
31
|
+
costTrend: CostTrendPoint[];
|
|
32
|
+
leaderboard: ProfileStats[];
|
|
33
|
+
hoursSaved: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function StatCard({
|
|
37
|
+
label,
|
|
38
|
+
value,
|
|
39
|
+
sub,
|
|
40
|
+
}: {
|
|
41
|
+
label: string;
|
|
42
|
+
value: string;
|
|
43
|
+
sub?: string;
|
|
44
|
+
}) {
|
|
45
|
+
return (
|
|
46
|
+
<div className="surface-card-muted rounded-lg p-4 text-center">
|
|
47
|
+
<p className="text-2xl font-bold">{value}</p>
|
|
48
|
+
<p className="text-xs text-muted-foreground">{label}</p>
|
|
49
|
+
{sub && <p className="text-[10px] text-muted-foreground mt-1">{sub}</p>}
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function AnalyticsDashboard({
|
|
55
|
+
outcomes,
|
|
56
|
+
successTrend,
|
|
57
|
+
costTrend,
|
|
58
|
+
leaderboard,
|
|
59
|
+
hoursSaved,
|
|
60
|
+
}: AnalyticsDashboardProps) {
|
|
61
|
+
const [hourlyRate, setHourlyRate] = useState(() => {
|
|
62
|
+
if (typeof window === "undefined") return 75;
|
|
63
|
+
const saved = localStorage.getItem("stagent-hourly-rate");
|
|
64
|
+
return saved ? parseInt(saved, 10) : 75;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
function updateHourlyRate(rate: number) {
|
|
68
|
+
setHourlyRate(rate);
|
|
69
|
+
if (typeof window !== "undefined") {
|
|
70
|
+
localStorage.setItem("stagent-hourly-rate", String(rate));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const valueGenerated = Math.round(hoursSaved * hourlyRate);
|
|
75
|
+
const totalCostUsd = leaderboard.reduce((sum, p) => sum + p.totalCostMicros, 0) / 1_000_000;
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className="space-y-6">
|
|
79
|
+
{/* ROI Summary Strip */}
|
|
80
|
+
<div className="grid grid-cols-4 gap-3">
|
|
81
|
+
<StatCard label="Tasks Completed" value={String(outcomes.completed)} sub="Last 30 days" />
|
|
82
|
+
<StatCard label="Success Rate" value={`${outcomes.successRate}%`} sub={`${outcomes.failed} failed`} />
|
|
83
|
+
<StatCard label="Hours Saved" value={`${hoursSaved}h`} sub={`~$${valueGenerated} value`} />
|
|
84
|
+
<StatCard label="Total Cost" value={`$${totalCostUsd.toFixed(2)}`} sub="Agent spend" />
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
{/* Charts Row */}
|
|
88
|
+
<div className="grid grid-cols-2 gap-4">
|
|
89
|
+
{/* Success Rate Trend */}
|
|
90
|
+
<Card>
|
|
91
|
+
<CardHeader className="pb-2">
|
|
92
|
+
<CardTitle className="text-sm">Task Outcomes (30d)</CardTitle>
|
|
93
|
+
</CardHeader>
|
|
94
|
+
<CardContent>
|
|
95
|
+
<ResponsiveContainer width="100%" height={200}>
|
|
96
|
+
<AreaChart data={successTrend}>
|
|
97
|
+
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
|
98
|
+
<XAxis dataKey="date" tick={{ fontSize: 10 }} tickFormatter={(v) => v.slice(5)} />
|
|
99
|
+
<YAxis tick={{ fontSize: 10 }} />
|
|
100
|
+
<Tooltip />
|
|
101
|
+
<Area type="monotone" dataKey="completed" stackId="1" fill="oklch(0.7 0.15 250)" stroke="oklch(0.6 0.2 250)" fillOpacity={0.3} />
|
|
102
|
+
<Area type="monotone" dataKey="failed" stackId="1" fill="oklch(0.7 0.15 25)" stroke="oklch(0.6 0.2 25)" fillOpacity={0.3} />
|
|
103
|
+
</AreaChart>
|
|
104
|
+
</ResponsiveContainer>
|
|
105
|
+
</CardContent>
|
|
106
|
+
</Card>
|
|
107
|
+
|
|
108
|
+
{/* Cost per Outcome Trend */}
|
|
109
|
+
<Card>
|
|
110
|
+
<CardHeader className="pb-2">
|
|
111
|
+
<CardTitle className="text-sm">Cost per Task (30d)</CardTitle>
|
|
112
|
+
</CardHeader>
|
|
113
|
+
<CardContent>
|
|
114
|
+
<ResponsiveContainer width="100%" height={200}>
|
|
115
|
+
<LineChart data={costTrend}>
|
|
116
|
+
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
|
117
|
+
<XAxis dataKey="date" tick={{ fontSize: 10 }} tickFormatter={(v) => v.slice(5)} />
|
|
118
|
+
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v) => `$${(v / 1_000_000).toFixed(2)}`} />
|
|
119
|
+
<Tooltip formatter={(v: number) => [`$${(v / 1_000_000).toFixed(4)}`, "Avg Cost"]} />
|
|
120
|
+
<Line type="monotone" dataKey="avgCostMicros" stroke="oklch(0.6 0.2 250)" strokeWidth={2} dot={false} />
|
|
121
|
+
</LineChart>
|
|
122
|
+
</ResponsiveContainer>
|
|
123
|
+
</CardContent>
|
|
124
|
+
</Card>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
{/* Profile Leaderboard */}
|
|
128
|
+
<Card>
|
|
129
|
+
<CardHeader className="pb-2">
|
|
130
|
+
<CardTitle className="text-sm">Profile Leaderboard (30d)</CardTitle>
|
|
131
|
+
</CardHeader>
|
|
132
|
+
<CardContent>
|
|
133
|
+
{leaderboard.length === 0 ? (
|
|
134
|
+
<p className="text-sm text-muted-foreground text-center py-6">
|
|
135
|
+
No agent profiles have run tasks yet.
|
|
136
|
+
</p>
|
|
137
|
+
) : (
|
|
138
|
+
<div className="overflow-x-auto">
|
|
139
|
+
<table className="w-full text-sm">
|
|
140
|
+
<thead>
|
|
141
|
+
<tr className="border-b text-left">
|
|
142
|
+
<th className="pb-2 font-medium">Profile</th>
|
|
143
|
+
<th className="pb-2 font-medium text-right">Completed</th>
|
|
144
|
+
<th className="pb-2 font-medium text-right">Failed</th>
|
|
145
|
+
<th className="pb-2 font-medium text-right">Success</th>
|
|
146
|
+
<th className="pb-2 font-medium text-right">Cost</th>
|
|
147
|
+
<th className="pb-2 font-medium text-right">Avg Duration</th>
|
|
148
|
+
</tr>
|
|
149
|
+
</thead>
|
|
150
|
+
<tbody>
|
|
151
|
+
{leaderboard.map((p) => (
|
|
152
|
+
<tr key={p.profileId} className="border-b border-border/50">
|
|
153
|
+
<td className="py-2 font-mono text-xs">{p.profileId}</td>
|
|
154
|
+
<td className="py-2 text-right">{p.completed}</td>
|
|
155
|
+
<td className="py-2 text-right text-muted-foreground">{p.failed}</td>
|
|
156
|
+
<td className="py-2 text-right">{p.successRate}%</td>
|
|
157
|
+
<td className="py-2 text-right">${(p.totalCostMicros / 1_000_000).toFixed(2)}</td>
|
|
158
|
+
<td className="py-2 text-right text-muted-foreground">
|
|
159
|
+
{p.avgDurationMs > 0 ? `${(p.avgDurationMs / 1000).toFixed(1)}s` : "—"}
|
|
160
|
+
</td>
|
|
161
|
+
</tr>
|
|
162
|
+
))}
|
|
163
|
+
</tbody>
|
|
164
|
+
</table>
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
</CardContent>
|
|
168
|
+
</Card>
|
|
169
|
+
|
|
170
|
+
{/* ROI Calculator */}
|
|
171
|
+
<Card>
|
|
172
|
+
<CardHeader className="pb-2">
|
|
173
|
+
<CardTitle className="text-sm">ROI Calculator</CardTitle>
|
|
174
|
+
</CardHeader>
|
|
175
|
+
<CardContent>
|
|
176
|
+
<div className="flex items-center gap-4">
|
|
177
|
+
<label className="text-xs text-muted-foreground whitespace-nowrap">
|
|
178
|
+
Your hourly rate:
|
|
179
|
+
</label>
|
|
180
|
+
<div className="flex items-center gap-1">
|
|
181
|
+
<span className="text-sm">$</span>
|
|
182
|
+
<input
|
|
183
|
+
type="number"
|
|
184
|
+
value={hourlyRate}
|
|
185
|
+
onChange={(e) => updateHourlyRate(Math.max(1, parseInt(e.target.value, 10) || 1))}
|
|
186
|
+
className="w-20 rounded-md border px-2 py-1 text-sm"
|
|
187
|
+
min={1}
|
|
188
|
+
/>
|
|
189
|
+
<span className="text-xs text-muted-foreground">/hr</span>
|
|
190
|
+
</div>
|
|
191
|
+
<div className="text-sm">
|
|
192
|
+
<span className="font-medium">${valueGenerated.toLocaleString()}</span>
|
|
193
|
+
<span className="text-muted-foreground"> estimated value from {hoursSaved}h saved</span>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
</CardContent>
|
|
197
|
+
</Card>
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { TrendingUp, Trophy, Coins, Sparkles } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Badge } from "@/components/ui/badge";
|
|
7
|
+
import { TIER_PRICING } from "@/lib/license/tier-limits";
|
|
8
|
+
|
|
9
|
+
function ValueProp({
|
|
10
|
+
icon: Icon,
|
|
11
|
+
title,
|
|
12
|
+
description,
|
|
13
|
+
}: {
|
|
14
|
+
icon: typeof TrendingUp;
|
|
15
|
+
title: string;
|
|
16
|
+
description: string;
|
|
17
|
+
}) {
|
|
18
|
+
return (
|
|
19
|
+
<div className="space-y-1.5">
|
|
20
|
+
<div className="flex items-center gap-2">
|
|
21
|
+
<div className="flex items-center justify-center w-7 h-7 rounded-lg border bg-background">
|
|
22
|
+
<Icon className="h-3.5 w-3.5 text-primary" />
|
|
23
|
+
</div>
|
|
24
|
+
<span className="text-xs font-semibold tracking-tight">{title}</span>
|
|
25
|
+
</div>
|
|
26
|
+
<p className="text-[11px] leading-relaxed text-muted-foreground pl-9">
|
|
27
|
+
{description}
|
|
28
|
+
</p>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Analytics-specific upgrade CTA card.
|
|
35
|
+
* Replaces the generic PremiumGateOverlay lock card with
|
|
36
|
+
* benefit-oriented messaging and value props.
|
|
37
|
+
*/
|
|
38
|
+
export function AnalyticsGateCard() {
|
|
39
|
+
const price = TIER_PRICING.operator.monthly;
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="w-full max-w-lg mx-auto">
|
|
43
|
+
<div className="surface-card rounded-xl border shadow-lg p-8 space-y-6">
|
|
44
|
+
{/* Header */}
|
|
45
|
+
<div className="space-y-2 text-center">
|
|
46
|
+
<div className="flex items-center justify-center gap-2 mb-3">
|
|
47
|
+
<Sparkles className="h-4 w-4 text-primary" />
|
|
48
|
+
<Badge variant="secondary" className="text-[10px] font-medium tracking-wide uppercase">
|
|
49
|
+
Operator Feature
|
|
50
|
+
</Badge>
|
|
51
|
+
</div>
|
|
52
|
+
<h2 className="text-xl font-bold tracking-tight">
|
|
53
|
+
See what your AI agents are worth
|
|
54
|
+
</h2>
|
|
55
|
+
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
|
|
56
|
+
Turn raw execution data into actionable ROI insights
|
|
57
|
+
</p>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
{/* Value Props */}
|
|
61
|
+
<div className="space-y-4 py-2">
|
|
62
|
+
<ValueProp
|
|
63
|
+
icon={TrendingUp}
|
|
64
|
+
title="ROI Tracking"
|
|
65
|
+
description="Know exactly how much time and money your agents save with automated value calculations"
|
|
66
|
+
/>
|
|
67
|
+
<ValueProp
|
|
68
|
+
icon={Trophy}
|
|
69
|
+
title="Profile Leaderboard"
|
|
70
|
+
description="See which agent profiles deliver the best results and optimize your team"
|
|
71
|
+
/>
|
|
72
|
+
<ValueProp
|
|
73
|
+
icon={Coins}
|
|
74
|
+
title="Cost Efficiency"
|
|
75
|
+
description="Track cost-per-outcome trends to find the sweet spot between quality and spend"
|
|
76
|
+
/>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
{/* Pricing + CTA */}
|
|
80
|
+
<div className="space-y-3 pt-2">
|
|
81
|
+
<div className="flex items-baseline justify-center gap-1.5">
|
|
82
|
+
<span className="text-2xl font-bold">${price}</span>
|
|
83
|
+
<span className="text-xs text-muted-foreground">/mo</span>
|
|
84
|
+
<span className="text-xs text-muted-foreground mx-1">·</span>
|
|
85
|
+
<span className="text-xs text-muted-foreground">Operator tier</span>
|
|
86
|
+
</div>
|
|
87
|
+
<Button className="w-full" size="lg" asChild>
|
|
88
|
+
<Link href="/settings?highlight=operator">
|
|
89
|
+
Unlock Analytics
|
|
90
|
+
</Link>
|
|
91
|
+
</Button>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{/* Social proof / objection handler */}
|
|
95
|
+
<p className="text-[11px] text-center text-muted-foreground">
|
|
96
|
+
Derived from data you already have — zero setup required
|
|
97
|
+
</p>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Download, Star } from "lucide-react";
|
|
4
|
+
import { Badge } from "@/components/ui/badge";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import type { MarketplaceBlueprint } from "@/lib/marketplace/marketplace-client";
|
|
7
|
+
|
|
8
|
+
interface BlueprintCardProps {
|
|
9
|
+
blueprint: MarketplaceBlueprint;
|
|
10
|
+
canImport: boolean;
|
|
11
|
+
onImport?: (id: string) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function BlueprintCard({ blueprint, canImport, onImport }: BlueprintCardProps) {
|
|
15
|
+
return (
|
|
16
|
+
<div className="surface-card-muted rounded-lg border p-4 space-y-3">
|
|
17
|
+
<div className="flex items-start justify-between gap-2">
|
|
18
|
+
<div className="space-y-1">
|
|
19
|
+
<h3 className="text-sm font-medium">{blueprint.title}</h3>
|
|
20
|
+
{blueprint.description && (
|
|
21
|
+
<p className="text-xs text-muted-foreground line-clamp-2">
|
|
22
|
+
{blueprint.description}
|
|
23
|
+
</p>
|
|
24
|
+
)}
|
|
25
|
+
</div>
|
|
26
|
+
<Badge variant="secondary" className="text-[10px] shrink-0">
|
|
27
|
+
{blueprint.category}
|
|
28
|
+
</Badge>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<div className="flex items-center justify-between">
|
|
32
|
+
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
|
33
|
+
<span className="flex items-center gap-1">
|
|
34
|
+
<Download className="h-3 w-3" />
|
|
35
|
+
{blueprint.install_count}
|
|
36
|
+
</span>
|
|
37
|
+
{blueprint.success_rate > 0 && (
|
|
38
|
+
<span className="flex items-center gap-1">
|
|
39
|
+
<Star className="h-3 w-3" />
|
|
40
|
+
{Math.round(blueprint.success_rate * 100)}%
|
|
41
|
+
</span>
|
|
42
|
+
)}
|
|
43
|
+
{blueprint.tags?.length > 0 && (
|
|
44
|
+
<span>{blueprint.tags.slice(0, 2).join(", ")}</span>
|
|
45
|
+
)}
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
{canImport && onImport && (
|
|
49
|
+
<Button
|
|
50
|
+
size="sm"
|
|
51
|
+
variant="outline"
|
|
52
|
+
onClick={() => onImport(blueprint.id)}
|
|
53
|
+
>
|
|
54
|
+
<Download className="h-3 w-3 mr-1" />
|
|
55
|
+
Import
|
|
56
|
+
</Button>
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|