From 682889e8fb9894c25a1529cbcb8cc26cb2a7f0fb Mon Sep 17 00:00:00 2001 From: hallelx2 Date: Sat, 4 Jul 2026 00:52:14 +0100 Subject: [PATCH] @ Standardize pricing + wire real Analytics/Usage pages Pricing: new lib/pricing.ts is the single source of truth for the tier ladder (free/pro/scale/enterprise) with the agreed numbers (pro 500 docs/20 GB, scale $99 5,000 docs/100 GB). The marketing Pricing section and the dashboard billing page both render from it, so the site, the app, and the CP quota plans can no longer drift. Adds the Scale tier and a 4-up grid on both surfaces. Analytics + Usage (were fully mocked): both pages now fetch real data. - /api/dashboard/analytics and /api/dashboard/usage proxy to the CP analytics + quota endpoints for the caller org. - Analytics: live summary (requests, success rate, avg latency, tokens), usage-by-source breakdown, daily request-volume chart, and a recent- calls feed with per-request source attribution. - Usage: real quota meters (queries / documents / tokens), usage-by- source bars, and plan ceilings derived from lib/pricing.ts. - lib/usage-sources.ts gives web/sdk/mcp/api consistent labels + colours (no purple, per brand). @ --- .../(dashboard)/dashboard/analytics/page.tsx | 435 ++++++++++++------ .../(dashboard)/dashboard/billing/page.tsx | 100 +--- .../app/(dashboard)/dashboard/usage/page.tsx | 401 ++++++++-------- apps/web/app/api/dashboard/analytics/route.ts | 35 ++ .../api/dashboard/analytics/usage/route.ts | 52 ++- apps/web/app/api/dashboard/usage/route.ts | 40 ++ apps/web/components/Pricing.tsx | 78 +--- apps/web/lib/pricing.ts | 173 +++++++ apps/web/lib/usage-sources.ts | 50 ++ 9 files changed, 903 insertions(+), 461 deletions(-) create mode 100644 apps/web/app/api/dashboard/analytics/route.ts create mode 100644 apps/web/app/api/dashboard/usage/route.ts create mode 100644 apps/web/lib/pricing.ts create mode 100644 apps/web/lib/usage-sources.ts diff --git a/apps/web/app/(dashboard)/dashboard/analytics/page.tsx b/apps/web/app/(dashboard)/dashboard/analytics/page.tsx index 83eda65..1740db0 100644 --- a/apps/web/app/(dashboard)/dashboard/analytics/page.tsx +++ b/apps/web/app/(dashboard)/dashboard/analytics/page.tsx @@ -1,11 +1,13 @@ +"use client"; + +import { useEffect, useState } from "react"; import { Activity, - ArrowDownRight, - ArrowUpRight, BarChart3, CheckCircle, Clock, - FileText, + Layers, + Loader2, } from "lucide-react"; import { @@ -25,81 +27,174 @@ import { TableRow, } from "@/components/ui/table"; import { PageHeader } from "@/components/dashboard/PageHeader"; +import { sourceMeta } from "@/lib/usage-sources"; -const STATS = [ - { - label: "Total requests", - value: "48,291", - delta: "+12.4%", - deltaUp: true, - description: "vs. last 30 days", - icon: Activity, - tone: "from-brand-blue/12 to-brand-blue/0", - }, - { - label: "Avg latency", - value: "142ms", - delta: "-8ms", - deltaUp: true, - description: "p50 response time", - icon: Clock, - tone: "from-emerald-400/12 to-emerald-400/0", - }, - { - label: "Success rate", - value: "99.7%", - delta: "+0.1%", - deltaUp: true, - description: "2xx of all responses", - icon: CheckCircle, - tone: "from-amber-400/12 to-amber-400/0", - }, - { - label: "Active documents", - value: "21", - delta: "+3", - deltaUp: true, - description: "with ready status", - icon: FileText, - tone: "from-brand-pink/12 to-brand-pink/0", - }, -]; - -const RECENT_CALLS = [ - { id: "req_1a2b3c", endpoint: "/v1/query", method: "POST", status: 200, latency: "128ms", ago: "2m ago" }, - { id: "req_4d5e6f", endpoint: "/v1/documents", method: "GET", status: 200, latency: "45ms", ago: "5m ago" }, - { id: "req_7g8h9i", endpoint: "/v1/query", method: "POST", status: 200, latency: "312ms", ago: "8m ago" }, - { id: "req_0j1k2l", endpoint: "/v1/documents/upload", method: "POST", status: 201, latency: "1.2s", ago: "12m ago" }, - { id: "req_3m4n5o", endpoint: "/v1/query", method: "POST", status: 429, latency: "12ms", ago: "15m ago" }, - { id: "req_6p7q8r", endpoint: "/v1/sections", method: "GET", status: 200, latency: "67ms", ago: "22m ago" }, -]; - -const SPARK = [12, 18, 14, 22, 20, 26, 24, 30, 36, 32, 40, 38, 44, 42, 50, 56, 52, 60, 64, 70, 66, 72, 78, 74, 80, 86, 82, 88, 92, 96]; +interface SourceUsage { + source: string; + requests: number; + queries: number; + documents: number; + tokens_in: number; + tokens_out: number; + error_requests: number; +} +interface DailyPoint { + date: string; + requests: number; + errors: number; +} +interface RecentEvent { + endpoint: string; + method: string; + status_code: number; + source: string; + duration_ms: number; + created_at: string; +} +interface AnalyticsResponse { + from: string; + to: string; + summary: { + total_requests: number; + success_requests: number; + error_requests: number; + success_rate: number; + avg_duration_ms: number; + tokens_in: number; + tokens_out: number; + }; + daily: DailyPoint[]; + by_source: SourceUsage[]; + recent: RecentEvent[]; +} function statusTone(status: number) { - if (status >= 200 && status < 300) return "bg-emerald-500/10 text-emerald-600 border-emerald-500/20"; - if (status >= 400 && status < 500) return "bg-amber-500/10 text-amber-600 border-amber-500/20"; + if (status >= 200 && status < 300) + return "bg-emerald-500/10 text-emerald-600 border-emerald-500/20"; + if (status >= 400 && status < 500) + return "bg-amber-500/10 text-amber-600 border-amber-500/20"; return "bg-red-500/10 text-red-600 border-red-500/20"; } +function fmt(n: number): string { + return n.toLocaleString(); +} + +function timeAgo(iso: string): string { + const then = new Date(iso).getTime(); + const secs = Math.max(0, Math.round((Date.now() - then) / 1000)); + if (secs < 60) return `${secs}s ago`; + const mins = Math.round(secs / 60); + if (mins < 60) return `${mins}m ago`; + const hrs = Math.round(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.round(hrs / 24)}d ago`; +} + export default function AnalyticsPage() { - const max = Math.max(...SPARK); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch("/api/dashboard/analytics"); + const json = await res.json(); + if (!res.ok) throw new Error(json?.error ?? "Failed to load analytics"); + if (!cancelled) setData(json as AnalyticsResponse); + } catch (e) { + if (!cancelled) + setError(e instanceof Error ? e.message : "Failed to load analytics"); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error || !data) { + return ( +
+ + + + {error ?? "No analytics available yet."} + + +
+ ); + } + + const { summary, daily, by_source, recent } = data; + const totalTokens = summary.tokens_in + summary.tokens_out; + const maxDaily = Math.max(1, ...daily.map((d) => d.requests)); + const maxSource = Math.max(1, ...by_source.map((s) => s.requests)); + const hasTraffic = summary.total_requests > 0; + + const stats = [ + { + label: "Total requests", + value: fmt(summary.total_requests), + description: `${data.from} → ${data.to}`, + icon: Activity, + tone: "from-brand-blue/12 to-brand-blue/0", + }, + { + label: "Success rate", + value: hasTraffic ? `${(summary.success_rate * 100).toFixed(1)}%` : "—", + description: `${fmt(summary.error_requests)} errors`, + icon: CheckCircle, + tone: "from-emerald-400/12 to-emerald-400/0", + }, + { + label: "Avg latency", + value: hasTraffic ? `${fmt(summary.avg_duration_ms)}ms` : "—", + description: "mean response time", + icon: Clock, + tone: "from-amber-400/12 to-amber-400/0", + }, + { + label: "Tokens used", + value: fmt(totalTokens), + description: `${fmt(summary.tokens_in)} in · ${fmt(summary.tokens_out)} out`, + icon: Layers, + tone: "from-brand-pink/12 to-brand-pink/0", + }, + ]; return (
{/* Stat cards */}
- {STATS.map((s) => { + {stats.map((s) => { const Icon = s.icon; - const DeltaIcon = s.deltaUp ? ArrowUpRight : ArrowDownRight; return ( - +
-
- - {s.value} - - - - {s.delta} - -
-

{s.description}

+ + {s.value} + +

+ {s.description} +

); })}
- {/* Chart */} + {/* Usage by source */} + + + + + Usage by source + + + Where your requests come from — dashboard, SDKs, MCP, and API keys. + + + + {by_source.map((s) => { + const meta = sourceMeta(s.source); + const pct = hasTraffic + ? Math.round((s.requests / summary.total_requests) * 100) + : 0; + return ( +
+
+ + + {meta.label} + + {meta.blurb} + + + + {fmt(s.requests)} req · {pct}% + +
+
+
+
+
+ {fmt(s.queries)} queries + {fmt(s.documents)} docs + {fmt(s.tokens_in + s.tokens_out)} tokens + {s.error_requests > 0 && ( + + {fmt(s.error_requests)} errors + + )} +
+
+ ); + })} + + + + {/* Request volume */} @@ -143,20 +283,28 @@ export default function AnalyticsPage() { -
- {SPARK.map((v, i) => ( -
- ))} -
-
- 30 days ago - Today -
+ {daily.length === 0 ? ( +
+ No requests in this period yet. +
+ ) : ( + <> +
+ {daily.map((d) => ( +
+ ))} +
+
+ {daily[0]?.date} + {daily[daily.length - 1]?.date} +
+ + )} @@ -171,47 +319,78 @@ export default function AnalyticsPage() {
- - - - Request ID - Endpoint - Method - Status - Latency - When - - - - {RECENT_CALLS.map((call) => ( - - - {call.id} - - - {call.endpoint} - - - - {call.method} - - - - - {call.status} - - - {call.latency} - - {call.ago} - + {recent.length === 0 ? ( +
+ No requests recorded yet. +
+ ) : ( +
+ + + + Endpoint + + + Method + + + Source + + + Status + + + Latency + + + When + - ))} - -
+ + + {recent.map((call, i) => { + const meta = sourceMeta(call.source); + return ( + + + {call.endpoint} + + + + {call.method} + + + + + + {meta.label} + + + + + {call.status_code} + + + + {call.duration_ms}ms + + + {timeAgo(call.created_at)} + + + ); + })} + + + )}
diff --git a/apps/web/app/(dashboard)/dashboard/billing/page.tsx b/apps/web/app/(dashboard)/dashboard/billing/page.tsx index 2c7458f..3931f1f 100644 --- a/apps/web/app/(dashboard)/dashboard/billing/page.tsx +++ b/apps/web/app/(dashboard)/dashboard/billing/page.tsx @@ -12,89 +12,27 @@ import { import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; -import { Check, CreditCard, Sparkles, Building2 } from "lucide-react"; +import { Check, CreditCard, Sparkles, Building2, Zap } from "lucide-react"; import { useSession } from "@/lib/auth-client"; +import { PLANS, type Plan } from "@/lib/pricing"; -interface PlanFeature { - label: string; - included: boolean; -} +const PLAN_ICONS: Record = { + free: , + pro: , + scale: , + enterprise: , +}; -interface PlanCard { - key: string; - name: string; - price: string; - period: string; - description: string; - features: PlanFeature[]; - cta: string; - popular?: boolean; - icon: React.ReactNode; +function ctaFor(key: string, current: string): string { + if (key === current) return "Current Plan"; + if (key === "enterprise") return "Contact Sales"; + return `Upgrade to ${PLANS.find((p) => p.key === key)?.name ?? key}`; } -const plans: PlanCard[] = [ - { - key: "free", - name: "Free", - price: "$0", - period: "forever", - description: "Perfect for exploring Vectorless and building prototypes.", - icon: , - features: [ - { label: "500 queries / month", included: true }, - { label: "500 pages ingested / month", included: true }, - { label: "25 documents stored", included: true }, - { label: "100 MB storage", included: true }, - { label: "20 queries / min rate limit", included: true }, - { label: "Community support", included: true }, - { label: "MCP server access", included: true }, - ], - cta: "Current Plan", - }, - { - key: "pro", - name: "Pro", - price: "$49", - period: "/ month", - description: - "For teams and individuals using Vectorless in production.", - icon: , - popular: true, - features: [ - { label: "20,000 queries / month", included: true }, - { label: "10,000 pages ingested / month", included: true }, - { label: "500 documents stored", included: true }, - { label: "10 GB storage", included: true }, - { label: "200 queries / min rate limit", included: true }, - { label: "Priority email support", included: true }, - { label: "OAuth app integrations", included: true }, - ], - cta: "Upgrade to Pro", - }, - { - key: "enterprise", - name: "Enterprise", - price: "Custom", - period: "", - description: - "For organizations with custom requirements, SLAs, and dedicated support.", - icon: , - features: [ - { label: "Unlimited queries", included: true }, - { label: "Unlimited ingestion", included: true }, - { label: "Unlimited documents", included: true }, - { label: "Unlimited storage", included: true }, - { label: "1,000 queries / min rate limit", included: true }, - { label: "Dedicated support + SLA", included: true }, - { label: "Custom integrations", included: true }, - ], - cta: "Contact Sales", - }, -]; - export default function BillingPage() { const { data: session } = useSession(); const [currentPlan] = useState("free"); // TODO: Fetch from API + const plans: Plan[] = PLANS; return (
@@ -133,7 +71,7 @@ export default function BillingPage() { {/* Plan Cards */} -
+
{plans.map((plan) => { const isCurrent = plan.key === currentPlan; @@ -156,7 +94,7 @@ export default function BillingPage() {
- {plan.icon} + {PLAN_ICONS[plan.key]}
{plan.name} @@ -181,13 +119,11 @@ export default function BillingPage() {
    {plan.features.map((feature) => (
  • - - {feature.label} - + {feature}
  • ))}
@@ -205,7 +141,7 @@ export default function BillingPage() { } disabled={isCurrent} > - {isCurrent ? "Current Plan" : plan.cta} + {ctaFor(plan.key, currentPlan)} diff --git a/apps/web/app/(dashboard)/dashboard/usage/page.tsx b/apps/web/app/(dashboard)/dashboard/usage/page.tsx index 130d8a6..13443bf 100644 --- a/apps/web/app/(dashboard)/dashboard/usage/page.tsx +++ b/apps/web/app/(dashboard)/dashboard/usage/page.tsx @@ -10,76 +10,76 @@ import { } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Progress } from "@/components/ui/progress"; -import { Loader2, Activity, FileUp, Search, TreePine } from "lucide-react"; -import { useSession } from "@/lib/auth-client"; +import { Loader2, Activity, FileText, Search, Layers } from "lucide-react"; +import { PLAN_BY_KEY } from "@/lib/pricing"; +import { sourceMeta } from "@/lib/usage-sources"; -interface UsageSummary { - queriesUsed: number; - queriesLimit: number; - ingestPagesUsed: number; - ingestPagesLimit: number; - documentsStored: number; - documentsLimit: number; +interface QuotaStatus { plan: string; + documents_used: number; + documents_limit: number; + queries_used: number; + queries_limit: number; + tokens_used: number; + tokens_limit: number; + allows_overage: boolean; +} +interface SourceUsage { + source: string; + requests: number; + queries: number; + documents: number; + tokens_in: number; + tokens_out: number; + error_requests: number; +} +interface UsageResponse { + quota: QuotaStatus; + bySource: SourceUsage[]; } -const PLAN_LIMITS: Record< - string, - { queries: number; ingest: number; documents: number } -> = { - free: { queries: 500, ingest: 500, documents: 25 }, - pro: { queries: 20_000, ingest: 10_000, documents: 500 }, - enterprise: { queries: Infinity, ingest: Infinity, documents: Infinity }, -}; - -function usagePercent(used: number, limit: number): number { - if (limit === Infinity || limit === 0) return 0; +function pct(used: number, limit: number): number { + if (limit === 0) return 0; // 0 = unlimited return Math.min(100, Math.round((used / limit) * 100)); } - -function formatNumber(n: number): string { - if (n === Infinity) return "Unlimited"; +function fmtLimit(n: number): string { + return n === 0 ? "Unlimited" : n.toLocaleString(); +} +function fmt(n: number): string { return n.toLocaleString(); } - -function progressColor(percent: number): string { - if (percent >= 90) return "bg-destructive"; - if (percent >= 75) return "bg-amber-500"; - return "bg-primary"; +function barColor(p: number): string { + if (p >= 90) return "[&>*]:bg-destructive"; + if (p >= 75) return "[&>*]:bg-amber-500"; + return ""; } export default function UsagePage() { - const { data: session } = useSession(); - const [isLoading, setIsLoading] = useState(true); - const [usage, setUsage] = useState({ - queriesUsed: 0, - queriesLimit: 500, - ingestPagesUsed: 0, - ingestPagesLimit: 500, - documentsStored: 0, - documentsLimit: 25, - plan: "free", - }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [data, setData] = useState(null); useEffect(() => { - // In production, this would fetch from the API - // For now, show placeholder data based on default free plan - const timer = setTimeout(() => { - setUsage({ - queriesUsed: 0, - queriesLimit: PLAN_LIMITS.free.queries, - ingestPagesUsed: 0, - ingestPagesLimit: PLAN_LIMITS.free.ingest, - documentsStored: 0, - documentsLimit: PLAN_LIMITS.free.documents, - plan: "free", - }); - setIsLoading(false); - }, 500); - return () => clearTimeout(timer); - }, [session]); + let cancelled = false; + (async () => { + try { + const res = await fetch("/api/dashboard/usage"); + const json = await res.json(); + if (!res.ok) throw new Error(json?.error ?? "Failed to load usage"); + if (!cancelled) setData(json as UsageResponse); + } catch (e) { + if (!cancelled) + setError(e instanceof Error ? e.message : "Failed to load usage"); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); - if (isLoading) { + if (loading) { return (
@@ -87,16 +87,54 @@ export default function UsagePage() { ); } - const queryPercent = usagePercent(usage.queriesUsed, usage.queriesLimit); - const ingestPercent = usagePercent( - usage.ingestPagesUsed, - usage.ingestPagesLimit - ); - const docsPercent = usagePercent( - usage.documentsStored, - usage.documentsLimit + if (error || !data) { + return ( +
+
+

+ Usage +

+

+ Track your monthly usage across queries, documents, and tokens. +

+
+ + + {error ?? "No usage available yet."} + + +
+ ); + } + + const { quota, bySource } = data; + const plan = PLAN_BY_KEY[quota.plan]; + const totalReqBySource = Math.max( + 1, + bySource.reduce((a, s) => a + s.requests, 0), ); + const meters = [ + { + label: "Queries This Month", + icon: Search, + used: quota.queries_used, + limit: quota.queries_limit, + }, + { + label: "Documents Stored", + icon: FileText, + used: quota.documents_used, + limit: quota.documents_limit, + }, + { + label: "Tokens This Month", + icon: Layers, + used: quota.tokens_used, + limit: quota.tokens_limit, + }, + ]; + return (
{/* Header */} @@ -106,151 +144,130 @@ export default function UsagePage() { Usage

- Track your monthly usage across queries, document ingestion, and - storage. + Track your monthly usage across queries, documents, and tokens.

- - {usage.plan} Plan + + {plan?.name ?? quota.plan} Plan
- {/* Usage Cards */} + {/* Quota meters */}
- {/* Queries */} - - - - Queries This Month - - - - -
- {formatNumber(usage.queriesUsed)} -
-

- of {formatNumber(usage.queriesLimit)} limit -

- -

- {queryPercent}% used -

-
-
- - {/* Ingest Pages */} - - - - Pages Ingested This Month - - - - -
- {formatNumber(usage.ingestPagesUsed)} -
-

- of {formatNumber(usage.ingestPagesLimit)} limit -

- -

- {ingestPercent}% used -

-
-
- - {/* Documents Stored */} - - - - Documents Stored - - - - -
- {formatNumber(usage.documentsStored)} -
-

- of {formatNumber(usage.documentsLimit)} limit -

- -

- {docsPercent}% used -

-
-
+ {meters.map((m) => { + const Icon = m.icon; + const p = pct(m.used, m.limit); + return ( + + + {m.label} + + + +
+ {fmt(m.used)} +
+

+ of {fmtLimit(m.limit)} {m.limit === 0 ? "" : "limit"} +

+ +

+ {m.limit === 0 ? "Unlimited" : `${p}% used`} +

+
+
+ ); + })}
- {/* Rate limits info */} + {/* Usage by source */} - - Rate Limits + + Usage by source - Request limits per minute for your current plan. + Requests over the last 30 days, split by dashboard, SDKs, MCP, and + API keys. - -
-
-

Queries

-

- {usage.plan === "free" - ? "20" - : usage.plan === "pro" - ? "200" - : "1,000"} -

-

- requests per minute -

-
-
-

Document Upload

-

- {usage.plan === "free" - ? "5" - : usage.plan === "pro" - ? "30" - : "200"} -

-

- requests per minute -

-
-
-

Tree Navigation

-

- {usage.plan === "free" - ? "120" - : usage.plan === "pro" - ? "1,200" - : "10,000"} -

-

- requests per minute -

-
-
+ + {bySource.every((s) => s.requests === 0) ? ( +

+ No requests recorded yet — start querying to see the breakdown. +

+ ) : ( + bySource.map((s) => { + const meta = sourceMeta(s.source); + const share = Math.round((s.requests / totalReqBySource) * 100); + return ( +
+
+ + + {meta.label} + + + {fmt(s.requests)} req · {share}% + +
+
+
+
+
+ ); + }) + )} + + {/* Plan limits */} + {plan && ( + + + + + Plan limits + + + Your {plan.name} plan ceilings. Upgrade for more headroom. + + + +
+
+

Documents

+

+ {fmtLimit(plan.limits.documents)} +

+

stored

+
+
+

Storage

+

+ {plan.limits.storageGB === 0 + ? "Unlimited" + : `${plan.limits.storageGB} GB`} +

+

total

+
+
+

Rate limit

+

+ {plan.limits.requestsPerSecond === 0 + ? "Custom" + : plan.limits.requestsPerSecond} +

+

requests / sec

+
+
+
+
+ )}
); } diff --git a/apps/web/app/api/dashboard/analytics/route.ts b/apps/web/app/api/dashboard/analytics/route.ts new file mode 100644 index 0000000..a36e576 --- /dev/null +++ b/apps/web/app/api/dashboard/analytics/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { forwardToCP, getPrimaryOrgId } from "@/lib/cp-proxy"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// GET /api/dashboard/analytics?from=YYYY-MM-DD&to=YYYY-MM-DD +// Proxies to the CP analytics endpoint (summary + daily series + +// per-source breakdown + recent feed) for the caller's primary org. +export async function GET(request: NextRequest) { + const orgId = await getPrimaryOrgId(); + if (!orgId) { + return NextResponse.json( + { error: "No organization found on your account." }, + { status: 400 }, + ); + } + + const { searchParams } = new URL(request.url); + const qs = new URLSearchParams(); + const from = searchParams.get("from"); + const to = searchParams.get("to"); + if (from) qs.set("from", from); + if (to) qs.set("to", to); + const suffix = qs.toString() ? `?${qs.toString()}` : ""; + + const { ok, status, data } = await forwardToCP( + `/admin/v1/orgs/${orgId}/analytics${suffix}`, + { orgId }, + ); + if (!ok) { + return NextResponse.json(data ?? { error: "Upstream error" }, { status }); + } + return NextResponse.json(data); +} diff --git a/apps/web/app/api/dashboard/analytics/usage/route.ts b/apps/web/app/api/dashboard/analytics/usage/route.ts index 141202f..62d71eb 100644 --- a/apps/web/app/api/dashboard/analytics/usage/route.ts +++ b/apps/web/app/api/dashboard/analytics/usage/route.ts @@ -1,14 +1,56 @@ import { NextRequest, NextResponse } from "next/server"; -import { forwardToCP } from "@/lib/cp-proxy"; +import { forwardToCP, getPrimaryOrgId } from "@/lib/cp-proxy"; +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// GET /api/dashboard/analytics/usage?range=7d|30d|90d +// Endpoint-breakdown view: maps the CP analytics payload to the shape the +// analytics/usage detail page expects. export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const range = searchParams.get("range") || "30d"; + const orgId = await getPrimaryOrgId(); + if (!orgId) { + return NextResponse.json( + { error: "No organization found on your account." }, + { status: 400 }, + ); + } + + const range = new URL(request.url).searchParams.get("range") || "30d"; + const days = range === "7d" ? 7 : range === "90d" ? 90 : 30; + const to = new Date(); + const from = new Date(to.getTime() - days * 24 * 60 * 60 * 1000); + const iso = (d: Date) => d.toISOString().slice(0, 10); + const { ok, status, data } = await forwardToCP( - `/v1/analytics/usage?range=${encodeURIComponent(range)}`, + `/admin/v1/orgs/${orgId}/analytics?from=${iso(from)}&to=${iso(to)}`, + { orgId }, ); if (!ok) { return NextResponse.json(data ?? { error: "Upstream error" }, { status }); } - return NextResponse.json(data); + + const a = data as { + summary?: { total_requests?: number; success_rate?: number }; + daily?: { date: string; requests: number }[]; + endpoints?: { + endpoint: string; + method: string; + count: number; + avg_latency_ms: number; + error_rate: number; + }[]; + }; + const daily = a.daily ?? []; + const total = a.summary?.total_requests ?? 0; + + return NextResponse.json({ + range, + total_requests: total, + avg_per_day: daily.length ? Math.round(total / daily.length) : 0, + peak_hour: "—", + success_rate: a.summary?.success_rate ?? 0, + endpoints: a.endpoints ?? [], + daily, + }); } diff --git a/apps/web/app/api/dashboard/usage/route.ts b/apps/web/app/api/dashboard/usage/route.ts new file mode 100644 index 0000000..6a4e02c --- /dev/null +++ b/apps/web/app/api/dashboard/usage/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; +import { forwardToCP, getPrimaryOrgId } from "@/lib/cp-proxy"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// GET /api/dashboard/usage +// Returns the current-month quota status (meters) plus the trailing +// 30-day usage-by-source breakdown, for the Usage page. +export async function GET() { + const orgId = await getPrimaryOrgId(); + if (!orgId) { + return NextResponse.json( + { error: "No organization found on your account." }, + { status: 400 }, + ); + } + + const [quotaRes, analyticsRes] = await Promise.all([ + forwardToCP(`/admin/v1/orgs/${orgId}/quota`, { orgId }), + forwardToCP(`/admin/v1/orgs/${orgId}/analytics`, { orgId }), + ]); + + if (!quotaRes.ok) { + return NextResponse.json( + quotaRes.data ?? { error: "Failed to load quota" }, + { status: quotaRes.status }, + ); + } + + const analytics = analyticsRes.ok + ? (analyticsRes.data as { by_source?: unknown; summary?: unknown } | null) + : null; + + return NextResponse.json({ + quota: quotaRes.data, + bySource: analytics?.by_source ?? [], + summary: analytics?.summary ?? null, + }); +} diff --git a/apps/web/components/Pricing.tsx b/apps/web/components/Pricing.tsx index b689bb9..2102266 100644 --- a/apps/web/components/Pricing.tsx +++ b/apps/web/components/Pricing.tsx @@ -3,60 +3,30 @@ import Link from 'next/link'; import { Check } from 'lucide-react'; import { useGsapEffect, useScopedRef, gsap } from '@/hooks/useGsap'; +import { PLANS } from '@/lib/pricing'; -const TIERS = [ - { - name: 'Hobby', - tag: 'Free, forever', - price: '$0', - cadence: 'free up to 100 docs', - desc: 'Everything you need to ship a real agent.', - cta: 'Start free', - href: '/register', - highlight: false, - features: [ - '100 documents', - 'Unlimited section fetches', - 'TypeScript + Python SDK', - 'Hybrid retrieval (BYO embeddings)', - 'Community support', - ], - }, - { - name: 'Pro', - tag: 'For shipping teams', - price: '$29', - cadence: 'per seat / month', - desc: 'Scale past prototype without paging an SRE.', - cta: 'Start 14-day trial', - href: '/register?plan=pro', - highlight: true, - features: [ - '10,000 documents', - 'Priority parallel fetch', - 'MCP server integration', - 'Document analytics + audit log', - 'Email support · 24h SLA', - ], - }, - { - name: 'Enterprise', - tag: 'For regulated work', - price: 'Custom', - cadence: 'talk to us', - desc: 'SSO, on-prem, contracts, and people who pick up the phone.', - cta: 'Contact sales', - href: 'mailto:sales@vectorless.dev', - highlight: false, - features: [ - 'Unlimited documents', - 'SSO + SCIM', - 'Self-hosted / VPC', - 'SOC 2 + DPA', - 'Dedicated Slack channel', - ], - }, -]; +// Marketing view of the canonical pricing ladder (lib/pricing.ts). Only +// the presentation (cadence phrasing, CTA copy, href) lives here; the +// tiers, prices, limits and features come from the shared source so the +// landing page can never drift from the dashboard or the quota engine. +const CTA: Record = { + free: { cta: 'Start free', href: '/register', cadence: 'free up to 100 docs' }, + pro: { cta: 'Start 14-day trial', href: '/register?plan=pro', cadence: 'per workspace / month' }, + scale: { cta: 'Start 14-day trial', href: '/register?plan=scale', cadence: 'per workspace / month' }, + enterprise: { cta: 'Contact sales', href: 'mailto:sales@vectorless.store', cadence: 'talk to us' }, +}; + +const TIERS = PLANS.map((p) => ({ + name: p.name, + tag: p.tag, + price: p.price, + cadence: CTA[p.key].cadence, + desc: p.description, + cta: CTA[p.key].cta, + href: CTA[p.key].href, + highlight: !!p.popular, + features: p.features, +})); export default function Pricing() { const ref = useScopedRef(); @@ -151,7 +121,7 @@ export default function Pricing() {
-
+
{TIERS.map((tier) => (
= Object.fromEntries( + PLANS.map((p) => [p.key, p]), +); diff --git a/apps/web/lib/usage-sources.ts b/apps/web/lib/usage-sources.ts new file mode 100644 index 0000000..394e1af --- /dev/null +++ b/apps/web/lib/usage-sources.ts @@ -0,0 +1,50 @@ +// Presentation metadata for the four usage surfaces the CP attributes +// every request to (web / sdk / mcp / api). Shared by the Analytics and +// Usage pages so labels + colours stay consistent. No purple (brand rule). + +export interface SourceMeta { + label: string; + /** Tailwind text colour class. */ + text: string; + /** Tailwind background colour class (solid, for bars/dots). */ + bg: string; + blurb: string; +} + +export const SOURCE_META: Record = { + web: { + label: "Dashboard", + text: "text-brand-blue", + bg: "bg-brand-blue", + blurb: "Requests from the web dashboard", + }, + sdk: { + label: "SDK", + text: "text-brand-pink", + bg: "bg-brand-pink", + blurb: "Official Python / TypeScript / Go SDKs", + }, + mcp: { + label: "MCP", + text: "text-amber-500", + bg: "bg-amber-500", + blurb: "Model Context Protocol server", + }, + api: { + label: "API keys", + text: "text-emerald-500", + bg: "bg-emerald-500", + blurb: "Direct API-key requests (curl, custom code)", + }, +}; + +export function sourceMeta(source: string): SourceMeta { + return ( + SOURCE_META[source] ?? { + label: source, + text: "text-muted-foreground", + bg: "bg-muted-foreground", + blurb: "", + } + ); +}