overview-client.tsx581 lines · main
| 1 | 'use client'; |
| 2 | |
| 3 | import Link from 'next/link'; |
| 4 | import { useCallback, useEffect, useState } from 'react'; |
| 5 | |
| 6 | import { ActivityIcon } from '@/components/ui/activity'; |
| 7 | import { CreditCardIcon } from '@/components/ui/credit-card'; |
| 8 | import { DatabaseIcon } from '@/components/ui/database'; |
| 9 | import { FoldersIcon } from '@/components/ui/folders'; |
| 10 | import { RocketIcon } from '@/components/ui/rocket'; |
| 11 | import { TriangleAlertIcon } from '@/components/ui/triangle-alert'; |
| 12 | import { UsersIcon } from '@/components/ui/users'; |
| 13 | |
| 14 | import { AreaChart, type AreaChartPoint } from './_components/area-chart'; |
| 15 | import { EmptyState, EmptyStateButton } from './_components/empty-state'; |
| 16 | import { Section } from './_components/section'; |
| 17 | import { CountUp, StatCard } from './_components/stat-card'; |
| 18 | |
| 19 | /* ─── payload types (mirror /v1/admin/overview) ──────────────────────────── */ |
| 20 | |
| 21 | type HealthCheck = 'ok' | 'unreachable' | 'not_configured'; |
| 22 | |
| 23 | interface HostMetrics { |
| 24 | cpuPercent: number | null; |
| 25 | memUsedBytes: number | null; |
| 26 | memTotalBytes: number | null; |
| 27 | diskPercent: number | null; |
| 28 | stealPercent: number | null; |
| 29 | instance?: string; |
| 30 | } |
| 31 | |
| 32 | export interface Overview { |
| 33 | billing: { |
| 34 | subscribers: number | null; |
| 35 | mrr: number | null; |
| 36 | currency: string | null; |
| 37 | planMix: { free: number; pro: number; team: number } | null; |
| 38 | churn30d: number | null; |
| 39 | }; |
| 40 | health: { |
| 41 | checks: { |
| 42 | control_postgres: HealthCheck; |
| 43 | data_plane_postgres: HealthCheck; |
| 44 | runtime: HealthCheck; |
| 45 | redis: HealthCheck; |
| 46 | }; |
| 47 | host: HostMetrics | null; |
| 48 | }; |
| 49 | openIncidents: number; |
| 50 | recentDeploys: Array<{ |
| 51 | id: string; |
| 52 | service: string; |
| 53 | buildSha: string; |
| 54 | buildAt: string | null; |
| 55 | env: string; |
| 56 | bootedAt: string; |
| 57 | }>; |
| 58 | counts: { projects: number; users: number }; |
| 59 | } |
| 60 | |
| 61 | /* ─── payload types (mirror /v1/admin/timeseries) ────────────────────────── */ |
| 62 | |
| 63 | interface DailyPoint { |
| 64 | day: string; // 'YYYY-MM-DD' |
| 65 | count: number; |
| 66 | } |
| 67 | |
| 68 | interface Timeseries { |
| 69 | signupsDaily: DailyPoint[]; |
| 70 | deploysDaily: DailyPoint[]; |
| 71 | invocationsDaily: DailyPoint[]; |
| 72 | apiRequests: Array<{ t: string; perMin: number }> | null; |
| 73 | hostCpu: Array<{ t: string; pct: number }> | null; |
| 74 | } |
| 75 | |
| 76 | /* ─── live-polled dashboard ──────────────────────────────────────────────── */ |
| 77 | |
| 78 | const POLL_MS = 10_000; |
| 79 | /** ~20 min of 10s samples for the live cpu chart. */ |
| 80 | const MAX_SAMPLES = 120; |
| 81 | /** |
| 82 | * Daily/24h series barely move — refetching every 10s alongside the |
| 83 | * overview poll would be pure waste. Once on mount + every 5 min. |
| 84 | */ |
| 85 | const SERIES_POLL_MS = 5 * 60_000; |
| 86 | |
| 87 | export function OverviewDashboard({ |
| 88 | apiOrigin, |
| 89 | initial, |
| 90 | }: { |
| 91 | apiOrigin: string; |
| 92 | initial: Overview | null; |
| 93 | }) { |
| 94 | const [data, setData] = useState<Overview | null>(initial); |
| 95 | const [failed, setFailed] = useState(false); |
| 96 | const [updatedAt, setUpdatedAt] = useState<number | null>(initial ? Date.now() : null); |
| 97 | // Real cpu% samples accumulated from the live poll — the chart never |
| 98 | // shows fabricated history, it grows as genuine samples arrive. |
| 99 | const [cpuSamples, setCpuSamples] = useState<AreaChartPoint[]>([]); |
| 100 | |
| 101 | const load = useCallback(async () => { |
| 102 | try { |
| 103 | const res = await fetch(`${apiOrigin}/v1/admin/overview`, { |
| 104 | credentials: 'include', |
| 105 | headers: { accept: 'application/json' }, |
| 106 | }); |
| 107 | if (!res.ok) throw new Error(`overview failed: ${res.status}`); |
| 108 | const json = (await res.json()) as Overview; |
| 109 | setData(json); |
| 110 | setUpdatedAt(Date.now()); |
| 111 | setFailed(false); |
| 112 | const cpu = json.health.host?.cpuPercent; |
| 113 | if (typeof cpu === 'number') { |
| 114 | setCpuSamples((prev) => [...prev.slice(-(MAX_SAMPLES - 1)), { x: Date.now(), y: cpu }]); |
| 115 | } |
| 116 | } catch { |
| 117 | setFailed(true); |
| 118 | } |
| 119 | }, [apiOrigin]); |
| 120 | |
| 121 | useEffect(() => { |
| 122 | void load(); |
| 123 | const timer = setInterval(() => { |
| 124 | if (document.visibilityState === 'visible') void load(); |
| 125 | }, POLL_MS); |
| 126 | return () => clearInterval(timer); |
| 127 | }, [load]); |
| 128 | |
| 129 | // 30-day + 24h activity series — real history from /v1/admin/timeseries. |
| 130 | const [series, setSeries] = useState<Timeseries | null>(null); |
| 131 | |
| 132 | const loadSeries = useCallback(async () => { |
| 133 | try { |
| 134 | const res = await fetch(`${apiOrigin}/v1/admin/timeseries`, { |
| 135 | credentials: 'include', |
| 136 | headers: { accept: 'application/json' }, |
| 137 | }); |
| 138 | if (!res.ok) throw new Error(`timeseries failed: ${res.status}`); |
| 139 | setSeries((await res.json()) as Timeseries); |
| 140 | } catch { |
| 141 | // Keep the last good series — a missed 5-min refresh on daily data |
| 142 | // is invisible; the main overview poll already surfaces staleness. |
| 143 | } |
| 144 | }, [apiOrigin]); |
| 145 | |
| 146 | useEffect(() => { |
| 147 | void loadSeries(); |
| 148 | const timer = setInterval(() => { |
| 149 | if (document.visibilityState === 'visible') void loadSeries(); |
| 150 | }, SERIES_POLL_MS); |
| 151 | return () => clearInterval(timer); |
| 152 | }, [loadSeries]); |
| 153 | |
| 154 | if (data === null) { |
| 155 | if (failed) { |
| 156 | return ( |
| 157 | <EmptyState |
| 158 | icon={<TriangleAlertIcon size={28} />} |
| 159 | title="overview unavailable" |
| 160 | message="the api didn't answer — it may be restarting or your session may have expired. retrying keeps your place; nothing is lost." |
| 161 | action={<EmptyStateButton onClick={() => void load()}>retry now</EmptyStateButton>} |
| 162 | /> |
| 163 | ); |
| 164 | } |
| 165 | return ( |
| 166 | <EmptyState |
| 167 | icon={<ActivityIcon size={28} />} |
| 168 | title="loading overview…" |
| 169 | message="fetching live platform data." |
| 170 | /> |
| 171 | ); |
| 172 | } |
| 173 | |
| 174 | const { billing, health, openIncidents, recentDeploys, counts } = data; |
| 175 | |
| 176 | return ( |
| 177 | <div className="flex flex-col gap-10"> |
| 178 | <header className="flex flex-wrap items-end justify-between gap-4"> |
| 179 | <div className="flex flex-col gap-2"> |
| 180 | <div className="flex items-center gap-2"> |
| 181 | <span className="text-[var(--color-primary)]"> |
| 182 | <ActivityIcon size={20} /> |
| 183 | </span> |
| 184 | <h1 className="font-mono text-xl tracking-tight">overview</h1> |
| 185 | </div> |
| 186 | <p className="max-w-prose font-mono text-sm text-[var(--color-text-muted)]"> |
| 187 | the platform at a glance — money in, engine alive. real numbers only; anything we |
| 188 | can't yet prove shows “—” with what it's waiting on. |
| 189 | </p> |
| 190 | </div> |
| 191 | <LiveBadge updatedAt={updatedAt} stale={failed} /> |
| 192 | </header> |
| 193 | |
| 194 | {/* ── top row: the four numbers that matter ─────────────────────── */} |
| 195 | <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-4"> |
| 196 | <StatCard |
| 197 | label="users" |
| 198 | value={counts.users} |
| 199 | icon={<UsersIcon size={14} />} |
| 200 | hint="non-deleted accounts" |
| 201 | /> |
| 202 | <StatCard |
| 203 | label="projects" |
| 204 | value={counts.projects} |
| 205 | icon={<FoldersIcon size={14} />} |
| 206 | hint="non-deleted totals" |
| 207 | /> |
| 208 | <StatCard |
| 209 | label="active incidents" |
| 210 | value={openIncidents} |
| 211 | icon={<TriangleAlertIcon size={14} />} |
| 212 | tone={openIncidents > 0 ? 'warning' : 'default'} |
| 213 | hint="unresolved · live" |
| 214 | /> |
| 215 | <StatCard |
| 216 | label="mrr" |
| 217 | value={billing.mrr} |
| 218 | prefix={currencySymbol(billing.currency)} |
| 219 | icon={<CreditCardIcon size={14} />} |
| 220 | tone="primary" |
| 221 | hint="monthly recurring revenue" |
| 222 | waitingOn="Mavi Pay not configured" |
| 223 | /> |
| 224 | </div> |
| 225 | |
| 226 | {/* ── live host cpu chart ───────────────────────────────────────── */} |
| 227 | <Section |
| 228 | title={ |
| 229 | health.host?.instance ? `host cpu · live · ${health.host.instance}` : 'host cpu · live' |
| 230 | } |
| 231 | icon={<ActivityIcon size={16} />} |
| 232 | right={ |
| 233 | <span className="font-mono text-[10px] text-[var(--color-text-subtle)]"> |
| 234 | sampled every 10s this session |
| 235 | </span> |
| 236 | } |
| 237 | > |
| 238 | {health.host === null ? ( |
| 239 | <EmptyState |
| 240 | icon={<TriangleAlertIcon size={24} />} |
| 241 | title="monitoring not connected" |
| 242 | message="host cpu appears here once Prometheus is wired up — no fake demo curve in the meantime." |
| 243 | /> |
| 244 | ) : ( |
| 245 | <div className="rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6"> |
| 246 | <AreaChart |
| 247 | data={cpuSamples} |
| 248 | height={200} |
| 249 | yFormat={(y) => `${y.toFixed(0)}%`} |
| 250 | xFormat={(x) => |
| 251 | new Date(x).toLocaleTimeString(undefined, { |
| 252 | hour: '2-digit', |
| 253 | minute: '2-digit', |
| 254 | second: '2-digit', |
| 255 | }) |
| 256 | } |
| 257 | ariaLabel="host cpu percent over the current session" |
| 258 | /> |
| 259 | </div> |
| 260 | )} |
| 261 | </Section> |
| 262 | |
| 263 | {/* ── activity charts (30d dailies + 24h api traffic) ───────────── */} |
| 264 | <Section |
| 265 | title="activity" |
| 266 | icon={<ActivityIcon size={16} />} |
| 267 | right={ |
| 268 | <span className="font-mono text-[10px] text-[var(--color-text-subtle)]"> |
| 269 | real history · refreshes every 5 min |
| 270 | </span> |
| 271 | } |
| 272 | > |
| 273 | <div className="flex flex-col gap-6"> |
| 274 | <div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3"> |
| 275 | <DailyChartCard |
| 276 | label="signups · 30d" |
| 277 | points={series?.signupsDaily ?? null} |
| 278 | ariaLabel="new user signups per day, last 30 days" |
| 279 | /> |
| 280 | <DailyChartCard |
| 281 | label="deploys · 30d" |
| 282 | points={series?.deploysDaily ?? null} |
| 283 | ariaLabel="platform deploys per day, last 30 days" |
| 284 | /> |
| 285 | <DailyChartCard |
| 286 | label="invocations · 30d" |
| 287 | points={series?.invocationsDaily ?? null} |
| 288 | ariaLabel="function invocations per day, last 30 days" |
| 289 | /> |
| 290 | </div> |
| 291 | |
| 292 | {series !== null && series.apiRequests === null && series.hostCpu === null ? ( |
| 293 | <EmptyState |
| 294 | icon={<TriangleAlertIcon size={24} />} |
| 295 | title="monitoring not connected" |
| 296 | message="api traffic and 24h host cpu appear here once Prometheus answers — start the observability stack. no fake demo curve in the meantime." |
| 297 | /> |
| 298 | ) : null} |
| 299 | {series?.apiRequests ? ( |
| 300 | <WideRangeChartCard |
| 301 | label="api traffic · 24h" |
| 302 | data={series.apiRequests.map((p) => ({ x: Date.parse(p.t), y: p.perMin }))} |
| 303 | yFormat={(y) => `${y.toLocaleString('en-US', { maximumFractionDigits: 1 })}/min`} |
| 304 | ariaLabel="api requests per minute over the last 24 hours" |
| 305 | /> |
| 306 | ) : null} |
| 307 | {series?.hostCpu ? ( |
| 308 | <WideRangeChartCard |
| 309 | label="host cpu · 24h" |
| 310 | data={series.hostCpu.map((p) => ({ x: Date.parse(p.t), y: p.pct }))} |
| 311 | yFormat={(y) => `${y.toFixed(0)}%`} |
| 312 | ariaLabel="host cpu percent over the last 24 hours" |
| 313 | /> |
| 314 | ) : null} |
| 315 | </div> |
| 316 | </Section> |
| 317 | |
| 318 | {/* ── platform health tiles ─────────────────────────────────────── */} |
| 319 | <Section |
| 320 | title="platform health" |
| 321 | icon={<DatabaseIcon size={16} />} |
| 322 | right={ |
| 323 | <Link |
| 324 | href="/admin/health" |
| 325 | className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)] transition hover:text-[var(--color-text-link)]" |
| 326 | > |
| 327 | open health → |
| 328 | </Link> |
| 329 | } |
| 330 | > |
| 331 | <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-4"> |
| 332 | {( |
| 333 | [ |
| 334 | ['control_postgres', 'control db'], |
| 335 | ['data_plane_postgres', 'data plane'], |
| 336 | ['runtime', 'runtime'], |
| 337 | ['redis', 'redis'], |
| 338 | ] as const |
| 339 | ).map(([key, label]) => ( |
| 340 | <HealthTile key={key} label={label} state={health.checks[key]} /> |
| 341 | ))} |
| 342 | </div> |
| 343 | </Section> |
| 344 | |
| 345 | {/* ── business row ──────────────────────────────────────────────── */} |
| 346 | <Section title="business · Mavi Pay" icon={<CreditCardIcon size={16} />}> |
| 347 | <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3"> |
| 348 | <StatCard |
| 349 | label="paid subscribers" |
| 350 | value={billing.subscribers} |
| 351 | tone="primary" |
| 352 | hint="non-deleted projects on pro + team" |
| 353 | waitingOn="Mavi Pay not configured" |
| 354 | /> |
| 355 | <PlanMixCard planMix={billing.planMix} /> |
| 356 | <StatCard |
| 357 | label="churn · 30d" |
| 358 | value={billing.churn30d} |
| 359 | hint="subscriptions canceled · last 30d" |
| 360 | waitingOn="Mavi Pay not configured" |
| 361 | /> |
| 362 | </div> |
| 363 | </Section> |
| 364 | |
| 365 | {/* ── recent deploys ────────────────────────────────────────────── */} |
| 366 | <Section |
| 367 | title="recent deploys · last 3" |
| 368 | icon={<RocketIcon size={16} />} |
| 369 | right={ |
| 370 | <Link |
| 371 | href="/admin/deploys" |
| 372 | className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)] transition hover:text-[var(--color-text-link)]" |
| 373 | > |
| 374 | all deploys → |
| 375 | </Link> |
| 376 | } |
| 377 | > |
| 378 | {recentDeploys.length === 0 ? ( |
| 379 | <EmptyState |
| 380 | icon={<RocketIcon size={24} />} |
| 381 | title="no deploys recorded yet" |
| 382 | message="the first api or web boot with build metadata will show up here." |
| 383 | /> |
| 384 | ) : ( |
| 385 | <ul className="flex flex-col divide-y divide-[var(--color-border-subtle)] rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)]"> |
| 386 | {recentDeploys.map((d) => ( |
| 387 | <li |
| 388 | key={d.id} |
| 389 | className="flex items-center justify-between gap-4 px-6 py-4 font-mono text-xs" |
| 390 | > |
| 391 | <span className="flex items-center gap-3"> |
| 392 | <span className="text-[var(--color-primary)]">{d.service}</span> |
| 393 | <span className="text-[var(--color-text-muted)]">{d.buildSha.slice(0, 7)}</span> |
| 394 | <span className="rounded-full border border-[var(--color-border-subtle)] px-2 py-0.5 text-[9px] uppercase tracking-wider text-[var(--color-text-subtle)]"> |
| 395 | {d.env} |
| 396 | </span> |
| 397 | </span> |
| 398 | <time className="shrink-0 text-[var(--color-text-subtle)]" dateTime={d.bootedAt}> |
| 399 | {new Date(d.bootedAt).toLocaleString()} |
| 400 | </time> |
| 401 | </li> |
| 402 | ))} |
| 403 | </ul> |
| 404 | )} |
| 405 | </Section> |
| 406 | </div> |
| 407 | ); |
| 408 | } |
| 409 | |
| 410 | /* ─── small pieces ───────────────────────────────────────────────────────── */ |
| 411 | |
| 412 | /** 'YYYY-MM-DD' midnight-UTC ms → 'jun 4' style lowercase label. */ |
| 413 | function formatDay(x: number): string { |
| 414 | return new Date(x) |
| 415 | .toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' }) |
| 416 | .toLowerCase(); |
| 417 | } |
| 418 | |
| 419 | /** Sample ms → 'hh:mm' label for the 24h range charts. */ |
| 420 | function formatClock(x: number): string { |
| 421 | return new Date(x).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); |
| 422 | } |
| 423 | |
| 424 | /** |
| 425 | * One 30-day daily series in a surface card. `points === null` means the |
| 426 | * first fetch hasn't landed yet — the AreaChart's own pending frame keeps |
| 427 | * the grid stable without inventing data. |
| 428 | */ |
| 429 | function DailyChartCard({ |
| 430 | label, |
| 431 | points, |
| 432 | ariaLabel, |
| 433 | }: { |
| 434 | label: string; |
| 435 | points: DailyPoint[] | null; |
| 436 | ariaLabel: string; |
| 437 | }) { |
| 438 | const data: AreaChartPoint[] = (points ?? []).map((p) => ({ |
| 439 | x: Date.parse(`${p.day}T00:00:00Z`), |
| 440 | y: p.count, |
| 441 | })); |
| 442 | return ( |
| 443 | <div className="flex h-full flex-col gap-4 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6"> |
| 444 | <p className="font-mono text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]"> |
| 445 | {label} |
| 446 | </p> |
| 447 | <AreaChart |
| 448 | data={data} |
| 449 | height={140} |
| 450 | yFormat={(y) => y.toLocaleString('en-US', { maximumFractionDigits: 0 })} |
| 451 | xFormat={formatDay} |
| 452 | ariaLabel={ariaLabel} |
| 453 | pendingLabel={points === null ? 'loading 30-day history…' : 'no activity recorded yet.'} |
| 454 | /> |
| 455 | </div> |
| 456 | ); |
| 457 | } |
| 458 | |
| 459 | /** Full-width 24h range chart (prometheus-backed, only rendered when real). */ |
| 460 | function WideRangeChartCard({ |
| 461 | label, |
| 462 | data, |
| 463 | yFormat, |
| 464 | ariaLabel, |
| 465 | }: { |
| 466 | label: string; |
| 467 | data: AreaChartPoint[]; |
| 468 | yFormat: (y: number) => string; |
| 469 | ariaLabel: string; |
| 470 | }) { |
| 471 | return ( |
| 472 | <div className="flex flex-col gap-4 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6"> |
| 473 | <p className="font-mono text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]"> |
| 474 | {label} |
| 475 | </p> |
| 476 | <AreaChart |
| 477 | data={data} |
| 478 | height={200} |
| 479 | yFormat={yFormat} |
| 480 | xFormat={formatClock} |
| 481 | ariaLabel={ariaLabel} |
| 482 | /> |
| 483 | </div> |
| 484 | ); |
| 485 | } |
| 486 | |
| 487 | /** ISO currency code → display symbol, falling back to the code itself. */ |
| 488 | function currencySymbol(code: string | null): string { |
| 489 | switch (code) { |
| 490 | case 'EUR': |
| 491 | return '€'; |
| 492 | case 'USD': |
| 493 | return '$'; |
| 494 | case 'GBP': |
| 495 | return '£'; |
| 496 | default: |
| 497 | return code ? `${code} ` : ''; |
| 498 | } |
| 499 | } |
| 500 | |
| 501 | function LiveBadge({ updatedAt, stale }: { updatedAt: number | null; stale: boolean }) { |
| 502 | return ( |
| 503 | <span className="flex items-center gap-2 rounded-full border border-[var(--color-border-subtle)] bg-[var(--color-surface)] px-3 py-1.5 font-mono text-[10px] text-[var(--color-text-subtle)]"> |
| 504 | <StatusDot state={stale ? 'unreachable' : 'ok'} /> |
| 505 | {stale |
| 506 | ? 'stale — retrying' |
| 507 | : updatedAt |
| 508 | ? `live · updated ${new Date(updatedAt).toLocaleTimeString()}` |
| 509 | : 'live'} |
| 510 | </span> |
| 511 | ); |
| 512 | } |
| 513 | |
| 514 | /** Pulsing status dot — green pulse ok, red pulse unreachable, amber static. */ |
| 515 | function StatusDot({ state }: { state: HealthCheck }) { |
| 516 | const color = |
| 517 | state === 'ok' |
| 518 | ? 'bg-[var(--color-success)]' |
| 519 | : state === 'unreachable' |
| 520 | ? 'bg-[var(--color-error)]' |
| 521 | : 'bg-[var(--color-warning)]'; |
| 522 | return ( |
| 523 | <span className="relative flex h-2.5 w-2.5 shrink-0" aria-hidden> |
| 524 | {state !== 'not_configured' ? ( |
| 525 | <span |
| 526 | className={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-60 ${color}`} |
| 527 | /> |
| 528 | ) : null} |
| 529 | <span className={`relative inline-flex h-2.5 w-2.5 rounded-full ${color}`} /> |
| 530 | </span> |
| 531 | ); |
| 532 | } |
| 533 | |
| 534 | function HealthTile({ label, state }: { label: string; state: HealthCheck }) { |
| 535 | return ( |
| 536 | <div className="flex h-full flex-col gap-4 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6"> |
| 537 | <p className="font-mono text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]"> |
| 538 | {label} |
| 539 | </p> |
| 540 | <div className="flex items-center gap-3"> |
| 541 | <StatusDot state={state} /> |
| 542 | <span className="font-mono text-sm text-[var(--color-text)]"> |
| 543 | {state === 'not_configured' ? 'not configured' : state} |
| 544 | </span> |
| 545 | </div> |
| 546 | </div> |
| 547 | ); |
| 548 | } |
| 549 | |
| 550 | function PlanMixCard({ |
| 551 | planMix, |
| 552 | }: { |
| 553 | planMix: { free: number; pro: number; team: number } | null; |
| 554 | }) { |
| 555 | return ( |
| 556 | <div className="flex h-full flex-col gap-4 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6"> |
| 557 | <p className="font-mono text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]"> |
| 558 | plan mix |
| 559 | </p> |
| 560 | {planMix === null ? ( |
| 561 | <div className="flex flex-col gap-1.5"> |
| 562 | <p className="font-mono text-4xl tracking-tight text-[var(--color-text-subtle)]">—</p> |
| 563 | <p className="font-mono text-[11px] text-[var(--color-text-subtle)]"> |
| 564 | Mavi Pay not configured |
| 565 | </p> |
| 566 | </div> |
| 567 | ) : ( |
| 568 | <dl className="flex flex-col gap-2 font-mono text-sm"> |
| 569 | {(['free', 'pro', 'team'] as const).map((tier) => ( |
| 570 | <div key={tier} className="flex items-center justify-between"> |
| 571 | <dt className="text-[var(--color-text-muted)]">{tier}</dt> |
| 572 | <dd className="text-[var(--color-text)]"> |
| 573 | <CountUp value={planMix[tier]} /> |
| 574 | </dd> |
| 575 | </div> |
| 576 | ))} |
| 577 | </dl> |
| 578 | )} |
| 579 | </div> |
| 580 | ); |
| 581 | } |