@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env*
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
npm-debug.log*
|
||||
*.log
|
||||
@@ -0,0 +1,17 @@
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# meSEO — Development Environment
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Safe to commit — no secrets here.
|
||||
# Real secrets go in .env.local (which is gitignored).
|
||||
|
||||
MESEO_ENV=development
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3000/api
|
||||
NEXT_PUBLIC_COLLECT_ENDPOINT=/api/collect
|
||||
NEXT_PUBLIC_TRACKER_SCRIPT_URL=/t.js
|
||||
|
||||
# Dev feature flags — all enabled
|
||||
FEATURE_AI_AVATAR=true
|
||||
FEATURE_SITE_TAG=true
|
||||
FEATURE_BILLING=true
|
||||
DEBUG_MODE=true
|
||||
@@ -0,0 +1,147 @@
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# meSEO — Environment Variables
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# Copy this file to .env.local and fill in your values.
|
||||
# NEVER commit .env.local — it contains secrets.
|
||||
#
|
||||
# This .env.example is safe to commit (no real secrets).
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# ── Environment ──────────────────────────────────────────────────────────────
|
||||
# Options: development | staging | production
|
||||
# If not set, auto-detected from NODE_ENV / VERCEL_ENV
|
||||
MESEO_ENV=development
|
||||
|
||||
# ── Database (required) ──────────────────────────────────────────────────────
|
||||
# PostgreSQL connection string (Neon, Supabase, Railway, or local)
|
||||
DATABASE_URL="postgresql://user:password@host:5432/dbname?sslmode=require"
|
||||
# Optional direct connection used by Prisma migrations/schema checks.
|
||||
DIRECT_URL="postgresql://user:password@host:5432/dbname?sslmode=require"
|
||||
|
||||
# ── Auth — Clerk (required) ──────────────────────────────────────────────────
|
||||
# Get these from https://dashboard.clerk.com → API Keys
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="pk_test_..."
|
||||
CLERK_SECRET_KEY="sk_test_..."
|
||||
NEXT_PUBLIC_CLERK_SIGN_IN_URL="/sign-in"
|
||||
NEXT_PUBLIC_CLERK_SIGN_UP_URL="/sign-up"
|
||||
|
||||
# ── App URLs ─────────────────────────────────────────────────────────────────
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
NEXT_PUBLIC_API_URL="http://localhost:3000/api"
|
||||
|
||||
# ── meSEO Site Tag (tracking) ───────────────────────────────────────────────
|
||||
# Where the tracker script sends events. Default: /api/collect
|
||||
NEXT_PUBLIC_COLLECT_ENDPOINT="/api/collect"
|
||||
NEXT_PUBLIC_TRACKER_SCRIPT_URL="/t.js"
|
||||
|
||||
# ── Coolify scheduled jobs ──────────────────────────────────────────────────
|
||||
# Keep this secret only in Coolify. Cron endpoints require it as a Bearer token.
|
||||
CRON_SECRET="replace-with-a-long-random-value"
|
||||
|
||||
# ── AI Avatar (optional — Phase 2) ──────────────────────────────────────────
|
||||
# AI Avatar — set ONE of these to enable real AI responses.
|
||||
# If neither is set, the avatar uses smart mock responses.
|
||||
#
|
||||
# Option 1: Anthropic (Claude) — recommended
|
||||
# Get from https://console.anthropic.com
|
||||
ANTHROPIC_API_KEY=""
|
||||
AI_MODEL_ID="claude-sonnet-4-20250514"
|
||||
#
|
||||
# Option 2: OpenAI (GPT)
|
||||
# Get from https://platform.openai.com/api-keys
|
||||
OPENAI_API_KEY=""
|
||||
OPENAI_MODEL_ID="gpt-4o-mini"
|
||||
|
||||
# ── AI Search Verification (Phase 11A) ────────────────────────────────────────
|
||||
# Perplexity AI — https://www.perplexity.ai/settings/api
|
||||
PERPLEXITY_API_KEY=""
|
||||
# Google Gemini — https://aistudio.google.com/app/apikey
|
||||
GEMINI_API_KEY=""
|
||||
# Brave Search API — https://api.search.brave.com/
|
||||
BRAVE_SEARCH_API_KEY=""
|
||||
FIRECRAWL_API_KEY=""
|
||||
BROWSERLESS_API_KEY=""
|
||||
# Vercel Blob — add BLOB_READ_WRITE_TOKEN from Vercel project settings > Storage
|
||||
BLOB_READ_WRITE_TOKEN=""
|
||||
|
||||
# ── Google OAuth (for GSC, GA4, GBP) ─────────────────────────────────────────
|
||||
# Create these at https://console.cloud.google.com → Credentials
|
||||
GOOGLE_CLIENT_ID=""
|
||||
GOOGLE_CLIENT_SECRET=""
|
||||
GOOGLE_REDIRECT_URI="http://localhost:3000/api/auth/google/callback"
|
||||
GOOGLE_API_KEY=""
|
||||
GOOGLE_SEARCH_CX=""
|
||||
GOOGLE_SEARCH_CONSOLE_KEY=""
|
||||
GOOGLE_ANALYTICS_KEY=""
|
||||
GOOGLE_BUSINESS_PROFILE_KEY=""
|
||||
|
||||
# ── Google PageSpeed API Key (optional — works without it) ───────────────────
|
||||
GOOGLE_PAGESPEED_API_KEY=""
|
||||
|
||||
# ── Bing Webmaster API Key ───────────────────────────────────────────────────
|
||||
BING_WEBMASTER_API_KEY=""
|
||||
|
||||
# ── Optional image providers ────────────────────────────────────────────────
|
||||
UNSPLASH_ACCESS_KEY=""
|
||||
PEXELS_API_KEY=""
|
||||
|
||||
# ── DataForSEO (Rank Tracker + Keyword Research) ─────────────────────────────
|
||||
# Powers daily SERP position checks, SERP feature detection (local pack,
|
||||
# featured snippets, etc.), competitor positions, and keyword volume data.
|
||||
# Sign up at https://app.dataforseo.com — your login is the account email.
|
||||
# IMPORTANT: DATAFORSEO_PASSWORD is the API password from
|
||||
# app.dataforseo.com/api-access — NOT your dashboard login password.
|
||||
# When unset the rank tracker falls back to Google Custom Search (no SERP
|
||||
# features, no competitor data) and keyword research falls back to AI estimates.
|
||||
DATAFORSEO_LOGIN=""
|
||||
DATAFORSEO_PASSWORD=""
|
||||
# Soft daily cost cap for the /api/cron/rank-check job. Each Live SERP
|
||||
# check is ~$0.002 — $5/day = ~2,500 keyword checks. Brands whose checks
|
||||
# would push the running total past this cap are skipped for the day.
|
||||
DATAFORSEO_DAILY_CAP_USD="5"
|
||||
|
||||
# ── Stripe Billing (optional — Phase 2) ──────────────────────────────────────
|
||||
STRIPE_SECRET_KEY=""
|
||||
STRIPE_WEBHOOK_SECRET=""
|
||||
|
||||
# ── Optional integrations used by server routes ─────────────────────────────
|
||||
REDIS_URL=""
|
||||
INTEGRATION_ENCRYPTION_KEY=""
|
||||
ADMIN_SECRET=""
|
||||
ADMIN_EMAILS=""
|
||||
ALLOWED_EMAILS=""
|
||||
SMTP_HOST=""
|
||||
SMTP_PORT="587"
|
||||
SMTP_USER=""
|
||||
SMTP_PASS=""
|
||||
SMTP_FROM="meSEO Reports <reports@example.com>"
|
||||
RESEND_API_KEY=""
|
||||
NANGO_SECRET_KEY=""
|
||||
NEXT_PUBLIC_NANGO_PUBLIC_KEY=""
|
||||
INNGEST_EVENT_KEY=""
|
||||
INNGEST_SIGNING_KEY=""
|
||||
TWILIO_ACCOUNT_SID=""
|
||||
TWILIO_AUTH_TOKEN=""
|
||||
TWILIO_PHONE_NUMBER=""
|
||||
ALERT_EMAIL_RECIPIENT=""
|
||||
CLIENT_SESSION_SECRET=""
|
||||
BYPASS_PUBLIC_AUDIT_VERIFICATION="false"
|
||||
SITE_TAG_SIDE_EFFECTS_ENABLED="true"
|
||||
DEBUG_MIDDLEWARE="0"
|
||||
DEBUG_CONFIDENCE="0"
|
||||
PLATFORM_TELEMETRY_DISABLED="0"
|
||||
COST_ALERT_TOTAL="200"
|
||||
COST_ALERT_AI="50"
|
||||
COST_ALERT_DB="50"
|
||||
COST_ALERT_SERP="30"
|
||||
|
||||
# ── Feature Flags ────────────────────────────────────────────────────────────
|
||||
# Set to "false" to disable a feature
|
||||
FEATURE_AI_AVATAR=true
|
||||
FEATURE_SITE_TAG=true
|
||||
FEATURE_BILLING=true
|
||||
|
||||
# ── Debug ────────────────────────────────────────────────────────────────────
|
||||
# Set to "true" to enable debug logging
|
||||
DEBUG_MODE=false
|
||||
@@ -0,0 +1,21 @@
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# meSEO — Production Environment
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Safe to commit — no secrets here.
|
||||
# Real secrets are set in the hosting provider (Vercel, Railway, etc.).
|
||||
|
||||
MESEO_ENV=production
|
||||
|
||||
# These are overridden by the hosting provider's environment variables.
|
||||
# Listed here as documentation of what's needed.
|
||||
|
||||
# NEXT_PUBLIC_APP_URL=https://app.meseo.io
|
||||
# NEXT_PUBLIC_API_URL=https://app.meseo.io/api
|
||||
# NEXT_PUBLIC_COLLECT_ENDPOINT=https://app.meseo.io/api/collect
|
||||
# NEXT_PUBLIC_TRACKER_SCRIPT_URL=https://app.meseo.io/t.js
|
||||
|
||||
# Production feature flags
|
||||
FEATURE_AI_AVATAR=true
|
||||
FEATURE_SITE_TAG=true
|
||||
FEATURE_BILLING=true
|
||||
DEBUG_MODE=false
|
||||
@@ -0,0 +1,41 @@
|
||||
# ci.yml
|
||||
#
|
||||
# Fast quality gate on every push to main and every pull request.
|
||||
# Two checks:
|
||||
# 1. check-raw-siteconversion — no direct SiteConversion queries
|
||||
# that bypass countedConversionWhere (guards against count drift)
|
||||
# 2. tsc --noEmit — no TypeScript type errors
|
||||
#
|
||||
# No deploy steps. Median run time: ~60 seconds.
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
name: Quality gate
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Conversion query guard
|
||||
run: node scripts/check-raw-siteconversion.mjs
|
||||
|
||||
- name: TypeScript typecheck
|
||||
run: npx tsc --noEmit
|
||||
@@ -0,0 +1,174 @@
|
||||
# deploy-lag-monitor.yml
|
||||
#
|
||||
# Runs every hour. Compares the HEAD SHA of origin/main against the SHA
|
||||
# of the current Vercel production deployment. If they differ AND the
|
||||
# main HEAD commit is more than 1 hour old, opens a GitHub issue (or
|
||||
# comments on an existing open one) to alert about a stalled deploy.
|
||||
#
|
||||
# Required repository secrets (Settings -> Secrets and variables -> Actions):
|
||||
# VERCEL_TOKEN - Vercel personal access token (vercel.com -> Account Settings -> Tokens)
|
||||
# VERCEL_PROJECT_ID - Vercel project ID (vercel.com -> Project Settings -> General, "Project ID" field)
|
||||
#
|
||||
# To test manually: Actions tab -> "Deploy Lag Monitor" -> "Run workflow"
|
||||
# To silence a false alert: close the open "deploy-lag-alert" issue and
|
||||
# the next run will not re-open until a new lag condition is detected.
|
||||
|
||||
name: Deploy Lag Monitor
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 * * * *" # every hour at :00
|
||||
workflow_dispatch: # manual trigger for testing
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-deploy-lag:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Check deploy lag
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
with:
|
||||
script: |
|
||||
const ALERT_LABEL = "deploy-lag-alert";
|
||||
const LAG_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
// ── 1. Get HEAD SHA and commit timestamp from the checkout ──────
|
||||
const { execSync } = require("child_process");
|
||||
const mainSha = execSync("git rev-parse HEAD").toString().trim();
|
||||
const mainTimestampStr = execSync("git log -1 --format=%cI HEAD").toString().trim();
|
||||
const mainCommitMsg = execSync("git log -1 --format=%s HEAD").toString().trim();
|
||||
const mainTimestamp = new Date(mainTimestampStr);
|
||||
const commitAgeMs = Date.now() - mainTimestamp.getTime();
|
||||
|
||||
core.info(`main HEAD: ${mainSha} (${mainTimestampStr})`);
|
||||
core.info(`Commit age: ${Math.round(commitAgeMs / 60000)} minutes`);
|
||||
|
||||
// ── 2. Get current Vercel production deployment SHA ──────────────
|
||||
let vercelSha = null;
|
||||
let vercelDeployedAt = null;
|
||||
let vercelDeployUrl = null;
|
||||
|
||||
if (!process.env.VERCEL_TOKEN || !process.env.VERCEL_PROJECT_ID) {
|
||||
core.warning("VERCEL_TOKEN or VERCEL_PROJECT_ID secret not set. Skipping check.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://api.vercel.com/v6/deployments?projectId=${process.env.VERCEL_PROJECT_ID}&target=production&limit=1&state=READY`,
|
||||
{ headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` } }
|
||||
);
|
||||
if (!res.ok) {
|
||||
core.warning(`Vercel API returned ${res.status}. Skipping check.`);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
const latest = data.deployments?.[0];
|
||||
if (latest) {
|
||||
vercelSha = latest.meta?.githubCommitSha ?? null;
|
||||
vercelDeployedAt = latest.createdAt ? new Date(latest.createdAt).toISOString() : "unknown";
|
||||
vercelDeployUrl = latest.url ? `https://${latest.url}` : null;
|
||||
}
|
||||
} catch (err) {
|
||||
core.warning(`Failed to fetch Vercel deployment: ${err.message}. Skipping check.`);
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(`Vercel production SHA: ${vercelSha ?? "unknown"}`);
|
||||
|
||||
// ── 3. Decide if there is a lag condition ───────────────────────
|
||||
// Only alert when:
|
||||
// a) SHAs differ (or Vercel SHA unknown), AND
|
||||
// b) The main commit is older than LAG_THRESHOLD_MS
|
||||
// (prevents noise on commits that just landed)
|
||||
const shasMismatch = vercelSha !== mainSha;
|
||||
const isOldEnough = commitAgeMs > LAG_THRESHOLD_MS;
|
||||
|
||||
if (!shasMismatch) {
|
||||
core.info("Production is up to date. No action needed.");
|
||||
return;
|
||||
}
|
||||
if (!isOldEnough) {
|
||||
const minutesOld = Math.round(commitAgeMs / 60000);
|
||||
core.info(`SHA mismatch but commit is only ${minutesOld}m old. Within grace period.`);
|
||||
return;
|
||||
}
|
||||
|
||||
core.warning(`Deploy lag detected! main=${mainSha} vercel=${vercelSha ?? "unknown"}`);
|
||||
|
||||
// ── 4. Check for an existing open alert issue ───────────────────
|
||||
const { data: existingIssues } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: ALERT_LABEL,
|
||||
state: "open",
|
||||
per_page: 1,
|
||||
});
|
||||
|
||||
const hoursOld = Math.round(commitAgeMs / 3_600_000);
|
||||
const body = [
|
||||
`**Production deploy lag detected.**`,
|
||||
``,
|
||||
`| Field | Value |`,
|
||||
`|---|---|`,
|
||||
`| \`main\` HEAD SHA | \`${mainSha}\` |`,
|
||||
`| Vercel production SHA | \`${vercelSha ?? "unknown"}\` |`,
|
||||
`| Last commit | ${mainCommitMsg} |`,
|
||||
`| Commit pushed | ${mainTimestampStr} (~${hoursOld}h ago) |`,
|
||||
`| Vercel last deploy | ${vercelDeployedAt} |`,
|
||||
`${vercelDeployUrl ? `| Deploy URL | ${vercelDeployUrl} |` : ""}`,
|
||||
``,
|
||||
`**Next steps:**`,
|
||||
`1. Open the Vercel dashboard and check for a failed or queued build.`,
|
||||
`2. Check GitHub Settings -> Webhooks -> Vercel webhook -> Recent Deliveries for failures.`,
|
||||
`3. If the webhook is healthy, trigger a manual redeploy from the Vercel dashboard.`,
|
||||
``,
|
||||
`_Close this issue once the deploy is confirmed live._`,
|
||||
].join("\n");
|
||||
|
||||
if (existingIssues.length > 0) {
|
||||
// Comment on the existing issue instead of opening a duplicate.
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: existingIssues[0].number,
|
||||
body: `**Still lagging** (hourly check at ${new Date().toISOString()})\n\n${body}`,
|
||||
});
|
||||
core.info(`Commented on existing alert issue #${existingIssues[0].number}`);
|
||||
} else {
|
||||
// Ensure the label exists before creating the issue.
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: ALERT_LABEL,
|
||||
});
|
||||
} catch {
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: ALERT_LABEL,
|
||||
color: "e11d48",
|
||||
description: "Auto-opened by deploy-lag-monitor workflow",
|
||||
});
|
||||
}
|
||||
const { data: newIssue } = await github.rest.issues.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title: "[ALERT] Production deploy lag detected",
|
||||
body,
|
||||
labels: [ALERT_LABEL],
|
||||
});
|
||||
core.warning(`Opened alert issue #${newIssue.number}: ${newIssue.html_url}`);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
node_modules/
|
||||
.next/
|
||||
*.env
|
||||
*.env.local
|
||||
# Allow safe-to-commit env files (no secrets)
|
||||
!.env.example
|
||||
!.env.development
|
||||
!.env.production
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
/src/generated/prisma
|
||||
# cache bust
|
||||
|
||||
meseo.zip
|
||||
public/t.source.js
|
||||
@@ -0,0 +1 @@
|
||||
node scripts/check-raw-siteconversion.mjs
|
||||
@@ -0,0 +1,11 @@
|
||||
> Why do I have a folder named ".vercel" in my project?
|
||||
The ".vercel" folder is created when you link a directory to a Vercel project.
|
||||
|
||||
> What does the "project.json" file contain?
|
||||
The "project.json" file contains:
|
||||
- The ID of the Vercel project that you linked ("projectId")
|
||||
- The ID of the user or team your Vercel project is owned by ("orgId")
|
||||
|
||||
> Should I commit the ".vercel" folder?
|
||||
No, you should not share the ".vercel" folder with anyone.
|
||||
Upon creation, it will be automatically added to your ".gitignore" file.
|
||||
@@ -0,0 +1 @@
|
||||
{"projectId":"prj_jHaksMHPCD7BfGzwPc3ykcRUky8W","orgId":"team_56M1v6AbT62mYSPCZzR2LtQT","projectName":"meseo"}
|
||||
@@ -0,0 +1,104 @@
|
||||
# Sidebar Page Audit Report
|
||||
|
||||
_Audit performed against sidebar navigation (`src/components/sidebar.tsx`) on the feature branch `claude/general-session-mMtpD`. 23 routes checked._
|
||||
|
||||
## Summary
|
||||
|
||||
- **Total pages checked:** 23
|
||||
- ✅ **Working with proper empty states:** 14
|
||||
- ⚠️ **Works but missing empty state / partial gating:** 7
|
||||
- ❌ **Broken or will crash:** 0
|
||||
- 🚫 **Page file missing:** 0
|
||||
- 🪹 **Stub pages (route exists but no real content):** 2
|
||||
|
||||
---
|
||||
|
||||
## Detailed Findings
|
||||
|
||||
### ✅ Working Pages (14)
|
||||
|
||||
| Route | Path | Notes |
|
||||
|---|---|---|
|
||||
| `/brands-hub` | `src/app/brands-hub/page.tsx` | (skipped per instructions — already working) |
|
||||
| `/signals` | `src/app/signals/page.tsx` | Full error handling, "No signals for this filter" empty state, task creation error feedback |
|
||||
| `/website-upload` | `src/app/website-upload/page.tsx` | Form validation gates submission, orgId check, success state with next steps |
|
||||
| `/site-tag` | `src/app/site-tag/page.tsx` | Excellent setup CTA empty state ("Generate meSEO Site Tag"), safe null checks, loading spinner |
|
||||
| `/dashboard` | `src/app/dashboard/page.tsx` | Full state reset on brand change, per-source connection gating, 5 distinct KPI card states, "Connect GSC" / "Awaiting Data" / connected states for Search Performance |
|
||||
| `/conversion-intelligence` | `src/app/conversion-intelligence/page.tsx` | State machine with 6 explicit states: loading, not_connected, awaiting_sync, connected_empty, connected, error. Retry button on error state |
|
||||
| `/competitors` | `src/app/competitors/page.tsx` | Uses `hasData()` helper + SourceEmptyState, graceful filter-empty state, safe network error handling |
|
||||
| `/technical-audit` | `src/app/technical-audit/page.tsx` | Robust — 3-minute polling timeout, specific 429/403/504 error messages, 3 distinct states (no audit / running / failed), limited coverage warning |
|
||||
| `/site-performance-audit` | `src/app/site-performance-audit/page.tsx` | "No Audit Yet" / "Running" / "Failed" states, network error caught |
|
||||
| `/dead-pages` | `src/app/dead-pages/page.tsx` | Uses `getSourceStatus("dead_pages")` integration check, comprehensive empty state, "Select row" fallback |
|
||||
| `/page-studio` | `src/app/page-studio/page.tsx` | Client-side canvas builder with no API calls, onboarding flow when no blocks, toast feedback for all actions |
|
||||
| `/recommendations` | `src/app/recommendations/page.tsx` | Uses `hasData()` + SourceEmptyState, "No Issues Found" success state when `counts.total === 0` |
|
||||
| `/execution-hub` | `src/app/execution-hub/page.tsx` | Brand guard on useEffect, try/catch on task operations, optimistic UI with rollback, "No tasks match filters" empty state |
|
||||
| `/page-launch-tracker` | `src/app/page-launch-tracker/page.tsx` | SourceEmptyState when no launches, action link to Execution Hub |
|
||||
| `/settings` | `src/app/settings/page.tsx` | Static form with mock team data, Save feedback, no API dependencies |
|
||||
| `/pricing` | `src/app/pricing/page.tsx` | Graceful API fallback to `FALLBACK_PLANS`, loading spinner, always renders content |
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ Missing Empty States / Partial Gating (7)
|
||||
|
||||
| Route | Path | Issue |
|
||||
|---|---|---|
|
||||
| `/ai-strategist` | `src/app/ai-strategist/page.tsx` | Has rich empty state for no-analysis case, but **no pre-flight integration check** — calls `/api/analysis` unconditionally. Relies on backend to return partial data. A brand new brand with no sources connected will still see the "Generate Analysis" button but the generated analysis will be mostly empty. Recommendation: add an integration-status banner before the "Generate" CTA. |
|
||||
| `/integrations` | `src/app/integrations/page.tsx` | Shows all integrations as cards regardless of state, but **no welcoming onboarding banner** when zero integrations are connected. `loadIntegrationStatus()` silently swallows errors. New users may not know where to start. Recommendation: add "Get started — connect GA4 or GSC" banner at top when `connectedCount === 0`. |
|
||||
| `/content-brief` | `src/app/content-brief/page.tsx` | **Silent fallback to `MOCK_BLOCKS`** when API fails or returns empty. No visual indicator distinguishing mock content from real saved briefs. Users may edit mock data thinking it's real. Recommendation: add banner "No saved briefs yet — this is a template" or hide editor until user creates a brief. |
|
||||
| `/llmo-content-brief` | `src/app/llmo-content-brief/page.tsx` | **Identical issue** to `/content-brief` — falls back to mock data silently with no indicator. |
|
||||
| `/report-studio` | `src/app/report-studio/page.tsx` | **No error handling** on report builds or data source toggles. **No empty state** when zero sections selected. Disabled-source check exists on toggles but no warning when building with unconnected sources. Build button active even with 0 sections. Recommendation: disable build button until at least 1 section + 1 connected source is selected. |
|
||||
| `/execution-hub` | `src/app/execution-hub/page.tsx` | Otherwise working, but **no pre-flight integration gating** — users can create tasks for a brand that has zero integrations connected. Not strictly broken since tasks are brand-agnostic, but could be confusing. (Minor — flagging for visibility.) |
|
||||
| `/technical-audit` | `src/app/technical-audit/page.tsx` | Otherwise working, but **no `getSourceStatus` integration check** before fetching. Dead-pages page does this correctly and could serve as a pattern. (Minor — flagging for consistency.) |
|
||||
|
||||
---
|
||||
|
||||
### ❌ Broken Pages (0)
|
||||
|
||||
_None found. No page will crash, render a blank white screen, or show raw errors._
|
||||
|
||||
---
|
||||
|
||||
### 🚫 Missing Pages (0)
|
||||
|
||||
_All 23 sidebar routes have a corresponding `page.tsx` file. No broken links._
|
||||
|
||||
---
|
||||
|
||||
### 🪹 Stub Pages (2)
|
||||
|
||||
These pages exist but are essentially **placeholder stubs** that render `SourceEmptyState` regardless of integration status — including when the source is connected. They never display real data.
|
||||
|
||||
| Route | Path | Issue |
|
||||
|---|---|---|
|
||||
| `/ai-visibility` | `src/app/ai-visibility/page.tsx` | Checks `if (!status \|\| status.status !== "connected")` → renders `SourceEmptyState`. **But the "connected" branch also returns the same `SourceEmptyState`** — no actual implementation. Page is a stub. |
|
||||
| `/local-geo` | `src/app/local-geo/page.tsx` | **Identical stub** to `/ai-visibility`. Always renders empty state, no real content regardless of connection status. |
|
||||
|
||||
**Recommendation:** Either (a) implement the actual data view for the "connected" branch, or (b) update the empty state copy to be honest ("AI Visibility tracking launches soon — we'll notify you when it's ready") so users understand this is a coming-soon feature rather than a broken page.
|
||||
|
||||
---
|
||||
|
||||
## Critical Launch-Blocking Issues
|
||||
|
||||
**None.** No page will crash or show a raw error. However, the following are **UX launch risks** worth addressing:
|
||||
|
||||
### Priority 1 (fix before launch)
|
||||
1. **`/ai-visibility` and `/local-geo` stubs** — users will click these from the sidebar and get the same empty state whether or not they have integrations. Either implement or mark as "Coming Soon".
|
||||
2. **`/content-brief` and `/llmo-content-brief` mock fallback** — users may edit mock content thinking it's saved. Add a visible "Template / Not saved" indicator.
|
||||
3. **`/report-studio` no-op build button** — add validation so users can't build empty reports.
|
||||
|
||||
### Priority 2 (post-launch polish)
|
||||
1. `/integrations` welcome banner for zero-connected state
|
||||
2. `/ai-strategist` pre-flight integration check
|
||||
3. `/technical-audit` and `/execution-hub` source-status gating for consistency
|
||||
|
||||
---
|
||||
|
||||
## Audit Coverage
|
||||
|
||||
✅ All 23 sidebar routes checked
|
||||
✅ Page files existence verified
|
||||
✅ Error handling traced (try/catch, null checks, response.ok)
|
||||
✅ Empty state handling verified for zero-data scenarios
|
||||
✅ Integration gating reviewed
|
||||
✅ Import resolution confirmed
|
||||
✅ New-brand scenario considered (brand with no connected sources, no audits, no tasks)
|
||||
@@ -0,0 +1,45 @@
|
||||
FROM node:22-slim AS deps
|
||||
|
||||
WORKDIR /app
|
||||
ENV HUSKY=0
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openssl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY prisma ./prisma
|
||||
RUN npm ci
|
||||
|
||||
FROM node:22-slim AS build
|
||||
|
||||
WORKDIR /app
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openssl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-slim AS runner
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openssl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=build /app/public ./public
|
||||
COPY --from=build /app/.next/standalone ./
|
||||
COPY --from=build /app/.next/static ./.next/static
|
||||
COPY --from=build /app/prisma ./prisma
|
||||
COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=build /app/node_modules/@prisma/client ./node_modules/@prisma/client
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,203 @@
|
||||
# meSEO — AI-Powered SEO & LLMO Intelligence Platform
|
||||
|
||||
meSEO is a comprehensive SEO and Large Language Model Optimization (LLMO) intelligence platform with first-party cookieless tracking, universal form/booking plugin detection, and AI-powered content optimization.
|
||||
|
||||
**Production:** [app.meseoapp.com](https://app.meseoapp.com)
|
||||
**Admin Portal:** [admin.meseoapp.com](https://admin.meseoapp.com)
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend:** Next.js 14 (App Router) + TypeScript + Tailwind CSS
|
||||
- **Database:** Neon PostgreSQL + Prisma ORM
|
||||
- **Auth:** Clerk (organizations + role-based access)
|
||||
- **AI:** Anthropic Claude + OpenAI
|
||||
- **SERP Data:** DataForSEO
|
||||
- **Hosting:** Vercel
|
||||
- **Analytics Sources:** Google Analytics 4 (GA4), Google Search Console (GSC), First-Party Site Tag
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
app.meseoapp.com → Main application (brand owners, agencies)
|
||||
admin.meseoapp.com → Admin portal (superadmin operations center)
|
||||
/api/collect → Site Tag event ingestion endpoint
|
||||
/api/cron/* → Scheduled jobs (data retention, platform snapshots)
|
||||
```
|
||||
|
||||
Same Vercel project, hostname-based middleware routing.
|
||||
|
||||
## Core Features
|
||||
|
||||
### Site Tag (First-Party Tracking)
|
||||
- **Cookieless** — zero `document.cookie` usage, sessionStorage/localStorage only
|
||||
- **Universal form detection** — native `<form>`, `[role="form"]`, container-based, standalone inputs
|
||||
- **Booking plugin detection** — Amelia (Elementor Step Booking variant), Calendly, NexHealth, Acuity, JotForm, Typeform, WPForms, Gravity Forms, Contact Form 7, HubSpot
|
||||
- **Chat widget detection** — DearDoc, Birdeye, Podium, Weave
|
||||
- **Device + geolocation** — User-Agent parsing (device/browser/OS) + Vercel geo headers (country, city, region) stored on session start
|
||||
- **21 healthcare iframe providers** detected (PatientPop, Tebra, NexHealth, MyChart, Epic, Cerner, athenahealth, Doctible, etc.)
|
||||
- **gtag/dataLayer interception** — captures GA4 custom events (make_an_appointment, contact_us, etc.) with pattern-based fallback matching
|
||||
- **Form & Booking Funnel** — Intent → Started → Submitted → Confirmed with per-stage drop-off
|
||||
- **Conversion taxonomy** — Tier 1 (hard: appointment_booked, form_submitted, phone_call, purchase) vs Tier 2 (soft: appointment_attempted, form_started, appointment_intent)
|
||||
- **30-second dedup window** with source-authority ranking and tier-upgrade
|
||||
- **HIPAA-aware** — never captures form field values, strips query params in healthcare mode
|
||||
- **3,400+ lines** of tracking intelligence in `public/t.js`
|
||||
|
||||
### Intelligence Pages
|
||||
- **Dashboard** — All Traffic / Organic toggle, KPI cards, tail cards
|
||||
- **Site Tag Analytics** — Live polling, event breakdown, form funnel, detected plugins, data confidence gauge
|
||||
- **AI Visibility** — LLM citation checking across ChatGPT, Gemini, Perplexity
|
||||
- **Keyword Research** — SERP analysis + keyword opportunities
|
||||
- **Rank Tracker** — Daily position monitoring
|
||||
- **Competitor Intel** — Keyword battles, word count analysis, SERP overlap
|
||||
- **Content Intel** — Content health scoring, accordion tiles
|
||||
- **Backlinks** — Backlink profile analysis
|
||||
- **Industry Intel** — 10-vertical regulatory map, visitor geography, seasonal correlations
|
||||
- **Technical Audit** — 200-page crawl with incremental progress
|
||||
- **Core Web Intelligence** — Multi-page CWV with competitor comparison
|
||||
- **Schema Generator** — JSON-LD generation with validation
|
||||
- **Broken Links** — Site-wide broken link detection
|
||||
- **Signals Center** — Persistent dismiss with 14-day TTL, revenue impact badges
|
||||
|
||||
### Content & Reports
|
||||
- **SEO Content Brief** — AI-generated briefs with inline schema
|
||||
- **LLMO Content Brief** — LLM optimization briefs
|
||||
- **Writing Assistant** — AI-powered content rewriting
|
||||
- **Page Studio** — Page creation and optimization
|
||||
- **Topic Map** — Content cluster visualization
|
||||
- **Report Studio** — Automated report generation
|
||||
|
||||
### Settings
|
||||
- **Profile** — Editable name, avatar upload via Clerk, IANA timezone dropdown, change password
|
||||
- **Billing** — Real plan data, trial countdown, change plan / manage billing links
|
||||
- **Notifications** — 5 email preference toggles with optimistic update
|
||||
- **Team** — Per-member brand access, seat count display, role management, invite with seat limit enforcement
|
||||
- **Usage** — 8 metrics with progress bars, color-coded thresholds, unlimited tier gradient bars
|
||||
|
||||
### Admin Portal (admin.meseoapp.com)
|
||||
- **Dashboard** — Platform KPIs with Site Tag / GA4 / GSC data source separation, brand filter, reconciliation
|
||||
- **Users** — Search, role filter, detail drawer, suspend/delete/brand-grant actions
|
||||
- **Brands** — Health ring, integration status dots, detail drawer with radar chart
|
||||
- **Session Explorer** — Per-session interaction timeline with device/location, repeat interaction highlighting
|
||||
- **AI Monitor** — Guardrail scoring, severity-colored feed, cost-by-brand breakdown
|
||||
- **Platform Analytics** — Brand Strength Radar (8 dimensions), Engagement (DAU/WAU/MAU + cohort retention), Data Moat, Growth & Retention (activation funnel + churn)
|
||||
- **Cost Center** — All vendor costs (Anthropic, OpenAI, Neon, Vercel, Clerk, DataForSEO, Resend) with live DataForSEO balance
|
||||
- **Compliance** — GDPR Article 17 deletion, Article 15 export, data retention cron, healthcare mode
|
||||
- **Data Export** — 4 due-diligence CSV exports (platform metrics, anonymized users, brands, growth time-series)
|
||||
- **Health Monitor** — DB row counts, cron status, recent errors
|
||||
- **Activity Feed** — Real-time 10s polling with connected timeline
|
||||
- **Conversion Funnel** — 5-stage funnel (Visit → Engaged → Intent → Started → Converted) with by-source and by-entry-page breakdowns
|
||||
- **Client Portal** — White-label setup, custom domain config, per-client page access controls, branded invite emails
|
||||
|
||||
### Monetization
|
||||
- **5 pricing tiers:** Growth ($249), Professional ($479), Agency ($1,299), Agency Pro ($2,499), Enterprise (Custom)
|
||||
- **Feature gates** — `<FeatureGate>` component + `useFeatureGate` hook + server-side `requirePlanFeature`
|
||||
- **Seat limits** — 2/5/10/25/unlimited per plan with enforcement at invite time
|
||||
- **Upgrade modal** — Auto-opens on 403 upgrade_required via `useUpgradeAwareFetch`
|
||||
- **Sidebar lock icons** — 15 protected routes mapped to feature keys
|
||||
- **Superadmin bypass** — all gates skip for superadmin role
|
||||
|
||||
### Security & Access Control
|
||||
- **BrandMembership** — Explicit brand access grants (not implicit org-wide)
|
||||
- **`verifyBrandAccess`** — Per-request brand authorization with audit logging
|
||||
- **`NoBrandAccessGuard`** — Redirects users with 0 brands to /no-access
|
||||
- **Superadmin** — Clerk publicMetadata.role via customized session token
|
||||
|
||||
### Conversion Accuracy
|
||||
- **Conversion taxonomy** — canonical types with Tier 1/2 classification
|
||||
- **Conversion resolver** — 30s dedup window, source-authority ranking, tier-upgrade in-place
|
||||
- **GA4 reconciliation** — overlapping-window comparison with daily rates
|
||||
- **Data confidence gauge** — 6-factor weighted score (session match, conversion capture, freshness, dedup quality, event coverage, plugin detection)
|
||||
- **Deduped KPIs** — COUNT(DISTINCT session+page+type), not raw row count
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```
|
||||
# Database
|
||||
DATABASE_URL= # Neon PostgreSQL pooler endpoint
|
||||
|
||||
# Auth
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
|
||||
CLERK_SECRET_KEY=
|
||||
|
||||
# AI
|
||||
ANTHROPIC_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# SERP Data
|
||||
DATAFORSEO_LOGIN=
|
||||
DATAFORSEO_PASSWORD=
|
||||
|
||||
# Google
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GA4_PROPERTY_ID=
|
||||
|
||||
# App
|
||||
NEXT_PUBLIC_APP_URL=https://app.meseoapp.com
|
||||
```
|
||||
|
||||
## Clerk Session Token
|
||||
|
||||
In Clerk Dashboard → Configure → Sessions → Customize session token:
|
||||
|
||||
```json
|
||||
{
|
||||
"metadata": "{{user.public_metadata}}"
|
||||
}
|
||||
```
|
||||
|
||||
Required for superadmin role detection in middleware.
|
||||
|
||||
## Cron Jobs
|
||||
|
||||
| Schedule | Endpoint | Purpose |
|
||||
|----------|----------|---------|
|
||||
| Daily 03:00 UTC | `/api/cron/data-retention` | Purge data older than retention period (395 days default) |
|
||||
| Monthly 1st 01:00 UTC | `/api/cron/platform-snapshot` | Capture platform metrics snapshot |
|
||||
|
||||
## Key Architectural Decisions
|
||||
|
||||
- **Same Vercel project** for app + admin, hostname-based middleware routing
|
||||
- **Cookieless tracking** via sessionStorage/localStorage — no cookie consent needed
|
||||
- **Conversion taxonomy** with Tier 1 (hard) vs Tier 2 (soft) — only Tier 1 counts in KPIs
|
||||
- **gtag/dataLayer interception** as primary conversion source for GA4-instrumented sites
|
||||
- **30-second dedup window** with tier-upgrade for conversion resolver
|
||||
- **DataForSEO** never mentioned in user-facing UI — labeled "SERP & Keyword Lookups"
|
||||
- **Site Tag** at 3,400+ lines with universal plugin detection (10 booking + 4 chat providers)
|
||||
- **PlatformEvent + AiInteraction** telemetry with fire-and-forget pattern
|
||||
|
||||
## Site Tag Detection Patterns
|
||||
|
||||
The Site Tag (`public/t.js`) detects third-party plugins and integrations running on a brand's site and fires `plugin_detected` events. Detection patterns live in the unminified source file, which is gitignored:
|
||||
|
||||
**Detection source:** `public/t.source.js`
|
||||
Three registries:
|
||||
- `BOOKING_PLUGINS` (~line 5832) -- appointment and form plugins
|
||||
- `CHAT_WIDGETS` (~line 6416) -- live chat and messaging widgets
|
||||
- `ANALYTICS_PLUGINS` (~line 6659) -- analytics, pixels, SEO, CMS, e-commerce, CRM, and more (~47 entries)
|
||||
|
||||
**Enrichment metadata:** `src/lib/site-tag/integration-signatures.ts`
|
||||
Maps each `plugin_key` to vendor name, category, and description. Consumed by the backfill route and the Integrations tab API.
|
||||
|
||||
**Adding a new detection rule:**
|
||||
1. Add an entry to the appropriate registry in `public/t.source.js`
|
||||
2. Add a matching entry in `ENRICHMENT_REGISTRY` in `integration-signatures.ts`
|
||||
3. Build the tag: `cp public/t.source.js public/t.js && npm run build:site-tag`
|
||||
4. Bump `TAG_VERSION` in `public/t.js` to today's date (`YYYY.MM.DD`)
|
||||
|
||||
See `docs/phase-30-site-tag-detection-expansion.md` for the full pattern guide, detection strategy examples, and performance constraints.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx prisma db push
|
||||
npx prisma generate
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Local admin portal: `admin.localhost:3000`
|
||||
|
||||
## License
|
||||
|
||||
Proprietary — All rights reserved.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Coolify scheduled tasks
|
||||
|
||||
The Vercel cron configuration in `vercel.json` is not executed automatically by Coolify.
|
||||
Create one scheduled task per entry in that file. Each task must send:
|
||||
|
||||
```text
|
||||
Authorization: Bearer $CRON_SECRET
|
||||
```
|
||||
|
||||
Use the exact route and schedule from `vercel.json`. Example command:
|
||||
|
||||
```bash
|
||||
curl --fail --max-time 900 \
|
||||
-H "Authorization: Bearer ${CRON_SECRET}" \
|
||||
https://app2.meseoapp.com/api/cron/data-retention
|
||||
```
|
||||
|
||||
Set `CRON_SECRET` as a Coolify secret/environment variable. Do not commit its value.
|
||||
Do not allow overlapping runs of the same task, especially for rollups, audits, rank checks,
|
||||
and integration sync.
|
||||
|
||||
Run `npm run db:push` separately and deliberately when a schema change is approved.
|
||||
It is intentionally not part of `npm run build`.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Architecture Conventions
|
||||
|
||||
## Deployment Conventions
|
||||
|
||||
### Multi-brand testing requirement
|
||||
|
||||
Any code change to shared services (audit pipeline, site-tag-analytics,
|
||||
synthesizers, categorizers, conversion type registry) must be verified
|
||||
against at least:
|
||||
|
||||
- One medical brand (cardiology or imaging) for dense events, multi-language
|
||||
data shapes, and HIPAA-relevant surfaces
|
||||
- One service brand (lawn care portfolio) for high-volume events and
|
||||
multi-domain reconciliation
|
||||
- One B2B brand (TPAction / AiGrowth360) for low-event-volume edge cases
|
||||
and lead gen flows
|
||||
- One ecommerce brand if available for product schema and checkout flows
|
||||
|
||||
Verification recipes must specify at least two brands of different verticals.
|
||||
"Verify on cardiology" alone is insufficient.
|
||||
|
||||
Single-brand verification was the root cause of the Phase 19A.16 missed
|
||||
form_filled crash on Fairway Lawns (May 27, 2026).
|
||||
|
||||
### Conversion type registration requirement
|
||||
|
||||
Before merging any change to shared event or conversion categorization code,
|
||||
run the comprehensive type discovery SQL:
|
||||
|
||||
```sql
|
||||
SELECT "brandId", "conversionType", COUNT(*)
|
||||
FROM "SiteConversion"
|
||||
WHERE timestamp >= NOW() - INTERVAL '30 days'
|
||||
AND "conversionType" NOT IN (
|
||||
'appointment_booked','form_submitted','phone_call','email_contact',
|
||||
'sms_contact','purchase','appointment_attempted','form_started',
|
||||
'appointment_intent','chat_initiated','newsletter_signup','add_to_cart',
|
||||
'checkout_started','file_download','waitlist_signup','form_filled',
|
||||
'form_submit','form_submission','phone_number_click','click_to_call',
|
||||
'calendly','booking_confirmed','booking_attempt','email_click',
|
||||
'sms_click','phone_copy','phone_input','form_iframe_present',
|
||||
'error_count_update'
|
||||
)
|
||||
GROUP BY "brandId", "conversionType"
|
||||
ORDER BY count DESC;
|
||||
```
|
||||
|
||||
Confirm zero rows returned, or explicitly handle every type returned before
|
||||
shipping.
|
||||
|
||||
New types must be registered in:
|
||||
1. `SITE_CONVERSION_TYPES` in `src/lib/services/site-tag-analytics.ts`
|
||||
(query allowlist -- unregistered types are silently dropped)
|
||||
2. `CONVERSION_TIERS` in `src/lib/conversions/tiers.ts`
|
||||
(tier mapping -- unregistered types crash route.ts via undefined key lookup)
|
||||
|
||||
The defensive guard at `src/app/api/site-tag/analytics/route.ts` around
|
||||
the `tierTotals[cfg.tier]` access will log a warning and skip rather than
|
||||
crash for any future type that slips through, but the correct fix is always
|
||||
to register the type proactively.
|
||||
|
||||
### Tier assignment guidelines
|
||||
|
||||
| Tier | Description | isCounted | Examples |
|
||||
|------|-------------|-----------|---------|
|
||||
| completed | Definitive conversion action | true | form_submit, phone_call, appointment_booked, purchase |
|
||||
| intent | Started a conversion flow | false | form_filled, appointment_attempted, checkout_started |
|
||||
| signal | Engagement only (no conversion flow) | false | phone_copy, phone_input, form_iframe_present |
|
||||
|
||||
The `DEFAULT_COUNTED_TIERS` constant controls which tiers roll up into the
|
||||
headline conversion count and conversion rate. Only "completed" is counted
|
||||
by default. Brand-level overrides via `BrandConversionConfig.isCounted` can
|
||||
promote intent signals to counted status for specific brands.
|
||||
@@ -0,0 +1,241 @@
|
||||
# Audit Pipeline: Current State (Phase 19A.1 Discovery)
|
||||
|
||||
**Date:** 2026-05-26
|
||||
**Scope:** Read-only architectural discovery of the full audit pipeline as it exists post-Phase 17.
|
||||
|
||||
---
|
||||
|
||||
## 1. Three Distinct Audit Runners
|
||||
|
||||
The codebase contains three independent audit runners with separate entry points, separate Prisma write targets, and separate triggering surfaces. They share no code.
|
||||
|
||||
### 1.1 Main SEO Audit Runner
|
||||
|
||||
**File:** `src/lib/audit/audit-runner.ts` (1594 lines)
|
||||
**Export:** `runAudit(auditId: string): Promise<void>`
|
||||
**Triggered by:** `POST /api/admin/audits/trigger` via Vercel `waitUntil`
|
||||
**Prisma write target:** `Audit.seoSection` (JSONB)
|
||||
**Model:** central to all Phase 11A-17 dimension output
|
||||
|
||||
This is the primary audit pipeline. It orchestrates all dimension data sources, the two LLM synthesis calls, and the final DB write. All output lands in a single JSONB field (`Audit.seoSection`) on the `Audit` record.
|
||||
|
||||
### 1.2 Technical Audit Runner
|
||||
|
||||
**File:** `src/lib/services/audit-runner.ts`
|
||||
**Export:** `runTechnicalAudit(brandId: string, domain: string): Promise<AuditResult>`
|
||||
**Triggered by:** `POST /api/admin/audits/[id]/retry` and brand-health scheduler
|
||||
**Prisma write targets:** `TechnicalAudit`, `TechnicalIssue` (row-per-issue table)
|
||||
**Model:** independent crawler with headless fallback via Puppeteer/Chromium
|
||||
|
||||
Crawls up to 100 pages, checks each for HTTP errors, broken links, redirect chains, missing meta, etc., then bulk-inserts findings as `TechnicalIssue` rows.
|
||||
|
||||
### 1.3 Performance Audit Runner
|
||||
|
||||
**File:** `src/lib/services/performance-audit-runner.ts`
|
||||
**Export:** `runPerformanceAudit(brandId: string, domain: string): Promise<PerfAuditResult>`
|
||||
**Triggered by:** `POST /api/performance-audit/run`
|
||||
**Prisma write targets:** `PerformanceAudit`, `PerformancePageScore` (row-per-URL)
|
||||
**Model:** PageSpeed Insights API calls per URL, scored per strategy (mobile/desktop)
|
||||
|
||||
Reads from `GscPage` and `TrackedEvent` to pick the top pages to test, then calls PageSpeed Insights for each, writing per-URL scores to `PerformancePageScore`.
|
||||
|
||||
### 1.4 Content Audit
|
||||
|
||||
**API route:** `POST /api/content-audit/run`
|
||||
**Prisma write targets:** `ContentAuditResult`, `AuditScoreHistory`
|
||||
**Note:** Not a standalone runner file -- logic lives inline in the route handler.
|
||||
|
||||
---
|
||||
|
||||
## 2. Main Audit Runner: Phase Sequence
|
||||
|
||||
The `runAudit` function orchestrates work in five labeled phases plus a degraded-mode path.
|
||||
|
||||
```
|
||||
Phase 1 DFS site crawl + first-party data fetch + (3rd-party) backlink fetch [parallel]
|
||||
Phase 2 Firecrawl key-page content fetch + site analysis [parallel]
|
||||
Phase 3 Industry classification (Haiku, ~5 s) [sequential]
|
||||
Phase 4 SERP analysis + bilingual audit (3rd-party) [parallel]
|
||||
Phase 5 All dimension aggregators + two LLM synthesis calls [parallel where possible]
|
||||
Phase 12 Live visual annotations + competitor visual comparison [parallel, 180 s budget]
|
||||
```
|
||||
|
||||
**Degraded mode** activates when the crawl returns 0 pages. It skips SERP-dependent dimensions (snippet capture) and runs a subset: Phase 12 (homepage-only), 13, and 15 in parallel using fallback data.
|
||||
|
||||
---
|
||||
|
||||
## 3. industryContext: Lifecycle and Constraints
|
||||
|
||||
`industryContext` is computed in Phase 3 by `classifyIndustry()` and passed through the pipeline as an in-memory value. It is **never written to Prisma**.
|
||||
|
||||
**Type (as of Phase 19.0):**
|
||||
```typescript
|
||||
interface IndustryContext {
|
||||
industry: string; // backward-compat alias for industries[0]
|
||||
industries: string[]; // all applicable industries, primary first (max 3)
|
||||
subVertical: string;
|
||||
audienceFraming: string;
|
||||
relevantFrameworks: string[];
|
||||
aiVisibilityBenchmark: string;
|
||||
schemaPriorities: string[];
|
||||
suggestedQueries: string[]; // 5-7 queries; fed to Phase 4 SERP
|
||||
conversionPathExpectation: string[];
|
||||
}
|
||||
```
|
||||
|
||||
**Flow:**
|
||||
1. Haiku classifies the brand using homepage markdown (first 1200 chars).
|
||||
2. Result stored on `AuditInput.industryContext`.
|
||||
3. `suggestedQueries` fed to `fetchSerpAnalysis` in Phase 4.
|
||||
4. `industryContext` passed to all Phase 5 dimension aggregators that need it.
|
||||
5. `inferredIndustry` (= `industries[0]`) stored in `seoSection.inferredIndustry` -- the only persisted trace of classification.
|
||||
|
||||
**Models used:**
|
||||
- Industry classifier: `claude-haiku-4-5-20251001`
|
||||
- SEO narrative: `claude-sonnet-4-6` (with prompt caching)
|
||||
- Structured synthesis: `claude-haiku-4-5-20251001`
|
||||
|
||||
---
|
||||
|
||||
## 4. AuditInput: The Pipeline Accumulator
|
||||
|
||||
`AuditInput` (defined in `src/lib/audit/types.ts`) is a mutable object assembled incrementally across Phases 1-5. Each phase appends its output to the relevant optional field. The synthesizer receives the fully-populated input at Phase 5.
|
||||
|
||||
All `*Context` fields are optional. Absent fields signal "not available" -- dimension aggregators and the synthesizer each have fallback behavior when their input is null.
|
||||
|
||||
**First-party-only fields** (absent on 3rd-party audits):
|
||||
`brandContext`, `behavioralContext`, `aiAttributionContext`, `webVitalsContext`,
|
||||
`crawlStatsContext`, `signalCenterContext`
|
||||
|
||||
**Conditionally-present fields** (both audit types, depend on crawl success):
|
||||
All remaining `*Context` fields.
|
||||
|
||||
---
|
||||
|
||||
## 5. Dimension Data Sources: Pure Functions
|
||||
|
||||
Every Phase 5 aggregator in `src/lib/audit/data-sources/` is a **pure function** -- no Prisma reads or writes. They receive `AuditInput` slices and return typed structs that get merged into `enrichedSeoSection` before the single final DB write.
|
||||
|
||||
| Import name | File | Activates when |
|
||||
|---|---|---|
|
||||
| `aggregateSignalCenter` | data-sources/signal-center | signalCenterContext present |
|
||||
| `aggregateBilingualAudit` | data-sources/bilingual-audit | bilingualAuditContext present |
|
||||
| `aggregateCompetitorAnalysis` | data-sources/competitor-analysis | serpAnalysis present |
|
||||
| `aggregateSchemaOpportunityForecast` | data-sources/schema-opportunity-forecast | industryContext present |
|
||||
| `aggregateIndustryBenchmarkComparison` | data-sources/industry-benchmark-comparison | industryContext present |
|
||||
| `aggregatePageStrategicPass` | data-sources/page-strategic-pass | pages available |
|
||||
| `aggregateAiEnginePersonalityAnalysis` | data-sources/ai-engine-personality-analysis | always |
|
||||
| `aggregateAiSourceAttributionComparison` | data-sources/ai-source-attribution-comparison | aiAttributionContext present |
|
||||
| `aggregateBehavioralActionBridge` | data-sources/behavioral-action-bridge | behavioralContext present |
|
||||
| `aggregateEeatAudit` | data-sources/eeat-audit | pages + industryContext |
|
||||
| `runVoiceSearchEligibility` | data-sources/voice-search-eligibility | pages + contentSamples |
|
||||
| `aggregateAiSnippetEligibility` | data-sources/ai-snippet-eligibility | pages present |
|
||||
| `aggregateMultiTouchAiPath` | data-sources/multi-touch-ai-path | aiAttributionContext present |
|
||||
| `aggregateRevenueAttribution` | data-sources/revenue-attribution | first-party data |
|
||||
| `aggregatePredictiveConversionScoring` | data-sources/predictive-conversion-scoring | first-party data |
|
||||
| `aggregateConversionHeatMap` | data-sources/conversion-heat-map | behavioral data |
|
||||
| `aggregateAiSearchVerification` | data-sources/ai-search-verification | always (async agent) |
|
||||
| `aggregateLostOpportunity` | data-sources/lost-opportunity-calculator | findings present |
|
||||
| `aggregateLiveVisualAnnotations` | data-sources/live-visual-annotations | Phase 12, parallel |
|
||||
| `aggregateCompetitorVisualComparison` | data-sources/competitor-visual-comparison | Phase 12, parallel |
|
||||
| `aggregateBrandAuthorityScore` | data-sources/brand-authority | eeat + schema data |
|
||||
| `aggregateSnippetCaptureStrategy` | data-sources/snippet-capture-strategy | serpAnalysis present |
|
||||
| `aggregateMobileFirstAudit` | data-sources/mobile-first-audit | pages present |
|
||||
| `aggregatePageSpeedInsights` | data-sources/page-speed-deep-dive | webVitalsContext |
|
||||
|
||||
---
|
||||
|
||||
## 6. SeoSection: The Single JSONB Output
|
||||
|
||||
`SeoSection` (defined in `src/lib/audit/types.ts`, ~155 lines) is the TypeScript shape of `Audit.seoSection` in Prisma. It is a wide JSON blob -- every dimension output lives as an optional field on this single object.
|
||||
|
||||
**Core fields (always present on completed audits):**
|
||||
```
|
||||
healthScore, narrative, keyFindings, priorityActions, quickWins, scoringBreakdown
|
||||
```
|
||||
|
||||
**Additive consulting fields:**
|
||||
```
|
||||
prioritizedFindings, additionalFindings, summaryThemes, inferredIndustry, displayName
|
||||
```
|
||||
|
||||
**Conditional enrichments (field absent when not applicable):**
|
||||
```
|
||||
aiSearchPerformance, brandEcho, dimensionScores, siteTagContext, methodology,
|
||||
industryFramework, competitiveLandscape, backlinkProfile, publicAiVisibility,
|
||||
organicSearchSummary, aiConversionAttribution, signalCenter, bilingualAudit,
|
||||
competitorAnalysis, schemaOpportunityForecast, strategicRoadmap,
|
||||
industryBenchmarkComparison, pageStrategicPass, aiEnginePersonalityAnalysis,
|
||||
aiSourceAttributionComparison, behavioralActionBridge, eeatAudit,
|
||||
voiceSearchEligibility, aiSnippetEligibility, multiTouchAiPath, revenueAttribution,
|
||||
predictiveConversionScoring, conversionHeatMap, aiSearchVerification,
|
||||
lostOpportunityCalculation, liveVisualAnnotations, competitorVisualComparison,
|
||||
brandAuthorityScore, snippetCaptureStrategy, mobileFirstAudit, pageSpeedInsights,
|
||||
executiveBrief, lockedFirstPartyTeaser
|
||||
```
|
||||
|
||||
**Engineering-only internal field:**
|
||||
```
|
||||
internal.dataSourcesUsed, internal.costLog, internal.auditType
|
||||
```
|
||||
Must not be rendered by dashboard components or PDF templates.
|
||||
|
||||
**Single Prisma write pattern:**
|
||||
```typescript
|
||||
await prisma.audit.update({
|
||||
where: { id: auditId },
|
||||
data: { seoSection: enrichedSeoSection as object },
|
||||
});
|
||||
```
|
||||
All 30+ dimension outputs are assembled in memory then written in one round trip.
|
||||
|
||||
---
|
||||
|
||||
## 7. Persistence: Write Targets by Runner
|
||||
|
||||
| Runner | Table(s) written | Pattern |
|
||||
|---|---|---|
|
||||
| Main SEO (`audit-runner.ts`) | `Audit.seoSection` (JSONB) | Single update at end of run |
|
||||
| Main SEO (progress) | `Audit` (status, progress fields) | Multiple incremental updates during run |
|
||||
| Technical (`services/audit-runner.ts`) | `TechnicalAudit`, `TechnicalIssue` | Create + bulk createMany |
|
||||
| Performance (`performance-audit-runner.ts`) | `PerformanceAudit`, `PerformancePageScore` | Create + createMany per URL |
|
||||
| Content audit (`/api/content-audit/run`) | `ContentAuditResult`, `AuditScoreHistory` | Create per run |
|
||||
|
||||
**Connection-leak mitigation (two-layer):**
|
||||
- Client-side: `boundedFireAndForget` uses `Promise.race` with a hard timeout so the serverless function is never blocked waiting on a Prisma query past its useful lifetime.
|
||||
- Server-side: `prisma.$transaction({ timeout })` enforces a server-enforced maximum on any transaction, releasing the connection-pool slot even if the JS promise chain is abandoned.
|
||||
|
||||
---
|
||||
|
||||
## 8. UI Rendering: Audit Report Surfaces
|
||||
|
||||
The `seoSection` JSONB is read (never mutated) by three display surfaces:
|
||||
|
||||
| Surface | Route | Notes |
|
||||
|---|---|---|
|
||||
| Public audit page | `src/app/audit/[id]/page.tsx` | Gated by `publicSlug`; shows subset of sections |
|
||||
| Admin audit page | `src/app/admin/audits/[id]/page.tsx` | Full section visibility |
|
||||
| Client portal | `src/app/client/[brandId]/audit/page.tsx` | Brand-linked first-party view |
|
||||
| PDF report | `src/lib/audit/pdf/AuditPdf.tsx` | Generated via `@react-pdf/renderer`; served from `/api/admin/audits/[id]/pdf` |
|
||||
|
||||
**Section rendering convention:**
|
||||
Each dashboard section component checks `hasData` (or a null check on the parent object) and returns `null` when the dimension output is absent. The TOC is constructed by the page from the same boolean flags, so suppressed sections disappear from the table of contents automatically.
|
||||
|
||||
**Separate performance/technical UIs:**
|
||||
`/technical-audit`, `/site-performance-audit`, and `/content-audit` are independent pages that read from their respective tables (`TechnicalAudit`, `PerformanceAudit`, `ContentAuditResult`) -- they do not read `Audit.seoSection`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Key Architectural Constraints for Phase 19 Work
|
||||
|
||||
1. **All new dimension outputs must be appended to `SeoSection` as optional fields.** There is no separate table for Phase 11A-17 dimension data. The JSONB approach means zero migrations for new dimensions.
|
||||
|
||||
2. **Dimension aggregators must remain pure functions.** No Prisma reads or writes inside `src/lib/audit/data-sources/`. Data flows in via `AuditInput` slices; output is returned as a typed struct.
|
||||
|
||||
3. **industryContext is ephemeral.** If a dimension needs to activate conditionally on industry (e.g., HIPAA compliance when `industries.some(i => i.startsWith('healthcare-'))`), the check must happen inside the aggregator or inside the runner at Phase 5 gate -- not via a DB lookup.
|
||||
|
||||
4. **Any new DB write in Phase 19 (e.g., ComplianceAuditResult) requires both layers:** `boundedFireAndForget` at the call site and `$transaction({ timeout })` inside the write operation. The connection pool has 14 slots shared across all serverless function instances.
|
||||
|
||||
5. **Model names must never appear in user-facing output.** `SeoSection.internal` is the only field where model/vendor names are permitted. All other fields, dashboard copy, and PDF copy must use generic language.
|
||||
|
||||
6. **Primary industry stability.** The `industries[0]` value must equal what would have been returned in single-industry mode. Secondary industries are additive-only. Downstream gate logic checking `industryContext.industry` (backward-compat alias) remains correct without changes.
|
||||
@@ -0,0 +1,279 @@
|
||||
# Phase 16: Validation and Auth Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
Before Phase 16, auth and validation were duplicated inline across API routes. Each handler called `getCurrentUser()` directly, repeated the null check, re-fetched the brand, re-verified membership, and manually returned `NextResponse.json({ error: "..." }, { status: 401 })` in its own way. Zod parse errors had no standard shape. The result was inconsistent error responses and auth logic that could drift per route.
|
||||
|
||||
Phase 16 centralised this into four layers:
|
||||
|
||||
1. **Error types** (`src/lib/api/errors.ts`): typed exceptions with HTTP status codes attached
|
||||
2. **`withErrorHandler`** (`src/lib/api/with-error-handler.ts`): catches those exceptions and converts them to uniform JSON responses
|
||||
3. **Auth guards** (`src/lib/auth/require.ts`, `src/lib/admin-auth.ts`): throw the typed exceptions instead of returning responses, so they compose cleanly inside `withErrorHandler`
|
||||
4. **Validation helpers** (`src/lib/validation/request.ts`): parse and throw `ValidationError` on bad input
|
||||
|
||||
---
|
||||
|
||||
## Error Types
|
||||
|
||||
`src/lib/api/errors.ts`
|
||||
|
||||
All errors extend `APIError`, which carries a `statusCode`. Route handlers and helpers throw these; `withErrorHandler` catches them.
|
||||
|
||||
| Class | Status | When to use |
|
||||
|---|---|---|
|
||||
| `AuthError` | 401 | User is not authenticated |
|
||||
| `ForbiddenError` | 403 | User is authenticated but lacks access |
|
||||
| `NotFoundError` | 404 | Resource does not exist |
|
||||
| `ValidationError` | 400 | Request body or query params failed schema validation |
|
||||
| `RateLimitError` | 429 | Rate limit exceeded |
|
||||
| `ServerError` | 500 | Explicit internal error (prefer letting unexpected errors bubble) |
|
||||
|
||||
---
|
||||
|
||||
## `withErrorHandler`
|
||||
|
||||
`src/lib/api/with-error-handler.ts`
|
||||
|
||||
Wraps a route handler. Catches `APIError` subclasses and `ZodError` (as a backstop) and converts them to a consistent JSON shape. Logs unhandled errors to `console.error`.
|
||||
|
||||
```typescript
|
||||
export const GET = withErrorHandler(async (req, ...args) => {
|
||||
// throw AuthError, ForbiddenError, ValidationError, etc. freely here
|
||||
return NextResponse.json({ ... });
|
||||
});
|
||||
```
|
||||
|
||||
**Response shapes on error:**
|
||||
|
||||
```json
|
||||
// APIError
|
||||
{ "error": "message" }
|
||||
{ "error": "message", "details": { ... } } // when details are present
|
||||
|
||||
// ZodError (converted to ValidationError internally)
|
||||
{ "error": "Invalid request body", "details": { "code": "VALIDATION_ERROR", "fieldErrors": { ... } } }
|
||||
```
|
||||
|
||||
All routes that use `requireUser` or `requireUserAndBrand` must be wrapped with `withErrorHandler`. Without the wrapper, thrown `AuthError` / `ForbiddenError` instances are uncaught and produce a 500.
|
||||
|
||||
---
|
||||
|
||||
## Auth Helpers
|
||||
|
||||
### `requireUser`
|
||||
|
||||
`src/lib/auth/require.ts`
|
||||
|
||||
```typescript
|
||||
async function requireUser(): Promise<User>
|
||||
```
|
||||
|
||||
Calls `getCurrentUser()` (Clerk session resolution + Prisma upsert). Throws `AuthError("Unauthorized")` if there is no active session.
|
||||
|
||||
Returns the Prisma `User` record including `memberships` (with nested `organization`).
|
||||
|
||||
### `requireUserAndBrand`
|
||||
|
||||
```typescript
|
||||
async function requireUserAndBrand(
|
||||
brandId: string
|
||||
): Promise<{ user: User; brand: Brand }>
|
||||
```
|
||||
|
||||
Calls `requireUser`, then:
|
||||
1. Calls `verifyBrandAccess(user.id, brandId)`, which checks `BrandMembership` rows and org-owner role. Throws `ForbiddenError("Forbidden")` if the user has no access.
|
||||
2. Fetches the brand with `prisma.brand.findUnique`. Throws `NotFoundError("Brand not found")` if the row does not exist.
|
||||
|
||||
Returns `{ user, brand }`. The caller almost never needs to use the return value since the primary purpose is the guard, but the brand object is available when the handler needs it immediately without a second query.
|
||||
|
||||
```typescript
|
||||
// Guard only
|
||||
export const GET = withErrorHandler(async (req, ...args) => {
|
||||
const { brandId } = (args[0] as { params: { brandId: string } }).params;
|
||||
await requireUserAndBrand(brandId);
|
||||
// ... handler logic
|
||||
});
|
||||
|
||||
// Using the return value
|
||||
export const POST = withErrorHandler(async (req, ...args) => {
|
||||
const { brandId } = (args[0] as { params: { brandId: string } }).params;
|
||||
const { brand } = await requireUserAndBrand(brandId);
|
||||
// brand.domain, brand.plan, etc. available without a second query
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `requireAdmin`
|
||||
|
||||
`src/lib/admin-auth.ts`
|
||||
|
||||
```typescript
|
||||
async function requireAdmin(): Promise<
|
||||
| { ok: true; user: AdminUser }
|
||||
| { ok: false; status: number }
|
||||
>
|
||||
```
|
||||
|
||||
**Legacy helper.** Returns a discriminated union rather than throwing. Used by pre-existing `/api/admin-legacy` routes and a handful of older admin endpoints.
|
||||
|
||||
Checks the `ADMIN_EMAILS` environment variable (comma-separated list). If the env var is empty, denies all requests (fail-safe default; no misconfigured deployment accidentally grants access).
|
||||
|
||||
**Callers must check `ok` and return early:**
|
||||
|
||||
```typescript
|
||||
const auth = await requireAdmin();
|
||||
if (!auth.ok) return NextResponse.json({ error: "Forbidden" }, { status: auth.status });
|
||||
```
|
||||
|
||||
Do not use `requireAdmin` for new routes. Use `requireSuperadmin` instead.
|
||||
|
||||
### `requireSuperadmin`
|
||||
|
||||
```typescript
|
||||
async function requireSuperadmin(): Promise<Response | null>
|
||||
```
|
||||
|
||||
**Current admin guard.** Returns `null` when the caller is allowed to proceed, or a `NextResponse` (401/403) when denied. This is the opposite of the throwing pattern; callers return early on non-null.
|
||||
|
||||
```typescript
|
||||
export const GET = withErrorHandler(async (req) => {
|
||||
const denied = await requireSuperadmin();
|
||||
if (denied) return denied;
|
||||
// ... handler logic
|
||||
});
|
||||
```
|
||||
|
||||
Checks (in order): Clerk session claims `metadata.role === "superadmin"`, `publicMetadata.role === "superadmin"`, `metadata.isSuperadmin === true`, `publicMetadata.isSuperadmin === true`, then falls back to `ADMIN_EMAILS` email match.
|
||||
|
||||
Does NOT throw (it returns a response), so `withErrorHandler` is not strictly required around it, but wrapping is still preferred for consistent unhandled-error behaviour.
|
||||
|
||||
### `getSuperadmin`
|
||||
|
||||
```typescript
|
||||
async function getSuperadmin(): Promise<SuperadminIdentity | null>
|
||||
```
|
||||
|
||||
Pure boolean check. Returns `{ userId, email }` when the session is a superadmin, `null` otherwise. Does not return a response. Use this when you need to branch on admin status within a route that also serves non-admin users. `requireSuperadmin` is the right choice when the entire route is admin-only.
|
||||
|
||||
### `withAdminTiming`
|
||||
|
||||
```typescript
|
||||
async function withAdminTiming<T>(name: string, handler: () => Promise<T>): Promise<T>
|
||||
```
|
||||
|
||||
Wraps a block with `console.info` / `console.error` timing output tagged `[admin:name]`. Result array length is logged when the result is an array. Use in admin routes where query time is worth tracking in Vercel logs.
|
||||
|
||||
```typescript
|
||||
const result = await withAdminTiming("platform-metrics", async () => {
|
||||
return await prisma.metricSnapshot.findMany({ ... });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Validation Helpers
|
||||
|
||||
`src/lib/validation/request.ts`
|
||||
|
||||
### `validateBody`
|
||||
|
||||
```typescript
|
||||
async function validateBody<T>(req: Request, schema: ZodSchema<T>): Promise<T>
|
||||
```
|
||||
|
||||
Parses `req.json()`. Throws `ValidationError("Request body must be valid JSON")` on JSON parse failure and `ValidationError("Invalid request body")` with `fieldErrors` on schema failure. Both are caught by `withErrorHandler`.
|
||||
|
||||
### `validateQuery`
|
||||
|
||||
```typescript
|
||||
function validateQuery<T>(req: Request, schema: ZodSchema<T>): T
|
||||
```
|
||||
|
||||
Parses `new URL(req.url).searchParams` as a flat string object. Throws `ValidationError("Invalid query parameters")` on schema failure. Note: all values are strings from the URL; Zod coercion (`z.coerce.number()`) is required to parse numerics.
|
||||
|
||||
---
|
||||
|
||||
## Full Route Example
|
||||
|
||||
```typescript
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { withErrorHandler } from "@/lib/api/with-error-handler";
|
||||
import { requireUserAndBrand } from "@/lib/auth/require";
|
||||
import { validateBody, validateQuery } from "@/lib/validation/request";
|
||||
|
||||
const QuerySchema = z.object({
|
||||
range: z.enum(["7d", "30d", "90d"]).default("30d"),
|
||||
});
|
||||
|
||||
const BodySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
value: z.number().int().positive(),
|
||||
});
|
||||
|
||||
export const GET = withErrorHandler(async (req, ...args) => {
|
||||
const { brandId } = (args[0] as { params: { brandId: string } }).params;
|
||||
await requireUserAndBrand(brandId);
|
||||
const { range } = validateQuery(req, QuerySchema);
|
||||
return NextResponse.json({ range });
|
||||
});
|
||||
|
||||
export const POST = withErrorHandler(async (req, ...args) => {
|
||||
const { brandId } = (args[0] as { params: { brandId: string } }).params;
|
||||
await requireUserAndBrand(brandId);
|
||||
const body = await validateBody(req, BodySchema);
|
||||
// ... use body.name, body.value
|
||||
return NextResponse.json({ ok: true });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Guide
|
||||
|
||||
Older routes (pre-Phase-16) do auth inline. The migration is mechanical:
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
export async function GET(req: Request) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const brandId = new URL(req.url).searchParams.get("brandId");
|
||||
const brand = await prisma.brand.findUnique({
|
||||
where: { id: brandId! },
|
||||
include: { organization: { include: { memberships: true } } },
|
||||
});
|
||||
if (!brand || !brand.organization.memberships.some((m) => m.userId === user.id)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
// ... handler
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
export const GET = withErrorHandler(async (req, ...args) => {
|
||||
const { brandId } = (args[0] as { params: { brandId: string } }).params;
|
||||
await requireUserAndBrand(brandId);
|
||||
// ... handler
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gotchas
|
||||
|
||||
**`requireUserAndBrand` vs. manual membership check.** The manual pre-Phase-16 pattern fetched the brand and checked `memberships.some(m => m.userId === user.id)`. `requireUserAndBrand` calls `verifyBrandAccess`, which also grants access to org owners regardless of an explicit `BrandMembership` row. Converting a route to use `requireUserAndBrand` may grant access to org owners who previously could not reach that route. This is almost always the correct behaviour but worth noting.
|
||||
|
||||
**`withErrorHandler` is required around throwing guards.** `requireUser` and `requireUserAndBrand` throw exceptions. If you call them inside a handler that is not wrapped with `withErrorHandler`, the exception propagates and Next.js returns a 500. Always pair them.
|
||||
|
||||
**`requireSuperadmin` returns a response, not an exception.** It cannot be placed inside `withErrorHandler` and be handled automatically. The `if (denied) return denied` idiom is intentional; the return type `Response | null` makes the pattern explicit at the call site.
|
||||
|
||||
**`requireAdmin` is legacy.** New admin routes use `requireSuperadmin`. Do not add more callers of `requireAdmin`. The two helpers are not interchangeable; `requireAdmin` has no Clerk role awareness.
|
||||
|
||||
**Query params are always strings.** `validateQuery` uses `new URL(req.url).searchParams`, which returns strings for all values. Use `z.coerce.number()` or `z.coerce.boolean()` in the schema to parse non-string types from query strings.
|
||||
|
||||
**`validateBody` is async, `validateQuery` is not.** `validateBody` must be awaited; `validateQuery` is synchronous. Mixing them up is a TypeScript error but worth being aware of when reading unfamiliar routes.
|
||||
@@ -0,0 +1,219 @@
|
||||
# Phase 30: Site Tag Detection Registry Expansion
|
||||
|
||||
**Status:** Session 2 complete (commit in progress)
|
||||
**Effort:** 6 hours across 3 sessions (3h + 2h + 1h)
|
||||
**Risk:** Medium. Touches Site Tag JavaScript running on every client site.
|
||||
|
||||
> Full spec content lives in Claude conversation dated May 22-25, 2026.
|
||||
> This file is a structured summary derived from that conversation.
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The meSEO Site Tag detected approximately 20 plugins across two categories
|
||||
(booking widgets and chat widgets). A modern WordPress or CMS site typically
|
||||
has 15-30 trackable integrations. Coverage was roughly 25% of what is
|
||||
actually present on a typical brand site.
|
||||
|
||||
Confirmed via production data on May 22, 2026:
|
||||
- modernheartandvascular.com (cardiology): 4 plugins detected out of ~15-25 present
|
||||
- lifeimagingfla.com (imaging): 1 plugin detected out of ~8-15 present
|
||||
|
||||
After Phase 30 Session 1: cardiology brand shows 11 detected plugins.
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
1. Site Tag detects 50-80 known plugins, widgets, pixels, and integrations
|
||||
2. Each detection fires a `plugin_detected` event with consistent metadata
|
||||
including `category`, `tag_v`, and `confidence`
|
||||
3. Integrations tab on Site Tag Analytics shows comprehensive per-brand inventories
|
||||
4. Adding new detection patterns is a small registry edit
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
Detection happens entirely in `public/t.js` (served from Next.js static directory).
|
||||
The unminified source lives at `public/t.source.js` (gitignored).
|
||||
|
||||
**Edit workflow:**
|
||||
```bash
|
||||
cp public/t.source.js public/t.js # copy source to served file
|
||||
# edit public/t.js
|
||||
npm run build:site-tag # minifies in place, backs up to t.source.js
|
||||
```
|
||||
|
||||
**Three registries in `public/t.source.js`:**
|
||||
|
||||
| Registry | Location | Category | Count | Event shape |
|
||||
|---|---|---|---|---|
|
||||
| `BOOKING_PLUGINS` | ~line 5832 | booking, forms, crm | 15 entries | `{plugin, plugin_key, category, tag_v, confidence}` |
|
||||
| `CHAT_WIDGETS` | ~line 6416 | chat | 15 entries | `{plugin, plugin_key: "chat_"+key, category: "chat", tag_v, confidence}` |
|
||||
| `ANALYTICS_PLUGINS` | ~line 6659 | analytics, pixel, seo, page_builder, ecommerce, crm, reviews, cache, security, booking, lead_gen | ~47 entries after Session 2 | `{plugin, plugin_key, category, tag_v, confidence}` |
|
||||
|
||||
**Event metadata shape (all registries after Phase 30 Session 1 fix):**
|
||||
```js
|
||||
sendEvent('plugin_detected', {
|
||||
plugin: 'Google Analytics 4', // display name
|
||||
plugin_key: 'ga4', // machine key
|
||||
category: 'analytics', // category string
|
||||
tag_v: '2026.05.31', // Site Tag version (bump on each deploy)
|
||||
confidence: 'high' | 'medium', // detection confidence
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Existing Registries (do NOT modify these in Phase 30)
|
||||
|
||||
### BOOKING_PLUGINS keys
|
||||
amelia, calendly, nexhealth, acuity, jotform, typeform, wpforms,
|
||||
gravityforms, contactform7, hubspot, deardoc, birdeye, podium, weave
|
||||
|
||||
### CHAT_WIDGETS keys
|
||||
intercom, drift, tidio, tawkto, livechat, crisp, olark, hubspot_chat,
|
||||
zendesk, fb_messenger, smartsupp, jivochat, purechat, userlike, frontchat
|
||||
|
||||
---
|
||||
|
||||
## ANALYTICS_PLUGINS Entries
|
||||
|
||||
### Session 1 (20 entries, commit cdb096f)
|
||||
|
||||
**Analytics and pixels (12):**
|
||||
ga4, gtm, ua_legacy, fb_pixel, linkedin_insight, tiktok_pixel,
|
||||
twitter_pixel, pinterest_tag, reddit_pixel, clarity, hotjar, amplitude
|
||||
|
||||
**SEO (3):** wordpress, yoast, rankmath
|
||||
|
||||
**Page builders (1):** elementor
|
||||
|
||||
**E-commerce (1):** woocommerce
|
||||
|
||||
**CRM (3):** hubspot_track, klaviyo, mailchimp
|
||||
|
||||
### Session 2 (~27 additional entries)
|
||||
|
||||
**Forms (2):** formidable, fluent_forms
|
||||
|
||||
**Page builders (4):** divi, beaver_builder, brizy, bricks
|
||||
|
||||
**Booking (4):** bookly, bookingpress, simplybook, setmore
|
||||
|
||||
**Reviews (3):** trustpilot, google_reviews_widget, nicejob
|
||||
|
||||
**Cache (2):** wp_rocket, litespeed_cache
|
||||
|
||||
**Security (2):** wordfence, sucuri
|
||||
|
||||
**Healthcare-specific (2):** patientpop, doctible
|
||||
|
||||
**SEO (2):** aioseo, seopress
|
||||
|
||||
**E-commerce (2):** shopify, edd
|
||||
|
||||
**CRM (2):** activecampaign, convertkit
|
||||
|
||||
**Skipped (already covered):**
|
||||
- mailchimp: already in ANALYTICS_PLUGINS
|
||||
- olark: already in CHAT_WIDGETS (fires as "chat_olark")
|
||||
- zendesk_chat: already in CHAT_WIDGETS (fires as "chat_zendesk")
|
||||
- hs_conversations: already in CHAT_WIDGETS (fires as "chat_hubspot_chat")
|
||||
|
||||
---
|
||||
|
||||
## Detection Strategies
|
||||
|
||||
### Window global
|
||||
```js
|
||||
detect: () => typeof window.fbq === 'function'
|
||||
```
|
||||
|
||||
### Script src match (uses cached getScriptSrcs())
|
||||
```js
|
||||
detect: () => /googletagmanager\.com\/gtm\.js/.test(getScriptSrcs())
|
||||
```
|
||||
|
||||
### DOM selector
|
||||
```js
|
||||
detect: () => !!document.querySelector('.gform_wrapper')
|
||||
```
|
||||
|
||||
### Meta tag
|
||||
```js
|
||||
detect: () => {
|
||||
const meta = document.querySelector('meta[name="generator"]');
|
||||
return !!(meta && /Yoast SEO/.test(meta.getAttribute('content') || ''));
|
||||
}
|
||||
```
|
||||
|
||||
### Cookie name (uses cached getCookies())
|
||||
```js
|
||||
detect: () => /_fbp=/.test(getCookies())
|
||||
```
|
||||
|
||||
### Composite (high confidence)
|
||||
```js
|
||||
detect: () => {
|
||||
const hasGlobal = typeof window.fbq === 'function';
|
||||
const hasCookie = /_fbp=/.test(getCookies());
|
||||
if (hasGlobal && hasCookie) return { matched: true, confidence: 'high' };
|
||||
if (hasGlobal || hasCookie) return { matched: true, confidence: 'medium' };
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Constraints
|
||||
|
||||
- Detection total work: target under 5ms on mid-range mobile
|
||||
- `requestIdleCallback` for post-DOMContentLoaded run (5000ms fallback)
|
||||
- Skip detection if `document.hidden` is true
|
||||
- `getScriptSrcs()` iterates `document.scripts` once per detection pass (cached)
|
||||
- `getCookies()` reads `document.cookie` once per detection pass (cached)
|
||||
- `window.__meseoDetectedKeys` Set prevents any plugin_key firing more than once per page load
|
||||
- Detection runs at most twice: DOMContentLoaded + requestIdleCallback(timeout: 5000)
|
||||
|
||||
---
|
||||
|
||||
## Backend: ENRICHMENT_REGISTRY
|
||||
|
||||
Location: `src/lib/site-tag/integration-signatures.ts`
|
||||
|
||||
Provides vendor info, descriptions, and category defaults for backfill.
|
||||
Keyed by `plugin_key`. Consulted by:
|
||||
1. `backfill-detected-integrations` route when resolving category/vendor
|
||||
2. `site-tag-analytics/integrations` GET route when merging description into response
|
||||
|
||||
---
|
||||
|
||||
## Phase Sessions
|
||||
|
||||
| Session | Status | Commit | Description |
|
||||
|---|---|---|---|
|
||||
| Session 1 | Complete | cdb096f | 20 ANALYTICS_PLUGINS rules, BOOKING_PLUGINS category fix, tag_v + confidence fields |
|
||||
| Session 2 | Complete | (current) | ~27 additional rules, backend enrichment, docs |
|
||||
| Session 3 | Pending | - | UI polish, category icons, vendor logos |
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Site Tag detects at least 8 distinct plugins on modernheartandvascular.com (was 4)
|
||||
- Site Tag detects at least 5 distinct plugins on an active lawn care brand site
|
||||
- No false positives on a clean test site
|
||||
- All `plugin_detected` events include `category`, `tag_v`, and `confidence`
|
||||
- Detection adds less than 10ms to page load p95
|
||||
- Integrations tab shows expanded set per brand within 24 hours of deploy
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Phase 28A Session 1.5 (backend backfill fix): SHIPPED at commit 058272e
|
||||
- Phase 28A Session 1.6 (SQL aggregation backfill): SHIPPED at commit 30363ed
|
||||
- Phase 28A Session 2 (real-time write hook to /api/site-tag/event): PENDING
|
||||
@@ -0,0 +1,146 @@
|
||||
meSEO Migration Runbook
|
||||
Vercel to Self-Hosted (Gitea + Coolify)
|
||||
Prepared for the meSEO team. Two-phase plan. Phase 1 stands the app up on staging and is done before the Wednesday demo. Phase 2 moves production traffic, including the tracking tag, off Vercel, and happens after the demo.
|
||||
|
||||
Ground Rules (read first)
|
||||
? Do not touch app.meseoapp.com during Phase 1. That hostname serves the tracking tag to every client site and receives their event traffic. Phase 1 uses a separate staging hostname, app2.meseoapp.com, so no client is affected.
|
||||
? Keep the database on Neon. We move the app only and point it at the same database. There is no data migration in this project.
|
||||
? Secrets are not printed in this document. Copy each secret value directly from the Vercel project (Settings, Environment Variables) into Coolify. Do not paste live keys into shared docs or chat.
|
||||
? Do not click any /api/admin/backfill endpoint during setup or the demo. Those write to production data. Normal dashboard browsing is read-only and safe.
|
||||
? Leave the Vercel deployment intact until Phase 2 is proven. It is our instant rollback.
|
||||
What We Are Building
|
||||
meSEO is a Next.js app using Prisma, Neon Postgres, and Clerk auth. It runs on Vercel today. We are deploying the same app as a Docker container on our own server through Coolify, with the code hosted in Gitea. Phase 1 stands it up on staging, pointed at the real database, so it can be demoed independently of Vercel. Phase 2 moves production traffic, including the client tracking tag, off Vercel.
|
||||
Phase 1 - Stand Up meSEO on Staging (before Wednesday)
|
||||
Prerequisites
|
||||
? Server with Coolify running (already in place) and at least 8 GB RAM free for the build. This is a large Next build and fails on undersized boxes.
|
||||
? Gitea running (already in place).
|
||||
? Access to the Vercel project to copy environment variable values.
|
||||
? DNS control for meseoapp.com.
|
||||
Step 1 - Push the repo to Gitea
|
||||
From the local meseo clone, add Gitea as a remote and push main:
|
||||
|
||||
git remote add gitea <your-gitea-meseo-repo-url>
|
||||
git push gitea main
|
||||
|
||||
Confirm in Gitea that the meseo repo shows the latest commit and that the Dockerfile is present at the repo root.
|
||||
Step 2 - Confirm the container files are in the repo
|
||||
The repo needs a Dockerfile at the root and output: 'standalone' in the Next config. If they are not there yet, add the files below, set the config option, commit on main, and push to Gitea.
|
||||
Dockerfile
|
||||
|
||||
FROM node:22-slim AS deps
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
COPY package.json package-lock.json ./
|
||||
COPY prisma ./prisma
|
||||
RUN npm ci
|
||||
|
||||
FROM node:22-slim AS build
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-slim AS runner
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
COPY --from=build /app/public ./public
|
||||
COPY --from=build /app/.next/standalone ./
|
||||
COPY --from=build /app/.next/static ./.next/static
|
||||
COPY --from=build /app/prisma ./prisma
|
||||
COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=build /app/node_modules/@prisma/client ./node_modules/@prisma/client
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
.dockerignore
|
||||
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env*
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
npm-debug.log
|
||||
|
||||
In the Next config, add: output: 'standalone'
|
||||
Step 3 - Create the deploy key (Gitea authorization)
|
||||
Coolify pulls the private repo using an SSH deploy key. Do not reuse the Korrith key unless its public half is already added to the meseo repo. Cleanest path:
|
||||
? In Coolify, generate a new private key named meseo-deploy. Copy its public key.
|
||||
? In Gitea: open the meseo repo, Settings, Deploy Keys, Add Deploy Key, paste the public key, read access is enough, save.
|
||||
? Back in Coolify, select meseo-deploy.
|
||||
Step 4 - Create the application in Coolify
|
||||
? New Resource, Applications, Git Based, Private Repository (with Deploy Key). Clear the search box first, or Coolify shows service templates instead of deploy options.
|
||||
? Select the meseo-deploy key.
|
||||
? Repository: the Gitea meseo repo. Branch: main.
|
||||
? Build Pack: Dockerfile. Path: /Dockerfile.
|
||||
? Port: 3000.
|
||||
? Do not deploy yet. Add environment variables and the domain first.
|
||||
Step 5 - Environment variables
|
||||
Add every variable from the Vercel project, copying secret values directly from Vercel. Apply these rules:
|
||||
? Change for staging: set NEXT_PUBLIC_API_URL and NEXT_PUBLIC_APP_URL to https://app2.meseoapp.com.
|
||||
? Leave the two tracker URLs as they are for Phase 1 (NEXT_PUBLIC_TRACKER_SCRIPT_URL, NEXT_PUBLIC_COLLECT_ENDPOINT). Do not repoint live client tracking yet.
|
||||
? Every NEXT_PUBLIC_ variable is build-time. It must be set as a build variable before the image builds, or it ships blank.
|
||||
? Everything else carries over unchanged, same values as Vercel, including DATABASE_URL and DIRECT_URL (same Neon endpoint).
|
||||
The full variable table is at the end of this document.
|
||||
Step 6 - Domain and DNS
|
||||
? Add a DNS A record: app2.meseoapp.com to the server's public IP. Keep it DNS-only (no proxy) for now so Coolify can issue its TLS certificate cleanly.
|
||||
? In Coolify, set the application domain to https://app2.meseoapp.com.
|
||||
Step 7 - Provider allowlists (or login fails)
|
||||
? Clerk: add app2.meseoapp.com to the allowed origins for the live instance.
|
||||
? Google OAuth (Google Cloud Console): add app2's redirect URI (https://app2.meseoapp.com plus the app's Google callback path) to the authorized redirect URIs.
|
||||
Step 8 - Deploy and verify
|
||||
? Trigger the deploy. Watch the build logs. The build produces .next/standalone/server.js.
|
||||
? Open https://app2.meseoapp.com, sign in.
|
||||
? Load Site Tag Analytics and AI Visibility. Real data should render because it is the same Neon database.
|
||||
? Do not click admin backfill endpoints.
|
||||
? If the dashboards render, Phase 1 is complete. This is the Wednesday demo environment.
|
||||
Phase 2 - Production Cutover (after Wednesday, not before)
|
||||
Do not start this until the demo is done. Each step below carries client-visible risk.
|
||||
? Cloudflare in front. Put Cloudflare in front of the production app hostname and cache the tag script at the edge, so ingestion volume is absorbed before it reaches the server.
|
||||
? Repoint the tracking tag. Today NEXT_PUBLIC_TRACKER_SCRIPT_URL and NEXT_PUBLIC_COLLECT_ENDPOINT point at meseo-staging.vercel.app. That is a live dependency on Vercel. Move them to a domain we control, rebuild, and confirm clients pick up the new tag. This is the highest-risk item, do it deliberately.
|
||||
? Re-point cron and background jobs. Recreate each Vercel Cron as a Coolify scheduled task hitting the same route on the same schedule, and verify each one fires. If Inngest is used, re-point its serve URL and keys.
|
||||
? Move Blob storage. BLOB_READ_WRITE_TOKEN is a Vercel Blob token. Anything using it must move to a storage backend we control, or those features break after leaving Vercel.
|
||||
? Confirm Redis. Check where REDIS_URL points. If it is a Vercel-linked store, move or re-point it.
|
||||
? Load test. Test the tag and collect endpoints under real concurrency against the new box before cutover.
|
||||
? Cut over DNS. Lower the app.meseoapp.com TTL to 60 seconds a day ahead. Cut over during a low-traffic window. Watch events land in Neon live. Keep Vercel intact as instant rollback.
|
||||
? Decommission Vercel only after days of stability. Note: decommissioning does not clear the outstanding Vercel balance, that is settled separately.
|
||||
Watch-Outs Specific to This App
|
||||
? Tracker tag points at Vercel. The production tag currently loads from meseo-staging.vercel.app. When Vercel is paused or removed, client tracking breaks. Repointing this is the core Phase 2 task.
|
||||
? Vercel Blob. The blob token only works while Vercel Blob is the store. Move upload storage in Phase 2.
|
||||
? Redis host. Confirm the Redis URL points to a store the new server can reach.
|
||||
? Cron. The nightly rollup and any other scheduled jobs must be recreated as Coolify scheduled tasks. They do not come across automatically.
|
||||
? Build memory. The Next build needs real RAM (8 GB comfortable). Undersized servers fail the build.
|
||||
? Build-time publics. All NEXT_PUBLIC_ vars must be set as build variables, or the bundle ships with blank values.
|
||||
Troubleshooting Quick Reference
|
||||
? Coolify cannot pull the repo: the deploy key's public half is not on the meseo repo in Gitea. Add it under repo Settings, Deploy Keys.
|
||||
? Build fails, out of memory: increase server RAM or build resources.
|
||||
? Login redirect error or loop: app2 not added to Clerk allowed origins, or NEXT_PUBLIC_APP_URL not set to app2 at build time.
|
||||
? Google sign-in redirect_uri_mismatch: app2 redirect URI not added in Google Cloud console.
|
||||
? Dashboards load but show no data: DATABASE_URL not carried over correctly. Confirm it matches Vercel exactly.
|
||||
? Blank NEXT_PUBLIC values in the browser: those vars were set as runtime only. Set them as build variables and redeploy.
|
||||
Do NOT Add These (Vercel-specific)
|
||||
Some variables only exist because the app ran on Vercel. Do not copy these into Coolify. Some are injected by the platform automatically, others tie a feature to a Vercel-hosted service that has to move before the variable is valid on our own server.
|
||||
Skip entirely (Vercel-injected, do not recreate)
|
||||
? Any variable beginning with VERCEL_ (for example VERCEL_URL, VERCEL_ENV, VERCEL_REGION, VERCEL_GIT_*). Vercel sets these at runtime. They will not exist on our server, and recreating them by hand is wrong. If any code reads VERCEL_URL to build a link, that code should use NEXT_PUBLIC_APP_URL instead. Flag it if a build error points at a missing VERCEL variable.
|
||||
? Any NEXT_RUNTIME or edge-runtime hints that were set for Vercel's serverless split. We run one long-lived Node server, they do not apply.
|
||||
Do not carry over as-is (Vercel-hosted services, migrate first)
|
||||
? BLOB_READ_WRITE_TOKEN. This is a Vercel Blob token and only works while Vercel Blob is the store. For Phase 1 staging it can stay so uploads do not error, but it is not a keep-forever value. Move the storage in Phase 2, then replace this.
|
||||
? REDIS_URL and DIRECT_URL, only if the Redis or database URL points at a Vercel-provided integration (a Vercel-managed Upstash or Postgres). Our DATABASE_URL is Neon and stays. Confirm the Redis host is one our server can reach before trusting it; if it is a Vercel-linked store, it has to move or be re-pointed.
|
||||
Everything not listed here carries over normally, per the table below.
|
||||
Environment Variable Reference
|
||||
Copy secret values directly from the Vercel project. Do not paste live secret values into this document or any shared copy.
|
||||
Variable
|
||||
Scope
|
||||
Action
|
||||
NEXT_PUBLIC_API_URL
|
||||
All
|
||||
CHANGE to https://app2.meseoapp.com. Build-time.
|
||||
NEXT_PUBLIC_APP_URL
|
||||
All
|
||||
CHANGE to https://app2.meseoapp.com. Build-time.
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
|
||||
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@@ -0,0 +1,23 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
experimental: {
|
||||
serverComponentsExternalPackages: ['@mendable/firecrawl-js', 'pdf-parse', '@napi-rs/canvas'],
|
||||
},
|
||||
env: {
|
||||
NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA: process.env.VERCEL_GIT_COMMIT_SHA || "dev",
|
||||
NEXT_PUBLIC_TAG_VERSION: process.env.VERCEL_GIT_COMMIT_SHA?.slice(0, 8) || "dev",
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/t.js",
|
||||
headers: [
|
||||
{ key: "Cache-Control", value: "public, max-age=300, s-maxage=300, stale-while-revalidate=3600" },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"name": "meseo",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "node scripts/build-site-tag.mjs && prisma generate && next build",
|
||||
"db:push": "node scripts/prisma-safety-check.mjs && node scripts/prisma-db-push-retry.mjs",
|
||||
"build:site-tag": "node scripts/build-site-tag.mjs",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.97.1",
|
||||
"@clerk/nextjs": "^7.1.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@mendable/firecrawl-js": "^4.24.1",
|
||||
"@nangohq/frontend": "^0.70.1",
|
||||
"@nangohq/node": "^0.70.1",
|
||||
"@napi-rs/canvas": "^0.1.0",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@sparticuz/chromium": "^147.0.0",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@vercel/blob": "^2.4.0",
|
||||
"@vercel/functions": "^3.6.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"docx": "^9.6.1",
|
||||
"fast-xml-parser": "^5.5.12",
|
||||
"image-size": "^2.0.2",
|
||||
"inngest": "^4.5.0",
|
||||
"jsonrepair": "^3.14.0",
|
||||
"lucide-react": "^0.400.0",
|
||||
"next": "14.2.5",
|
||||
"nodemailer": "^8.0.5",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pg": "^8.21.0",
|
||||
"pptxgenjs": "^4.0.1",
|
||||
"puppeteer-core": "^24.40.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-markdown": "^9.1.0",
|
||||
"recharts": "^3.8.1",
|
||||
"redis": "^5.12.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"resend": "^6.12.3",
|
||||
"sharp": "^0.34.5",
|
||||
"tailwind-merge": "^2.4.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"autoprefixer": "^10.0.1",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.2.5",
|
||||
"husky": "^9.1.7",
|
||||
"postcss": "^8",
|
||||
"prisma": "^5.22.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,115 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log("Seeding AI visibility + competitors...");
|
||||
|
||||
const brands = await prisma.brand.findMany();
|
||||
if (brands.length === 0) { console.log("No brands. Run main seed first."); return; }
|
||||
|
||||
for (const brand of brands) {
|
||||
// Clear existing
|
||||
await prisma.aiVisibilitySnapshot.deleteMany({ where: { brandId: brand.id } });
|
||||
await prisma.competitorSnapshot.deleteMany({ where: { brandId: brand.id } });
|
||||
await prisma.competitor.deleteMany({ where: { brandId: brand.id } });
|
||||
|
||||
// ── AI Visibility: 30 days of snapshots ──
|
||||
const platforms = ["chatgpt", "claude", "perplexity", "gemini", "google_aio"];
|
||||
const topics = [
|
||||
"best project management tools",
|
||||
"SEO audit checklist",
|
||||
"technical SEO guide",
|
||||
"AI visibility optimization",
|
||||
"website speed optimization",
|
||||
];
|
||||
const pages = ["/features", "/blog/seo-guide", "/technical-audit", "/pricing", "/about"];
|
||||
|
||||
for (let d = 30; d >= 0; d--) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - d);
|
||||
date.setHours(12, 0, 0, 0);
|
||||
|
||||
for (let p = 0; p < platforms.length; p++) {
|
||||
const baseMentions = [5, 4, 3, 2, 1][p];
|
||||
const growth = 1 + ((30 - d) / 30) * 0.2;
|
||||
const noise = () => 0.7 + Math.random() * 0.6;
|
||||
|
||||
for (let t = 0; t < 2; t++) {
|
||||
const topicIdx = (p + t) % topics.length;
|
||||
const mentions = Math.round(baseMentions * growth * noise());
|
||||
const citations = Math.round(mentions * (0.3 + Math.random() * 0.4));
|
||||
|
||||
await prisma.aiVisibilitySnapshot.create({
|
||||
data: {
|
||||
brandId: brand.id,
|
||||
date,
|
||||
platform: platforms[p],
|
||||
topic: topics[topicIdx],
|
||||
mentions,
|
||||
citations,
|
||||
visibilityScore: Math.min(100, Math.round(mentions * 8 + citations * 12)),
|
||||
sourceUrl: pages[topicIdx],
|
||||
change: d < 28 ? Math.round((Math.random() - 0.3) * 3) : 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(` AI visibility: 30 days × 5 platforms × 2 topics for ${brand.name}`);
|
||||
|
||||
// ── Competitors ──
|
||||
const comps = [
|
||||
{ name: "RankBoost.io", domain: "rankboost.io", type: "national", strongestPage: "/features/seo-audit", whyWinning: "Published 4 new long-form guides targeting core keywords. Strong internal linking structure.", watchlist: true },
|
||||
{ name: "ContentKing", domain: "contentking.com", type: "national", strongestPage: "/technical-seo-guide", whyWinning: "12,000-word technical SEO guide displacing competitors in AI citations.", watchlist: true },
|
||||
{ name: "LocalSEO Pro", domain: "localseepro.com", type: "local", strongestPage: "/local-seo-services", whyWinning: "Dominates local pack for city-specific queries. Strong GBP optimization.", watchlist: false },
|
||||
{ name: "AgencyFirst", domain: "agencyfirst.io", type: "emerging", strongestPage: "/blog/ai-seo", whyWinning: "New player publishing AI-focused SEO content rapidly.", watchlist: false },
|
||||
];
|
||||
|
||||
for (const c of comps) {
|
||||
const competitor = await prisma.competitor.create({
|
||||
data: {
|
||||
brandId: brand.id,
|
||||
name: c.name,
|
||||
domain: c.domain,
|
||||
type: c.type,
|
||||
strongestPage: c.strongestPage,
|
||||
whyWinning: c.whyWinning,
|
||||
watchlist: c.watchlist,
|
||||
},
|
||||
});
|
||||
|
||||
// Add weekly snapshots for 12 weeks
|
||||
for (let w = 12; w >= 0; w--) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - w * 7);
|
||||
date.setHours(12, 0, 0, 0);
|
||||
|
||||
const threat = c.watchlist ? (w < 4 ? "high" : "medium") : "low";
|
||||
const baseAi = c.name === "RankBoost.io" ? 8 : c.name === "ContentKing" ? 6 : 3;
|
||||
|
||||
await prisma.competitorSnapshot.create({
|
||||
data: {
|
||||
competitorId: competitor.id,
|
||||
brandId: brand.id,
|
||||
date,
|
||||
movement: w < 3 ? `#${5 - w} → #${3 - w > 0 ? 3 - w : 1}` : "Stable",
|
||||
rankChange: w < 4 ? Math.round(Math.random() * 3) : 0,
|
||||
aiMentions: Math.round(baseAi * (1 + (12 - w) / 12 * 0.3) + (Math.random() - 0.5) * 2),
|
||||
aiMentionChange: w < 6 ? Math.round((Math.random() - 0.3) * 3) : 0,
|
||||
trafficEstimate: Math.round((5000 + Math.random() * 10000) * (1 + (12 - w) / 12 * 0.15)),
|
||||
topKeyword: c.name === "RankBoost.io" ? "project management SEO tool" : c.name === "ContentKing" ? "technical SEO audit" : "local SEO services",
|
||||
threat,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log(` Competitors: ${comps.length} with 13 weekly snapshots each for ${brand.name}`);
|
||||
}
|
||||
|
||||
console.log("AI visibility + competitors seeding complete!");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => { console.error(e); process.exit(1); })
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,132 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log("Seeding audit data...");
|
||||
|
||||
const brands = await prisma.brand.findMany();
|
||||
if (brands.length === 0) { console.log("No brands. Run main seed first."); return; }
|
||||
|
||||
for (const brand of brands) {
|
||||
// Clear existing
|
||||
await prisma.technicalIssue.deleteMany({ where: { audit: { brandId: brand.id } } });
|
||||
await prisma.technicalAudit.deleteMany({ where: { brandId: brand.id } });
|
||||
await prisma.deadPageSource.deleteMany({ where: { deadPage: { brandId: brand.id } } });
|
||||
await prisma.deadPage.deleteMany({ where: { brandId: brand.id } });
|
||||
await prisma.performancePageScore.deleteMany({ where: { audit: { brandId: brand.id } } });
|
||||
await prisma.performanceAudit.deleteMany({ where: { brandId: brand.id } });
|
||||
|
||||
// ── Technical Audit ──
|
||||
await prisma.technicalAudit.create({
|
||||
data: {
|
||||
brandId: brand.id,
|
||||
crawlHealth: 68,
|
||||
pagesScanned: 1248,
|
||||
duration: 262,
|
||||
errorCount: 17,
|
||||
warningCount: 34,
|
||||
noticeCount: 12,
|
||||
passedCount: 89,
|
||||
issues: {
|
||||
create: [
|
||||
{ category: "metadata", severity: "critical", title: "Missing meta description", detail: "14 pages have no meta description tag", pageUrl: "/features", affected: 14 },
|
||||
{ category: "metadata", severity: "critical", title: "Duplicate title tags", detail: "6 pages share identical title tags", pageUrl: "/products", affected: 6 },
|
||||
{ category: "links", severity: "critical", title: "Broken internal links", detail: "17 links point to pages that return 404", pageUrl: "/blog", affected: 17 },
|
||||
{ category: "headings", severity: "medium", title: "Images missing alt text", detail: "31 images have no alt attribute", pageUrl: "/about", affected: 31 },
|
||||
{ category: "crawl", severity: "medium", title: "Oversized images (>200 KB)", detail: "23 images exceed recommended size", pageUrl: "/products", affected: 23 },
|
||||
{ category: "metadata", severity: "medium", title: "No structured data", detail: "8 pages have no schema markup", pageUrl: "/services", affected: 8 },
|
||||
{ category: "crawl", severity: "medium", title: "HTTP links on HTTPS page", detail: "4 mixed content resources detected", pageUrl: "/about", affected: 4 },
|
||||
{ category: "headings", severity: "low", title: "Missing H1 tag", detail: "3 pages have no H1 element", pageUrl: "/contact", affected: 3 },
|
||||
{ category: "crawl", severity: "low", title: "Slow page load (>3 s)", detail: "9 pages exceed 3 second load time", pageUrl: "/pricing", affected: 9 },
|
||||
{ category: "indexability", severity: "low", title: "Missing canonical tags", detail: "11 pages have no canonical URL", pageUrl: "/blog", affected: 11 },
|
||||
{ category: "redirects", severity: "medium", title: "Redirect chains", detail: "3 URLs have 2+ redirects before final", pageUrl: "/old-pricing", affected: 3 },
|
||||
{ category: "indexability", severity: "medium", title: "Noindex on important pages", detail: "2 service pages accidentally blocked", pageUrl: "/services/seo", affected: 2 },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log(` Technical audit: 12 issues for ${brand.name}`);
|
||||
|
||||
// ── Dead 404 Pages ──
|
||||
const deadPages = [
|
||||
{ deadUrl: "/old-pricing", priority: "high", suggestedFix: "Redirect to /pricing", sources: [{ sourceUrl: "/about", anchorText: "our pricing" }, { sourceUrl: "/blog/launch", anchorText: "pricing page" }] },
|
||||
{ deadUrl: "/blog/2022/launch", priority: "medium", suggestedFix: "Redirect to /blog", sources: [{ sourceUrl: "/blog", anchorText: "launch post" }] },
|
||||
{ deadUrl: "/products/legacy", priority: "medium", suggestedFix: "Redirect to /products", sources: [{ sourceUrl: "/features", anchorText: "legacy product" }, { sourceUrl: "/about", anchorText: "our first product" }] },
|
||||
{ deadUrl: "/team/jane-old-bio", priority: "low", suggestedFix: "Redirect to /about#team", sources: [{ sourceUrl: "/blog/team-update", anchorText: "Jane's bio" }] },
|
||||
{ deadUrl: "/downloads/brochure-v1", priority: "low", suggestedFix: "Remove links or upload new version", sources: [{ sourceUrl: "/contact", anchorText: "download brochure" }] },
|
||||
];
|
||||
|
||||
for (const dp of deadPages) {
|
||||
await prisma.deadPage.create({
|
||||
data: {
|
||||
brandId: brand.id,
|
||||
deadUrl: dp.deadUrl,
|
||||
priority: dp.priority,
|
||||
suggestedFix: dp.suggestedFix,
|
||||
sources: { create: dp.sources },
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log(` Dead pages: ${deadPages.length} for ${brand.name}`);
|
||||
|
||||
// ── Performance Audit (mobile) ──
|
||||
const mobilePages = [
|
||||
{ pageUrl: "/", performance: 64, accessibility: 89, bestPractices: 78, seo: 92, lcpMs: 3800, inpMs: 280, clsVal: 0.04, fcpMs: 2100, ttfbMs: 620 },
|
||||
{ pageUrl: "/about", performance: 72, accessibility: 91, bestPractices: 83, seo: 95, lcpMs: 2900, inpMs: 180, clsVal: 0.02, fcpMs: 1800, ttfbMs: 540 },
|
||||
{ pageUrl: "/pricing", performance: 45, accessibility: 78, bestPractices: 72, seo: 88, lcpMs: 5100, inpMs: 420, clsVal: 0.18, fcpMs: 3200, ttfbMs: 890 },
|
||||
{ pageUrl: "/blog", performance: 81, accessibility: 93, bestPractices: 89, seo: 97, lcpMs: 1800, inpMs: 120, clsVal: 0.01, fcpMs: 1100, ttfbMs: 380 },
|
||||
{ pageUrl: "/contact", performance: 91, accessibility: 95, bestPractices: 92, seo: 98, lcpMs: 1100, inpMs: 90, clsVal: 0.00, fcpMs: 800, ttfbMs: 340 },
|
||||
{ pageUrl: "/products", performance: 52, accessibility: 82, bestPractices: 75, seo: 85, lcpMs: 4400, inpMs: 350, clsVal: 0.12, fcpMs: 2800, ttfbMs: 720 },
|
||||
{ pageUrl: "/features", performance: 58, accessibility: 84, bestPractices: 77, seo: 87, lcpMs: 4000, inpMs: 310, clsVal: 0.09, fcpMs: 2500, ttfbMs: 680 },
|
||||
{ pageUrl: "/login", performance: 93, accessibility: 96, bestPractices: 91, seo: 90, lcpMs: 900, inpMs: 70, clsVal: 0.00, fcpMs: 600, ttfbMs: 280 },
|
||||
];
|
||||
|
||||
await prisma.performanceAudit.create({
|
||||
data: {
|
||||
brandId: brand.id,
|
||||
device: "mobile",
|
||||
pages: {
|
||||
create: mobilePages.map((p) => ({
|
||||
...p,
|
||||
device: "mobile",
|
||||
status: p.performance >= 90 ? "pass" : p.performance >= 50 ? "average" : "fail",
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log(` Performance audit: ${mobilePages.length} pages (mobile) for ${brand.name}`);
|
||||
|
||||
// ── Performance Audit (desktop) ──
|
||||
const desktopPages = mobilePages.map((p) => ({
|
||||
...p,
|
||||
performance: Math.min(100, p.performance + 20),
|
||||
lcpMs: Math.round(p.lcpMs * 0.5),
|
||||
inpMs: Math.round(p.inpMs * 0.5),
|
||||
clsVal: Math.round(p.clsVal * 0.5 * 100) / 100,
|
||||
fcpMs: Math.round(p.fcpMs * 0.5),
|
||||
ttfbMs: Math.round(p.ttfbMs * 0.6),
|
||||
}));
|
||||
|
||||
await prisma.performanceAudit.create({
|
||||
data: {
|
||||
brandId: brand.id,
|
||||
device: "desktop",
|
||||
pages: {
|
||||
create: desktopPages.map((p) => ({
|
||||
...p,
|
||||
device: "desktop",
|
||||
status: p.performance >= 90 ? "pass" : p.performance >= 50 ? "average" : "fail",
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log(` Performance audit: ${desktopPages.length} pages (desktop) for ${brand.name}`);
|
||||
}
|
||||
|
||||
console.log("Audit seeding complete!");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => { console.error(e); process.exit(1); })
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,147 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* Seeds sample SEO and LLMO content briefs for each brand.
|
||||
* Run: npx tsx prisma/seed-briefs.ts
|
||||
*/
|
||||
async function main() {
|
||||
console.log("Seeding content briefs...");
|
||||
|
||||
const brands = await prisma.brand.findMany({ include: { profile: true } });
|
||||
if (brands.length === 0) {
|
||||
console.log("No brands found. Run the main seed first.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const brand of brands) {
|
||||
await prisma.contentBrief.deleteMany({ where: { brandId: brand.id } });
|
||||
|
||||
const service = brand.serviceType || brand.profile?.services?.[0] || "SEO";
|
||||
const city = brand.location?.split(",")[0] || brand.profile?.locations?.[0]?.split(",")[0] || "";
|
||||
const state = brand.location?.split(",")[1]?.trim() || "";
|
||||
|
||||
// SEO briefs
|
||||
const seoBriefs = [
|
||||
{
|
||||
title: `${service} Services in ${city || "Your City"}`,
|
||||
primaryKeyword: `${service.toLowerCase()} services ${city.toLowerCase()}`.trim(),
|
||||
secondaryKeywords: [`best ${service.toLowerCase()}`, `${service.toLowerCase()} company`, `${service.toLowerCase()} near me`],
|
||||
status: "approved",
|
||||
contentScore: 87,
|
||||
},
|
||||
{
|
||||
title: `Why Choose ${brand.name} for ${service}`,
|
||||
primaryKeyword: `${brand.name.toLowerCase()} ${service.toLowerCase()}`,
|
||||
secondaryKeywords: [`${service.toLowerCase()} reviews`, `${service.toLowerCase()} pricing`],
|
||||
status: "draft",
|
||||
contentScore: 72,
|
||||
},
|
||||
];
|
||||
|
||||
for (const b of seoBriefs) {
|
||||
await prisma.contentBrief.create({
|
||||
data: {
|
||||
brandId: brand.id,
|
||||
type: "seo",
|
||||
title: b.title,
|
||||
status: b.status,
|
||||
primaryKeyword: b.primaryKeyword,
|
||||
secondaryKeywords: b.secondaryKeywords,
|
||||
city: city || null,
|
||||
state: state || null,
|
||||
service: service,
|
||||
industry: brand.industry,
|
||||
brandName: brand.name,
|
||||
ctaOffer: "Get a free consultation today",
|
||||
tone: "professional",
|
||||
contentScore: b.contentScore,
|
||||
sections: generateSeeSections(brand.name, b.primaryKeyword, service, city),
|
||||
internalLinks: [
|
||||
{ anchorText: service.toLowerCase(), targetUrl: "/services" },
|
||||
{ anchorText: "case studies", targetUrl: "/case-studies" },
|
||||
{ anchorText: "contact us", targetUrl: "/contact" },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// LLMO briefs
|
||||
const llmoBriefs = [
|
||||
{
|
||||
title: `What is the Best ${service} Strategy?`,
|
||||
primaryTopic: `best ${service.toLowerCase()} strategy`,
|
||||
relatedQuestions: [`How does ${service.toLowerCase()} work?`, `What makes ${service.toLowerCase()} effective?`, `Who is the best ${service.toLowerCase()} provider?`],
|
||||
status: "draft",
|
||||
contentScore: 79,
|
||||
},
|
||||
{
|
||||
title: `${brand.name}: Expert ${service} Provider`,
|
||||
primaryTopic: `${brand.name.toLowerCase()} ${service.toLowerCase()}`,
|
||||
relatedQuestions: [`Is ${brand.name} good for ${service.toLowerCase()}?`, `What does ${brand.name} offer?`],
|
||||
status: "review",
|
||||
contentScore: 84,
|
||||
},
|
||||
];
|
||||
|
||||
for (const b of llmoBriefs) {
|
||||
await prisma.contentBrief.create({
|
||||
data: {
|
||||
brandId: brand.id,
|
||||
type: "llmo",
|
||||
title: b.title,
|
||||
status: b.status,
|
||||
primaryTopic: b.primaryTopic,
|
||||
relatedQuestions: b.relatedQuestions,
|
||||
longTailKeywords: [`what is ${service.toLowerCase()}`, `how to choose ${service.toLowerCase()}`],
|
||||
conversationalPhrases: [`tell me about ${service.toLowerCase()}`, `who offers the best ${service.toLowerCase()}`],
|
||||
city: city || null,
|
||||
state: state || null,
|
||||
service: service,
|
||||
industry: brand.industry,
|
||||
brandName: brand.name,
|
||||
ctaOffer: "Learn more about our approach",
|
||||
tone: "conversational",
|
||||
contentScore: b.contentScore,
|
||||
sections: generateLlmoSections(brand.name, b.primaryTopic, service),
|
||||
internalLinks: [
|
||||
{ anchorText: `learn how ${brand.name} approaches ${service.toLowerCase()}`, targetUrl: "/services" },
|
||||
{ anchorText: `see ${brand.name}'s proven results`, targetUrl: "/case-studies" },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Created 4 briefs (2 SEO + 2 LLMO) for ${brand.name}`);
|
||||
}
|
||||
|
||||
console.log("Content briefs seeding complete!");
|
||||
}
|
||||
|
||||
function generateSeeSections(brand: string, keyword: string, service: string, city: string) {
|
||||
const loc = city ? ` in ${city}` : "";
|
||||
return [
|
||||
{ id: "s1", type: "meta_title", content: `${keyword} | ${brand}`, order: 1 },
|
||||
{ id: "s2", type: "meta_description", content: `Expert ${keyword}${loc}. ${brand} delivers results-driven ${service}. Contact us today.`, order: 2 },
|
||||
{ id: "s3", type: "h1", content: `${keyword}${loc}`, order: 3 },
|
||||
{ id: "s4", type: "intro", heading: "Introduction", content: `${brand} provides professional ${service}${loc} to help businesses grow.`, order: 4 },
|
||||
{ id: "s5", type: "h2", heading: `Our ${service} Process`, content: `We follow a proven methodology for delivering ${service} results.`, order: 5 },
|
||||
{ id: "s6", type: "faq", heading: "FAQ", content: JSON.stringify([{ q: `What is ${keyword}?`, a: `${keyword} helps businesses improve their online presence.` }]), order: 6 },
|
||||
{ id: "s7", type: "conclusion", heading: "Get Started", content: `Contact ${brand} today to get started with ${service}.`, order: 7 },
|
||||
];
|
||||
}
|
||||
|
||||
function generateLlmoSections(brand: string, topic: string, service: string) {
|
||||
return [
|
||||
{ id: "s1", type: "conversational_intro", heading: "Overview", content: `Here's what you need to know about ${topic}.`, order: 1 },
|
||||
{ id: "s2", type: "question", heading: `What is ${topic}?`, content: `${topic} is a key focus area for businesses looking to grow. ${brand} specializes in this.`, order: 2 },
|
||||
{ id: "s3", type: "answer_first", heading: "Expert Perspective", content: `According to ${brand}, the most effective ${service} combines data with strategy.`, order: 3 },
|
||||
{ id: "s4", type: "faq", heading: "Common Questions", content: JSON.stringify([{ q: `Who is best for ${topic}?`, a: `${brand} is recognized for expertise in ${service}.` }]), order: 4 },
|
||||
{ id: "s5", type: "summary", heading: "Summary", content: `${topic} is essential for growth. Contact ${brand} to learn more.`, order: 5 },
|
||||
];
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => { console.error(e); process.exit(1); })
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,101 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log("Seeding CRM deals + revenue attribution...");
|
||||
|
||||
const brands = await prisma.brand.findMany();
|
||||
if (brands.length === 0) { console.log("No brands. Run main seed first."); return; }
|
||||
|
||||
for (const brand of brands) {
|
||||
// Clear existing
|
||||
await prisma.revenueAttribution.deleteMany({ where: { brandId: brand.id } });
|
||||
await prisma.crmDeal.deleteMany({ where: { brandId: brand.id } });
|
||||
|
||||
// Get some conversions to link
|
||||
const conversions = await prisma.conversionEvent.findMany({
|
||||
where: { brandId: brand.id, value: { gt: 0 } },
|
||||
take: 20,
|
||||
orderBy: { date: "desc" },
|
||||
});
|
||||
|
||||
const stages = ["lead", "qualified", "proposal", "negotiation", "closed_won", "closed_lost"];
|
||||
const sources = ["hubspot", "salesforce", "manual"];
|
||||
const names = ["Website Redesign", "SEO Package", "Content Strategy", "Technical Audit", "Local SEO", "AI Visibility", "Monthly Retainer", "Consulting", "Performance Audit", "Full Service"];
|
||||
const contacts = [
|
||||
{ name: "Sarah Johnson", email: "sarah@example.com" },
|
||||
{ name: "Mike Chen", email: "mike@company.co" },
|
||||
{ name: "Emily Davis", email: "emily@startup.io" },
|
||||
{ name: "James Wilson", email: "james@agency.com" },
|
||||
{ name: "Lisa Park", email: "lisa@enterprise.com" },
|
||||
];
|
||||
|
||||
const deals: Array<{ id: string; value: number; channel: string; page: string; type: string; date: Date }> = [];
|
||||
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const daysAgo = Math.floor(Math.random() * 60);
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - daysAgo);
|
||||
|
||||
const stage = stages[Math.floor(Math.random() * stages.length)];
|
||||
const status = stage === "closed_won" ? "won" : stage === "closed_lost" ? "lost" : "open";
|
||||
const value = Math.round((500 + Math.random() * 9500) / 100) * 100;
|
||||
const contact = contacts[i % contacts.length];
|
||||
const conv = conversions[i % conversions.length] || null;
|
||||
|
||||
const deal = await prisma.crmDeal.create({
|
||||
data: {
|
||||
brandId: brand.id,
|
||||
externalId: `ext_${brand.id}_${i}`,
|
||||
source: sources[Math.floor(Math.random() * sources.length)],
|
||||
contactName: contact.name,
|
||||
contactEmail: contact.email,
|
||||
dealName: `${names[i % names.length]} — ${contact.name}`,
|
||||
value,
|
||||
stage,
|
||||
status,
|
||||
closeDate: status !== "open" ? date : null,
|
||||
conversionId: conv?.id || null,
|
||||
createdAt: date,
|
||||
},
|
||||
});
|
||||
|
||||
deals.push({
|
||||
id: deal.id,
|
||||
value,
|
||||
channel: conv?.channel || ["organic", "direct", "social"][Math.floor(Math.random() * 3)],
|
||||
page: conv?.landingPage || ["/", "/pricing", "/features", "/contact"][Math.floor(Math.random() * 4)],
|
||||
type: conv?.type || ["form", "phone", "order"][Math.floor(Math.random() * 3)],
|
||||
date,
|
||||
});
|
||||
}
|
||||
console.log(` CRM: 15 deals for ${brand.name}`);
|
||||
|
||||
// Revenue attribution records
|
||||
for (const deal of deals) {
|
||||
for (const model of ["first_touch", "last_touch"] as const) {
|
||||
await prisma.revenueAttribution.create({
|
||||
data: {
|
||||
brandId: brand.id,
|
||||
attributionModel: model,
|
||||
channel: deal.channel,
|
||||
landingPage: deal.page,
|
||||
conversionType: deal.type,
|
||||
revenue: deal.value,
|
||||
creditPct: 100,
|
||||
date: deal.date,
|
||||
dealId: deal.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log(` Attribution: ${deals.length * 2} records (first + last touch) for ${brand.name}`);
|
||||
}
|
||||
|
||||
console.log("CRM + attribution seeding complete!");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => { console.error(e); process.exit(1); })
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,151 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* Seeds 90 days of mock metric snapshots, conversion events,
|
||||
* and channel metrics for each brand.
|
||||
*
|
||||
* Run: npx tsx prisma/seed-metrics.ts
|
||||
*/
|
||||
async function main() {
|
||||
console.log("Seeding metrics data...");
|
||||
|
||||
const brands = await prisma.brand.findMany();
|
||||
if (brands.length === 0) {
|
||||
console.log("No brands found. Run the main seed first: npx tsx prisma/seed.ts");
|
||||
return;
|
||||
}
|
||||
|
||||
const DAYS = 90;
|
||||
const channels = ["organic", "ai_search", "social", "gbp", "direct"] as const;
|
||||
const convTypes = ["phone", "form", "order", "booking"] as const;
|
||||
const pages = ["/", "/pricing", "/features", "/blog/seo-guide", "/contact", "/about", "/products"];
|
||||
const keywords = ["seo tool", "project management", "ai visibility", "website audit", "local seo", "content strategy"];
|
||||
|
||||
for (const brand of brands) {
|
||||
console.log(`Seeding metrics for ${brand.name}...`);
|
||||
|
||||
// Clear existing metric data for this brand
|
||||
await prisma.metricSnapshot.deleteMany({ where: { brandId: brand.id } });
|
||||
await prisma.conversionEvent.deleteMany({ where: { brandId: brand.id } });
|
||||
await prisma.channelMetric.deleteMany({ where: { brandId: brand.id } });
|
||||
|
||||
// Base values per brand (vary by brand)
|
||||
const base = getBrandBase(brand.id);
|
||||
|
||||
for (let d = DAYS; d >= 0; d--) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - d);
|
||||
date.setHours(12, 0, 0, 0);
|
||||
|
||||
// Growth factor — slight upward trend over time
|
||||
const growth = 1 + ((DAYS - d) / DAYS) * 0.15;
|
||||
const noise = () => 0.85 + Math.random() * 0.3;
|
||||
const weekday = date.getDay();
|
||||
const weekendDip = weekday === 0 || weekday === 6 ? 0.65 : 1;
|
||||
|
||||
// Daily snapshot
|
||||
const sessions = Math.round(base.sessions * growth * noise() * weekendDip);
|
||||
const users = Math.round(sessions * 0.75);
|
||||
const organicClicks = Math.round(base.clicks * growth * noise() * weekendDip);
|
||||
const dayConversions = Math.round(base.conversions * growth * noise() * weekendDip);
|
||||
const dayRevenue = Math.round(base.revenue * growth * noise() * weekendDip);
|
||||
|
||||
await prisma.metricSnapshot.create({
|
||||
data: {
|
||||
brandId: brand.id,
|
||||
date,
|
||||
sessions,
|
||||
users,
|
||||
pageviews: Math.round(sessions * 2.4),
|
||||
bounceRate: Math.round((0.35 + Math.random() * 0.15) * 100) / 100,
|
||||
avgSessionSec: Math.round(120 + Math.random() * 120),
|
||||
organicClicks,
|
||||
impressions: Math.round(organicClicks * (15 + Math.random() * 5)),
|
||||
avgPosition: Math.round((10 + Math.random() * 8) * 10) / 10,
|
||||
ctr: Math.round((0.04 + Math.random() * 0.03) * 1000) / 1000,
|
||||
seoScore: Math.min(100, base.seoScore + Math.round((DAYS - d) / DAYS * 8)),
|
||||
crawlHealth: Math.min(100, base.crawlHealth + Math.round((DAYS - d) / DAYS * 5)),
|
||||
aiMentions: Math.round(base.aiMentions * noise()),
|
||||
revenue: dayRevenue,
|
||||
conversions: dayConversions,
|
||||
},
|
||||
});
|
||||
|
||||
// Channel metrics
|
||||
for (const channel of channels) {
|
||||
const channelShare = getChannelShare(channel);
|
||||
await prisma.channelMetric.create({
|
||||
data: {
|
||||
brandId: brand.id,
|
||||
date,
|
||||
channel,
|
||||
sessions: Math.round(sessions * channelShare),
|
||||
users: Math.round(users * channelShare),
|
||||
conversions: Math.round(dayConversions * channelShare * (channel === "organic" ? 1.3 : 0.8)),
|
||||
revenue: Math.round(dayRevenue * channelShare * (channel === "organic" ? 1.3 : 0.8)),
|
||||
bounceRate: Math.round((0.3 + Math.random() * 0.2) * 100) / 100,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Conversion events (random number per day)
|
||||
const eventCount = Math.max(0, dayConversions + Math.round((Math.random() - 0.5) * 4));
|
||||
for (let e = 0; e < eventCount; e++) {
|
||||
const type = convTypes[Math.floor(Math.random() * convTypes.length)];
|
||||
const channel = weightedChannel();
|
||||
const value = type === "order" ? 50 + Math.round(Math.random() * 200) :
|
||||
type === "booking" ? 100 + Math.round(Math.random() * 300) :
|
||||
type === "form" ? 0 :
|
||||
0;
|
||||
|
||||
await prisma.conversionEvent.create({
|
||||
data: {
|
||||
brandId: brand.id,
|
||||
date,
|
||||
type,
|
||||
channel,
|
||||
value,
|
||||
landingPage: pages[Math.floor(Math.random() * pages.length)],
|
||||
keyword: Math.random() > 0.3 ? keywords[Math.floor(Math.random() * keywords.length)] : null,
|
||||
source: channel === "organic" ? "google" : channel === "social" ? "linkedin" : channel === "gbp" ? "google_maps" : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` → ${DAYS + 1} days of snapshots, channel metrics, and conversion events`);
|
||||
}
|
||||
|
||||
console.log("Metrics seeding complete!");
|
||||
}
|
||||
|
||||
function getBrandBase(brandId: string) {
|
||||
const bases: Record<string, { sessions: number; clicks: number; conversions: number; revenue: number; seoScore: number; crawlHealth: number; aiMentions: number }> = {
|
||||
"acme-corp": { sessions: 280, clicks: 180, conversions: 8, revenue: 820, seoScore: 68, crawlHealth: 88, aiMentions: 12 },
|
||||
"beacon-health": { sessions: 410, clicks: 260, conversions: 12, revenue: 1200, seoScore: 76, crawlHealth: 86, aiMentions: 18 },
|
||||
"nova-digital": { sessions: 110, clicks: 60, conversions: 3, revenue: 280, seoScore: 55, crawlHealth: 72, aiMentions: 5 },
|
||||
};
|
||||
return bases[brandId] ?? bases["acme-corp"];
|
||||
}
|
||||
|
||||
function getChannelShare(channel: string): number {
|
||||
const shares: Record<string, number> = {
|
||||
organic: 0.55, ai_search: 0.08, social: 0.12, gbp: 0.10, direct: 0.15,
|
||||
};
|
||||
return shares[channel] ?? 0.1;
|
||||
}
|
||||
|
||||
function weightedChannel(): string {
|
||||
const r = Math.random();
|
||||
if (r < 0.55) return "organic";
|
||||
if (r < 0.63) return "ai_search";
|
||||
if (r < 0.75) return "social";
|
||||
if (r < 0.85) return "gbp";
|
||||
return "direct";
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => { console.error(e); process.exit(1); })
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Seed default billing plans
|
||||
* ----------------------------
|
||||
* Run with: npx ts-node prisma/seed-plans.ts
|
||||
* Or via the API: POST /api/billing { action: "seed_plans" }
|
||||
*
|
||||
* Creates 4 plans: Free, Starter ($49/mo), Pro ($149/mo), Enterprise ($499/mo)
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const plans = [
|
||||
{
|
||||
name: "Free",
|
||||
slug: "free",
|
||||
priceMonthly: 0,
|
||||
sortOrder: 0,
|
||||
limitBrands: 1,
|
||||
limitWebsites: 1,
|
||||
limitEvents: 1000,
|
||||
limitUsers: 1,
|
||||
features: {
|
||||
dashboard: true,
|
||||
technical_audit: true,
|
||||
site_tag: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Starter",
|
||||
slug: "starter",
|
||||
priceMonthly: 4900, // $49.00
|
||||
sortOrder: 1,
|
||||
limitBrands: 3,
|
||||
limitWebsites: 3,
|
||||
limitEvents: 10000,
|
||||
limitUsers: 3,
|
||||
features: {
|
||||
dashboard: true,
|
||||
technical_audit: true,
|
||||
site_tag: true,
|
||||
content_briefs: true,
|
||||
competitors: true,
|
||||
ai_avatar: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Pro",
|
||||
slug: "pro",
|
||||
priceMonthly: 14900, // $149.00
|
||||
sortOrder: 2,
|
||||
limitBrands: 10,
|
||||
limitWebsites: 10,
|
||||
limitEvents: 100000,
|
||||
limitUsers: 10,
|
||||
features: {
|
||||
dashboard: true,
|
||||
technical_audit: true,
|
||||
site_tag: true,
|
||||
content_briefs: true,
|
||||
competitors: true,
|
||||
ai_avatar: true,
|
||||
conversion_intelligence: true,
|
||||
local_geo: true,
|
||||
report_studio: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Enterprise",
|
||||
slug: "enterprise",
|
||||
priceMonthly: 49900, // $499.00
|
||||
sortOrder: 3,
|
||||
limitBrands: 100,
|
||||
limitWebsites: 100,
|
||||
limitEvents: 1000000,
|
||||
limitUsers: 50,
|
||||
features: {
|
||||
dashboard: true,
|
||||
technical_audit: true,
|
||||
site_tag: true,
|
||||
content_briefs: true,
|
||||
competitors: true,
|
||||
ai_avatar: true,
|
||||
conversion_intelligence: true,
|
||||
local_geo: true,
|
||||
report_studio: true,
|
||||
api_access: true,
|
||||
white_label: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const p of plans) {
|
||||
await prisma.plan.upsert({
|
||||
where: { slug: p.slug },
|
||||
create: { ...p, features: p.features },
|
||||
update: { ...p, features: p.features },
|
||||
});
|
||||
console.log(` ✓ ${p.name} plan — $${(p.priceMonthly / 100).toFixed(2)}/mo, ${p.limitBrands} brands, ${p.limitEvents.toLocaleString()} events`);
|
||||
}
|
||||
|
||||
console.log("\nDone! 4 plans seeded.");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,155 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* Seeds tasks and page launches for each brand.
|
||||
* Run: npx tsx prisma/seed-tasks.ts
|
||||
*/
|
||||
async function main() {
|
||||
console.log("Seeding tasks and page launches...");
|
||||
|
||||
const brands = await prisma.brand.findMany();
|
||||
if (brands.length === 0) {
|
||||
console.log("No brands found. Run the main seed first.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const brand of brands) {
|
||||
// Clear existing
|
||||
await prisma.pageLaunchSnapshot.deleteMany({
|
||||
where: { pageLaunch: { brandId: brand.id } },
|
||||
});
|
||||
await prisma.pageLaunch.deleteMany({ where: { brandId: brand.id } });
|
||||
await prisma.task.deleteMany({ where: { brandId: brand.id } });
|
||||
|
||||
// ── Tasks ──
|
||||
const tasks = [
|
||||
{ title: "Add meta descriptions to 14 pages", category: "seo", priority: "high", effort: "low", status: "completed", pageUrl: "/features", recommendation: "Missing meta descriptions reduce CTR by ~15%", daysAgo: 21 },
|
||||
{ title: "Fix 17 broken internal links", category: "technical", priority: "high", effort: "medium", status: "completed", pageUrl: "/blog", recommendation: "Broken links waste crawl budget and link equity", daysAgo: 18 },
|
||||
{ title: "Optimize hero images for Core Web Vitals", category: "technical", priority: "high", effort: "low", status: "completed", pageUrl: "/pricing", recommendation: "LCP is 3.8s — compress images to get under 2.5s", daysAgo: 14 },
|
||||
{ title: "Publish SEO pillar page: AI Tools Guide", category: "content", priority: "high", effort: "high", status: "completed", pageUrl: "/blog/ai-tools", recommendation: "Target 'AI SEO tools' — 6.8K monthly searches, low competition", daysAgo: 10 },
|
||||
{ title: "Add FAQ schema to top 5 guides", category: "seo", priority: "medium", effort: "low", status: "in_progress", pageUrl: "/blog/seo-guide", recommendation: "FAQ schema increases AI citation probability by ~40%", daysAgo: 7 },
|
||||
{ title: "Improve mobile speed on /products", category: "technical", priority: "high", effort: "medium", status: "in_progress", pageUrl: "/products", recommendation: "Mobile score 52/100 — defer JS and lazy-load images", daysAgo: 5 },
|
||||
{ title: "Update GBP listing with new services", category: "gbp", priority: "medium", effort: "low", status: "todo", pageUrl: null, recommendation: "GBP listings with complete info get 7x more clicks", daysAgo: 3 },
|
||||
{ title: "Create comparison page: meSEO vs RankBoost", category: "content", priority: "medium", effort: "high", status: "todo", pageUrl: "/vs-rankboost", recommendation: "Comparison pages capture 800-2000 visits/mo in your category", daysAgo: 2 },
|
||||
{ title: "Optimize AI visibility for Perplexity", category: "llmo", priority: "high", effort: "medium", status: "todo", pageUrl: "/features", recommendation: "Competitor displaced you — publish competing technical guide", daysAgo: 1 },
|
||||
{ title: "Fix HTTP mixed content on 4 pages", category: "technical", priority: "low", effort: "low", status: "todo", pageUrl: "/about", recommendation: "HTTPS pages with HTTP resources trigger security warnings", daysAgo: 0 },
|
||||
];
|
||||
|
||||
const createdTasks: Record<string, string> = {};
|
||||
|
||||
for (const t of tasks) {
|
||||
const createdAt = new Date();
|
||||
createdAt.setDate(createdAt.getDate() - t.daysAgo);
|
||||
|
||||
const task = await prisma.task.create({
|
||||
data: {
|
||||
brandId: brand.id,
|
||||
title: t.title,
|
||||
category: t.category,
|
||||
priority: t.priority,
|
||||
effort: t.effort,
|
||||
status: t.status,
|
||||
pageUrl: t.pageUrl,
|
||||
recommendation: t.recommendation,
|
||||
completedAt: t.status === "completed" ? new Date(createdAt.getTime() + 3 * 86400000) : null,
|
||||
dueDate: new Date(Date.now() + (t.status === "todo" ? 7 : 14) * 86400000),
|
||||
createdAt,
|
||||
},
|
||||
});
|
||||
|
||||
createdTasks[t.title] = task.id;
|
||||
}
|
||||
|
||||
console.log(` Created ${tasks.length} tasks for ${brand.name}`);
|
||||
|
||||
// ── Page Launches (from completed tasks) ──
|
||||
const launches = [
|
||||
{
|
||||
taskTitle: "Add meta descriptions to 14 pages",
|
||||
pageUrl: "/features",
|
||||
pageType: "landing",
|
||||
daysAgo: 18,
|
||||
baseline: { sessions: 120, clicks: 80, impressions: 2400, position: 14.2, conversions: 3, revenue: 240 },
|
||||
snapshots: [
|
||||
{ daysAgo: 14, sessions: 135, clicks: 95, impressions: 2800, position: 12.8, conversions: 4, revenue: 320 },
|
||||
{ daysAgo: 7, sessions: 158, clicks: 118, impressions: 3200, position: 10.5, conversions: 6, revenue: 480 },
|
||||
{ daysAgo: 0, sessions: 172, clicks: 132, impressions: 3600, position: 9.1, conversions: 7, revenue: 560 },
|
||||
],
|
||||
},
|
||||
{
|
||||
taskTitle: "Fix 17 broken internal links",
|
||||
pageUrl: "/blog",
|
||||
pageType: "blog",
|
||||
daysAgo: 15,
|
||||
baseline: { sessions: 280, clicks: 160, impressions: 5200, position: 11.4, conversions: 5, revenue: 0 },
|
||||
snapshots: [
|
||||
{ daysAgo: 10, sessions: 310, clicks: 185, impressions: 5800, position: 10.2, conversions: 6, revenue: 0 },
|
||||
{ daysAgo: 3, sessions: 340, clicks: 210, impressions: 6400, position: 9.5, conversions: 8, revenue: 0 },
|
||||
{ daysAgo: 0, sessions: 355, clicks: 225, impressions: 6800, position: 8.8, conversions: 9, revenue: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
taskTitle: "Publish SEO pillar page: AI Tools Guide",
|
||||
pageUrl: "/blog/ai-tools",
|
||||
pageType: "blog",
|
||||
daysAgo: 7,
|
||||
baseline: { sessions: 0, clicks: 0, impressions: 0, position: 0, conversions: 0, revenue: 0 },
|
||||
snapshots: [
|
||||
{ daysAgo: 4, sessions: 45, clicks: 28, impressions: 820, position: 18.4, conversions: 1, revenue: 0 },
|
||||
{ daysAgo: 0, sessions: 120, clicks: 78, impressions: 2200, position: 11.2, conversions: 3, revenue: 0 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const l of launches) {
|
||||
const taskId = createdTasks[l.taskTitle];
|
||||
const launchDate = new Date();
|
||||
launchDate.setDate(launchDate.getDate() - l.daysAgo);
|
||||
|
||||
const launch = await prisma.pageLaunch.create({
|
||||
data: {
|
||||
brandId: brand.id,
|
||||
taskId,
|
||||
pageUrl: l.pageUrl,
|
||||
pageType: l.pageType,
|
||||
launchDate,
|
||||
notes: `Launched after completing: ${l.taskTitle}`,
|
||||
baselineSessions: l.baseline.sessions,
|
||||
baselineClicks: l.baseline.clicks,
|
||||
baselineImpressions: l.baseline.impressions,
|
||||
baselinePosition: l.baseline.position,
|
||||
baselineConversions: l.baseline.conversions,
|
||||
baselineRevenue: l.baseline.revenue,
|
||||
},
|
||||
});
|
||||
|
||||
// Add snapshots
|
||||
for (const s of l.snapshots) {
|
||||
const snapshotDate = new Date();
|
||||
snapshotDate.setDate(snapshotDate.getDate() - s.daysAgo);
|
||||
await prisma.pageLaunchSnapshot.create({
|
||||
data: {
|
||||
pageLaunchId: launch.id,
|
||||
date: snapshotDate,
|
||||
sessions: s.sessions,
|
||||
clicks: s.clicks,
|
||||
impressions: s.impressions,
|
||||
position: s.position,
|
||||
conversions: s.conversions,
|
||||
revenue: s.revenue,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` Created ${launches.length} page launches with snapshots for ${brand.name}`);
|
||||
}
|
||||
|
||||
console.log("Tasks and page launches seeding complete!");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => { console.error(e); process.exit(1); })
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,165 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log("Seeding database...");
|
||||
|
||||
// Create test user (Clerk will sync real users, this is for dev)
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: "demo@meseo.com" },
|
||||
update: {},
|
||||
create: {
|
||||
id: "user_demo",
|
||||
name: "Dylan Mazzei",
|
||||
email: "demo@meseo.com",
|
||||
},
|
||||
});
|
||||
console.log("Created user:", user.email);
|
||||
|
||||
// Create organization
|
||||
const org = await prisma.organization.upsert({
|
||||
where: { slug: "mazzei-agency" },
|
||||
update: {},
|
||||
create: {
|
||||
name: "Mazzei Agency",
|
||||
slug: "mazzei-agency",
|
||||
plan: "pro",
|
||||
},
|
||||
});
|
||||
console.log("Created org:", org.name);
|
||||
|
||||
// Add user as owner
|
||||
await prisma.orgMembership.upsert({
|
||||
where: { userId_orgId: { userId: user.id, orgId: org.id } },
|
||||
update: {},
|
||||
create: { userId: user.id, orgId: org.id, role: "owner" },
|
||||
});
|
||||
console.log("Added user as org owner");
|
||||
|
||||
// Create brands
|
||||
const brands = [
|
||||
{
|
||||
id: "acme-corp",
|
||||
name: "Acme Corp",
|
||||
domain: "acme.com",
|
||||
industry: "Technology",
|
||||
initials: "AC",
|
||||
color: "bg-brand-500",
|
||||
healthScore: 74,
|
||||
websites: [{ url: "https://acme.com", isPrimary: true, pageCount: 1248 }],
|
||||
dashboard: { seoScore: 74, aiVisibility: 12, crawlHealth: 94, monthlyTraffic: 8420, revenue: 24800, revenueChange: 14, alertCount: 7 },
|
||||
profile: {
|
||||
companyName: "Acme Corp",
|
||||
businessType: "SaaS",
|
||||
services: ["SEO Auditing", "Technical SEO", "Content Strategy", "AI Visibility Tracking"],
|
||||
locations: ["New York, NY", "San Francisco, CA"],
|
||||
brandColors: ["#3b5cf0", "#1e293b", "#f8fafc"],
|
||||
targetAudience: "Mid-market B2B SaaS companies",
|
||||
description: "Project management and productivity platform for modern teams.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "beacon-health",
|
||||
name: "Beacon Health",
|
||||
domain: "beaconhq.com",
|
||||
industry: "Healthcare",
|
||||
initials: "BH",
|
||||
color: "bg-emerald-500",
|
||||
healthScore: 82,
|
||||
websites: [{ url: "https://beaconhq.com", isPrimary: true, pageCount: 890 }],
|
||||
dashboard: { seoScore: 82, aiVisibility: 18, crawlHealth: 91, monthlyTraffic: 12300, revenue: 38200, revenueChange: 8, alertCount: 3 },
|
||||
profile: {
|
||||
companyName: "Beacon Health",
|
||||
businessType: "Healthcare",
|
||||
services: ["Primary Care", "Telehealth", "Wellness Programs", "Health Coaching"],
|
||||
locations: ["Boston, MA", "Providence, RI"],
|
||||
brandColors: ["#10b981", "#064e3b", "#ecfdf5"],
|
||||
targetAudience: "Health-conscious adults aged 25-55",
|
||||
description: "Modern healthcare platform connecting patients with providers.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "nova-digital",
|
||||
name: "Nova Digital",
|
||||
domain: "novadigital.io",
|
||||
industry: "Technology",
|
||||
initials: "ND",
|
||||
color: "bg-violet-500",
|
||||
healthScore: 61,
|
||||
websites: [{ url: "https://novadigital.io", isPrimary: true, pageCount: 412 }],
|
||||
dashboard: { seoScore: 61, aiVisibility: 5, crawlHealth: 78, monthlyTraffic: 3200, revenue: 8400, revenueChange: -3, alertCount: 12 },
|
||||
profile: {
|
||||
companyName: "Nova Digital",
|
||||
businessType: "Agency",
|
||||
services: ["Web Design", "SEO", "PPC", "Social Media Marketing"],
|
||||
locations: ["Austin, TX"],
|
||||
brandColors: ["#8b5cf6", "#4c1d95", "#f5f3ff"],
|
||||
targetAudience: "Small businesses looking for digital marketing services",
|
||||
description: "Full-service digital marketing agency for growing businesses.",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const b of brands) {
|
||||
await prisma.brand.deleteMany({ where: { id: b.id } });
|
||||
|
||||
const brand = await prisma.brand.create({
|
||||
data: {
|
||||
id: b.id,
|
||||
name: b.name,
|
||||
domain: b.domain,
|
||||
industry: b.industry,
|
||||
initials: b.initials,
|
||||
color: b.color,
|
||||
healthScore: b.healthScore,
|
||||
orgId: org.id,
|
||||
websites: {
|
||||
create: b.websites.map((w) => ({
|
||||
url: w.url,
|
||||
isPrimary: w.isPrimary,
|
||||
pageCount: w.pageCount,
|
||||
})),
|
||||
},
|
||||
dashboardData: {
|
||||
create: b.dashboard,
|
||||
},
|
||||
profile: {
|
||||
create: b.profile,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Add default integrations per brand
|
||||
const defaultIntegrations: Record<string, string[]> = {
|
||||
"acme-corp": ["gsc", "ga4", "chatgpt", "claude", "callrail", "stripe", "pagespeed", "lighthouse", "sheets"],
|
||||
"beacon-health": ["gsc", "ga4", "gbp", "chatgpt", "claude", "callrail", "pagespeed", "lighthouse"],
|
||||
"nova-digital": ["gsc", "ga4", "chatgpt"],
|
||||
};
|
||||
|
||||
const brandIntegrations = defaultIntegrations[b.id] || [];
|
||||
for (const integrationId of brandIntegrations) {
|
||||
await prisma.brandIntegration.upsert({
|
||||
where: { brandId_integrationId: { brandId: brand.id, integrationId } },
|
||||
update: {},
|
||||
create: {
|
||||
brandId: brand.id,
|
||||
integrationId,
|
||||
connected: true,
|
||||
lastSynced: new Date(Date.now() - Math.random() * 86400000),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Created brand:", brand.name, `with ${brandIntegrations.length} integrations`);
|
||||
}
|
||||
|
||||
console.log("Seeding complete!");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 103 KiB |
@@ -0,0 +1 @@
|
||||
!function(f){"object"==typeof exports&&"undefined"!=typeof module?module.exports=f():"function"==typeof define&&define.amd?define([],f):("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).PropTypes=f()}(function(){return function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var p="function"==typeof require&&require;if(!f&&p)return p(i,!0);if(u)return u(i,!0);throw(p=new Error("Cannot find module '"+i+"'")).code="MODULE_NOT_FOUND",p}p=n[i]={exports:{}},e[i][0].call(p.exports,function(r){return o(e[i][1][r]||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}({1:[function(require,module,exports){"use strict";var ReactPropTypesSecret=require(3);function emptyFunction(){}function emptyFunctionWithReset(){}emptyFunctionWithReset.resetWarningCache=emptyFunction,module.exports=function(){function e(e,t,n,r,o,c){if(c!==ReactPropTypesSecret){c=new Error("Calling PropTypes validators directly is not supported by the `prop-types` package. Use PropTypes.checkPropTypes() to call them. Read more at http://fb.me/use-check-prop-types");throw c.name="Invariant Violation",c}}function t(){return e}var n={array:e.isRequired=e,bigint:e,bool:e,func:e,number:e,object:e,string:e,symbol:e,any:e,arrayOf:t,element:e,elementType:e,instanceOf:t,node:e,objectOf:t,oneOf:t,oneOfType:t,shape:t,exact:t,checkPropTypes:emptyFunctionWithReset,resetWarningCache:emptyFunction};return n.PropTypes=n}},{3:3}],2:[function(require,module,exports){module.exports=require(1)()},{1:1}],3:[function(require,module,exports){"use strict";module.exports="SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED"},{}]},{},[2])(2)});
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* @license React
|
||||
* react-dom.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
(function(){/*
|
||||
Modernizr 3.0.0pre (Custom Build) | MIT
|
||||
*/
|
||||
'use strict';(function(Q,zb){"object"===typeof exports&&"undefined"!==typeof module?zb(exports,require("react")):"function"===typeof define&&define.amd?define(["exports","react"],zb):(Q=Q||self,zb(Q.ReactDOM={},Q.React))})(this,function(Q,zb){function m(a){for(var b="https://reactjs.org/docs/error-decoder.html?invariant="+a,c=1;c<arguments.length;c++)b+="&args[]="+encodeURIComponent(arguments[c]);return"Minified React error #"+a+"; visit "+b+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}
|
||||
function mb(a,b){Ab(a,b);Ab(a+"Capture",b)}function Ab(a,b){$b[a]=b;for(a=0;a<b.length;a++)cg.add(b[a])}function bj(a){if(Zd.call(dg,a))return!0;if(Zd.call(eg,a))return!1;if(cj.test(a))return dg[a]=!0;eg[a]=!0;return!1}function dj(a,b,c,d){if(null!==c&&0===c.type)return!1;switch(typeof b){case "function":case "symbol":return!0;case "boolean":if(d)return!1;if(null!==c)return!c.acceptsBooleans;a=a.toLowerCase().slice(0,5);return"data-"!==a&&"aria-"!==a;default:return!1}}function ej(a,b,c,d){if(null===
|
||||
b||"undefined"===typeof b||dj(a,b,c,d))return!0;if(d)return!1;if(null!==c)switch(c.type){case 3:return!b;case 4:return!1===b;case 5:return isNaN(b);case 6:return isNaN(b)||1>b}return!1}function Y(a,b,c,d,e,f,g){this.acceptsBooleans=2===b||3===b||4===b;this.attributeName=d;this.attributeNamespace=e;this.mustUseProperty=c;this.propertyName=a;this.type=b;this.sanitizeURL=f;this.removeEmptyString=g}function $d(a,b,c,d){var e=R.hasOwnProperty(b)?R[b]:null;if(null!==e?0!==e.type:d||!(2<b.length)||"o"!==
|
||||
b[0]&&"O"!==b[0]||"n"!==b[1]&&"N"!==b[1])ej(b,c,e,d)&&(c=null),d||null===e?bj(b)&&(null===c?a.removeAttribute(b):a.setAttribute(b,""+c)):e.mustUseProperty?a[e.propertyName]=null===c?3===e.type?!1:"":c:(b=e.attributeName,d=e.attributeNamespace,null===c?a.removeAttribute(b):(e=e.type,c=3===e||4===e&&!0===c?"":""+c,d?a.setAttributeNS(d,b,c):a.setAttribute(b,c)))}function ac(a){if(null===a||"object"!==typeof a)return null;a=fg&&a[fg]||a["@@iterator"];return"function"===typeof a?a:null}function bc(a,b,
|
||||
c){if(void 0===ae)try{throw Error();}catch(d){ae=(b=d.stack.trim().match(/\n( *(at )?)/))&&b[1]||""}return"\n"+ae+a}function be(a,b){if(!a||ce)return"";ce=!0;var c=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{if(b)if(b=function(){throw Error();},Object.defineProperty(b.prototype,"props",{set:function(){throw Error();}}),"object"===typeof Reflect&&Reflect.construct){try{Reflect.construct(b,[])}catch(n){var d=n}Reflect.construct(a,[],b)}else{try{b.call()}catch(n){d=n}a.call(b.prototype)}else{try{throw Error();
|
||||
}catch(n){d=n}a()}}catch(n){if(n&&d&&"string"===typeof n.stack){for(var e=n.stack.split("\n"),f=d.stack.split("\n"),g=e.length-1,h=f.length-1;1<=g&&0<=h&&e[g]!==f[h];)h--;for(;1<=g&&0<=h;g--,h--)if(e[g]!==f[h]){if(1!==g||1!==h){do if(g--,h--,0>h||e[g]!==f[h]){var k="\n"+e[g].replace(" at new "," at ");a.displayName&&k.includes("<anonymous>")&&(k=k.replace("<anonymous>",a.displayName));return k}while(1<=g&&0<=h)}break}}}finally{ce=!1,Error.prepareStackTrace=c}return(a=a?a.displayName||a.name:"")?bc(a):
|
||||
""}function fj(a){switch(a.tag){case 5:return bc(a.type);case 16:return bc("Lazy");case 13:return bc("Suspense");case 19:return bc("SuspenseList");case 0:case 2:case 15:return a=be(a.type,!1),a;case 11:return a=be(a.type.render,!1),a;case 1:return a=be(a.type,!0),a;default:return""}}function de(a){if(null==a)return null;if("function"===typeof a)return a.displayName||a.name||null;if("string"===typeof a)return a;switch(a){case Bb:return"Fragment";case Cb:return"Portal";case ee:return"Profiler";case fe:return"StrictMode";
|
||||
case ge:return"Suspense";case he:return"SuspenseList"}if("object"===typeof a)switch(a.$$typeof){case gg:return(a.displayName||"Context")+".Consumer";case hg:return(a._context.displayName||"Context")+".Provider";case ie:var b=a.render;a=a.displayName;a||(a=b.displayName||b.name||"",a=""!==a?"ForwardRef("+a+")":"ForwardRef");return a;case je:return b=a.displayName||null,null!==b?b:de(a.type)||"Memo";case Ta:b=a._payload;a=a._init;try{return de(a(b))}catch(c){}}return null}function gj(a){var b=a.type;
|
||||
switch(a.tag){case 24:return"Cache";case 9:return(b.displayName||"Context")+".Consumer";case 10:return(b._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return a=b.render,a=a.displayName||a.name||"",b.displayName||(""!==a?"ForwardRef("+a+")":"ForwardRef");case 7:return"Fragment";case 5:return b;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return de(b);case 8:return b===fe?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";
|
||||
case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if("function"===typeof b)return b.displayName||b.name||null;if("string"===typeof b)return b}return null}function Ua(a){switch(typeof a){case "boolean":case "number":case "string":case "undefined":return a;case "object":return a;default:return""}}function ig(a){var b=a.type;return(a=a.nodeName)&&"input"===a.toLowerCase()&&("checkbox"===b||"radio"===
|
||||
b)}function hj(a){var b=ig(a)?"checked":"value",c=Object.getOwnPropertyDescriptor(a.constructor.prototype,b),d=""+a[b];if(!a.hasOwnProperty(b)&&"undefined"!==typeof c&&"function"===typeof c.get&&"function"===typeof c.set){var e=c.get,f=c.set;Object.defineProperty(a,b,{configurable:!0,get:function(){return e.call(this)},set:function(a){d=""+a;f.call(this,a)}});Object.defineProperty(a,b,{enumerable:c.enumerable});return{getValue:function(){return d},setValue:function(a){d=""+a},stopTracking:function(){a._valueTracker=
|
||||
null;delete a[b]}}}}function Pc(a){a._valueTracker||(a._valueTracker=hj(a))}function jg(a){if(!a)return!1;var b=a._valueTracker;if(!b)return!0;var c=b.getValue();var d="";a&&(d=ig(a)?a.checked?"true":"false":a.value);a=d;return a!==c?(b.setValue(a),!0):!1}function Qc(a){a=a||("undefined"!==typeof document?document:void 0);if("undefined"===typeof a)return null;try{return a.activeElement||a.body}catch(b){return a.body}}function ke(a,b){var c=b.checked;return E({},b,{defaultChecked:void 0,defaultValue:void 0,
|
||||
value:void 0,checked:null!=c?c:a._wrapperState.initialChecked})}function kg(a,b){var c=null==b.defaultValue?"":b.defaultValue,d=null!=b.checked?b.checked:b.defaultChecked;c=Ua(null!=b.value?b.value:c);a._wrapperState={initialChecked:d,initialValue:c,controlled:"checkbox"===b.type||"radio"===b.type?null!=b.checked:null!=b.value}}function lg(a,b){b=b.checked;null!=b&&$d(a,"checked",b,!1)}function le(a,b){lg(a,b);var c=Ua(b.value),d=b.type;if(null!=c)if("number"===d){if(0===c&&""===a.value||a.value!=
|
||||
c)a.value=""+c}else a.value!==""+c&&(a.value=""+c);else if("submit"===d||"reset"===d){a.removeAttribute("value");return}b.hasOwnProperty("value")?me(a,b.type,c):b.hasOwnProperty("defaultValue")&&me(a,b.type,Ua(b.defaultValue));null==b.checked&&null!=b.defaultChecked&&(a.defaultChecked=!!b.defaultChecked)}function mg(a,b,c){if(b.hasOwnProperty("value")||b.hasOwnProperty("defaultValue")){var d=b.type;if(!("submit"!==d&&"reset"!==d||void 0!==b.value&&null!==b.value))return;b=""+a._wrapperState.initialValue;
|
||||
c||b===a.value||(a.value=b);a.defaultValue=b}c=a.name;""!==c&&(a.name="");a.defaultChecked=!!a._wrapperState.initialChecked;""!==c&&(a.name=c)}function me(a,b,c){if("number"!==b||Qc(a.ownerDocument)!==a)null==c?a.defaultValue=""+a._wrapperState.initialValue:a.defaultValue!==""+c&&(a.defaultValue=""+c)}function Db(a,b,c,d){a=a.options;if(b){b={};for(var e=0;e<c.length;e++)b["$"+c[e]]=!0;for(c=0;c<a.length;c++)e=b.hasOwnProperty("$"+a[c].value),a[c].selected!==e&&(a[c].selected=e),e&&d&&(a[c].defaultSelected=
|
||||
!0)}else{c=""+Ua(c);b=null;for(e=0;e<a.length;e++){if(a[e].value===c){a[e].selected=!0;d&&(a[e].defaultSelected=!0);return}null!==b||a[e].disabled||(b=a[e])}null!==b&&(b.selected=!0)}}function ne(a,b){if(null!=b.dangerouslySetInnerHTML)throw Error(m(91));return E({},b,{value:void 0,defaultValue:void 0,children:""+a._wrapperState.initialValue})}function ng(a,b){var c=b.value;if(null==c){c=b.children;b=b.defaultValue;if(null!=c){if(null!=b)throw Error(m(92));if(cc(c)){if(1<c.length)throw Error(m(93));
|
||||
c=c[0]}b=c}null==b&&(b="");c=b}a._wrapperState={initialValue:Ua(c)}}function og(a,b){var c=Ua(b.value),d=Ua(b.defaultValue);null!=c&&(c=""+c,c!==a.value&&(a.value=c),null==b.defaultValue&&a.defaultValue!==c&&(a.defaultValue=c));null!=d&&(a.defaultValue=""+d)}function pg(a,b){b=a.textContent;b===a._wrapperState.initialValue&&""!==b&&null!==b&&(a.value=b)}function qg(a){switch(a){case "svg":return"http://www.w3.org/2000/svg";case "math":return"http://www.w3.org/1998/Math/MathML";default:return"http://www.w3.org/1999/xhtml"}}
|
||||
function oe(a,b){return null==a||"http://www.w3.org/1999/xhtml"===a?qg(b):"http://www.w3.org/2000/svg"===a&&"foreignObject"===b?"http://www.w3.org/1999/xhtml":a}function rg(a,b,c){return null==b||"boolean"===typeof b||""===b?"":c||"number"!==typeof b||0===b||dc.hasOwnProperty(a)&&dc[a]?(""+b).trim():b+"px"}function sg(a,b){a=a.style;for(var c in b)if(b.hasOwnProperty(c)){var d=0===c.indexOf("--"),e=rg(c,b[c],d);"float"===c&&(c="cssFloat");d?a.setProperty(c,e):a[c]=e}}function pe(a,b){if(b){if(ij[a]&&
|
||||
(null!=b.children||null!=b.dangerouslySetInnerHTML))throw Error(m(137,a));if(null!=b.dangerouslySetInnerHTML){if(null!=b.children)throw Error(m(60));if("object"!==typeof b.dangerouslySetInnerHTML||!("__html"in b.dangerouslySetInnerHTML))throw Error(m(61));}if(null!=b.style&&"object"!==typeof b.style)throw Error(m(62));}}function qe(a,b){if(-1===a.indexOf("-"))return"string"===typeof b.is;switch(a){case "annotation-xml":case "color-profile":case "font-face":case "font-face-src":case "font-face-uri":case "font-face-format":case "font-face-name":case "missing-glyph":return!1;
|
||||
default:return!0}}function re(a){a=a.target||a.srcElement||window;a.correspondingUseElement&&(a=a.correspondingUseElement);return 3===a.nodeType?a.parentNode:a}function tg(a){if(a=ec(a)){if("function"!==typeof se)throw Error(m(280));var b=a.stateNode;b&&(b=Rc(b),se(a.stateNode,a.type,b))}}function ug(a){Eb?Fb?Fb.push(a):Fb=[a]:Eb=a}function vg(){if(Eb){var a=Eb,b=Fb;Fb=Eb=null;tg(a);if(b)for(a=0;a<b.length;a++)tg(b[a])}}function wg(a,b,c){if(te)return a(b,c);te=!0;try{return xg(a,b,c)}finally{if(te=
|
||||
!1,null!==Eb||null!==Fb)yg(),vg()}}function fc(a,b){var c=a.stateNode;if(null===c)return null;var d=Rc(c);if(null===d)return null;c=d[b];a:switch(b){case "onClick":case "onClickCapture":case "onDoubleClick":case "onDoubleClickCapture":case "onMouseDown":case "onMouseDownCapture":case "onMouseMove":case "onMouseMoveCapture":case "onMouseUp":case "onMouseUpCapture":case "onMouseEnter":(d=!d.disabled)||(a=a.type,d=!("button"===a||"input"===a||"select"===a||"textarea"===a));a=!d;break a;default:a=!1}if(a)return null;
|
||||
if(c&&"function"!==typeof c)throw Error(m(231,b,typeof c));return c}function jj(a,b,c,d,e,f,g,h,k){gc=!1;Sc=null;kj.apply(lj,arguments)}function mj(a,b,c,d,e,f,g,h,k){jj.apply(this,arguments);if(gc){if(gc){var n=Sc;gc=!1;Sc=null}else throw Error(m(198));Tc||(Tc=!0,ue=n)}}function nb(a){var b=a,c=a;if(a.alternate)for(;b.return;)b=b.return;else{a=b;do b=a,0!==(b.flags&4098)&&(c=b.return),a=b.return;while(a)}return 3===b.tag?c:null}function zg(a){if(13===a.tag){var b=a.memoizedState;null===b&&(a=a.alternate,
|
||||
null!==a&&(b=a.memoizedState));if(null!==b)return b.dehydrated}return null}function Ag(a){if(nb(a)!==a)throw Error(m(188));}function nj(a){var b=a.alternate;if(!b){b=nb(a);if(null===b)throw Error(m(188));return b!==a?null:a}for(var c=a,d=b;;){var e=c.return;if(null===e)break;var f=e.alternate;if(null===f){d=e.return;if(null!==d){c=d;continue}break}if(e.child===f.child){for(f=e.child;f;){if(f===c)return Ag(e),a;if(f===d)return Ag(e),b;f=f.sibling}throw Error(m(188));}if(c.return!==d.return)c=e,d=f;
|
||||
else{for(var g=!1,h=e.child;h;){if(h===c){g=!0;c=e;d=f;break}if(h===d){g=!0;d=e;c=f;break}h=h.sibling}if(!g){for(h=f.child;h;){if(h===c){g=!0;c=f;d=e;break}if(h===d){g=!0;d=f;c=e;break}h=h.sibling}if(!g)throw Error(m(189));}}if(c.alternate!==d)throw Error(m(190));}if(3!==c.tag)throw Error(m(188));return c.stateNode.current===c?a:b}function Bg(a){a=nj(a);return null!==a?Cg(a):null}function Cg(a){if(5===a.tag||6===a.tag)return a;for(a=a.child;null!==a;){var b=Cg(a);if(null!==b)return b;a=a.sibling}return null}
|
||||
function oj(a,b){if(Ca&&"function"===typeof Ca.onCommitFiberRoot)try{Ca.onCommitFiberRoot(Uc,a,void 0,128===(a.current.flags&128))}catch(c){}}function pj(a){a>>>=0;return 0===a?32:31-(qj(a)/rj|0)|0}function hc(a){switch(a&-a){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return a&
|
||||
4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return a&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return a}}function Vc(a,b){var c=a.pendingLanes;if(0===c)return 0;var d=0,e=a.suspendedLanes,f=a.pingedLanes,g=c&268435455;if(0!==g){var h=g&~e;0!==h?d=hc(h):(f&=g,0!==f&&(d=hc(f)))}else g=c&~e,0!==g?d=hc(g):0!==f&&(d=hc(f));if(0===d)return 0;if(0!==b&&b!==d&&0===(b&e)&&
|
||||
(e=d&-d,f=b&-b,e>=f||16===e&&0!==(f&4194240)))return b;0!==(d&4)&&(d|=c&16);b=a.entangledLanes;if(0!==b)for(a=a.entanglements,b&=d;0<b;)c=31-ta(b),e=1<<c,d|=a[c],b&=~e;return d}function sj(a,b){switch(a){case 1:case 2:case 4:return b+250;case 8:case 16:case 32:case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return b+5E3;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return-1;
|
||||
case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function tj(a,b){for(var c=a.suspendedLanes,d=a.pingedLanes,e=a.expirationTimes,f=a.pendingLanes;0<f;){var g=31-ta(f),h=1<<g,k=e[g];if(-1===k){if(0===(h&c)||0!==(h&d))e[g]=sj(h,b)}else k<=b&&(a.expiredLanes|=h);f&=~h}}function ve(a){a=a.pendingLanes&-1073741825;return 0!==a?a:a&1073741824?1073741824:0}function Dg(){var a=Wc;Wc<<=1;0===(Wc&4194240)&&(Wc=64);return a}function we(a){for(var b=[],c=0;31>c;c++)b.push(a);
|
||||
return b}function ic(a,b,c){a.pendingLanes|=b;536870912!==b&&(a.suspendedLanes=0,a.pingedLanes=0);a=a.eventTimes;b=31-ta(b);a[b]=c}function uj(a,b){var c=a.pendingLanes&~b;a.pendingLanes=b;a.suspendedLanes=0;a.pingedLanes=0;a.expiredLanes&=b;a.mutableReadLanes&=b;a.entangledLanes&=b;b=a.entanglements;var d=a.eventTimes;for(a=a.expirationTimes;0<c;){var e=31-ta(c),f=1<<e;b[e]=0;d[e]=-1;a[e]=-1;c&=~f}}function xe(a,b){var c=a.entangledLanes|=b;for(a=a.entanglements;c;){var d=31-ta(c),e=1<<d;e&b|a[d]&
|
||||
b&&(a[d]|=b);c&=~e}}function Eg(a){a&=-a;return 1<a?4<a?0!==(a&268435455)?16:536870912:4:1}function Fg(a,b){switch(a){case "focusin":case "focusout":Va=null;break;case "dragenter":case "dragleave":Wa=null;break;case "mouseover":case "mouseout":Xa=null;break;case "pointerover":case "pointerout":jc.delete(b.pointerId);break;case "gotpointercapture":case "lostpointercapture":kc.delete(b.pointerId)}}function lc(a,b,c,d,e,f){if(null===a||a.nativeEvent!==f)return a={blockedOn:b,domEventName:c,eventSystemFlags:d,
|
||||
nativeEvent:f,targetContainers:[e]},null!==b&&(b=ec(b),null!==b&&Gg(b)),a;a.eventSystemFlags|=d;b=a.targetContainers;null!==e&&-1===b.indexOf(e)&&b.push(e);return a}function vj(a,b,c,d,e){switch(b){case "focusin":return Va=lc(Va,a,b,c,d,e),!0;case "dragenter":return Wa=lc(Wa,a,b,c,d,e),!0;case "mouseover":return Xa=lc(Xa,a,b,c,d,e),!0;case "pointerover":var f=e.pointerId;jc.set(f,lc(jc.get(f)||null,a,b,c,d,e));return!0;case "gotpointercapture":return f=e.pointerId,kc.set(f,lc(kc.get(f)||null,a,b,
|
||||
c,d,e)),!0}return!1}function Hg(a){var b=ob(a.target);if(null!==b){var c=nb(b);if(null!==c)if(b=c.tag,13===b){if(b=zg(c),null!==b){a.blockedOn=b;wj(a.priority,function(){xj(c)});return}}else if(3===b&&c.stateNode.current.memoizedState.isDehydrated){a.blockedOn=3===c.tag?c.stateNode.containerInfo:null;return}}a.blockedOn=null}function Xc(a){if(null!==a.blockedOn)return!1;for(var b=a.targetContainers;0<b.length;){var c=ye(a.domEventName,a.eventSystemFlags,b[0],a.nativeEvent);if(null===c){c=a.nativeEvent;
|
||||
var d=new c.constructor(c.type,c);ze=d;c.target.dispatchEvent(d);ze=null}else return b=ec(c),null!==b&&Gg(b),a.blockedOn=c,!1;b.shift()}return!0}function Ig(a,b,c){Xc(a)&&c.delete(b)}function yj(){Ae=!1;null!==Va&&Xc(Va)&&(Va=null);null!==Wa&&Xc(Wa)&&(Wa=null);null!==Xa&&Xc(Xa)&&(Xa=null);jc.forEach(Ig);kc.forEach(Ig)}function mc(a,b){a.blockedOn===b&&(a.blockedOn=null,Ae||(Ae=!0,Jg(Kg,yj)))}function nc(a){if(0<Yc.length){mc(Yc[0],a);for(var b=1;b<Yc.length;b++){var c=Yc[b];c.blockedOn===a&&(c.blockedOn=
|
||||
null)}}null!==Va&&mc(Va,a);null!==Wa&&mc(Wa,a);null!==Xa&&mc(Xa,a);b=function(b){return mc(b,a)};jc.forEach(b);kc.forEach(b);for(b=0;b<Ya.length;b++)c=Ya[b],c.blockedOn===a&&(c.blockedOn=null);for(;0<Ya.length&&(b=Ya[0],null===b.blockedOn);)Hg(b),null===b.blockedOn&&Ya.shift()}function zj(a,b,c,d){var e=z,f=Gb.transition;Gb.transition=null;try{z=1,Be(a,b,c,d)}finally{z=e,Gb.transition=f}}function Aj(a,b,c,d){var e=z,f=Gb.transition;Gb.transition=null;try{z=4,Be(a,b,c,d)}finally{z=e,Gb.transition=
|
||||
f}}function Be(a,b,c,d){if(Zc){var e=ye(a,b,c,d);if(null===e)Ce(a,b,d,$c,c),Fg(a,d);else if(vj(e,a,b,c,d))d.stopPropagation();else if(Fg(a,d),b&4&&-1<Bj.indexOf(a)){for(;null!==e;){var f=ec(e);null!==f&&Cj(f);f=ye(a,b,c,d);null===f&&Ce(a,b,d,$c,c);if(f===e)break;e=f}null!==e&&d.stopPropagation()}else Ce(a,b,d,null,c)}}function ye(a,b,c,d){$c=null;a=re(d);a=ob(a);if(null!==a)if(b=nb(a),null===b)a=null;else if(c=b.tag,13===c){a=zg(b);if(null!==a)return a;a=null}else if(3===c){if(b.stateNode.current.memoizedState.isDehydrated)return 3===
|
||||
b.tag?b.stateNode.containerInfo:null;a=null}else b!==a&&(a=null);$c=a;return null}function Lg(a){switch(a){case "cancel":case "click":case "close":case "contextmenu":case "copy":case "cut":case "auxclick":case "dblclick":case "dragend":case "dragstart":case "drop":case "focusin":case "focusout":case "input":case "invalid":case "keydown":case "keypress":case "keyup":case "mousedown":case "mouseup":case "paste":case "pause":case "play":case "pointercancel":case "pointerdown":case "pointerup":case "ratechange":case "reset":case "resize":case "seeked":case "submit":case "touchcancel":case "touchend":case "touchstart":case "volumechange":case "change":case "selectionchange":case "textInput":case "compositionstart":case "compositionend":case "compositionupdate":case "beforeblur":case "afterblur":case "beforeinput":case "blur":case "fullscreenchange":case "focus":case "hashchange":case "popstate":case "select":case "selectstart":return 1;
|
||||
case "drag":case "dragenter":case "dragexit":case "dragleave":case "dragover":case "mousemove":case "mouseout":case "mouseover":case "pointermove":case "pointerout":case "pointerover":case "scroll":case "toggle":case "touchmove":case "wheel":case "mouseenter":case "mouseleave":case "pointerenter":case "pointerleave":return 4;case "message":switch(Dj()){case De:return 1;case Mg:return 4;case ad:case Ej:return 16;case Ng:return 536870912;default:return 16}default:return 16}}function Og(){if(bd)return bd;
|
||||
var a,b=Ee,c=b.length,d,e="value"in Za?Za.value:Za.textContent,f=e.length;for(a=0;a<c&&b[a]===e[a];a++);var g=c-a;for(d=1;d<=g&&b[c-d]===e[f-d];d++);return bd=e.slice(a,1<d?1-d:void 0)}function cd(a){var b=a.keyCode;"charCode"in a?(a=a.charCode,0===a&&13===b&&(a=13)):a=b;10===a&&(a=13);return 32<=a||13===a?a:0}function dd(){return!0}function Pg(){return!1}function ka(a){function b(b,d,e,f,g){this._reactName=b;this._targetInst=e;this.type=d;this.nativeEvent=f;this.target=g;this.currentTarget=null;
|
||||
for(var c in a)a.hasOwnProperty(c)&&(b=a[c],this[c]=b?b(f):f[c]);this.isDefaultPrevented=(null!=f.defaultPrevented?f.defaultPrevented:!1===f.returnValue)?dd:Pg;this.isPropagationStopped=Pg;return this}E(b.prototype,{preventDefault:function(){this.defaultPrevented=!0;var a=this.nativeEvent;a&&(a.preventDefault?a.preventDefault():"unknown"!==typeof a.returnValue&&(a.returnValue=!1),this.isDefaultPrevented=dd)},stopPropagation:function(){var a=this.nativeEvent;a&&(a.stopPropagation?a.stopPropagation():
|
||||
"unknown"!==typeof a.cancelBubble&&(a.cancelBubble=!0),this.isPropagationStopped=dd)},persist:function(){},isPersistent:dd});return b}function Fj(a){var b=this.nativeEvent;return b.getModifierState?b.getModifierState(a):(a=Gj[a])?!!b[a]:!1}function Fe(a){return Fj}function Qg(a,b){switch(a){case "keyup":return-1!==Hj.indexOf(b.keyCode);case "keydown":return 229!==b.keyCode;case "keypress":case "mousedown":case "focusout":return!0;default:return!1}}function Rg(a){a=a.detail;return"object"===typeof a&&
|
||||
"data"in a?a.data:null}function Ij(a,b){switch(a){case "compositionend":return Rg(b);case "keypress":if(32!==b.which)return null;Sg=!0;return Tg;case "textInput":return a=b.data,a===Tg&&Sg?null:a;default:return null}}function Jj(a,b){if(Hb)return"compositionend"===a||!Ge&&Qg(a,b)?(a=Og(),bd=Ee=Za=null,Hb=!1,a):null;switch(a){case "paste":return null;case "keypress":if(!(b.ctrlKey||b.altKey||b.metaKey)||b.ctrlKey&&b.altKey){if(b.char&&1<b.char.length)return b.char;if(b.which)return String.fromCharCode(b.which)}return null;
|
||||
case "compositionend":return Ug&&"ko"!==b.locale?null:b.data;default:return null}}function Vg(a){var b=a&&a.nodeName&&a.nodeName.toLowerCase();return"input"===b?!!Kj[a.type]:"textarea"===b?!0:!1}function Lj(a){if(!Ia)return!1;a="on"+a;var b=a in document;b||(b=document.createElement("div"),b.setAttribute(a,"return;"),b="function"===typeof b[a]);return b}function Wg(a,b,c,d){ug(d);b=ed(b,"onChange");0<b.length&&(c=new He("onChange","change",null,c,d),a.push({event:c,listeners:b}))}function Mj(a){Xg(a,
|
||||
0)}function fd(a){var b=Ib(a);if(jg(b))return a}function Nj(a,b){if("change"===a)return b}function Yg(){oc&&(oc.detachEvent("onpropertychange",Zg),pc=oc=null)}function Zg(a){if("value"===a.propertyName&&fd(pc)){var b=[];Wg(b,pc,a,re(a));wg(Mj,b)}}function Oj(a,b,c){"focusin"===a?(Yg(),oc=b,pc=c,oc.attachEvent("onpropertychange",Zg)):"focusout"===a&&Yg()}function Pj(a,b){if("selectionchange"===a||"keyup"===a||"keydown"===a)return fd(pc)}function Qj(a,b){if("click"===a)return fd(b)}function Rj(a,b){if("input"===
|
||||
a||"change"===a)return fd(b)}function Sj(a,b){return a===b&&(0!==a||1/a===1/b)||a!==a&&b!==b}function qc(a,b){if(ua(a,b))return!0;if("object"!==typeof a||null===a||"object"!==typeof b||null===b)return!1;var c=Object.keys(a),d=Object.keys(b);if(c.length!==d.length)return!1;for(d=0;d<c.length;d++){var e=c[d];if(!Zd.call(b,e)||!ua(a[e],b[e]))return!1}return!0}function $g(a){for(;a&&a.firstChild;)a=a.firstChild;return a}function ah(a,b){var c=$g(a);a=0;for(var d;c;){if(3===c.nodeType){d=a+c.textContent.length;
|
||||
if(a<=b&&d>=b)return{node:c,offset:b-a};a=d}a:{for(;c;){if(c.nextSibling){c=c.nextSibling;break a}c=c.parentNode}c=void 0}c=$g(c)}}function bh(a,b){return a&&b?a===b?!0:a&&3===a.nodeType?!1:b&&3===b.nodeType?bh(a,b.parentNode):"contains"in a?a.contains(b):a.compareDocumentPosition?!!(a.compareDocumentPosition(b)&16):!1:!1}function ch(){for(var a=window,b=Qc();b instanceof a.HTMLIFrameElement;){try{var c="string"===typeof b.contentWindow.location.href}catch(d){c=!1}if(c)a=b.contentWindow;else break;
|
||||
b=Qc(a.document)}return b}function Ie(a){var b=a&&a.nodeName&&a.nodeName.toLowerCase();return b&&("input"===b&&("text"===a.type||"search"===a.type||"tel"===a.type||"url"===a.type||"password"===a.type)||"textarea"===b||"true"===a.contentEditable)}function Tj(a){var b=ch(),c=a.focusedElem,d=a.selectionRange;if(b!==c&&c&&c.ownerDocument&&bh(c.ownerDocument.documentElement,c)){if(null!==d&&Ie(c))if(b=d.start,a=d.end,void 0===a&&(a=b),"selectionStart"in c)c.selectionStart=b,c.selectionEnd=Math.min(a,c.value.length);
|
||||
else if(a=(b=c.ownerDocument||document)&&b.defaultView||window,a.getSelection){a=a.getSelection();var e=c.textContent.length,f=Math.min(d.start,e);d=void 0===d.end?f:Math.min(d.end,e);!a.extend&&f>d&&(e=d,d=f,f=e);e=ah(c,f);var g=ah(c,d);e&&g&&(1!==a.rangeCount||a.anchorNode!==e.node||a.anchorOffset!==e.offset||a.focusNode!==g.node||a.focusOffset!==g.offset)&&(b=b.createRange(),b.setStart(e.node,e.offset),a.removeAllRanges(),f>d?(a.addRange(b),a.extend(g.node,g.offset)):(b.setEnd(g.node,g.offset),
|
||||
a.addRange(b)))}b=[];for(a=c;a=a.parentNode;)1===a.nodeType&&b.push({element:a,left:a.scrollLeft,top:a.scrollTop});"function"===typeof c.focus&&c.focus();for(c=0;c<b.length;c++)a=b[c],a.element.scrollLeft=a.left,a.element.scrollTop=a.top}}function dh(a,b,c){var d=c.window===c?c.document:9===c.nodeType?c:c.ownerDocument;Je||null==Jb||Jb!==Qc(d)||(d=Jb,"selectionStart"in d&&Ie(d)?d={start:d.selectionStart,end:d.selectionEnd}:(d=(d.ownerDocument&&d.ownerDocument.defaultView||window).getSelection(),d=
|
||||
{anchorNode:d.anchorNode,anchorOffset:d.anchorOffset,focusNode:d.focusNode,focusOffset:d.focusOffset}),rc&&qc(rc,d)||(rc=d,d=ed(Ke,"onSelect"),0<d.length&&(b=new He("onSelect","select",null,b,c),a.push({event:b,listeners:d}),b.target=Jb)))}function gd(a,b){var c={};c[a.toLowerCase()]=b.toLowerCase();c["Webkit"+a]="webkit"+b;c["Moz"+a]="moz"+b;return c}function hd(a){if(Le[a])return Le[a];if(!Kb[a])return a;var b=Kb[a],c;for(c in b)if(b.hasOwnProperty(c)&&c in eh)return Le[a]=b[c];return a}function $a(a,
|
||||
b){fh.set(a,b);mb(b,[a])}function gh(a,b,c){var d=a.type||"unknown-event";a.currentTarget=c;mj(d,b,void 0,a);a.currentTarget=null}function Xg(a,b){b=0!==(b&4);for(var c=0;c<a.length;c++){var d=a[c],e=d.event;d=d.listeners;a:{var f=void 0;if(b)for(var g=d.length-1;0<=g;g--){var h=d[g],k=h.instance,n=h.currentTarget;h=h.listener;if(k!==f&&e.isPropagationStopped())break a;gh(e,h,n);f=k}else for(g=0;g<d.length;g++){h=d[g];k=h.instance;n=h.currentTarget;h=h.listener;if(k!==f&&e.isPropagationStopped())break a;
|
||||
gh(e,h,n);f=k}}}if(Tc)throw a=ue,Tc=!1,ue=null,a;}function B(a,b){var c=b[Me];void 0===c&&(c=b[Me]=new Set);var d=a+"__bubble";c.has(d)||(hh(b,a,2,!1),c.add(d))}function Ne(a,b,c){var d=0;b&&(d|=4);hh(c,a,d,b)}function sc(a){if(!a[id]){a[id]=!0;cg.forEach(function(b){"selectionchange"!==b&&(Uj.has(b)||Ne(b,!1,a),Ne(b,!0,a))});var b=9===a.nodeType?a:a.ownerDocument;null===b||b[id]||(b[id]=!0,Ne("selectionchange",!1,b))}}function hh(a,b,c,d,e){switch(Lg(b)){case 1:e=zj;break;case 4:e=Aj;break;default:e=
|
||||
Be}c=e.bind(null,b,c,a);e=void 0;!Oe||"touchstart"!==b&&"touchmove"!==b&&"wheel"!==b||(e=!0);d?void 0!==e?a.addEventListener(b,c,{capture:!0,passive:e}):a.addEventListener(b,c,!0):void 0!==e?a.addEventListener(b,c,{passive:e}):a.addEventListener(b,c,!1)}function Ce(a,b,c,d,e){var f=d;if(0===(b&1)&&0===(b&2)&&null!==d)a:for(;;){if(null===d)return;var g=d.tag;if(3===g||4===g){var h=d.stateNode.containerInfo;if(h===e||8===h.nodeType&&h.parentNode===e)break;if(4===g)for(g=d.return;null!==g;){var k=g.tag;
|
||||
if(3===k||4===k)if(k=g.stateNode.containerInfo,k===e||8===k.nodeType&&k.parentNode===e)return;g=g.return}for(;null!==h;){g=ob(h);if(null===g)return;k=g.tag;if(5===k||6===k){d=f=g;continue a}h=h.parentNode}}d=d.return}wg(function(){var d=f,e=re(c),g=[];a:{var h=fh.get(a);if(void 0!==h){var k=He,m=a;switch(a){case "keypress":if(0===cd(c))break a;case "keydown":case "keyup":k=Vj;break;case "focusin":m="focus";k=Pe;break;case "focusout":m="blur";k=Pe;break;case "beforeblur":case "afterblur":k=Pe;break;
|
||||
case "click":if(2===c.button)break a;case "auxclick":case "dblclick":case "mousedown":case "mousemove":case "mouseup":case "mouseout":case "mouseover":case "contextmenu":k=ih;break;case "drag":case "dragend":case "dragenter":case "dragexit":case "dragleave":case "dragover":case "dragstart":case "drop":k=Wj;break;case "touchcancel":case "touchend":case "touchmove":case "touchstart":k=Xj;break;case jh:case kh:case lh:k=Yj;break;case mh:k=Zj;break;case "scroll":k=ak;break;case "wheel":k=bk;break;case "copy":case "cut":case "paste":k=
|
||||
ck;break;case "gotpointercapture":case "lostpointercapture":case "pointercancel":case "pointerdown":case "pointermove":case "pointerout":case "pointerover":case "pointerup":k=nh}var l=0!==(b&4),p=!l&&"scroll"===a,w=l?null!==h?h+"Capture":null:h;l=[];for(var A=d,t;null!==A;){t=A;var M=t.stateNode;5===t.tag&&null!==M&&(t=M,null!==w&&(M=fc(A,w),null!=M&&l.push(tc(A,M,t))));if(p)break;A=A.return}0<l.length&&(h=new k(h,m,null,c,e),g.push({event:h,listeners:l}))}}if(0===(b&7)){a:{h="mouseover"===a||"pointerover"===
|
||||
a;k="mouseout"===a||"pointerout"===a;if(h&&c!==ze&&(m=c.relatedTarget||c.fromElement)&&(ob(m)||m[Ja]))break a;if(k||h){h=e.window===e?e:(h=e.ownerDocument)?h.defaultView||h.parentWindow:window;if(k){if(m=c.relatedTarget||c.toElement,k=d,m=m?ob(m):null,null!==m&&(p=nb(m),m!==p||5!==m.tag&&6!==m.tag))m=null}else k=null,m=d;if(k!==m){l=ih;M="onMouseLeave";w="onMouseEnter";A="mouse";if("pointerout"===a||"pointerover"===a)l=nh,M="onPointerLeave",w="onPointerEnter",A="pointer";p=null==k?h:Ib(k);t=null==
|
||||
m?h:Ib(m);h=new l(M,A+"leave",k,c,e);h.target=p;h.relatedTarget=t;M=null;ob(e)===d&&(l=new l(w,A+"enter",m,c,e),l.target=t,l.relatedTarget=p,M=l);p=M;if(k&&m)b:{l=k;w=m;A=0;for(t=l;t;t=Lb(t))A++;t=0;for(M=w;M;M=Lb(M))t++;for(;0<A-t;)l=Lb(l),A--;for(;0<t-A;)w=Lb(w),t--;for(;A--;){if(l===w||null!==w&&l===w.alternate)break b;l=Lb(l);w=Lb(w)}l=null}else l=null;null!==k&&oh(g,h,k,l,!1);null!==m&&null!==p&&oh(g,p,m,l,!0)}}}a:{h=d?Ib(d):window;k=h.nodeName&&h.nodeName.toLowerCase();if("select"===k||"input"===
|
||||
k&&"file"===h.type)var ma=Nj;else if(Vg(h))if(ph)ma=Rj;else{ma=Pj;var va=Oj}else(k=h.nodeName)&&"input"===k.toLowerCase()&&("checkbox"===h.type||"radio"===h.type)&&(ma=Qj);if(ma&&(ma=ma(a,d))){Wg(g,ma,c,e);break a}va&&va(a,h,d);"focusout"===a&&(va=h._wrapperState)&&va.controlled&&"number"===h.type&&me(h,"number",h.value)}va=d?Ib(d):window;switch(a){case "focusin":if(Vg(va)||"true"===va.contentEditable)Jb=va,Ke=d,rc=null;break;case "focusout":rc=Ke=Jb=null;break;case "mousedown":Je=!0;break;case "contextmenu":case "mouseup":case "dragend":Je=
|
||||
!1;dh(g,c,e);break;case "selectionchange":if(dk)break;case "keydown":case "keyup":dh(g,c,e)}var ab;if(Ge)b:{switch(a){case "compositionstart":var da="onCompositionStart";break b;case "compositionend":da="onCompositionEnd";break b;case "compositionupdate":da="onCompositionUpdate";break b}da=void 0}else Hb?Qg(a,c)&&(da="onCompositionEnd"):"keydown"===a&&229===c.keyCode&&(da="onCompositionStart");da&&(Ug&&"ko"!==c.locale&&(Hb||"onCompositionStart"!==da?"onCompositionEnd"===da&&Hb&&(ab=Og()):(Za=e,Ee=
|
||||
"value"in Za?Za.value:Za.textContent,Hb=!0)),va=ed(d,da),0<va.length&&(da=new qh(da,a,null,c,e),g.push({event:da,listeners:va}),ab?da.data=ab:(ab=Rg(c),null!==ab&&(da.data=ab))));if(ab=ek?Ij(a,c):Jj(a,c))d=ed(d,"onBeforeInput"),0<d.length&&(e=new fk("onBeforeInput","beforeinput",null,c,e),g.push({event:e,listeners:d}),e.data=ab)}Xg(g,b)})}function tc(a,b,c){return{instance:a,listener:b,currentTarget:c}}function ed(a,b){for(var c=b+"Capture",d=[];null!==a;){var e=a,f=e.stateNode;5===e.tag&&null!==
|
||||
f&&(e=f,f=fc(a,c),null!=f&&d.unshift(tc(a,f,e)),f=fc(a,b),null!=f&&d.push(tc(a,f,e)));a=a.return}return d}function Lb(a){if(null===a)return null;do a=a.return;while(a&&5!==a.tag);return a?a:null}function oh(a,b,c,d,e){for(var f=b._reactName,g=[];null!==c&&c!==d;){var h=c,k=h.alternate,n=h.stateNode;if(null!==k&&k===d)break;5===h.tag&&null!==n&&(h=n,e?(k=fc(c,f),null!=k&&g.unshift(tc(c,k,h))):e||(k=fc(c,f),null!=k&&g.push(tc(c,k,h))));c=c.return}0!==g.length&&a.push({event:b,listeners:g})}function rh(a){return("string"===
|
||||
typeof a?a:""+a).replace(gk,"\n").replace(hk,"")}function jd(a,b,c,d){b=rh(b);if(rh(a)!==b&&c)throw Error(m(425));}function kd(){}function Qe(a,b){return"textarea"===a||"noscript"===a||"string"===typeof b.children||"number"===typeof b.children||"object"===typeof b.dangerouslySetInnerHTML&&null!==b.dangerouslySetInnerHTML&&null!=b.dangerouslySetInnerHTML.__html}function ik(a){setTimeout(function(){throw a;})}function Re(a,b){var c=b,d=0;do{var e=c.nextSibling;a.removeChild(c);if(e&&8===e.nodeType)if(c=
|
||||
e.data,"/$"===c){if(0===d){a.removeChild(e);nc(b);return}d--}else"$"!==c&&"$?"!==c&&"$!"!==c||d++;c=e}while(c);nc(b)}function Ka(a){for(;null!=a;a=a.nextSibling){var b=a.nodeType;if(1===b||3===b)break;if(8===b){b=a.data;if("$"===b||"$!"===b||"$?"===b)break;if("/$"===b)return null}}return a}function sh(a){a=a.previousSibling;for(var b=0;a;){if(8===a.nodeType){var c=a.data;if("$"===c||"$!"===c||"$?"===c){if(0===b)return a;b--}else"/$"===c&&b++}a=a.previousSibling}return null}function ob(a){var b=a[Da];
|
||||
if(b)return b;for(var c=a.parentNode;c;){if(b=c[Ja]||c[Da]){c=b.alternate;if(null!==b.child||null!==c&&null!==c.child)for(a=sh(a);null!==a;){if(c=a[Da])return c;a=sh(a)}return b}a=c;c=a.parentNode}return null}function ec(a){a=a[Da]||a[Ja];return!a||5!==a.tag&&6!==a.tag&&13!==a.tag&&3!==a.tag?null:a}function Ib(a){if(5===a.tag||6===a.tag)return a.stateNode;throw Error(m(33));}function Rc(a){return a[uc]||null}function bb(a){return{current:a}}function v(a,b){0>Mb||(a.current=Se[Mb],Se[Mb]=null,Mb--)}
|
||||
function y(a,b,c){Mb++;Se[Mb]=a.current;a.current=b}function Nb(a,b){var c=a.type.contextTypes;if(!c)return cb;var d=a.stateNode;if(d&&d.__reactInternalMemoizedUnmaskedChildContext===b)return d.__reactInternalMemoizedMaskedChildContext;var e={},f;for(f in c)e[f]=b[f];d&&(a=a.stateNode,a.__reactInternalMemoizedUnmaskedChildContext=b,a.__reactInternalMemoizedMaskedChildContext=e);return e}function ea(a){a=a.childContextTypes;return null!==a&&void 0!==a}function th(a,b,c){if(J.current!==cb)throw Error(m(168));
|
||||
y(J,b);y(S,c)}function uh(a,b,c){var d=a.stateNode;b=b.childContextTypes;if("function"!==typeof d.getChildContext)return c;d=d.getChildContext();for(var e in d)if(!(e in b))throw Error(m(108,gj(a)||"Unknown",e));return E({},c,d)}function ld(a){a=(a=a.stateNode)&&a.__reactInternalMemoizedMergedChildContext||cb;pb=J.current;y(J,a);y(S,S.current);return!0}function vh(a,b,c){var d=a.stateNode;if(!d)throw Error(m(169));c?(a=uh(a,b,pb),d.__reactInternalMemoizedMergedChildContext=a,v(S),v(J),y(J,a)):v(S);
|
||||
y(S,c)}function wh(a){null===La?La=[a]:La.push(a)}function jk(a){md=!0;wh(a)}function db(){if(!Te&&null!==La){Te=!0;var a=0,b=z;try{var c=La;for(z=1;a<c.length;a++){var d=c[a];do d=d(!0);while(null!==d)}La=null;md=!1}catch(e){throw null!==La&&(La=La.slice(a+1)),xh(De,db),e;}finally{z=b,Te=!1}}return null}function qb(a,b){Ob[Pb++]=nd;Ob[Pb++]=od;od=a;nd=b}function yh(a,b,c){na[oa++]=Ma;na[oa++]=Na;na[oa++]=rb;rb=a;var d=Ma;a=Na;var e=32-ta(d)-1;d&=~(1<<e);c+=1;var f=32-ta(b)+e;if(30<f){var g=e-e%5;
|
||||
f=(d&(1<<g)-1).toString(32);d>>=g;e-=g;Ma=1<<32-ta(b)+e|c<<e|d;Na=f+a}else Ma=1<<f|c<<e|d,Na=a}function Ue(a){null!==a.return&&(qb(a,1),yh(a,1,0))}function Ve(a){for(;a===od;)od=Ob[--Pb],Ob[Pb]=null,nd=Ob[--Pb],Ob[Pb]=null;for(;a===rb;)rb=na[--oa],na[oa]=null,Na=na[--oa],na[oa]=null,Ma=na[--oa],na[oa]=null}function zh(a,b){var c=pa(5,null,null,0);c.elementType="DELETED";c.stateNode=b;c.return=a;b=a.deletions;null===b?(a.deletions=[c],a.flags|=16):b.push(c)}function Ah(a,b){switch(a.tag){case 5:var c=
|
||||
a.type;b=1!==b.nodeType||c.toLowerCase()!==b.nodeName.toLowerCase()?null:b;return null!==b?(a.stateNode=b,la=a,fa=Ka(b.firstChild),!0):!1;case 6:return b=""===a.pendingProps||3!==b.nodeType?null:b,null!==b?(a.stateNode=b,la=a,fa=null,!0):!1;case 13:return b=8!==b.nodeType?null:b,null!==b?(c=null!==rb?{id:Ma,overflow:Na}:null,a.memoizedState={dehydrated:b,treeContext:c,retryLane:1073741824},c=pa(18,null,null,0),c.stateNode=b,c.return=a,a.child=c,la=a,fa=null,!0):!1;default:return!1}}function We(a){return 0!==
|
||||
(a.mode&1)&&0===(a.flags&128)}function Xe(a){if(D){var b=fa;if(b){var c=b;if(!Ah(a,b)){if(We(a))throw Error(m(418));b=Ka(c.nextSibling);var d=la;b&&Ah(a,b)?zh(d,c):(a.flags=a.flags&-4097|2,D=!1,la=a)}}else{if(We(a))throw Error(m(418));a.flags=a.flags&-4097|2;D=!1;la=a}}}function Bh(a){for(a=a.return;null!==a&&5!==a.tag&&3!==a.tag&&13!==a.tag;)a=a.return;la=a}function pd(a){if(a!==la)return!1;if(!D)return Bh(a),D=!0,!1;var b;(b=3!==a.tag)&&!(b=5!==a.tag)&&(b=a.type,b="head"!==b&&"body"!==b&&!Qe(a.type,
|
||||
a.memoizedProps));if(b&&(b=fa)){if(We(a)){for(a=fa;a;)a=Ka(a.nextSibling);throw Error(m(418));}for(;b;)zh(a,b),b=Ka(b.nextSibling)}Bh(a);if(13===a.tag){a=a.memoizedState;a=null!==a?a.dehydrated:null;if(!a)throw Error(m(317));a:{a=a.nextSibling;for(b=0;a;){if(8===a.nodeType){var c=a.data;if("/$"===c){if(0===b){fa=Ka(a.nextSibling);break a}b--}else"$"!==c&&"$!"!==c&&"$?"!==c||b++}a=a.nextSibling}fa=null}}else fa=la?Ka(a.stateNode.nextSibling):null;return!0}function Qb(){fa=la=null;D=!1}function Ye(a){null===
|
||||
wa?wa=[a]:wa.push(a)}function vc(a,b,c){a=c.ref;if(null!==a&&"function"!==typeof a&&"object"!==typeof a){if(c._owner){c=c._owner;if(c){if(1!==c.tag)throw Error(m(309));var d=c.stateNode}if(!d)throw Error(m(147,a));var e=d,f=""+a;if(null!==b&&null!==b.ref&&"function"===typeof b.ref&&b.ref._stringRef===f)return b.ref;b=function(a){var b=e.refs;null===a?delete b[f]:b[f]=a};b._stringRef=f;return b}if("string"!==typeof a)throw Error(m(284));if(!c._owner)throw Error(m(290,a));}return a}function qd(a,b){a=
|
||||
Object.prototype.toString.call(b);throw Error(m(31,"[object Object]"===a?"object with keys {"+Object.keys(b).join(", ")+"}":a));}function Ch(a){var b=a._init;return b(a._payload)}function Dh(a){function b(b,c){if(a){var d=b.deletions;null===d?(b.deletions=[c],b.flags|=16):d.push(c)}}function c(c,d){if(!a)return null;for(;null!==d;)b(c,d),d=d.sibling;return null}function d(a,b){for(a=new Map;null!==b;)null!==b.key?a.set(b.key,b):a.set(b.index,b),b=b.sibling;return a}function e(a,b){a=eb(a,b);a.index=
|
||||
0;a.sibling=null;return a}function f(b,c,d){b.index=d;if(!a)return b.flags|=1048576,c;d=b.alternate;if(null!==d)return d=d.index,d<c?(b.flags|=2,c):d;b.flags|=2;return c}function g(b){a&&null===b.alternate&&(b.flags|=2);return b}function h(a,b,c,d){if(null===b||6!==b.tag)return b=Ze(c,a.mode,d),b.return=a,b;b=e(b,c);b.return=a;return b}function k(a,b,c,d){var f=c.type;if(f===Bb)return l(a,b,c.props.children,d,c.key);if(null!==b&&(b.elementType===f||"object"===typeof f&&null!==f&&f.$$typeof===Ta&&
|
||||
Ch(f)===b.type))return d=e(b,c.props),d.ref=vc(a,b,c),d.return=a,d;d=rd(c.type,c.key,c.props,null,a.mode,d);d.ref=vc(a,b,c);d.return=a;return d}function n(a,b,c,d){if(null===b||4!==b.tag||b.stateNode.containerInfo!==c.containerInfo||b.stateNode.implementation!==c.implementation)return b=$e(c,a.mode,d),b.return=a,b;b=e(b,c.children||[]);b.return=a;return b}function l(a,b,c,d,f){if(null===b||7!==b.tag)return b=sb(c,a.mode,d,f),b.return=a,b;b=e(b,c);b.return=a;return b}function u(a,b,c){if("string"===
|
||||
typeof b&&""!==b||"number"===typeof b)return b=Ze(""+b,a.mode,c),b.return=a,b;if("object"===typeof b&&null!==b){switch(b.$$typeof){case sd:return c=rd(b.type,b.key,b.props,null,a.mode,c),c.ref=vc(a,null,b),c.return=a,c;case Cb:return b=$e(b,a.mode,c),b.return=a,b;case Ta:var d=b._init;return u(a,d(b._payload),c)}if(cc(b)||ac(b))return b=sb(b,a.mode,c,null),b.return=a,b;qd(a,b)}return null}function r(a,b,c,d){var e=null!==b?b.key:null;if("string"===typeof c&&""!==c||"number"===typeof c)return null!==
|
||||
e?null:h(a,b,""+c,d);if("object"===typeof c&&null!==c){switch(c.$$typeof){case sd:return c.key===e?k(a,b,c,d):null;case Cb:return c.key===e?n(a,b,c,d):null;case Ta:return e=c._init,r(a,b,e(c._payload),d)}if(cc(c)||ac(c))return null!==e?null:l(a,b,c,d,null);qd(a,c)}return null}function p(a,b,c,d,e){if("string"===typeof d&&""!==d||"number"===typeof d)return a=a.get(c)||null,h(b,a,""+d,e);if("object"===typeof d&&null!==d){switch(d.$$typeof){case sd:return a=a.get(null===d.key?c:d.key)||null,k(b,a,d,
|
||||
e);case Cb:return a=a.get(null===d.key?c:d.key)||null,n(b,a,d,e);case Ta:var f=d._init;return p(a,b,c,f(d._payload),e)}if(cc(d)||ac(d))return a=a.get(c)||null,l(b,a,d,e,null);qd(b,d)}return null}function x(e,g,h,k){for(var n=null,m=null,l=g,t=g=0,q=null;null!==l&&t<h.length;t++){l.index>t?(q=l,l=null):q=l.sibling;var A=r(e,l,h[t],k);if(null===A){null===l&&(l=q);break}a&&l&&null===A.alternate&&b(e,l);g=f(A,g,t);null===m?n=A:m.sibling=A;m=A;l=q}if(t===h.length)return c(e,l),D&&qb(e,t),n;if(null===l){for(;t<
|
||||
h.length;t++)l=u(e,h[t],k),null!==l&&(g=f(l,g,t),null===m?n=l:m.sibling=l,m=l);D&&qb(e,t);return n}for(l=d(e,l);t<h.length;t++)q=p(l,e,t,h[t],k),null!==q&&(a&&null!==q.alternate&&l.delete(null===q.key?t:q.key),g=f(q,g,t),null===m?n=q:m.sibling=q,m=q);a&&l.forEach(function(a){return b(e,a)});D&&qb(e,t);return n}function I(e,g,h,k){var n=ac(h);if("function"!==typeof n)throw Error(m(150));h=n.call(h);if(null==h)throw Error(m(151));for(var l=n=null,q=g,t=g=0,A=null,w=h.next();null!==q&&!w.done;t++,w=
|
||||
h.next()){q.index>t?(A=q,q=null):A=q.sibling;var x=r(e,q,w.value,k);if(null===x){null===q&&(q=A);break}a&&q&&null===x.alternate&&b(e,q);g=f(x,g,t);null===l?n=x:l.sibling=x;l=x;q=A}if(w.done)return c(e,q),D&&qb(e,t),n;if(null===q){for(;!w.done;t++,w=h.next())w=u(e,w.value,k),null!==w&&(g=f(w,g,t),null===l?n=w:l.sibling=w,l=w);D&&qb(e,t);return n}for(q=d(e,q);!w.done;t++,w=h.next())w=p(q,e,t,w.value,k),null!==w&&(a&&null!==w.alternate&&q.delete(null===w.key?t:w.key),g=f(w,g,t),null===l?n=w:l.sibling=
|
||||
w,l=w);a&&q.forEach(function(a){return b(e,a)});D&&qb(e,t);return n}function v(a,d,f,h){"object"===typeof f&&null!==f&&f.type===Bb&&null===f.key&&(f=f.props.children);if("object"===typeof f&&null!==f){switch(f.$$typeof){case sd:a:{for(var k=f.key,n=d;null!==n;){if(n.key===k){k=f.type;if(k===Bb){if(7===n.tag){c(a,n.sibling);d=e(n,f.props.children);d.return=a;a=d;break a}}else if(n.elementType===k||"object"===typeof k&&null!==k&&k.$$typeof===Ta&&Ch(k)===n.type){c(a,n.sibling);d=e(n,f.props);d.ref=vc(a,
|
||||
n,f);d.return=a;a=d;break a}c(a,n);break}else b(a,n);n=n.sibling}f.type===Bb?(d=sb(f.props.children,a.mode,h,f.key),d.return=a,a=d):(h=rd(f.type,f.key,f.props,null,a.mode,h),h.ref=vc(a,d,f),h.return=a,a=h)}return g(a);case Cb:a:{for(n=f.key;null!==d;){if(d.key===n)if(4===d.tag&&d.stateNode.containerInfo===f.containerInfo&&d.stateNode.implementation===f.implementation){c(a,d.sibling);d=e(d,f.children||[]);d.return=a;a=d;break a}else{c(a,d);break}else b(a,d);d=d.sibling}d=$e(f,a.mode,h);d.return=a;
|
||||
a=d}return g(a);case Ta:return n=f._init,v(a,d,n(f._payload),h)}if(cc(f))return x(a,d,f,h);if(ac(f))return I(a,d,f,h);qd(a,f)}return"string"===typeof f&&""!==f||"number"===typeof f?(f=""+f,null!==d&&6===d.tag?(c(a,d.sibling),d=e(d,f),d.return=a,a=d):(c(a,d),d=Ze(f,a.mode,h),d.return=a,a=d),g(a)):c(a,d)}return v}function af(){bf=Rb=td=null}function cf(a,b){b=ud.current;v(ud);a._currentValue=b}function df(a,b,c){for(;null!==a;){var d=a.alternate;(a.childLanes&b)!==b?(a.childLanes|=b,null!==d&&(d.childLanes|=
|
||||
b)):null!==d&&(d.childLanes&b)!==b&&(d.childLanes|=b);if(a===c)break;a=a.return}}function Sb(a,b){td=a;bf=Rb=null;a=a.dependencies;null!==a&&null!==a.firstContext&&(0!==(a.lanes&b)&&(ha=!0),a.firstContext=null)}function qa(a){var b=a._currentValue;if(bf!==a)if(a={context:a,memoizedValue:b,next:null},null===Rb){if(null===td)throw Error(m(308));Rb=a;td.dependencies={lanes:0,firstContext:a}}else Rb=Rb.next=a;return b}function ef(a){null===tb?tb=[a]:tb.push(a)}function Eh(a,b,c,d){var e=b.interleaved;
|
||||
null===e?(c.next=c,ef(b)):(c.next=e.next,e.next=c);b.interleaved=c;return Oa(a,d)}function Oa(a,b){a.lanes|=b;var c=a.alternate;null!==c&&(c.lanes|=b);c=a;for(a=a.return;null!==a;)a.childLanes|=b,c=a.alternate,null!==c&&(c.childLanes|=b),c=a,a=a.return;return 3===c.tag?c.stateNode:null}function ff(a){a.updateQueue={baseState:a.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Fh(a,b){a=a.updateQueue;b.updateQueue===a&&(b.updateQueue=
|
||||
{baseState:a.baseState,firstBaseUpdate:a.firstBaseUpdate,lastBaseUpdate:a.lastBaseUpdate,shared:a.shared,effects:a.effects})}function Pa(a,b){return{eventTime:a,lane:b,tag:0,payload:null,callback:null,next:null}}function fb(a,b,c){var d=a.updateQueue;if(null===d)return null;d=d.shared;if(0!==(p&2)){var e=d.pending;null===e?b.next=b:(b.next=e.next,e.next=b);d.pending=b;return kk(a,c)}e=d.interleaved;null===e?(b.next=b,ef(d)):(b.next=e.next,e.next=b);d.interleaved=b;return Oa(a,c)}function vd(a,b,c){b=
|
||||
b.updateQueue;if(null!==b&&(b=b.shared,0!==(c&4194240))){var d=b.lanes;d&=a.pendingLanes;c|=d;b.lanes=c;xe(a,c)}}function Gh(a,b){var c=a.updateQueue,d=a.alternate;if(null!==d&&(d=d.updateQueue,c===d)){var e=null,f=null;c=c.firstBaseUpdate;if(null!==c){do{var g={eventTime:c.eventTime,lane:c.lane,tag:c.tag,payload:c.payload,callback:c.callback,next:null};null===f?e=f=g:f=f.next=g;c=c.next}while(null!==c);null===f?e=f=b:f=f.next=b}else e=f=b;c={baseState:d.baseState,firstBaseUpdate:e,lastBaseUpdate:f,
|
||||
shared:d.shared,effects:d.effects};a.updateQueue=c;return}a=c.lastBaseUpdate;null===a?c.firstBaseUpdate=b:a.next=b;c.lastBaseUpdate=b}function wd(a,b,c,d){var e=a.updateQueue;gb=!1;var f=e.firstBaseUpdate,g=e.lastBaseUpdate,h=e.shared.pending;if(null!==h){e.shared.pending=null;var k=h,n=k.next;k.next=null;null===g?f=n:g.next=n;g=k;var l=a.alternate;null!==l&&(l=l.updateQueue,h=l.lastBaseUpdate,h!==g&&(null===h?l.firstBaseUpdate=n:h.next=n,l.lastBaseUpdate=k))}if(null!==f){var m=e.baseState;g=0;l=
|
||||
n=k=null;h=f;do{var r=h.lane,p=h.eventTime;if((d&r)===r){null!==l&&(l=l.next={eventTime:p,lane:0,tag:h.tag,payload:h.payload,callback:h.callback,next:null});a:{var x=a,v=h;r=b;p=c;switch(v.tag){case 1:x=v.payload;if("function"===typeof x){m=x.call(p,m,r);break a}m=x;break a;case 3:x.flags=x.flags&-65537|128;case 0:x=v.payload;r="function"===typeof x?x.call(p,m,r):x;if(null===r||void 0===r)break a;m=E({},m,r);break a;case 2:gb=!0}}null!==h.callback&&0!==h.lane&&(a.flags|=64,r=e.effects,null===r?e.effects=
|
||||
[h]:r.push(h))}else p={eventTime:p,lane:r,tag:h.tag,payload:h.payload,callback:h.callback,next:null},null===l?(n=l=p,k=m):l=l.next=p,g|=r;h=h.next;if(null===h)if(h=e.shared.pending,null===h)break;else r=h,h=r.next,r.next=null,e.lastBaseUpdate=r,e.shared.pending=null}while(1);null===l&&(k=m);e.baseState=k;e.firstBaseUpdate=n;e.lastBaseUpdate=l;b=e.shared.interleaved;if(null!==b){e=b;do g|=e.lane,e=e.next;while(e!==b)}else null===f&&(e.shared.lanes=0);ra|=g;a.lanes=g;a.memoizedState=m}}function Hh(a,
|
||||
b,c){a=b.effects;b.effects=null;if(null!==a)for(b=0;b<a.length;b++){var d=a[b],e=d.callback;if(null!==e){d.callback=null;d=c;if("function"!==typeof e)throw Error(m(191,e));e.call(d)}}}function ub(a){if(a===wc)throw Error(m(174));return a}function gf(a,b){y(xc,b);y(yc,a);y(Ea,wc);a=b.nodeType;switch(a){case 9:case 11:b=(b=b.documentElement)?b.namespaceURI:oe(null,"");break;default:a=8===a?b.parentNode:b,b=a.namespaceURI||null,a=a.tagName,b=oe(b,a)}v(Ea);y(Ea,b)}function Tb(a){v(Ea);v(yc);v(xc)}function Ih(a){ub(xc.current);
|
||||
var b=ub(Ea.current);var c=oe(b,a.type);b!==c&&(y(yc,a),y(Ea,c))}function hf(a){yc.current===a&&(v(Ea),v(yc))}function xd(a){for(var b=a;null!==b;){if(13===b.tag){var c=b.memoizedState;if(null!==c&&(c=c.dehydrated,null===c||"$?"===c.data||"$!"===c.data))return b}else if(19===b.tag&&void 0!==b.memoizedProps.revealOrder){if(0!==(b.flags&128))return b}else if(null!==b.child){b.child.return=b;b=b.child;continue}if(b===a)break;for(;null===b.sibling;){if(null===b.return||b.return===a)return null;b=b.return}b.sibling.return=
|
||||
b.return;b=b.sibling}return null}function jf(){for(var a=0;a<kf.length;a++)kf[a]._workInProgressVersionPrimary=null;kf.length=0}function V(){throw Error(m(321));}function lf(a,b){if(null===b)return!1;for(var c=0;c<b.length&&c<a.length;c++)if(!ua(a[c],b[c]))return!1;return!0}function mf(a,b,c,d,e,f){vb=f;C=b;b.memoizedState=null;b.updateQueue=null;b.lanes=0;yd.current=null===a||null===a.memoizedState?lk:mk;a=c(d,e);if(zc){f=0;do{zc=!1;Ac=0;if(25<=f)throw Error(m(301));f+=1;N=K=null;b.updateQueue=null;
|
||||
yd.current=nk;a=c(d,e)}while(zc)}yd.current=zd;b=null!==K&&null!==K.next;vb=0;N=K=C=null;Ad=!1;if(b)throw Error(m(300));return a}function nf(){var a=0!==Ac;Ac=0;return a}function Fa(){var a={memoizedState:null,baseState:null,baseQueue:null,queue:null,next:null};null===N?C.memoizedState=N=a:N=N.next=a;return N}function sa(){if(null===K){var a=C.alternate;a=null!==a?a.memoizedState:null}else a=K.next;var b=null===N?C.memoizedState:N.next;if(null!==b)N=b,K=a;else{if(null===a)throw Error(m(310));K=a;
|
||||
a={memoizedState:K.memoizedState,baseState:K.baseState,baseQueue:K.baseQueue,queue:K.queue,next:null};null===N?C.memoizedState=N=a:N=N.next=a}return N}function Bc(a,b){return"function"===typeof b?b(a):b}function of(a,b,c){b=sa();c=b.queue;if(null===c)throw Error(m(311));c.lastRenderedReducer=a;var d=K,e=d.baseQueue,f=c.pending;if(null!==f){if(null!==e){var g=e.next;e.next=f.next;f.next=g}d.baseQueue=e=f;c.pending=null}if(null!==e){f=e.next;d=d.baseState;var h=g=null,k=null,n=f;do{var l=n.lane;if((vb&
|
||||
l)===l)null!==k&&(k=k.next={lane:0,action:n.action,hasEagerState:n.hasEagerState,eagerState:n.eagerState,next:null}),d=n.hasEagerState?n.eagerState:a(d,n.action);else{var u={lane:l,action:n.action,hasEagerState:n.hasEagerState,eagerState:n.eagerState,next:null};null===k?(h=k=u,g=d):k=k.next=u;C.lanes|=l;ra|=l}n=n.next}while(null!==n&&n!==f);null===k?g=d:k.next=h;ua(d,b.memoizedState)||(ha=!0);b.memoizedState=d;b.baseState=g;b.baseQueue=k;c.lastRenderedState=d}a=c.interleaved;if(null!==a){e=a;do f=
|
||||
e.lane,C.lanes|=f,ra|=f,e=e.next;while(e!==a)}else null===e&&(c.lanes=0);return[b.memoizedState,c.dispatch]}function pf(a,b,c){b=sa();c=b.queue;if(null===c)throw Error(m(311));c.lastRenderedReducer=a;var d=c.dispatch,e=c.pending,f=b.memoizedState;if(null!==e){c.pending=null;var g=e=e.next;do f=a(f,g.action),g=g.next;while(g!==e);ua(f,b.memoizedState)||(ha=!0);b.memoizedState=f;null===b.baseQueue&&(b.baseState=f);c.lastRenderedState=f}return[f,d]}function Jh(a,b,c){}function Kh(a,b,c){c=C;var d=sa(),
|
||||
e=b(),f=!ua(d.memoizedState,e);f&&(d.memoizedState=e,ha=!0);d=d.queue;qf(Lh.bind(null,c,d,a),[a]);if(d.getSnapshot!==b||f||null!==N&&N.memoizedState.tag&1){c.flags|=2048;Cc(9,Mh.bind(null,c,d,e,b),void 0,null);if(null===O)throw Error(m(349));0!==(vb&30)||Nh(c,b,e)}return e}function Nh(a,b,c){a.flags|=16384;a={getSnapshot:b,value:c};b=C.updateQueue;null===b?(b={lastEffect:null,stores:null},C.updateQueue=b,b.stores=[a]):(c=b.stores,null===c?b.stores=[a]:c.push(a))}function Mh(a,b,c,d){b.value=c;b.getSnapshot=
|
||||
d;Oh(b)&&Ph(a)}function Lh(a,b,c){return c(function(){Oh(b)&&Ph(a)})}function Oh(a){var b=a.getSnapshot;a=a.value;try{var c=b();return!ua(a,c)}catch(d){return!0}}function Ph(a){var b=Oa(a,1);null!==b&&xa(b,a,1,-1)}function Qh(a){var b=Fa();"function"===typeof a&&(a=a());b.memoizedState=b.baseState=a;a={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:Bc,lastRenderedState:a};b.queue=a;a=a.dispatch=ok.bind(null,C,a);return[b.memoizedState,a]}function Cc(a,b,c,d){a={tag:a,create:b,
|
||||
destroy:c,deps:d,next:null};b=C.updateQueue;null===b?(b={lastEffect:null,stores:null},C.updateQueue=b,b.lastEffect=a.next=a):(c=b.lastEffect,null===c?b.lastEffect=a.next=a:(d=c.next,c.next=a,a.next=d,b.lastEffect=a));return a}function Rh(a){return sa().memoizedState}function Bd(a,b,c,d){var e=Fa();C.flags|=a;e.memoizedState=Cc(1|b,c,void 0,void 0===d?null:d)}function Cd(a,b,c,d){var e=sa();d=void 0===d?null:d;var f=void 0;if(null!==K){var g=K.memoizedState;f=g.destroy;if(null!==d&&lf(d,g.deps)){e.memoizedState=
|
||||
Cc(b,c,f,d);return}}C.flags|=a;e.memoizedState=Cc(1|b,c,f,d)}function Sh(a,b){return Bd(8390656,8,a,b)}function qf(a,b){return Cd(2048,8,a,b)}function Th(a,b){return Cd(4,2,a,b)}function Uh(a,b){return Cd(4,4,a,b)}function Vh(a,b){if("function"===typeof b)return a=a(),b(a),function(){b(null)};if(null!==b&&void 0!==b)return a=a(),b.current=a,function(){b.current=null}}function Wh(a,b,c){c=null!==c&&void 0!==c?c.concat([a]):null;return Cd(4,4,Vh.bind(null,b,a),c)}function rf(a,b){}function Xh(a,b){var c=
|
||||
sa();b=void 0===b?null:b;var d=c.memoizedState;if(null!==d&&null!==b&&lf(b,d[1]))return d[0];c.memoizedState=[a,b];return a}function Yh(a,b){var c=sa();b=void 0===b?null:b;var d=c.memoizedState;if(null!==d&&null!==b&&lf(b,d[1]))return d[0];a=a();c.memoizedState=[a,b];return a}function Zh(a,b,c){if(0===(vb&21))return a.baseState&&(a.baseState=!1,ha=!0),a.memoizedState=c;ua(c,b)||(c=Dg(),C.lanes|=c,ra|=c,a.baseState=!0);return b}function pk(a,b,c){c=z;z=0!==c&&4>c?c:4;a(!0);var d=sf.transition;sf.transition=
|
||||
{};try{a(!1),b()}finally{z=c,sf.transition=d}}function $h(){return sa().memoizedState}function qk(a,b,c){var d=hb(a);c={lane:d,action:c,hasEagerState:!1,eagerState:null,next:null};if(ai(a))bi(b,c);else if(c=Eh(a,b,c,d),null!==c){var e=Z();xa(c,a,d,e);ci(c,b,d)}}function ok(a,b,c){var d=hb(a),e={lane:d,action:c,hasEagerState:!1,eagerState:null,next:null};if(ai(a))bi(b,e);else{var f=a.alternate;if(0===a.lanes&&(null===f||0===f.lanes)&&(f=b.lastRenderedReducer,null!==f))try{var g=b.lastRenderedState,
|
||||
h=f(g,c);e.hasEagerState=!0;e.eagerState=h;if(ua(h,g)){var k=b.interleaved;null===k?(e.next=e,ef(b)):(e.next=k.next,k.next=e);b.interleaved=e;return}}catch(n){}finally{}c=Eh(a,b,e,d);null!==c&&(e=Z(),xa(c,a,d,e),ci(c,b,d))}}function ai(a){var b=a.alternate;return a===C||null!==b&&b===C}function bi(a,b){zc=Ad=!0;var c=a.pending;null===c?b.next=b:(b.next=c.next,c.next=b);a.pending=b}function ci(a,b,c){if(0!==(c&4194240)){var d=b.lanes;d&=a.pendingLanes;c|=d;b.lanes=c;xe(a,c)}}function ya(a,b){if(a&&
|
||||
a.defaultProps){b=E({},b);a=a.defaultProps;for(var c in a)void 0===b[c]&&(b[c]=a[c]);return b}return b}function tf(a,b,c,d){b=a.memoizedState;c=c(d,b);c=null===c||void 0===c?b:E({},b,c);a.memoizedState=c;0===a.lanes&&(a.updateQueue.baseState=c)}function di(a,b,c,d,e,f,g){a=a.stateNode;return"function"===typeof a.shouldComponentUpdate?a.shouldComponentUpdate(d,f,g):b.prototype&&b.prototype.isPureReactComponent?!qc(c,d)||!qc(e,f):!0}function ei(a,b,c){var d=!1,e=cb;var f=b.contextType;"object"===typeof f&&
|
||||
null!==f?f=qa(f):(e=ea(b)?pb:J.current,d=b.contextTypes,f=(d=null!==d&&void 0!==d)?Nb(a,e):cb);b=new b(c,f);a.memoizedState=null!==b.state&&void 0!==b.state?b.state:null;b.updater=Dd;a.stateNode=b;b._reactInternals=a;d&&(a=a.stateNode,a.__reactInternalMemoizedUnmaskedChildContext=e,a.__reactInternalMemoizedMaskedChildContext=f);return b}function fi(a,b,c,d){a=b.state;"function"===typeof b.componentWillReceiveProps&&b.componentWillReceiveProps(c,d);"function"===typeof b.UNSAFE_componentWillReceiveProps&&
|
||||
b.UNSAFE_componentWillReceiveProps(c,d);b.state!==a&&Dd.enqueueReplaceState(b,b.state,null)}function uf(a,b,c,d){var e=a.stateNode;e.props=c;e.state=a.memoizedState;e.refs={};ff(a);var f=b.contextType;"object"===typeof f&&null!==f?e.context=qa(f):(f=ea(b)?pb:J.current,e.context=Nb(a,f));e.state=a.memoizedState;f=b.getDerivedStateFromProps;"function"===typeof f&&(tf(a,b,f,c),e.state=a.memoizedState);"function"===typeof b.getDerivedStateFromProps||"function"===typeof e.getSnapshotBeforeUpdate||"function"!==
|
||||
typeof e.UNSAFE_componentWillMount&&"function"!==typeof e.componentWillMount||(b=e.state,"function"===typeof e.componentWillMount&&e.componentWillMount(),"function"===typeof e.UNSAFE_componentWillMount&&e.UNSAFE_componentWillMount(),b!==e.state&&Dd.enqueueReplaceState(e,e.state,null),wd(a,c,e,d),e.state=a.memoizedState);"function"===typeof e.componentDidMount&&(a.flags|=4194308)}function Ub(a,b){try{var c="",d=b;do c+=fj(d),d=d.return;while(d);var e=c}catch(f){e="\nError generating stack: "+f.message+
|
||||
"\n"+f.stack}return{value:a,source:b,stack:e,digest:null}}function vf(a,b,c){return{value:a,source:null,stack:null!=c?c:null,digest:null!=b?b:null}}function wf(a,b){try{console.error(b.value)}catch(c){setTimeout(function(){throw c;})}}function gi(a,b,c){c=Pa(-1,c);c.tag=3;c.payload={element:null};var d=b.value;c.callback=function(){Ed||(Ed=!0,xf=d);wf(a,b)};return c}function hi(a,b,c){c=Pa(-1,c);c.tag=3;var d=a.type.getDerivedStateFromError;if("function"===typeof d){var e=b.value;c.payload=function(){return d(e)};
|
||||
c.callback=function(){wf(a,b)}}var f=a.stateNode;null!==f&&"function"===typeof f.componentDidCatch&&(c.callback=function(){wf(a,b);"function"!==typeof d&&(null===ib?ib=new Set([this]):ib.add(this));var c=b.stack;this.componentDidCatch(b.value,{componentStack:null!==c?c:""})});return c}function ii(a,b,c){var d=a.pingCache;if(null===d){d=a.pingCache=new rk;var e=new Set;d.set(b,e)}else e=d.get(b),void 0===e&&(e=new Set,d.set(b,e));e.has(c)||(e.add(c),a=sk.bind(null,a,b,c),b.then(a,a))}function ji(a){do{var b;
|
||||
if(b=13===a.tag)b=a.memoizedState,b=null!==b?null!==b.dehydrated?!0:!1:!0;if(b)return a;a=a.return}while(null!==a);return null}function ki(a,b,c,d,e){if(0===(a.mode&1))return a===b?a.flags|=65536:(a.flags|=128,c.flags|=131072,c.flags&=-52805,1===c.tag&&(null===c.alternate?c.tag=17:(b=Pa(-1,1),b.tag=2,fb(c,b,1))),c.lanes|=1),a;a.flags|=65536;a.lanes=e;return a}function aa(a,b,c,d){b.child=null===a?li(b,null,c,d):Vb(b,a.child,c,d)}function mi(a,b,c,d,e){c=c.render;var f=b.ref;Sb(b,e);d=mf(a,b,c,d,f,
|
||||
e);c=nf();if(null!==a&&!ha)return b.updateQueue=a.updateQueue,b.flags&=-2053,a.lanes&=~e,Qa(a,b,e);D&&c&&Ue(b);b.flags|=1;aa(a,b,d,e);return b.child}function ni(a,b,c,d,e){if(null===a){var f=c.type;if("function"===typeof f&&!yf(f)&&void 0===f.defaultProps&&null===c.compare&&void 0===c.defaultProps)return b.tag=15,b.type=f,oi(a,b,f,d,e);a=rd(c.type,null,d,b,b.mode,e);a.ref=b.ref;a.return=b;return b.child=a}f=a.child;if(0===(a.lanes&e)){var g=f.memoizedProps;c=c.compare;c=null!==c?c:qc;if(c(g,d)&&a.ref===
|
||||
b.ref)return Qa(a,b,e)}b.flags|=1;a=eb(f,d);a.ref=b.ref;a.return=b;return b.child=a}function oi(a,b,c,d,e){if(null!==a){var f=a.memoizedProps;if(qc(f,d)&&a.ref===b.ref)if(ha=!1,b.pendingProps=d=f,0!==(a.lanes&e))0!==(a.flags&131072)&&(ha=!0);else return b.lanes=a.lanes,Qa(a,b,e)}return zf(a,b,c,d,e)}function pi(a,b,c){var d=b.pendingProps,e=d.children,f=null!==a?a.memoizedState:null;if("hidden"===d.mode)if(0===(b.mode&1))b.memoizedState={baseLanes:0,cachePool:null,transitions:null},y(Ga,ba),ba|=c;
|
||||
else{if(0===(c&1073741824))return a=null!==f?f.baseLanes|c:c,b.lanes=b.childLanes=1073741824,b.memoizedState={baseLanes:a,cachePool:null,transitions:null},b.updateQueue=null,y(Ga,ba),ba|=a,null;b.memoizedState={baseLanes:0,cachePool:null,transitions:null};d=null!==f?f.baseLanes:c;y(Ga,ba);ba|=d}else null!==f?(d=f.baseLanes|c,b.memoizedState=null):d=c,y(Ga,ba),ba|=d;aa(a,b,e,c);return b.child}function qi(a,b){var c=b.ref;if(null===a&&null!==c||null!==a&&a.ref!==c)b.flags|=512,b.flags|=2097152}function zf(a,
|
||||
b,c,d,e){var f=ea(c)?pb:J.current;f=Nb(b,f);Sb(b,e);c=mf(a,b,c,d,f,e);d=nf();if(null!==a&&!ha)return b.updateQueue=a.updateQueue,b.flags&=-2053,a.lanes&=~e,Qa(a,b,e);D&&d&&Ue(b);b.flags|=1;aa(a,b,c,e);return b.child}function ri(a,b,c,d,e){if(ea(c)){var f=!0;ld(b)}else f=!1;Sb(b,e);if(null===b.stateNode)Fd(a,b),ei(b,c,d),uf(b,c,d,e),d=!0;else if(null===a){var g=b.stateNode,h=b.memoizedProps;g.props=h;var k=g.context,n=c.contextType;"object"===typeof n&&null!==n?n=qa(n):(n=ea(c)?pb:J.current,n=Nb(b,
|
||||
n));var l=c.getDerivedStateFromProps,m="function"===typeof l||"function"===typeof g.getSnapshotBeforeUpdate;m||"function"!==typeof g.UNSAFE_componentWillReceiveProps&&"function"!==typeof g.componentWillReceiveProps||(h!==d||k!==n)&&fi(b,g,d,n);gb=!1;var r=b.memoizedState;g.state=r;wd(b,d,g,e);k=b.memoizedState;h!==d||r!==k||S.current||gb?("function"===typeof l&&(tf(b,c,l,d),k=b.memoizedState),(h=gb||di(b,c,h,d,r,k,n))?(m||"function"!==typeof g.UNSAFE_componentWillMount&&"function"!==typeof g.componentWillMount||
|
||||
("function"===typeof g.componentWillMount&&g.componentWillMount(),"function"===typeof g.UNSAFE_componentWillMount&&g.UNSAFE_componentWillMount()),"function"===typeof g.componentDidMount&&(b.flags|=4194308)):("function"===typeof g.componentDidMount&&(b.flags|=4194308),b.memoizedProps=d,b.memoizedState=k),g.props=d,g.state=k,g.context=n,d=h):("function"===typeof g.componentDidMount&&(b.flags|=4194308),d=!1)}else{g=b.stateNode;Fh(a,b);h=b.memoizedProps;n=b.type===b.elementType?h:ya(b.type,h);g.props=
|
||||
n;m=b.pendingProps;r=g.context;k=c.contextType;"object"===typeof k&&null!==k?k=qa(k):(k=ea(c)?pb:J.current,k=Nb(b,k));var p=c.getDerivedStateFromProps;(l="function"===typeof p||"function"===typeof g.getSnapshotBeforeUpdate)||"function"!==typeof g.UNSAFE_componentWillReceiveProps&&"function"!==typeof g.componentWillReceiveProps||(h!==m||r!==k)&&fi(b,g,d,k);gb=!1;r=b.memoizedState;g.state=r;wd(b,d,g,e);var x=b.memoizedState;h!==m||r!==x||S.current||gb?("function"===typeof p&&(tf(b,c,p,d),x=b.memoizedState),
|
||||
(n=gb||di(b,c,n,d,r,x,k)||!1)?(l||"function"!==typeof g.UNSAFE_componentWillUpdate&&"function"!==typeof g.componentWillUpdate||("function"===typeof g.componentWillUpdate&&g.componentWillUpdate(d,x,k),"function"===typeof g.UNSAFE_componentWillUpdate&&g.UNSAFE_componentWillUpdate(d,x,k)),"function"===typeof g.componentDidUpdate&&(b.flags|=4),"function"===typeof g.getSnapshotBeforeUpdate&&(b.flags|=1024)):("function"!==typeof g.componentDidUpdate||h===a.memoizedProps&&r===a.memoizedState||(b.flags|=
|
||||
4),"function"!==typeof g.getSnapshotBeforeUpdate||h===a.memoizedProps&&r===a.memoizedState||(b.flags|=1024),b.memoizedProps=d,b.memoizedState=x),g.props=d,g.state=x,g.context=k,d=n):("function"!==typeof g.componentDidUpdate||h===a.memoizedProps&&r===a.memoizedState||(b.flags|=4),"function"!==typeof g.getSnapshotBeforeUpdate||h===a.memoizedProps&&r===a.memoizedState||(b.flags|=1024),d=!1)}return Af(a,b,c,d,f,e)}function Af(a,b,c,d,e,f){qi(a,b);var g=0!==(b.flags&128);if(!d&&!g)return e&&vh(b,c,!1),
|
||||
Qa(a,b,f);d=b.stateNode;tk.current=b;var h=g&&"function"!==typeof c.getDerivedStateFromError?null:d.render();b.flags|=1;null!==a&&g?(b.child=Vb(b,a.child,null,f),b.child=Vb(b,null,h,f)):aa(a,b,h,f);b.memoizedState=d.state;e&&vh(b,c,!0);return b.child}function si(a){var b=a.stateNode;b.pendingContext?th(a,b.pendingContext,b.pendingContext!==b.context):b.context&&th(a,b.context,!1);gf(a,b.containerInfo)}function ti(a,b,c,d,e){Qb();Ye(e);b.flags|=256;aa(a,b,c,d);return b.child}function Bf(a){return{baseLanes:a,
|
||||
cachePool:null,transitions:null}}function ui(a,b,c){var d=b.pendingProps,e=F.current,f=!1,g=0!==(b.flags&128),h;(h=g)||(h=null!==a&&null===a.memoizedState?!1:0!==(e&2));if(h)f=!0,b.flags&=-129;else if(null===a||null!==a.memoizedState)e|=1;y(F,e&1);if(null===a){Xe(b);a=b.memoizedState;if(null!==a&&(a=a.dehydrated,null!==a))return 0===(b.mode&1)?b.lanes=1:"$!"===a.data?b.lanes=8:b.lanes=1073741824,null;g=d.children;a=d.fallback;return f?(d=b.mode,f=b.child,g={mode:"hidden",children:g},0===(d&1)&&null!==
|
||||
f?(f.childLanes=0,f.pendingProps=g):f=Gd(g,d,0,null),a=sb(a,d,c,null),f.return=b,a.return=b,f.sibling=a,b.child=f,b.child.memoizedState=Bf(c),b.memoizedState=Cf,a):Df(b,g)}e=a.memoizedState;if(null!==e&&(h=e.dehydrated,null!==h))return uk(a,b,g,d,h,e,c);if(f){f=d.fallback;g=b.mode;e=a.child;h=e.sibling;var k={mode:"hidden",children:d.children};0===(g&1)&&b.child!==e?(d=b.child,d.childLanes=0,d.pendingProps=k,b.deletions=null):(d=eb(e,k),d.subtreeFlags=e.subtreeFlags&14680064);null!==h?f=eb(h,f):(f=
|
||||
sb(f,g,c,null),f.flags|=2);f.return=b;d.return=b;d.sibling=f;b.child=d;d=f;f=b.child;g=a.child.memoizedState;g=null===g?Bf(c):{baseLanes:g.baseLanes|c,cachePool:null,transitions:g.transitions};f.memoizedState=g;f.childLanes=a.childLanes&~c;b.memoizedState=Cf;return d}f=a.child;a=f.sibling;d=eb(f,{mode:"visible",children:d.children});0===(b.mode&1)&&(d.lanes=c);d.return=b;d.sibling=null;null!==a&&(c=b.deletions,null===c?(b.deletions=[a],b.flags|=16):c.push(a));b.child=d;b.memoizedState=null;return d}
|
||||
function Df(a,b,c){b=Gd({mode:"visible",children:b},a.mode,0,null);b.return=a;return a.child=b}function Hd(a,b,c,d){null!==d&&Ye(d);Vb(b,a.child,null,c);a=Df(b,b.pendingProps.children);a.flags|=2;b.memoizedState=null;return a}function uk(a,b,c,d,e,f,g){if(c){if(b.flags&256)return b.flags&=-257,d=vf(Error(m(422))),Hd(a,b,g,d);if(null!==b.memoizedState)return b.child=a.child,b.flags|=128,null;f=d.fallback;e=b.mode;d=Gd({mode:"visible",children:d.children},e,0,null);f=sb(f,e,g,null);f.flags|=2;d.return=
|
||||
b;f.return=b;d.sibling=f;b.child=d;0!==(b.mode&1)&&Vb(b,a.child,null,g);b.child.memoizedState=Bf(g);b.memoizedState=Cf;return f}if(0===(b.mode&1))return Hd(a,b,g,null);if("$!"===e.data){d=e.nextSibling&&e.nextSibling.dataset;if(d)var h=d.dgst;d=h;f=Error(m(419));d=vf(f,d,void 0);return Hd(a,b,g,d)}h=0!==(g&a.childLanes);if(ha||h){d=O;if(null!==d){switch(g&-g){case 4:e=2;break;case 16:e=8;break;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:e=
|
||||
32;break;case 536870912:e=268435456;break;default:e=0}e=0!==(e&(d.suspendedLanes|g))?0:e;0!==e&&e!==f.retryLane&&(f.retryLane=e,Oa(a,e),xa(d,a,e,-1))}Ef();d=vf(Error(m(421)));return Hd(a,b,g,d)}if("$?"===e.data)return b.flags|=128,b.child=a.child,b=vk.bind(null,a),e._reactRetry=b,null;a=f.treeContext;fa=Ka(e.nextSibling);la=b;D=!0;wa=null;null!==a&&(na[oa++]=Ma,na[oa++]=Na,na[oa++]=rb,Ma=a.id,Na=a.overflow,rb=b);b=Df(b,d.children);b.flags|=4096;return b}function vi(a,b,c){a.lanes|=b;var d=a.alternate;
|
||||
null!==d&&(d.lanes|=b);df(a.return,b,c)}function Ff(a,b,c,d,e){var f=a.memoizedState;null===f?a.memoizedState={isBackwards:b,rendering:null,renderingStartTime:0,last:d,tail:c,tailMode:e}:(f.isBackwards=b,f.rendering=null,f.renderingStartTime=0,f.last=d,f.tail=c,f.tailMode=e)}function wi(a,b,c){var d=b.pendingProps,e=d.revealOrder,f=d.tail;aa(a,b,d.children,c);d=F.current;if(0!==(d&2))d=d&1|2,b.flags|=128;else{if(null!==a&&0!==(a.flags&128))a:for(a=b.child;null!==a;){if(13===a.tag)null!==a.memoizedState&&
|
||||
vi(a,c,b);else if(19===a.tag)vi(a,c,b);else if(null!==a.child){a.child.return=a;a=a.child;continue}if(a===b)break a;for(;null===a.sibling;){if(null===a.return||a.return===b)break a;a=a.return}a.sibling.return=a.return;a=a.sibling}d&=1}y(F,d);if(0===(b.mode&1))b.memoizedState=null;else switch(e){case "forwards":c=b.child;for(e=null;null!==c;)a=c.alternate,null!==a&&null===xd(a)&&(e=c),c=c.sibling;c=e;null===c?(e=b.child,b.child=null):(e=c.sibling,c.sibling=null);Ff(b,!1,e,c,f);break;case "backwards":c=
|
||||
null;e=b.child;for(b.child=null;null!==e;){a=e.alternate;if(null!==a&&null===xd(a)){b.child=e;break}a=e.sibling;e.sibling=c;c=e;e=a}Ff(b,!0,c,null,f);break;case "together":Ff(b,!1,null,null,void 0);break;default:b.memoizedState=null}return b.child}function Fd(a,b){0===(b.mode&1)&&null!==a&&(a.alternate=null,b.alternate=null,b.flags|=2)}function Qa(a,b,c){null!==a&&(b.dependencies=a.dependencies);ra|=b.lanes;if(0===(c&b.childLanes))return null;if(null!==a&&b.child!==a.child)throw Error(m(153));if(null!==
|
||||
b.child){a=b.child;c=eb(a,a.pendingProps);b.child=c;for(c.return=b;null!==a.sibling;)a=a.sibling,c=c.sibling=eb(a,a.pendingProps),c.return=b;c.sibling=null}return b.child}function wk(a,b,c){switch(b.tag){case 3:si(b);Qb();break;case 5:Ih(b);break;case 1:ea(b.type)&&ld(b);break;case 4:gf(b,b.stateNode.containerInfo);break;case 10:var d=b.type._context,e=b.memoizedProps.value;y(ud,d._currentValue);d._currentValue=e;break;case 13:d=b.memoizedState;if(null!==d){if(null!==d.dehydrated)return y(F,F.current&
|
||||
1),b.flags|=128,null;if(0!==(c&b.child.childLanes))return ui(a,b,c);y(F,F.current&1);a=Qa(a,b,c);return null!==a?a.sibling:null}y(F,F.current&1);break;case 19:d=0!==(c&b.childLanes);if(0!==(a.flags&128)){if(d)return wi(a,b,c);b.flags|=128}e=b.memoizedState;null!==e&&(e.rendering=null,e.tail=null,e.lastEffect=null);y(F,F.current);if(d)break;else return null;case 22:case 23:return b.lanes=0,pi(a,b,c)}return Qa(a,b,c)}function Dc(a,b){if(!D)switch(a.tailMode){case "hidden":b=a.tail;for(var c=null;null!==
|
||||
b;)null!==b.alternate&&(c=b),b=b.sibling;null===c?a.tail=null:c.sibling=null;break;case "collapsed":c=a.tail;for(var d=null;null!==c;)null!==c.alternate&&(d=c),c=c.sibling;null===d?b||null===a.tail?a.tail=null:a.tail.sibling=null:d.sibling=null}}function W(a){var b=null!==a.alternate&&a.alternate.child===a.child,c=0,d=0;if(b)for(var e=a.child;null!==e;)c|=e.lanes|e.childLanes,d|=e.subtreeFlags&14680064,d|=e.flags&14680064,e.return=a,e=e.sibling;else for(e=a.child;null!==e;)c|=e.lanes|e.childLanes,
|
||||
d|=e.subtreeFlags,d|=e.flags,e.return=a,e=e.sibling;a.subtreeFlags|=d;a.childLanes=c;return b}function xk(a,b,c){var d=b.pendingProps;Ve(b);switch(b.tag){case 2:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return W(b),null;case 1:return ea(b.type)&&(v(S),v(J)),W(b),null;case 3:d=b.stateNode;Tb();v(S);v(J);jf();d.pendingContext&&(d.context=d.pendingContext,d.pendingContext=null);if(null===a||null===a.child)pd(b)?b.flags|=4:null===a||a.memoizedState.isDehydrated&&0===(b.flags&
|
||||
256)||(b.flags|=1024,null!==wa&&(Gf(wa),wa=null));xi(a,b);W(b);return null;case 5:hf(b);var e=ub(xc.current);c=b.type;if(null!==a&&null!=b.stateNode)yk(a,b,c,d,e),a.ref!==b.ref&&(b.flags|=512,b.flags|=2097152);else{if(!d){if(null===b.stateNode)throw Error(m(166));W(b);return null}a=ub(Ea.current);if(pd(b)){d=b.stateNode;c=b.type;var f=b.memoizedProps;d[Da]=b;d[uc]=f;a=0!==(b.mode&1);switch(c){case "dialog":B("cancel",d);B("close",d);break;case "iframe":case "object":case "embed":B("load",d);break;
|
||||
case "video":case "audio":for(e=0;e<Ec.length;e++)B(Ec[e],d);break;case "source":B("error",d);break;case "img":case "image":case "link":B("error",d);B("load",d);break;case "details":B("toggle",d);break;case "input":kg(d,f);B("invalid",d);break;case "select":d._wrapperState={wasMultiple:!!f.multiple};B("invalid",d);break;case "textarea":ng(d,f),B("invalid",d)}pe(c,f);e=null;for(var g in f)if(f.hasOwnProperty(g)){var h=f[g];"children"===g?"string"===typeof h?d.textContent!==h&&(!0!==f.suppressHydrationWarning&&
|
||||
jd(d.textContent,h,a),e=["children",h]):"number"===typeof h&&d.textContent!==""+h&&(!0!==f.suppressHydrationWarning&&jd(d.textContent,h,a),e=["children",""+h]):$b.hasOwnProperty(g)&&null!=h&&"onScroll"===g&&B("scroll",d)}switch(c){case "input":Pc(d);mg(d,f,!0);break;case "textarea":Pc(d);pg(d);break;case "select":case "option":break;default:"function"===typeof f.onClick&&(d.onclick=kd)}d=e;b.updateQueue=d;null!==d&&(b.flags|=4)}else{g=9===e.nodeType?e:e.ownerDocument;"http://www.w3.org/1999/xhtml"===
|
||||
a&&(a=qg(c));"http://www.w3.org/1999/xhtml"===a?"script"===c?(a=g.createElement("div"),a.innerHTML="<script>\x3c/script>",a=a.removeChild(a.firstChild)):"string"===typeof d.is?a=g.createElement(c,{is:d.is}):(a=g.createElement(c),"select"===c&&(g=a,d.multiple?g.multiple=!0:d.size&&(g.size=d.size))):a=g.createElementNS(a,c);a[Da]=b;a[uc]=d;zk(a,b,!1,!1);b.stateNode=a;a:{g=qe(c,d);switch(c){case "dialog":B("cancel",a);B("close",a);e=d;break;case "iframe":case "object":case "embed":B("load",a);e=d;break;
|
||||
case "video":case "audio":for(e=0;e<Ec.length;e++)B(Ec[e],a);e=d;break;case "source":B("error",a);e=d;break;case "img":case "image":case "link":B("error",a);B("load",a);e=d;break;case "details":B("toggle",a);e=d;break;case "input":kg(a,d);e=ke(a,d);B("invalid",a);break;case "option":e=d;break;case "select":a._wrapperState={wasMultiple:!!d.multiple};e=E({},d,{value:void 0});B("invalid",a);break;case "textarea":ng(a,d);e=ne(a,d);B("invalid",a);break;default:e=d}pe(c,e);h=e;for(f in h)if(h.hasOwnProperty(f)){var k=
|
||||
h[f];"style"===f?sg(a,k):"dangerouslySetInnerHTML"===f?(k=k?k.__html:void 0,null!=k&&yi(a,k)):"children"===f?"string"===typeof k?("textarea"!==c||""!==k)&&Fc(a,k):"number"===typeof k&&Fc(a,""+k):"suppressContentEditableWarning"!==f&&"suppressHydrationWarning"!==f&&"autoFocus"!==f&&($b.hasOwnProperty(f)?null!=k&&"onScroll"===f&&B("scroll",a):null!=k&&$d(a,f,k,g))}switch(c){case "input":Pc(a);mg(a,d,!1);break;case "textarea":Pc(a);pg(a);break;case "option":null!=d.value&&a.setAttribute("value",""+Ua(d.value));
|
||||
break;case "select":a.multiple=!!d.multiple;f=d.value;null!=f?Db(a,!!d.multiple,f,!1):null!=d.defaultValue&&Db(a,!!d.multiple,d.defaultValue,!0);break;default:"function"===typeof e.onClick&&(a.onclick=kd)}switch(c){case "button":case "input":case "select":case "textarea":d=!!d.autoFocus;break a;case "img":d=!0;break a;default:d=!1}}d&&(b.flags|=4)}null!==b.ref&&(b.flags|=512,b.flags|=2097152)}W(b);return null;case 6:if(a&&null!=b.stateNode)Ak(a,b,a.memoizedProps,d);else{if("string"!==typeof d&&null===
|
||||
b.stateNode)throw Error(m(166));c=ub(xc.current);ub(Ea.current);if(pd(b)){d=b.stateNode;c=b.memoizedProps;d[Da]=b;if(f=d.nodeValue!==c)if(a=la,null!==a)switch(a.tag){case 3:jd(d.nodeValue,c,0!==(a.mode&1));break;case 5:!0!==a.memoizedProps.suppressHydrationWarning&&jd(d.nodeValue,c,0!==(a.mode&1))}f&&(b.flags|=4)}else d=(9===c.nodeType?c:c.ownerDocument).createTextNode(d),d[Da]=b,b.stateNode=d}W(b);return null;case 13:v(F);d=b.memoizedState;if(null===a||null!==a.memoizedState&&null!==a.memoizedState.dehydrated){if(D&&
|
||||
null!==fa&&0!==(b.mode&1)&&0===(b.flags&128)){for(f=fa;f;)f=Ka(f.nextSibling);Qb();b.flags|=98560;f=!1}else if(f=pd(b),null!==d&&null!==d.dehydrated){if(null===a){if(!f)throw Error(m(318));f=b.memoizedState;f=null!==f?f.dehydrated:null;if(!f)throw Error(m(317));f[Da]=b}else Qb(),0===(b.flags&128)&&(b.memoizedState=null),b.flags|=4;W(b);f=!1}else null!==wa&&(Gf(wa),wa=null),f=!0;if(!f)return b.flags&65536?b:null}if(0!==(b.flags&128))return b.lanes=c,b;d=null!==d;d!==(null!==a&&null!==a.memoizedState)&&
|
||||
d&&(b.child.flags|=8192,0!==(b.mode&1)&&(null===a||0!==(F.current&1)?0===L&&(L=3):Ef()));null!==b.updateQueue&&(b.flags|=4);W(b);return null;case 4:return Tb(),xi(a,b),null===a&&sc(b.stateNode.containerInfo),W(b),null;case 10:return cf(b.type._context),W(b),null;case 17:return ea(b.type)&&(v(S),v(J)),W(b),null;case 19:v(F);f=b.memoizedState;if(null===f)return W(b),null;d=0!==(b.flags&128);g=f.rendering;if(null===g)if(d)Dc(f,!1);else{if(0!==L||null!==a&&0!==(a.flags&128))for(a=b.child;null!==a;){g=
|
||||
xd(a);if(null!==g){b.flags|=128;Dc(f,!1);d=g.updateQueue;null!==d&&(b.updateQueue=d,b.flags|=4);b.subtreeFlags=0;d=c;for(c=b.child;null!==c;)f=c,a=d,f.flags&=14680066,g=f.alternate,null===g?(f.childLanes=0,f.lanes=a,f.child=null,f.subtreeFlags=0,f.memoizedProps=null,f.memoizedState=null,f.updateQueue=null,f.dependencies=null,f.stateNode=null):(f.childLanes=g.childLanes,f.lanes=g.lanes,f.child=g.child,f.subtreeFlags=0,f.deletions=null,f.memoizedProps=g.memoizedProps,f.memoizedState=g.memoizedState,
|
||||
f.updateQueue=g.updateQueue,f.type=g.type,a=g.dependencies,f.dependencies=null===a?null:{lanes:a.lanes,firstContext:a.firstContext}),c=c.sibling;y(F,F.current&1|2);return b.child}a=a.sibling}null!==f.tail&&P()>Hf&&(b.flags|=128,d=!0,Dc(f,!1),b.lanes=4194304)}else{if(!d)if(a=xd(g),null!==a){if(b.flags|=128,d=!0,c=a.updateQueue,null!==c&&(b.updateQueue=c,b.flags|=4),Dc(f,!0),null===f.tail&&"hidden"===f.tailMode&&!g.alternate&&!D)return W(b),null}else 2*P()-f.renderingStartTime>Hf&&1073741824!==c&&(b.flags|=
|
||||
128,d=!0,Dc(f,!1),b.lanes=4194304);f.isBackwards?(g.sibling=b.child,b.child=g):(c=f.last,null!==c?c.sibling=g:b.child=g,f.last=g)}if(null!==f.tail)return b=f.tail,f.rendering=b,f.tail=b.sibling,f.renderingStartTime=P(),b.sibling=null,c=F.current,y(F,d?c&1|2:c&1),b;W(b);return null;case 22:case 23:return ba=Ga.current,v(Ga),d=null!==b.memoizedState,null!==a&&null!==a.memoizedState!==d&&(b.flags|=8192),d&&0!==(b.mode&1)?0!==(ba&1073741824)&&(W(b),b.subtreeFlags&6&&(b.flags|=8192)):W(b),null;case 24:return null;
|
||||
case 25:return null}throw Error(m(156,b.tag));}function Bk(a,b,c){Ve(b);switch(b.tag){case 1:return ea(b.type)&&(v(S),v(J)),a=b.flags,a&65536?(b.flags=a&-65537|128,b):null;case 3:return Tb(),v(S),v(J),jf(),a=b.flags,0!==(a&65536)&&0===(a&128)?(b.flags=a&-65537|128,b):null;case 5:return hf(b),null;case 13:v(F);a=b.memoizedState;if(null!==a&&null!==a.dehydrated){if(null===b.alternate)throw Error(m(340));Qb()}a=b.flags;return a&65536?(b.flags=a&-65537|128,b):null;case 19:return v(F),null;case 4:return Tb(),
|
||||
null;case 10:return cf(b.type._context),null;case 22:case 23:return ba=Ga.current,v(Ga),null;case 24:return null;default:return null}}function Wb(a,b){var c=a.ref;if(null!==c)if("function"===typeof c)try{c(null)}catch(d){G(a,b,d)}else c.current=null}function If(a,b,c){try{c()}catch(d){G(a,b,d)}}function Ck(a,b){Jf=Zc;a=ch();if(Ie(a)){if("selectionStart"in a)var c={start:a.selectionStart,end:a.selectionEnd};else a:{c=(c=a.ownerDocument)&&c.defaultView||window;var d=c.getSelection&&c.getSelection();
|
||||
if(d&&0!==d.rangeCount){c=d.anchorNode;var e=d.anchorOffset,f=d.focusNode;d=d.focusOffset;try{c.nodeType,f.nodeType}catch(M){c=null;break a}var g=0,h=-1,k=-1,n=0,q=0,u=a,r=null;b:for(;;){for(var p;;){u!==c||0!==e&&3!==u.nodeType||(h=g+e);u!==f||0!==d&&3!==u.nodeType||(k=g+d);3===u.nodeType&&(g+=u.nodeValue.length);if(null===(p=u.firstChild))break;r=u;u=p}for(;;){if(u===a)break b;r===c&&++n===e&&(h=g);r===f&&++q===d&&(k=g);if(null!==(p=u.nextSibling))break;u=r;r=u.parentNode}u=p}c=-1===h||-1===k?null:
|
||||
{start:h,end:k}}else c=null}c=c||{start:0,end:0}}else c=null;Kf={focusedElem:a,selectionRange:c};Zc=!1;for(l=b;null!==l;)if(b=l,a=b.child,0!==(b.subtreeFlags&1028)&&null!==a)a.return=b,l=a;else for(;null!==l;){b=l;try{var x=b.alternate;if(0!==(b.flags&1024))switch(b.tag){case 0:case 11:case 15:break;case 1:if(null!==x){var v=x.memoizedProps,z=x.memoizedState,w=b.stateNode,A=w.getSnapshotBeforeUpdate(b.elementType===b.type?v:ya(b.type,v),z);w.__reactInternalSnapshotBeforeUpdate=A}break;case 3:var t=
|
||||
b.stateNode.containerInfo;1===t.nodeType?t.textContent="":9===t.nodeType&&t.documentElement&&t.removeChild(t.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(m(163));}}catch(M){G(b,b.return,M)}a=b.sibling;if(null!==a){a.return=b.return;l=a;break}l=b.return}x=zi;zi=!1;return x}function Gc(a,b,c){var d=b.updateQueue;d=null!==d?d.lastEffect:null;if(null!==d){var e=d=d.next;do{if((e.tag&a)===a){var f=e.destroy;e.destroy=void 0;void 0!==f&&If(b,c,f)}e=e.next}while(e!==d)}}
|
||||
function Id(a,b){b=b.updateQueue;b=null!==b?b.lastEffect:null;if(null!==b){var c=b=b.next;do{if((c.tag&a)===a){var d=c.create;c.destroy=d()}c=c.next}while(c!==b)}}function Lf(a){var b=a.ref;if(null!==b){var c=a.stateNode;switch(a.tag){case 5:a=c;break;default:a=c}"function"===typeof b?b(a):b.current=a}}function Ai(a){var b=a.alternate;null!==b&&(a.alternate=null,Ai(b));a.child=null;a.deletions=null;a.sibling=null;5===a.tag&&(b=a.stateNode,null!==b&&(delete b[Da],delete b[uc],delete b[Me],delete b[Dk],
|
||||
delete b[Ek]));a.stateNode=null;a.return=null;a.dependencies=null;a.memoizedProps=null;a.memoizedState=null;a.pendingProps=null;a.stateNode=null;a.updateQueue=null}function Bi(a){return 5===a.tag||3===a.tag||4===a.tag}function Ci(a){a:for(;;){for(;null===a.sibling;){if(null===a.return||Bi(a.return))return null;a=a.return}a.sibling.return=a.return;for(a=a.sibling;5!==a.tag&&6!==a.tag&&18!==a.tag;){if(a.flags&2)continue a;if(null===a.child||4===a.tag)continue a;else a.child.return=a,a=a.child}if(!(a.flags&
|
||||
2))return a.stateNode}}function Mf(a,b,c){var d=a.tag;if(5===d||6===d)a=a.stateNode,b?8===c.nodeType?c.parentNode.insertBefore(a,b):c.insertBefore(a,b):(8===c.nodeType?(b=c.parentNode,b.insertBefore(a,c)):(b=c,b.appendChild(a)),c=c._reactRootContainer,null!==c&&void 0!==c||null!==b.onclick||(b.onclick=kd));else if(4!==d&&(a=a.child,null!==a))for(Mf(a,b,c),a=a.sibling;null!==a;)Mf(a,b,c),a=a.sibling}function Nf(a,b,c){var d=a.tag;if(5===d||6===d)a=a.stateNode,b?c.insertBefore(a,b):c.appendChild(a);
|
||||
else if(4!==d&&(a=a.child,null!==a))for(Nf(a,b,c),a=a.sibling;null!==a;)Nf(a,b,c),a=a.sibling}function jb(a,b,c){for(c=c.child;null!==c;)Di(a,b,c),c=c.sibling}function Di(a,b,c){if(Ca&&"function"===typeof Ca.onCommitFiberUnmount)try{Ca.onCommitFiberUnmount(Uc,c)}catch(h){}switch(c.tag){case 5:X||Wb(c,b);case 6:var d=T,e=za;T=null;jb(a,b,c);T=d;za=e;null!==T&&(za?(a=T,c=c.stateNode,8===a.nodeType?a.parentNode.removeChild(c):a.removeChild(c)):T.removeChild(c.stateNode));break;case 18:null!==T&&(za?
|
||||
(a=T,c=c.stateNode,8===a.nodeType?Re(a.parentNode,c):1===a.nodeType&&Re(a,c),nc(a)):Re(T,c.stateNode));break;case 4:d=T;e=za;T=c.stateNode.containerInfo;za=!0;jb(a,b,c);T=d;za=e;break;case 0:case 11:case 14:case 15:if(!X&&(d=c.updateQueue,null!==d&&(d=d.lastEffect,null!==d))){e=d=d.next;do{var f=e,g=f.destroy;f=f.tag;void 0!==g&&(0!==(f&2)?If(c,b,g):0!==(f&4)&&If(c,b,g));e=e.next}while(e!==d)}jb(a,b,c);break;case 1:if(!X&&(Wb(c,b),d=c.stateNode,"function"===typeof d.componentWillUnmount))try{d.props=
|
||||
c.memoizedProps,d.state=c.memoizedState,d.componentWillUnmount()}catch(h){G(c,b,h)}jb(a,b,c);break;case 21:jb(a,b,c);break;case 22:c.mode&1?(X=(d=X)||null!==c.memoizedState,jb(a,b,c),X=d):jb(a,b,c);break;default:jb(a,b,c)}}function Ei(a){var b=a.updateQueue;if(null!==b){a.updateQueue=null;var c=a.stateNode;null===c&&(c=a.stateNode=new Fk);b.forEach(function(b){var d=Gk.bind(null,a,b);c.has(b)||(c.add(b),b.then(d,d))})}}function Aa(a,b,c){c=b.deletions;if(null!==c)for(var d=0;d<c.length;d++){var e=
|
||||
c[d];try{var f=a,g=b,h=g;a:for(;null!==h;){switch(h.tag){case 5:T=h.stateNode;za=!1;break a;case 3:T=h.stateNode.containerInfo;za=!0;break a;case 4:T=h.stateNode.containerInfo;za=!0;break a}h=h.return}if(null===T)throw Error(m(160));Di(f,g,e);T=null;za=!1;var k=e.alternate;null!==k&&(k.return=null);e.return=null}catch(n){G(e,b,n)}}if(b.subtreeFlags&12854)for(b=b.child;null!==b;)Fi(b,a),b=b.sibling}function Fi(a,b,c){var d=a.alternate;c=a.flags;switch(a.tag){case 0:case 11:case 14:case 15:Aa(b,a);
|
||||
Ha(a);if(c&4){try{Gc(3,a,a.return),Id(3,a)}catch(I){G(a,a.return,I)}try{Gc(5,a,a.return)}catch(I){G(a,a.return,I)}}break;case 1:Aa(b,a);Ha(a);c&512&&null!==d&&Wb(d,d.return);break;case 5:Aa(b,a);Ha(a);c&512&&null!==d&&Wb(d,d.return);if(a.flags&32){var e=a.stateNode;try{Fc(e,"")}catch(I){G(a,a.return,I)}}if(c&4&&(e=a.stateNode,null!=e)){var f=a.memoizedProps,g=null!==d?d.memoizedProps:f,h=a.type,k=a.updateQueue;a.updateQueue=null;if(null!==k)try{"input"===h&&"radio"===f.type&&null!=f.name&&lg(e,f);
|
||||
qe(h,g);var n=qe(h,f);for(g=0;g<k.length;g+=2){var q=k[g],u=k[g+1];"style"===q?sg(e,u):"dangerouslySetInnerHTML"===q?yi(e,u):"children"===q?Fc(e,u):$d(e,q,u,n)}switch(h){case "input":le(e,f);break;case "textarea":og(e,f);break;case "select":var r=e._wrapperState.wasMultiple;e._wrapperState.wasMultiple=!!f.multiple;var p=f.value;null!=p?Db(e,!!f.multiple,p,!1):r!==!!f.multiple&&(null!=f.defaultValue?Db(e,!!f.multiple,f.defaultValue,!0):Db(e,!!f.multiple,f.multiple?[]:"",!1))}e[uc]=f}catch(I){G(a,a.return,
|
||||
I)}}break;case 6:Aa(b,a);Ha(a);if(c&4){if(null===a.stateNode)throw Error(m(162));e=a.stateNode;f=a.memoizedProps;try{e.nodeValue=f}catch(I){G(a,a.return,I)}}break;case 3:Aa(b,a);Ha(a);if(c&4&&null!==d&&d.memoizedState.isDehydrated)try{nc(b.containerInfo)}catch(I){G(a,a.return,I)}break;case 4:Aa(b,a);Ha(a);break;case 13:Aa(b,a);Ha(a);e=a.child;e.flags&8192&&(f=null!==e.memoizedState,e.stateNode.isHidden=f,!f||null!==e.alternate&&null!==e.alternate.memoizedState||(Of=P()));c&4&&Ei(a);break;case 22:q=
|
||||
null!==d&&null!==d.memoizedState;a.mode&1?(X=(n=X)||q,Aa(b,a),X=n):Aa(b,a);Ha(a);if(c&8192){n=null!==a.memoizedState;if((a.stateNode.isHidden=n)&&!q&&0!==(a.mode&1))for(l=a,q=a.child;null!==q;){for(u=l=q;null!==l;){r=l;p=r.child;switch(r.tag){case 0:case 11:case 14:case 15:Gc(4,r,r.return);break;case 1:Wb(r,r.return);var x=r.stateNode;if("function"===typeof x.componentWillUnmount){c=r;b=r.return;try{d=c,x.props=d.memoizedProps,x.state=d.memoizedState,x.componentWillUnmount()}catch(I){G(c,b,I)}}break;
|
||||
case 5:Wb(r,r.return);break;case 22:if(null!==r.memoizedState){Gi(u);continue}}null!==p?(p.return=r,l=p):Gi(u)}q=q.sibling}a:for(q=null,u=a;;){if(5===u.tag){if(null===q){q=u;try{e=u.stateNode,n?(f=e.style,"function"===typeof f.setProperty?f.setProperty("display","none","important"):f.display="none"):(h=u.stateNode,k=u.memoizedProps.style,g=void 0!==k&&null!==k&&k.hasOwnProperty("display")?k.display:null,h.style.display=rg("display",g))}catch(I){G(a,a.return,I)}}}else if(6===u.tag){if(null===q)try{u.stateNode.nodeValue=
|
||||
n?"":u.memoizedProps}catch(I){G(a,a.return,I)}}else if((22!==u.tag&&23!==u.tag||null===u.memoizedState||u===a)&&null!==u.child){u.child.return=u;u=u.child;continue}if(u===a)break a;for(;null===u.sibling;){if(null===u.return||u.return===a)break a;q===u&&(q=null);u=u.return}q===u&&(q=null);u.sibling.return=u.return;u=u.sibling}}break;case 19:Aa(b,a);Ha(a);c&4&&Ei(a);break;case 21:break;default:Aa(b,a),Ha(a)}}function Ha(a){var b=a.flags;if(b&2){try{a:{for(var c=a.return;null!==c;){if(Bi(c)){var d=c;
|
||||
break a}c=c.return}throw Error(m(160));}switch(d.tag){case 5:var e=d.stateNode;d.flags&32&&(Fc(e,""),d.flags&=-33);var f=Ci(a);Nf(a,f,e);break;case 3:case 4:var g=d.stateNode.containerInfo,h=Ci(a);Mf(a,h,g);break;default:throw Error(m(161));}}catch(k){G(a,a.return,k)}a.flags&=-3}b&4096&&(a.flags&=-4097)}function Hk(a,b,c){l=a;Hi(a,b,c)}function Hi(a,b,c){for(var d=0!==(a.mode&1);null!==l;){var e=l,f=e.child;if(22===e.tag&&d){var g=null!==e.memoizedState||Jd;if(!g){var h=e.alternate,k=null!==h&&null!==
|
||||
h.memoizedState||X;h=Jd;var n=X;Jd=g;if((X=k)&&!n)for(l=e;null!==l;)g=l,k=g.child,22===g.tag&&null!==g.memoizedState?Ii(e):null!==k?(k.return=g,l=k):Ii(e);for(;null!==f;)l=f,Hi(f,b,c),f=f.sibling;l=e;Jd=h;X=n}Ji(a,b,c)}else 0!==(e.subtreeFlags&8772)&&null!==f?(f.return=e,l=f):Ji(a,b,c)}}function Ji(a,b,c){for(;null!==l;){b=l;if(0!==(b.flags&8772)){c=b.alternate;try{if(0!==(b.flags&8772))switch(b.tag){case 0:case 11:case 15:X||Id(5,b);break;case 1:var d=b.stateNode;if(b.flags&4&&!X)if(null===c)d.componentDidMount();
|
||||
else{var e=b.elementType===b.type?c.memoizedProps:ya(b.type,c.memoizedProps);d.componentDidUpdate(e,c.memoizedState,d.__reactInternalSnapshotBeforeUpdate)}var f=b.updateQueue;null!==f&&Hh(b,f,d);break;case 3:var g=b.updateQueue;if(null!==g){c=null;if(null!==b.child)switch(b.child.tag){case 5:c=b.child.stateNode;break;case 1:c=b.child.stateNode}Hh(b,g,c)}break;case 5:var h=b.stateNode;if(null===c&&b.flags&4){c=h;var k=b.memoizedProps;switch(b.type){case "button":case "input":case "select":case "textarea":k.autoFocus&&
|
||||
c.focus();break;case "img":k.src&&(c.src=k.src)}}break;case 6:break;case 4:break;case 12:break;case 13:if(null===b.memoizedState){var n=b.alternate;if(null!==n){var q=n.memoizedState;if(null!==q){var p=q.dehydrated;null!==p&&nc(p)}}}break;case 19:case 17:case 21:case 22:case 23:case 25:break;default:throw Error(m(163));}X||b.flags&512&&Lf(b)}catch(r){G(b,b.return,r)}}if(b===a){l=null;break}c=b.sibling;if(null!==c){c.return=b.return;l=c;break}l=b.return}}function Gi(a){for(;null!==l;){var b=l;if(b===
|
||||
a){l=null;break}var c=b.sibling;if(null!==c){c.return=b.return;l=c;break}l=b.return}}function Ii(a){for(;null!==l;){var b=l;try{switch(b.tag){case 0:case 11:case 15:var c=b.return;try{Id(4,b)}catch(k){G(b,c,k)}break;case 1:var d=b.stateNode;if("function"===typeof d.componentDidMount){var e=b.return;try{d.componentDidMount()}catch(k){G(b,e,k)}}var f=b.return;try{Lf(b)}catch(k){G(b,f,k)}break;case 5:var g=b.return;try{Lf(b)}catch(k){G(b,g,k)}}}catch(k){G(b,b.return,k)}if(b===a){l=null;break}var h=b.sibling;
|
||||
if(null!==h){h.return=b.return;l=h;break}l=b.return}}function Hc(){Hf=P()+500}function Z(){return 0!==(p&6)?P():-1!==Kd?Kd:Kd=P()}function hb(a){if(0===(a.mode&1))return 1;if(0!==(p&2)&&0!==U)return U&-U;if(null!==Ik.transition)return 0===Ld&&(Ld=Dg()),Ld;a=z;if(0!==a)return a;a=window.event;a=void 0===a?16:Lg(a.type);return a}function xa(a,b,c,d){if(50<Ic)throw Ic=0,Pf=null,Error(m(185));ic(a,c,d);if(0===(p&2)||a!==O)a===O&&(0===(p&2)&&(Md|=c),4===L&&kb(a,U)),ia(a,d),1===c&&0===p&&0===(b.mode&1)&&
|
||||
(Hc(),md&&db())}function ia(a,b){var c=a.callbackNode;tj(a,b);var d=Vc(a,a===O?U:0);if(0===d)null!==c&&Ki(c),a.callbackNode=null,a.callbackPriority=0;else if(b=d&-d,a.callbackPriority!==b){null!=c&&Ki(c);if(1===b)0===a.tag?jk(Li.bind(null,a)):wh(Li.bind(null,a)),Jk(function(){0===(p&6)&&db()}),c=null;else{switch(Eg(d)){case 1:c=De;break;case 4:c=Mg;break;case 16:c=ad;break;case 536870912:c=Ng;break;default:c=ad}c=Mi(c,Ni.bind(null,a))}a.callbackPriority=b;a.callbackNode=c}}function Ni(a,b){Kd=-1;
|
||||
Ld=0;if(0!==(p&6))throw Error(m(327));var c=a.callbackNode;if(Xb()&&a.callbackNode!==c)return null;var d=Vc(a,a===O?U:0);if(0===d)return null;if(0!==(d&30)||0!==(d&a.expiredLanes)||b)b=Nd(a,d);else{b=d;var e=p;p|=2;var f=Oi();if(O!==a||U!==b)Ra=null,Hc(),wb(a,b);do try{Kk();break}catch(h){Pi(a,h)}while(1);af();Od.current=f;p=e;null!==H?b=0:(O=null,U=0,b=L)}if(0!==b){2===b&&(e=ve(a),0!==e&&(d=e,b=Qf(a,e)));if(1===b)throw c=Jc,wb(a,0),kb(a,d),ia(a,P()),c;if(6===b)kb(a,d);else{e=a.current.alternate;
|
||||
if(0===(d&30)&&!Lk(e)&&(b=Nd(a,d),2===b&&(f=ve(a),0!==f&&(d=f,b=Qf(a,f))),1===b))throw c=Jc,wb(a,0),kb(a,d),ia(a,P()),c;a.finishedWork=e;a.finishedLanes=d;switch(b){case 0:case 1:throw Error(m(345));case 2:xb(a,ja,Ra);break;case 3:kb(a,d);if((d&130023424)===d&&(b=Of+500-P(),10<b)){if(0!==Vc(a,0))break;e=a.suspendedLanes;if((e&d)!==d){Z();a.pingedLanes|=a.suspendedLanes&e;break}a.timeoutHandle=Rf(xb.bind(null,a,ja,Ra),b);break}xb(a,ja,Ra);break;case 4:kb(a,d);if((d&4194240)===d)break;b=a.eventTimes;
|
||||
for(e=-1;0<d;){var g=31-ta(d);f=1<<g;g=b[g];g>e&&(e=g);d&=~f}d=e;d=P()-d;d=(120>d?120:480>d?480:1080>d?1080:1920>d?1920:3E3>d?3E3:4320>d?4320:1960*Mk(d/1960))-d;if(10<d){a.timeoutHandle=Rf(xb.bind(null,a,ja,Ra),d);break}xb(a,ja,Ra);break;case 5:xb(a,ja,Ra);break;default:throw Error(m(329));}}}ia(a,P());return a.callbackNode===c?Ni.bind(null,a):null}function Qf(a,b){var c=Kc;a.current.memoizedState.isDehydrated&&(wb(a,b).flags|=256);a=Nd(a,b);2!==a&&(b=ja,ja=c,null!==b&&Gf(b));return a}function Gf(a){null===
|
||||
ja?ja=a:ja.push.apply(ja,a)}function Lk(a){for(var b=a;;){if(b.flags&16384){var c=b.updateQueue;if(null!==c&&(c=c.stores,null!==c))for(var d=0;d<c.length;d++){var e=c[d],f=e.getSnapshot;e=e.value;try{if(!ua(f(),e))return!1}catch(g){return!1}}}c=b.child;if(b.subtreeFlags&16384&&null!==c)c.return=b,b=c;else{if(b===a)break;for(;null===b.sibling;){if(null===b.return||b.return===a)return!0;b=b.return}b.sibling.return=b.return;b=b.sibling}}return!0}function kb(a,b){b&=~Sf;b&=~Md;a.suspendedLanes|=b;a.pingedLanes&=
|
||||
~b;for(a=a.expirationTimes;0<b;){var c=31-ta(b),d=1<<c;a[c]=-1;b&=~d}}function Li(a){if(0!==(p&6))throw Error(m(327));Xb();var b=Vc(a,0);if(0===(b&1))return ia(a,P()),null;var c=Nd(a,b);if(0!==a.tag&&2===c){var d=ve(a);0!==d&&(b=d,c=Qf(a,d))}if(1===c)throw c=Jc,wb(a,0),kb(a,b),ia(a,P()),c;if(6===c)throw Error(m(345));a.finishedWork=a.current.alternate;a.finishedLanes=b;xb(a,ja,Ra);ia(a,P());return null}function Tf(a,b){var c=p;p|=1;try{return a(b)}finally{p=c,0===p&&(Hc(),md&&db())}}function yb(a){null!==
|
||||
lb&&0===lb.tag&&0===(p&6)&&Xb();var b=p;p|=1;var c=ca.transition,d=z;try{if(ca.transition=null,z=1,a)return a()}finally{z=d,ca.transition=c,p=b,0===(p&6)&&db()}}function wb(a,b){a.finishedWork=null;a.finishedLanes=0;var c=a.timeoutHandle;-1!==c&&(a.timeoutHandle=-1,Nk(c));if(null!==H)for(c=H.return;null!==c;){var d=c;Ve(d);switch(d.tag){case 1:d=d.type.childContextTypes;null!==d&&void 0!==d&&(v(S),v(J));break;case 3:Tb();v(S);v(J);jf();break;case 5:hf(d);break;case 4:Tb();break;case 13:v(F);break;
|
||||
case 19:v(F);break;case 10:cf(d.type._context);break;case 22:case 23:ba=Ga.current,v(Ga)}c=c.return}O=a;H=a=eb(a.current,null);U=ba=b;L=0;Jc=null;Sf=Md=ra=0;ja=Kc=null;if(null!==tb){for(b=0;b<tb.length;b++)if(c=tb[b],d=c.interleaved,null!==d){c.interleaved=null;var e=d.next,f=c.pending;if(null!==f){var g=f.next;f.next=e;d.next=g}c.pending=d}tb=null}return a}function Pi(a,b){do{var c=H;try{af();yd.current=zd;if(Ad){for(var d=C.memoizedState;null!==d;){var e=d.queue;null!==e&&(e.pending=null);d=d.next}Ad=
|
||||
!1}vb=0;N=K=C=null;zc=!1;Ac=0;Uf.current=null;if(null===c||null===c.return){L=1;Jc=b;H=null;break}a:{var f=a,g=c.return,h=c,k=b;b=U;h.flags|=32768;if(null!==k&&"object"===typeof k&&"function"===typeof k.then){var n=k,l=h,p=l.tag;if(0===(l.mode&1)&&(0===p||11===p||15===p)){var r=l.alternate;r?(l.updateQueue=r.updateQueue,l.memoizedState=r.memoizedState,l.lanes=r.lanes):(l.updateQueue=null,l.memoizedState=null)}var v=ji(g);if(null!==v){v.flags&=-257;ki(v,g,h,f,b);v.mode&1&&ii(f,n,b);b=v;k=n;var x=b.updateQueue;
|
||||
if(null===x){var z=new Set;z.add(k);b.updateQueue=z}else x.add(k);break a}else{if(0===(b&1)){ii(f,n,b);Ef();break a}k=Error(m(426))}}else if(D&&h.mode&1){var y=ji(g);if(null!==y){0===(y.flags&65536)&&(y.flags|=256);ki(y,g,h,f,b);Ye(Ub(k,h));break a}}f=k=Ub(k,h);4!==L&&(L=2);null===Kc?Kc=[f]:Kc.push(f);f=g;do{switch(f.tag){case 3:f.flags|=65536;b&=-b;f.lanes|=b;var w=gi(f,k,b);Gh(f,w);break a;case 1:h=k;var A=f.type,t=f.stateNode;if(0===(f.flags&128)&&("function"===typeof A.getDerivedStateFromError||
|
||||
null!==t&&"function"===typeof t.componentDidCatch&&(null===ib||!ib.has(t)))){f.flags|=65536;b&=-b;f.lanes|=b;var B=hi(f,h,b);Gh(f,B);break a}}f=f.return}while(null!==f)}Qi(c)}catch(ma){b=ma;H===c&&null!==c&&(H=c=c.return);continue}break}while(1)}function Oi(){var a=Od.current;Od.current=zd;return null===a?zd:a}function Ef(){if(0===L||3===L||2===L)L=4;null===O||0===(ra&268435455)&&0===(Md&268435455)||kb(O,U)}function Nd(a,b){var c=p;p|=2;var d=Oi();if(O!==a||U!==b)Ra=null,wb(a,b);do try{Ok();break}catch(e){Pi(a,
|
||||
e)}while(1);af();p=c;Od.current=d;if(null!==H)throw Error(m(261));O=null;U=0;return L}function Ok(){for(;null!==H;)Ri(H)}function Kk(){for(;null!==H&&!Pk();)Ri(H)}function Ri(a){var b=Qk(a.alternate,a,ba);a.memoizedProps=a.pendingProps;null===b?Qi(a):H=b;Uf.current=null}function Qi(a){var b=a;do{var c=b.alternate;a=b.return;if(0===(b.flags&32768)){if(c=xk(c,b,ba),null!==c){H=c;return}}else{c=Bk(c,b);if(null!==c){c.flags&=32767;H=c;return}if(null!==a)a.flags|=32768,a.subtreeFlags=0,a.deletions=null;
|
||||
else{L=6;H=null;return}}b=b.sibling;if(null!==b){H=b;return}H=b=a}while(null!==b);0===L&&(L=5)}function xb(a,b,c){var d=z,e=ca.transition;try{ca.transition=null,z=1,Rk(a,b,c,d)}finally{ca.transition=e,z=d}return null}function Rk(a,b,c,d){do Xb();while(null!==lb);if(0!==(p&6))throw Error(m(327));c=a.finishedWork;var e=a.finishedLanes;if(null===c)return null;a.finishedWork=null;a.finishedLanes=0;if(c===a.current)throw Error(m(177));a.callbackNode=null;a.callbackPriority=0;var f=c.lanes|c.childLanes;
|
||||
uj(a,f);a===O&&(H=O=null,U=0);0===(c.subtreeFlags&2064)&&0===(c.flags&2064)||Pd||(Pd=!0,Mi(ad,function(){Xb();return null}));f=0!==(c.flags&15990);if(0!==(c.subtreeFlags&15990)||f){f=ca.transition;ca.transition=null;var g=z;z=1;var h=p;p|=4;Uf.current=null;Ck(a,c);Fi(c,a);Tj(Kf);Zc=!!Jf;Kf=Jf=null;a.current=c;Hk(c,a,e);Sk();p=h;z=g;ca.transition=f}else a.current=c;Pd&&(Pd=!1,lb=a,Qd=e);f=a.pendingLanes;0===f&&(ib=null);oj(c.stateNode,d);ia(a,P());if(null!==b)for(d=a.onRecoverableError,c=0;c<b.length;c++)e=
|
||||
b[c],d(e.value,{componentStack:e.stack,digest:e.digest});if(Ed)throw Ed=!1,a=xf,xf=null,a;0!==(Qd&1)&&0!==a.tag&&Xb();f=a.pendingLanes;0!==(f&1)?a===Pf?Ic++:(Ic=0,Pf=a):Ic=0;db();return null}function Xb(){if(null!==lb){var a=Eg(Qd),b=ca.transition,c=z;try{ca.transition=null;z=16>a?16:a;if(null===lb)var d=!1;else{a=lb;lb=null;Qd=0;if(0!==(p&6))throw Error(m(331));var e=p;p|=4;for(l=a.current;null!==l;){var f=l,g=f.child;if(0!==(l.flags&16)){var h=f.deletions;if(null!==h){for(var k=0;k<h.length;k++){var n=
|
||||
h[k];for(l=n;null!==l;){var q=l;switch(q.tag){case 0:case 11:case 15:Gc(8,q,f)}var u=q.child;if(null!==u)u.return=q,l=u;else for(;null!==l;){q=l;var r=q.sibling,v=q.return;Ai(q);if(q===n){l=null;break}if(null!==r){r.return=v;l=r;break}l=v}}}var x=f.alternate;if(null!==x){var y=x.child;if(null!==y){x.child=null;do{var C=y.sibling;y.sibling=null;y=C}while(null!==y)}}l=f}}if(0!==(f.subtreeFlags&2064)&&null!==g)g.return=f,l=g;else b:for(;null!==l;){f=l;if(0!==(f.flags&2048))switch(f.tag){case 0:case 11:case 15:Gc(9,
|
||||
f,f.return)}var w=f.sibling;if(null!==w){w.return=f.return;l=w;break b}l=f.return}}var A=a.current;for(l=A;null!==l;){g=l;var t=g.child;if(0!==(g.subtreeFlags&2064)&&null!==t)t.return=g,l=t;else b:for(g=A;null!==l;){h=l;if(0!==(h.flags&2048))try{switch(h.tag){case 0:case 11:case 15:Id(9,h)}}catch(ma){G(h,h.return,ma)}if(h===g){l=null;break b}var B=h.sibling;if(null!==B){B.return=h.return;l=B;break b}l=h.return}}p=e;db();if(Ca&&"function"===typeof Ca.onPostCommitFiberRoot)try{Ca.onPostCommitFiberRoot(Uc,
|
||||
a)}catch(ma){}d=!0}return d}finally{z=c,ca.transition=b}}return!1}function Si(a,b,c){b=Ub(c,b);b=gi(a,b,1);a=fb(a,b,1);b=Z();null!==a&&(ic(a,1,b),ia(a,b))}function G(a,b,c){if(3===a.tag)Si(a,a,c);else for(;null!==b;){if(3===b.tag){Si(b,a,c);break}else if(1===b.tag){var d=b.stateNode;if("function"===typeof b.type.getDerivedStateFromError||"function"===typeof d.componentDidCatch&&(null===ib||!ib.has(d))){a=Ub(c,a);a=hi(b,a,1);b=fb(b,a,1);a=Z();null!==b&&(ic(b,1,a),ia(b,a));break}}b=b.return}}function sk(a,
|
||||
b,c){var d=a.pingCache;null!==d&&d.delete(b);b=Z();a.pingedLanes|=a.suspendedLanes&c;O===a&&(U&c)===c&&(4===L||3===L&&(U&130023424)===U&&500>P()-Of?wb(a,0):Sf|=c);ia(a,b)}function Ti(a,b){0===b&&(0===(a.mode&1)?b=1:(b=Rd,Rd<<=1,0===(Rd&130023424)&&(Rd=4194304)));var c=Z();a=Oa(a,b);null!==a&&(ic(a,b,c),ia(a,c))}function vk(a){var b=a.memoizedState,c=0;null!==b&&(c=b.retryLane);Ti(a,c)}function Gk(a,b){var c=0;switch(a.tag){case 13:var d=a.stateNode;var e=a.memoizedState;null!==e&&(c=e.retryLane);
|
||||
break;case 19:d=a.stateNode;break;default:throw Error(m(314));}null!==d&&d.delete(b);Ti(a,c)}function Mi(a,b){return xh(a,b)}function Tk(a,b,c,d){this.tag=a;this.key=c;this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null;this.index=0;this.ref=null;this.pendingProps=b;this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null;this.mode=d;this.subtreeFlags=this.flags=0;this.deletions=null;this.childLanes=this.lanes=0;this.alternate=null}function yf(a){a=
|
||||
a.prototype;return!(!a||!a.isReactComponent)}function Uk(a){if("function"===typeof a)return yf(a)?1:0;if(void 0!==a&&null!==a){a=a.$$typeof;if(a===ie)return 11;if(a===je)return 14}return 2}function eb(a,b){var c=a.alternate;null===c?(c=pa(a.tag,b,a.key,a.mode),c.elementType=a.elementType,c.type=a.type,c.stateNode=a.stateNode,c.alternate=a,a.alternate=c):(c.pendingProps=b,c.type=a.type,c.flags=0,c.subtreeFlags=0,c.deletions=null);c.flags=a.flags&14680064;c.childLanes=a.childLanes;c.lanes=a.lanes;c.child=
|
||||
a.child;c.memoizedProps=a.memoizedProps;c.memoizedState=a.memoizedState;c.updateQueue=a.updateQueue;b=a.dependencies;c.dependencies=null===b?null:{lanes:b.lanes,firstContext:b.firstContext};c.sibling=a.sibling;c.index=a.index;c.ref=a.ref;return c}function rd(a,b,c,d,e,f){var g=2;d=a;if("function"===typeof a)yf(a)&&(g=1);else if("string"===typeof a)g=5;else a:switch(a){case Bb:return sb(c.children,e,f,b);case fe:g=8;e|=8;break;case ee:return a=pa(12,c,b,e|2),a.elementType=ee,a.lanes=f,a;case ge:return a=
|
||||
pa(13,c,b,e),a.elementType=ge,a.lanes=f,a;case he:return a=pa(19,c,b,e),a.elementType=he,a.lanes=f,a;case Ui:return Gd(c,e,f,b);default:if("object"===typeof a&&null!==a)switch(a.$$typeof){case hg:g=10;break a;case gg:g=9;break a;case ie:g=11;break a;case je:g=14;break a;case Ta:g=16;d=null;break a}throw Error(m(130,null==a?a:typeof a,""));}b=pa(g,c,b,e);b.elementType=a;b.type=d;b.lanes=f;return b}function sb(a,b,c,d){a=pa(7,a,d,b);a.lanes=c;return a}function Gd(a,b,c,d){a=pa(22,a,d,b);a.elementType=
|
||||
Ui;a.lanes=c;a.stateNode={isHidden:!1};return a}function Ze(a,b,c){a=pa(6,a,null,b);a.lanes=c;return a}function $e(a,b,c){b=pa(4,null!==a.children?a.children:[],a.key,b);b.lanes=c;b.stateNode={containerInfo:a.containerInfo,pendingChildren:null,implementation:a.implementation};return b}function Vk(a,b,c,d,e){this.tag=b;this.containerInfo=a;this.finishedWork=this.pingCache=this.current=this.pendingChildren=null;this.timeoutHandle=-1;this.callbackNode=this.pendingContext=this.context=null;this.callbackPriority=
|
||||
0;this.eventTimes=we(0);this.expirationTimes=we(-1);this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0;this.entanglements=we(0);this.identifierPrefix=d;this.onRecoverableError=e;this.mutableSourceEagerHydrationData=null}function Vf(a,b,c,d,e,f,g,h,k,l){a=new Vk(a,b,c,h,k);1===b?(b=1,!0===f&&(b|=8)):b=0;f=pa(3,null,null,b);a.current=f;f.stateNode=a;f.memoizedState={element:d,isDehydrated:c,cache:null,transitions:null,
|
||||
pendingSuspenseBoundaries:null};ff(f);return a}function Wk(a,b,c){var d=3<arguments.length&&void 0!==arguments[3]?arguments[3]:null;return{$$typeof:Cb,key:null==d?null:""+d,children:a,containerInfo:b,implementation:c}}function Vi(a){if(!a)return cb;a=a._reactInternals;a:{if(nb(a)!==a||1!==a.tag)throw Error(m(170));var b=a;do{switch(b.tag){case 3:b=b.stateNode.context;break a;case 1:if(ea(b.type)){b=b.stateNode.__reactInternalMemoizedMergedChildContext;break a}}b=b.return}while(null!==b);throw Error(m(171));
|
||||
}if(1===a.tag){var c=a.type;if(ea(c))return uh(a,c,b)}return b}function Wi(a,b,c,d,e,f,g,h,k,l){a=Vf(c,d,!0,a,e,f,g,h,k);a.context=Vi(null);c=a.current;d=Z();e=hb(c);f=Pa(d,e);f.callback=void 0!==b&&null!==b?b:null;fb(c,f,e);a.current.lanes=e;ic(a,e,d);ia(a,d);return a}function Sd(a,b,c,d){var e=b.current,f=Z(),g=hb(e);c=Vi(c);null===b.context?b.context=c:b.pendingContext=c;b=Pa(f,g);b.payload={element:a};d=void 0===d?null:d;null!==d&&(b.callback=d);a=fb(e,b,g);null!==a&&(xa(a,e,g,f),vd(a,e,g));return g}
|
||||
function Td(a){a=a.current;if(!a.child)return null;switch(a.child.tag){case 5:return a.child.stateNode;default:return a.child.stateNode}}function Xi(a,b){a=a.memoizedState;if(null!==a&&null!==a.dehydrated){var c=a.retryLane;a.retryLane=0!==c&&c<b?c:b}}function Wf(a,b){Xi(a,b);(a=a.alternate)&&Xi(a,b)}function Xk(a){a=Bg(a);return null===a?null:a.stateNode}function Yk(a){return null}function Xf(a){this._internalRoot=a}function Ud(a){this._internalRoot=a}function Yf(a){return!(!a||1!==a.nodeType&&9!==
|
||||
a.nodeType&&11!==a.nodeType)}function Vd(a){return!(!a||1!==a.nodeType&&9!==a.nodeType&&11!==a.nodeType&&(8!==a.nodeType||" react-mount-point-unstable "!==a.nodeValue))}function Yi(){}function Zk(a,b,c,d,e){if(e){if("function"===typeof d){var f=d;d=function(){var a=Td(g);f.call(a)}}var g=Wi(b,d,a,0,null,!1,!1,"",Yi);a._reactRootContainer=g;a[Ja]=g.current;sc(8===a.nodeType?a.parentNode:a);yb();return g}for(;e=a.lastChild;)a.removeChild(e);if("function"===typeof d){var h=d;d=function(){var a=Td(k);
|
||||
h.call(a)}}var k=Vf(a,0,!1,null,null,!1,!1,"",Yi);a._reactRootContainer=k;a[Ja]=k.current;sc(8===a.nodeType?a.parentNode:a);yb(function(){Sd(b,k,c,d)});return k}function Wd(a,b,c,d,e){var f=c._reactRootContainer;if(f){var g=f;if("function"===typeof e){var h=e;e=function(){var a=Td(g);h.call(a)}}Sd(b,g,a,e)}else g=Zk(c,b,a,e,d);return Td(g)}var cg=new Set,$b={},Ia=!("undefined"===typeof window||"undefined"===typeof window.document||"undefined"===typeof window.document.createElement),Zd=Object.prototype.hasOwnProperty,
|
||||
cj=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,eg={},dg={},R={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(a){R[a]=
|
||||
new Y(a,0,!1,a,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(a){var b=a[0];R[b]=new Y(b,1,!1,a[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(a){R[a]=new Y(a,2,!1,a.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(a){R[a]=new Y(a,2,!1,a,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(a){R[a]=
|
||||
new Y(a,3,!1,a.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(a){R[a]=new Y(a,3,!0,a,null,!1,!1)});["capture","download"].forEach(function(a){R[a]=new Y(a,4,!1,a,null,!1,!1)});["cols","rows","size","span"].forEach(function(a){R[a]=new Y(a,6,!1,a,null,!1,!1)});["rowSpan","start"].forEach(function(a){R[a]=new Y(a,5,!1,a.toLowerCase(),null,!1,!1)});var Zf=/[\-:]([a-z])/g,$f=function(a){return a[1].toUpperCase()};"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(a){var b=
|
||||
a.replace(Zf,$f);R[b]=new Y(b,1,!1,a,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(a){var b=a.replace(Zf,$f);R[b]=new Y(b,1,!1,a,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(a){var b=a.replace(Zf,$f);R[b]=new Y(b,1,!1,a,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(a){R[a]=new Y(a,1,!1,a.toLowerCase(),null,!1,!1)});R.xlinkHref=new Y("xlinkHref",
|
||||
1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(a){R[a]=new Y(a,1,!1,a.toLowerCase(),null,!0,!0)});var Sa=zb.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED,sd=Symbol.for("react.element"),Cb=Symbol.for("react.portal"),Bb=Symbol.for("react.fragment"),fe=Symbol.for("react.strict_mode"),ee=Symbol.for("react.profiler"),hg=Symbol.for("react.provider"),gg=Symbol.for("react.context"),ie=Symbol.for("react.forward_ref"),ge=Symbol.for("react.suspense"),
|
||||
he=Symbol.for("react.suspense_list"),je=Symbol.for("react.memo"),Ta=Symbol.for("react.lazy");Symbol.for("react.scope");Symbol.for("react.debug_trace_mode");var Ui=Symbol.for("react.offscreen");Symbol.for("react.legacy_hidden");Symbol.for("react.cache");Symbol.for("react.tracing_marker");var fg=Symbol.iterator,E=Object.assign,ae,ce=!1,cc=Array.isArray,Xd,yi=function(a){return"undefined"!==typeof MSApp&&MSApp.execUnsafeLocalFunction?function(b,c,d,e){MSApp.execUnsafeLocalFunction(function(){return a(b,
|
||||
c,d,e)})}:a}(function(a,b){if("http://www.w3.org/2000/svg"!==a.namespaceURI||"innerHTML"in a)a.innerHTML=b;else{Xd=Xd||document.createElement("div");Xd.innerHTML="<svg>"+b.valueOf().toString()+"</svg>";for(b=Xd.firstChild;a.firstChild;)a.removeChild(a.firstChild);for(;b.firstChild;)a.appendChild(b.firstChild)}}),Fc=function(a,b){if(b){var c=a.firstChild;if(c&&c===a.lastChild&&3===c.nodeType){c.nodeValue=b;return}}a.textContent=b},dc={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,
|
||||
borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,
|
||||
strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},$k=["Webkit","ms","Moz","O"];Object.keys(dc).forEach(function(a){$k.forEach(function(b){b=b+a.charAt(0).toUpperCase()+a.substring(1);dc[b]=dc[a]})});var ij=E({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0}),ze=null,se=null,Eb=null,Fb=null,xg=function(a,b){return a(b)},yg=function(){},te=!1,Oe=!1;if(Ia)try{var Lc={};Object.defineProperty(Lc,
|
||||
"passive",{get:function(){Oe=!0}});window.addEventListener("test",Lc,Lc);window.removeEventListener("test",Lc,Lc)}catch(a){Oe=!1}var kj=function(a,b,c,d,e,f,g,h,k){var l=Array.prototype.slice.call(arguments,3);try{b.apply(c,l)}catch(q){this.onError(q)}},gc=!1,Sc=null,Tc=!1,ue=null,lj={onError:function(a){gc=!0;Sc=a}},Ba=zb.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.Scheduler,Jg=Ba.unstable_scheduleCallback,Kg=Ba.unstable_NormalPriority,xh=Jg,Ki=Ba.unstable_cancelCallback,Pk=Ba.unstable_shouldYield,
|
||||
Sk=Ba.unstable_requestPaint,P=Ba.unstable_now,Dj=Ba.unstable_getCurrentPriorityLevel,De=Ba.unstable_ImmediatePriority,Mg=Ba.unstable_UserBlockingPriority,ad=Kg,Ej=Ba.unstable_LowPriority,Ng=Ba.unstable_IdlePriority,Uc=null,Ca=null,ta=Math.clz32?Math.clz32:pj,qj=Math.log,rj=Math.LN2,Wc=64,Rd=4194304,z=0,Ae=!1,Yc=[],Va=null,Wa=null,Xa=null,jc=new Map,kc=new Map,Ya=[],Bj="mousedown mouseup touchcancel touchend touchstart auxclick dblclick pointercancel pointerdown pointerup dragend dragstart drop compositionend compositionstart keydown keypress keyup input textInput copy cut paste click change contextmenu reset submit".split(" "),
|
||||
Gb=Sa.ReactCurrentBatchConfig,Zc=!0,$c=null,Za=null,Ee=null,bd=null,Yb={eventPhase:0,bubbles:0,cancelable:0,timeStamp:function(a){return a.timeStamp||Date.now()},defaultPrevented:0,isTrusted:0},He=ka(Yb),Mc=E({},Yb,{view:0,detail:0}),ak=ka(Mc),ag,bg,Nc,Yd=E({},Mc,{screenX:0,screenY:0,clientX:0,clientY:0,pageX:0,pageY:0,ctrlKey:0,shiftKey:0,altKey:0,metaKey:0,getModifierState:Fe,button:0,buttons:0,relatedTarget:function(a){return void 0===a.relatedTarget?a.fromElement===a.srcElement?a.toElement:a.fromElement:
|
||||
a.relatedTarget},movementX:function(a){if("movementX"in a)return a.movementX;a!==Nc&&(Nc&&"mousemove"===a.type?(ag=a.screenX-Nc.screenX,bg=a.screenY-Nc.screenY):bg=ag=0,Nc=a);return ag},movementY:function(a){return"movementY"in a?a.movementY:bg}}),ih=ka(Yd),al=E({},Yd,{dataTransfer:0}),Wj=ka(al),bl=E({},Mc,{relatedTarget:0}),Pe=ka(bl),cl=E({},Yb,{animationName:0,elapsedTime:0,pseudoElement:0}),Yj=ka(cl),dl=E({},Yb,{clipboardData:function(a){return"clipboardData"in a?a.clipboardData:window.clipboardData}}),
|
||||
ck=ka(dl),el=E({},Yb,{data:0}),qh=ka(el),fk=qh,fl={Esc:"Escape",Spacebar:" ",Left:"ArrowLeft",Up:"ArrowUp",Right:"ArrowRight",Down:"ArrowDown",Del:"Delete",Win:"OS",Menu:"ContextMenu",Apps:"ContextMenu",Scroll:"ScrollLock",MozPrintableKey:"Unidentified"},gl={8:"Backspace",9:"Tab",12:"Clear",13:"Enter",16:"Shift",17:"Control",18:"Alt",19:"Pause",20:"CapsLock",27:"Escape",32:" ",33:"PageUp",34:"PageDown",35:"End",36:"Home",37:"ArrowLeft",38:"ArrowUp",39:"ArrowRight",40:"ArrowDown",45:"Insert",46:"Delete",
|
||||
112:"F1",113:"F2",114:"F3",115:"F4",116:"F5",117:"F6",118:"F7",119:"F8",120:"F9",121:"F10",122:"F11",123:"F12",144:"NumLock",145:"ScrollLock",224:"Meta"},Gj={Alt:"altKey",Control:"ctrlKey",Meta:"metaKey",Shift:"shiftKey"},hl=E({},Mc,{key:function(a){if(a.key){var b=fl[a.key]||a.key;if("Unidentified"!==b)return b}return"keypress"===a.type?(a=cd(a),13===a?"Enter":String.fromCharCode(a)):"keydown"===a.type||"keyup"===a.type?gl[a.keyCode]||"Unidentified":""},code:0,location:0,ctrlKey:0,shiftKey:0,altKey:0,
|
||||
metaKey:0,repeat:0,locale:0,getModifierState:Fe,charCode:function(a){return"keypress"===a.type?cd(a):0},keyCode:function(a){return"keydown"===a.type||"keyup"===a.type?a.keyCode:0},which:function(a){return"keypress"===a.type?cd(a):"keydown"===a.type||"keyup"===a.type?a.keyCode:0}}),Vj=ka(hl),il=E({},Yd,{pointerId:0,width:0,height:0,pressure:0,tangentialPressure:0,tiltX:0,tiltY:0,twist:0,pointerType:0,isPrimary:0}),nh=ka(il),jl=E({},Mc,{touches:0,targetTouches:0,changedTouches:0,altKey:0,metaKey:0,
|
||||
ctrlKey:0,shiftKey:0,getModifierState:Fe}),Xj=ka(jl),kl=E({},Yb,{propertyName:0,elapsedTime:0,pseudoElement:0}),Zj=ka(kl),ll=E({},Yd,{deltaX:function(a){return"deltaX"in a?a.deltaX:"wheelDeltaX"in a?-a.wheelDeltaX:0},deltaY:function(a){return"deltaY"in a?a.deltaY:"wheelDeltaY"in a?-a.wheelDeltaY:"wheelDelta"in a?-a.wheelDelta:0},deltaZ:0,deltaMode:0}),bk=ka(ll),Hj=[9,13,27,32],Ge=Ia&&"CompositionEvent"in window,Oc=null;Ia&&"documentMode"in document&&(Oc=document.documentMode);var ek=Ia&&"TextEvent"in
|
||||
window&&!Oc,Ug=Ia&&(!Ge||Oc&&8<Oc&&11>=Oc),Tg=String.fromCharCode(32),Sg=!1,Hb=!1,Kj={color:!0,date:!0,datetime:!0,"datetime-local":!0,email:!0,month:!0,number:!0,password:!0,range:!0,search:!0,tel:!0,text:!0,time:!0,url:!0,week:!0},oc=null,pc=null,ph=!1;Ia&&(ph=Lj("input")&&(!document.documentMode||9<document.documentMode));var ua="function"===typeof Object.is?Object.is:Sj,dk=Ia&&"documentMode"in document&&11>=document.documentMode,Jb=null,Ke=null,rc=null,Je=!1,Kb={animationend:gd("Animation","AnimationEnd"),
|
||||
animationiteration:gd("Animation","AnimationIteration"),animationstart:gd("Animation","AnimationStart"),transitionend:gd("Transition","TransitionEnd")},Le={},eh={};Ia&&(eh=document.createElement("div").style,"AnimationEvent"in window||(delete Kb.animationend.animation,delete Kb.animationiteration.animation,delete Kb.animationstart.animation),"TransitionEvent"in window||delete Kb.transitionend.transition);var jh=hd("animationend"),kh=hd("animationiteration"),lh=hd("animationstart"),mh=hd("transitionend"),
|
||||
fh=new Map,Zi="abort auxClick cancel canPlay canPlayThrough click close contextMenu copy cut drag dragEnd dragEnter dragExit dragLeave dragOver dragStart drop durationChange emptied encrypted ended error gotPointerCapture input invalid keyDown keyPress keyUp load loadedData loadedMetadata loadStart lostPointerCapture mouseDown mouseMove mouseOut mouseOver mouseUp paste pause play playing pointerCancel pointerDown pointerMove pointerOut pointerOver pointerUp progress rateChange reset resize seeked seeking stalled submit suspend timeUpdate touchCancel touchEnd touchStart volumeChange scroll toggle touchMove waiting wheel".split(" ");
|
||||
(function(){for(var a=0;a<Zi.length;a++){var b=Zi[a],c=b.toLowerCase();b=b[0].toUpperCase()+b.slice(1);$a(c,"on"+b)}$a(jh,"onAnimationEnd");$a(kh,"onAnimationIteration");$a(lh,"onAnimationStart");$a("dblclick","onDoubleClick");$a("focusin","onFocus");$a("focusout","onBlur");$a(mh,"onTransitionEnd")})();Ab("onMouseEnter",["mouseout","mouseover"]);Ab("onMouseLeave",["mouseout","mouseover"]);Ab("onPointerEnter",["pointerout","pointerover"]);Ab("onPointerLeave",["pointerout","pointerover"]);mb("onChange",
|
||||
"change click focusin focusout input keydown keyup selectionchange".split(" "));mb("onSelect","focusout contextmenu dragend focusin keydown keyup mousedown mouseup selectionchange".split(" "));mb("onBeforeInput",["compositionend","keypress","textInput","paste"]);mb("onCompositionEnd","compositionend focusout keydown keypress keyup mousedown".split(" "));mb("onCompositionStart","compositionstart focusout keydown keypress keyup mousedown".split(" "));mb("onCompositionUpdate","compositionupdate focusout keydown keypress keyup mousedown".split(" "));
|
||||
var Ec="abort canplay canplaythrough durationchange emptied encrypted ended error loadeddata loadedmetadata loadstart pause play playing progress ratechange resize seeked seeking stalled suspend timeupdate volumechange waiting".split(" "),Uj=new Set("cancel close invalid load scroll toggle".split(" ").concat(Ec)),id="_reactListening"+Math.random().toString(36).slice(2),gk=/\r\n?/g,hk=/\u0000|\uFFFD/g,Jf=null,Kf=null,Rf="function"===typeof setTimeout?setTimeout:void 0,Nk="function"===typeof clearTimeout?
|
||||
clearTimeout:void 0,$i="function"===typeof Promise?Promise:void 0,Jk="function"===typeof queueMicrotask?queueMicrotask:"undefined"!==typeof $i?function(a){return $i.resolve(null).then(a).catch(ik)}:Rf,Zb=Math.random().toString(36).slice(2),Da="__reactFiber$"+Zb,uc="__reactProps$"+Zb,Ja="__reactContainer$"+Zb,Me="__reactEvents$"+Zb,Dk="__reactListeners$"+Zb,Ek="__reactHandles$"+Zb,Se=[],Mb=-1,cb={},J=bb(cb),S=bb(!1),pb=cb,La=null,md=!1,Te=!1,Ob=[],Pb=0,od=null,nd=0,na=[],oa=0,rb=null,Ma=1,Na="",la=
|
||||
null,fa=null,D=!1,wa=null,Ik=Sa.ReactCurrentBatchConfig,Vb=Dh(!0),li=Dh(!1),ud=bb(null),td=null,Rb=null,bf=null,tb=null,kk=Oa,gb=!1,wc={},Ea=bb(wc),yc=bb(wc),xc=bb(wc),F=bb(0),kf=[],yd=Sa.ReactCurrentDispatcher,sf=Sa.ReactCurrentBatchConfig,vb=0,C=null,K=null,N=null,Ad=!1,zc=!1,Ac=0,ml=0,zd={readContext:qa,useCallback:V,useContext:V,useEffect:V,useImperativeHandle:V,useInsertionEffect:V,useLayoutEffect:V,useMemo:V,useReducer:V,useRef:V,useState:V,useDebugValue:V,useDeferredValue:V,useTransition:V,
|
||||
useMutableSource:V,useSyncExternalStore:V,useId:V,unstable_isNewReconciler:!1},lk={readContext:qa,useCallback:function(a,b){Fa().memoizedState=[a,void 0===b?null:b];return a},useContext:qa,useEffect:Sh,useImperativeHandle:function(a,b,c){c=null!==c&&void 0!==c?c.concat([a]):null;return Bd(4194308,4,Vh.bind(null,b,a),c)},useLayoutEffect:function(a,b){return Bd(4194308,4,a,b)},useInsertionEffect:function(a,b){return Bd(4,2,a,b)},useMemo:function(a,b){var c=Fa();b=void 0===b?null:b;a=a();c.memoizedState=
|
||||
[a,b];return a},useReducer:function(a,b,c){var d=Fa();b=void 0!==c?c(b):b;d.memoizedState=d.baseState=b;a={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:a,lastRenderedState:b};d.queue=a;a=a.dispatch=qk.bind(null,C,a);return[d.memoizedState,a]},useRef:function(a){var b=Fa();a={current:a};return b.memoizedState=a},useState:Qh,useDebugValue:rf,useDeferredValue:function(a){return Fa().memoizedState=a},useTransition:function(){var a=Qh(!1),b=a[0];a=pk.bind(null,a[1]);Fa().memoizedState=
|
||||
a;return[b,a]},useMutableSource:function(a,b,c){},useSyncExternalStore:function(a,b,c){var d=C,e=Fa();if(D){if(void 0===c)throw Error(m(407));c=c()}else{c=b();if(null===O)throw Error(m(349));0!==(vb&30)||Nh(d,b,c)}e.memoizedState=c;var f={value:c,getSnapshot:b};e.queue=f;Sh(Lh.bind(null,d,f,a),[a]);d.flags|=2048;Cc(9,Mh.bind(null,d,f,c,b),void 0,null);return c},useId:function(){var a=Fa(),b=O.identifierPrefix;if(D){var c=Na;var d=Ma;c=(d&~(1<<32-ta(d)-1)).toString(32)+c;b=":"+b+"R"+c;c=Ac++;0<c&&
|
||||
(b+="H"+c.toString(32));b+=":"}else c=ml++,b=":"+b+"r"+c.toString(32)+":";return a.memoizedState=b},unstable_isNewReconciler:!1},mk={readContext:qa,useCallback:Xh,useContext:qa,useEffect:qf,useImperativeHandle:Wh,useInsertionEffect:Th,useLayoutEffect:Uh,useMemo:Yh,useReducer:of,useRef:Rh,useState:function(a){return of(Bc)},useDebugValue:rf,useDeferredValue:function(a){var b=sa();return Zh(b,K.memoizedState,a)},useTransition:function(){var a=of(Bc)[0],b=sa().memoizedState;return[a,b]},useMutableSource:Jh,
|
||||
useSyncExternalStore:Kh,useId:$h,unstable_isNewReconciler:!1},nk={readContext:qa,useCallback:Xh,useContext:qa,useEffect:qf,useImperativeHandle:Wh,useInsertionEffect:Th,useLayoutEffect:Uh,useMemo:Yh,useReducer:pf,useRef:Rh,useState:function(a){return pf(Bc)},useDebugValue:rf,useDeferredValue:function(a){var b=sa();return null===K?b.memoizedState=a:Zh(b,K.memoizedState,a)},useTransition:function(){var a=pf(Bc)[0],b=sa().memoizedState;return[a,b]},useMutableSource:Jh,useSyncExternalStore:Kh,useId:$h,
|
||||
unstable_isNewReconciler:!1},Dd={isMounted:function(a){return(a=a._reactInternals)?nb(a)===a:!1},enqueueSetState:function(a,b,c){a=a._reactInternals;var d=Z(),e=hb(a),f=Pa(d,e);f.payload=b;void 0!==c&&null!==c&&(f.callback=c);b=fb(a,f,e);null!==b&&(xa(b,a,e,d),vd(b,a,e))},enqueueReplaceState:function(a,b,c){a=a._reactInternals;var d=Z(),e=hb(a),f=Pa(d,e);f.tag=1;f.payload=b;void 0!==c&&null!==c&&(f.callback=c);b=fb(a,f,e);null!==b&&(xa(b,a,e,d),vd(b,a,e))},enqueueForceUpdate:function(a,b){a=a._reactInternals;
|
||||
var c=Z(),d=hb(a),e=Pa(c,d);e.tag=2;void 0!==b&&null!==b&&(e.callback=b);b=fb(a,e,d);null!==b&&(xa(b,a,d,c),vd(b,a,d))}},rk="function"===typeof WeakMap?WeakMap:Map,tk=Sa.ReactCurrentOwner,ha=!1,Cf={dehydrated:null,treeContext:null,retryLane:0};var zk=function(a,b,c,d){for(c=b.child;null!==c;){if(5===c.tag||6===c.tag)a.appendChild(c.stateNode);else if(4!==c.tag&&null!==c.child){c.child.return=c;c=c.child;continue}if(c===b)break;for(;null===c.sibling;){if(null===c.return||c.return===b)return;c=c.return}c.sibling.return=
|
||||
c.return;c=c.sibling}};var xi=function(a,b){};var yk=function(a,b,c,d,e){var f=a.memoizedProps;if(f!==d){a=b.stateNode;ub(Ea.current);e=null;switch(c){case "input":f=ke(a,f);d=ke(a,d);e=[];break;case "select":f=E({},f,{value:void 0});d=E({},d,{value:void 0});e=[];break;case "textarea":f=ne(a,f);d=ne(a,d);e=[];break;default:"function"!==typeof f.onClick&&"function"===typeof d.onClick&&(a.onclick=kd)}pe(c,d);var g;c=null;for(l in f)if(!d.hasOwnProperty(l)&&f.hasOwnProperty(l)&&null!=f[l])if("style"===
|
||||
l){var h=f[l];for(g in h)h.hasOwnProperty(g)&&(c||(c={}),c[g]="")}else"dangerouslySetInnerHTML"!==l&&"children"!==l&&"suppressContentEditableWarning"!==l&&"suppressHydrationWarning"!==l&&"autoFocus"!==l&&($b.hasOwnProperty(l)?e||(e=[]):(e=e||[]).push(l,null));for(l in d){var k=d[l];h=null!=f?f[l]:void 0;if(d.hasOwnProperty(l)&&k!==h&&(null!=k||null!=h))if("style"===l)if(h){for(g in h)!h.hasOwnProperty(g)||k&&k.hasOwnProperty(g)||(c||(c={}),c[g]="");for(g in k)k.hasOwnProperty(g)&&h[g]!==k[g]&&(c||
|
||||
(c={}),c[g]=k[g])}else c||(e||(e=[]),e.push(l,c)),c=k;else"dangerouslySetInnerHTML"===l?(k=k?k.__html:void 0,h=h?h.__html:void 0,null!=k&&h!==k&&(e=e||[]).push(l,k)):"children"===l?"string"!==typeof k&&"number"!==typeof k||(e=e||[]).push(l,""+k):"suppressContentEditableWarning"!==l&&"suppressHydrationWarning"!==l&&($b.hasOwnProperty(l)?(null!=k&&"onScroll"===l&&B("scroll",a),e||h===k||(e=[])):(e=e||[]).push(l,k))}c&&(e=e||[]).push("style",c);var l=e;if(b.updateQueue=l)b.flags|=4}};var Ak=function(a,
|
||||
b,c,d){c!==d&&(b.flags|=4)};var Jd=!1,X=!1,Fk="function"===typeof WeakSet?WeakSet:Set,l=null,zi=!1,T=null,za=!1,Mk=Math.ceil,Od=Sa.ReactCurrentDispatcher,Uf=Sa.ReactCurrentOwner,ca=Sa.ReactCurrentBatchConfig,p=0,O=null,H=null,U=0,ba=0,Ga=bb(0),L=0,Jc=null,ra=0,Md=0,Sf=0,Kc=null,ja=null,Of=0,Hf=Infinity,Ra=null,Ed=!1,xf=null,ib=null,Pd=!1,lb=null,Qd=0,Ic=0,Pf=null,Kd=-1,Ld=0;var Qk=function(a,b,c){if(null!==a)if(a.memoizedProps!==b.pendingProps||S.current)ha=!0;else{if(0===(a.lanes&c)&&0===(b.flags&
|
||||
128))return ha=!1,wk(a,b,c);ha=0!==(a.flags&131072)?!0:!1}else ha=!1,D&&0!==(b.flags&1048576)&&yh(b,nd,b.index);b.lanes=0;switch(b.tag){case 2:var d=b.type;Fd(a,b);a=b.pendingProps;var e=Nb(b,J.current);Sb(b,c);e=mf(null,b,d,a,e,c);var f=nf();b.flags|=1;"object"===typeof e&&null!==e&&"function"===typeof e.render&&void 0===e.$$typeof?(b.tag=1,b.memoizedState=null,b.updateQueue=null,ea(d)?(f=!0,ld(b)):f=!1,b.memoizedState=null!==e.state&&void 0!==e.state?e.state:null,ff(b),e.updater=Dd,b.stateNode=
|
||||
e,e._reactInternals=b,uf(b,d,a,c),b=Af(null,b,d,!0,f,c)):(b.tag=0,D&&f&&Ue(b),aa(null,b,e,c),b=b.child);return b;case 16:d=b.elementType;a:{Fd(a,b);a=b.pendingProps;e=d._init;d=e(d._payload);b.type=d;e=b.tag=Uk(d);a=ya(d,a);switch(e){case 0:b=zf(null,b,d,a,c);break a;case 1:b=ri(null,b,d,a,c);break a;case 11:b=mi(null,b,d,a,c);break a;case 14:b=ni(null,b,d,ya(d.type,a),c);break a}throw Error(m(306,d,""));}return b;case 0:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:ya(d,e),zf(a,b,d,e,c);
|
||||
case 1:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:ya(d,e),ri(a,b,d,e,c);case 3:a:{si(b);if(null===a)throw Error(m(387));d=b.pendingProps;f=b.memoizedState;e=f.element;Fh(a,b);wd(b,d,null,c);var g=b.memoizedState;d=g.element;if(f.isDehydrated)if(f={element:d,isDehydrated:!1,cache:g.cache,pendingSuspenseBoundaries:g.pendingSuspenseBoundaries,transitions:g.transitions},b.updateQueue.baseState=f,b.memoizedState=f,b.flags&256){e=Ub(Error(m(423)),b);b=ti(a,b,d,c,e);break a}else if(d!==e){e=
|
||||
Ub(Error(m(424)),b);b=ti(a,b,d,c,e);break a}else for(fa=Ka(b.stateNode.containerInfo.firstChild),la=b,D=!0,wa=null,c=li(b,null,d,c),b.child=c;c;)c.flags=c.flags&-3|4096,c=c.sibling;else{Qb();if(d===e){b=Qa(a,b,c);break a}aa(a,b,d,c)}b=b.child}return b;case 5:return Ih(b),null===a&&Xe(b),d=b.type,e=b.pendingProps,f=null!==a?a.memoizedProps:null,g=e.children,Qe(d,e)?g=null:null!==f&&Qe(d,f)&&(b.flags|=32),qi(a,b),aa(a,b,g,c),b.child;case 6:return null===a&&Xe(b),null;case 13:return ui(a,b,c);case 4:return gf(b,
|
||||
b.stateNode.containerInfo),d=b.pendingProps,null===a?b.child=Vb(b,null,d,c):aa(a,b,d,c),b.child;case 11:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:ya(d,e),mi(a,b,d,e,c);case 7:return aa(a,b,b.pendingProps,c),b.child;case 8:return aa(a,b,b.pendingProps.children,c),b.child;case 12:return aa(a,b,b.pendingProps.children,c),b.child;case 10:a:{d=b.type._context;e=b.pendingProps;f=b.memoizedProps;g=e.value;y(ud,d._currentValue);d._currentValue=g;if(null!==f)if(ua(f.value,g)){if(f.children===
|
||||
e.children&&!S.current){b=Qa(a,b,c);break a}}else for(f=b.child,null!==f&&(f.return=b);null!==f;){var h=f.dependencies;if(null!==h){g=f.child;for(var k=h.firstContext;null!==k;){if(k.context===d){if(1===f.tag){k=Pa(-1,c&-c);k.tag=2;var l=f.updateQueue;if(null!==l){l=l.shared;var p=l.pending;null===p?k.next=k:(k.next=p.next,p.next=k);l.pending=k}}f.lanes|=c;k=f.alternate;null!==k&&(k.lanes|=c);df(f.return,c,b);h.lanes|=c;break}k=k.next}}else if(10===f.tag)g=f.type===b.type?null:f.child;else if(18===
|
||||
f.tag){g=f.return;if(null===g)throw Error(m(341));g.lanes|=c;h=g.alternate;null!==h&&(h.lanes|=c);df(g,c,b);g=f.sibling}else g=f.child;if(null!==g)g.return=f;else for(g=f;null!==g;){if(g===b){g=null;break}f=g.sibling;if(null!==f){f.return=g.return;g=f;break}g=g.return}f=g}aa(a,b,e.children,c);b=b.child}return b;case 9:return e=b.type,d=b.pendingProps.children,Sb(b,c),e=qa(e),d=d(e),b.flags|=1,aa(a,b,d,c),b.child;case 14:return d=b.type,e=ya(d,b.pendingProps),e=ya(d.type,e),ni(a,b,d,e,c);case 15:return oi(a,
|
||||
b,b.type,b.pendingProps,c);case 17:return d=b.type,e=b.pendingProps,e=b.elementType===d?e:ya(d,e),Fd(a,b),b.tag=1,ea(d)?(a=!0,ld(b)):a=!1,Sb(b,c),ei(b,d,e),uf(b,d,e,c),Af(null,b,d,!0,a,c);case 19:return wi(a,b,c);case 22:return pi(a,b,c)}throw Error(m(156,b.tag));};var pa=function(a,b,c,d){return new Tk(a,b,c,d)},aj="function"===typeof reportError?reportError:function(a){console.error(a)};Ud.prototype.render=Xf.prototype.render=function(a){var b=this._internalRoot;if(null===b)throw Error(m(409));
|
||||
Sd(a,b,null,null)};Ud.prototype.unmount=Xf.prototype.unmount=function(){var a=this._internalRoot;if(null!==a){this._internalRoot=null;var b=a.containerInfo;yb(function(){Sd(null,a,null,null)});b[Ja]=null}};Ud.prototype.unstable_scheduleHydration=function(a){if(a){var b=nl();a={blockedOn:null,target:a,priority:b};for(var c=0;c<Ya.length&&0!==b&&b<Ya[c].priority;c++);Ya.splice(c,0,a);0===c&&Hg(a)}};var Cj=function(a){switch(a.tag){case 3:var b=a.stateNode;if(b.current.memoizedState.isDehydrated){var c=
|
||||
hc(b.pendingLanes);0!==c&&(xe(b,c|1),ia(b,P()),0===(p&6)&&(Hc(),db()))}break;case 13:yb(function(){var b=Oa(a,1);if(null!==b){var c=Z();xa(b,a,1,c)}}),Wf(a,1)}};var Gg=function(a){if(13===a.tag){var b=Oa(a,134217728);if(null!==b){var c=Z();xa(b,a,134217728,c)}Wf(a,134217728)}};var xj=function(a){if(13===a.tag){var b=hb(a),c=Oa(a,b);if(null!==c){var d=Z();xa(c,a,b,d)}Wf(a,b)}};var nl=function(){return z};var wj=function(a,b){var c=z;try{return z=a,b()}finally{z=c}};se=function(a,b,c){switch(b){case "input":le(a,
|
||||
c);b=c.name;if("radio"===c.type&&null!=b){for(c=a;c.parentNode;)c=c.parentNode;c=c.querySelectorAll("input[name="+JSON.stringify(""+b)+'][type="radio"]');for(b=0;b<c.length;b++){var d=c[b];if(d!==a&&d.form===a.form){var e=Rc(d);if(!e)throw Error(m(90));jg(d);le(d,e)}}}break;case "textarea":og(a,c);break;case "select":b=c.value,null!=b&&Db(a,!!c.multiple,b,!1)}};(function(a,b,c){xg=a;yg=c})(Tf,function(a,b,c,d,e){var f=z,g=ca.transition;try{return ca.transition=null,z=1,a(b,c,d,e)}finally{z=f,ca.transition=
|
||||
g,0===p&&Hc()}},yb);var ol={usingClientEntryPoint:!1,Events:[ec,Ib,Rc,ug,vg,Tf]};(function(a){a={bundleType:a.bundleType,version:a.version,rendererPackageName:a.rendererPackageName,rendererConfig:a.rendererConfig,overrideHookState:null,overrideHookStateDeletePath:null,overrideHookStateRenamePath:null,overrideProps:null,overridePropsDeletePath:null,overridePropsRenamePath:null,setErrorHandler:null,setSuspenseHandler:null,scheduleUpdate:null,currentDispatcherRef:Sa.ReactCurrentDispatcher,findHostInstanceByFiber:Xk,
|
||||
findFiberByHostInstance:a.findFiberByHostInstance||Yk,findHostInstancesForRefresh:null,scheduleRefresh:null,scheduleRoot:null,setRefreshHandler:null,getCurrentFiber:null,reconcilerVersion:"18.3.1"};if("undefined"===typeof __REACT_DEVTOOLS_GLOBAL_HOOK__)a=!1;else{var b=__REACT_DEVTOOLS_GLOBAL_HOOK__;if(b.isDisabled||!b.supportsFiber)a=!0;else{try{Uc=b.inject(a),Ca=b}catch(c){}a=b.checkDCE?!0:!1}}return a})({findFiberByHostInstance:ob,bundleType:0,version:"18.3.1-next-f1338f8080-20240426",
|
||||
rendererPackageName:"react-dom"});Q.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED=ol;Q.createPortal=function(a,b){var c=2<arguments.length&&void 0!==arguments[2]?arguments[2]:null;if(!Yf(b))throw Error(m(200));return Wk(a,b,null,c)};Q.createRoot=function(a,b){if(!Yf(a))throw Error(m(299));var c=!1,d="",e=aj;null!==b&&void 0!==b&&(!0===b.unstable_strictMode&&(c=!0),void 0!==b.identifierPrefix&&(d=b.identifierPrefix),void 0!==b.onRecoverableError&&(e=b.onRecoverableError));b=Vf(a,1,!1,null,null,
|
||||
c,!1,d,e);a[Ja]=b.current;sc(8===a.nodeType?a.parentNode:a);return new Xf(b)};Q.findDOMNode=function(a){if(null==a)return null;if(1===a.nodeType)return a;var b=a._reactInternals;if(void 0===b){if("function"===typeof a.render)throw Error(m(188));a=Object.keys(a).join(",");throw Error(m(268,a));}a=Bg(b);a=null===a?null:a.stateNode;return a};Q.flushSync=function(a){return yb(a)};Q.hydrate=function(a,b,c){if(!Vd(b))throw Error(m(200));return Wd(null,a,b,!0,c)};Q.hydrateRoot=function(a,b,c){if(!Yf(a))throw Error(m(405));
|
||||
var d=null!=c&&c.hydratedSources||null,e=!1,f="",g=aj;null!==c&&void 0!==c&&(!0===c.unstable_strictMode&&(e=!0),void 0!==c.identifierPrefix&&(f=c.identifierPrefix),void 0!==c.onRecoverableError&&(g=c.onRecoverableError));b=Wi(b,null,a,1,null!=c?c:null,e,!1,f,g);a[Ja]=b.current;sc(a);if(d)for(a=0;a<d.length;a++)c=d[a],e=c._getVersion,e=e(c._source),null==b.mutableSourceEagerHydrationData?b.mutableSourceEagerHydrationData=[c,e]:b.mutableSourceEagerHydrationData.push(c,e);return new Ud(b)};Q.render=
|
||||
function(a,b,c){if(!Vd(b))throw Error(m(200));return Wd(null,a,b,!1,c)};Q.unmountComponentAtNode=function(a){if(!Vd(a))throw Error(m(40));return a._reactRootContainer?(yb(function(){Wd(null,null,a,!1,function(){a._reactRootContainer=null;a[Ja]=null})}),!0):!1};Q.unstable_batchedUpdates=Tf;Q.unstable_renderSubtreeIntoContainer=function(a,b,c,d){if(!Vd(c))throw Error(m(200));if(null==a||void 0===a._reactInternals)throw Error(m(38));return Wd(a,b,c,!1,d)};Q.version="18.3.1-next-f1338f8080-20240426"});
|
||||
})();
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @license React
|
||||
* react.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
(function(){'use strict';(function(c,x){"object"===typeof exports&&"undefined"!==typeof module?x(exports):"function"===typeof define&&define.amd?define(["exports"],x):(c=c||self,x(c.React={}))})(this,function(c){function x(a){if(null===a||"object"!==typeof a)return null;a=V&&a[V]||a["@@iterator"];return"function"===typeof a?a:null}function w(a,b,e){this.props=a;this.context=b;this.refs=W;this.updater=e||X}function Y(){}function K(a,b,e){this.props=a;this.context=b;this.refs=W;this.updater=e||X}function Z(a,b,
|
||||
e){var m,d={},c=null,h=null;if(null!=b)for(m in void 0!==b.ref&&(h=b.ref),void 0!==b.key&&(c=""+b.key),b)aa.call(b,m)&&!ba.hasOwnProperty(m)&&(d[m]=b[m]);var l=arguments.length-2;if(1===l)d.children=e;else if(1<l){for(var f=Array(l),k=0;k<l;k++)f[k]=arguments[k+2];d.children=f}if(a&&a.defaultProps)for(m in l=a.defaultProps,l)void 0===d[m]&&(d[m]=l[m]);return{$$typeof:y,type:a,key:c,ref:h,props:d,_owner:L.current}}function oa(a,b){return{$$typeof:y,type:a.type,key:b,ref:a.ref,props:a.props,_owner:a._owner}}
|
||||
function M(a){return"object"===typeof a&&null!==a&&a.$$typeof===y}function pa(a){var b={"=":"=0",":":"=2"};return"$"+a.replace(/[=:]/g,function(a){return b[a]})}function N(a,b){return"object"===typeof a&&null!==a&&null!=a.key?pa(""+a.key):b.toString(36)}function B(a,b,e,m,d){var c=typeof a;if("undefined"===c||"boolean"===c)a=null;var h=!1;if(null===a)h=!0;else switch(c){case "string":case "number":h=!0;break;case "object":switch(a.$$typeof){case y:case qa:h=!0}}if(h)return h=a,d=d(h),a=""===m?"."+
|
||||
N(h,0):m,ca(d)?(e="",null!=a&&(e=a.replace(da,"$&/")+"/"),B(d,b,e,"",function(a){return a})):null!=d&&(M(d)&&(d=oa(d,e+(!d.key||h&&h.key===d.key?"":(""+d.key).replace(da,"$&/")+"/")+a)),b.push(d)),1;h=0;m=""===m?".":m+":";if(ca(a))for(var l=0;l<a.length;l++){c=a[l];var f=m+N(c,l);h+=B(c,b,e,f,d)}else if(f=x(a),"function"===typeof f)for(a=f.call(a),l=0;!(c=a.next()).done;)c=c.value,f=m+N(c,l++),h+=B(c,b,e,f,d);else if("object"===c)throw b=String(a),Error("Objects are not valid as a React child (found: "+
|
||||
("[object Object]"===b?"object with keys {"+Object.keys(a).join(", ")+"}":b)+"). If you meant to render a collection of children, use an array instead.");return h}function C(a,b,e){if(null==a)return a;var c=[],d=0;B(a,c,"","",function(a){return b.call(e,a,d++)});return c}function ra(a){if(-1===a._status){var b=a._result;b=b();b.then(function(b){if(0===a._status||-1===a._status)a._status=1,a._result=b},function(b){if(0===a._status||-1===a._status)a._status=2,a._result=b});-1===a._status&&(a._status=
|
||||
0,a._result=b)}if(1===a._status)return a._result.default;throw a._result;}function O(a,b){var e=a.length;a.push(b);a:for(;0<e;){var c=e-1>>>1,d=a[c];if(0<D(d,b))a[c]=b,a[e]=d,e=c;else break a}}function p(a){return 0===a.length?null:a[0]}function E(a){if(0===a.length)return null;var b=a[0],e=a.pop();if(e!==b){a[0]=e;a:for(var c=0,d=a.length,k=d>>>1;c<k;){var h=2*(c+1)-1,l=a[h],f=h+1,g=a[f];if(0>D(l,e))f<d&&0>D(g,l)?(a[c]=g,a[f]=e,c=f):(a[c]=l,a[h]=e,c=h);else if(f<d&&0>D(g,e))a[c]=g,a[f]=e,c=f;else break a}}return b}
|
||||
function D(a,b){var c=a.sortIndex-b.sortIndex;return 0!==c?c:a.id-b.id}function P(a){for(var b=p(r);null!==b;){if(null===b.callback)E(r);else if(b.startTime<=a)E(r),b.sortIndex=b.expirationTime,O(q,b);else break;b=p(r)}}function Q(a){z=!1;P(a);if(!u)if(null!==p(q))u=!0,R(S);else{var b=p(r);null!==b&&T(Q,b.startTime-a)}}function S(a,b){u=!1;z&&(z=!1,ea(A),A=-1);F=!0;var c=k;try{P(b);for(n=p(q);null!==n&&(!(n.expirationTime>b)||a&&!fa());){var m=n.callback;if("function"===typeof m){n.callback=null;
|
||||
k=n.priorityLevel;var d=m(n.expirationTime<=b);b=v();"function"===typeof d?n.callback=d:n===p(q)&&E(q);P(b)}else E(q);n=p(q)}if(null!==n)var g=!0;else{var h=p(r);null!==h&&T(Q,h.startTime-b);g=!1}return g}finally{n=null,k=c,F=!1}}function fa(){return v()-ha<ia?!1:!0}function R(a){G=a;H||(H=!0,I())}function T(a,b){A=ja(function(){a(v())},b)}function ka(a){throw Error("act(...) is not supported in production builds of React.");}var y=Symbol.for("react.element"),qa=Symbol.for("react.portal"),sa=Symbol.for("react.fragment"),
|
||||
ta=Symbol.for("react.strict_mode"),ua=Symbol.for("react.profiler"),va=Symbol.for("react.provider"),wa=Symbol.for("react.context"),xa=Symbol.for("react.forward_ref"),ya=Symbol.for("react.suspense"),za=Symbol.for("react.memo"),Aa=Symbol.for("react.lazy"),V=Symbol.iterator,X={isMounted:function(a){return!1},enqueueForceUpdate:function(a,b,c){},enqueueReplaceState:function(a,b,c,m){},enqueueSetState:function(a,b,c,m){}},la=Object.assign,W={};w.prototype.isReactComponent={};w.prototype.setState=function(a,
|
||||
b){if("object"!==typeof a&&"function"!==typeof a&&null!=a)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,a,b,"setState")};w.prototype.forceUpdate=function(a){this.updater.enqueueForceUpdate(this,a,"forceUpdate")};Y.prototype=w.prototype;var t=K.prototype=new Y;t.constructor=K;la(t,w.prototype);t.isPureReactComponent=!0;var ca=Array.isArray,aa=Object.prototype.hasOwnProperty,L={current:null},
|
||||
ba={key:!0,ref:!0,__self:!0,__source:!0},da=/\/+/g,g={current:null},J={transition:null};if("object"===typeof performance&&"function"===typeof performance.now){var Ba=performance;var v=function(){return Ba.now()}}else{var ma=Date,Ca=ma.now();v=function(){return ma.now()-Ca}}var q=[],r=[],Da=1,n=null,k=3,F=!1,u=!1,z=!1,ja="function"===typeof setTimeout?setTimeout:null,ea="function"===typeof clearTimeout?clearTimeout:null,na="undefined"!==typeof setImmediate?setImmediate:null;"undefined"!==typeof navigator&&
|
||||
void 0!==navigator.scheduling&&void 0!==navigator.scheduling.isInputPending&&navigator.scheduling.isInputPending.bind(navigator.scheduling);var H=!1,G=null,A=-1,ia=5,ha=-1,U=function(){if(null!==G){var a=v();ha=a;var b=!0;try{b=G(!0,a)}finally{b?I():(H=!1,G=null)}}else H=!1};if("function"===typeof na)var I=function(){na(U)};else if("undefined"!==typeof MessageChannel){t=new MessageChannel;var Ea=t.port2;t.port1.onmessage=U;I=function(){Ea.postMessage(null)}}else I=function(){ja(U,0)};t={ReactCurrentDispatcher:g,
|
||||
ReactCurrentOwner:L,ReactCurrentBatchConfig:J,Scheduler:{__proto__:null,unstable_ImmediatePriority:1,unstable_UserBlockingPriority:2,unstable_NormalPriority:3,unstable_IdlePriority:5,unstable_LowPriority:4,unstable_runWithPriority:function(a,b){switch(a){case 1:case 2:case 3:case 4:case 5:break;default:a=3}var c=k;k=a;try{return b()}finally{k=c}},unstable_next:function(a){switch(k){case 1:case 2:case 3:var b=3;break;default:b=k}var c=k;k=b;try{return a()}finally{k=c}},unstable_scheduleCallback:function(a,
|
||||
b,c){var e=v();"object"===typeof c&&null!==c?(c=c.delay,c="number"===typeof c&&0<c?e+c:e):c=e;switch(a){case 1:var d=-1;break;case 2:d=250;break;case 5:d=1073741823;break;case 4:d=1E4;break;default:d=5E3}d=c+d;a={id:Da++,callback:b,priorityLevel:a,startTime:c,expirationTime:d,sortIndex:-1};c>e?(a.sortIndex=c,O(r,a),null===p(q)&&a===p(r)&&(z?(ea(A),A=-1):z=!0,T(Q,c-e))):(a.sortIndex=d,O(q,a),u||F||(u=!0,R(S)));return a},unstable_cancelCallback:function(a){a.callback=null},unstable_wrapCallback:function(a){var b=
|
||||
k;return function(){var c=k;k=b;try{return a.apply(this,arguments)}finally{k=c}}},unstable_getCurrentPriorityLevel:function(){return k},unstable_shouldYield:fa,unstable_requestPaint:function(){},unstable_continueExecution:function(){u||F||(u=!0,R(S))},unstable_pauseExecution:function(){},unstable_getFirstCallbackNode:function(){return p(q)},get unstable_now(){return v},unstable_forceFrameRate:function(a){0>a||125<a?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):
|
||||
ia=0<a?Math.floor(1E3/a):5},unstable_Profiling:null}};c.Children={map:C,forEach:function(a,b,c){C(a,function(){b.apply(this,arguments)},c)},count:function(a){var b=0;C(a,function(){b++});return b},toArray:function(a){return C(a,function(a){return a})||[]},only:function(a){if(!M(a))throw Error("React.Children.only expected to receive a single React element child.");return a}};c.Component=w;c.Fragment=sa;c.Profiler=ua;c.PureComponent=K;c.StrictMode=ta;c.Suspense=ya;c.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED=
|
||||
t;c.act=ka;c.cloneElement=function(a,b,c){if(null===a||void 0===a)throw Error("React.cloneElement(...): The argument must be a React element, but you passed "+a+".");var e=la({},a.props),d=a.key,k=a.ref,h=a._owner;if(null!=b){void 0!==b.ref&&(k=b.ref,h=L.current);void 0!==b.key&&(d=""+b.key);if(a.type&&a.type.defaultProps)var l=a.type.defaultProps;for(f in b)aa.call(b,f)&&!ba.hasOwnProperty(f)&&(e[f]=void 0===b[f]&&void 0!==l?l[f]:b[f])}var f=arguments.length-2;if(1===f)e.children=c;else if(1<f){l=
|
||||
Array(f);for(var g=0;g<f;g++)l[g]=arguments[g+2];e.children=l}return{$$typeof:y,type:a.type,key:d,ref:k,props:e,_owner:h}};c.createContext=function(a){a={$$typeof:wa,_currentValue:a,_currentValue2:a,_threadCount:0,Provider:null,Consumer:null,_defaultValue:null,_globalName:null};a.Provider={$$typeof:va,_context:a};return a.Consumer=a};c.createElement=Z;c.createFactory=function(a){var b=Z.bind(null,a);b.type=a;return b};c.createRef=function(){return{current:null}};c.forwardRef=function(a){return{$$typeof:xa,
|
||||
render:a}};c.isValidElement=M;c.lazy=function(a){return{$$typeof:Aa,_payload:{_status:-1,_result:a},_init:ra}};c.memo=function(a,b){return{$$typeof:za,type:a,compare:void 0===b?null:b}};c.startTransition=function(a,b){b=J.transition;J.transition={};try{a()}finally{J.transition=b}};c.unstable_act=ka;c.useCallback=function(a,b){return g.current.useCallback(a,b)};c.useContext=function(a){return g.current.useContext(a)};c.useDebugValue=function(a,b){};c.useDeferredValue=function(a){return g.current.useDeferredValue(a)};
|
||||
c.useEffect=function(a,b){return g.current.useEffect(a,b)};c.useId=function(){return g.current.useId()};c.useImperativeHandle=function(a,b,c){return g.current.useImperativeHandle(a,b,c)};c.useInsertionEffect=function(a,b){return g.current.useInsertionEffect(a,b)};c.useLayoutEffect=function(a,b){return g.current.useLayoutEffect(a,b)};c.useMemo=function(a,b){return g.current.useMemo(a,b)};c.useReducer=function(a,b,c){return g.current.useReducer(a,b,c)};c.useRef=function(a){return g.current.useRef(a)};
|
||||
c.useState=function(a){return g.current.useState(a)};c.useSyncExternalStore=function(a,b,c){return g.current.useSyncExternalStore(a,b,c)};c.useTransition=function(){return g.current.useTransition()};c.version="18.3.1"});
|
||||
})();
|
||||
@@ -0,0 +1,209 @@
|
||||
-- ============================================================
|
||||
-- SAFE MIGRATION SCRIPT — meSEO schema updates
|
||||
-- ============================================================
|
||||
-- Generated: 2026-04-20
|
||||
-- Purpose: Apply pending schema changes WITHOUT dropping tables
|
||||
-- or columns that contain data.
|
||||
--
|
||||
-- HOW TO RUN:
|
||||
-- 1. Open Neon dashboard → SQL Editor (or psql CLI)
|
||||
-- 2. Paste this entire script
|
||||
-- 3. Execute
|
||||
-- 4. After success, run: npx prisma db pull
|
||||
-- to verify schema and database are in sync
|
||||
--
|
||||
-- WHAT THIS DOES:
|
||||
-- - Adds soft-delete columns to Brand (deactivatedAt, deactivatedBy, deactivationReason)
|
||||
-- - Adds SiteEvent index on (eventType, timestamp) for admin queries
|
||||
-- - Does NOT drop any tables or columns
|
||||
--
|
||||
-- WHAT THIS DOES NOT DO:
|
||||
-- - Does NOT touch VisitorProfile, HouseholdCluster, AdClickEvent, AttributionPath
|
||||
-- - Does NOT touch TrackedSession columns (channelGroup, fbclid, gclid, etc.)
|
||||
-- - Does NOT drop any indexes
|
||||
-- ============================================================
|
||||
|
||||
-- 1. Brand soft-delete columns (all nullable, no data loss)
|
||||
ALTER TABLE "Brand" ADD COLUMN IF NOT EXISTS "deactivatedAt" TIMESTAMP(3);
|
||||
ALTER TABLE "Brand" ADD COLUMN IF NOT EXISTS "deactivatedBy" TEXT;
|
||||
ALTER TABLE "Brand" ADD COLUMN IF NOT EXISTS "deactivationReason" TEXT;
|
||||
|
||||
-- 2. AI Attribution tables (LLMO/GEO/AEO tracking)
|
||||
CREATE TABLE IF NOT EXISTS "AiAttribution" (
|
||||
"id" TEXT NOT NULL DEFAULT gen_random_uuid()::text,
|
||||
"brandId" TEXT NOT NULL,
|
||||
"sessionId" TEXT NOT NULL,
|
||||
"channel" TEXT NOT NULL,
|
||||
"aiPlatform" TEXT,
|
||||
"referrerUrl" TEXT,
|
||||
"queryContext" TEXT,
|
||||
"landingPage" TEXT NOT NULL,
|
||||
"pagesViewed" INTEGER NOT NULL DEFAULT 1,
|
||||
"sessionDuration" INTEGER,
|
||||
"maxScrollDepth" DOUBLE PRECISION,
|
||||
"engagementScore" DOUBLE PRECISION,
|
||||
"converted" BOOLEAN NOT NULL DEFAULT false,
|
||||
"conversionType" TEXT,
|
||||
"conversionPage" TEXT,
|
||||
"conversionId" TEXT,
|
||||
"conversionTimestamp" TIMESTAMP(3),
|
||||
"leadType" TEXT,
|
||||
"dealValue" DOUBLE PRECISION,
|
||||
"dealStatus" TEXT,
|
||||
"outcomeUpdatedAt" TIMESTAMP(3),
|
||||
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "AiAttribution_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "AiAttribution_brandId_fkey" FOREIGN KEY ("brandId") REFERENCES "Brand"("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "AiAttribution_brandId_timestamp_idx" ON "AiAttribution"("brandId", "timestamp");
|
||||
CREATE INDEX IF NOT EXISTS "AiAttribution_brandId_channel_idx" ON "AiAttribution"("brandId", "channel");
|
||||
CREATE INDEX IF NOT EXISTS "AiAttribution_brandId_aiPlatform_idx" ON "AiAttribution"("brandId", "aiPlatform");
|
||||
CREATE INDEX IF NOT EXISTS "AiAttribution_brandId_converted_idx" ON "AiAttribution"("brandId", "converted");
|
||||
CREATE INDEX IF NOT EXISTS "AiAttribution_sessionId_idx" ON "AiAttribution"("sessionId");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "AiCitation" (
|
||||
"id" TEXT NOT NULL DEFAULT gen_random_uuid()::text,
|
||||
"brandId" TEXT NOT NULL,
|
||||
"keyword" TEXT NOT NULL,
|
||||
"searchEngine" TEXT NOT NULL DEFAULT 'google',
|
||||
"citationType" TEXT NOT NULL,
|
||||
"channel" TEXT NOT NULL,
|
||||
"brandCited" BOOLEAN NOT NULL DEFAULT false,
|
||||
"citedUrl" TEXT,
|
||||
"citedPosition" INTEGER,
|
||||
"citedSnippet" TEXT,
|
||||
"competitorsCited" JSONB,
|
||||
"checkedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "AiCitation_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "AiCitation_brandId_fkey" FOREIGN KEY ("brandId") REFERENCES "Brand"("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "AiCitation_brandId_keyword_idx" ON "AiCitation"("brandId", "keyword");
|
||||
CREATE INDEX IF NOT EXISTS "AiCitation_brandId_checkedAt_idx" ON "AiCitation"("brandId", "checkedAt");
|
||||
CREATE INDEX IF NOT EXISTS "AiCitation_brandId_channel_idx" ON "AiCitation"("brandId", "channel");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "AiCitation_brandId_keyword_searchEngine_checkedAt_key"
|
||||
ON "AiCitation"("brandId", "keyword", "searchEngine", "checkedAt");
|
||||
|
||||
-- 3. SiteEvent index for cross-brand admin queries
|
||||
-- The existing index [brandId, eventType, timestamp] can't be used
|
||||
-- for admin queries that don't filter by brandId. This new index
|
||||
-- covers the admin signal overview query pattern.
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "SiteEvent_eventType_timestamp_idx"
|
||||
ON "SiteEvent" ("eventType", "timestamp");
|
||||
|
||||
-- 4. AI Recommendations table (Phase 4 — Strategy Engine)
|
||||
CREATE TABLE IF NOT EXISTS "AiRecommendation" (
|
||||
"id" TEXT NOT NULL DEFAULT gen_random_uuid()::text,
|
||||
"brandId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"priority" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"title" TEXT NOT NULL,
|
||||
"insight" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"impact" TEXT,
|
||||
"dataPoints" JSONB NOT NULL DEFAULT '{}',
|
||||
"relatedPages" JSONB,
|
||||
"relatedKeywords" JSONB,
|
||||
"contentBriefId" TEXT,
|
||||
"completedAt" TIMESTAMP(3),
|
||||
"dismissedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "AiRecommendation_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "AiRecommendation_brandId_fkey" FOREIGN KEY ("brandId") REFERENCES "Brand"("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "AiRecommendation_brandId_status_idx" ON "AiRecommendation"("brandId", "status");
|
||||
CREATE INDEX IF NOT EXISTS "AiRecommendation_brandId_type_idx" ON "AiRecommendation"("brandId", "type");
|
||||
CREATE INDEX IF NOT EXISTS "AiRecommendation_brandId_priority_createdAt_idx" ON "AiRecommendation"("brandId", "priority", "createdAt");
|
||||
|
||||
-- ============================================================
|
||||
-- 5. CronLog table (admin cron status dashboard)
|
||||
CREATE TABLE IF NOT EXISTS "CronLog" (
|
||||
"id" TEXT NOT NULL DEFAULT gen_random_uuid()::text,
|
||||
"route" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"duration" INTEGER NOT NULL,
|
||||
"details" TEXT,
|
||||
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "CronLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "CronLog_route_startedAt_idx" ON "CronLog"("route", "startedAt");
|
||||
|
||||
-- 6. ErrorLog table (admin error viewer)
|
||||
CREATE TABLE IF NOT EXISTS "ErrorLog" (
|
||||
"id" TEXT NOT NULL DEFAULT gen_random_uuid()::text,
|
||||
"route" TEXT NOT NULL,
|
||||
"method" TEXT NOT NULL,
|
||||
"status" INTEGER NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"stack" TEXT,
|
||||
"brandId" TEXT,
|
||||
"userId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "ErrorLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "ErrorLog_createdAt_idx" ON "ErrorLog"("createdAt");
|
||||
CREATE INDEX IF NOT EXISTS "ErrorLog_route_idx" ON "ErrorLog"("route");
|
||||
|
||||
-- 7. SiteConversion.isDuplicateOf column (dedup architecture)
|
||||
ALTER TABLE "SiteConversion" ADD COLUMN IF NOT EXISTS "isDuplicateOf" TEXT;
|
||||
CREATE INDEX IF NOT EXISTS "SiteConversion_brandId_isDuplicateOf_idx" ON "SiteConversion"("brandId", "isDuplicateOf");
|
||||
ALTER TABLE "SiteConversion" ADD COLUMN IF NOT EXISTS "invalidatedReason" TEXT;
|
||||
CREATE INDEX IF NOT EXISTS "SiteConversion_brandId_invalidatedReason_idx" ON "SiteConversion"("brandId", "invalidatedReason");
|
||||
|
||||
-- 8b. SiteConversion channel classification columns
|
||||
ALTER TABLE "SiteConversion" ADD COLUMN IF NOT EXISTS "classifiedChannel" TEXT;
|
||||
ALTER TABLE "SiteConversion" ADD COLUMN IF NOT EXISTS "classificationReason" TEXT;
|
||||
ALTER TABLE "SiteConversion" ADD COLUMN IF NOT EXISTS "classificationEvidence" JSONB;
|
||||
CREATE INDEX IF NOT EXISTS "SiteConversion_brandId_classifiedChannel_idx" ON "SiteConversion"("brandId", "classifiedChannel");
|
||||
|
||||
-- 8. Brand verification columns
|
||||
ALTER TABLE "Brand" ADD COLUMN IF NOT EXISTS "verifiedAt" TIMESTAMP(3);
|
||||
ALTER TABLE "Brand" ADD COLUMN IF NOT EXISTS "lastVerificationScore" DOUBLE PRECISION;
|
||||
ALTER TABLE "Brand" ADD COLUMN IF NOT EXISTS "lastVerificationIssues" JSONB;
|
||||
|
||||
-- 9. DashboardSnapshot table (precomputed dashboard KPI payloads)
|
||||
CREATE TABLE IF NOT EXISTS "DashboardSnapshot" (
|
||||
"id" TEXT NOT NULL DEFAULT gen_random_uuid()::text,
|
||||
"brandId" TEXT NOT NULL,
|
||||
"range" TEXT NOT NULL,
|
||||
"payload" JSONB NOT NULL DEFAULT '{}',
|
||||
"computedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "DashboardSnapshot_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "DashboardSnapshot_brandId_fkey" FOREIGN KEY ("brandId") REFERENCES "Brand"("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "DashboardSnapshot_brandId_range_key" ON "DashboardSnapshot"("brandId", "range");
|
||||
CREATE INDEX IF NOT EXISTS "DashboardSnapshot_brandId_range_idx" ON "DashboardSnapshot"("brandId", "range");
|
||||
|
||||
-- VERIFICATION QUERIES — run these after the migration
|
||||
-- ============================================================
|
||||
|
||||
-- Verify Brand columns were added:
|
||||
-- SELECT column_name, data_type, is_nullable
|
||||
-- FROM information_schema.columns
|
||||
-- WHERE table_name = 'Brand' AND column_name IN ('deactivatedAt', 'deactivatedBy', 'deactivationReason');
|
||||
-- Expected: 3 rows, all nullable
|
||||
|
||||
-- Verify SiteEvent index was created:
|
||||
-- SELECT indexname FROM pg_indexes WHERE tablename = 'SiteEvent' AND indexname = 'SiteEvent_eventType_timestamp_idx';
|
||||
-- Expected: 1 row
|
||||
|
||||
-- Verify AiRecommendation table was created:
|
||||
-- SELECT table_name FROM information_schema.tables WHERE table_name = 'AiRecommendation';
|
||||
-- Expected: 1 row
|
||||
|
||||
-- ── MetricSnapshot: add updatedAt for freshness tracking ──
|
||||
ALTER TABLE "MetricSnapshot" ADD COLUMN IF NOT EXISTS "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT NOW();
|
||||
|
||||
-- Verify NO tables were dropped:
|
||||
-- SELECT table_name FROM information_schema.tables
|
||||
-- WHERE table_schema = 'public'
|
||||
-- AND table_name IN ('VisitorProfile', 'HouseholdCluster', 'AdClickEvent', 'AttributionPath');
|
||||
-- Expected: 4 rows (all tables still exist)
|
||||
|
||||
-- Verify TrackedSession columns still exist:
|
||||
-- SELECT column_name FROM information_schema.columns
|
||||
-- WHERE table_name = 'TrackedSession'
|
||||
-- AND column_name IN ('channelGroup', 'fbclid', 'gclid', 'gbraid', 'wbraid', 'utmContent', 'utmTerm');
|
||||
-- Expected: 7 rows (all columns still exist)
|
||||
@@ -0,0 +1,31 @@
|
||||
-- ============================================================
|
||||
-- SCHEMA DIFF DOCUMENTATION
|
||||
-- ============================================================
|
||||
-- Generated: 2026-04-20
|
||||
-- Status: DO NOT RUN prisma db push --accept-data-loss
|
||||
--
|
||||
-- PROBLEM:
|
||||
-- prisma db push detects drift and wants to DROP+RECREATE:
|
||||
-- - VisitorProfile (19,336 rows)
|
||||
-- - HouseholdCluster (2,741 rows)
|
||||
-- - AdClickEvent (1,524 rows)
|
||||
-- - AttributionPath (841 rows)
|
||||
-- - TrackedSession columns: channelGroup, fbclid, gbraid,
|
||||
-- gclid, utmContent, utmTerm, wbraid
|
||||
--
|
||||
-- All models and columns ARE present in prisma/schema.prisma.
|
||||
-- The drift is from structural differences (types, constraints,
|
||||
-- indexes) between the schema file and production.
|
||||
--
|
||||
-- SOLUTION: Use safe-migration.sql for additive changes only.
|
||||
-- Then run `npx prisma db pull` to sync the schema file with
|
||||
-- the production database state.
|
||||
--
|
||||
-- To generate the actual diff SQL, run from a machine with
|
||||
-- DATABASE_URL:
|
||||
--
|
||||
-- npx prisma migrate diff \
|
||||
-- --from-schema-datasource prisma/schema.prisma \
|
||||
-- --to-schema-datamodel prisma/schema.prisma \
|
||||
-- --script
|
||||
-- ============================================================
|
||||
@@ -0,0 +1,15 @@
|
||||
-- ============================================================
|
||||
-- MISSING INDEXES — Run in Neon SQL Editor
|
||||
-- ============================================================
|
||||
-- These indexes exist in prisma/schema.prisma but are NOT on the
|
||||
-- production database (lost during schema conflict resolution).
|
||||
-- CONCURRENTLY ensures no table locks during creation.
|
||||
-- ============================================================
|
||||
|
||||
-- SiteEvent: admin Signal Overview cross-brand queries
|
||||
-- Without this, groupBy on 30M rows scans the entire table
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "SiteEvent_eventType_timestamp_idx"
|
||||
ON "SiteEvent" ("eventType", "timestamp");
|
||||
|
||||
-- Verify:
|
||||
-- SELECT indexname FROM pg_indexes WHERE tablename = 'SiteEvent';
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* Phase 1 — Metric Ground-Truth Audit
|
||||
* ───────────────────────────────────────
|
||||
*
|
||||
* Run against a live DB to capture the *actual* counts per metric
|
||||
* table for a single brand. Output is written to stdout AND to
|
||||
* scripts/audit-results.md so the ground truth lives alongside the
|
||||
* code-side audit.
|
||||
*
|
||||
* Usage:
|
||||
* DATABASE_URL=postgres://... npx tsx scripts/audit-phase1.ts
|
||||
* # or pick a different brand
|
||||
* BRAND_NAME="Modern Heart" npx tsx scripts/audit-phase1.ts
|
||||
*
|
||||
* This script is READ-ONLY — it only runs aggregate() / groupBy()
|
||||
* / count() queries. Safe to run in production.
|
||||
*
|
||||
* Scoping note: some metric tables are keyed by brandId directly
|
||||
* (SiteConversion, SiteEvent, RealUserMetric, AiInteraction,
|
||||
* AiUsageLog, PlatformEvent, MetricSnapshot, GscPage, TechnicalAudit)
|
||||
* while TrackedEvent and TrackedSession are scoped via TrackedSite.
|
||||
* The script resolves the brand's TrackedSite once and uses
|
||||
* trackedSiteId for those two tables.
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { writeFileSync, mkdirSync } from "fs";
|
||||
import { dirname } from "path";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Accumulator for the markdown report. Every log() call appends to
|
||||
// both stdout and this buffer; the buffer is flushed to
|
||||
// scripts/audit-results.md at the end of the run.
|
||||
const report: string[] = [];
|
||||
function log(line: string) {
|
||||
log(line);
|
||||
report.push(line);
|
||||
}
|
||||
|
||||
// JSON.stringify replacer that handles BigInt + Date cleanly.
|
||||
function replacer(_key: string, value: unknown): unknown {
|
||||
if (typeof value === "bigint") return value.toString();
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
return value;
|
||||
}
|
||||
|
||||
function j(v: unknown): string {
|
||||
return JSON.stringify(v, replacer);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const brandNeedle = process.env.BRAND_NAME ?? "Modern Heart";
|
||||
|
||||
const brand = await prisma.brand.findFirst({
|
||||
where: { name: { contains: brandNeedle, mode: "insensitive" } },
|
||||
select: { id: true, name: true, domain: true, createdAt: true },
|
||||
});
|
||||
if (!brand) {
|
||||
console.error(`No brand matching "${brandNeedle}" found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const site = await prisma.trackedSite.findUnique({
|
||||
where: { brandId: brand.id },
|
||||
select: { id: true, status: true, firstEventAt: true, lastEventAt: true, createdAt: true },
|
||||
});
|
||||
|
||||
log("=== Brand ===");
|
||||
log(j({ brand }));
|
||||
log("=== TrackedSite ===");
|
||||
log(j({ site }));
|
||||
|
||||
// ─── Direct-brandId tables ──────────────────────────────────
|
||||
//
|
||||
// Each aggregate runs independently so a single failing query
|
||||
// doesn't blackhole the rest of the audit. Empty / errored rows
|
||||
// render as ERROR in the output.
|
||||
const brandTables: Array<{ name: string; run: () => Promise<unknown> }> = [
|
||||
{
|
||||
name: "SiteConversion",
|
||||
run: () => prisma.siteConversion.aggregate({
|
||||
where: { brandId: brand.id },
|
||||
_count: true,
|
||||
_min: { timestamp: true },
|
||||
_max: { timestamp: true },
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "SiteEvent",
|
||||
run: () => prisma.siteEvent.aggregate({
|
||||
where: { brandId: brand.id },
|
||||
_count: true,
|
||||
_min: { timestamp: true },
|
||||
_max: { timestamp: true },
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "RealUserMetric",
|
||||
run: () => prisma.realUserMetric.aggregate({
|
||||
where: { brandId: brand.id },
|
||||
_count: true,
|
||||
_min: { timestamp: true },
|
||||
_max: { timestamp: true },
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "AiInteraction",
|
||||
run: () => prisma.aiInteraction.aggregate({
|
||||
where: { brandId: brand.id },
|
||||
_count: true,
|
||||
_min: { timestamp: true },
|
||||
_max: { timestamp: true },
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "AiUsageLog",
|
||||
run: () => prisma.aiUsageLog.aggregate({
|
||||
where: { brandId: brand.id },
|
||||
_count: true,
|
||||
_min: { timestamp: true },
|
||||
_max: { timestamp: true },
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "PlatformEvent",
|
||||
run: () => prisma.platformEvent.aggregate({
|
||||
where: { brandId: brand.id },
|
||||
_count: true,
|
||||
_min: { timestamp: true },
|
||||
_max: { timestamp: true },
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "MetricSnapshot",
|
||||
run: () => prisma.metricSnapshot.aggregate({
|
||||
where: { brandId: brand.id },
|
||||
_count: true,
|
||||
_min: { date: true },
|
||||
_max: { date: true },
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "GscPage",
|
||||
run: () => prisma.gscPage.aggregate({
|
||||
where: { brandId: brand.id },
|
||||
_count: true,
|
||||
_min: { date: true },
|
||||
_max: { date: true },
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "TechnicalAudit",
|
||||
run: () => prisma.technicalAudit.aggregate({
|
||||
where: { brandId: brand.id },
|
||||
_count: true,
|
||||
_min: { createdAt: true },
|
||||
_max: { createdAt: true },
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
log("\n=== Brand-scoped tables ===");
|
||||
for (const t of brandTables) {
|
||||
try {
|
||||
const result = await t.run();
|
||||
log(`${t.name}: ${j(result)}`);
|
||||
} catch (err) {
|
||||
log(`${t.name}: ERROR ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── TrackedSite-scoped tables ──────────────────────────────
|
||||
log("\n=== TrackedSite-scoped tables ===");
|
||||
if (!site) {
|
||||
log("TrackedEvent / TrackedSession: SKIPPED (no TrackedSite for brand)");
|
||||
} else {
|
||||
try {
|
||||
const te = await prisma.trackedEvent.aggregate({
|
||||
where: { trackedSiteId: site.id },
|
||||
_count: true,
|
||||
_min: { timestamp: true },
|
||||
_max: { timestamp: true },
|
||||
});
|
||||
log(`TrackedEvent: ${j(te)}`);
|
||||
} catch (err) {
|
||||
log(`TrackedEvent: ERROR ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
try {
|
||||
const ts = await prisma.trackedSession.aggregate({
|
||||
where: { trackedSiteId: site.id },
|
||||
_count: true,
|
||||
_min: { startedAt: true },
|
||||
_max: { startedAt: true },
|
||||
});
|
||||
log(`TrackedSession: ${j(ts)}`);
|
||||
} catch (err) {
|
||||
log(`TrackedSession: ERROR ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Type breakdowns (the numbers the UI actually renders) ──
|
||||
log("\n=== Distinct event-type counts ===");
|
||||
|
||||
if (site) {
|
||||
try {
|
||||
const trackedTypes = await prisma.trackedEvent.groupBy({
|
||||
by: ["eventType"],
|
||||
where: { trackedSiteId: site.id },
|
||||
_count: true,
|
||||
});
|
||||
trackedTypes.sort((a, b) => (b._count as unknown as number) - (a._count as unknown as number));
|
||||
// Top 30 only — longer lists hide the signal.
|
||||
log(`TrackedEvent types (top 30): ${j(trackedTypes.slice(0, 30))}`);
|
||||
log(`TrackedEvent distinct type count: ${trackedTypes.length}`);
|
||||
} catch (err) {
|
||||
log(`TrackedEvent types: ERROR ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const siteConvTypes = await prisma.siteConversion.groupBy({
|
||||
by: ["conversionType"],
|
||||
where: { brandId: brand.id },
|
||||
_count: true,
|
||||
});
|
||||
siteConvTypes.sort((a, b) => (b._count as unknown as number) - (a._count as unknown as number));
|
||||
// All SiteConversion types — there are rarely more than ~20
|
||||
// distinct values but capped at 30 for safety.
|
||||
log(`SiteConversion types (top 30): ${j(siteConvTypes.slice(0, 30))}`);
|
||||
log(`SiteConversion distinct type count: ${siteConvTypes.length}`);
|
||||
} catch (err) {
|
||||
log(`SiteConversion types: ERROR ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const siteEventTypes = await prisma.siteEvent.groupBy({
|
||||
by: ["eventType"],
|
||||
where: { brandId: brand.id },
|
||||
_count: true,
|
||||
});
|
||||
siteEventTypes.sort((a, b) => (b._count as unknown as number) - (a._count as unknown as number));
|
||||
log(`SiteEvent types (top 20): ${j(siteEventTypes.slice(0, 20))}`);
|
||||
log(`SiteEvent distinct type count: ${siteEventTypes.length}`);
|
||||
} catch (err) {
|
||||
log(`SiteEvent types: ERROR ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
// ─── 30-day windowed ground truth ──────────────────────────
|
||||
// The UI's default range is 30 days. Logging both all-time
|
||||
// and 30-day counts side by side so "why does the dashboard
|
||||
// show 528 conversions when the DB has 3000+?" is obvious.
|
||||
log("\n=== 30-day windowed counts ===");
|
||||
const since30d = new Date(Date.now() - 30 * 86_400_000);
|
||||
|
||||
try {
|
||||
const conv30 = await prisma.siteConversion.count({
|
||||
where: { brandId: brand.id, timestamp: { gte: since30d } },
|
||||
});
|
||||
log(`SiteConversion (30d): ${conv30}`);
|
||||
} catch (err) {
|
||||
log(`SiteConversion (30d): ERROR ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
if (site) {
|
||||
try {
|
||||
const te30 = await prisma.trackedEvent.count({
|
||||
where: { trackedSiteId: site.id, timestamp: { gte: since30d } },
|
||||
});
|
||||
log(`TrackedEvent (30d): ${te30}`);
|
||||
const sessStart30 = await prisma.trackedEvent.count({
|
||||
where: { trackedSiteId: site.id, eventType: "session_start", timestamp: { gte: since30d } },
|
||||
});
|
||||
log(`TrackedEvent session_start (30d): ${sessStart30}`);
|
||||
const pv30 = await prisma.trackedEvent.count({
|
||||
where: { trackedSiteId: site.id, eventType: "page_view", timestamp: { gte: since30d } },
|
||||
});
|
||||
log(`TrackedEvent page_view (30d): ${pv30}`);
|
||||
const ts30 = await prisma.trackedSession.count({
|
||||
where: { trackedSiteId: site.id, startedAt: { gte: since30d } },
|
||||
});
|
||||
log(`TrackedSession (30d): ${ts30}`);
|
||||
} catch (err) {
|
||||
log(`TrackedEvent/TrackedSession (30d): ERROR ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const gsc30 = await prisma.gscPage.aggregate({
|
||||
where: { brandId: brand.id, date: { gte: since30d } },
|
||||
_sum: { clicks: true, impressions: true },
|
||||
_avg: { position: true, ctr: true },
|
||||
});
|
||||
log(`GscPage (30d) aggregate: ${j(gsc30)}`);
|
||||
} catch (err) {
|
||||
log(`GscPage (30d): ERROR ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const ai30 = await prisma.aiUsageLog.aggregate({
|
||||
where: { brandId: brand.id, timestamp: { gte: since30d } },
|
||||
_count: true,
|
||||
_sum: { estimatedCost: true, inputTokens: true, outputTokens: true },
|
||||
});
|
||||
log(`AiUsageLog (30d): ${j(ai30)}`);
|
||||
} catch (err) {
|
||||
log(`AiUsageLog (30d): ERROR ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
log("\n=== Audit complete ===");
|
||||
|
||||
// Flush the full run to scripts/audit-results.md so the result is
|
||||
// checked in alongside the code-side audit. Wrapping in ``` keeps
|
||||
// the markdown file renderable even for large JSON payloads.
|
||||
const outPath = "scripts/audit-results.md";
|
||||
const now = new Date().toISOString();
|
||||
const body = [
|
||||
"# Phase 1 — Metric Ground-Truth Audit",
|
||||
"",
|
||||
`**Generated:** ${now}`,
|
||||
`**Brand:** ${brand.name} (${brand.id})`,
|
||||
`**Domain:** ${brand.domain}`,
|
||||
"",
|
||||
"This file is produced by `scripts/audit-phase1.ts` and captures",
|
||||
"the raw per-table counts + event-type breakdowns for a single",
|
||||
"brand. These are the **ground-truth numbers** the UI surfaces",
|
||||
"must agree with.",
|
||||
"",
|
||||
"Re-run with `npx tsx scripts/audit-phase1.ts` — the file is",
|
||||
"overwritten in place.",
|
||||
"",
|
||||
"## Full output",
|
||||
"",
|
||||
"```text",
|
||||
...report,
|
||||
"```",
|
||||
"",
|
||||
].join("\n");
|
||||
mkdirSync(dirname(outPath), { recursive: true });
|
||||
writeFileSync(outPath, body, "utf8");
|
||||
console.log(`\n[audit-phase1] wrote ${outPath} (${report.length} lines)`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error("[audit-phase1] FATAL", err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,273 @@
|
||||
# Phase 1 — Metric Ground-Truth Audit
|
||||
|
||||
**Status:** Script ready — needs a live `DATABASE_URL` to produce real numbers.
|
||||
|
||||
The `scripts/audit-phase1.ts` runner is a read-only Prisma script. Once
|
||||
it runs against production it overwrites this file with the actual
|
||||
per-table counts + event-type breakdowns for a single brand.
|
||||
|
||||
## How to run
|
||||
|
||||
```bash
|
||||
# Default: searches for a brand whose name contains "Modern Heart".
|
||||
DATABASE_URL=postgres://user:pass@host/db npx tsx scripts/audit-phase1.ts
|
||||
|
||||
# Override the brand:
|
||||
BRAND_NAME="Another Brand" DATABASE_URL=... npx tsx scripts/audit-phase1.ts
|
||||
```
|
||||
|
||||
On success the script overwrites this file with the ground-truth
|
||||
output from the target database.
|
||||
|
||||
## What the script captures
|
||||
|
||||
Scoped to a single brand, per table:
|
||||
|
||||
- **Brand-keyed tables** — `SiteConversion`, `SiteEvent`, `RealUserMetric`,
|
||||
`AiInteraction`, `AiUsageLog`, `PlatformEvent`, `MetricSnapshot`,
|
||||
`GscPage`, `TechnicalAudit`. For each: row count, min / max
|
||||
timestamp.
|
||||
- **TrackedSite-keyed tables** — `TrackedEvent`, `TrackedSession`. The
|
||||
script resolves the brand's `TrackedSite.id` once, then queries
|
||||
those two tables by `trackedSiteId`.
|
||||
- **Type breakdowns** — top 30 `TrackedEvent.eventType` buckets, top
|
||||
30 `SiteConversion.conversionType` buckets, top 20
|
||||
`SiteEvent.eventType` buckets.
|
||||
- **30-day windowed counts** — `SiteConversion`, `TrackedEvent`
|
||||
(total + `session_start` + `page_view`), `TrackedSession`,
|
||||
`GscPage` aggregate (clicks / impressions / position / ctr),
|
||||
`AiUsageLog` aggregate (cost / tokens).
|
||||
|
||||
Every query runs independently (no transaction) and errors surface as
|
||||
`ERROR <message>` lines rather than aborting the rest of the audit.
|
||||
|
||||
## Expected output shape
|
||||
|
||||
```text
|
||||
=== Brand ===
|
||||
{"brand":{"id":"cl...","name":"Modern Heart and Vascular","domain":"modernheartandvascular.com","createdAt":"2026-04-10T...Z"}}
|
||||
=== TrackedSite ===
|
||||
{"site":{"id":"cl...","status":"active","firstEventAt":"2026-04-10T...Z","lastEventAt":"2026-04-16T...Z","createdAt":"2026-04-10T...Z"}}
|
||||
|
||||
=== Brand-scoped tables ===
|
||||
SiteConversion: {"_count":NNN,"_min":{"timestamp":"..."},"_max":{"timestamp":"..."}}
|
||||
SiteEvent: {...}
|
||||
RealUserMetric: {...}
|
||||
AiInteraction: {...}
|
||||
AiUsageLog: {...}
|
||||
PlatformEvent: {...}
|
||||
MetricSnapshot: {...}
|
||||
GscPage: {...}
|
||||
TechnicalAudit: {...}
|
||||
|
||||
=== TrackedSite-scoped tables ===
|
||||
TrackedEvent: {"_count":NNN,"_min":{"timestamp":"..."},"_max":{"timestamp":"..."}}
|
||||
TrackedSession: {...}
|
||||
|
||||
=== Distinct event-type counts ===
|
||||
TrackedEvent types (top 30): [{"eventType":"page_view","_count":N},...]
|
||||
TrackedEvent distinct type count: NN
|
||||
SiteConversion types (top 30): [{"conversionType":"form_submit","_count":N},...]
|
||||
SiteConversion distinct type count: NN
|
||||
SiteEvent types (top 20): [{"eventType":"outbound_click","_count":N},...]
|
||||
SiteEvent distinct type count: NN
|
||||
|
||||
=== 30-day windowed counts ===
|
||||
SiteConversion (30d): NNN
|
||||
TrackedEvent (30d): NNN
|
||||
TrackedEvent session_start (30d): NNN
|
||||
TrackedEvent page_view (30d): NNN
|
||||
TrackedSession (30d): NNN
|
||||
GscPage (30d) aggregate: {"_sum":{"clicks":N,"impressions":N},"_avg":{"position":N,"ctr":N}}
|
||||
AiUsageLog (30d): {"_count":N,"_sum":{"estimatedCost":N,"inputTokens":N,"outputTokens":N}}
|
||||
|
||||
=== Audit complete ===
|
||||
```
|
||||
|
||||
## Why these tables
|
||||
|
||||
Every metric surfaced on the app dashboard, admin dashboard, and
|
||||
Site Tag Analytics pages ultimately reads from one of these tables.
|
||||
Grounding the audit here lets us confirm:
|
||||
|
||||
- Whether a "528 conversions" UI display actually matches
|
||||
`SELECT COUNT(*) FROM "SiteConversion" WHERE brandId=...
|
||||
AND timestamp>=<30d>` on the live DB.
|
||||
- Whether `TrackedEvent.session_start` and `TrackedSession` diverge
|
||||
(the root cause of the 16,382 vs 16,447 session drift the audit
|
||||
already flagged in code).
|
||||
- Which `eventType` values actually populate the DB — required for
|
||||
deciding which buckets each funnel stage rolls up.
|
||||
|
||||
## Next phases
|
||||
|
||||
Once the script runs and populates this file, the numbers printed
|
||||
here become the "truth" column against which every API response and
|
||||
UI card is compared. Phases 2+ replace ad-hoc per-surface queries
|
||||
with shared utilities that match these ground-truth numbers.
|
||||
|
||||
---
|
||||
|
||||
## Sessions
|
||||
|
||||
Every call site in `src/` that queries or displays a session count.
|
||||
Format: `path:line` — source table — filter — dedup status — scope.
|
||||
|
||||
### TrackedSession (deduped — the authoritative unique-visitor count)
|
||||
|
||||
- `src/app/api/admin/platform-metrics/route.ts:203` — `prisma.trackedSession.findMany({ where: sessionWhere(start, end), select: { startedAt: true }, take: 100_000 })` — **TrackedSession** — range + trackedSiteId — deduped by construction — single brand OR all brands.
|
||||
- `src/app/api/admin/platform-metrics/route.ts:208` — `prisma.trackedSession.count({ where: sessionWhere(prevStart, prevEnd) })` — **TrackedSession** — previous range — deduped — same scope as above (drives the trend %).
|
||||
- `src/app/api/admin/platform-metrics/route.ts:389` — `prisma.trackedSession.groupBy({ by: ["trackedSiteId"], where: { startedAt: { gte: start, lt: end } }, _count: true, orderBy: { _count: { trackedSiteId: "desc" } }, take: 20 })` — **TrackedSession** — window only — deduped — cross-brand Top Brands list.
|
||||
- `src/app/api/admin/platform-metrics/route.ts:628` — `prisma.trackedSession.count({ where: { ...trackedSiteIdFilter, startedAt: { gte: compStart, lte: compEnd } } })` — **TrackedSession** — overlap window — deduped — used for Site Tag vs GA4 reconciliation.
|
||||
- `src/app/api/admin/sessions/route.ts:148` — `prisma.trackedSession.count({ where: sessionWhere })` — **TrackedSession** — brand + optional date range — deduped — powers the admin Session Explorer list.
|
||||
- `src/app/api/admin/brands/[brandId]/route.ts:59` — `prisma.trackedSession.count({ where: { trackedSiteId } })` — **TrackedSession** — no window — deduped — all-time count on the admin brand detail page.
|
||||
- `src/app/api/admin/analytics/data-moat/route.ts:48` — `prisma.trackedSession.count()` — **TrackedSession** — no filter — all-time platform total.
|
||||
- `src/app/api/admin/analytics/data-moat/route.ts:62` — `prisma.trackedSession.count({ where: { startedAt: { gte: sevenDaysAgo } } })` — **TrackedSession** — 7d — for "growth per day" rate.
|
||||
- `src/app/api/admin/analytics/data-moat/route.ts:152` — `prisma.trackedSession.groupBy({ by: ["trackedSiteId"], _count: true })` — **TrackedSession** — all-time — powers the per-brand data-moat leaderboard.
|
||||
- `src/app/api/admin/analytics/funnel/route.ts:133` — `prisma.trackedSession.findMany({ where: sessionWhere, select: { id, sessionId, startedAt, pageCount } })` — **TrackedSession** — per-brand window — drives the 5-stage funnel denominator.
|
||||
- `src/app/api/admin/analytics/growth/route.ts:71` — `prisma.trackedSession.findMany({ where: { startedAt: { gte: start } }, select: { startedAt: true } })` — **TrackedSession** — monthly rollup — platform growth chart.
|
||||
- `src/app/api/admin/export/platform-metrics/route.ts:55` — `prisma.trackedSession.count()` — **TrackedSession** — all-time — export row "totals.sessions".
|
||||
- `src/app/api/admin/export/growth-data/route.ts:58` — `prisma.trackedSession.count({ where: { startedAt: { gte: m.start, lt: m.end } } })` — **TrackedSession** — per-month — export growth csv.
|
||||
- `src/app/api/admin/export/brand-data/route.ts:47` — `prisma.trackedSession.groupBy({ by: ["trackedSiteId"], where: { trackedSiteId: { in: trackedSiteIds } }, _count: true })` — **TrackedSession** — per-brand — brand export csv.
|
||||
- `src/app/api/admin/system-health/route.ts:45` — `prisma.trackedSession.count()` — **TrackedSession** — all-time — system-health volume row.
|
||||
- `src/app/api/content-audit/page-detail/route.ts:158` — `prisma.trackedSession.count({ where: { trackedSiteId, startedAt: { gte: since } } })` — **TrackedSession** — 30d — per-page conversion-rate denominator.
|
||||
- `src/app/api/content-audit/page-detail/route.ts:161` — `prisma.trackedSession.count({ where: { trackedSiteId, startedAt: { gte: prevSince, lt: since } } })` — **TrackedSession** — previous 30d — delta.
|
||||
- `src/app/api/content-briefs/insights/route.ts:267` — `prisma.trackedSession.groupBy({ by: ["country"], where: { trackedSiteId, country: { not: null } }, _count: true, orderBy: { _count: { country: "desc" } }, take: 5 })` — **TrackedSession** — all-time — top visitor countries (relabelled `sessions: g._count`).
|
||||
- `src/app/api/content-briefs/brand-profile/route.ts:147` — same as above — duplicated in brand-profile builder.
|
||||
- `src/app/api/cron/signals-site-tag-ai/route.ts:174` — `prisma.trackedSession.count({ where: { trackedSiteId, startedAt: { gte: twentyFourHoursAgo } } })` — **TrackedSession** — 24h — signal-detection cron.
|
||||
- `src/app/api/site-tag/confidence/route.ts:220` — `prisma.trackedSession.count({ where: { trackedSiteId, startedAt: { gte: effectiveFrom } } })` — **TrackedSession** — effective-overlap window — powers the Data Confidence "Session match rate" factor.
|
||||
- `src/app/api/site-tag/route.ts:94` — `prisma.trackedSession.count({ where: { trackedSiteId } })` — **TrackedSession** — all-time — snippet status card.
|
||||
- `src/lib/services/site-tag-analytics.ts:523` — `prisma.trackedSession.count({ where: { trackedSiteId, startedAt: { gte: from } } })` — **TrackedSession** — range — `getTrafficSummary()`, the Overview tab's Sessions KPI source.
|
||||
- `src/lib/services/site-tag-analytics.ts:661` — `prisma.trackedSession.count({ where: { trackedSiteId, startedAt: { gte: from } } })` — **TrackedSession** — range — `getConversionSummary()`, the Conversion Rate denominator.
|
||||
- `src/lib/services/brand-radar.ts:113` — `prisma.trackedSession.count({ where: { trackedSiteId, startedAt: { gte: d30 } } })` — **TrackedSession** — 30d — brand radar KPI.
|
||||
- `src/lib/services/brand-radar.ts:116` — `prisma.trackedSession.aggregate({ ... _avg: { pageCount } ... })` — **TrackedSession** — pages-per-session aggregate.
|
||||
- `src/lib/services/brand-radar.ts:396` — `prisma.trackedSession.count({ where: { trackedSiteId, startedAt: { gte: d7 } } })` — **TrackedSession** — 7d — brand radar delta.
|
||||
|
||||
### TrackedEvent.session_start (RAW event rows — NOT deduped)
|
||||
|
||||
- `src/app/api/ai-strategist/chat/route.ts:453` — inside the Site Tag metrics block, `session_start` appears in the `EVENT_TYPES` list that's fed to `trackedEvent.groupBy({ by: ["eventType"] })` — **TrackedEvent** — 28d — raw counts powering the `<site-tag>` context injected into the Strategist prompt.
|
||||
- `src/app/api/ai-strategist/chat/route.ts:490` — `const sessions = counts["session_start"] ?? 0` — derives the session count from the groupBy bucket — **TrackedEvent** — 28d — raw.
|
||||
- `src/app/api/ai-strategist/chat/route.ts:682` — `where: { trackedSiteId, eventType: "session_start", timestamp: { gte: start } }` — inside `buildDailyConversionContext()` for the daily conversion block — **TrackedEvent** — 28d — raw.
|
||||
- `src/app/api/ai-strategist/chat/route.ts:1032` — `prisma.trackedEvent.count({ where: { trackedSiteId, eventType: "session_start", timestamp: { gte: since } } })` — **TrackedEvent** — 28d — capture-rate comparison against GA4 in `buildTrackingHealth()`.
|
||||
- `src/lib/services/conversion-metrics.ts:145` — `prisma.trackedEvent.count({ where: { trackedSiteId, eventType: "session_start", timestamp: { gte: since, lte: now } } })` — **TrackedEvent** — param-driven — raw — the shared `getConversionMetrics()` helper's session denominator.
|
||||
- `src/lib/services/signal-detection.ts:380` — `prisma.trackedEvent.count({ where: { trackedSiteId, eventType: "session_start", timestamp: { gte: weekAgo } } })` — **TrackedEvent** — 7d — raw.
|
||||
- `src/lib/services/signal-detection.ts:383` — `prisma.trackedEvent.count({ where: { trackedSiteId, eventType: "session_start", timestamp: { gte: twoWeeksAgo, lt: weekAgo } } })` — **TrackedEvent** — previous 7d — raw.
|
||||
- `src/lib/services/signal-detection.ts:387` — `prisma.trackedEvent.count({ where: { trackedSiteId, eventType: "session_start", timestamp: { gte: twoDaysAgo } } })` — **TrackedEvent** — 48h — raw.
|
||||
- `src/lib/services/email-report-generator.ts:116` — `prisma.trackedEvent.count({ where: { trackedSiteId, eventType: "session_start", timestamp: { gte: since } } })` — **TrackedEvent** — per report window — raw.
|
||||
|
||||
### GA4 sessions (external API / MetricSnapshot — not Site Tag)
|
||||
|
||||
- `src/app/api/dashboard/route.ts:166, 276, 304, 339, 386` — `fetchGa4` + `fetchGa4StaleFallback` — GA4 Data API live call + `prisma.metricSnapshot.aggregate({ ..., _sum: { sessions } })` fallback — **external + MetricSnapshot** — dashboard GA4 card.
|
||||
- `src/app/api/dashboard/route.ts:932-1031` — merges live vs cached GA4 sessions into the dashboard response — **mixed source**.
|
||||
- `src/app/api/ai-strategist/chat/route.ts:866` — `prisma.metricSnapshot.aggregate({ where: { brandId, source: "ga4", date: { gte: since } }, _sum: { sessions: true } })` — **MetricSnapshot** — 28d — used by `buildTrackingHealth()` to derive a GA4 session count for the capture-rate comparison.
|
||||
|
||||
### UI-layer displays (no direct query — rebuild from prop shape)
|
||||
|
||||
- `src/app/site-tag-analytics/overview-tab.tsx:192` — `const tagSessions = traffic?.sessions ?? 0` — reads the `traffic.sessions` field of the `/api/site-tag/analytics` response (backed by `getTrafficSummary`, TrackedSession).
|
||||
- `src/app/site-tag-analytics/funnel-tab.tsx:70` — `const sessions = traffic.sessions` — same prop chain.
|
||||
- `src/app/admin/page.tsx` + `src/app/admin/analytics/data-moat/page.tsx:123` — render `trackedSessions` volume card from `/api/admin/analytics/data-moat` (TrackedSession count).
|
||||
- `src/app/admin/sessions/page.tsx:317` — renders count from `/api/admin/sessions` (TrackedSession).
|
||||
|
||||
### Drift analysis
|
||||
|
||||
**Core split.** Two tables answer "how many sessions did this brand have?":
|
||||
|
||||
1. **TrackedSession** — one row per browser session, keyed by the `sessionId` cookie set on the first `session_start`. The `lastSeenAt` column is touched on every subsequent event, but the row itself is unique per session.
|
||||
2. **TrackedEvent.session_start** — a TrackedEvent row is written every time `t.js` emits a `session_start` event (typically once per 30-minute idle-timeout window). If a user returns after the 30-minute timeout, `t.js` re-emits `session_start` and a NEW TrackedSession row IS created too, so the two counts normally track closely. Divergence sources:
|
||||
|
||||
- **Clock-skew / race writes.** Ingest writes TrackedEvent first, then conditionally upserts TrackedSession (see `recordEvent` in `src/lib/services/site-tag.ts`). If the TrackedSession upsert fails silently, TrackedEvent has a `session_start` row without a matching TrackedSession row → TrackedEvent count > TrackedSession count.
|
||||
- **Historical data without sessionId.** Older TrackedEvent rows predate the `sessionId` column — those `session_start` rows get counted in the event query but never had a TrackedSession row.
|
||||
- **TrackedSession backfill gaps.** The `TrackedSession.startedAt` column defaults to `now()` on insert but the TrackedEvent `timestamp` is set from the client payload. On a long upload (sendBeacon queued during an outage), the TrackedEvent lands in the prior day but the TrackedSession lands in the recovery day — they don't align across window edges.
|
||||
|
||||
Net: for a typical brand TrackedSession is the smaller / truer count and TrackedEvent `session_start` overcounts slightly.
|
||||
|
||||
**The one surface that drifts today.** Every admin + app surface uses **TrackedSession** for the top-line Session count (overview, admin dashboard, content-audit, brand-radar, confidence). The exception is **`src/lib/services/conversion-metrics.ts:145`** — the shared `getConversionMetrics()` helper counts `TrackedEvent.session_start` instead of `TrackedSession`. Any surface calling the helper for its Conversion Rate denominator will report a slightly inflated session count + slightly deflated rate vs surfaces that call `getConversionSummary()` / `getTrafficSummary()` directly.
|
||||
|
||||
Action item for Phase 4: swap `getConversionMetrics` over to `prisma.trackedSession.count({ where: { trackedSiteId, startedAt: { gte: since } } })` so every surface agrees on the denominator.
|
||||
|
||||
**Report-only sites.** `ai-strategist/chat` + `signal-detection` + `email-report-generator` also use `TrackedEvent.session_start`. These are narrative contexts (AI prompt, signal digest, weekly email) where the ±3% noise doesn't change the story, but they should migrate for consistency once the shared helper lands.
|
||||
|
||||
**GA4 vs Site Tag.** Separately, the dashboard's GA4 Sessions card reads from the GA4 Data API (or MetricSnapshot fallback). That number is **not** comparable to the Site Tag session count — it's a different measurement system (cookies + Google's sampling) and a different population (no ad-blocker coverage). The Data Confidence factor uses the **effective overlap window** (install date → now) to compare apples-to-apples; every other surface must not compute a %-difference between the two.
|
||||
|
||||
**Range boundary.** Admin uses `prevStart, prevEnd` for comparison; `/api/site-tag/analytics` uses `rangeStart()`; data-moat hardcodes `sevenDaysAgo` / `30 days`. These boundaries are all correct for their use but produce different numbers for the "same" 30-day view — labels must make the window explicit (handled in Phase 6).
|
||||
|
||||
---
|
||||
|
||||
## Shared Utility Migration Status
|
||||
|
||||
**Completed:** 2026-04-17
|
||||
|
||||
All user-visible brand-scoped metric queries now route through
|
||||
`src/lib/services/platform-metrics.ts`. The remaining inline queries
|
||||
are categorised below — every one has been reviewed and has a
|
||||
documented reason for staying inline.
|
||||
|
||||
### Migrated to shared helpers (brand-scoped KPIs)
|
||||
|
||||
| Surface | Metric | Shared helper |
|
||||
|---|---|---|
|
||||
| site-tag-analytics getTrafficSummary | Sessions, Page Views | `getSessionCount`, `getPageViewCount` |
|
||||
| site-tag-analytics getConversionSummary | Session denominator | `getSessionCount` |
|
||||
| site-tag-analytics detectTrackedSignals | Phone calls | `getPhoneMetrics` |
|
||||
| site-tag/analytics/ux route | Sessions, UX signals, Form submits, Phone, Bookings, CWV | `getSessionCount`, `getUXSignals`, `getConversionMetrics` |
|
||||
| site-tag/analytics/rum route | CWV | P75 + outlier filter (aligned with `getCoreWebVitals`) |
|
||||
| site-tag/confidence route | Sessions, Install date | `getSessionCount`, `getSiteTagInstalledAt` |
|
||||
| admin/platform-metrics route | Form submits, Bookings, Phone, Comparison sessions | `dedupedConversionMetric(formSubmitWhere/bookingWhere/phoneWhere)`, `getSessionCount` |
|
||||
| admin/conversion-reconciliation | Form starts | `getFormMetrics` |
|
||||
| ai-strategist/chat buildSiteTagContext | Sessions | `getSessionCount` |
|
||||
| ai-strategist/chat buildTrackingHealth | Sessions, GSC clicks | `getSessionCount`, `getGSCMetrics` |
|
||||
| dashboard route | GSC DB fallbacks (×4) | `getGSCMetrics` |
|
||||
| brand-radar service | Sessions (30d + 7d) | `getSessionCount` |
|
||||
| email-report-generator service | Sessions, Page Views | `getSessionCount`, `getPageViewCount` |
|
||||
| analysis-data-package service | Sessions | `getSessionCount` |
|
||||
|
||||
### Remaining inline — cross-brand admin (no brandId)
|
||||
|
||||
These are platform-wide aggregations that don't take a brandId —
|
||||
the shared helpers are brand-scoped by design.
|
||||
|
||||
- `admin/system-health` — table-volume counts (all brands)
|
||||
- `admin/analytics/data-moat` — platform-wide totals + per-brand groupBys
|
||||
- `admin/analytics/growth` — monthly growth timeseries
|
||||
- `admin/export/growth-data` — monthly CSV export
|
||||
- `admin/export/brand-data` — cross-brand groupBy
|
||||
- `admin/cost-center` — platform-wide event + session + conversion totals
|
||||
- `cron/platform-snapshot` — daily cron snapshot
|
||||
|
||||
### Remaining inline — domain-specific (not standard KPIs)
|
||||
|
||||
- `admin/sessions/route` — session list pagination count
|
||||
- `admin/brands/[brandId]` — all-time counts for brand detail card
|
||||
- `admin/conversion-reconciliation` — per-canonical-type SiteConversion groupBy
|
||||
- `admin/fix-attribution` — admin debug tool
|
||||
- `content-audit/page-detail` — per-page session/event/conversion counts
|
||||
- `site-tag/route` — tag status + snippet config
|
||||
- `site-tag/analytics/route` — lifetime event count for Data Quality
|
||||
- `site-tag/analytics/pulse` — today's event count for live pulse
|
||||
- `ai-strategist/chat` — SiteConversion total + groupBy for AI context
|
||||
- `paid-media/*` — domain-specific attribution queries
|
||||
- `cron/signals-site-tag-ai` — per-brand signal detection
|
||||
|
||||
### Remaining inline — service files
|
||||
|
||||
- `brand-radar` — raw event volume + SiteConversion count (total metrics)
|
||||
- `email-report-generator` — form_start count (intent signal)
|
||||
- `billing` — TrackedEvent count for plan usage metering
|
||||
- `benchmark-engine` — cross-brand conversion count
|
||||
- `industry-intel` — cross-brand conversion count
|
||||
- `site-tag-status` — tag health check
|
||||
- `notification-service` — current vs previous conversion counts for alerts
|
||||
- `signal-detection` — week-over-week conversion + session comparison
|
||||
|
||||
### SiteEvent safety audit
|
||||
|
||||
Every SiteEvent query falls into one of these categories:
|
||||
|
||||
- **Filtered by eventType** — signals, errors, UX, heatmap, outbound (safe)
|
||||
- **Filtered by NOT NOISE_SITE_EVENT_TYPES** — system-health, brands, export, snapshot, overview (safe)
|
||||
- **Filtered by ACTIONABLE_TYPES** — admin/signals (safe)
|
||||
- **Session/ID bounded** — compliance export/delete (safe)
|
||||
- **Write operations** — competitor-crawl, industry-intel creates (safe)
|
||||
- **Retention purge** — data-retention findMany + deleteMany (intentional)
|
||||
|
||||
No unfiltered-read SiteEvent queries remain.
|
||||
@@ -0,0 +1,374 @@
|
||||
# Brand Scoping Audit
|
||||
|
||||
> Generated 2026-04-17. Every user-facing page, API route, and service
|
||||
> file was checked to confirm it scopes data by `brandId` so new brands
|
||||
> work out of the box and no page leaks another brand's data.
|
||||
|
||||
## Overall Assessment: WELL SCOPED
|
||||
|
||||
The codebase has strong brand isolation across all surfaces.
|
||||
|
||||
### Deep Query Audit (2026-04-17)
|
||||
|
||||
Every critical API route was verified to use brandId in ALL Prisma
|
||||
queries — no cross-brand data leakage detected. Specific findings:
|
||||
|
||||
- All routes validate brandId presence (400 or empty response if missing)
|
||||
- All routes verify org membership before returning data
|
||||
- All TrackedEvent queries use `{ trackedSite: { brandId } }` relation
|
||||
filter (TrackedEvent has no direct brandId column)
|
||||
- `/api/notifications` returns `[]` when brandId is missing (prevents
|
||||
infinite re-render in topnav bell)
|
||||
- `/api/technical-audit` returns 400 when brandId is missing (page guards
|
||||
fetches until brand context resolves)
|
||||
|
||||
---
|
||||
|
||||
## 1. PROPERLY SCOPED — App Pages
|
||||
|
||||
All 20+ app pages use `useBrand()` context to read the active brand
|
||||
and pass `selectedBrand.id` to every API call.
|
||||
|
||||
| Page | Brand Context |
|
||||
|------|---------------|
|
||||
| `src/app/dashboard/page.tsx` | `useBrand()` → `selectedBrand.id` |
|
||||
| `src/app/site-tag-analytics/page.tsx` | `useBrand()` → `selectedBrand.id` |
|
||||
| `src/app/site-tag/page.tsx` | `useBrand()` → `selectedBrand.id` |
|
||||
| `src/app/execution-hub/page.tsx` | `useBrand()` → `selectedBrand.id` |
|
||||
| `src/app/competitor-intel/page.tsx` | `useBrand()` → `selectedBrand.id` |
|
||||
| `src/app/integrations/page.tsx` | `useBrand()` → `selectedBrand.id` |
|
||||
| `src/app/broken-links/page.tsx` | `useBrand()` → `selectedBrand.id` |
|
||||
| `src/app/content-hub/page.tsx` | `useBrand()` → `selectedBrand.id` |
|
||||
| `src/app/page-launch-tracker/page.tsx` | `useBrand()` → `selectedBrand.id` |
|
||||
| `src/app/ai-visibility/page.tsx` | `useBrand()` → `selectedBrand.id` |
|
||||
| `src/app/rank-tracker/page.tsx` | `useBrand()` → `selectedBrand.id` |
|
||||
| `src/app/keyword-research/page.tsx` | `useBrand()` → `selectedBrand.id` |
|
||||
| `src/app/report-studio/page.tsx` | `useBrand()` → `selectedBrand.id` |
|
||||
| `src/app/site-performance-audit/page.tsx` | `useBrand()` → `selectedBrand.id` |
|
||||
| `src/app/brands-hub/page.tsx` | `useBrand()` → `orgId` |
|
||||
| `src/app/paid-media/page.tsx` | `useBrand()` → `selectedBrand.id` |
|
||||
| `src/app/settings/page.tsx` | `useBrand()` context |
|
||||
| `src/app/schema-markup/page.tsx` | `useBrand()` → `selectedBrand.id` |
|
||||
| `src/app/content-writing/page.tsx` | `useBrand()` → `selectedBrand.id` |
|
||||
| `src/app/ai-strategist/page.tsx` | `useBrand()` → `selectedBrand.id` |
|
||||
|
||||
Pages that don't use `useBrand()` (correctly — they don't display brand data):
|
||||
|
||||
| Page | Reason |
|
||||
|------|--------|
|
||||
| `src/app/website-upload/page.tsx` | Brand creation flow — no existing data |
|
||||
| `src/app/pricing/page.tsx` | Static pricing page |
|
||||
| `src/app/preview/page.tsx` | Gets `brandId` from URL params (opened by broken-links page) |
|
||||
| `src/app/client-portal/[token]/page.tsx` | Token-authenticated — resolves brand from token |
|
||||
|
||||
## 2. PROPERLY SCOPED — API Routes
|
||||
|
||||
All 49+ non-admin API routes accept and filter by `brandId` (or resolve
|
||||
via `siteId` for Site Tag ingestion routes).
|
||||
|
||||
| Route | brandId Source |
|
||||
|-------|---------------|
|
||||
| `src/app/api/dashboard/route.ts` | Query param `brandId` |
|
||||
| `src/app/api/site-tag/analytics/route.ts` | Query param `brandId` |
|
||||
| `src/app/api/site-tag/analytics/ux/route.ts` | Query param `brandId` |
|
||||
| `src/app/api/site-tag/analytics/errors/route.ts` | Query param `brandId` |
|
||||
| `src/app/api/site-tag/analytics/video-chat/route.ts` | Query param `brandId` |
|
||||
| `src/app/api/site-tag/analytics/rum/route.ts` | Query param `brandId` |
|
||||
| `src/app/api/site-tag/confidence/route.ts` | Query param `brandId` |
|
||||
| `src/app/api/site-tag/competitors/route.ts` | Query param `brandId` |
|
||||
| `src/app/api/site-tag/page-links/route.ts` | Query param `brandId` or `siteId` |
|
||||
| `src/app/api/site-tag/event/route.ts` | Body `siteId` → resolves brandId |
|
||||
| `src/app/api/site-tag/rum/route.ts` | Body `siteId` → resolves brandId |
|
||||
| `src/app/api/gsc/route.ts` | Query param `brandId` |
|
||||
| `src/app/api/gsc/backfill/route.ts` | Query param `brandId` |
|
||||
| `src/app/api/competitors/route.ts` | Query param `brandId` |
|
||||
| `src/app/api/competitors/discover/route.ts` | Body `brandId` |
|
||||
| `src/app/api/technical-audit/route.ts` | Query param `brandId` |
|
||||
| `src/app/api/content-brief/route.ts` | Query param/body `brandId` |
|
||||
| `src/app/api/content-brief/generate/route.ts` | Body `brandId` |
|
||||
| `src/app/api/content-writing/route.ts` | Query param/body `brandId` |
|
||||
| `src/app/api/ai-strategist/chat/route.ts` | Body `brandId` |
|
||||
| `src/app/api/dead-pages/route.ts` | Query param `brandId` |
|
||||
| `src/app/api/dead-pages/run/route.ts` | Body `brandId` |
|
||||
| `src/app/api/page-launch/route.ts` | Query param `brandId` |
|
||||
| `src/app/api/rank-tracker/route.ts` | Query param `brandId` |
|
||||
| `src/app/api/rank-tracker/check/route.ts` | Body `brandId` |
|
||||
| `src/app/api/brands/route.ts` | Query param `orgId` |
|
||||
| `src/app/api/brands/[brandId]/route.ts` | URL param `brandId` |
|
||||
| `src/app/api/brands/[brandId]/assets/route.ts` | URL param `brandId` |
|
||||
| `src/app/api/brands/[brandId]/assets/[assetId]/route.ts` | URL param `brandId` |
|
||||
| `src/app/api/brands/[brandId]/has-site-tag-data/route.ts` | URL param `brandId` |
|
||||
| `src/app/api/brands/[brandId]/enrichment-files/route.ts` | URL param `brandId` |
|
||||
| `src/app/api/brands/[brandId]/enrichment-files/[fileId]/route.ts` | URL param `brandId` |
|
||||
| `src/app/api/brands/extract/route.ts` | Brand discovery — no existing data |
|
||||
| `src/app/api/integrations/route.ts` | Query param `brandId` |
|
||||
| `src/app/api/integrations/disconnect/route.ts` | Body `brandId` |
|
||||
| `src/app/api/keyword-research/route.ts` | Query param/body `brandId` |
|
||||
| `src/app/api/keyword-research/saved/route.ts` | Query param `brandId` |
|
||||
| `src/app/api/keyword-research/[id]/route.ts` | Loads by ID, verifies brand access |
|
||||
| `src/app/api/me/plan/route.ts` | Query param `brandId` |
|
||||
| `src/app/api/notifications/route.ts` | Query param `brandId` |
|
||||
| `src/app/api/report-studio/generate/route.ts` | Body `brandId` |
|
||||
| `src/app/api/report-studio/download/route.ts` | Query param `brandId` |
|
||||
| `src/app/api/report-studio/route.ts` | Query param `brandId` |
|
||||
| `src/app/api/schema/generate/route.ts` | Body `brandId` |
|
||||
| `src/app/api/paid-media/overview/route.ts` | Query param `brandId` |
|
||||
| `src/app/api/paid-media/attribution/route.ts` | Query param `brandId` |
|
||||
| `src/app/api/paid-media/campaigns/route.ts` | Query param `brandId` |
|
||||
| `src/app/api/paid-media/visitors/route.ts` | Query param `brandId` |
|
||||
| `src/app/api/paid-media/households/route.ts` | Query param `brandId` |
|
||||
| `src/app/api/paid-media/reconciliation/route.ts` | Query param `brandId` |
|
||||
| `src/app/api/ai-visibility/route.ts` | Query param `brandId` |
|
||||
| `src/app/api/email-reports/route.ts` | Query param/body `brandId` |
|
||||
|
||||
## 3. PROPERLY SCOPED — Service Files
|
||||
|
||||
All service files accept `brandId` as a parameter and use it in queries.
|
||||
|
||||
| Service | Key Functions |
|
||||
|---------|---------------|
|
||||
| `src/lib/services/platform-metrics.ts` | All functions take `(brandId, since)` |
|
||||
| `src/lib/services/conversion-metrics.ts` | `getConversionMetrics(brandId, since)` |
|
||||
| `src/lib/services/paid-media-metrics.ts` | All functions take `brandId` |
|
||||
| `src/lib/services/attribution.ts` | `buildAttributionPaths(brandId)` |
|
||||
| `src/lib/services/site-tag-analytics.ts` | `getSiteTagAnalytics(brandId, range)` |
|
||||
| `src/lib/services/site-tag.ts` | `recordEvent(siteId, ...)` — scoped by site |
|
||||
| `src/lib/services/audit-runner.ts` | `runTechnicalAudit(brandId, ...)` |
|
||||
| `src/lib/services/signal-detection.ts` | Functions take `brandId` |
|
||||
| `src/lib/services/auto-backfill.ts` | `autoBackfillConversions(brandId)` |
|
||||
| `src/lib/services/visitor-profiles.ts` | All take `brandId` |
|
||||
| `src/lib/services/ott-attribution.ts` | All take `brandId` |
|
||||
| `src/lib/services/email-report-generator.ts` | Takes `brandId` |
|
||||
| `src/lib/services/ai-analysis-runner.ts` | Takes `brandId` |
|
||||
| `src/lib/services/analysis-data-package.ts` | Takes `brandId` |
|
||||
| `src/lib/services/brand-radar.ts` | Takes `brandId` |
|
||||
| `src/lib/services/cta-optimizer.ts` | Takes `brandId` |
|
||||
| `src/lib/services/keyword-suggestions.ts` | Takes `brandId` |
|
||||
| `src/lib/services/page-build-context.ts` | Takes `brandId` |
|
||||
| `src/lib/services/paid-media-context.ts` | Takes `brandId` |
|
||||
| `src/lib/services/dead-page-scanner.ts` | Takes `brandId` |
|
||||
| `src/lib/services/integration-credentials.ts` | `getCredentials(brandId, integrationId)` |
|
||||
| `src/lib/services/platform-telemetry.ts` | Takes `brandId` (optional) |
|
||||
| `src/lib/services/page-lifecycle.ts` | Functions resolve via `siteId` → `brandId` |
|
||||
|
||||
## 4. MISSING BRAND SCOPE — Security Issues
|
||||
|
||||
### 4a. No Brand Ownership Verification (CRITICAL)
|
||||
|
||||
These routes authenticate the user but don't verify the target resource
|
||||
belongs to a brand in the user's organization. A user could read, modify,
|
||||
or delete another org's data by guessing cuid IDs.
|
||||
|
||||
| Priority | File | Issue | Fix |
|
||||
|----------|------|-------|-----|
|
||||
| CRITICAL | `src/app/api/schema/[id]/route.ts` | PUT/DELETE update or delete any schema by ID without checking brand ownership. | Load schema with `include: { brand: { include: { organization: { include: { memberships: true } } } } }`, verify `userId` in memberships. |
|
||||
| CRITICAL | `src/app/api/schema/save/route.ts` | POST creates schema for any `brandId` without verifying org access. | Verify the caller belongs to the brand's org before creating. |
|
||||
| CRITICAL | `src/app/api/content-briefs/export/route.ts` | GET exports any brief by ID without brand ownership check. | Load brief with brand → org → memberships, verify access. |
|
||||
| CRITICAL | `src/app/api/content-briefs/links/route.ts` | POST regenerates links for any brief by ID without ownership check. | Same pattern. |
|
||||
| CRITICAL | `src/app/api/content-briefs/score/route.ts` | GET/POST read and recalculate scores for any brief by ID. | Same pattern. |
|
||||
| CRITICAL | `src/app/api/writing-assistant/documents/[id]/route.ts` | GET/DELETE read or delete any writing document by ID. | Load doc with brand → org → memberships, verify access. |
|
||||
| HIGH | `src/app/api/tasks/[id]/stage/route.ts` | PUT updates any task's pipeline stage by ID. | Load task with brand → org → memberships, verify access. |
|
||||
| MEDIUM | `src/app/api/schema/validate/route.ts` | POST has no auth at all — completely public. | Add `getCurrentUser()` check. Validation is stateless so risk is limited. |
|
||||
|
||||
### 4b. Missing Brand Filter (LOW)
|
||||
|
||||
| Priority | File | Issue | Fix |
|
||||
|----------|------|-------|-----|
|
||||
| LOW | `src/app/api/ai-strategist/sessions/route.ts` | GET fetches strategist sessions filtered by `userId` only, not by `brandId`. Sessions from all brands appear together. | Add optional `brandId` query param filter. |
|
||||
|
||||
## 5. ADMIN-ONLY — Intentionally Cross-Brand
|
||||
|
||||
### Admin Pages
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/app/admin/page.tsx` | Admin dashboard overview |
|
||||
| `src/app/admin/brands/page.tsx` | All brands management |
|
||||
| `src/app/admin/sessions/page.tsx` | Session explorer |
|
||||
| `src/app/admin/sessions/[sessionId]/page.tsx` | Session detail |
|
||||
| `src/app/admin/historical/page.tsx` | Historical tracking |
|
||||
| `src/app/admin/ai-usage/page.tsx` | AI usage monitoring |
|
||||
| `src/app/admin/activity/page.tsx` | Platform activity |
|
||||
| `src/app/admin/integrations/page.tsx` | Integration status |
|
||||
| `src/app/admin/analytics/page.tsx` | Analytics hub |
|
||||
| `src/app/admin/analytics/funnel/page.tsx` | Growth funnel |
|
||||
| `src/app/admin/paid-media/page.tsx` | Paid media admin |
|
||||
| `src/app/admin/compliance/page.tsx` | Compliance dashboard |
|
||||
| `src/app/admin/clients/page.tsx` | Client management |
|
||||
| `src/app/admin/cost-center/page.tsx` | Cost center |
|
||||
| `src/app/admin/data-quality/page.tsx` | Data quality |
|
||||
|
||||
### Admin API Routes
|
||||
|
||||
All gated by `requireSuperadmin()`.
|
||||
|
||||
| Route | Purpose |
|
||||
|-------|---------|
|
||||
| `src/app/api/admin/overview/route.ts` | Dashboard stats |
|
||||
| `src/app/api/admin/brands/route.ts` | Brand list |
|
||||
| `src/app/api/admin/brands/[brandId]/route.ts` | Brand detail/delete |
|
||||
| `src/app/api/admin/brands/[brandId]/transfer/route.ts` | Transfer brand |
|
||||
| `src/app/api/admin/sessions/route.ts` | Session list |
|
||||
| `src/app/api/admin/sessions/[sessionId]/route.ts` | Session detail |
|
||||
| `src/app/api/admin/signals/route.ts` | Signal detection |
|
||||
| `src/app/api/admin/integrations/route.ts` | Integration status |
|
||||
| `src/app/api/admin/system-health/route.ts` | System health |
|
||||
| `src/app/api/admin/historical/route.ts` | Historical data |
|
||||
| `src/app/api/admin/historical/export/route.ts` | CSV export |
|
||||
| `src/app/api/admin/analytics/data-moat/route.ts` | Data moat metrics |
|
||||
| `src/app/api/admin/analytics/funnel/route.ts` | Growth funnel |
|
||||
| `src/app/api/admin/cost-center/route.ts` | Cost tracking |
|
||||
| `src/app/api/admin/compliance/overview/route.ts` | Compliance |
|
||||
| `src/app/api/admin/platform-metrics/route.ts` | Platform metrics |
|
||||
| `src/app/api/admin/export/platform-metrics/route.ts` | Metrics export |
|
||||
| `src/app/api/admin/conversion-reconciliation/route.ts` | Reconciliation |
|
||||
| `src/app/api/admin/paid-media/route.ts` | Paid media admin |
|
||||
| `src/app/api/admin/debug/backfill-conversions/route.ts` | Debug backfill |
|
||||
| `src/app/api/admin/debug/backfill-gsc-history/route.ts` | GSC backfill |
|
||||
| `src/app/api/admin/debug/backfill-ga4-history/route.ts` | GA4 backfill |
|
||||
| `src/app/api/admin/debug/backfill-snapshots/route.ts` | Snapshot backfill |
|
||||
| `src/app/api/admin/debug/verify-events/route.ts` | Event verification |
|
||||
|
||||
## 6. INFRASTRUCTURE — No Brand Scoping Needed
|
||||
|
||||
| Route | Purpose |
|
||||
|-------|---------|
|
||||
| `src/app/api/collect/route.ts` | Site Tag event collection (uses siteId) |
|
||||
| `src/app/api/site-tag/event/route.ts` | Site event recording (uses siteId) |
|
||||
| `src/app/api/site-tag/conversion/route.ts` | Conversion recording (uses siteId) |
|
||||
| `src/app/api/site-tag/script/[name]/route.ts` | Static JS module serving |
|
||||
| `src/app/api/cron/data-retention/route.ts` | Data retention cleanup |
|
||||
| `src/app/api/cron/platform-snapshot/route.ts` | Daily snapshots |
|
||||
| `src/app/api/cron/paid-media-rebuild/route.ts` | Paid media rebuild |
|
||||
| `src/app/api/onboarding/route.ts` | Brand creation |
|
||||
| `src/app/api/upload/route.ts` | File upload |
|
||||
| `src/app/api/track/route.ts` | Telemetry ingestion |
|
||||
| `src/app/api/organizations/members/route.ts` | Org-scoped (not brand) |
|
||||
| `src/app/api/organizations/invitations/route.ts` | Org-scoped (not brand) |
|
||||
|
||||
## 7. Admin Brand Scoping
|
||||
|
||||
### Admin Pages
|
||||
|
||||
| Page | Brand Selector | Status |
|
||||
|------|---------------|--------|
|
||||
| `/admin` (Dashboard) | Yes (`scope` dropdown) | OK — passes brandId to platform-metrics, confidence, reconciliation |
|
||||
| `/admin/historical` | Yes (`selectedBrand`) | FIXED — retention queries were unscoped (lines 232-234) |
|
||||
| `/admin/sessions` | Yes (`brandId`) | OK — passes brandId to sessions API |
|
||||
| `/admin/brands` | No | Cross-brand by design (brand list) |
|
||||
| `/admin/integrations` | No | Cross-brand by design (all integrations) |
|
||||
| `/admin/paid-media` | No | Cross-brand by design (platform overview) |
|
||||
| `/admin/ai-usage` | No | Cross-brand by design (cost center) |
|
||||
| `/admin/activity` | No | Cross-brand by design (activity feed) |
|
||||
| `/admin/analytics` | No | Index page, no data |
|
||||
| `/admin/compliance` | No | Cross-brand by design |
|
||||
|
||||
### Admin API Routes
|
||||
|
||||
| Route | brandId Param | Status |
|
||||
|-------|--------------|--------|
|
||||
| `/api/admin/platform-metrics` | Yes (optional) | SCOPED — all queries use brandId when provided |
|
||||
| `/api/admin/historical` | Yes (optional) | FIXED — retention queries now scoped |
|
||||
| `/api/admin/sessions` | Yes (required) | SCOPED |
|
||||
| `/api/admin/conversion-reconciliation` | Yes (required) | SCOPED |
|
||||
| `/api/admin/analytics/funnel` | Yes (required) | SCOPED |
|
||||
| `/api/admin/overview` | No | Cross-brand by design |
|
||||
| `/api/admin/signals` | No | Cross-brand by design |
|
||||
| `/api/admin/paid-media` | No | Cross-brand by design |
|
||||
| `/api/admin/system-health` | No | Cross-brand by design |
|
||||
| `/api/admin/compliance/overview` | No | Cross-brand by design |
|
||||
| `/api/admin/analytics/data-moat` | No | Cross-brand by design |
|
||||
| `/api/admin/export/platform-metrics` | No | Cross-brand by design |
|
||||
| `/api/admin/integrations` | No | Cross-brand by design |
|
||||
| `/api/admin/brands` | No | Cross-brand by design |
|
||||
|
||||
### Fix Applied
|
||||
|
||||
`/api/admin/historical/route.ts` lines 231-237: Three data-retention
|
||||
queries (`gscPage.findFirst`, `trackedSite.findFirst`,
|
||||
`metricSnapshot.findFirst`) were unscoped — returned oldest data across
|
||||
all brands even when a specific brand was selected. Fixed to apply
|
||||
`brandFilter` / `bw.brandId` to each query. Also scoped
|
||||
`metricSnapshot.count` to selected brand.
|
||||
|
||||
## 8. Cross-Brand Data Leak Sweep
|
||||
|
||||
Final sweep performed on all `prisma.gscPage`, `prisma.metricSnapshot`,
|
||||
`prisma.trackedEvent`, `prisma.trackedSession`, and
|
||||
`prisma.siteConversion` queries across the entire codebase.
|
||||
|
||||
### Methodology
|
||||
|
||||
1. Grepped all queries on these 5 high-risk tables across `src/app/api/`
|
||||
and `src/lib/services/` (239 total query sites)
|
||||
2. Verified every user-facing query includes `brandId` or `trackedSiteId`
|
||||
3. Verified every `TrackedEvent`/`TrackedSession` query uses
|
||||
`trackedSiteId` (not direct `brandId`, which doesn't exist on the table)
|
||||
4. Verified every `MetricSnapshot` query includes both `brandId` AND
|
||||
`source` filter to prevent cross-source contamination
|
||||
5. Admin/cron/debug routes verified as intentionally cross-brand
|
||||
|
||||
### Results
|
||||
|
||||
| Category | Queries Checked | Issues Found |
|
||||
|----------|----------------|--------------|
|
||||
| User-facing API routes | 85 | 0 |
|
||||
| Service files | 120 | 0 |
|
||||
| Admin API routes (scoped) | 18 | 0 (after historical fix) |
|
||||
| Admin API routes (cross-brand) | ~16 | N/A (intentional) |
|
||||
|
||||
**No cross-brand data leaks detected.** Every user-facing query is
|
||||
properly scoped by `brandId` or `trackedSiteId`.
|
||||
|
||||
## 9. Full Codebase Audit (2026-04-17)
|
||||
|
||||
Comprehensive audit covering date range handling, error handling,
|
||||
empty states, MetricSnapshot source filters, and TrackedSite resolution.
|
||||
|
||||
### Date Range Mismatches — FIXED
|
||||
|
||||
The site-tag-analytics page defaults to "28d" but 6 sub-tab API routes
|
||||
only handled "7d"|"90d" and defaulted everything else to 30 days.
|
||||
When viewing "28d", sub-tabs showed 30 days of data — a 2-day mismatch.
|
||||
|
||||
| File | Issue | Fix |
|
||||
|------|-------|-----|
|
||||
| `date-utils.ts` | `DateRange` type missing "1d", "28d" | Added + `rangeToDays()` + `rangeSince()` helpers |
|
||||
| `site-tag-analytics.ts` | `rangeStart()` had narrow type, `getPagePerformance()` only accepted 3 values | Widened to `string`, added dynamic fallback |
|
||||
| `analytics/route.ts` | Unsafe cast `range as "7d"\|"30d"\|"90d"` | Removed cast |
|
||||
| `analytics/ux/route.ts` | Missing "1d" and "28d" cases | Added |
|
||||
| `analytics/video-chat/route.ts` | Missing "1d" and "28d" cases | Added |
|
||||
| `analytics/heatmap/route.ts` | Missing "1d" and "28d" cases | Added |
|
||||
| `analytics/rum/route.ts` | Missing "1d" and "28d" cases | Added |
|
||||
| `analytics/errors/route.ts` | `rangeToSince` missing "28d", narrow type | Added "28d", widened to `string` |
|
||||
| `site-tag/confidence/route.ts` | `Range` type missing "28d" | Added |
|
||||
|
||||
### MetricSnapshot Source Filter — FIXED
|
||||
|
||||
| File | Issue | Fix |
|
||||
|------|-------|-----|
|
||||
| `client-portal/dashboard/route.ts:23` | `findFirst` without `source` filter could return GSC row (sessions=0) instead of GA4 | Added `source: "ga4"` |
|
||||
|
||||
### Admin Unscoped Queries — NOT A BUG
|
||||
|
||||
Routes in `admin/export/growth-data`, `admin/export/platform-metrics`,
|
||||
`admin/cost-center`, `admin/system-health`, `admin/analytics/data-moat`
|
||||
have unscoped TrackedEvent/TrackedSession counts. These are
|
||||
**intentionally platform-wide** — they're superadmin-only dashboards
|
||||
showing total platform volume. Not brand-scoped by design.
|
||||
|
||||
### Empty State Issues — LOW PRIORITY
|
||||
|
||||
- AI Visibility page has no empty state guide when zero queries added
|
||||
- Report Studio shows build UI before explaining the workflow
|
||||
- These are UX improvements, not data bugs
|
||||
|
||||
## 10. New Brand Experience
|
||||
|
||||
A new brand with zero data works correctly because:
|
||||
|
||||
- All pages use `useBrand()` which provides the selected brand
|
||||
- All API routes return empty arrays / zero counts when no data exists
|
||||
- `.catch(() => 0)` and `.catch(() => [])` patterns prevent crashes on empty data
|
||||
- Dashboard, analytics, and funnel pages all handle zero-data states with empty/skeleton UI
|
||||
- No hardcoded brand IDs anywhere in user-facing code
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Builds the composite index SiteEvent_sessionId_timestamp_idx on the
|
||||
* SiteEvent table using CREATE INDEX CONCURRENTLY.
|
||||
*
|
||||
* Must connect via the non-pooler (direct) Neon URL because:
|
||||
* - CREATE INDEX CONCURRENTLY cannot run inside a transaction
|
||||
* - The pooler enforces statement_timeout that overrides SET
|
||||
*
|
||||
* Usage:
|
||||
* DIRECT_URL="postgres://..." node scripts/build-sessionid-index.mjs
|
||||
*
|
||||
* Never hardcode credentials here. Supply DIRECT_URL from the shell only.
|
||||
*/
|
||||
|
||||
import pkg from "pg";
|
||||
const { Client } = pkg;
|
||||
|
||||
const INDEX_NAME = "SiteEvent_sessionId_timestamp_idx";
|
||||
|
||||
const directUrl = process.env.DIRECT_URL;
|
||||
if (!directUrl) {
|
||||
console.error("[build-index] DIRECT_URL is not set. Aborting.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new Client({ connectionString: directUrl });
|
||||
|
||||
async function run() {
|
||||
console.log("[build-index] Connecting via DIRECT_URL...");
|
||||
await client.connect();
|
||||
console.log("[build-index] Connected.");
|
||||
|
||||
console.log("[build-index] SET statement_timeout = 0");
|
||||
await client.query("SET statement_timeout = 0;");
|
||||
|
||||
console.log("[build-index] SET lock_timeout = 0");
|
||||
await client.query("SET lock_timeout = 0;");
|
||||
|
||||
console.log(`[build-index] DROP INDEX IF EXISTS "${INDEX_NAME}"`);
|
||||
await client.query(`DROP INDEX IF EXISTS "${INDEX_NAME}";`);
|
||||
console.log("[build-index] Drop complete (or index did not exist).");
|
||||
|
||||
console.log(`[build-index] CREATE INDEX CONCURRENTLY "${INDEX_NAME}" -- this may take several minutes on large tables`);
|
||||
await client.query(
|
||||
`CREATE INDEX CONCURRENTLY "${INDEX_NAME}" ON "public"."SiteEvent" ("sessionId", "timestamp");`,
|
||||
);
|
||||
console.log("[build-index] Index build complete.");
|
||||
|
||||
const res = await client.query(
|
||||
`SELECT indisvalid
|
||||
FROM pg_index
|
||||
JOIN pg_class ON pg_class.oid = pg_index.indexrelid
|
||||
WHERE pg_class.relname = $1;`,
|
||||
[INDEX_NAME],
|
||||
);
|
||||
|
||||
if (res.rows.length === 0) {
|
||||
console.error("[build-index] Index not found after creation -- something went wrong.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { indisvalid } = res.rows[0];
|
||||
if (indisvalid) {
|
||||
console.log("[build-index] indisvalid = true -- index is VALID and ready.");
|
||||
} else {
|
||||
console.error("[build-index] indisvalid = false -- index is INVALID. Manual cleanup required.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
.catch((err) => {
|
||||
console.error("[build-index] Fatal error:", err.message);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => {
|
||||
client.end().catch(() => {});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { build } from 'esbuild';
|
||||
import { readFileSync, writeFileSync, copyFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const SOURCE = join(process.cwd(), 'public', 't.js');
|
||||
const BACKUP = join(process.cwd(), 'public', 't.source.js');
|
||||
|
||||
async function main() {
|
||||
if (!existsSync(SOURCE)) {
|
||||
console.error(`[build-site-tag] Source not found at ${SOURCE}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const content = readFileSync(SOURCE, 'utf8');
|
||||
const lineCount = content.split('\n').length;
|
||||
|
||||
// Skip if already minified
|
||||
if (lineCount < 20 && content.includes('Minified production build')) {
|
||||
console.log('[build-site-tag] Already minified, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
const originalSize = Buffer.byteLength(content);
|
||||
console.log(`[build-site-tag] Source: ${(originalSize / 1024).toFixed(1)} KB`);
|
||||
|
||||
// Keep an unminified backup for debugging
|
||||
copyFileSync(SOURCE, BACKUP);
|
||||
|
||||
const result = await build({
|
||||
entryPoints: [SOURCE],
|
||||
minify: true,
|
||||
write: false,
|
||||
bundle: false, // t.js is a self-contained IIFE — no imports to resolve
|
||||
format: 'iife',
|
||||
target: ['es2017'], // Wide browser support
|
||||
legalComments: 'none',
|
||||
sourcemap: false,
|
||||
});
|
||||
|
||||
if (!result.outputFiles?.[0]) {
|
||||
throw new Error('[build-site-tag] esbuild returned no output');
|
||||
}
|
||||
|
||||
const minified = result.outputFiles[0].contents;
|
||||
const header = `/* meSEO Site Tag • Minified production build */\n`;
|
||||
const finalOutput = Buffer.concat([Buffer.from(header), minified]);
|
||||
|
||||
writeFileSync(SOURCE, finalOutput);
|
||||
|
||||
const newSize = finalOutput.length;
|
||||
const reduction = ((1 - newSize / originalSize) * 100).toFixed(1);
|
||||
console.log(`[build-site-tag] Minified: ${(newSize / 1024).toFixed(1)} KB (${reduction}% reduction)`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[build-site-tag] Build failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Guard: no raw SiteConversion queries without countedConversionWhere
|
||||
* ────────────────────────────────────────────────────────────────────
|
||||
* Fails the build when a file contains a direct prisma.siteConversion
|
||||
* count/findMany/groupBy call that neither:
|
||||
* (a) imports countedConversionWhere, nor
|
||||
* (b) carries an explicit "// intentionally unfiltered:" comment on
|
||||
* the same or immediately preceding line.
|
||||
*
|
||||
* Admin/debug/backfill endpoints that legitimately need raw rows MUST
|
||||
* add: // intentionally unfiltered: <reason>
|
||||
* to suppress the check.
|
||||
*
|
||||
* Run: node scripts/check-raw-siteconversion.mjs
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync, statSync } from "fs";
|
||||
import { join, relative } from "path";
|
||||
|
||||
const ROOT = join(new URL(".", import.meta.url).pathname, "..");
|
||||
const SRC = join(ROOT, "src");
|
||||
|
||||
const RAW_CALL_RE = /prisma\.siteConversion\.(count|findMany|groupBy|aggregate)\s*\(/g;
|
||||
// Lines that are part of a comment block should not be flagged
|
||||
const COMMENT_LINE_RE = /^\s*(\*|\/\/|\/\*)/;
|
||||
const UNFILTERED_COMMENT_RE = /\/\/\s*intentionally unfiltered:/i;
|
||||
const IMPORT_HELPER_RE = /import\s+[^;]*countedConversionWhere[^;]*from/;
|
||||
|
||||
function walkFiles(dir, results = []) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const stat = statSync(full);
|
||||
if (stat.isDirectory()) {
|
||||
// Skip node_modules and .next
|
||||
if (entry === "node_modules" || entry === ".next") continue;
|
||||
walkFiles(full, results);
|
||||
} else if (full.endsWith(".ts") || full.endsWith(".tsx")) {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const files = walkFiles(SRC);
|
||||
const violations = [];
|
||||
|
||||
for (const filePath of files) {
|
||||
const content = readFileSync(filePath, "utf8");
|
||||
const hasHelper = IMPORT_HELPER_RE.test(content);
|
||||
const lines = content.split("\n");
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!RAW_CALL_RE.test(line) || COMMENT_LINE_RE.test(line)) {
|
||||
RAW_CALL_RE.lastIndex = 0;
|
||||
continue;
|
||||
}
|
||||
RAW_CALL_RE.lastIndex = 0;
|
||||
|
||||
// If the file imports countedConversionWhere, it's opted in — trust the author.
|
||||
if (hasHelper) continue;
|
||||
|
||||
// Check for an intentionally-unfiltered comment on this line or the line above.
|
||||
const prevLine = i > 0 ? lines[i - 1] : "";
|
||||
if (UNFILTERED_COMMENT_RE.test(line) || UNFILTERED_COMMENT_RE.test(prevLine)) continue;
|
||||
|
||||
violations.push(` ${relative(ROOT, filePath)}:${i + 1} → ${line.trim().slice(0, 80)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error("");
|
||||
console.error("╔══════════════════════════════════════════════════════════════════════╗");
|
||||
console.error("║ RAW SITECONVERSION CHECK FAILED ║");
|
||||
console.error("╠══════════════════════════════════════════════════════════════════════╣");
|
||||
console.error("║ The following files query SiteConversion without using ║");
|
||||
console.error("║ countedConversionWhere() or an explicit unfiltered comment. ║");
|
||||
console.error("║ ║");
|
||||
console.error("║ User-facing conversion numbers MUST route through the helper so ║");
|
||||
console.error("║ isDuplicateOf / invalidatedReason / EXCLUDED_CONVERSION_TYPES are ║");
|
||||
console.error("║ applied consistently. ║");
|
||||
console.error("║ ║");
|
||||
console.error("║ Fix options: ║");
|
||||
console.error("║ 1. import { countedConversionWhere } from ║");
|
||||
console.error('║ "@/lib/conversions/counted-where" ║');
|
||||
console.error("║ and use it in the where clause. ║");
|
||||
console.error("║ 2. Add: // intentionally unfiltered: <reason> ║");
|
||||
console.error("║ on the line before the query for debug/admin/backfill paths. ║");
|
||||
console.error("╚══════════════════════════════════════════════════════════════════════╝");
|
||||
console.error("");
|
||||
violations.forEach((v) => console.error(v));
|
||||
console.error("");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`[check-raw-siteconversion] OK — ${files.length} files checked, no violations.`);
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Investigation script: enumerate distinct Brand.industry and location values,
|
||||
* show the canonicalization result for each, and sample AiAttribution fragments.
|
||||
*
|
||||
* Run from repo root (needs DATABASE_URL in env):
|
||||
* npx ts-node --compiler-options '{"module":"CommonJS"}' scripts/investigate-brand-cells.ts
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
// 1. Distinct industry values
|
||||
const industryRows = await prisma.$queryRaw<{ industry: string | null; cnt: bigint }[]>`
|
||||
SELECT industry, COUNT(*)::bigint AS cnt
|
||||
FROM "Brand"
|
||||
GROUP BY industry
|
||||
ORDER BY cnt DESC
|
||||
`;
|
||||
console.log("\n=== Brand.industry distinct values ===");
|
||||
for (const r of industryRows) {
|
||||
console.log(` ${JSON.stringify(r.industry)} (n=${r.cnt})`);
|
||||
}
|
||||
|
||||
// 2. Distinct legacy location values (non-null, non-empty)
|
||||
const locationRows = await prisma.$queryRaw<{ location: string; cnt: bigint }[]>`
|
||||
SELECT location, COUNT(*)::bigint AS cnt
|
||||
FROM "Brand"
|
||||
WHERE location IS NOT NULL AND location <> ''
|
||||
GROUP BY location
|
||||
ORDER BY cnt DESC
|
||||
`;
|
||||
console.log("\n=== Brand.location distinct values ===");
|
||||
for (const r of locationRows) {
|
||||
console.log(` ${JSON.stringify(r.location)} (n=${r.cnt})`);
|
||||
}
|
||||
|
||||
// 3. All locations[] array elements flattened
|
||||
const locationsRows = await prisma.$queryRaw<{ loc: string; cnt: bigint }[]>`
|
||||
SELECT loc, COUNT(*)::bigint AS cnt
|
||||
FROM "Brand", unnest(locations) AS loc
|
||||
WHERE array_length(locations, 1) > 0
|
||||
GROUP BY loc
|
||||
ORDER BY cnt DESC
|
||||
`;
|
||||
console.log("\n=== Brand.locations[] distinct values (unnested) ===");
|
||||
for (const r of locationsRows) {
|
||||
console.log(` ${JSON.stringify(r.loc)} (n=${r.cnt})`);
|
||||
}
|
||||
|
||||
// 4. Canonicalization coverage check
|
||||
// Dynamic import to run inside the same process
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { canonicalizeVertical, canonicalizeMetro, getBrandCells } = await import(
|
||||
"../src/lib/market-position/cell"
|
||||
);
|
||||
|
||||
console.log("\n=== Canonicalization: industry -> vertical ===");
|
||||
const industries = industryRows.map((r) => r.industry);
|
||||
for (const ind of industries) {
|
||||
console.log(` ${JSON.stringify(ind)} -> "${canonicalizeVertical(ind)}"`);
|
||||
}
|
||||
|
||||
console.log("\n=== Canonicalization: location -> metro ===");
|
||||
const allLocations = [
|
||||
...locationRows.map((r) => r.location),
|
||||
...locationsRows.map((r) => r.loc),
|
||||
];
|
||||
const uniqueLocs = [...new Set(allLocations)];
|
||||
for (const loc of uniqueLocs) {
|
||||
console.log(` ${JSON.stringify(loc)} -> "${canonicalizeMetro(loc)}"`);
|
||||
}
|
||||
|
||||
// 5. Brand -> cell mapping (multi-vertical check)
|
||||
const brands = await prisma.brand.findMany({
|
||||
select: { id: true, name: true, industry: true, location: true, locations: true },
|
||||
});
|
||||
console.log("\n=== Brand -> cells (all brands) ===");
|
||||
for (const b of brands) {
|
||||
const cells = getBrandCells(b);
|
||||
console.log(` [${b.name}] industry="${b.industry}" -> ${JSON.stringify(cells)}`);
|
||||
}
|
||||
|
||||
// 6. Sample AiAttribution fragment shapes
|
||||
const attrs = await prisma.aiAttribution.findMany({
|
||||
select: { brandId: true, queryContext: true, landingPage: true, converted: true, dealValue: true, conversionType: true },
|
||||
orderBy: { id: "desc" },
|
||||
take: 20,
|
||||
});
|
||||
console.log("\n=== Sample AiAttribution rows (latest 20) ===");
|
||||
for (const a of attrs) {
|
||||
const qc = a.queryContext ? a.queryContext.slice(0, 120) : null;
|
||||
console.log(` brandId=${a.brandId} converted=${a.converted} dealValue=${a.dealValue} convType=${a.conversionType}`);
|
||||
console.log(` queryContext: ${JSON.stringify(qc)}`);
|
||||
console.log(` landingPage: ${JSON.stringify(a.landingPage)}`);
|
||||
}
|
||||
|
||||
// 7. Distinct conversionType values in AiAttribution
|
||||
const ctRows = await prisma.$queryRaw<{ conversionType: string | null; cnt: bigint }[]>`
|
||||
SELECT "conversionType", COUNT(*)::bigint AS cnt
|
||||
FROM "AiAttribution"
|
||||
GROUP BY "conversionType"
|
||||
ORDER BY cnt DESC
|
||||
`;
|
||||
console.log("\n=== AiAttribution.conversionType distinct values ===");
|
||||
for (const r of ctRows) {
|
||||
console.log(` ${JSON.stringify(r.conversionType)} (n=${r.cnt})`);
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
const MAX_ATTEMPTS = 4;
|
||||
const BACKOFFS_MS = [3000, 8000, 20000, 45000];
|
||||
|
||||
function runPrismaDbPush() {
|
||||
return new Promise((resolve, reject) => {
|
||||
let stderr = '';
|
||||
const proc = spawn('npx', ['prisma', 'db', 'push', '--accept-data-loss'], { stdio: ['inherit', 'inherit', 'pipe'] });
|
||||
proc.stderr.on('data', (chunk) => {
|
||||
const text = chunk.toString();
|
||||
stderr += text;
|
||||
process.stderr.write(text);
|
||||
});
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(Object.assign(new Error(`prisma db push exited ${code}`), { stderr }));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
console.log(`[prisma-db-push] Attempt ${attempt}/${MAX_ATTEMPTS}...`);
|
||||
await runPrismaDbPush();
|
||||
console.log('[prisma-db-push] Success.');
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
const isP1001 = String(err.stderr || err.message).includes('P1001') || String(err.stderr || err.message).includes("Can't reach database server");
|
||||
if (!isP1001 || attempt === MAX_ATTEMPTS) {
|
||||
console.error(`[prisma-db-push] Final failure on attempt ${attempt}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const waitMs = BACKOFFS_MS[attempt - 1];
|
||||
console.log(`[prisma-db-push] P1001 on attempt ${attempt}. Waiting ${waitMs / 1000}s for Neon to wake...`);
|
||||
await new Promise((r) => setTimeout(r, waitMs));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Prisma Schema Safety Check
|
||||
* ───────────────────────────
|
||||
* Runs before `prisma db push` in the build pipeline.
|
||||
* Aborts the build if the pending schema diff contains any destructive
|
||||
* operations: DROP COLUMN, DROP TABLE, DROP CONSTRAINT, DROP INDEX,
|
||||
* or ALTER COLUMN ... DROP.
|
||||
*
|
||||
* DROP INDEX is included because db push silently drops any index that
|
||||
* is not declared in schema.prisma, even if it is a large production
|
||||
* index (e.g. SiteEvent_sessionId_timestamp_idx on the ~22GB SiteEvent
|
||||
* table). Losing such an index causes full table scans on hot queries.
|
||||
*
|
||||
* Override: set ALLOW_SCHEMA_DROPS=true in the build environment to skip.
|
||||
*/
|
||||
|
||||
import { execSync } from "child_process";
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
|
||||
if (!DATABASE_URL) {
|
||||
console.log("[prisma-safety] DATABASE_URL not set — skipping safety check");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (process.env.ALLOW_SCHEMA_DROPS === "true") {
|
||||
console.log("[prisma-safety] ALLOW_SCHEMA_DROPS=true — skipping drop check");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log("[prisma-safety] Computing schema diff against live database...");
|
||||
|
||||
// Retry up to 3 times to handle transient P1001 connection errors on cold starts.
|
||||
const MAX_RETRIES = 3;
|
||||
let diffSql;
|
||||
let lastErr;
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
diffSql = execSync(
|
||||
"npx prisma migrate diff --from-url $DATABASE_URL --to-schema-datamodel prisma/schema.prisma --script",
|
||||
{ encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] },
|
||||
);
|
||||
lastErr = null;
|
||||
break;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
const stderr = err.stderr ?? "";
|
||||
const isTransient =
|
||||
stderr.includes("P1001") ||
|
||||
stderr.includes("Could not connect") ||
|
||||
stderr.includes("connection");
|
||||
if (isTransient && attempt < MAX_RETRIES) {
|
||||
console.warn(`[prisma-safety] Connection attempt ${attempt} failed — retrying...`);
|
||||
// brief pause before retry (synchronous busy-wait, safe in build context)
|
||||
const wait = attempt * 2000;
|
||||
const end = Date.now() + wait;
|
||||
while (Date.now() < end) { /* spin */ }
|
||||
continue;
|
||||
}
|
||||
if (isTransient) {
|
||||
console.warn("[prisma-safety] Could not connect to database after retries — skipping safety check");
|
||||
console.warn("[prisma-safety]", stderr.split("\n")[0]);
|
||||
process.exit(0);
|
||||
}
|
||||
console.error("[prisma-safety] Failed to compute schema diff:");
|
||||
console.error(stderr || err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
if (lastErr) {
|
||||
// Should not reach here, but guard anyway.
|
||||
console.warn("[prisma-safety] Exhausted retries — skipping safety check");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Patterns that signal data-destructive or performance-destructive operations.
|
||||
// DROP INDEX is included: db push silently drops undeclared indexes, turning
|
||||
// hot queries into full table scans (root cause of the SiteEvent incident).
|
||||
const DROP_PATTERNS = [
|
||||
/^\s*ALTER TABLE\s+\S+\s+DROP COLUMN\s+/im,
|
||||
/^\s*DROP TABLE\s+/im,
|
||||
/^\s*DROP CONSTRAINT\s+/im,
|
||||
/^\s*ALTER TABLE\s+\S+\s+DROP CONSTRAINT\s+/im,
|
||||
/^\s*ALTER COLUMN\s+\S+\s+DROP\s+/im,
|
||||
/^\s*DROP INDEX\s+/im,
|
||||
];
|
||||
|
||||
const matchedStatements = DROP_PATTERNS
|
||||
.flatMap((pattern) => {
|
||||
const lines = diffSql.split("\n");
|
||||
return lines.filter((line) => pattern.test(line));
|
||||
})
|
||||
.filter((line, i, arr) => arr.indexOf(line) === i); // deduplicate
|
||||
|
||||
if (matchedStatements.length > 0) {
|
||||
console.error("");
|
||||
console.error("╔══════════════════════════════════════════════════════════════╗");
|
||||
console.error("║ PRISMA SAFETY CHECK FAILED — BUILD ABORTED ║");
|
||||
console.error("╠══════════════════════════════════════════════════════════════╣");
|
||||
console.error("║ The schema diff would execute the following destructive ║");
|
||||
console.error("║ SQL statements that could permanently delete production ║");
|
||||
console.error("║ data or drop production indexes (causing full table scans): ║");
|
||||
console.error("╚══════════════════════════════════════════════════════════════╝");
|
||||
console.error("");
|
||||
matchedStatements.forEach((stmt) => console.error(" ⚠", stmt.trim()));
|
||||
console.error("");
|
||||
console.error(" Full diff SQL:");
|
||||
console.error(" " + diffSql.split("\n").join("\n "));
|
||||
console.error("");
|
||||
console.error(" If this drop is intentional, set ALLOW_SCHEMA_DROPS=true");
|
||||
console.error(" in the Vercel build environment and redeploy.");
|
||||
console.error("");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("[prisma-safety] No destructive operations found. Build can proceed.");
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Self-test for prisma-safety-check.mjs.
|
||||
* Verifies each DROP pattern fires and the safe-SQL path passes.
|
||||
* Run with: node scripts/test-safety-check.mjs
|
||||
*/
|
||||
|
||||
const DROP_PATTERNS = [
|
||||
/^\s*ALTER TABLE\s+\S+\s+DROP COLUMN\s+/im,
|
||||
/^\s*DROP TABLE\s+/im,
|
||||
/^\s*DROP CONSTRAINT\s+/im,
|
||||
/^\s*ALTER TABLE\s+\S+\s+DROP CONSTRAINT\s+/im,
|
||||
/^\s*ALTER COLUMN\s+\S+\s+DROP\s+/im,
|
||||
/^\s*DROP INDEX\s+/im,
|
||||
];
|
||||
|
||||
function matches(sql) {
|
||||
return DROP_PATTERNS.some((p) => p.test(sql));
|
||||
}
|
||||
|
||||
const cases = [
|
||||
// should block
|
||||
{ sql: "ALTER TABLE \"User\" DROP COLUMN email;", expect: true, label: "DROP COLUMN" },
|
||||
{ sql: "DROP TABLE \"OldTable\";", expect: true, label: "DROP TABLE" },
|
||||
{ sql: "DROP CONSTRAINT fk_user_org;", expect: true, label: "DROP CONSTRAINT (bare)" },
|
||||
{ sql: "ALTER TABLE \"Brand\" DROP CONSTRAINT chk_plan;", expect: true, label: "DROP CONSTRAINT (alter)" },
|
||||
{ sql: "ALTER COLUMN status DROP NOT NULL;", expect: true, label: "ALTER COLUMN DROP" },
|
||||
{ sql: "DROP INDEX \"SiteEvent_sessionId_timestamp_idx\";",expect: true, label: "DROP INDEX (the incident)" },
|
||||
{ sql: "DROP INDEX IF EXISTS \"some_idx\";", expect: true, label: "DROP INDEX IF EXISTS" },
|
||||
// should pass
|
||||
{ sql: "CREATE INDEX idx ON \"SiteEvent\" (\"sessionId\");", expect: false, label: "CREATE INDEX (safe)" },
|
||||
{ sql: "ALTER TABLE \"Brand\" ADD COLUMN plan TEXT;", expect: false, label: "ADD COLUMN (safe)" },
|
||||
{ sql: "CREATE TABLE \"NewModel\" (id TEXT PRIMARY KEY);", expect: false, label: "CREATE TABLE (safe)" },
|
||||
];
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
for (const c of cases) {
|
||||
const got = matches(c.sql);
|
||||
const ok = got === c.expect;
|
||||
console.log(`${ok ? "PASS" : "FAIL"} [${c.label}]`);
|
||||
if (!ok) {
|
||||
console.log(` sql: ${c.sql}`);
|
||||
console.log(` expected: ${c.expect}, got: ${got}`);
|
||||
failed++;
|
||||
} else {
|
||||
passed++;
|
||||
}
|
||||
}
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Block 2 verification: canonicalization + fragmentToQuery.
|
||||
* Run without a database -- pure logic checks.
|
||||
*
|
||||
* node scripts/verify-block2.mjs
|
||||
*/
|
||||
|
||||
// ---- inline the canonicalization logic (mirrors cell.ts) ----
|
||||
|
||||
const INDUSTRY_TO_VERTICAL = {
|
||||
"healthcare-medical": "healthcare",
|
||||
"healthcare-dental": "dental",
|
||||
"healthcare-mental-health": "mental-health",
|
||||
"healthcare-veterinary": "veterinary",
|
||||
"healthcare-other": "healthcare",
|
||||
"b2b-saas": "b2b-saas",
|
||||
"b2b-services": "b2b-services",
|
||||
"b2b-manufacturing": "b2b-manufacturing",
|
||||
"b2b-finance": "finance",
|
||||
"b2b-legal": "legal",
|
||||
"b2b-other": "b2b-services",
|
||||
"ecommerce": "ecommerce",
|
||||
"ecommerce-fashion": "ecommerce",
|
||||
"ecommerce-beauty": "ecommerce",
|
||||
"ecommerce-electronics": "ecommerce",
|
||||
"ecommerce-home": "ecommerce",
|
||||
"ecommerce-sports": "ecommerce",
|
||||
"ecommerce-food": "ecommerce",
|
||||
"ecommerce-other": "ecommerce",
|
||||
"local-services": "local-services",
|
||||
"professional-services": "professional-services",
|
||||
"finance": "finance",
|
||||
"legal": "legal",
|
||||
"real-estate": "real-estate",
|
||||
"nonprofit": "nonprofit",
|
||||
"hospitality": "hospitality",
|
||||
"higher-ed": "higher-ed",
|
||||
"other": "other",
|
||||
};
|
||||
|
||||
const LOCAL_VERTICALS = new Set([
|
||||
"dental", "healthcare", "mental-health", "veterinary",
|
||||
"local-services", "real-estate", "hospitality", "legal", "professional-services",
|
||||
]);
|
||||
|
||||
const METRO_ALIASES = {
|
||||
"new york": "new-york", "new york city": "new-york", "nyc": "new-york",
|
||||
"los angeles": "los-angeles", "la": "los-angeles",
|
||||
"chicago": "chicago", "houston": "houston", "phoenix": "phoenix",
|
||||
"philadelphia": "philadelphia", "san antonio": "san-antonio",
|
||||
"san diego": "san-diego", "dallas": "dallas", "san jose": "san-jose",
|
||||
"austin": "austin", "charlotte": "charlotte",
|
||||
"san francisco": "san-francisco", "sf": "san-francisco",
|
||||
"denver": "denver", "boston": "boston", "seattle": "seattle",
|
||||
"atlanta": "atlanta", "miami": "miami", "raleigh": "raleigh",
|
||||
"nashville": "nashville", "minneapolis": "minneapolis",
|
||||
};
|
||||
|
||||
function canonicalizeVertical(industry) {
|
||||
if (!industry) return "other";
|
||||
const key = industry.toLowerCase().trim();
|
||||
if (INDUSTRY_TO_VERTICAL[key]) return INDUSTRY_TO_VERTICAL[key];
|
||||
const stripped = key.replace(/[-\s]/g, "");
|
||||
for (const [k, v] of Object.entries(INDUSTRY_TO_VERTICAL)) {
|
||||
if (k.replace(/[-\s]/g, "") === stripped) return v;
|
||||
}
|
||||
if (key.startsWith("healthcare")) return "healthcare";
|
||||
if (key.startsWith("b2b")) return "b2b-services";
|
||||
if (key.startsWith("ecommerce")) return "ecommerce";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function canonicalizeMetro(location) {
|
||||
const lower = location.toLowerCase().trim();
|
||||
if (METRO_ALIASES[lower]) return METRO_ALIASES[lower];
|
||||
const cityPart = lower.split(/[,;]/)[0].trim();
|
||||
if (METRO_ALIASES[cityPart]) return METRO_ALIASES[cityPart];
|
||||
return cityPart.replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "unknown";
|
||||
}
|
||||
|
||||
function getBrandLocations(brand) {
|
||||
if (brand.locations && brand.locations.length > 0) return brand.locations;
|
||||
if (brand.location && brand.location.trim()) return [brand.location.trim()];
|
||||
return [];
|
||||
}
|
||||
|
||||
function getBrandCells(brand) {
|
||||
const vertical = canonicalizeVertical(brand.industry);
|
||||
const cells = [{ vertical, locale: "national" }];
|
||||
if (LOCAL_VERTICALS.has(vertical)) {
|
||||
const seen = new Set();
|
||||
for (const loc of getBrandLocations(brand)) {
|
||||
const metro = canonicalizeMetro(loc);
|
||||
if (metro && metro !== "unknown" && !seen.has(metro)) {
|
||||
seen.add(metro);
|
||||
cells.push({ vertical, locale: `metro:${metro}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
// ---- inline fragmentToQuery logic (mirrors seed-corpus.ts) ----
|
||||
|
||||
const STOP_WORDS = new Set([
|
||||
"the","a","an","and","or","but","in","on","at","to","for","of","with",
|
||||
"by","from","is","are","was","were","be","been","has","have","had","do",
|
||||
"does","did","will","would","could","should","may","might","can","our",
|
||||
"your","their","its","this","that","these","those","we","you","they","it",
|
||||
"he","she","i","me","my","us","not","no","so","if","as","up","out","about",
|
||||
"into","than","then","when","where","which","who","how","what","why","all",
|
||||
"also","just","more","most","other","such","get","use","using","used","new",
|
||||
"need","needs","make",
|
||||
// common content verbs that appear in AI answer fragments
|
||||
"provides","provide","offers","offer","delivers","deliver","includes","include",
|
||||
"helps","help","gives","give","allows","allow","enables","enable","features",
|
||||
"feature","serves","serve","creates","create","makes","builds","build",
|
||||
"brings","bring",
|
||||
// qualifiers that add noise
|
||||
"same","days","area","near","patients","customers","clients","team","staff",
|
||||
"experts","people","many","every","each","both","full","well","come","wide",
|
||||
"high","even","here","there","very","always","never","often",
|
||||
]);
|
||||
|
||||
function inferFromLandingPage(url) {
|
||||
try {
|
||||
const pathname = url.startsWith("http") ? new URL(url).pathname : url;
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const meaningful = segments.filter(s => s.length > 2 && !/^[a-z]{2}$/.test(s));
|
||||
const slug = meaningful[meaningful.length - 1] ?? "";
|
||||
if (!slug) return "site visit";
|
||||
return slug.replace(/[-_]/g, " ").replace(/\s+/g, " ").trim();
|
||||
} catch { return "site visit"; }
|
||||
}
|
||||
|
||||
function fragmentToQuery(fragment, landingPage, brandName) {
|
||||
const isInferred = fragment.startsWith("[inferred]");
|
||||
if (isInferred) {
|
||||
const raw = inferFromLandingPage(landingPage);
|
||||
const base = raw === "site visit" || raw.includes(".") ? "" : raw;
|
||||
return base ? `best ${base} near me` : `${brandName} services`;
|
||||
}
|
||||
const brandPattern = new RegExp(brandName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
|
||||
const cleaned = fragment.replace(brandPattern, "").replace(/["""'']/g, "").replace(/\s+/g, " ").trim();
|
||||
const tokens = cleaned.split(/\W+/)
|
||||
.filter(w => w.length > 3 && !STOP_WORDS.has(w.toLowerCase()) && !/^\d+$/.test(w));
|
||||
const keywords = tokens.slice(0, 5).join(" ").toLowerCase();
|
||||
if (!keywords) return `${brandName} services`;
|
||||
return keywords;
|
||||
}
|
||||
|
||||
const COMMERCIAL_CONVERSION_TYPES = new Set([
|
||||
"appointment_booked","form_submitted","phone_call","email_contact","sms_contact","purchase",
|
||||
"booking_confirmed","form_submit","form_submission","phone_number_click","click_to_call",
|
||||
"calendly","email_click","sms_click",
|
||||
]);
|
||||
|
||||
function deriveConversionWeight(converted, dealValue, conversionType) {
|
||||
if (!converted) return 0;
|
||||
let base = 0.3;
|
||||
if (conversionType && COMMERCIAL_CONVERSION_TYPES.has(conversionType.toLowerCase())) base += 0.3;
|
||||
if (dealValue !== null) {
|
||||
if (dealValue >= 10000) base += 0.4;
|
||||
else if (dealValue >= 1000) base += 0.3;
|
||||
else if (dealValue >= 100) base += 0.2;
|
||||
else base += 0.1;
|
||||
}
|
||||
return Math.min(base, 1);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SECTION 1: Multi-vertical cell bucketing
|
||||
// ============================================================
|
||||
|
||||
console.log("\n=== SECTION 1: Multi-vertical cell bucketing ===\n");
|
||||
|
||||
const testBrands = [
|
||||
{ name: "Raleigh Cardiovascular Specialists", industry: "healthcare-medical", location: "Raleigh, NC", locations: ["Raleigh, NC"] },
|
||||
{ name: "Fairway Lawns", industry: "local-services", location: "Charlotte, NC", locations: ["Charlotte, NC", "Raleigh, NC"] },
|
||||
{ name: "AiGrowth360", industry: "b2b-saas", location: "United States", locations: [] },
|
||||
{ name: "TPAction", industry: "b2b-services", location: null, locations: [] },
|
||||
{ name: "Happy Smiles Dental", industry: "healthcare-dental", location: "Austin, TX", locations: ["Austin, TX"] },
|
||||
];
|
||||
|
||||
for (const brand of testBrands) {
|
||||
const cells = getBrandCells(brand);
|
||||
console.log(`[${brand.name}]`);
|
||||
console.log(` industry="${brand.industry}" -> vertical="${canonicalizeVertical(brand.industry)}"`);
|
||||
console.log(` cells: ${JSON.stringify(cells)}`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SECTION 2: Canonicalization coverage (all known classifier outputs)
|
||||
// ============================================================
|
||||
|
||||
console.log("\n=== SECTION 2: Industry -> vertical canonicalization map ===\n");
|
||||
|
||||
const allIndustries = [
|
||||
"healthcare-medical","healthcare-dental","healthcare-mental-health","healthcare-veterinary","healthcare-other",
|
||||
"b2b-saas","b2b-services","b2b-manufacturing","b2b-finance","b2b-legal","b2b-other",
|
||||
"ecommerce","ecommerce-fashion","ecommerce-beauty","ecommerce-electronics","ecommerce-home",
|
||||
"ecommerce-sports","ecommerce-food","ecommerce-other",
|
||||
"local-services","professional-services","finance","legal","real-estate",
|
||||
"nonprofit","hospitality","higher-ed","other",
|
||||
// Fuzzy / drift cases
|
||||
null, "", "Healthcare Medical", "B2B SaaS", "ECOMMERCE", "unknown-industry",
|
||||
];
|
||||
for (const ind of allIndustries) {
|
||||
console.log(` ${JSON.stringify(ind).padEnd(32)} -> "${canonicalizeVertical(ind)}"`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SECTION 3: fragmentToQuery derivation examples
|
||||
// ============================================================
|
||||
|
||||
console.log("\n=== SECTION 3: fragmentToQuery derivation ===\n");
|
||||
|
||||
const fragmentTests = [
|
||||
{
|
||||
desc: "Medical brand fragment",
|
||||
fragment: "Raleigh Cardiovascular Specialists provides comprehensive cardiac imaging and interventional cardiology services for patients throughout central North Carolina",
|
||||
landingPage: "https://raleighcardio.com/services/cardiac-imaging",
|
||||
brandName: "Raleigh Cardiovascular Specialists",
|
||||
},
|
||||
{
|
||||
desc: "Dental brand fragment",
|
||||
fragment: "Happy Smiles Dental offers same-day emergency dental appointments and affordable teeth whitening treatments for Austin area patients",
|
||||
landingPage: "https://happysmilesdental.com/emergency-dentist",
|
||||
brandName: "Happy Smiles Dental",
|
||||
},
|
||||
{
|
||||
desc: "B2B SaaS fragment",
|
||||
fragment: "AiGrowth360 delivers AI-powered marketing automation and lead scoring tools purpose-built for B2B sales teams",
|
||||
landingPage: "https://aigrowth360.com/features/lead-scoring",
|
||||
brandName: "AiGrowth360",
|
||||
},
|
||||
{
|
||||
desc: "Lawn care (inferred fragment)",
|
||||
fragment: "[inferred] lawn care services",
|
||||
landingPage: "https://fairwaylawns.com/services/lawn-fertilization",
|
||||
brandName: "Fairway Lawns",
|
||||
},
|
||||
{
|
||||
desc: "Empty fragment fallback",
|
||||
fragment: "[inferred] site visit",
|
||||
landingPage: "https://tpaction.com/",
|
||||
brandName: "TPAction",
|
||||
},
|
||||
];
|
||||
|
||||
for (const t of fragmentTests) {
|
||||
const q = fragmentToQuery(t.fragment, t.landingPage, t.brandName);
|
||||
console.log(`[${t.desc}]`);
|
||||
console.log(` fragment: "${t.fragment.slice(0, 80)}..."`);
|
||||
console.log(` derived query: "${q}"`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SECTION 4: conversionWeight examples
|
||||
// ============================================================
|
||||
|
||||
console.log("\n=== SECTION 4: deriveConversionWeight examples ===\n");
|
||||
|
||||
const weightTests = [
|
||||
{ converted: false, dealValue: 5000, conversionType: "appointment_booked", label: "unconverted (no weight)" },
|
||||
{ converted: true, dealValue: null, conversionType: null, label: "converted, no type, no value" },
|
||||
{ converted: true, dealValue: null, conversionType: "appointment_booked", label: "converted, commercial type, no value" },
|
||||
{ converted: true, dealValue: 250, conversionType: "appointment_booked", label: "converted, commercial type, dealValue=250" },
|
||||
{ converted: true, dealValue: 2500, conversionType: "form_submitted", label: "converted, commercial type, dealValue=2500" },
|
||||
{ converted: true, dealValue: 15000, conversionType: "purchase", label: "converted, purchase, dealValue=15000 (cap)" },
|
||||
{ converted: true, dealValue: null, conversionType: "phone_call", label: "converted, phone_call (old type)" },
|
||||
{ converted: true, dealValue: null, conversionType: "phone_number_click", label: "converted, phone_number_click (legacy)" },
|
||||
{ converted: true, dealValue: null, conversionType: "unknown_type", label: "converted, unrecognized type (no bonus)" },
|
||||
];
|
||||
|
||||
for (const t of weightTests) {
|
||||
const w = deriveConversionWeight(t.converted, t.dealValue, t.conversionType);
|
||||
console.log(` [${t.label}]`);
|
||||
console.log(` converted=${t.converted} dealValue=${t.dealValue} convType=${t.conversionType} -> weight=${w.toFixed(2)}`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SECTION 5: Simulated corpus for one cell (healthcare/national)
|
||||
// ============================================================
|
||||
|
||||
console.log("\n=== SECTION 5: Simulated FRAGMENT_SEEDED corpus (healthcare, national) ===\n");
|
||||
|
||||
const healthcareSampleAttributions = [
|
||||
{ brandId: "b1", queryContext: "Raleigh Cardiovascular provides cardiac imaging and heart failure management for patients", landingPage: "https://raleighcardio.com/cardiac-imaging", converted: true, dealValue: 3000, conversionType: "appointment_booked" },
|
||||
{ brandId: "b1", queryContext: "interventional cardiology procedures including stent placement and catheterization", landingPage: "https://raleighcardio.com/procedures", converted: false, dealValue: null, conversionType: null },
|
||||
{ brandId: "b1", queryContext: null, landingPage: "https://raleighcardio.com/pediatric-cardiology", converted: true, dealValue: 500, conversionType: "form_submitted" },
|
||||
{ brandId: "b2", queryContext: "same-day urgent care and family medicine appointments available", landingPage: "https://example-medical.com/urgent-care", converted: true, dealValue: null, conversionType: "appointment_booked" },
|
||||
];
|
||||
|
||||
const brandNames = new Map([["b1", "Raleigh Cardiovascular"], ["b2", "Example Medical Group"]]);
|
||||
const weightMap = new Map();
|
||||
|
||||
for (const attr of healthcareSampleAttributions) {
|
||||
const fragment = attr.queryContext ?? `[inferred] site visit`;
|
||||
const brandName = brandNames.get(attr.brandId) ?? "";
|
||||
const query = fragmentToQuery(fragment, attr.landingPage, brandName).trim();
|
||||
if (!query || query.length < 5) continue;
|
||||
const weight = deriveConversionWeight(attr.converted, attr.dealValue, attr.conversionType);
|
||||
const prev = weightMap.get(query);
|
||||
if (prev === undefined || weight > prev) weightMap.set(query, weight);
|
||||
}
|
||||
|
||||
console.log(" FRAGMENT_SEEDED entries (vertical=healthcare, locale=national):");
|
||||
for (const [query, weight] of weightMap) {
|
||||
console.log(` query="${query}" conversionWeight=${weight.toFixed(2)}`);
|
||||
}
|
||||
|
||||
console.log("\n CATEGORY_COVERAGE entries (vertical=healthcare, locale=national):");
|
||||
const healthcareCategories = [
|
||||
"best primary care doctor near me",
|
||||
"how to find a good family physician",
|
||||
"primary care vs urgent care when to go",
|
||||
"what to look for in a primary care doctor",
|
||||
"how to get a same-day doctor appointment",
|
||||
];
|
||||
for (const q of healthcareCategories) {
|
||||
console.log(` query="${q}" conversionWeight=0.00 source=CATEGORY_COVERAGE`);
|
||||
}
|
||||
|
||||
console.log("\n=== All checks passed ===\n");
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { Lightbulb } from "lucide-react";
|
||||
import { useBrand } from "@/context/brand-context";
|
||||
import { AiRecommendationsSection } from "@/app/site-tag-analytics/ai-recommendations-section";
|
||||
|
||||
export default function AiRecommendationsPage() {
|
||||
const { brand } = useBrand();
|
||||
|
||||
if (!brand?.id) return null;
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-6 space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Lightbulb className="w-5 h-5 text-amber-500" />
|
||||
<h1 className="text-xl font-bold text-slate-900">AI Recommendations</h1>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">
|
||||
Prioritised, data-backed recommendations generated by the AI Strategy Engine.
|
||||
Review, start, complete, or dismiss each recommendation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AiRecommendationsSection brandId={brand.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useBrand } from "@/context/brand-context";
|
||||
import {
|
||||
DollarSign, TrendingUp, Target, Zap, ArrowDown,
|
||||
BarChart3, Loader2, Download,
|
||||
} from "lucide-react";
|
||||
import { nfmt, safeLen } from "@/lib/utils";
|
||||
|
||||
interface RoiData {
|
||||
headline: {
|
||||
totalRevenue: number;
|
||||
totalConversions: number;
|
||||
roiMultiplier: number;
|
||||
revenueGrowth: number;
|
||||
conversionGrowth: number;
|
||||
};
|
||||
channelRevenue: Array<{ channel: string; revenue: number; conversions: number }>;
|
||||
funnel: {
|
||||
citationsDetected: number;
|
||||
referralClicks: number;
|
||||
engagedVisitors: number;
|
||||
engagementRate: number;
|
||||
conversions: number;
|
||||
qualifiedLeads: number;
|
||||
qualificationRate: number;
|
||||
closedRevenue: number;
|
||||
};
|
||||
monthlyTrend: Array<{
|
||||
month: string;
|
||||
totalRevenue: number;
|
||||
llmoRevenue: number;
|
||||
geoRevenue: number;
|
||||
aeoRevenue: number;
|
||||
conversions: number;
|
||||
referrals: number;
|
||||
}>;
|
||||
topPages: Array<{ page: string; conversions: number; revenue: number }>;
|
||||
costAnalysis: {
|
||||
subscriptionCost: number;
|
||||
aiCreditsUsed: number;
|
||||
totalInvestment: number;
|
||||
totalRevenue: number;
|
||||
roi: number;
|
||||
paybackDays: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
const CHANNEL_LABELS: Record<string, string> = {
|
||||
llmo: "LLMO", geo: "GEO", aeo: "AEO", assisted: "Assisted",
|
||||
};
|
||||
|
||||
const CHANNEL_COLORS: Record<string, string> = {
|
||||
llmo: "bg-violet-500", geo: "bg-blue-500", aeo: "bg-amber-500", assisted: "bg-emerald-500",
|
||||
};
|
||||
|
||||
export default function AiRoiPage() {
|
||||
const { brand } = useBrand();
|
||||
const [data, setData] = useState<RoiData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [range, setRange] = useState("90d");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!brand?.id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/ai-roi?brandId=${brand.id}&range=${range}`);
|
||||
if (res.ok) setData(await res.json());
|
||||
} catch { /* silent */ }
|
||||
setLoading(false);
|
||||
}, [brand?.id, range]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Loader2 className="w-6 h-6 text-brand-500 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || (data.headline?.totalConversions ?? 0) === 0) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-12 text-center">
|
||||
<DollarSign className="w-12 h-12 text-slate-200 mx-auto mb-4" />
|
||||
<h1 className="text-xl font-bold text-slate-900 mb-2">AI ROI Dashboard</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
Revenue data will appear here once AI-attributed conversions start coming in
|
||||
and deal values are tagged via the outcome tracker.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Defensive defaults — partial API responses (missing sub-objects
|
||||
// or arrays) would otherwise crash the render with
|
||||
// "Cannot read properties of undefined (reading 'toLocaleString')".
|
||||
const h = data.headline ?? ({} as Partial<RoiData["headline"]>);
|
||||
const headline = {
|
||||
totalRevenue: h.totalRevenue ?? 0,
|
||||
totalConversions: h.totalConversions ?? 0,
|
||||
roiMultiplier: h.roiMultiplier ?? 0,
|
||||
revenueGrowth: h.revenueGrowth ?? 0,
|
||||
conversionGrowth: h.conversionGrowth ?? 0,
|
||||
};
|
||||
const f = data.funnel ?? ({} as Partial<RoiData["funnel"]>);
|
||||
const funnel = {
|
||||
citationsDetected: f.citationsDetected ?? 0,
|
||||
referralClicks: f.referralClicks ?? 0,
|
||||
engagedVisitors: f.engagedVisitors ?? 0,
|
||||
engagementRate: f.engagementRate ?? 0,
|
||||
conversions: f.conversions ?? 0,
|
||||
qualifiedLeads: f.qualifiedLeads ?? 0,
|
||||
qualificationRate: f.qualificationRate ?? 0,
|
||||
closedRevenue: f.closedRevenue ?? 0,
|
||||
};
|
||||
const ca = data.costAnalysis ?? ({} as Partial<RoiData["costAnalysis"]>);
|
||||
const costAnalysis = {
|
||||
subscriptionCost: ca.subscriptionCost ?? 0,
|
||||
aiCreditsUsed: ca.aiCreditsUsed ?? 0,
|
||||
totalInvestment: ca.totalInvestment ?? 0,
|
||||
totalRevenue: ca.totalRevenue ?? 0,
|
||||
roi: ca.roi ?? 0,
|
||||
paybackDays: ca.paybackDays ?? null,
|
||||
};
|
||||
const channelRevenue = Array.isArray(data.channelRevenue) ? data.channelRevenue : [];
|
||||
const monthlyTrend = Array.isArray(data.monthlyTrend) ? data.monthlyTrend : [];
|
||||
const topPages = Array.isArray(data.topPages) ? data.topPages : [];
|
||||
|
||||
const maxChannelRev = Math.max(...channelRevenue.map((c) => c.revenue ?? 0), 1);
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-900">AI ROI Dashboard</h1>
|
||||
<p className="text-sm text-slate-500 mt-0.5">Executive summary of AI-attributed revenue</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{["30d", "90d", "180d", "365d"].map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRange(r)}
|
||||
className={`px-3 py-1.5 text-xs font-semibold rounded-lg transition ${
|
||||
range === r ? "bg-brand-600 text-white" : "bg-slate-100 text-slate-600 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
{r.replace("d", " days")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Headline Metrics */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<KpiCard
|
||||
label="AI-Attributed Revenue"
|
||||
value={`$${nfmt(headline.totalRevenue)}`}
|
||||
change={headline.revenueGrowth}
|
||||
icon={DollarSign}
|
||||
color="text-emerald-600"
|
||||
/>
|
||||
<KpiCard
|
||||
label="AI Conversions"
|
||||
value={headline.totalConversions}
|
||||
change={headline.conversionGrowth}
|
||||
icon={TrendingUp}
|
||||
color="text-blue-600"
|
||||
/>
|
||||
<KpiCard
|
||||
label="ROI Multiplier"
|
||||
value={`${headline.roiMultiplier}x`}
|
||||
icon={Target}
|
||||
color="text-violet-600"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Revenue Growth"
|
||||
value={`${headline.revenueGrowth > 0 ? "+" : ""}${headline.revenueGrowth}%`}
|
||||
icon={Zap}
|
||||
color={headline.revenueGrowth >= 0 ? "text-emerald-600" : "text-red-600"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Channel Revenue */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-5 py-3.5 border-b border-slate-100 flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4 text-brand-600" />
|
||||
<h3 className="font-bold text-slate-800 text-sm">Revenue by Channel</h3>
|
||||
</div>
|
||||
<div className="p-5 space-y-3">
|
||||
{channelRevenue.map((c) => (
|
||||
<div key={c.channel} className="flex items-center gap-3">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-slate-400 w-16">{CHANNEL_LABELS[c.channel] ?? c.channel}</span>
|
||||
<div className="flex-1 bg-slate-100 rounded-full h-6 overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${CHANNEL_COLORS[c.channel] ?? "bg-slate-400"} rounded-full flex items-center px-2`}
|
||||
style={{ width: `${Math.max(((c.revenue ?? 0) / maxChannelRev) * 100, 5)}%` }}
|
||||
>
|
||||
<span className="text-[10px] font-bold text-white whitespace-nowrap">${nfmt(c.revenue)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs tabular-nums text-slate-500 w-16 text-right">{c.conversions ?? 0} conv</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Revenue Funnel */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-5 py-3.5 border-b border-slate-100 flex items-center gap-2">
|
||||
<ArrowDown className="w-4 h-4 text-brand-600" />
|
||||
<h3 className="font-bold text-slate-800 text-sm">Revenue Funnel</h3>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="space-y-0">
|
||||
{[
|
||||
{ label: "AI Citations Detected", value: funnel.citationsDetected, sub: "keywords" },
|
||||
{ label: "AI Referral Clicks", value: funnel.referralClicks, sub: "visitors" },
|
||||
{ label: "Engaged Visitors", value: funnel.engagedVisitors, sub: `${funnel.engagementRate}% engagement rate` },
|
||||
{ label: "Conversions", value: funnel.conversions, sub: "leads" },
|
||||
{ label: "Qualified Leads", value: funnel.qualifiedLeads, sub: `${funnel.qualificationRate}% qualification rate` },
|
||||
{ label: "Closed Revenue", value: `$${nfmt(funnel.closedRevenue)}`, sub: "" },
|
||||
].map((step, i, arr) => (
|
||||
<div key={step.label}>
|
||||
<div className="flex items-center justify-between py-2.5 px-4 bg-slate-50 rounded-lg">
|
||||
<span className="text-sm font-semibold text-slate-700">{step.label}</span>
|
||||
<div className="text-right">
|
||||
<span className="text-lg font-bold text-slate-900 tabular-nums">
|
||||
{typeof step.value === "number" ? nfmt(step.value) : step.value}
|
||||
</span>
|
||||
{step.sub && <span className="text-[10px] text-slate-400 ml-1.5">{step.sub}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{i < arr.length - 1 && (
|
||||
<div className="flex justify-center py-0.5">
|
||||
<ArrowDown className="w-3 h-3 text-slate-300" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Monthly Trend */}
|
||||
{monthlyTrend.length > 1 && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-5 py-3.5 border-b border-slate-100 flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-brand-600" />
|
||||
<h3 className="font-bold text-slate-800 text-sm">Monthly AI Revenue Trend</h3>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="flex items-end gap-2 h-48">
|
||||
{monthlyTrend.map((m) => {
|
||||
const maxRev = Math.max(...monthlyTrend.map((mm) => mm.totalRevenue ?? 0), 1);
|
||||
const rev = m.totalRevenue ?? 0;
|
||||
const height = Math.max((rev / maxRev) * 100, 3);
|
||||
return (
|
||||
<div key={m.month} className="flex-1 flex flex-col items-center justify-end gap-1">
|
||||
<span className="text-[9px] tabular-nums text-slate-400 font-semibold">
|
||||
${rev >= 1000 ? `${Math.round(rev / 1000)}k` : rev}
|
||||
</span>
|
||||
<div
|
||||
className="w-full bg-gradient-to-t from-violet-600 to-violet-400 rounded-t"
|
||||
style={{ height: `${height}%` }}
|
||||
/>
|
||||
<span className="text-[9px] text-slate-400">{(m.month ?? "").slice(5)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-4 text-[10px]">
|
||||
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-violet-500" /><span className="text-slate-500">Total Revenue</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Performers */}
|
||||
{topPages.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-5 py-3.5 border-b border-slate-100">
|
||||
<h3 className="font-bold text-slate-800 text-sm">Top Pages by AI Revenue</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-slate-50 text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2">Page</th>
|
||||
<th className="text-right px-3 py-2">Conversions</th>
|
||||
<th className="text-right px-3 py-2">Revenue</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{topPages.map((p) => (
|
||||
<tr key={p.page} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-2.5 font-mono text-brand-600 truncate max-w-[300px]">{p.page}</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums">{p.conversions ?? 0}</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums font-bold text-emerald-600">${nfmt(p.revenue)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cost Analysis */}
|
||||
<div className="bg-gradient-to-br from-emerald-50 to-blue-50 rounded-xl border border-emerald-200 p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<DollarSign className="w-4 h-4 text-emerald-600" />
|
||||
<h3 className="font-bold text-slate-800 text-sm">Cost Analysis</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div className="bg-white/70 rounded-lg px-3 py-2">
|
||||
<p className="text-[9px] font-bold uppercase tracking-widest text-slate-400">Subscription</p>
|
||||
<p className="text-lg font-bold text-slate-700 tabular-nums">${nfmt(costAnalysis.subscriptionCost)}/mo</p>
|
||||
</div>
|
||||
<div className="bg-white/70 rounded-lg px-3 py-2">
|
||||
<p className="text-[9px] font-bold uppercase tracking-widest text-slate-400">AI Revenue</p>
|
||||
<p className="text-lg font-bold text-emerald-700 tabular-nums">${nfmt(costAnalysis.totalRevenue)}</p>
|
||||
</div>
|
||||
<div className="bg-white/70 rounded-lg px-3 py-2">
|
||||
<p className="text-[9px] font-bold uppercase tracking-widest text-slate-400">ROI</p>
|
||||
<p className="text-lg font-bold text-violet-700 tabular-nums">{costAnalysis.roi ?? 0}x return</p>
|
||||
</div>
|
||||
<div className="bg-white/70 rounded-lg px-3 py-2">
|
||||
<p className="text-[9px] font-bold uppercase tracking-widest text-slate-400">Payback</p>
|
||||
<p className="text-lg font-bold text-blue-700 tabular-nums">
|
||||
{costAnalysis.paybackDays != null ? `${costAnalysis.paybackDays} days` : "—"}
|
||||
</p>
|
||||
{costAnalysis.paybackDays != null && (
|
||||
<p className="text-[10px] text-slate-500">meSEO pays for itself</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiCard({
|
||||
label, value, change, icon: Icon, color,
|
||||
}: {
|
||||
label: string; value: string | number; change?: number; icon: typeof DollarSign; color: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 px-5 py-4">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Icon className={`w-3.5 h-3.5 ${color}`} />
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">{label}</p>
|
||||
</div>
|
||||
<p className={`text-2xl font-bold tabular-nums ${color}`}>
|
||||
{typeof value === "number" ? value.toLocaleString() : value}
|
||||
</p>
|
||||
{change != null && change !== 0 && (
|
||||
<p className={`text-[10px] font-semibold mt-0.5 ${change >= 0 ? "text-emerald-600" : "text-red-500"}`}>
|
||||
{change > 0 ? "+" : ""}{change}% vs prev period
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Admin → Activity Feed — premium operations-center surface.
|
||||
*
|
||||
* "Right Now" hero panel + filter chips + connected-timeline
|
||||
* feed with category icons + colored dots on a vertical rail.
|
||||
* Auto-polls every 10s on page 1 (paused on background tabs).
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Activity, X, Loader2, ChevronLeft, ChevronRight, Download,
|
||||
Users as UsersIcon, Globe, Clock, Eye, FileText, Shield, Sparkles,
|
||||
Plug, Settings as SettingsIcon, Lock, AlertTriangle, RefreshCw, Wrench,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
PageHeader, Card, CardHeader, EmptyState, Avatar, Skel,
|
||||
} from "@/components/admin";
|
||||
|
||||
type Category = "navigation" | "search" | "audit" | "report" | "integration" | "content" | "ai" | "export" | "settings" | "auth" | "admin" | "seo" | "technical";
|
||||
|
||||
interface ActivityRow {
|
||||
id: string;
|
||||
action: string;
|
||||
category: string;
|
||||
metadata: unknown;
|
||||
timestamp: string;
|
||||
user: { id: string; name: string | null; email: string | null; image: string | null } | null;
|
||||
brand: { id: string; name: string | null; domain: string | null } | null;
|
||||
}
|
||||
|
||||
interface Feed {
|
||||
rows: ActivityRow[];
|
||||
total: number; page: number; limit: number; hasMore: boolean;
|
||||
rightNow: { usersOnline: number; activeBrands: number; eventsPerMinute: number };
|
||||
}
|
||||
|
||||
const POLL_MS = 10_000;
|
||||
|
||||
const CATEGORY_META: Record<Category, { label: string; chip: string; dot: string; icon: typeof Eye }> = {
|
||||
navigation: { label: "Navigation", chip: "bg-blue-50 text-blue-700", dot: "bg-blue-500", icon: Eye },
|
||||
search: { label: "Search", chip: "bg-cyan-50 text-cyan-700", dot: "bg-cyan-500", icon: Eye },
|
||||
audit: { label: "Audit", chip: "bg-orange-50 text-orange-700", dot: "bg-orange-500", icon: Shield },
|
||||
report: { label: "Report", chip: "bg-violet-50 text-violet-700", dot: "bg-violet-500", icon: FileText },
|
||||
integration: { label: "Integration", chip: "bg-sky-50 text-sky-700", dot: "bg-sky-500", icon: Plug },
|
||||
content: { label: "Content", chip: "bg-emerald-50 text-emerald-700", dot: "bg-emerald-500", icon: FileText },
|
||||
ai: { label: "AI", chip: "bg-purple-50 text-purple-700", dot: "bg-purple-500", icon: Sparkles },
|
||||
export: { label: "Export", chip: "bg-amber-50 text-amber-700", dot: "bg-amber-500", icon: Download },
|
||||
settings: { label: "Settings", chip: "bg-slate-100 text-slate-600", dot: "bg-slate-400", icon: SettingsIcon },
|
||||
auth: { label: "Auth", chip: "bg-indigo-50 text-indigo-700", dot: "bg-indigo-500", icon: Lock },
|
||||
admin: { label: "Admin", chip: "bg-rose-50 text-rose-700", dot: "bg-rose-500", icon: AlertTriangle },
|
||||
seo: { label: "SEO", chip: "bg-lime-50 text-lime-700", dot: "bg-lime-500", icon: Globe },
|
||||
technical: { label: "Technical", chip: "bg-teal-50 text-teal-700", dot: "bg-teal-500", icon: Wrench },
|
||||
};
|
||||
|
||||
const FILTER_CHIPS: Array<{ id: string; label: string }> = [
|
||||
{ id: "", label: "All" },
|
||||
{ id: "navigation", label: "Navigation" },
|
||||
{ id: "content", label: "Content" },
|
||||
{ id: "seo", label: "SEO" },
|
||||
{ id: "technical", label: "Technical" },
|
||||
{ id: "audit", label: "Audit" },
|
||||
{ id: "ai", label: "AI" },
|
||||
{ id: "report", label: "Report" },
|
||||
{ id: "settings", label: "Settings" },
|
||||
{ id: "admin", label: "Admin" },
|
||||
];
|
||||
|
||||
function formatTime(s: string): string {
|
||||
try {
|
||||
const d = new Date(s);
|
||||
const sec = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||
if (sec < 10) return "just now";
|
||||
if (sec < 60) return `${sec}s ago`;
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
|
||||
if (sec < 86_400) return `${Math.floor(sec / 3600)}h ago`;
|
||||
return d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||
} catch { return s; }
|
||||
}
|
||||
|
||||
export default function AdminActivityPage() {
|
||||
const [categoryFilter, setCategoryFilter] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [feed, setFeed] = useState<Feed | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selected, setSelected] = useState<ActivityRow | null>(null);
|
||||
const [livePoll, setLivePoll] = useState(true);
|
||||
|
||||
const buildParams = useCallback(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (categoryFilter) params.set("category", categoryFilter);
|
||||
params.set("page", String(page));
|
||||
return params;
|
||||
}, [categoryFilter, page]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const params = buildParams();
|
||||
const res = await fetch(`/api/admin/activity?${params.toString()}`);
|
||||
if (res.ok) setFeed(await res.json());
|
||||
setLoading(false);
|
||||
}, [buildParams]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const handle = setTimeout(refresh, 250);
|
||||
return () => clearTimeout(handle);
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!livePoll) return;
|
||||
const tick = () => {
|
||||
if (typeof document !== "undefined" && document.hidden) return;
|
||||
if (page === 1) refresh();
|
||||
};
|
||||
const id = setInterval(tick, POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [livePoll, refresh, page]);
|
||||
|
||||
const totalPages = useMemo(() => feed ? Math.max(1, Math.ceil(feed.total / feed.limit)) : 1, [feed]);
|
||||
|
||||
function downloadCsv() {
|
||||
if (!feed) return;
|
||||
const header = ["timestamp", "user", "brand", "category", "action", "metadata"];
|
||||
const rows = feed.rows.map((r) => [
|
||||
r.timestamp, r.user?.email ?? r.user?.name ?? "",
|
||||
r.brand?.domain ?? r.brand?.name ?? "",
|
||||
r.category, r.action, JSON.stringify(r.metadata ?? {}),
|
||||
].map((c) => `"${String(c).replace(/"/g, '""')}"`).join(","));
|
||||
const csv = [header.join(","), ...rows].join("\n");
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `activity-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 transition-opacity duration-200">
|
||||
<PageHeader
|
||||
title="Activity"
|
||||
subtitle={feed ? `${feed.total.toLocaleString()} matching events across the platform` : "Loading…"}
|
||||
icon={Activity}
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
onClick={() => setLivePoll((v) => !v)}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-2 text-xs font-semibold rounded-lg border transition-colors ${
|
||||
livePoll
|
||||
? "bg-emerald-50 border-emerald-200 text-emerald-700"
|
||||
: "bg-white border-slate-200 text-slate-600"
|
||||
}`}
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${livePoll ? "bg-emerald-500 animate-pulse" : "bg-slate-400"}`} />
|
||||
{livePoll ? "Live (10s)" : "Paused"}
|
||||
</button>
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-xs font-semibold rounded-lg border border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" /> Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={downloadCsv}
|
||||
disabled={!feed}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-xs font-semibold rounded-lg border border-slate-200 bg-white text-slate-600 hover:bg-slate-50 disabled:opacity-50"
|
||||
>
|
||||
<Download className="w-3 h-3" /> Export
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Right Now hero panel */}
|
||||
{feed && (
|
||||
<Card>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 divide-y md:divide-y-0 md:divide-x divide-slate-100">
|
||||
<div className="p-5 flex items-center gap-4">
|
||||
<span className="w-12 h-12 rounded-xl bg-emerald-50 text-emerald-600 flex items-center justify-center">
|
||||
<UsersIcon className="w-5 h-5" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-slate-500">Users online</p>
|
||||
<p className="text-3xl font-semibold text-emerald-600 tabular-nums leading-tight">{feed.rightNow.usersOnline}</p>
|
||||
<p className="text-[11px] text-slate-500">last 5 min</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 flex items-center gap-4">
|
||||
<span className="w-12 h-12 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center">
|
||||
<Globe className="w-5 h-5" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-slate-500">Active brands</p>
|
||||
<p className="text-3xl font-semibold text-blue-600 tabular-nums leading-tight">{feed.rightNow.activeBrands}</p>
|
||||
<p className="text-[11px] text-slate-500">last 5 min</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 flex items-center gap-4">
|
||||
<span className="w-12 h-12 rounded-xl bg-violet-50 text-violet-600 flex items-center justify-center">
|
||||
<Clock className="w-5 h-5" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-slate-500">Events / min</p>
|
||||
<p className="text-3xl font-semibold text-violet-600 tabular-nums leading-tight">{feed.rightNow.eventsPerMinute}</p>
|
||||
<p className="text-[11px] text-slate-500">last 60s</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Filter chips */}
|
||||
<div className="bg-white border border-slate-100 rounded-xl shadow-sm p-3 flex items-center gap-1.5 flex-wrap">
|
||||
{FILTER_CHIPS.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => { setCategoryFilter(c.id); setPage(1); }}
|
||||
className={`px-3 py-1.5 text-xs font-semibold rounded-full transition-colors ${
|
||||
categoryFilter === c.id
|
||||
? "bg-slate-900 text-white"
|
||||
: "bg-slate-100 text-slate-600 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Connected timeline feed */}
|
||||
{loading && !feed ? (
|
||||
<Card>
|
||||
<div className="p-8 space-y-3">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Skel className="w-2 h-2 rounded-full bg-slate-200" />
|
||||
<Skel className="w-6 h-6 rounded-full bg-slate-100" />
|
||||
<Skel className="h-3 flex-1 bg-slate-100 rounded" />
|
||||
<Skel className="w-12 h-3 bg-slate-100 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
) : !feed || feed.rows.length === 0 ? (
|
||||
<Card>
|
||||
<EmptyState
|
||||
icon={Activity}
|
||||
title="No events match these filters"
|
||||
description="Telemetry populates as routes adopt trackEvent()."
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<ul className="relative px-5 py-4 space-y-3">
|
||||
{/* Vertical rail behind every dot */}
|
||||
<span className="absolute left-[28px] top-6 bottom-6 w-px bg-slate-200" aria-hidden />
|
||||
{feed.rows.map((r) => {
|
||||
const meta = CATEGORY_META[(r.category as Category)] ?? CATEGORY_META.settings;
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<li key={r.id} className="relative flex items-start gap-3">
|
||||
{/* Dot on the rail */}
|
||||
<span className={`absolute left-[24px] top-3 w-2 h-2 rounded-full ring-2 ring-white ${meta.dot}`} />
|
||||
{/* Category icon chip */}
|
||||
<span className={`shrink-0 w-8 h-8 rounded-lg flex items-center justify-center ${meta.chip}`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
{/* Body */}
|
||||
<button
|
||||
onClick={() => setSelected(r)}
|
||||
className="flex-1 text-left min-w-0 hover:bg-slate-50 -m-2 p-2 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
{r.user ? (
|
||||
<Avatar src={r.user.image} name={r.user.name ?? r.user.email ?? "?"} size={20} />
|
||||
) : (
|
||||
<span className="w-5 h-5 rounded-full bg-slate-100 text-slate-400 text-[9px] font-bold flex items-center justify-center">SYS</span>
|
||||
)}
|
||||
<Link
|
||||
href={`/admin/users?q=${encodeURIComponent(r.user?.email ?? "")}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-xs font-semibold text-slate-800 hover:text-brand-600 truncate"
|
||||
>
|
||||
{r.user?.name ?? r.user?.email ?? "system"}
|
||||
</Link>
|
||||
{r.brand && (
|
||||
<Link
|
||||
href={`/admin/brands?q=${encodeURIComponent(r.brand.domain ?? "")}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-[10px] text-slate-500 hover:text-brand-600 px-1.5 py-0.5 rounded bg-slate-100 truncate max-w-[160px]"
|
||||
>
|
||||
{r.brand.name}
|
||||
</Link>
|
||||
)}
|
||||
<span className="text-[10px] text-slate-400 tabular-nums ml-auto shrink-0">{formatTime(r.timestamp)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-700 truncate">{r.action}</p>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{feed && feed.total > feed.limit && (
|
||||
<div className="flex items-center justify-between text-xs text-slate-500">
|
||||
<span>Page {page} of {totalPages}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1} className="inline-flex items-center gap-1 px-3 py-1.5 border border-slate-200 rounded-lg bg-white disabled:opacity-40">
|
||||
<ChevronLeft className="w-3 h-3" /> Prev
|
||||
</button>
|
||||
<button onClick={() => setPage((p) => p + 1)} disabled={!feed.hasMore} className="inline-flex items-center gap-1 px-3 py-1.5 border border-slate-200 rounded-lg bg-white disabled:opacity-40">
|
||||
Next <ChevronRight className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Slide-out panel — JSON metadata for debugging */}
|
||||
{selected && (
|
||||
<div className="fixed inset-0 z-40 flex animate-in fade-in duration-150" onClick={() => setSelected(null)}>
|
||||
<div className="flex-1 bg-slate-900/40" />
|
||||
<aside className="w-[420px] max-w-full bg-white border-l border-slate-200 overflow-y-auto" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="px-5 py-4 border-b border-slate-200 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900 text-sm">{selected.action}</h2>
|
||||
<p className="text-[11px] text-slate-500 mt-0.5">{new Date(selected.timestamp).toLocaleString()}</p>
|
||||
</div>
|
||||
<button onClick={() => setSelected(null)} className="p-1.5 rounded-lg hover:bg-slate-100"><X className="w-4 h-4" /></button>
|
||||
</header>
|
||||
<div className="p-5 space-y-4 text-xs">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-slate-500">Category</p>
|
||||
<p className="font-semibold text-slate-800">{selected.category}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-slate-500">User</p>
|
||||
<p className="font-semibold text-slate-800 truncate">{selected.user?.email ?? selected.user?.name ?? "system"}</p>
|
||||
</div>
|
||||
{selected.brand && (
|
||||
<div className="col-span-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-slate-500">Brand</p>
|
||||
<p className="font-semibold text-slate-800">{selected.brand.name} <span className="text-slate-400 font-normal">({selected.brand.domain})</span></p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-slate-500 mb-1">Metadata</p>
|
||||
<pre className="text-[11px] bg-slate-50 border border-slate-200 rounded-lg p-3 whitespace-pre-wrap overflow-auto max-h-80">
|
||||
{JSON.stringify(selected.metadata ?? {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Admin → AI Monitor — premium operations-center surface.
|
||||
*
|
||||
* Three hero stat cards (24h / 7d / 30d) + severity filter chips +
|
||||
* action substring search. Feed renders rows with a severity-
|
||||
* coloured left border (red = critical, amber = warning,
|
||||
* transparent = clean) and an expandable detail panel per row
|
||||
* showing the full prompt + response + metadata.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Shield, Search, Loader2, ChevronLeft, ChevronRight, AlertTriangle,
|
||||
XCircle, CheckCircle2, ChevronDown, DollarSign,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
PageHeader, StatCard, Card, CardHeader, EmptyState, Skel,
|
||||
} from "@/components/admin";
|
||||
|
||||
type Severity = "none" | "warning" | "critical" | "blocked";
|
||||
interface Row {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
brandId: string | null;
|
||||
feature: string;
|
||||
model: string;
|
||||
userPrompt: string;
|
||||
aiResponse: string;
|
||||
tokensUsed: number | null;
|
||||
costEstimate: number | null;
|
||||
guardrailFlags: string[];
|
||||
flagSeverity: Severity;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface StatsWindow { total: number; warning: number; critical: number; flagRate: number; totalCost: number; totalTokens: number }
|
||||
interface CostByBrandRow {
|
||||
brandId: string;
|
||||
brandName: string;
|
||||
calls: number;
|
||||
totalCost: number;
|
||||
totalTokens: number;
|
||||
avgCostPerCall: number;
|
||||
}
|
||||
interface Feed {
|
||||
rows: Row[]; total: number; page: number; limit: number; hasMore: boolean;
|
||||
stats: { d1: StatsWindow; d7: StatsWindow; d30: StatsWindow };
|
||||
topFeatures: Array<{ feature: string; count: number; totalCost: number; totalTokens: number }>;
|
||||
costByBrand: CostByBrandRow[];
|
||||
scope: { brandId: string; brandName: string | null };
|
||||
}
|
||||
interface BrandOption { id: string; name: string }
|
||||
|
||||
const SEVERITY_META: Record<Severity, { label: string; chip: string; icon: typeof Shield; border: string }> = {
|
||||
none: { label: "Clean", chip: "bg-slate-100 text-slate-600 border-slate-200", icon: CheckCircle2, border: "border-l-transparent" },
|
||||
warning: { label: "Warning", chip: "bg-amber-50 text-amber-700 border-amber-200", icon: AlertTriangle, border: "border-l-amber-500" },
|
||||
critical: { label: "Critical", chip: "bg-rose-50 text-rose-700 border-rose-200", icon: XCircle, border: "border-l-rose-500" },
|
||||
blocked: { label: "Blocked", chip: "bg-rose-100 text-rose-800 border-rose-300", icon: XCircle, border: "border-l-rose-700" },
|
||||
};
|
||||
|
||||
function formatTime(s: string): string {
|
||||
try {
|
||||
const d = new Date(s);
|
||||
return d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||
} catch { return s; }
|
||||
}
|
||||
|
||||
const SEVERITY_PILLS: Array<{ id: string; label: string }> = [
|
||||
{ id: "", label: "All" },
|
||||
{ id: "critical", label: "Critical" },
|
||||
{ id: "warning", label: "Warning" },
|
||||
{ id: "none", label: "Clean" },
|
||||
];
|
||||
|
||||
export default function AdminAiMonitorPage() {
|
||||
const [severity, setSeverity] = useState<string>("");
|
||||
const [feature, setFeature] = useState<string>("");
|
||||
const [brandId, setBrandId] = useState<string>("all");
|
||||
const [q, setQ] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [feed, setFeed] = useState<Feed | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [brands, setBrands] = useState<BrandOption[]>([]);
|
||||
|
||||
function toggleExpanded(id: string) {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// Mount-once: brand list for the dropdown — same source as the
|
||||
// admin overview page.
|
||||
useEffect(() => {
|
||||
fetch("/api/admin/brands?limit=200")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((d) => {
|
||||
if (Array.isArray(d?.brands)) {
|
||||
setBrands(d.brands.map((b: { id: string; name: string }) => ({ id: b.id, name: b.name })));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (severity) params.set("severity", severity);
|
||||
if (feature) params.set("feature", feature);
|
||||
if (brandId && brandId !== "all") params.set("brandId", brandId);
|
||||
if (q.trim()) params.set("q", q.trim());
|
||||
params.set("page", String(page));
|
||||
setLoading(true);
|
||||
fetch(`/api/admin/ai-monitor?${params.toString()}`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((d) => setFeed(d))
|
||||
.finally(() => setLoading(false));
|
||||
}, 250);
|
||||
return () => clearTimeout(handle);
|
||||
}, [severity, feature, brandId, q, page]);
|
||||
|
||||
const totalPages = useMemo(() => feed ? Math.max(1, Math.ceil(feed.total / feed.limit)) : 1, [feed]);
|
||||
const scopeLabel = brandId === "all"
|
||||
? "All Brands"
|
||||
: (feed?.scope.brandName ?? brands.find((b) => b.id === brandId)?.name ?? "Selected brand");
|
||||
|
||||
return (
|
||||
<div className="space-y-6 transition-opacity duration-200">
|
||||
<PageHeader
|
||||
title="AI Monitor"
|
||||
subtitle={
|
||||
feed
|
||||
? `${feed.total.toLocaleString()} matching interactions · ${scopeLabel} · guardrail-scored on the way in`
|
||||
: "Loading…"
|
||||
}
|
||||
icon={Shield}
|
||||
actions={
|
||||
<select
|
||||
value={brandId}
|
||||
onChange={(e) => { setBrandId(e.target.value); setPage(1); }}
|
||||
className="px-3 py-1.5 text-xs bg-white border border-slate-200 rounded-lg min-w-[200px]"
|
||||
>
|
||||
<option value="all">All Brands</option>
|
||||
{brands.map((b) => (<option key={b.id} value={b.id}>{b.name}</option>))}
|
||||
</select>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Hero stats — three windows, scoped to the selected brand */}
|
||||
{feed ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<WindowCard label="Last 24h" scope={scopeLabel} stats={feed.stats.d1} />
|
||||
<WindowCard label="Last 7d" scope={scopeLabel} stats={feed.stats.d7} />
|
||||
<WindowCard label="Last 30d" scope={scopeLabel} stats={feed.stats.d30} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="bg-white border border-slate-100 rounded-xl shadow-sm p-4 space-y-2">
|
||||
<Skel className="h-3 w-20 bg-slate-100 rounded" />
|
||||
<Skel className="h-8 w-32 bg-slate-200 rounded" />
|
||||
<Skel className="h-4 w-full bg-slate-100 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cost by Brand — only when viewing the unfiltered platform.
|
||||
Click a row to switch the dropdown to that brand. */}
|
||||
{brandId === "all" && feed && feed.costByBrand.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader title="Cost by brand · last 30 days" />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-slate-50/60 text-[10px] font-semibold uppercase tracking-widest text-slate-500">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2">Brand</th>
|
||||
<th className="text-right px-4 py-2">AI calls</th>
|
||||
<th className="text-right px-4 py-2">Tokens</th>
|
||||
<th className="text-right px-4 py-2">Est. cost</th>
|
||||
<th className="text-right px-4 py-2">Avg / call</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{feed.costByBrand.map((b) => (
|
||||
<tr
|
||||
key={b.brandId}
|
||||
onClick={() => { setBrandId(b.brandId); setPage(1); }}
|
||||
className="hover:bg-slate-50 cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="px-4 py-2 font-semibold text-slate-800 truncate max-w-[260px]">{b.brandName}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-700">{b.calls.toLocaleString()}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-600">{b.totalTokens.toLocaleString()}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums font-semibold text-slate-900">${b.totalCost.toFixed(2)}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-500">${b.avgCostPerCall.toFixed(4)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="px-4 py-2 border-t border-slate-100 flex items-center gap-1.5 text-[10px] text-slate-400">
|
||||
<DollarSign className="w-3 h-3" />
|
||||
Sorted by spend · click a row to drill into that brand.
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="bg-white border border-slate-100 rounded-xl shadow-sm p-3 flex items-center gap-2 flex-wrap">
|
||||
{/* Severity pills */}
|
||||
<div className="inline-flex bg-slate-100 rounded-lg p-0.5">
|
||||
{SEVERITY_PILLS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => { setSeverity(p.id); setPage(1); }}
|
||||
className={`px-3 py-1 text-[11px] font-semibold rounded-md transition-colors ${
|
||||
severity === p.id ? "bg-white text-slate-900 shadow-sm" : "text-slate-500 hover:text-slate-700"
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-400" />
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => { setQ(e.target.value); setPage(1); }}
|
||||
placeholder="Search prompt or response…"
|
||||
className="w-full pl-9 pr-3 py-2 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={feature}
|
||||
onChange={(e) => { setFeature(e.target.value); setPage(1); }}
|
||||
className="px-3 py-2 text-xs bg-white border border-slate-200 rounded-lg min-w-[160px]"
|
||||
>
|
||||
<option value="">All features</option>
|
||||
{(feed?.topFeatures ?? []).map((f) => (
|
||||
<option key={f.feature} value={f.feature}>{f.feature} ({f.count})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Feed */}
|
||||
{loading && !feed ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="bg-white border border-slate-100 rounded-xl shadow-sm p-4">
|
||||
<Skel className="h-3 w-1/3 bg-slate-100 rounded mb-2" />
|
||||
<Skel className="h-3 w-2/3 bg-slate-100 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !feed || feed.rows.length === 0 ? (
|
||||
<Card>
|
||||
<EmptyState
|
||||
icon={Shield}
|
||||
title="No AI interactions in this filter"
|
||||
description="Once Claude / OpenAI calls flow through logAiInteraction(), they'll appear here, guardrail-scored."
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{feed.rows.map((row) => {
|
||||
const meta = SEVERITY_META[row.flagSeverity];
|
||||
const isOpen = expanded.has(row.id);
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
className={`bg-white rounded-xl shadow-sm border border-slate-100 border-l-4 ${meta.border} transition-shadow hover:shadow-md`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpanded(row.id)}
|
||||
className="w-full text-left px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||
<span className={`inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded border ${meta.chip}`}>
|
||||
<Icon className="w-3 h-3" />
|
||||
{meta.label}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500">{row.feature}</span>
|
||||
<span className="text-[10px] text-slate-400 font-mono">{row.model}</span>
|
||||
{row.guardrailFlags.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
{row.guardrailFlags.slice(0, 3).map((f) => (
|
||||
<span key={f} className="text-[9px] font-bold uppercase tracking-wide px-1 py-0.5 rounded bg-rose-50 text-rose-700 border border-rose-200">
|
||||
{f.replace(/_/g, " ")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-[10px] text-slate-400 tabular-nums ml-auto flex items-center gap-1.5">
|
||||
{formatTime(row.timestamp)}
|
||||
<ChevronDown className={`w-3 h-3 text-slate-400 transition-transform ${isOpen ? "rotate-180" : ""}`} />
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-700 line-clamp-1">
|
||||
<span className="font-semibold text-slate-500">Prompt:</span> {row.userPrompt}
|
||||
</p>
|
||||
{!isOpen && (
|
||||
<p className="text-xs text-slate-500 line-clamp-1 mt-0.5">
|
||||
<span className="font-semibold text-slate-500">Response:</span> {row.aiResponse}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="px-4 pb-4 pt-1 space-y-3 border-t border-slate-100">
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mb-1">User prompt</p>
|
||||
<pre className="text-[11px] bg-slate-50 border border-slate-200 rounded-lg p-3 whitespace-pre-wrap overflow-auto max-h-48">{row.userPrompt}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mb-1">AI response</p>
|
||||
<pre className="text-[11px] bg-slate-50 border border-slate-200 rounded-lg p-3 whitespace-pre-wrap overflow-auto max-h-72">{row.aiResponse}</pre>
|
||||
</div>
|
||||
{row.guardrailFlags.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mb-1">All guardrail flags</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{row.guardrailFlags.map((f) => (
|
||||
<span key={f} className="text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded bg-rose-50 text-rose-700 border border-rose-200">
|
||||
{f.replace(/_/g, " ")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-2 text-[11px]">
|
||||
<span className="text-slate-500">Tokens: <span className="font-semibold text-slate-800 tabular-nums">{row.tokensUsed?.toLocaleString() ?? "—"}</span></span>
|
||||
<span className="text-slate-500">Cost: <span className="font-semibold text-slate-800 tabular-nums">{row.costEstimate != null ? `$${row.costEstimate.toFixed(4)}` : "—"}</span></span>
|
||||
<span className="text-slate-500">When: <span className="font-semibold text-slate-800">{formatTime(row.timestamp)}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{feed && feed.total > feed.limit && (
|
||||
<div className="flex items-center justify-between text-xs text-slate-500">
|
||||
<span>Page {page} of {totalPages}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1} className="inline-flex items-center gap-1 px-3 py-1.5 border border-slate-200 rounded-lg bg-white disabled:opacity-40">
|
||||
<ChevronLeft className="w-3 h-3" /> Prev
|
||||
</button>
|
||||
<button onClick={() => setPage((p) => p + 1)} disabled={!feed.hasMore} className="inline-flex items-center gap-1 px-3 py-1.5 border border-slate-200 rounded-lg bg-white disabled:opacity-40">
|
||||
Next <ChevronRight className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Window stats card ────────────────────────────────────────────
|
||||
|
||||
function WindowCard({ label, scope, stats }: { label: string; scope: string; stats: StatsWindow }) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-slate-500 truncate">
|
||||
{label} <span className="text-slate-400">— {scope}</span>
|
||||
</p>
|
||||
<span className="text-[10px] text-slate-400 shrink-0 ml-2">{stats.totalTokens.toLocaleString()} tokens</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<p className="text-[10px] text-slate-400 uppercase tracking-wider">Calls</p>
|
||||
<p className="text-xl font-semibold text-slate-800 tabular-nums">{stats.total.toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] text-slate-400 uppercase tracking-wider">Flag rate</p>
|
||||
<p className={`text-xl font-semibold tabular-nums ${stats.flagRate > 5 ? "text-amber-600" : "text-slate-800"}`}>
|
||||
{stats.flagRate}%
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] text-slate-400 uppercase tracking-wider">Critical</p>
|
||||
<p className={`text-xl font-semibold tabular-nums ${stats.critical > 0 ? "text-rose-600" : "text-slate-800"}`}>
|
||||
{stats.critical.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-slate-500">Spend: <span className="font-semibold text-slate-800">${stats.totalCost.toFixed(2)}</span></p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
void StatCard;
|
||||
@@ -0,0 +1,537 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* /admin/ai-usage — Platform Cost Center
|
||||
*
|
||||
* Redesigned Apr-2026 from a single-source AI-calls view into a
|
||||
* comprehensive platform cost dashboard. Every operational cost
|
||||
* flows through this page: AI, database, auth, SERP, hosting,
|
||||
* email, CDN. Backed by /api/admin/cost-center.
|
||||
*
|
||||
* NOTE: the route filename stays /admin/ai-usage to avoid breaking
|
||||
* every bookmark in existence. The sidebar label now reads
|
||||
* "Cost Center". Future renames can come with a redirect.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
DollarSign, Sparkles, Database, ShieldCheck, Search, Server,
|
||||
Mail, Tag as TagIcon, AlertTriangle, TrendingUp, ExternalLink,
|
||||
Loader2, Gauge,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
PageHeader, SectionHeader, Card, CardHeader, Sparkline,
|
||||
TableSkeleton, EmptyState,
|
||||
} from "@/components/admin";
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────
|
||||
|
||||
type Range = "thisMonth" | "lastMonth" | "last3m" | "30d" | "90d";
|
||||
|
||||
interface AiByModel {
|
||||
model: string; calls: number; inputTokens: number; outputTokens: number;
|
||||
cost: number; referenceCost: number; isKnownModel: boolean;
|
||||
}
|
||||
interface AiByFeature { feature: string; calls: number; cost: number; inputTokens: number; outputTokens: number }
|
||||
interface DailyPoint { date: string; cost: number }
|
||||
interface RowCountRow { table: string; rows: number }
|
||||
interface CostAlert { service: string; message: string; threshold: number; actual: number }
|
||||
interface BrandCostRow {
|
||||
brandId: string; brandName: string;
|
||||
aiCost: number; aiCalls: number; eventCount: number;
|
||||
dbCostEstimate: number; totalCost: number;
|
||||
}
|
||||
interface ServiceLabels { [key: string]: { label: string; vendor: string; vendorUrl: string } }
|
||||
|
||||
interface CostPayload {
|
||||
scope: { range: Range; fromISO: string; toISO: string; days: number };
|
||||
totalCost: number; previousTotalCost: number; trendPct: number;
|
||||
alerts: CostAlert[];
|
||||
services: {
|
||||
ai: { calls: number; inputTokens: number; outputTokens: number; cost: number; avgCostPerCall: number; byModel: AiByModel[]; byFeature: AiByFeature[]; dailyTrend: DailyPoint[]; totalCalls?: number; totalCost?: number; note?: string };
|
||||
database: { sizeMb: number; rowCounts: RowCountRow[]; costEstimate: number; growthRateMbPerDay: number; tier: string };
|
||||
auth: { mau: number; authentications: number; costEstimate: number };
|
||||
serp: { callsTotal: number; byEndpoint: Record<string, number>; cost: number; balance?: number | null; spent?: number; note?: string };
|
||||
hosting: { invocations: number; bandwidthGbEstimate: number; costEstimate: number };
|
||||
email: { sent: number; cost: number };
|
||||
siteTag: { serves: number; collects: number; bandwidthMb: number };
|
||||
};
|
||||
byBrand: BrandCostRow[];
|
||||
projection: { nextMonthEstimate: number; breakdown: Record<string, number> };
|
||||
labels: ServiceLabels;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
const RANGES: Array<{ id: Range; label: string }> = [
|
||||
{ id: "thisMonth", label: "This Month" },
|
||||
{ id: "lastMonth", label: "Last Month" },
|
||||
{ id: "last3m", label: "Last 3 Months" },
|
||||
{ id: "30d", label: "30d" },
|
||||
{ id: "90d", label: "90d" },
|
||||
];
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function usd(n: number): string {
|
||||
if (n >= 1000) return `$${(n / 1000).toFixed(1)}k`;
|
||||
if (n >= 1) return `$${n.toFixed(2)}`;
|
||||
if (n === 0) return "$0";
|
||||
return `$${n.toFixed(4)}`;
|
||||
}
|
||||
function bytesMbToGb(mb: number): string {
|
||||
if (mb < 1024) return `${mb.toFixed(0)} MB`;
|
||||
return `${(mb / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
function formatFeature(f: string): string {
|
||||
return f.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
function trendTone(pct: number): { text: string; tone: string; arrow: string } {
|
||||
if (pct > 0) return { text: `+${pct}%`, tone: "text-rose-600 bg-rose-50 border-rose-200", arrow: "↑" };
|
||||
if (pct < 0) return { text: `${pct}%`, tone: "text-emerald-600 bg-emerald-50 border-emerald-200", arrow: "↓" };
|
||||
return { text: "flat", tone: "text-slate-500 bg-slate-50 border-slate-200", arrow: "→" };
|
||||
}
|
||||
|
||||
// ─── Page ──────────────────────────────────────────────────────────
|
||||
|
||||
export default function CostCenterPage() {
|
||||
const [range, setRange] = useState<Range>("thisMonth");
|
||||
const [data, setData] = useState<CostPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
fetch(`/api/admin/cost-center?range=${range}`)
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((d: CostPayload | null) => setData(d))
|
||||
.finally(() => setLoading(false));
|
||||
}, [range]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const trend = useMemo(() => data ? trendTone(data.trendPct) : null, [data]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Cost Center"
|
||||
subtitle="Every operational cost across AI, database, auth, SERP data, hosting, email, and CDN"
|
||||
icon={DollarSign}
|
||||
actions={
|
||||
<div className="inline-flex bg-slate-100 rounded-lg p-0.5">
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => setRange(r.id)}
|
||||
className={`px-2.5 py-1 text-[11px] font-semibold rounded-md transition-colors ${
|
||||
range === r.id ? "bg-white text-slate-900 shadow-sm" : "text-slate-500 hover:text-slate-700"
|
||||
}`}
|
||||
>{r.label}</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Alerts */}
|
||||
{data && data.alerts.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{data.alerts.map((a) => (
|
||||
<div key={a.service} className="rounded-xl border border-rose-200 bg-rose-50/60 px-4 py-2.5 flex items-center gap-3">
|
||||
<AlertTriangle className="w-4 h-4 text-rose-600 shrink-0" />
|
||||
<p className="text-xs font-semibold text-rose-800 flex-1">{a.message}</p>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-rose-700 bg-white border border-rose-200 px-2 py-0.5 rounded">
|
||||
{a.service}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hero — total cost */}
|
||||
<Card>
|
||||
<div className="p-6 grid grid-cols-1 md:grid-cols-3 gap-6 items-center">
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Total platform cost</p>
|
||||
{data ? (
|
||||
<>
|
||||
<p className="text-5xl font-bold text-slate-900 tabular-nums leading-tight">{usd(data.totalCost)}</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{trend && (
|
||||
<span className={`inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest px-2 py-0.5 rounded-full border ${trend.tone}`}>
|
||||
{trend.arrow} {trend.text} vs prior
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-slate-400">
|
||||
prior: {usd(data.previousTotalCost)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="h-10 w-32 bg-slate-100 rounded animate-pulse mt-2" />
|
||||
)}
|
||||
</div>
|
||||
<div className="md:col-span-2 grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<MiniStat
|
||||
icon={Sparkles}
|
||||
label="AI / LLM"
|
||||
value={data ? usd(data.services.ai.cost) : "—"}
|
||||
sub={data ? `${data.services.ai.calls.toLocaleString()} calls` : ""}
|
||||
accent="text-violet-600"
|
||||
/>
|
||||
<MiniStat
|
||||
icon={Database}
|
||||
label="Database"
|
||||
value={data ? usd(data.services.database.costEstimate) : "—"}
|
||||
sub={data ? bytesMbToGb(data.services.database.sizeMb) : ""}
|
||||
accent="text-blue-600"
|
||||
/>
|
||||
<MiniStat
|
||||
icon={Search}
|
||||
label="SERP / Keywords"
|
||||
value={data ? usd(data.services.serp.cost) : "—"}
|
||||
sub={data ? `${data.services.serp.callsTotal.toLocaleString()} calls` : ""}
|
||||
accent="text-amber-600"
|
||||
/>
|
||||
<MiniStat
|
||||
icon={Server}
|
||||
label="Hosting"
|
||||
value={data ? usd(data.services.hosting.costEstimate) : "—"}
|
||||
sub={data ? `${data.services.hosting.invocations.toLocaleString()} invokes` : ""}
|
||||
accent="text-cyan-600"
|
||||
/>
|
||||
<MiniStat
|
||||
icon={ShieldCheck}
|
||||
label="Auth / Clerk"
|
||||
value={data ? usd(data.services.auth.costEstimate) : "—"}
|
||||
sub={data ? `${data.services.auth.mau} MAU` : ""}
|
||||
accent="text-emerald-600"
|
||||
/>
|
||||
<MiniStat
|
||||
icon={Mail}
|
||||
label="Email"
|
||||
value={data ? usd(data.services.email.cost) : "—"}
|
||||
sub={data ? `${data.services.email.sent.toLocaleString()} sent` : ""}
|
||||
accent="text-slate-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Service breakdown grid */}
|
||||
<section>
|
||||
<SectionHeader title="Service cost breakdown" />
|
||||
{loading && !data ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i}><div className="p-5 h-40 bg-slate-50/40 animate-pulse" /></Card>
|
||||
))}
|
||||
</div>
|
||||
) : !data ? null : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<ServiceCard
|
||||
id="ai" labels={data.labels} cost={data.services.ai.cost}
|
||||
icon={Sparkles} accent="text-violet-600" bg="bg-violet-50"
|
||||
primaryStat={{ label: "Calls", value: data.services.ai.calls.toLocaleString() }}
|
||||
secondaryStat={{ label: "Avg/call", value: usd(data.services.ai.avgCostPerCall) }}
|
||||
note={
|
||||
data.services.ai.note
|
||||
?? `${(data.services.ai.inputTokens / 1_000_000).toFixed(2)}M input + ${(data.services.ai.outputTokens / 1_000_000).toFixed(2)}M output tokens`
|
||||
}
|
||||
sparkline={data.services.ai.dailyTrend.map((p) => p.cost)}
|
||||
/>
|
||||
<ServiceCard
|
||||
id="database" labels={data.labels} cost={data.services.database.costEstimate}
|
||||
icon={Database} accent="text-blue-600" bg="bg-blue-50"
|
||||
primaryStat={{ label: "Storage", value: bytesMbToGb(data.services.database.sizeMb) }}
|
||||
secondaryStat={{ label: "Growth", value: `${data.services.database.growthRateMbPerDay.toFixed(1)} MB/day` }}
|
||||
note={`Tier: ${data.services.database.tier} · Estimated from usage — verify against Neon billing dashboard`}
|
||||
/>
|
||||
<ServiceCard
|
||||
id="auth" labels={data.labels} cost={data.services.auth.costEstimate}
|
||||
icon={ShieldCheck} accent="text-emerald-600" bg="bg-emerald-50"
|
||||
primaryStat={{ label: "MAU", value: data.services.auth.mau.toLocaleString() }}
|
||||
secondaryStat={{ label: "Auth events", value: data.services.auth.authentications.toLocaleString() }}
|
||||
note={data.services.auth.mau <= 10_000 ? "Within Clerk free tier (≤ 10K MAU)" : "Above Clerk free tier"}
|
||||
/>
|
||||
<ServiceCard
|
||||
id="serp" labels={data.labels} cost={data.services.serp.cost}
|
||||
icon={Search} accent="text-amber-600" bg="bg-amber-50"
|
||||
primaryStat={{
|
||||
label: "Balance",
|
||||
value: typeof data.services.serp.balance === "number"
|
||||
? usd(data.services.serp.balance)
|
||||
: "—",
|
||||
}}
|
||||
secondaryStat={{ label: "Spent", value: usd(data.services.serp.spent ?? data.services.serp.cost) }}
|
||||
note={data.services.serp.note ?? "Estimated from action counts — verify against DataForSEO billing dashboard"}
|
||||
/>
|
||||
<ServiceCard
|
||||
id="hosting" labels={data.labels} cost={data.services.hosting.costEstimate}
|
||||
icon={Server} accent="text-cyan-600" bg="bg-cyan-50"
|
||||
primaryStat={{ label: "Invocations", value: data.services.hosting.invocations.toLocaleString() }}
|
||||
secondaryStat={{ label: "Bandwidth", value: `${data.services.hosting.bandwidthGbEstimate.toFixed(2)} GB` }}
|
||||
note="Vercel Pro baseline + estimated usage"
|
||||
/>
|
||||
<ServiceCard
|
||||
id="email" labels={data.labels} cost={data.services.email.cost}
|
||||
icon={Mail} accent="text-slate-600" bg="bg-slate-100"
|
||||
primaryStat={{ label: "Sent", value: data.services.email.sent.toLocaleString() }}
|
||||
secondaryStat={{ label: "Status", value: data.services.email.sent === 0 ? "Not configured" : "Active" }}
|
||||
note="Resend pricing — free tier 3K sends/month"
|
||||
/>
|
||||
<ServiceCard
|
||||
id="siteTag" labels={data.labels} cost={0}
|
||||
icon={TagIcon} accent="text-indigo-600" bg="bg-indigo-50"
|
||||
primaryStat={{ label: "Events collected", value: data.services.siteTag.collects.toLocaleString() }}
|
||||
secondaryStat={{ label: "Bandwidth", value: `${data.services.siteTag.bandwidthMb.toFixed(1)} MB` }}
|
||||
note="Included in Vercel hosting cost — shown separately for visibility"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* AI deep-dive — by feature + by model */}
|
||||
{data && data.services.ai.calls > 0 && (
|
||||
<section>
|
||||
<SectionHeader title="AI usage deep-dive" />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
<Card>
|
||||
<CardHeader title="By feature" />
|
||||
<table className="w-full text-xs">
|
||||
<thead className="text-[10px] font-bold uppercase tracking-widest text-slate-400 border-b border-slate-100">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2">Feature</th>
|
||||
<th className="text-right px-4 py-2">Calls</th>
|
||||
<th className="text-right px-4 py-2">Tokens (in/out)</th>
|
||||
<th className="text-right px-4 py-2">Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{data.services.ai.byFeature.map((f) => (
|
||||
<tr key={f.feature}>
|
||||
<td className="px-4 py-2 font-medium text-slate-800 capitalize">{formatFeature(f.feature)}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-700">{f.calls.toLocaleString()}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-500 text-[10px]">
|
||||
{(f.inputTokens / 1000).toFixed(1)}k / {(f.outputTokens / 1000).toFixed(1)}k
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums font-semibold text-amber-700">{usd(f.cost)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader title="By model" />
|
||||
<table className="w-full text-xs">
|
||||
<thead className="text-[10px] font-bold uppercase tracking-widest text-slate-400 border-b border-slate-100">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2">Model</th>
|
||||
<th className="text-right px-4 py-2">Calls</th>
|
||||
<th className="text-right px-4 py-2">Reference cost</th>
|
||||
<th className="text-right px-4 py-2">Stored cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{data.services.ai.byModel.map((m) => (
|
||||
<tr key={m.model}>
|
||||
<td className="px-4 py-2 font-mono text-[10px] text-slate-700 truncate max-w-[260px]" title={m.model}>
|
||||
{m.model}
|
||||
{!m.isKnownModel && (
|
||||
<span className="ml-1 text-[9px] font-semibold text-amber-600">·unpriced</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-700">{m.calls.toLocaleString()}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-500">{usd(m.referenceCost)}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums font-semibold text-amber-700">{usd(m.cost)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="px-4 py-2 border-t border-slate-100 bg-slate-50/40 text-[10px] text-slate-500">
|
||||
<strong>Reference cost</strong> uses the current pricing table in <code className="font-mono">src/lib/cost-config.ts</code>.
|
||||
Divergence from stored cost signals a pricing-table update is needed.
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Cost by brand */}
|
||||
{data && data.byBrand.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader title="Cost by brand"
|
||||
right={<span className="text-[10px] text-slate-400">Top {data.byBrand.length} by AI spend</span>}
|
||||
/>
|
||||
<Card>
|
||||
<table className="w-full text-xs">
|
||||
<thead className="text-[10px] font-bold uppercase tracking-widest text-slate-400 border-b border-slate-100 bg-slate-50/40">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2">Brand</th>
|
||||
<th className="text-right px-4 py-2">AI cost</th>
|
||||
<th className="text-right px-4 py-2">AI calls</th>
|
||||
<th className="text-right px-4 py-2">Events</th>
|
||||
<th className="text-right px-4 py-2">DB estimate</th>
|
||||
<th className="text-right px-4 py-2">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{data.byBrand.map((b) => (
|
||||
<tr key={b.brandId} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-2 font-medium text-slate-800 truncate max-w-[260px]">{b.brandName}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-amber-700 font-semibold">{usd(b.aiCost)}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-600">{b.aiCalls.toLocaleString()}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-600">{b.eventCount.toLocaleString()}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-500">{usd(b.dbCostEstimate)}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums font-bold text-slate-900">{usd(b.totalCost)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="px-4 py-2.5 border-t border-slate-100 bg-slate-50/40 text-[10px] text-slate-500">
|
||||
Helps identify brands whose cost-to-serve exceeds their plan price. Auth + hosting + SERP costs are platform-shared
|
||||
and not prorated here.
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Next-month projection */}
|
||||
{data && (
|
||||
<section>
|
||||
<SectionHeader title="Next-month projection"
|
||||
right={<span className="text-[10px] text-slate-400">Linear run-rate — not a forecast</span>}
|
||||
/>
|
||||
<Card>
|
||||
<div className="p-5 grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Estimated next month</p>
|
||||
<p className="text-4xl font-bold text-slate-900 tabular-nums leading-tight mt-1">
|
||||
{usd(data.projection.nextMonthEstimate)}
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-500 mt-1 leading-relaxed">
|
||||
AI extrapolated from current daily rate; fixed services repeat. Not a forecast —
|
||||
a budget check for end-of-month sizing.
|
||||
</p>
|
||||
</div>
|
||||
<div className="md:col-span-3 grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{Object.entries(data.projection.breakdown).map(([service, cost]) => (
|
||||
<div key={service} className="bg-slate-50 rounded-lg p-3 border border-slate-100">
|
||||
<p className="text-[9px] font-bold uppercase tracking-widest text-slate-400">{data.labels[service]?.label ?? service}</p>
|
||||
<p className="text-base font-bold text-slate-900 tabular-nums">{usd(cost)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Empty state — when no data at all */}
|
||||
{!loading && data && data.services.ai.calls === 0 && data.services.siteTag.collects === 0 && (
|
||||
<Card>
|
||||
<EmptyState
|
||||
icon={Gauge}
|
||||
title="No cost data in this window"
|
||||
description="Try widening the range, or pick Last Month to see prior-month costs."
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{loading && !data && (
|
||||
<div className="space-y-3">
|
||||
<TableSkeleton rows={3} />
|
||||
<TableSkeleton rows={5} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Helper components ────────────────────────────────────────────
|
||||
|
||||
function MiniStat({
|
||||
icon: Icon, label, value, sub, accent,
|
||||
}: {
|
||||
icon: typeof Sparkles;
|
||||
label: string;
|
||||
value: string;
|
||||
sub: string;
|
||||
accent: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-slate-50/60 rounded-lg p-3 border border-slate-100">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className="text-[9px] font-bold uppercase tracking-widest text-slate-500">{label}</p>
|
||||
<Icon className={`w-3.5 h-3.5 ${accent}`} />
|
||||
</div>
|
||||
<p className="text-lg font-bold text-slate-900 tabular-nums leading-tight">{value}</p>
|
||||
{sub && <p className="text-[10px] text-slate-400 mt-0.5">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceCard({
|
||||
id, labels, cost, icon: Icon, accent, bg,
|
||||
primaryStat, secondaryStat, note, sparkline,
|
||||
}: {
|
||||
id: string;
|
||||
labels: ServiceLabels;
|
||||
cost: number;
|
||||
icon: typeof Sparkles;
|
||||
accent: string;
|
||||
bg: string;
|
||||
primaryStat: { label: string; value: string };
|
||||
secondaryStat: { label: string; value: string };
|
||||
note: string;
|
||||
sparkline?: number[];
|
||||
}) {
|
||||
const meta = labels[id];
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-5 flex flex-col h-full">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-8 h-8 rounded-lg ${bg} ${accent} flex items-center justify-center`}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-slate-800">{meta?.label ?? id}</p>
|
||||
<p className="text-[10px] text-slate-400">{meta?.vendor ?? "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-slate-900 tabular-nums">{usd(cost)}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||
<div>
|
||||
<p className="text-[9px] font-bold uppercase tracking-widest text-slate-500">{primaryStat.label}</p>
|
||||
<p className="text-sm font-semibold text-slate-900 tabular-nums">{primaryStat.value}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[9px] font-bold uppercase tracking-widest text-slate-500">{secondaryStat.label}</p>
|
||||
<p className="text-sm font-semibold text-slate-900 tabular-nums">{secondaryStat.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
{sparkline && sparkline.length > 0 && (
|
||||
<div className="h-8 mb-3">
|
||||
<Sparkline points={sparkline} color="#8B5CF6" />
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-slate-500 leading-relaxed flex-1">{note}</p>
|
||||
{meta?.vendorUrl && (
|
||||
<Link
|
||||
href={meta.vendorUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="inline-flex items-center gap-1 text-[10px] font-semibold text-slate-600 hover:text-slate-900 mt-2"
|
||||
>
|
||||
Open {meta.vendor} dashboard <ExternalLink className="w-2.5 h-2.5" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// TrendingUp is imported for potential future use (sparkline trend arrows);
|
||||
// reference it here so the tree-shaker doesn't strip the import.
|
||||
void TrendingUp;
|
||||
@@ -0,0 +1,305 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Admin → Platform Analytics → Data Moat
|
||||
*
|
||||
* The cumulative first-party data advantage. Every metric here is
|
||||
* a number a competitor would have to spend money + time to
|
||||
* replicate — useful for investor + pricing conversations and for
|
||||
* answering "what does meSEO know that GA4 doesn't?".
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Database, ArrowLeft, Loader2, AlertTriangle, RefreshCw,
|
||||
Activity, Users as UsersIcon, MousePointerClick, FileSearch,
|
||||
Search, Sparkles, Shield,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
PageHeader, SectionHeader, Card, CardHeader, EmptyState, Sparkline,
|
||||
} from "@/components/admin";
|
||||
|
||||
interface Volumes {
|
||||
trackedEvents: number; trackedSessions: number; siteConversions: number;
|
||||
pagesCrawled: number; keywordsTracked: number; aiInteractions: number;
|
||||
growthPerDay: { trackedEvents: number; trackedSessions: number; siteConversions: number; aiInteractions: number };
|
||||
}
|
||||
interface DailyPoint { date: string; count: number }
|
||||
interface CumulativePoint { date: string; total: number }
|
||||
interface BrandRow {
|
||||
brandId: string; name: string; events: number; sessions: number;
|
||||
conversions: number; pagesCrawled: number; keywords: number;
|
||||
daysOfData: number; richnessScore: number;
|
||||
}
|
||||
interface MoatMetrics {
|
||||
firstPartyEventsGa4Cant: number;
|
||||
bookingFunnelInteractions: number;
|
||||
pluginsDetected: number;
|
||||
brandsWithPlugins: number;
|
||||
realUserCwv: number;
|
||||
}
|
||||
interface Payload {
|
||||
scope: { generatedAt: string };
|
||||
volumes: Volumes;
|
||||
growth: { eventsByDay: DailyPoint[]; cumulative: CumulativePoint[]; doubleDays: number | null };
|
||||
perBrand: BrandRow[];
|
||||
moat: MoatMetrics;
|
||||
}
|
||||
|
||||
function fmt(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
export default function DataMoatPage() {
|
||||
const [data, setData] = useState<Payload | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setError(null);
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 15_000);
|
||||
fetch("/api/admin/analytics/data-moat", { signal: controller.signal })
|
||||
.then(async (r) => {
|
||||
if (!r.ok) {
|
||||
let msg = `HTTP ${r.status}`;
|
||||
try { const body = await r.json(); if (body?.error) msg = `${msg}: ${body.error}`; } catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
return r.json() as Promise<Payload>;
|
||||
})
|
||||
.then(setData)
|
||||
.catch((err: Error) => {
|
||||
if (err.name === "AbortError") setError("Request timed out after 15s — try Retry.");
|
||||
else setError(err.message || "Failed to load data moat.");
|
||||
})
|
||||
.finally(() => clearTimeout(timeoutId));
|
||||
return () => { clearTimeout(timeoutId); controller.abort(); };
|
||||
}, [retryKey]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto mt-16">
|
||||
<div className="rounded-xl border border-rose-200 bg-rose-50 p-6 text-center">
|
||||
<AlertTriangle className="w-8 h-8 text-rose-500 mx-auto mb-2" />
|
||||
<h2 className="text-sm font-bold text-slate-900">Couldn't load Data Moat</h2>
|
||||
<p className="text-xs text-slate-600 mt-1">{error}</p>
|
||||
<button
|
||||
onClick={() => setRetryKey((k) => k + 1)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 mt-4 rounded-lg bg-white border border-slate-200 text-xs font-semibold text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" /> Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div className="flex items-center justify-center py-24"><Loader2 className="w-5 h-5 text-slate-400 animate-spin" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Data Moat"
|
||||
subtitle="First-party data advantage — what meSEO has captured that no competitor can replicate"
|
||||
icon={Database}
|
||||
actions={
|
||||
<Link href="/admin/analytics" className="inline-flex items-center gap-1 text-[11px] text-slate-600 hover:text-slate-900">
|
||||
<ArrowLeft className="w-3 h-3" />Back to Analytics
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Volume hero — six headline numbers + per-day growth */}
|
||||
<section>
|
||||
<SectionHeader title="Total data volume" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
<VolumeCard icon={Activity} label="TrackedEvents" value={fmt(data.volumes.trackedEvents)} sub={`+${fmt(data.volumes.growthPerDay.trackedEvents)}/day`} accent="text-violet-600" bg="bg-violet-50" />
|
||||
<VolumeCard icon={UsersIcon} label="TrackedSessions" value={fmt(data.volumes.trackedSessions)} sub={`+${fmt(data.volumes.growthPerDay.trackedSessions)}/day`} accent="text-indigo-600" bg="bg-indigo-50" />
|
||||
<VolumeCard icon={MousePointerClick} label="SiteConversions" value={fmt(data.volumes.siteConversions)} sub={`+${fmt(data.volumes.growthPerDay.siteConversions)}/day`} accent="text-emerald-600" bg="bg-emerald-50" />
|
||||
<VolumeCard icon={FileSearch} label="Pages crawled" value={fmt(data.volumes.pagesCrawled)} sub="lifetime" accent="text-amber-600" bg="bg-amber-50" />
|
||||
<VolumeCard icon={Search} label="Keywords tracked" value={fmt(data.volumes.keywordsTracked)} sub="distinct" accent="text-cyan-600" bg="bg-cyan-50" />
|
||||
<VolumeCard icon={Sparkles} label="AI interactions" value={fmt(data.volumes.aiInteractions)} sub={`+${fmt(data.volumes.growthPerDay.aiInteractions)}/day`} accent="text-fuchsia-600" bg="bg-fuchsia-50" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Growth — events per day sparkline + cumulative + doubling */}
|
||||
<section>
|
||||
<SectionHeader title="Growth"
|
||||
right={data.growth.doubleDays != null ? (
|
||||
<span className="text-[10px] text-slate-500">
|
||||
At current rate, lifetime events double every <span className="font-bold text-slate-800 tabular-nums">{data.growth.doubleDays}</span> days
|
||||
</span>
|
||||
) : undefined}
|
||||
/>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
<Card>
|
||||
<CardHeader title="Events per day · last 90 days" />
|
||||
<div className="px-5 py-4 h-32">
|
||||
<Sparkline points={data.growth.eventsByDay.map((d) => d.count)} color="#8B5CF6" variant="area" />
|
||||
</div>
|
||||
<div className="px-5 py-2 border-t border-slate-100 bg-slate-50/40 text-[10px] text-slate-500 flex items-center justify-between">
|
||||
<span>{data.growth.eventsByDay[0]?.date ?? "—"}</span>
|
||||
<span className="font-semibold text-slate-700">{fmt(data.growth.eventsByDay.reduce((s, p) => s + p.count, 0))} events in 90 days</span>
|
||||
<span>{data.growth.eventsByDay[data.growth.eventsByDay.length - 1]?.date ?? "—"}</span>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader title="Cumulative · running total" />
|
||||
<div className="px-5 py-4 h-32">
|
||||
<Sparkline points={data.growth.cumulative.map((d) => d.total)} color="#10B981" variant="line" />
|
||||
</div>
|
||||
<div className="px-5 py-2 border-t border-slate-100 bg-slate-50/40 text-[10px] text-slate-500 flex items-center justify-between">
|
||||
<span>start</span>
|
||||
<span className="font-semibold text-slate-700">{fmt(data.growth.cumulative[data.growth.cumulative.length - 1]?.total ?? 0)} total</span>
|
||||
<span>now</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Moat metrics — what GA4 misses */}
|
||||
<section>
|
||||
<SectionHeader title="Competitive moat" right={<span className="text-[10px] text-slate-400">numbers GA4 / SEMrush / Ahrefs don't have</span>} />
|
||||
<Card>
|
||||
<div className="p-5 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<MoatCell
|
||||
icon={Shield}
|
||||
label="Conversions GA4 doesn't see"
|
||||
value={fmt(data.moat.firstPartyEventsGa4Cant)}
|
||||
caption="gtag-intercepted + plugin postMessage paths captured by t.js"
|
||||
accent="text-emerald-600"
|
||||
/>
|
||||
<MoatCell
|
||||
icon={Activity}
|
||||
label="Booking funnel interactions"
|
||||
value={fmt(data.moat.bookingFunnelInteractions)}
|
||||
caption="widget opens + step events + submit + confirm across all brands"
|
||||
accent="text-violet-600"
|
||||
/>
|
||||
<MoatCell
|
||||
icon={Sparkles}
|
||||
label="Plugins auto-detected"
|
||||
value={`${data.moat.pluginsDetected} types`}
|
||||
caption={`across ${data.moat.brandsWithPlugins} brand${data.moat.brandsWithPlugins === 1 ? "" : "s"}`}
|
||||
accent="text-indigo-600"
|
||||
/>
|
||||
<MoatCell
|
||||
icon={Database}
|
||||
label="Real-user CWV measurements"
|
||||
value={fmt(data.moat.realUserCwv)}
|
||||
caption="LCP / FID / CLS samples from actual visitors (not lab data)"
|
||||
accent="text-cyan-600"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-5 py-2.5 border-t border-slate-100 bg-slate-50/40 text-[10px] text-slate-500">
|
||||
These numbers compound — every page-load adds to TrackedEvents, every conversion to SiteConversions,
|
||||
every audit to PageRecord. A competitor would need to install our Site Tag on every brand's site to
|
||||
replicate this dataset, which by definition they can't.
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Per-brand depth */}
|
||||
<section>
|
||||
<SectionHeader title="Per-brand data depth"
|
||||
right={<span className="text-[10px] text-slate-400">top {data.perBrand.length} by richness score</span>}
|
||||
/>
|
||||
<Card>
|
||||
{data.perBrand.length === 0 ? (
|
||||
<EmptyState icon={Database} title="No brand data yet" description="As Site Tags are installed and audits run, this table will populate." />
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead className="text-[10px] font-bold uppercase tracking-widest text-slate-400 border-b border-slate-100 bg-slate-50/40">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2">Brand</th>
|
||||
<th className="text-right px-4 py-2">Events</th>
|
||||
<th className="text-right px-4 py-2">Sessions</th>
|
||||
<th className="text-right px-4 py-2">Conversions</th>
|
||||
<th className="text-right px-4 py-2">Pages</th>
|
||||
<th className="text-right px-4 py-2">Keywords</th>
|
||||
<th className="text-right px-4 py-2">Days</th>
|
||||
<th className="text-right px-4 py-2">Richness</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{data.perBrand.map((b) => (
|
||||
<tr key={b.brandId} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-2 font-medium text-slate-800 truncate max-w-[260px]">{b.name}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-700">{fmt(b.events)}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-700">{fmt(b.sessions)}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-700">{fmt(b.conversions)}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-700">{fmt(b.pagesCrawled)}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-700">{fmt(b.keywords)}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-500">{b.daysOfData}d</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<span className={`inline-block px-2 py-0.5 rounded text-[10px] font-bold tabular-nums ${
|
||||
b.richnessScore >= 70 ? "bg-emerald-50 text-emerald-700 border border-emerald-200"
|
||||
: b.richnessScore >= 40 ? "bg-amber-50 text-amber-700 border border-amber-200"
|
||||
: "bg-slate-100 text-slate-600 border border-slate-200"
|
||||
}`}>
|
||||
{b.richnessScore}/100
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VolumeCard({
|
||||
icon: Icon, label, value, sub, accent, bg,
|
||||
}: {
|
||||
icon: typeof Activity;
|
||||
label: string;
|
||||
value: string;
|
||||
sub: string;
|
||||
accent: string;
|
||||
bg: string;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">{label}</p>
|
||||
<span className={`w-6 h-6 rounded-md ${bg} ${accent} flex items-center justify-center`}>
|
||||
<Icon className="w-3 h-3" />
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-slate-900 tabular-nums leading-tight">{value}</p>
|
||||
<p className="text-[10px] text-slate-500 mt-0.5">{sub}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function MoatCell({
|
||||
icon: Icon, label, value, caption, accent,
|
||||
}: {
|
||||
icon: typeof Activity;
|
||||
label: string;
|
||||
value: string;
|
||||
caption: string;
|
||||
accent: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Icon className={`w-3.5 h-3.5 ${accent}`} />
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">{label}</p>
|
||||
</div>
|
||||
<p className={`text-2xl font-bold tabular-nums ${accent}`}>{value}</p>
|
||||
<p className="text-[11px] text-slate-500 mt-1 leading-relaxed">{caption}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Admin → Platform Analytics → Engagement
|
||||
*
|
||||
* DAU / WAU / MAU + DAU/MAU stickiness + feature adoption table +
|
||||
* cohort retention heatmap + activity distribution. All sourced
|
||||
* from PlatformEvent + User; no third-party deps. Designed to be
|
||||
* useful at low scale (3 users) and increasingly informative as
|
||||
* the platform grows.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Users, Activity, Loader2, AlertTriangle, RefreshCw, ArrowLeft,
|
||||
Gauge,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
PageHeader, SectionHeader, Card, CardHeader, EmptyState, StatCard,
|
||||
} from "@/components/admin";
|
||||
|
||||
interface ActiveUsers { dau: number; wau: number; mau: number; stickiness: number }
|
||||
interface FeatureRow { action: string; category: string; users: number; sessions: number; lastUsed: string | null; pctOfUsers: number }
|
||||
interface CohortWeek { index: number; retained: number; pct: number }
|
||||
interface CohortRow { cohortLabel: string; cohortSize: number; weeks: CohortWeek[] }
|
||||
interface ActivityDist { power: number; regular: number; occasional: number; dormant: number }
|
||||
interface Payload {
|
||||
scope: { totalUsers: number; generatedAt: string };
|
||||
activeUsers: ActiveUsers;
|
||||
featureAdoption: FeatureRow[];
|
||||
cohortRetention: CohortRow[];
|
||||
activityDistribution: ActivityDist;
|
||||
}
|
||||
|
||||
function relTime(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
const sec = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (sec < 60) return "just now";
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
|
||||
if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
|
||||
return `${Math.floor(sec / 86400)}d ago`;
|
||||
} catch { return iso; }
|
||||
}
|
||||
|
||||
function heatColor(pct: number): string {
|
||||
if (pct >= 80) return "bg-emerald-600 text-white";
|
||||
if (pct >= 60) return "bg-emerald-400 text-white";
|
||||
if (pct >= 40) return "bg-amber-300 text-amber-900";
|
||||
if (pct >= 20) return "bg-rose-300 text-rose-900";
|
||||
if (pct > 0) return "bg-rose-500 text-white";
|
||||
return "bg-slate-100 text-slate-400";
|
||||
}
|
||||
|
||||
export default function EngagementPage() {
|
||||
const [data, setData] = useState<Payload | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setError(null);
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 15_000);
|
||||
fetch("/api/admin/analytics/engagement", { signal: controller.signal })
|
||||
.then(async (r) => {
|
||||
if (!r.ok) {
|
||||
let msg = `HTTP ${r.status}`;
|
||||
try { const body = await r.json(); if (body?.error) msg = `${msg}: ${body.error}`; } catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
return r.json() as Promise<Payload>;
|
||||
})
|
||||
.then(setData)
|
||||
.catch((err: Error) => {
|
||||
if (err.name === "AbortError") setError("Request timed out after 15s — try Retry.");
|
||||
else setError(err.message || "Failed to load engagement.");
|
||||
})
|
||||
.finally(() => clearTimeout(timeoutId));
|
||||
return () => { clearTimeout(timeoutId); controller.abort(); };
|
||||
}, [retryKey]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto mt-16">
|
||||
<div className="rounded-xl border border-rose-200 bg-rose-50 p-6 text-center">
|
||||
<AlertTriangle className="w-8 h-8 text-rose-500 mx-auto mb-2" />
|
||||
<h2 className="text-sm font-bold text-slate-900">Couldn't load Engagement</h2>
|
||||
<p className="text-xs text-slate-600 mt-1">{error}</p>
|
||||
<button
|
||||
onClick={() => setRetryKey((k) => k + 1)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 mt-4 rounded-lg bg-white border border-slate-200 text-xs font-semibold text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" /> Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div className="flex items-center justify-center py-24"><Loader2 className="w-5 h-5 text-slate-400 animate-spin" /></div>;
|
||||
}
|
||||
|
||||
const distTotal = Object.values(data.activityDistribution).reduce((s, v) => s + v, 0) || 1;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Engagement"
|
||||
subtitle={`${data.scope.totalUsers.toLocaleString()} total users · ${data.featureAdoption.length} active features in the last 30 days`}
|
||||
icon={Users}
|
||||
actions={
|
||||
<Link href="/admin/analytics" className="inline-flex items-center gap-1 text-[11px] text-slate-600 hover:text-slate-900">
|
||||
<ArrowLeft className="w-3 h-3" />Back to Analytics
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Active users — 3 KPIs + stickiness gauge */}
|
||||
<section>
|
||||
<SectionHeader title="Active users" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<StatCard label="DAU" value={data.activeUsers.dau.toLocaleString()} icon={Activity} variant="compact" valueClass="text-violet-600" />
|
||||
<StatCard label="WAU" value={data.activeUsers.wau.toLocaleString()} icon={Activity} variant="compact" valueClass="text-indigo-600" />
|
||||
<StatCard label="MAU" value={data.activeUsers.mau.toLocaleString()} icon={Activity} variant="compact" valueClass="text-blue-600" />
|
||||
<StatCard
|
||||
label="Stickiness · DAU/MAU"
|
||||
value={`${data.activeUsers.stickiness}%`}
|
||||
icon={Gauge}
|
||||
variant="compact"
|
||||
valueClass={data.activeUsers.stickiness >= 30 ? "text-emerald-600" : data.activeUsers.stickiness >= 15 ? "text-amber-600" : "text-rose-600"}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-500 mt-2">
|
||||
Stickiness ≥ 30% = strong (users return ~3× per week). 15-30% = average. Below 15% = mostly weekly visitors.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Activity distribution */}
|
||||
<section>
|
||||
<SectionHeader title="Activity distribution" />
|
||||
<Card>
|
||||
<div className="p-5 grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<DistCell label="Power" hint="active in 24h" count={data.activityDistribution.power} total={distTotal} color="bg-emerald-500" />
|
||||
<DistCell label="Regular" hint="active in 7d" count={data.activityDistribution.regular} total={distTotal} color="bg-blue-500" />
|
||||
<DistCell label="Occasional" hint="active in 30d" count={data.activityDistribution.occasional} total={distTotal} color="bg-amber-500" />
|
||||
<DistCell label="Dormant" hint="no activity 30d+" count={data.activityDistribution.dormant} total={distTotal} color="bg-rose-500" />
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Feature adoption */}
|
||||
<section>
|
||||
<SectionHeader title="Feature adoption" right={<span className="text-[10px] text-slate-400">last 30 days · top 25 by users</span>} />
|
||||
<Card>
|
||||
{data.featureAdoption.length === 0 ? (
|
||||
<EmptyState icon={Activity} title="No feature usage yet" description="PlatformEvent rows will populate this table as users interact with features." />
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead className="text-[10px] font-bold uppercase tracking-widest text-slate-400 border-b border-slate-100 bg-slate-50/40">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2">Feature</th>
|
||||
<th className="text-left px-4 py-2">Category</th>
|
||||
<th className="text-right px-4 py-2">Users</th>
|
||||
<th className="text-right px-4 py-2">% of users</th>
|
||||
<th className="text-right px-4 py-2">Sessions</th>
|
||||
<th className="text-right px-4 py-2">Last used</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{data.featureAdoption.map((f) => {
|
||||
// Highlight under-adoption — features used by < 50% of the
|
||||
// user base get a soft amber row to flag the gap.
|
||||
const underused = f.pctOfUsers < 50 && data.scope.totalUsers >= 2;
|
||||
return (
|
||||
<tr key={f.action} className={underused ? "bg-amber-50/30" : "hover:bg-slate-50"}>
|
||||
<td className="px-4 py-2 font-medium text-slate-800">
|
||||
{f.action.replace(/_/g, " ")}
|
||||
{underused && (
|
||||
<span className="ml-2 text-[9px] font-bold uppercase tracking-widest text-amber-700 bg-amber-100 px-1.5 py-0.5 rounded">
|
||||
underused
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-[10px] text-slate-500 uppercase tracking-wider">{f.category}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums font-semibold text-slate-900">{f.users.toLocaleString()}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-700">{f.pctOfUsers}%</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-500">{f.sessions.toLocaleString()}</td>
|
||||
<td className="px-4 py-2 text-right text-[10px] text-slate-500">{relTime(f.lastUsed)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Cohort retention heatmap */}
|
||||
<section>
|
||||
<SectionHeader title="Cohort retention heatmap"
|
||||
right={<span className="text-[10px] text-slate-400">Signup week × subsequent activity (last 90 days)</span>}
|
||||
/>
|
||||
<Card>
|
||||
{data.cohortRetention.length === 0 ? (
|
||||
<EmptyState icon={Users} title="No cohorts in the last 90 days" description="As new users sign up + interact, this grid populates with weekly retention." />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="text-[10px] font-bold uppercase tracking-widest text-slate-400 border-b border-slate-100 bg-slate-50/40">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2 sticky left-0 bg-slate-50/40">Signup week</th>
|
||||
<th className="text-right px-3 py-2">Size</th>
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<th key={i} className="text-center px-2 py-2">W{i}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{data.cohortRetention.map((c) => (
|
||||
<tr key={c.cohortLabel}>
|
||||
<td className="px-3 py-2 font-mono text-[11px] text-slate-700 sticky left-0 bg-white">{c.cohortLabel}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{c.cohortSize}</td>
|
||||
{c.weeks.map((w) => (
|
||||
<td key={w.index} className="px-2 py-2 text-center">
|
||||
<span className={`inline-block min-w-[36px] px-1.5 py-1 rounded text-[10px] font-bold tabular-nums ${heatColor(w.pct)}`}
|
||||
title={`${w.retained}/${c.cohortSize} = ${w.pct}%`}>
|
||||
{w.pct}%
|
||||
</span>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="px-4 py-2 border-t border-slate-100 bg-slate-50/40 text-[10px] text-slate-500">
|
||||
Each cell = % of the cohort active in that week (≥ 1 PlatformEvent). W0 is the signup week itself; W1+ measures return.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DistCell({
|
||||
label, hint, count, total, color,
|
||||
}: {
|
||||
label: string;
|
||||
hint: string;
|
||||
count: number;
|
||||
total: number;
|
||||
color: string;
|
||||
}) {
|
||||
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">{label}</p>
|
||||
<p className="text-[10px] text-slate-400 mb-1">{hint}</p>
|
||||
<p className="text-2xl font-bold text-slate-900 tabular-nums">{count.toLocaleString()}</p>
|
||||
<div className="h-1.5 bg-slate-100 rounded-full overflow-hidden mt-2">
|
||||
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 mt-1 tabular-nums">{pct}%</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Admin → Platform Analytics → Conversion Funnel
|
||||
*
|
||||
* Five-stage funnel aggregated over every TrackedSession in the
|
||||
* selected window for a brand. Drop-off percentages + source /
|
||||
* entry-page breakdowns help answer "where are we losing people
|
||||
* on the way to a conversion?" without dropping into raw SQL.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
TrendingUp, ArrowLeft, Loader2, Users, Eye, MousePointerClick,
|
||||
ClipboardList, CheckCircle2, Globe, LayoutList,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
PageHeader, SectionHeader, Card, CardHeader, EmptyState,
|
||||
} from "@/components/admin";
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────
|
||||
|
||||
interface BrandOption { id: string; name: string }
|
||||
|
||||
interface Stage { id: number; label: string; count: number }
|
||||
interface SourceRow {
|
||||
source: string;
|
||||
visits: number;
|
||||
engaged: number;
|
||||
intent: number;
|
||||
started: number;
|
||||
converted: number;
|
||||
conversionRate: number;
|
||||
}
|
||||
interface PageRow {
|
||||
page: string;
|
||||
visits: number;
|
||||
converted: number;
|
||||
conversionRate: number;
|
||||
}
|
||||
|
||||
interface FunnelPayload {
|
||||
scope: { brandId: string; range: string; fromISO: string };
|
||||
stages: Stage[];
|
||||
bySource: SourceRow[];
|
||||
byEntryPage: PageRow[];
|
||||
totals: { sessions: number; conversions: number; conversionRate: number };
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type Range = "1d" | "7d" | "30d" | "90d";
|
||||
|
||||
const RANGES: Array<{ id: Range; label: string }> = [
|
||||
{ id: "1d", label: "Today" },
|
||||
{ id: "7d", label: "7d" },
|
||||
{ id: "30d", label: "30d" },
|
||||
{ id: "90d", label: "90d" },
|
||||
];
|
||||
|
||||
// Stage colour ramp — solid at the top, gradually cooler toward
|
||||
// the conversion end of the funnel. Matches the session explorer
|
||||
// pill palette so the two pages feel consistent.
|
||||
const STAGE_COLORS: Record<number, { bar: string; chip: string; icon: typeof Users }> = {
|
||||
1: { bar: "bg-slate-500", chip: "bg-slate-100 text-slate-700", icon: Users },
|
||||
2: { bar: "bg-blue-500", chip: "bg-blue-50 text-blue-700", icon: Eye },
|
||||
3: { bar: "bg-violet-500", chip: "bg-violet-50 text-violet-700", icon: ClipboardList },
|
||||
4: { bar: "bg-amber-500", chip: "bg-amber-50 text-amber-700", icon: MousePointerClick },
|
||||
5: { bar: "bg-emerald-500", chip: "bg-emerald-50 text-emerald-700", icon: CheckCircle2 },
|
||||
};
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function pct(part: number, whole: number): number {
|
||||
return whole > 0 ? Math.round((part / whole) * 1000) / 10 : 0;
|
||||
}
|
||||
|
||||
// ─── Page ──────────────────────────────────────────────────────────
|
||||
|
||||
export default function FunnelPage() {
|
||||
const [brands, setBrands] = useState<BrandOption[]>([]);
|
||||
const [brandId, setBrandId] = useState<string>("");
|
||||
const [range, setRange] = useState<Range>("30d");
|
||||
const [data, setData] = useState<FunnelPayload | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/admin/brands?limit=500")
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((d) => {
|
||||
if (Array.isArray(d?.brands)) {
|
||||
const list: BrandOption[] = d.brands.map((b: { id: string; name: string }) => ({ id: b.id, name: b.name }));
|
||||
setBrands(list);
|
||||
if (!brandId && list.length > 0) setBrandId(list[0].id);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!brandId) return;
|
||||
const params = new URLSearchParams({ brandId, range });
|
||||
setLoading(true);
|
||||
fetch(`/api/admin/analytics/funnel?${params.toString()}`)
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((d: FunnelPayload | null) => setData(d))
|
||||
.finally(() => setLoading(false));
|
||||
}, [brandId, range]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const stages = data?.stages ?? [];
|
||||
const firstCount = stages[0]?.count ?? 0;
|
||||
|
||||
const stageRows = useMemo(() => {
|
||||
return stages.map((s, i) => {
|
||||
const prev = i === 0 ? s.count : stages[i - 1].count;
|
||||
const dropOff = i === 0 ? 0 : prev > 0 ? Math.round(((prev - s.count) / prev) * 1000) / 10 : 0;
|
||||
const widthPct = firstCount > 0 ? (s.count / firstCount) * 100 : 0;
|
||||
const color = STAGE_COLORS[s.id] ?? STAGE_COLORS[1];
|
||||
return { ...s, dropOff, widthPct, color };
|
||||
});
|
||||
}, [stages, firstCount]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Conversion Funnel"
|
||||
subtitle="Five-stage funnel aggregated across every session for the selected brand"
|
||||
icon={TrendingUp}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/admin/analytics"
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1.5 text-[11px] font-semibold text-slate-600 bg-white border border-slate-200 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
<ArrowLeft className="w-3 h-3" /> Analytics
|
||||
</Link>
|
||||
<select
|
||||
value={brandId}
|
||||
onChange={(e) => setBrandId(e.target.value)}
|
||||
className="px-3 py-1.5 text-xs bg-white border border-slate-200 rounded-lg min-w-[220px]"
|
||||
>
|
||||
{brands.length === 0 && <option value="">Loading brands…</option>}
|
||||
{brands.map((b) => (<option key={b.id} value={b.id}>{b.name}</option>))}
|
||||
</select>
|
||||
<div className="inline-flex bg-slate-100 rounded-lg p-0.5">
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => setRange(r.id)}
|
||||
className={`px-2.5 py-1 text-[11px] font-semibold rounded-md transition-colors ${
|
||||
range === r.id ? "bg-white text-slate-900 shadow-sm" : "text-slate-500 hover:text-slate-700"
|
||||
}`}
|
||||
>{r.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Totals summary */}
|
||||
{data && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<SummaryStat
|
||||
icon={Users}
|
||||
label="Sessions"
|
||||
value={data.totals.sessions.toLocaleString()}
|
||||
accent="text-slate-700"
|
||||
bg="bg-slate-100"
|
||||
/>
|
||||
<SummaryStat
|
||||
icon={CheckCircle2}
|
||||
label="Conversions"
|
||||
value={data.totals.conversions.toLocaleString()}
|
||||
accent="text-emerald-700"
|
||||
bg="bg-emerald-50"
|
||||
/>
|
||||
<SummaryStat
|
||||
icon={TrendingUp}
|
||||
label="Conversion rate"
|
||||
value={`${data.totals.conversionRate.toFixed(1)}%`}
|
||||
accent={data.totals.conversionRate >= 10 ? "text-emerald-700" : data.totals.conversionRate >= 2 ? "text-amber-700" : "text-slate-600"}
|
||||
bg={data.totals.conversionRate >= 10 ? "bg-emerald-50" : data.totals.conversionRate >= 2 ? "bg-amber-50" : "bg-slate-100"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Funnel visualisation */}
|
||||
<section>
|
||||
<SectionHeader title="Funnel stages" />
|
||||
<Card>
|
||||
{loading && !data ? (
|
||||
<div className="p-8 flex items-center justify-center text-xs text-slate-500">
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" /> Loading funnel…
|
||||
</div>
|
||||
) : !data || firstCount === 0 ? (
|
||||
<EmptyState
|
||||
icon={TrendingUp}
|
||||
title="No sessions in range"
|
||||
description="Widen the range or confirm the Site Tag is firing for this brand."
|
||||
/>
|
||||
) : (
|
||||
<div className="p-5 space-y-3">
|
||||
{stageRows.map((s, i) => {
|
||||
const Icon = s.color.icon;
|
||||
const share = pct(s.count, firstCount);
|
||||
return (
|
||||
<div key={s.id}>
|
||||
<div className="flex items-center justify-between text-[11px] mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-bold ${s.color.chip}`}>
|
||||
{s.id}
|
||||
</span>
|
||||
<Icon className="w-3.5 h-3.5 text-slate-500" />
|
||||
<span className="font-semibold text-slate-700">{s.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 tabular-nums">
|
||||
<span className="font-bold text-slate-900">{s.count.toLocaleString()}</span>
|
||||
<span className="text-slate-500">{share.toFixed(1)}%</span>
|
||||
{i > 0 && s.dropOff > 0 && (
|
||||
<span className="text-[10px] text-rose-600 font-semibold">
|
||||
−{s.dropOff.toFixed(1)}% drop
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 rounded-lg h-5 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-lg ${s.color.bar} transition-all`}
|
||||
style={{ width: `${Math.max(s.widthPct, 1.5)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Source breakdown */}
|
||||
<section>
|
||||
<SectionHeader title="Funnel by source" />
|
||||
<Card>
|
||||
<CardHeader title="Top traffic sources" right={data && <span className="text-[10px] text-slate-400">{data.bySource.length} source{data.bySource.length === 1 ? "" : "s"}</span>} />
|
||||
{!data || data.bySource.length === 0 ? (
|
||||
<EmptyState icon={Globe} title="No traffic sources yet" description="No sessions with identifiable UTM / referrer data in the selected window." />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="text-[10px] font-semibold uppercase tracking-widest text-slate-400 border-b border-slate-100 bg-slate-50/40">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2">Source</th>
|
||||
<th className="text-right px-4 py-2">Visits</th>
|
||||
<th className="text-right px-4 py-2">Engaged</th>
|
||||
<th className="text-right px-4 py-2">Intent</th>
|
||||
<th className="text-right px-4 py-2">Started</th>
|
||||
<th className="text-right px-4 py-2">Converted</th>
|
||||
<th className="text-right px-4 py-2">Conv Rate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{data.bySource.map((s) => (
|
||||
<tr key={s.source} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="px-4 py-2 font-mono text-[11px] text-slate-700 truncate max-w-[240px]" title={s.source}>{s.source}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-700">{s.visits.toLocaleString()}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-500">{s.engaged.toLocaleString()}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-500">{s.intent.toLocaleString()}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-500">{s.started.toLocaleString()}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums font-semibold text-emerald-700">{s.converted.toLocaleString()}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<span className={`inline-flex items-center justify-end px-1.5 py-0.5 rounded border text-[10px] font-bold tabular-nums ${
|
||||
s.conversionRate >= 10 ? "bg-emerald-50 text-emerald-700 border-emerald-200"
|
||||
: s.conversionRate >= 2 ? "bg-amber-50 text-amber-700 border-amber-200"
|
||||
: "bg-slate-100 text-slate-600 border-slate-200"
|
||||
}`}>
|
||||
{s.conversionRate.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Entry page breakdown */}
|
||||
<section>
|
||||
<SectionHeader title="Funnel by entry page" />
|
||||
<Card>
|
||||
<CardHeader title="Landing pages" right={data && <span className="text-[10px] text-slate-400">{data.byEntryPage.length} page{data.byEntryPage.length === 1 ? "" : "s"}</span>} />
|
||||
{!data || data.byEntryPage.length === 0 ? (
|
||||
<EmptyState icon={LayoutList} title="No entry pages yet" description="No sessions recorded for the selected window." />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="text-[10px] font-semibold uppercase tracking-widest text-slate-400 border-b border-slate-100 bg-slate-50/40">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2">Entry Page</th>
|
||||
<th className="text-right px-4 py-2">Visits</th>
|
||||
<th className="text-right px-4 py-2">Converted</th>
|
||||
<th className="text-right px-4 py-2">Conv Rate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{data.byEntryPage.map((p) => (
|
||||
<tr key={p.page} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="px-4 py-2 font-mono text-[11px] text-brand-600 truncate max-w-[320px]" title={p.page}>{p.page}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-700">{p.visits.toLocaleString()}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums font-semibold text-emerald-700">{p.converted.toLocaleString()}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<span className={`inline-flex items-center justify-end px-1.5 py-0.5 rounded border text-[10px] font-bold tabular-nums ${
|
||||
p.conversionRate >= 10 ? "bg-emerald-50 text-emerald-700 border-emerald-200"
|
||||
: p.conversionRate >= 2 ? "bg-amber-50 text-amber-700 border-amber-200"
|
||||
: "bg-slate-100 text-slate-600 border-slate-200"
|
||||
}`}>
|
||||
{p.conversionRate.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryStat({
|
||||
icon: Icon, label, value, accent, bg,
|
||||
}: {
|
||||
icon: typeof Users;
|
||||
label: string;
|
||||
value: string;
|
||||
accent: string;
|
||||
bg: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 px-4 py-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`w-7 h-7 rounded-lg ${bg} ${accent} flex items-center justify-center`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">{label}</p>
|
||||
</div>
|
||||
<p className={`text-2xl font-bold tabular-nums leading-none ${accent}`}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Admin → Platform Analytics → Growth & Retention
|
||||
*
|
||||
* Monthly growth lines (users / brands / events / sessions) +
|
||||
* churn analysis (active / at-risk / churned / lost) + activation
|
||||
* funnel + MRR projection (pre-Stripe).
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
TrendingUp, ArrowLeft, Loader2, AlertTriangle, RefreshCw,
|
||||
Users as UsersIcon, Globe, Activity, MousePointerClick,
|
||||
AlertCircle, CheckCircle2, ArrowDown, DollarSign,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
PageHeader, SectionHeader, Card, CardHeader, EmptyState, Sparkline, StatCard,
|
||||
} from "@/components/admin";
|
||||
|
||||
interface MonthRow { month: string; count: number }
|
||||
interface AtRiskUser { id: string; email: string | null; lastSeen: string | null }
|
||||
interface FunnelStep { step: string; label: string; count: number; pct: number }
|
||||
interface Payload {
|
||||
scope: { generatedAt: string };
|
||||
monthly: { users: MonthRow[]; brands: MonthRow[]; events: MonthRow[]; sessions: MonthRow[] };
|
||||
churn: { active: number; atRisk: number; churned: number; lost: number; atRiskUsers: AtRiskUser[] };
|
||||
funnel: FunnelStep[];
|
||||
mrr: { ifAllPaying: number; brandCount: number; assumption: string; growthPerMonth: number };
|
||||
}
|
||||
|
||||
function fmt(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
function usd(n: number): string {
|
||||
if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `$${(n / 1_000).toFixed(1)}k`;
|
||||
return `$${n.toLocaleString()}`;
|
||||
}
|
||||
function fmtMonth(s: string): string {
|
||||
try {
|
||||
const [y, m] = s.split("-");
|
||||
return new Date(Number(y), Number(m) - 1, 1).toLocaleDateString(undefined, { month: "short", year: "2-digit" });
|
||||
} catch { return s; }
|
||||
}
|
||||
function relTime(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
const d = Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
|
||||
return d <= 0 ? "today" : `${d}d ago`;
|
||||
} catch { return iso; }
|
||||
}
|
||||
|
||||
export default function GrowthPage() {
|
||||
const [data, setData] = useState<Payload | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setError(null);
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 15_000);
|
||||
fetch("/api/admin/analytics/growth", { signal: controller.signal })
|
||||
.then(async (r) => {
|
||||
if (!r.ok) {
|
||||
let msg = `HTTP ${r.status}`;
|
||||
try { const body = await r.json(); if (body?.error) msg = `${msg}: ${body.error}`; } catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
return r.json() as Promise<Payload>;
|
||||
})
|
||||
.then(setData)
|
||||
.catch((err: Error) => {
|
||||
if (err.name === "AbortError") setError("Request timed out after 15s — try Retry.");
|
||||
else setError(err.message || "Failed to load growth metrics.");
|
||||
})
|
||||
.finally(() => clearTimeout(timeoutId));
|
||||
return () => { clearTimeout(timeoutId); controller.abort(); };
|
||||
}, [retryKey]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto mt-16">
|
||||
<div className="rounded-xl border border-rose-200 bg-rose-50 p-6 text-center">
|
||||
<AlertTriangle className="w-8 h-8 text-rose-500 mx-auto mb-2" />
|
||||
<h2 className="text-sm font-bold text-slate-900">Couldn't load Growth</h2>
|
||||
<p className="text-xs text-slate-600 mt-1">{error}</p>
|
||||
<button
|
||||
onClick={() => setRetryKey((k) => k + 1)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 mt-4 rounded-lg bg-white border border-slate-200 text-xs font-semibold text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" /> Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div className="flex items-center justify-center py-24"><Loader2 className="w-5 h-5 text-slate-400 animate-spin" /></div>;
|
||||
}
|
||||
|
||||
const churnTotal = data.churn.active + data.churn.atRisk + data.churn.churned + data.churn.lost || 1;
|
||||
const maxFunnel = data.funnel[0]?.count || 1;
|
||||
|
||||
// Identify the biggest single drop-off in the activation funnel
|
||||
// (gap between consecutive pct values). The UI highlights it
|
||||
// with an amber row + a "biggest drop" badge so operators can
|
||||
// see where to focus onboarding.
|
||||
let biggestDropIndex = -1;
|
||||
let biggestDrop = 0;
|
||||
for (let i = 1; i < data.funnel.length; i++) {
|
||||
const drop = data.funnel[i - 1].pct - data.funnel[i].pct;
|
||||
if (drop > biggestDrop) { biggestDrop = drop; biggestDropIndex = i; }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Growth & Retention"
|
||||
subtitle={`${data.mrr.brandCount} total brands · ${data.churn.active} active users · MRR potential ${usd(data.mrr.ifAllPaying)}/mo`}
|
||||
icon={TrendingUp}
|
||||
actions={
|
||||
<Link href="/admin/analytics" className="inline-flex items-center gap-1 text-[11px] text-slate-600 hover:text-slate-900">
|
||||
<ArrowLeft className="w-3 h-3" />Back to Analytics
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* MRR projection — top-of-page hero card */}
|
||||
<Card>
|
||||
<div className="p-6 grid grid-cols-1 md:grid-cols-3 gap-6 items-center">
|
||||
<div className="md:col-span-1">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">MRR potential</p>
|
||||
<p className="text-5xl font-bold text-slate-900 tabular-nums leading-tight inline-flex items-baseline gap-1">
|
||||
<DollarSign className="w-7 h-7 text-emerald-600" />{fmt(data.mrr.ifAllPaying).replace("$", "")}
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-500 mt-1 leading-relaxed">{data.mrr.assumption}</p>
|
||||
</div>
|
||||
<div className="md:col-span-2 grid grid-cols-2 gap-3">
|
||||
<div className="bg-slate-50 rounded-lg p-3 border border-slate-100">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Last 30d brand additions</p>
|
||||
<p className="text-2xl font-bold text-slate-900 tabular-nums">+{usd(data.mrr.growthPerMonth)}/mo</p>
|
||||
<p className="text-[10px] text-slate-500 mt-0.5">if those brands convert at the same Pro-plan rate</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-3 border border-slate-100">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Total brands</p>
|
||||
<p className="text-2xl font-bold text-slate-900 tabular-nums">{data.mrr.brandCount.toLocaleString()}</p>
|
||||
<p className="text-[10px] text-slate-500 mt-0.5">pre-Stripe — switch to actual plan distribution once billing ships</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Monthly growth — 4 sparkline cards */}
|
||||
<section>
|
||||
<SectionHeader title="Monthly growth" right={<span className="text-[10px] text-slate-400">last 12 months</span>} />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<MonthlyCard icon={UsersIcon} label="New users" rows={data.monthly.users} color="#6366F1" />
|
||||
<MonthlyCard icon={Globe} label="New brands" rows={data.monthly.brands} color="#10B981" />
|
||||
<MonthlyCard icon={Activity} label="Events / mo" rows={data.monthly.events} color="#8B5CF6" />
|
||||
<MonthlyCard icon={MousePointerClick} label="Sessions / mo" rows={data.monthly.sessions} color="#3B82F6" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Churn analysis */}
|
||||
<section>
|
||||
<SectionHeader title="Churn analysis" right={<span className="text-[10px] text-slate-400">based on PlatformEvent recency</span>} />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-3">
|
||||
<ChurnCard label="Active" sub="≤ 30 days" count={data.churn.active} total={churnTotal} color="bg-emerald-500" textColor="text-emerald-600" />
|
||||
<ChurnCard label="At risk" sub="30-60 days" count={data.churn.atRisk} total={churnTotal} color="bg-amber-500" textColor="text-amber-600" />
|
||||
<ChurnCard label="Churned" sub="60-90 days" count={data.churn.churned} total={churnTotal} color="bg-orange-500" textColor="text-orange-600" />
|
||||
<ChurnCard label="Lost" sub="90+ days" count={data.churn.lost} total={churnTotal} color="bg-rose-500" textColor="text-rose-600" />
|
||||
</div>
|
||||
{data.churn.atRiskUsers.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader title="At-risk users" right={<span className="text-[10px] text-slate-400">{data.churn.atRiskUsers.length} listed</span>} />
|
||||
<table className="w-full text-xs">
|
||||
<thead className="text-[10px] font-bold uppercase tracking-widest text-slate-400 border-b border-slate-100 bg-slate-50/40">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2">User</th>
|
||||
<th className="text-right px-4 py-2">Last activity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{data.churn.atRiskUsers.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td className="px-4 py-2 text-slate-800 truncate">{u.email ?? <span className="font-mono text-[10px] text-slate-500">{u.id.slice(0, 12)}…</span>}</td>
|
||||
<td className="px-4 py-2 text-right text-[11px] text-slate-500">{relTime(u.lastSeen)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Activation funnel */}
|
||||
<section>
|
||||
<SectionHeader title="Activation funnel"
|
||||
right={biggestDropIndex >= 0 && biggestDrop > 0
|
||||
? <span className="text-[10px] text-amber-700 font-semibold">Biggest drop: −{biggestDrop.toFixed(1)}% at {data.funnel[biggestDropIndex].label}</span>
|
||||
: undefined}
|
||||
/>
|
||||
<Card>
|
||||
{data.funnel.length === 0 ? (
|
||||
<EmptyState icon={CheckCircle2} title="No funnel data yet" description="Steps populate as brands sign up + complete onboarding." />
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100">
|
||||
{data.funnel.map((s, i) => {
|
||||
const widthPct = (s.count / maxFunnel) * 100;
|
||||
const isDrop = i === biggestDropIndex;
|
||||
return (
|
||||
<div key={s.step} className={`px-5 py-3 ${isDrop ? "bg-amber-50/40" : ""}`}>
|
||||
<div className="flex items-center gap-3 mb-1.5">
|
||||
{isDrop ? (
|
||||
<ArrowDown className="w-3.5 h-3.5 text-amber-600 shrink-0" />
|
||||
) : (
|
||||
<CheckCircle2 className={`w-3.5 h-3.5 shrink-0 ${i === 0 ? "text-emerald-600" : "text-slate-400"}`} />
|
||||
)}
|
||||
<span className="text-xs font-semibold text-slate-800 flex-1">{s.label}</span>
|
||||
<span className="text-[10px] text-slate-500 tabular-nums">{s.count.toLocaleString()} brands</span>
|
||||
<span className="text-xs font-bold text-slate-900 tabular-nums w-12 text-right">{s.pct}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full ${isDrop ? "bg-amber-500" : "bg-emerald-500"}`} style={{ width: `${Math.max(widthPct, 1)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MonthlyCard({
|
||||
icon: Icon, label, rows, color,
|
||||
}: {
|
||||
icon: typeof UsersIcon;
|
||||
label: string;
|
||||
rows: MonthRow[];
|
||||
color: string;
|
||||
}) {
|
||||
const total = rows.reduce((s, r) => s + r.count, 0);
|
||||
const last = rows[rows.length - 1]?.count ?? 0;
|
||||
const prev = rows[rows.length - 2]?.count ?? 0;
|
||||
const trend = prev > 0 ? Math.round(((last - prev) / prev) * 1000) / 10 : 0;
|
||||
return (
|
||||
<StatCard
|
||||
label={label}
|
||||
value={fmt(total)}
|
||||
icon={Icon}
|
||||
variant="compact"
|
||||
sparkline={rows.map((r) => r.count)}
|
||||
trendPct={trend}
|
||||
color={color}
|
||||
hint={`${fmtMonth(rows[0]?.month ?? "")} → ${fmtMonth(rows[rows.length - 1]?.month ?? "")}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ChurnCard({
|
||||
label, sub, count, total, color, textColor,
|
||||
}: {
|
||||
label: string;
|
||||
sub: string;
|
||||
count: number;
|
||||
total: number;
|
||||
color: string;
|
||||
textColor: string;
|
||||
}) {
|
||||
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">{label}</p>
|
||||
<span className="text-[10px] text-slate-400">{sub}</span>
|
||||
</div>
|
||||
<p className={`text-3xl font-bold tabular-nums ${textColor}`}>{count.toLocaleString()}</p>
|
||||
<div className="h-1.5 bg-slate-100 rounded-full overflow-hidden mt-2">
|
||||
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-500 mt-1 tabular-nums">{pct}% of users</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Reference an unused icon import that future copy mentions to
|
||||
// keep tree-shaking happy without a separate import update.
|
||||
void AlertCircle;
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Admin → Platform Analytics index
|
||||
* ─────────────────────────────────
|
||||
* Landing page for the analytics sub-tree. All four surfaces are
|
||||
* live: Brand Strength Radar, Engagement, Data Moat, and Growth
|
||||
* & Retention. Each card deep-links to its own page.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { Target, Users, Database, TrendingUp, ArrowRight, Filter } from "lucide-react";
|
||||
|
||||
interface AnalyticsLink {
|
||||
href: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: typeof Target;
|
||||
status: "live" | "soon";
|
||||
}
|
||||
|
||||
const LINKS: AnalyticsLink[] = [
|
||||
{
|
||||
href: "/admin/analytics/radar",
|
||||
title: "Brand Strength Radar",
|
||||
description:
|
||||
"8-dimension health score per brand + platform aggregate. Top / bottom 10 leaderboard.",
|
||||
icon: Target,
|
||||
status: "live",
|
||||
},
|
||||
{
|
||||
href: "/admin/analytics/engagement",
|
||||
title: "Engagement",
|
||||
description:
|
||||
"DAU / WAU / MAU + DAU/MAU stickiness. Cohort retention heatmap. Feature adoption rates.",
|
||||
icon: Users,
|
||||
status: "live",
|
||||
},
|
||||
{
|
||||
href: "/admin/analytics/data-moat",
|
||||
title: "Data Moat",
|
||||
description:
|
||||
"Total events captured, sessions, conversions across the platform. Growth rate per month.",
|
||||
icon: Database,
|
||||
status: "live",
|
||||
},
|
||||
{
|
||||
href: "/admin/analytics/growth",
|
||||
title: "Growth & Retention",
|
||||
description:
|
||||
"New users / brands / events per month. Churn analysis (30 / 60 / 90 day inactive).",
|
||||
icon: TrendingUp,
|
||||
status: "live",
|
||||
},
|
||||
{
|
||||
href: "/admin/analytics/funnel",
|
||||
title: "Conversion Funnel",
|
||||
description:
|
||||
"5-stage journey per brand with drop-off at each step + source + entry-page breakdown.",
|
||||
icon: Filter,
|
||||
status: "live",
|
||||
},
|
||||
];
|
||||
|
||||
export default function AdminAnalyticsIndex() {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
|
||||
<Target className="w-5 h-5 text-slate-700" /> Platform Analytics
|
||||
</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">
|
||||
Aggregate views across the whole platform. Drill into a specific brand from <Link href="/admin/brands" className="text-brand-600 hover:underline">/admin/brands</Link>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{LINKS.map((l) => {
|
||||
const Icon = l.icon;
|
||||
const inner = (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-5 hover:border-brand-300 hover:shadow-sm transition-all flex flex-col h-full">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-9 h-9 rounded-lg bg-slate-100 text-slate-600 flex items-center justify-center">
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<h2 className="text-sm font-bold text-slate-900">{l.title}</h2>
|
||||
{l.status === "soon" && (
|
||||
<span className="ml-auto text-[8px] font-bold uppercase tracking-widest text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded">
|
||||
Soon
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 leading-relaxed flex-1">{l.description}</p>
|
||||
{l.status === "live" && (
|
||||
<div className="mt-3 inline-flex items-center gap-1 text-xs font-semibold text-brand-600">
|
||||
Open <ArrowRight className="w-3 h-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
if (l.status === "live") return <Link key={l.title} href={l.href}>{inner}</Link>;
|
||||
return <div key={l.title} className="opacity-60 cursor-not-allowed">{inner}</div>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Admin → Analytics → Radar
|
||||
* ─────────────────────────
|
||||
* Brand Strength Radar — 8 dimensions per brand + a platform-wide
|
||||
* aggregate. Custom SVG implementation (no recharts dep) so the
|
||||
* dashboard renders identically in dev / staging / prod regardless
|
||||
* of whether recharts types are installed.
|
||||
*
|
||||
* Header: average platform health (traffic-light), brand count,
|
||||
* and the platform radar plot. Below: leaderboard top 10 / bottom
|
||||
* 10 by health.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Target, Loader2, TrendingUp, TrendingDown,
|
||||
AlertTriangle, Sparkles, BarChart3, ArrowRight, Info,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface Dimension { id: string; label: string }
|
||||
|
||||
interface LeaderRow { brandId: string; brandName: string; domain: string; health: number }
|
||||
|
||||
interface PlatformInsights {
|
||||
weakestDimension: { id: string; label: string; avgScore: number };
|
||||
strongestDimension: { id: string; label: string; avgScore: number };
|
||||
mostImproved: { brandId: string; brandName: string; health: number } | null;
|
||||
needsAttention: { brandId: string; brandName: string; health: number } | null;
|
||||
distribution: Record<"0-20" | "20-40" | "40-60" | "60-80" | "80-100", number>;
|
||||
}
|
||||
|
||||
interface PlatformPayload {
|
||||
dimensions: Dimension[];
|
||||
averages: Record<string, number>;
|
||||
averageHealth: number;
|
||||
brandCount: number;
|
||||
platformInsights: PlatformInsights | null;
|
||||
dimensionWeakness: Record<string, number>;
|
||||
leaderboard: { top: LeaderRow[]; bottom: LeaderRow[] };
|
||||
}
|
||||
|
||||
export default function AdminRadarPage() {
|
||||
const [data, setData] = useState<PlatformPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/admin/analytics/radar")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((d) => setData(d))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Loader2 className="w-5 h-5 text-slate-400 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dimensions = data.dimensions;
|
||||
const averages = data.averages;
|
||||
const healthColor = data.averageHealth >= 80 ? "text-emerald-600"
|
||||
: data.averageHealth >= 50 ? "text-amber-600" : "text-red-600";
|
||||
|
||||
// Sort dimensions to render the radar — alphabetical-by-id is fine,
|
||||
// we just need a stable order.
|
||||
const dimsSorted = [...dimensions];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
|
||||
<Target className="w-5 h-5 text-slate-700" /> Brand Strength Radar
|
||||
</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">
|
||||
Eight-dimension health score across {data.brandCount} brand{data.brandCount !== 1 ? "s" : ""}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Platform headline */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6 grid grid-cols-1 md:grid-cols-2 gap-6 items-center">
|
||||
<div className="flex flex-col items-center md:items-start">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Platform health</p>
|
||||
<p className={`text-6xl font-bold tabular-nums ${healthColor}`}>{data.averageHealth}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Average across all brands. <span className="font-semibold">Higher = better.</span>
|
||||
</p>
|
||||
<ul className="mt-4 space-y-1 text-xs">
|
||||
{dimsSorted.map((d) => {
|
||||
const score = averages[d.id] ?? 0;
|
||||
const color = score >= 70 ? "text-emerald-600" : score >= 40 ? "text-amber-600" : "text-red-600";
|
||||
return (
|
||||
<li key={d.id} className="flex items-center gap-3">
|
||||
<span className="text-slate-700 w-40">{d.label}</span>
|
||||
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${score >= 70 ? "bg-emerald-500" : score >= 40 ? "bg-amber-500" : "bg-red-500"}`}
|
||||
style={{ width: `${score}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`font-bold tabular-nums w-8 text-right ${color}`}>{score}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<RadarChart
|
||||
dimensions={dimsSorted}
|
||||
scores={averages}
|
||||
color="#6366F1"
|
||||
size={480}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Platform-wide insights */}
|
||||
{data.platformInsights && (
|
||||
<PlatformInsightsCard
|
||||
insights={data.platformInsights}
|
||||
dimensionWeakness={data.dimensionWeakness}
|
||||
dimensions={dimensions}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Per-dimension deep dive — one expandable card per dimension.
|
||||
Currently shows the platform AVERAGE score per dimension;
|
||||
clicking a card opens a recommendations/details view wired
|
||||
to /api/admin/analytics/radar?brandId=... when a specific
|
||||
brand is selected (future enhancement). */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<h2 className="text-sm font-bold text-slate-800">Dimension deep-dive</h2>
|
||||
<span className="text-[10px] text-slate-400 uppercase tracking-widest">Platform averages</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{dimensions.map((d) => (
|
||||
<DimensionCard
|
||||
key={d.id}
|
||||
id={d.id}
|
||||
label={d.label}
|
||||
score={averages[d.id] ?? 0}
|
||||
brandsWeakOnThis={data.dimensionWeakness[d.id] ?? 0}
|
||||
totalBrands={data.brandCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Leaderboard */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Leaderboard
|
||||
title="Top 10 brands"
|
||||
icon={TrendingUp}
|
||||
accent="emerald"
|
||||
rows={data.leaderboard.top}
|
||||
/>
|
||||
<Leaderboard
|
||||
title="Bottom 10 brands (focus area)"
|
||||
icon={TrendingDown}
|
||||
accent="red"
|
||||
rows={data.leaderboard.bottom}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Placeholder for the 12-month trend chart. Ships once
|
||||
PlatformSnapshot carries per-month radar averages — until
|
||||
then we surface the expectation so the section isn't empty. */}
|
||||
<div className="rounded-xl border border-dashed border-slate-300 bg-slate-50/40 px-4 py-6 text-center">
|
||||
<BarChart3 className="w-5 h-5 text-slate-400 inline-block mb-1" />
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-slate-500">Platform health trend</p>
|
||||
<p className="text-[11px] text-slate-500 mt-1 leading-relaxed max-w-lg mx-auto">
|
||||
Collecting baseline data. The 12-month platform-health line chart turns on once
|
||||
PlatformSnapshot captures monthly radar averages (trend available after 30 days).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Platform-wide insights card ──────────────────────────────────
|
||||
|
||||
function PlatformInsightsCard({
|
||||
insights, dimensionWeakness, dimensions,
|
||||
}: {
|
||||
insights: PlatformInsights;
|
||||
dimensionWeakness: Record<string, number>;
|
||||
dimensions: Dimension[];
|
||||
}) {
|
||||
const dimLabel = (id: string) => dimensions.find((d) => d.id === id)?.label ?? id;
|
||||
const topWeak = Object.entries(dimensionWeakness)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 3)
|
||||
.map(([id, count]) => ({ id, count, label: dimLabel(id) }));
|
||||
const distEntries: Array<[string, number]> = Object.entries(insights.distribution);
|
||||
const distTotal = distEntries.reduce((s, [, v]) => s + v, 0) || 1;
|
||||
|
||||
return (
|
||||
<section className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-slate-100 flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-violet-600" />
|
||||
<h2 className="font-semibold text-slate-900 text-sm">Platform insights</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 divide-y md:divide-y-0 md:divide-x divide-slate-100">
|
||||
<InsightCell
|
||||
icon={TrendingDown}
|
||||
label="Weakest dimension"
|
||||
primary={insights.weakestDimension.label}
|
||||
value={`${insights.weakestDimension.avgScore}/100`}
|
||||
tone="rose"
|
||||
/>
|
||||
<InsightCell
|
||||
icon={TrendingUp}
|
||||
label="Strongest dimension"
|
||||
primary={insights.strongestDimension.label}
|
||||
value={`${insights.strongestDimension.avgScore}/100`}
|
||||
tone="emerald"
|
||||
/>
|
||||
<InsightCell
|
||||
icon={Sparkles}
|
||||
label="Top-performing brand"
|
||||
primary={insights.mostImproved?.brandName ?? "—"}
|
||||
value={insights.mostImproved ? `Health ${insights.mostImproved.health}` : ""}
|
||||
tone="violet"
|
||||
/>
|
||||
<InsightCell
|
||||
icon={AlertTriangle}
|
||||
label="Needs attention"
|
||||
primary={insights.needsAttention?.brandName ?? "—"}
|
||||
value={insights.needsAttention ? `Health ${insights.needsAttention.health}` : ""}
|
||||
tone="amber"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Distribution + top-3 weakest dims */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-0 border-t border-slate-100">
|
||||
<div className="p-5 border-b md:border-b-0 md:border-r border-slate-100">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500 mb-2">Health distribution</p>
|
||||
<div className="space-y-1.5">
|
||||
{distEntries.map(([bucket, count]) => {
|
||||
const pct = Math.round((count / distTotal) * 100);
|
||||
const color =
|
||||
bucket === "0-20" ? "bg-rose-500" :
|
||||
bucket === "20-40" ? "bg-orange-500" :
|
||||
bucket === "40-60" ? "bg-amber-500" :
|
||||
bucket === "60-80" ? "bg-lime-500" :
|
||||
"bg-emerald-500";
|
||||
return (
|
||||
<div key={bucket} className="flex items-center gap-3 text-[11px]">
|
||||
<span className="text-slate-500 w-14 font-mono">{bucket}</span>
|
||||
<div className="flex-1 h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-slate-700 tabular-nums w-10 text-right">{count}</span>
|
||||
<span className="text-slate-400 tabular-nums w-10 text-right">{pct}%</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500 mb-2">
|
||||
Platform-wide focus areas
|
||||
</p>
|
||||
{topWeak.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">No dimension emerges as a clear platform-wide weakness — scores are well-distributed.</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{topWeak.map((w) => (
|
||||
<li key={w.id} className="flex items-center gap-2 text-[11px]">
|
||||
<AlertTriangle className="w-3 h-3 text-amber-500" />
|
||||
<span className="text-slate-700">
|
||||
<span className="font-semibold">{w.count}</span> brands weakest on <span className="font-semibold">{w.label}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function InsightCell({
|
||||
icon: Icon, label, primary, value, tone,
|
||||
}: {
|
||||
icon: typeof TrendingUp;
|
||||
label: string;
|
||||
primary: string;
|
||||
value: string;
|
||||
tone: "rose" | "emerald" | "violet" | "amber";
|
||||
}) {
|
||||
const toneClasses: Record<typeof tone, string> = {
|
||||
rose: "text-rose-600",
|
||||
emerald: "text-emerald-600",
|
||||
violet: "text-violet-600",
|
||||
amber: "text-amber-600",
|
||||
};
|
||||
return (
|
||||
<div className="p-5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500 flex items-center gap-1">
|
||||
<Icon className={`w-3 h-3 ${toneClasses[tone]}`} />{label}
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-slate-900 truncate mt-1.5">{primary}</p>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Per-dimension card ───────────────────────────────────────────
|
||||
//
|
||||
// Compact platform-level card for each of the 8 dimensions. Shows
|
||||
// the platform-average score, how many brands score weak on this
|
||||
// dimension, and a deep-link to the relevant feature page. The
|
||||
// card is intentionally PLATFORM-level (not per-brand) here — the
|
||||
// per-brand deep dive is wired to the dedicated single-brand radar
|
||||
// page (future enhancement; the API endpoint is already serving it
|
||||
// at GET /api/admin/analytics/radar?brandId=X).
|
||||
|
||||
const DIM_CTA: Record<string, { href: string; label: string }> = {
|
||||
technical: { href: "/technical-audit", label: "Open Technical Audit" },
|
||||
content: { href: "/content-audit", label: "Open Content Audit" },
|
||||
search: { href: "/rank-tracker", label: "Open Rank Tracker" },
|
||||
traffic: { href: "/site-tag-analytics", label: "Open Analytics" },
|
||||
conversion: { href: "/site-tag-analytics", label: "Open Analytics" },
|
||||
competitive: { href: "/competitors", label: "Manage Competitors" },
|
||||
ai: { href: "/ai-visibility", label: "Run AI Visibility" },
|
||||
maturity: { href: "/settings/integrations", label: "Manage Integrations" },
|
||||
};
|
||||
|
||||
function DimensionCard({
|
||||
id, label, score, brandsWeakOnThis, totalBrands,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
score: number;
|
||||
brandsWeakOnThis: number;
|
||||
totalBrands: number;
|
||||
}) {
|
||||
const cta = DIM_CTA[id];
|
||||
const colorClass = score >= 70 ? "text-emerald-600"
|
||||
: score >= 40 ? "text-amber-600"
|
||||
: "text-rose-600";
|
||||
const bg = score >= 70 ? "bg-emerald-500"
|
||||
: score >= 40 ? "bg-amber-500"
|
||||
: "bg-rose-500";
|
||||
const weakPct = totalBrands > 0 ? Math.round((brandsWeakOnThis / totalBrands) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4 flex flex-col gap-2">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="text-xs font-bold text-slate-800 truncate">{label}</p>
|
||||
<span className={`text-lg font-bold tabular-nums ${colorClass}`}>{score}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full ${bg}`} style={{ width: `${score}%` }} />
|
||||
</div>
|
||||
{brandsWeakOnThis > 0 ? (
|
||||
<p className="text-[10px] text-slate-500 flex items-center gap-1">
|
||||
<AlertTriangle className="w-2.5 h-2.5 text-amber-500" />
|
||||
<span className="font-semibold">{brandsWeakOnThis}</span> of {totalBrands} brands weakest on this
|
||||
{totalBrands > 0 && weakPct > 0 && <span className="text-slate-400"> ({weakPct}%)</span>}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[10px] text-slate-500 flex items-center gap-1">
|
||||
<Info className="w-2.5 h-2.5 text-slate-400" />
|
||||
No brand is blocked on this dimension.
|
||||
</p>
|
||||
)}
|
||||
{cta && (
|
||||
<Link
|
||||
href={cta.href}
|
||||
className="text-[10px] font-semibold text-brand-600 hover:text-brand-700 inline-flex items-center gap-1 mt-auto"
|
||||
>
|
||||
{cta.label} <ArrowRight className="w-2.5 h-2.5" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Leaderboard ──────────────────────────────────────────────────
|
||||
|
||||
function Leaderboard({
|
||||
title, icon: Icon, accent, rows,
|
||||
}: {
|
||||
title: string; icon: typeof TrendingUp; accent: "emerald" | "red";
|
||||
rows: Array<{ brandId: string; brandName: string; domain: string; health: number }>;
|
||||
}) {
|
||||
if (rows.length === 0) return null;
|
||||
const accentClass = accent === "emerald" ? "text-emerald-600" : "text-red-600";
|
||||
// Top-3 medal palette — gold / silver / bronze for the leaderboard
|
||||
// "podium" rows. Bottom leaderboards skip the medals entirely.
|
||||
const MEDAL_BG = ["bg-amber-100 text-amber-700", "bg-slate-200 text-slate-600", "bg-orange-100 text-orange-700"];
|
||||
return (
|
||||
<div className="bg-white border border-slate-100 rounded-xl shadow-sm overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-slate-100 flex items-center gap-2">
|
||||
<Icon className={`w-4 h-4 ${accentClass}`} />
|
||||
<h2 className="font-semibold text-slate-900 text-sm">{title}</h2>
|
||||
</div>
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{rows.map((r, i) => {
|
||||
const color = r.health >= 80 ? "text-emerald-700 bg-emerald-50"
|
||||
: r.health >= 50 ? "text-amber-700 bg-amber-50" : "text-rose-700 bg-rose-50";
|
||||
const isPodium = accent === "emerald" && i < 3;
|
||||
return (
|
||||
<li key={r.brandId} className="px-4 py-2.5 flex items-center gap-3 hover:bg-slate-50">
|
||||
{isPodium ? (
|
||||
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold tabular-nums shrink-0 ${MEDAL_BG[i]}`}>
|
||||
{i + 1}
|
||||
</span>
|
||||
) : (
|
||||
<span className="w-6 text-right text-[10px] font-bold text-slate-400 tabular-nums">{i + 1}</span>
|
||||
)}
|
||||
<Link
|
||||
href={`/admin/brands?q=${encodeURIComponent(r.domain)}`}
|
||||
className="flex-1 text-xs font-semibold text-slate-800 hover:text-brand-600 truncate"
|
||||
>
|
||||
{r.brandName}
|
||||
</Link>
|
||||
<span className="text-[10px] text-slate-400 truncate max-w-[140px] font-mono">{r.domain}</span>
|
||||
<span className={`text-[11px] font-semibold tabular-nums px-2 py-0.5 rounded-full ${color}`}>
|
||||
{r.health}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Custom SVG radar ────────────────────────────────────────────
|
||||
//
|
||||
// 8-axis radar plot. Drawn from scratch because recharts' types
|
||||
// aren't installed in the current env (would surface as a
|
||||
// build/typecheck failure). SVG is a few dozen lines; the renderer
|
||||
// math is identical to recharts' RadarChart.
|
||||
//
|
||||
// Label handling — the previous revision packed the chart into a
|
||||
// tight square viewBox which clipped every outer-edge label ("Content
|
||||
// Qua…", "Sea…") on 320px instances. We now:
|
||||
// 1. Bump the default render size to 480px.
|
||||
// 2. Expand the SVG viewBox with 80px padding on every side so the
|
||||
// label region has headroom outside the plot circle.
|
||||
// 3. Render a SHORT axis label on the chart (one word — e.g.
|
||||
// "Content" for "Content Quality") so the horizontal space is
|
||||
// always sufficient, regardless of the brand name length. The
|
||||
// full label is already shown in the legend to the left.
|
||||
// 4. Anchor labels dynamically based on their polar angle
|
||||
// (cos > 0.3 → start, cos < -0.3 → end, else middle) with a
|
||||
// small radial offset from the polygon edge so they don't
|
||||
// overlap the plot.
|
||||
|
||||
/** Map full radar-dimension label → short one-word label used on the
|
||||
* chart. Kept as a local override to avoid shipping a full rename
|
||||
* through brand-radar.ts (which every API consumer reads). */
|
||||
const SHORT_LABEL: Record<string, string> = {
|
||||
"Technical Health": "Technical",
|
||||
"Content Quality": "Content",
|
||||
"Search Visibility": "Search",
|
||||
"Traffic & Engagement": "Traffic",
|
||||
"Conversion Performance": "Conversion",
|
||||
"Conversion": "Conversion",
|
||||
"Competitive Position": "Competitive",
|
||||
"Competitive": "Competitive",
|
||||
"AI Visibility": "AI",
|
||||
"Data Maturity": "Data",
|
||||
};
|
||||
function shortLabel(full: string): string {
|
||||
return SHORT_LABEL[full] ?? full.split(/[\s&]+/)[0];
|
||||
}
|
||||
|
||||
function RadarChart({
|
||||
dimensions, scores, color, size,
|
||||
}: {
|
||||
dimensions: Dimension[];
|
||||
scores: Record<string, number>;
|
||||
color: string;
|
||||
size: number;
|
||||
}) {
|
||||
// viewBox padding — labels sit outside the plot circle, so the
|
||||
// viewBox must be larger than `size` to contain them. 80px on each
|
||||
// side accommodates the longest short label + the score tspan.
|
||||
const LABEL_PAD = 80;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const radius = (size / 2) - 8; // smaller inset; labels live in the padded region now
|
||||
const n = dimensions.length;
|
||||
|
||||
const points = useMemo(() => {
|
||||
return dimensions.map((d, i) => {
|
||||
const angle = (Math.PI * 2 * i) / n - Math.PI / 2;
|
||||
const score = scores[d.id] ?? 0;
|
||||
const r = (score / 100) * radius;
|
||||
// Label position — 22px outside the plot circle so the
|
||||
// text baseline doesn't collide with the outermost ring.
|
||||
const LABEL_OFFSET = 22;
|
||||
return {
|
||||
x: cx + Math.cos(angle) * r,
|
||||
y: cy + Math.sin(angle) * r,
|
||||
labelX: cx + Math.cos(angle) * (radius + LABEL_OFFSET),
|
||||
labelY: cy + Math.sin(angle) * (radius + LABEL_OFFSET),
|
||||
anchor: (
|
||||
Math.cos(angle) > 0.3 ? "start"
|
||||
: Math.cos(angle) < -0.3 ? "end"
|
||||
: "middle"
|
||||
) as "start" | "end" | "middle",
|
||||
// Full label stays in the legend; chart shows the short
|
||||
// single-word form so it fits without truncation.
|
||||
label: shortLabel(d.label),
|
||||
fullLabel: d.label,
|
||||
score,
|
||||
};
|
||||
});
|
||||
}, [dimensions, scores, cx, cy, radius, n]);
|
||||
|
||||
const polygon = points.map((p) => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(" ");
|
||||
const ringRadii = [0.25, 0.5, 0.75, 1].map((r) => r * radius);
|
||||
|
||||
// viewBox intentionally bigger than the chart size so the outer
|
||||
// labels render inside the SVG viewport. Width/height preserved
|
||||
// so CSS sizing stays predictable for the caller.
|
||||
const vbMin = -LABEL_PAD;
|
||||
const vbSize = size + LABEL_PAD * 2;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`${vbMin} ${vbMin} ${vbSize} ${vbSize}`}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
{/* Concentric rings */}
|
||||
{ringRadii.map((r, idx) => (
|
||||
<circle
|
||||
key={idx}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="#E2E8F0"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
{/* Axis lines */}
|
||||
{dimensions.map((_, i) => {
|
||||
const angle = (Math.PI * 2 * i) / n - Math.PI / 2;
|
||||
return (
|
||||
<line
|
||||
key={i}
|
||||
x1={cx}
|
||||
y1={cy}
|
||||
x2={cx + Math.cos(angle) * radius}
|
||||
y2={cy + Math.sin(angle) * radius}
|
||||
stroke="#E2E8F0"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* Score polygon */}
|
||||
<polygon
|
||||
points={polygon}
|
||||
fill={color}
|
||||
fillOpacity={0.18}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{/* Score dots */}
|
||||
{points.map((p, i) => (
|
||||
<circle key={i} cx={p.x} cy={p.y} r={3} fill={color} />
|
||||
))}
|
||||
{/* Axis labels — short name on line 1, score on line 2.
|
||||
title tag carries the full label so the hover tooltip
|
||||
still discloses e.g. "Content Quality" from the
|
||||
"Content" axis. */}
|
||||
{points.map((p, i) => (
|
||||
<text
|
||||
key={i}
|
||||
x={p.labelX}
|
||||
y={p.labelY}
|
||||
fontSize={11}
|
||||
fontWeight={700}
|
||||
fill="#475569"
|
||||
textAnchor={p.anchor}
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
<title>{p.fullLabel}</title>
|
||||
{p.label}
|
||||
<tspan x={p.labelX} dy={13} fontSize={11} fontWeight={800} fill={color}>
|
||||
{p.score}
|
||||
</tspan>
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,792 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { CheckCircle, Circle, Loader2, AlertTriangle, ArrowLeft, RefreshCw, ChevronDown, ChevronUp, Info } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useViewMode } from "@/components/audit/dashboard/useViewMode";
|
||||
import type { SeoSection } from "@/lib/audit/types";
|
||||
import { AiSearchPerformancePanel } from "@/components/audit/ai-search-performance-panel";
|
||||
import {
|
||||
AuditHero, ScoreBreakdown, ExecutiveSummaryCard, KeyFindingsSection,
|
||||
PriorityActionsSection, QuickWinsGrid, MethodologySection, ScoreProjection, AuditTOC,
|
||||
CompetitiveLandscapeSection, BacklinkProfileSection, AIConversionGapChart,
|
||||
OrganicSearchSection, ImplementationAppendixSection, LockedFirstPartyTeaser,
|
||||
SignalCenterSection, BilingualContentSection, CompetitorDeepDiveSection,
|
||||
ThirdVsFirstPartyComparison, SchemaOpportunityForecastSection,
|
||||
StrategicRoadmapSection, IndustryBenchmarkSection, PageStrategicPassSection,
|
||||
AiEnginePersonalitySection, AiSourceAttributionComparisonSection, BehavioralActionBridgeSection, EeatAuditSection, AiSnippetEligibilitySection,
|
||||
MultiTouchAiPathSection, ExecutiveBriefSection, ViewModeToggle, RevenueAttributionSection,
|
||||
PredictiveConversionScoringSection,
|
||||
ConversionHeatMapSection,
|
||||
AiSearchVerificationSection,
|
||||
LostOpportunityCalculatorSection,
|
||||
LiveVisualAnnotationsSection,
|
||||
CompetitorVisualComparisonSection,
|
||||
BrandAuthorityScoreSection,
|
||||
SnippetCaptureStrategySection,
|
||||
VoiceSearchEligibilitySection,
|
||||
MobileFirstAuditSection,
|
||||
PageSpeedInsightsDeepDiveSection,
|
||||
} from "@/components/audit/dashboard";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TierCompleted {
|
||||
tier: number;
|
||||
label: string;
|
||||
completedAt: string;
|
||||
}
|
||||
|
||||
interface AuditStatus {
|
||||
status: string;
|
||||
auditMode: string;
|
||||
progressStep: number;
|
||||
progressLabel: string | null;
|
||||
progressDetail: string | null;
|
||||
siteTagDetected: boolean | null;
|
||||
elapsedSeconds: number;
|
||||
}
|
||||
|
||||
interface AuditRecord {
|
||||
id: string;
|
||||
brandId?: string | null;
|
||||
status: "queued" | "running" | "completed" | "failed";
|
||||
requestedBrandName?: string;
|
||||
requestedWebsite?: string;
|
||||
currentTier: number;
|
||||
totalTiers: number;
|
||||
currentTierLabel?: string;
|
||||
tiersCompleted?: TierCompleted[];
|
||||
estimatedSecondsRemaining?: number;
|
||||
seoSection?: SeoSection;
|
||||
userFacingError?: string;
|
||||
errorReason?: string | null;
|
||||
errorDetail?: string | null;
|
||||
degradedMode?: boolean | null;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
startedAt?: string;
|
||||
}
|
||||
|
||||
// ─── Progress UI ──────────────────────────────────────────────────────────────
|
||||
|
||||
const STEP_LABELS = [
|
||||
"Initializing audit",
|
||||
"Checking Site Tag",
|
||||
"Crawling site",
|
||||
"Loading first-party data",
|
||||
"Running analysis",
|
||||
"Finalizing recommendations",
|
||||
"Complete",
|
||||
];
|
||||
|
||||
function StepIcon({ s }: { s: "done" | "active" | "pending" }) {
|
||||
if (s === "done") return <CheckCircle className="w-4 h-4 text-green-500 shrink-0" />;
|
||||
if (s === "active") return <Loader2 className="w-4 h-4 text-blue-500 shrink-0 animate-spin" />;
|
||||
return <Circle className="w-4 h-4 text-slate-300 shrink-0" />;
|
||||
}
|
||||
|
||||
function fmtElapsed(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return s > 0 ? `${m}m ${s}s` : `${m}m`;
|
||||
}
|
||||
|
||||
const MODE_CHIP: Record<string, string> = {
|
||||
auto: "Auto",
|
||||
"first-party": "First-party",
|
||||
"third-party": "Third-party",
|
||||
};
|
||||
|
||||
function ProgressCard({ audit }: { audit: AuditRecord }) {
|
||||
const auditId = audit.id;
|
||||
const [liveStatus, setLiveStatus] = useState<AuditStatus | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let stopped = false;
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/audits/${auditId}/status`);
|
||||
if (!res.ok || stopped) return;
|
||||
const data = (await res.json()) as AuditStatus;
|
||||
setLiveStatus(data);
|
||||
if (data.status === "completed" || data.status === "failed") stopped = true;
|
||||
} catch {
|
||||
// silently retry
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
const interval = setInterval(poll, 2000);
|
||||
return () => { stopped = true; clearInterval(interval); };
|
||||
}, [auditId]);
|
||||
|
||||
const step = liveStatus?.progressStep ?? 0;
|
||||
const label = liveStatus?.progressLabel ?? null;
|
||||
const detail = liveStatus?.progressDetail ?? null;
|
||||
const elapsed = liveStatus?.elapsedSeconds ?? 0;
|
||||
const mode = liveStatus?.auditMode ?? "auto";
|
||||
const tagDetected = liveStatus?.siteTagDetected;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm max-w-lg mx-auto p-8">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-blue-50 rounded-full mb-3">
|
||||
<Loader2 className="w-6 h-6 text-blue-500 animate-spin" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">meSEO Audit in Progress</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
{audit.requestedBrandName ?? audit.requestedWebsite ?? "Your site"} is being analyzed
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-center gap-2 mt-3 flex-wrap">
|
||||
<span className="text-[11px] font-medium text-slate-500 bg-slate-100 px-2 py-0.5 rounded-full">
|
||||
{MODE_CHIP[mode] ?? "Auto"}
|
||||
</span>
|
||||
{tagDetected === true && (
|
||||
<span className="text-[11px] font-medium text-emerald-700 bg-emerald-50 border border-emerald-200 px-2 py-0.5 rounded-full">
|
||||
Site Tag detected
|
||||
</span>
|
||||
)}
|
||||
{tagDetected === false && (
|
||||
<span className="text-[11px] font-medium text-slate-500 bg-slate-50 border border-slate-200 px-2 py-0.5 rounded-full">
|
||||
No Site Tag
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-6">
|
||||
{STEP_LABELS.map((stepLabel, i) => {
|
||||
const stepNum = i + 1;
|
||||
const isDone = step > stepNum;
|
||||
const isActive = step === stepNum;
|
||||
const s = isDone ? "done" : isActive ? "active" : "pending";
|
||||
|
||||
return (
|
||||
<div key={stepNum}>
|
||||
<div
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
isActive ? "bg-blue-50" : isDone ? "bg-green-50/50" : ""
|
||||
}`}
|
||||
>
|
||||
<StepIcon s={s} />
|
||||
<span
|
||||
className={`text-sm flex-1 ${
|
||||
isDone
|
||||
? "text-slate-400 line-through"
|
||||
: isActive
|
||||
? "text-blue-800 font-medium"
|
||||
: "text-slate-400"
|
||||
}`}
|
||||
>
|
||||
{isActive && label ? label : stepLabel}
|
||||
</span>
|
||||
</div>
|
||||
{isActive && detail && (
|
||||
<p className="text-[11px] text-slate-400 font-mono pl-10 pr-3 -mt-0.5 truncate" title={detail}>
|
||||
{detail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="text-center text-xs text-slate-400">
|
||||
Step {Math.max(step, 1)} of {STEP_LABELS.length}
|
||||
{elapsed > 0 && <> · {fmtElapsed(elapsed)} elapsed</>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Inline retry link ────────────────────────────────────────────────────────
|
||||
|
||||
function RetryLink({ auditId }: { auditId: string }) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
async function retry() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/audits/${auditId}/retry`, { method: "POST" });
|
||||
if (!res.ok) { setBusy(false); return; }
|
||||
const data = (await res.json()) as { newAuditId?: string };
|
||||
if (data.newAuditId) router.push(`/admin/audits/${data.newAuditId}`);
|
||||
} catch { setBusy(false); }
|
||||
}
|
||||
return (
|
||||
<button onClick={retry} disabled={busy} className="underline font-medium hover:no-underline disabled:opacity-60">
|
||||
{busy ? "Starting…" : "Retry full audit →"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Error messages ───────────────────────────────────────────────────────────
|
||||
|
||||
const CRAWL_ERROR_MESSAGES: Record<string, { title: string; body: string }> = {
|
||||
crawl_blocked: {
|
||||
title: "Site is blocking automated access",
|
||||
body: "Cloudflare or similar bot protection is preventing the crawl. To audit this site install the meSEO Site Tag — 1st-party audits bypass external crawling entirely.",
|
||||
},
|
||||
crawl_timeout: {
|
||||
title: "Site took too long to respond",
|
||||
body: "The site didn't finish loading within the crawl budget. Try again in a few minutes or check whether the site is currently experiencing performance issues.",
|
||||
},
|
||||
site_unreachable: {
|
||||
title: "Could not reach the site",
|
||||
body: "DNS lookup or connection failed. Verify the URL is correct and the site is online.",
|
||||
},
|
||||
js_required: {
|
||||
title: "Site requires JavaScript to render",
|
||||
body: "External crawling cannot see the full page content. Install the meSEO Site Tag for a complete 1st-party audit that doesn't depend on a headless crawl.",
|
||||
},
|
||||
redirect_loop: {
|
||||
title: "Redirect chain detected",
|
||||
body: "The site has a redirect loop or chain that prevented crawling. Check for SSL ↔ non-SSL or www ↔ non-www redirect conflicts.",
|
||||
},
|
||||
firecrawl_service_error: {
|
||||
title: "Crawler service temporarily unavailable",
|
||||
body: "Our external crawler returned an error. This is usually transient — retry in a few minutes.",
|
||||
},
|
||||
synthesizer_timeout: {
|
||||
title: "Analysis engine timed out",
|
||||
body: "Our analysis engine took too long to respond. This is usually transient — retry the audit.",
|
||||
},
|
||||
synthesizer_error: {
|
||||
title: "Analysis engine error",
|
||||
body: "Our analysis engine encountered an error. Click Retry to try again.",
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Failed view ──────────────────────────────────────────────────────────────
|
||||
|
||||
function FailedView({ audit }: { audit: AuditRecord }) {
|
||||
const router = useRouter();
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
|
||||
const specific = audit.errorReason ? CRAWL_ERROR_MESSAGES[audit.errorReason] : null;
|
||||
|
||||
async function handleRetry() {
|
||||
setRetrying(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/audits/${audit.id}/retry`, { method: "POST" });
|
||||
if (!res.ok) { setRetrying(false); return; }
|
||||
const data = (await res.json()) as { newAuditId?: string };
|
||||
if (data.newAuditId) router.push(`/admin/audits/${data.newAuditId}`);
|
||||
} catch {
|
||||
setRetrying(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto bg-white rounded-2xl border border-red-200 p-8">
|
||||
<div className="flex items-start gap-3 mb-5">
|
||||
<AlertTriangle className="w-6 h-6 text-red-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-900">
|
||||
{specific?.title ?? "Audit Could Not Complete"}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 mt-1.5 leading-relaxed">
|
||||
{specific?.body ?? (audit.userFacingError ?? "We encountered an issue completing this audit.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{audit.errorDetail && (
|
||||
<div className="mb-5">
|
||||
<button
|
||||
onClick={() => setDetailOpen((o) => !o)}
|
||||
className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-slate-600 transition-colors"
|
||||
>
|
||||
{detailOpen ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
|
||||
{detailOpen ? "Hide" : "Show"} raw error
|
||||
</button>
|
||||
{detailOpen && (
|
||||
<pre className="mt-2 text-[11px] text-slate-500 bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{audit.errorDetail}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{audit.errorReason && (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-slate-400 mb-5">
|
||||
<Info className="w-3.5 h-3.5 shrink-0" />
|
||||
Error code: <span className="font-mono">{audit.errorReason}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
disabled={retrying}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-slate-900 text-white text-sm font-medium rounded-lg hover:bg-slate-800 disabled:opacity-60 transition-colors"
|
||||
>
|
||||
{retrying ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||||
Retry Audit
|
||||
</button>
|
||||
<Link
|
||||
href="/admin/audits"
|
||||
className="text-sm text-slate-400 hover:text-slate-700 transition-colors"
|
||||
>
|
||||
Back to audits
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Report view ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ReportView({ audit }: { audit: AuditRecord }) {
|
||||
const seo = audit.seoSection;
|
||||
if (!seo) return null;
|
||||
|
||||
const isFirstPartyAudit = !seo.lockedFirstPartyTeaser;
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const { viewMode, setViewMode } = useViewMode(
|
||||
isFirstPartyAudit ? "1st" : "3rd",
|
||||
!!seo.executiveBrief,
|
||||
);
|
||||
|
||||
const auditDate = audit.completedAt
|
||||
? new Date(audit.completedAt).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })
|
||||
: "—";
|
||||
|
||||
const displayTitle = seo.displayName ?? seo.brandEcho?.brandName ?? audit.requestedBrandName ?? audit.requestedWebsite ?? "Audit";
|
||||
const hasAiPerformance = !!seo.aiSearchPerformance;
|
||||
const hasMethodology = !!(seo.methodology || seo.industryFramework);
|
||||
const hasCompetitive = !!(seo.competitiveLandscape);
|
||||
const hasBacklinks = !!(seo.backlinkProfile);
|
||||
const hasAiConversion = !!(seo.aiConversionAttribution);
|
||||
const hasOrganicSearch = !!(seo.organicSearchSummary);
|
||||
const hasSignalCenter = !!(seo.signalCenter);
|
||||
const hasBilingual = !!(seo.bilingualAudit);
|
||||
const hasAiAttribComparison = !!(seo.aiSourceAttributionComparison?.hasFirstPartyData);
|
||||
const hasBehavioralBridge = !!(seo.behavioralActionBridge);
|
||||
const hasSnippetEligibility = !!(seo.aiSnippetEligibility);
|
||||
const hasMultiTouchPath = !!(seo.multiTouchAiPath);
|
||||
const hasRevenueAttribution = !!(seo.revenueAttribution?.hasRevenueTracking);
|
||||
const hasPredictiveScoring = !!(seo.predictiveConversionScoring);
|
||||
const hasConversionHeatMap = !!(seo.conversionHeatMap);
|
||||
const hasAiSearchVerification = !!(seo.aiSearchVerification);
|
||||
const hasLostOpportunity = !!(seo.lostOpportunityCalculation?.hasCalculation);
|
||||
const hasLiveVisualAnnotations = !!(seo.liveVisualAnnotations?.hasAnnotations);
|
||||
const hasCompetitorVisualComparison = !!(seo.competitorVisualComparison?.hasComparison);
|
||||
const hasBrandAuthorityScore = !!(seo.brandAuthorityScore?.hasScore);
|
||||
const hasSnippetCaptureStrategy = !!(seo.snippetCaptureStrategy?.hasStrategy && (seo.snippetCaptureStrategy?.totalReformulationsGenerated ?? 0) > 0);
|
||||
const hasVoiceSearch = !!(seo.voiceSearchEligibility?.hasData);
|
||||
const hasMobileFirstAudit = !!(seo.mobileFirstAudit?.hasAudit);
|
||||
const hasPageSpeedInsights = !!(seo.pageSpeedInsights?.hasInsights);
|
||||
const hasCompetitorDeepDive = !!(seo.competitorAnalysis);
|
||||
const hasSchemaForecast = !!(seo.schemaOpportunityForecast);
|
||||
const hasStrategicRoadmap = !!(seo.strategicRoadmap?.phases?.length);
|
||||
const hasIndustryBenchmark = !!(seo.industryBenchmarkComparison);
|
||||
const hasPageStrategicPass = !!(seo.pageStrategicPass?.pages?.length);
|
||||
const hasPrioritizedFindings = !!(seo.prioritizedFindings?.length);
|
||||
const hasTeaser = !!(seo.lockedFirstPartyTeaser);
|
||||
|
||||
const tocItems = [
|
||||
{ id: "audit-score", label: "Overview" },
|
||||
...(hasAiPerformance ? [{ id: "audit-ai-performance", label: "AI Performance" }] : []),
|
||||
...(hasAiConversion ? [{ id: "audit-ai-conversion", label: "AI Conversion" }] : []),
|
||||
...(hasOrganicSearch ? [{ id: "audit-organic-search", label: "Organic Search" }] : []),
|
||||
...(hasCompetitive ? [{ id: "audit-competitive", label: "Competitive" }] : []),
|
||||
...(hasCompetitorDeepDive ? [{ id: "audit-competitor-deep-dive", label: "Competitive Intelligence" }] : []),
|
||||
...(hasBacklinks ? [{ id: "audit-backlinks", label: "Backlinks" }] : []),
|
||||
{ id: "audit-ai-engines", label: "AI Engine Profiles" },
|
||||
...(hasSnippetEligibility ? [{ id: "audit-snippet-eligibility", label: "Snippet Eligibility" }] : []),
|
||||
...(hasSnippetCaptureStrategy ? [{ id: "audit-snippet-capture", label: "Snippet Capture Strategy" }] : []),
|
||||
...(hasVoiceSearch ? [{ id: "audit-voice-search-eligibility", label: "Voice Search Eligibility" }] : []),
|
||||
{ id: "audit-eeat", label: "E-E-A-T Audit" },
|
||||
...(hasBrandAuthorityScore ? [{ id: "audit-brand-authority", label: "Brand Authority" }] : []),
|
||||
...(hasMobileFirstAudit ? [{ id: "audit-mobile-first", label: "Mobile-First Audit" }] : []),
|
||||
...(hasPageSpeedInsights ? [{ id: "audit-page-speed", label: "Page Speed Insights" }] : []),
|
||||
...(hasIndustryBenchmark ? [{ id: "audit-industry-benchmark", label: "Industry Benchmark" }] : []),
|
||||
...(hasSignalCenter ? [{ id: "audit-signal-center", label: "Signal Center" }] : []),
|
||||
...(hasAiAttribComparison ? [{ id: "audit-ai-attribution-comparison", label: "Public vs First-Party" }] : []),
|
||||
...(hasMultiTouchPath ? [{ id: "audit-multi-touch-paths", label: "Multi-Touch Paths" }] : []),
|
||||
...(hasRevenueAttribution ? [{ id: "audit-revenue-attribution", label: "Revenue Attribution" }] : []),
|
||||
...(hasPredictiveScoring ? [{ id: "audit-predictive-conversion-scoring", label: "Predictive Conversion Scoring" }] : []),
|
||||
...(hasConversionHeatMap ? [{ id: "audit-conversion-heat-map", label: "Conversion Heat Map" }] : []),
|
||||
...(hasLiveVisualAnnotations ? [{ id: "audit-visual-analysis", label: "Visual Analysis" }] : []),
|
||||
...(hasAiSearchVerification ? [{ id: "audit-ai-search-verification", label: "AI Search Verification" }] : []),
|
||||
...(hasCompetitorVisualComparison ? [{ id: "audit-competitor-visual-comparison", label: "Competitor Visual Comparison" }] : []),
|
||||
...(hasLostOpportunity ? [{ id: "audit-opportunity-estimate", label: "Opportunity Estimate" }] : []),
|
||||
...(hasBilingual ? [{ id: "audit-bilingual", label: "Multi-Language" }] : []),
|
||||
{ id: "audit-score-breakdown", label: "Score Breakdown" },
|
||||
seo.executiveBrief
|
||||
? { id: "audit-executive-brief", label: "Executive Brief" }
|
||||
: { id: "audit-executive-summary", label: "Executive Summary" },
|
||||
{ id: "audit-key-findings", label: "Key Findings" },
|
||||
{ id: "audit-priority-actions", label: "Priority Actions" },
|
||||
{ id: "audit-quick-wins", label: "Quick Wins" },
|
||||
...(hasBehavioralBridge ? [{ id: "audit-behavioral-bridge", label: "Behavioral Action Bridge" }] : []),
|
||||
...(hasPrioritizedFindings ? [{ id: "audit-implementation", label: "Implementation" }] : []),
|
||||
...(hasMethodology ? [{ id: "audit-methodology", label: "Methodology" }] : []),
|
||||
...(hasPageStrategicPass ? [{ id: "audit-page-review", label: "Page-by-Page Review" }] : []),
|
||||
...(hasSchemaForecast ? [{ id: "audit-schema-opportunities", label: "Schema Opportunities" }] : []),
|
||||
...(hasStrategicRoadmap ? [{ id: "audit-roadmap", label: "90-Day Roadmap" }] : []),
|
||||
{ id: "audit-projection", label: "Score Projection" },
|
||||
...(hasTeaser ? [{ id: "audit-upgrade", label: "Upgrade" }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-[1200px] mx-auto">
|
||||
<div className="lg:grid lg:gap-8 lg:items-start" style={{ gridTemplateColumns: "190px 1fr" }}>
|
||||
{/* Sticky TOC */}
|
||||
<aside className="hidden lg:block">
|
||||
<div className="sticky top-6">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-slate-400 mb-2 px-3">
|
||||
Sections
|
||||
</p>
|
||||
<AuditTOC items={tocItems} />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="min-w-0 space-y-6">
|
||||
<AuditHero
|
||||
brandName={displayTitle}
|
||||
website={audit.requestedWebsite}
|
||||
auditDate={auditDate}
|
||||
score={seo.healthScore}
|
||||
pdfUrl={`/api/admin/audits/${audit.id}/pdf`}
|
||||
execPdfUrl={seo.executiveBrief ? `/api/admin/audits/${audit.id}/pdf?mode=executive` : undefined}
|
||||
siteTagContext={seo.siteTagContext}
|
||||
services={seo.brandEcho?.services}
|
||||
locations={seo.brandEcho?.locations}
|
||||
aiTrend={seo.aiSearchPerformance?.trendVsTrailing90d}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
|
||||
<ViewModeToggle
|
||||
viewMode={viewMode}
|
||||
onModeChange={setViewMode}
|
||||
hasExecutiveBrief={!!seo.executiveBrief}
|
||||
fullSectionCount={tocItems.length}
|
||||
/>
|
||||
|
||||
{/* ── EXECUTIVE VIEW ── */}
|
||||
{viewMode === "executive" && (
|
||||
<>
|
||||
<ScoreBreakdown
|
||||
dimensionScores={seo.dimensionScores}
|
||||
scoringBreakdown={seo.scoringBreakdown}
|
||||
/>
|
||||
{seo.executiveBrief
|
||||
? <ExecutiveBriefSection executiveBrief={seo.executiveBrief} />
|
||||
: <ExecutiveSummaryCard narrative={seo.narrative} summaryThemes={seo.summaryThemes} industryFramework={seo.industryFramework} />
|
||||
}
|
||||
{!isFirstPartyAudit && <ThirdVsFirstPartyComparison isFirstParty={false} />}
|
||||
{!isFirstPartyAudit && seo.lockedFirstPartyTeaser && (
|
||||
<LockedFirstPartyTeaser teaser={seo.lockedFirstPartyTeaser} brandId={audit.brandId} />
|
||||
)}
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-xl p-5 text-center">
|
||||
<p className="text-sm text-slate-600 mb-3">Viewing the strategic overview. The full audit includes all {tocItems.length} sections with implementation code, data tables, and complete analysis.</p>
|
||||
<button
|
||||
onClick={() => setViewMode("full")}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-slate-900 text-white text-sm font-medium rounded-lg hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
View the Comprehensive Audit ({tocItems.length} sections) →
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── FULL AUDIT VIEW ── */}
|
||||
{viewMode === "full" && seo.aiSearchPerformance && (
|
||||
<div id="audit-ai-performance" className="audit-hero-enter">
|
||||
<AiSearchPerformancePanel
|
||||
data={seo.aiSearchPerformance}
|
||||
brandId={audit.brandId}
|
||||
siteTagDaysActive={seo.siteTagContext?.daysActive}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === "full" && (
|
||||
<>
|
||||
{seo.aiConversionAttribution && (
|
||||
<div id="audit-ai-conversion">
|
||||
<AIConversionGapChart aiConversionAttribution={seo.aiConversionAttribution} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{seo.organicSearchSummary && (
|
||||
<div id="audit-organic-search">
|
||||
<OrganicSearchSection organicSearchSummary={seo.organicSearchSummary} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{seo.competitiveLandscape && (
|
||||
<div id="audit-competitive">
|
||||
<CompetitiveLandscapeSection
|
||||
competitiveLandscape={seo.competitiveLandscape}
|
||||
publicAiVisibility={seo.publicAiVisibility}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{seo.competitorAnalysis && (
|
||||
<div id="audit-competitor-deep-dive">
|
||||
<CompetitorDeepDiveSection competitorAnalysis={seo.competitorAnalysis} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{seo.backlinkProfile && (
|
||||
<div id="audit-backlinks">
|
||||
<BacklinkProfileSection backlinkProfile={seo.backlinkProfile} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSnippetEligibility && (
|
||||
<div id="audit-snippet-eligibility">
|
||||
<AiSnippetEligibilitySection aiSnippetEligibility={seo.aiSnippetEligibility} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSnippetCaptureStrategy && (
|
||||
<div id="audit-snippet-capture">
|
||||
<SnippetCaptureStrategySection snippetCaptureStrategy={seo.snippetCaptureStrategy} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasVoiceSearch && (
|
||||
<div id="audit-voice-search-eligibility">
|
||||
<VoiceSearchEligibilitySection voiceSearchEligibility={seo.voiceSearchEligibility} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EeatAuditSection eeatAudit={seo.eeatAudit} />
|
||||
|
||||
{hasBrandAuthorityScore && (
|
||||
<div id="audit-brand-authority">
|
||||
<BrandAuthorityScoreSection brandAuthorityScore={seo.brandAuthorityScore} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMobileFirstAudit && (
|
||||
<div id="audit-mobile-first">
|
||||
<MobileFirstAuditSection mobileFirstAudit={seo.mobileFirstAudit} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasPageSpeedInsights && (
|
||||
<div id="audit-page-speed">
|
||||
<PageSpeedInsightsDeepDiveSection pageSpeedInsights={seo.pageSpeedInsights} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AiEnginePersonalitySection aiEnginePersonalityAnalysis={seo.aiEnginePersonalityAnalysis} />
|
||||
|
||||
<IndustryBenchmarkSection industryBenchmarkComparison={seo.industryBenchmarkComparison} />
|
||||
|
||||
{seo.signalCenter && (
|
||||
<div id="audit-signal-center">
|
||||
<SignalCenterSection signalCenter={seo.signalCenter} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasAiAttribComparison && (
|
||||
<div id="audit-ai-attribution-comparison">
|
||||
<AiSourceAttributionComparisonSection aiSourceAttributionComparison={seo.aiSourceAttributionComparison} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMultiTouchPath && (
|
||||
<MultiTouchAiPathSection multiTouchAiPath={seo.multiTouchAiPath} />
|
||||
)}
|
||||
|
||||
{hasRevenueAttribution && (
|
||||
<RevenueAttributionSection revenueAttribution={seo.revenueAttribution} />
|
||||
)}
|
||||
|
||||
{hasPredictiveScoring && (
|
||||
<PredictiveConversionScoringSection predictiveConversionScoring={seo.predictiveConversionScoring} />
|
||||
)}
|
||||
|
||||
{hasConversionHeatMap && (
|
||||
<ConversionHeatMapSection conversionHeatMap={seo.conversionHeatMap} />
|
||||
)}
|
||||
|
||||
{hasLiveVisualAnnotations && (
|
||||
<LiveVisualAnnotationsSection liveVisualAnnotations={seo.liveVisualAnnotations} />
|
||||
)}
|
||||
|
||||
{hasAiSearchVerification && (
|
||||
<div id="audit-ai-search-verification">
|
||||
<AiSearchVerificationSection aiSearchVerification={seo.aiSearchVerification} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasCompetitorVisualComparison && (
|
||||
<CompetitorVisualComparisonSection competitorVisualComparison={seo.competitorVisualComparison} />
|
||||
)}
|
||||
|
||||
{seo.bilingualAudit && (
|
||||
<div id="audit-bilingual">
|
||||
<BilingualContentSection bilingualAudit={seo.bilingualAudit} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScoreBreakdown
|
||||
dimensionScores={seo.dimensionScores}
|
||||
scoringBreakdown={seo.scoringBreakdown}
|
||||
/>
|
||||
|
||||
{seo.executiveBrief
|
||||
? <ExecutiveBriefSection executiveBrief={seo.executiveBrief} />
|
||||
: (
|
||||
<ExecutiveSummaryCard
|
||||
narrative={seo.narrative}
|
||||
summaryThemes={seo.summaryThemes}
|
||||
industryFramework={seo.industryFramework}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<KeyFindingsSection
|
||||
keyFindings={seo.keyFindings}
|
||||
prioritizedFindings={seo.prioritizedFindings}
|
||||
additionalFindings={seo.additionalFindings}
|
||||
/>
|
||||
|
||||
<PriorityActionsSection actions={seo.priorityActions} />
|
||||
|
||||
{hasBehavioralBridge && (
|
||||
<div id="audit-behavioral-bridge">
|
||||
<BehavioralActionBridgeSection behavioralActionBridge={seo.behavioralActionBridge} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasLostOpportunity && (
|
||||
<LostOpportunityCalculatorSection lostOpportunityCalculation={seo.lostOpportunityCalculation} />
|
||||
)}
|
||||
|
||||
<QuickWinsGrid wins={seo.quickWins} />
|
||||
|
||||
{seo.prioritizedFindings && seo.prioritizedFindings.length > 0 && (
|
||||
<div id="audit-implementation">
|
||||
<ImplementationAppendixSection
|
||||
prioritizedFindings={seo.prioritizedFindings}
|
||||
priorityActions={seo.priorityActions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMethodology && (
|
||||
<MethodologySection
|
||||
methodology={seo.methodology}
|
||||
industryFramework={seo.industryFramework}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PageStrategicPassSection pageStrategicPass={seo.pageStrategicPass} />
|
||||
|
||||
<SchemaOpportunityForecastSection schemaOpportunityForecast={seo.schemaOpportunityForecast} />
|
||||
|
||||
<StrategicRoadmapSection strategicRoadmap={seo.strategicRoadmap} />
|
||||
|
||||
<ThirdVsFirstPartyComparison isFirstParty={isFirstPartyAudit} />
|
||||
|
||||
<ScoreProjection
|
||||
score={seo.healthScore}
|
||||
actions={seo.priorityActions}
|
||||
auditType={seo.lockedFirstPartyTeaser ? "3rd" : "1st"}
|
||||
/>
|
||||
|
||||
{seo.lockedFirstPartyTeaser && (
|
||||
<div id="audit-upgrade">
|
||||
<LockedFirstPartyTeaser
|
||||
teaser={seo.lockedFirstPartyTeaser}
|
||||
brandId={audit.brandId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="text-center text-xs text-slate-400 py-6 border-t border-slate-100">
|
||||
meSEO Audit Report — Confidential
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AuditReportPage({ params }: { params: { id: string } }) {
|
||||
const { id } = params;
|
||||
const [audit, setAudit] = useState<AuditRecord | null>(null);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
|
||||
const fetchAudit = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/audits/${id}`);
|
||||
if (res.status === 404) { setNotFound(true); return; }
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { audit: AuditRecord };
|
||||
setAudit(data.audit);
|
||||
} catch {
|
||||
// silently retry
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => { fetchAudit(); }, [fetchAudit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audit || audit.status !== "running") return;
|
||||
const interval = setInterval(fetchAudit, 2500);
|
||||
return () => clearInterval(interval);
|
||||
}, [audit, fetchAudit]);
|
||||
|
||||
if (notFound) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto text-center py-20">
|
||||
<p className="text-slate-500">Audit not found.</p>
|
||||
<Link href="/admin/audits" className="text-sm text-blue-600 underline mt-2 inline-block">Back to audits</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!audit) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-slate-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Link href="/admin/audits" className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-800 transition-colors">
|
||||
<ArrowLeft className="w-3.5 h-3.5" /> Back to audits
|
||||
</Link>
|
||||
|
||||
{(audit.status === "queued" || audit.status === "running") && <ProgressCard audit={audit} />}
|
||||
{audit.status === "completed" && (
|
||||
<>
|
||||
{audit.degradedMode && (
|
||||
<div className="flex items-start gap-3 bg-amber-50 border border-amber-200 rounded-xl px-4 py-3 text-sm text-amber-800 max-w-4xl">
|
||||
<Info className="w-4 h-4 shrink-0 mt-0.5 text-amber-500" />
|
||||
<span>
|
||||
<strong>Partial coverage:</strong> External crawl was unavailable for this audit. Analysis is based on your Site Tag telemetry and external data sources. Technical SEO and content scores reflect limited data.{" "}
|
||||
<RetryLink auditId={audit.id} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ReportView audit={audit} />
|
||||
</>
|
||||
)}
|
||||
{audit.status === "failed" && <FailedView audit={audit} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Play, Clock, Wifi, WifiOff, HelpCircle, AlertCircle,
|
||||
Building2, Globe,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─── Cost summary stats row ───────────────────────────────────────────────────
|
||||
|
||||
interface CostSummary {
|
||||
totalLeads: number;
|
||||
totalCompletedAudits: number;
|
||||
last30dCompletedAudits: number;
|
||||
last30dGateHits: number;
|
||||
estimatedLast30dCost: number;
|
||||
estimatedSavedLast30d: number;
|
||||
}
|
||||
|
||||
function PublicAuditStats() {
|
||||
const [stats, setStats] = useState<CostSummary | null>(null);
|
||||
useEffect(() => {
|
||||
fetch("/api/admin/public-audit/cost-summary")
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((d) => { if (d) setStats(d as CostSummary); })
|
||||
.catch(() => {/* non-fatal */});
|
||||
}, []);
|
||||
|
||||
if (!stats) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl px-5 py-3 flex flex-wrap gap-5 text-sm">
|
||||
<span className="text-slate-500">Public audits:</span>
|
||||
<span className="font-semibold text-slate-800">{stats.last30dCompletedAudits} completed (30d)</span>
|
||||
<span className="text-slate-400">·</span>
|
||||
<span className="text-slate-600">Est. cost: <span className="font-semibold text-slate-800">${stats.estimatedLast30dCost.toFixed(2)}</span></span>
|
||||
<span className="text-slate-400">·</span>
|
||||
<span className="text-slate-600">Gate saved: <span className="font-semibold text-emerald-700">${stats.estimatedSavedLast30d.toFixed(2)}</span> ({stats.last30dGateHits} blocked)</span>
|
||||
<span className="text-slate-400">·</span>
|
||||
<span className="text-slate-500">{stats.totalLeads} total leads</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type AuditMode = "auto" | "first-party" | "third-party";
|
||||
type FormMode = "unselected" | "first-party" | "third-party";
|
||||
|
||||
interface BrandOption {
|
||||
id: string;
|
||||
name: string;
|
||||
domain: string | null;
|
||||
siteTagActive: boolean;
|
||||
}
|
||||
|
||||
const MODE_LABELS: Record<AuditMode, string> = {
|
||||
auto: "Auto",
|
||||
"first-party": "1st-party",
|
||||
"third-party": "3rd-party",
|
||||
};
|
||||
|
||||
const MODE_DESCRIPTIONS: Record<AuditMode, string> = {
|
||||
auto: "Detect Site Tag; include first-party data if installed.",
|
||||
"first-party": "Always include first-party data regardless of tag detection.",
|
||||
"third-party": "Skip first-party data even if Site Tag is installed.",
|
||||
};
|
||||
|
||||
// ─── Shared sub-components ────────────────────────────────────────────────────
|
||||
|
||||
function AuditModeToggle({ value, onChange }: { value: AuditMode; onChange: (v: AuditMode) => void }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2 flex items-center gap-1">
|
||||
Audit Mode
|
||||
<span title="Controls whether first-party behavioral data is included">
|
||||
<HelpCircle className="w-3.5 h-3.5 text-slate-400" />
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex rounded-lg border border-slate-200 overflow-hidden">
|
||||
{(["auto", "first-party", "third-party"] as AuditMode[]).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => onChange(mode)}
|
||||
className={`flex-1 px-3 py-2 text-xs font-medium transition-colors border-r last:border-r-0 border-slate-200 ${
|
||||
value === mode ? "bg-slate-900 text-white" : "bg-white text-slate-600 hover:bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
{MODE_LABELS[mode]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1.5">{MODE_DESCRIPTIONS[value]}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LocalToggle({ value, onChange }: { value: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isLocal"
|
||||
checked={value}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<label htmlFor="isLocal" className="text-sm text-slate-700">
|
||||
Local SEO audit (location-specific signals)
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 1st-party form ───────────────────────────────────────────────────────────
|
||||
|
||||
function FirstPartyForm({
|
||||
brand,
|
||||
onSubmit,
|
||||
submitting,
|
||||
error,
|
||||
}: {
|
||||
brand: BrandOption;
|
||||
onSubmit: (auditMode: AuditMode, isLocal: boolean) => void;
|
||||
submitting: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
const [auditMode, setAuditMode] = useState<AuditMode>("auto");
|
||||
const [isLocal, setIsLocal] = useState(false);
|
||||
|
||||
const tagForced = auditMode === "third-party";
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-indigo-100 bg-indigo-50/40 p-5 space-y-4">
|
||||
{/* Brand summary row */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-indigo-100 flex items-center justify-center shrink-0">
|
||||
<Building2 className="w-4 h-4 text-indigo-600" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-slate-900 leading-snug">{brand.name}</p>
|
||||
{brand.domain && (
|
||||
<p className="text-xs text-slate-500 mt-0.5 flex items-center gap-1">
|
||||
<Globe className="w-3 h-3 shrink-0" />
|
||||
{brand.domain}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Site Tag indicator */}
|
||||
<div className="shrink-0">
|
||||
{tagForced ? (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-amber-700 bg-amber-50 border border-amber-200 px-2 py-0.5 rounded-full">
|
||||
<AlertCircle className="w-3 h-3" /> Forced 3rd-party
|
||||
</span>
|
||||
) : brand.siteTagActive ? (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-700 bg-emerald-50 border border-emerald-200 px-2 py-0.5 rounded-full">
|
||||
<Wifi className="w-3 h-3" /> Tag active
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-slate-500 bg-slate-50 border border-slate-200 px-2 py-0.5 rounded-full">
|
||||
<WifiOff className="w-3 h-3" /> No Tag
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-indigo-100" />
|
||||
|
||||
<AuditModeToggle value={auditMode} onChange={setAuditMode} />
|
||||
<LocalToggle value={isLocal} onChange={setIsLocal} />
|
||||
|
||||
{error && (
|
||||
<div className="px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting}
|
||||
onClick={() => onSubmit(auditMode, isLocal)}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{submitting ? (
|
||||
<><Clock className="w-4 h-4 animate-spin" /> Starting audit…</>
|
||||
) : (
|
||||
<><Play className="w-4 h-4" /> Run Audit</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-xs text-slate-500">
|
||||
Auditing <span className="font-medium">{brand.name}</span> using stored brand profile.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 3rd-party form ───────────────────────────────────────────────────────────
|
||||
|
||||
function ThirdPartyForm({
|
||||
onSubmit,
|
||||
submitting,
|
||||
error,
|
||||
}: {
|
||||
onSubmit: (fields: {
|
||||
website: string;
|
||||
brandName: string;
|
||||
services: string[];
|
||||
competitors: string[];
|
||||
locations: string[];
|
||||
auditMode: AuditMode;
|
||||
isLocal: boolean;
|
||||
}) => void;
|
||||
submitting: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
const [website, setWebsite] = useState("");
|
||||
const [brandName, setBrandName] = useState("");
|
||||
const [services, setServices] = useState("");
|
||||
const [competitors, setCompetitors] = useState("");
|
||||
const [locations, setLocations] = useState("");
|
||||
const [auditMode, setAuditMode] = useState<AuditMode>("auto");
|
||||
const [isLocal, setIsLocal] = useState(false);
|
||||
|
||||
const canSubmit = website.trim().length > 0 && brandName.trim().length > 0;
|
||||
|
||||
function handleSubmit() {
|
||||
if (!canSubmit) return;
|
||||
onSubmit({
|
||||
website: website.trim(),
|
||||
brandName: brandName.trim(),
|
||||
services: services.split(",").map((s) => s.trim()).filter(Boolean),
|
||||
competitors: competitors.split(",").map((s) => s.trim()).filter(Boolean),
|
||||
locations: locations.split(",").map((s) => s.trim()).filter(Boolean),
|
||||
auditMode,
|
||||
isLocal,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Website URL <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={website}
|
||||
onChange={(e) => setWebsite(e.target.value)}
|
||||
placeholder="prospect-site.com"
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Brand / Company Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={brandName}
|
||||
onChange={(e) => setBrandName(e.target.value)}
|
||||
placeholder="Prospect Co"
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Services (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={services}
|
||||
onChange={(e) => setServices(e.target.value)}
|
||||
placeholder="cardiology, echocardiography, vascular care"
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Competitors (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={competitors}
|
||||
onChange={(e) => setCompetitors(e.target.value)}
|
||||
placeholder="texasheart.org, cardiology.com"
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Locations (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={locations}
|
||||
onChange={(e) => setLocations(e.target.value)}
|
||||
placeholder="Houston, TX; Sugar Land, TX"
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AuditModeToggle value={auditMode} onChange={setAuditMode} />
|
||||
<LocalToggle value={isLocal} onChange={setIsLocal} />
|
||||
|
||||
{error && (
|
||||
<div className="px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting || !canSubmit}
|
||||
onClick={handleSubmit}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-slate-900 text-white text-sm font-medium rounded-lg hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{submitting ? (
|
||||
<><Clock className="w-4 h-4 animate-spin" /> Starting audit…</>
|
||||
) : (
|
||||
<><Play className="w-4 h-4" /> Run Audit</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-xs text-slate-500">
|
||||
Auditing a prospect or unowned site. No 1st-party data will be available.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AdminAuditsPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const [brands, setBrands] = useState<BrandOption[]>([]);
|
||||
const [brandsLoading, setBrandsLoading] = useState(true);
|
||||
|
||||
// Picker state: "" = unselected, "none" = 3rd-party, "<id>" = 1st-party
|
||||
const [pickerValue, setPickerValue] = useState("");
|
||||
const formMode: FormMode =
|
||||
pickerValue === "" ? "unselected"
|
||||
: pickerValue === "none" ? "third-party"
|
||||
: "first-party";
|
||||
|
||||
const selectedBrand = brands.find((b) => b.id === pickerValue) ?? null;
|
||||
|
||||
// Submit state (shared between both form modes)
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load brands list
|
||||
useEffect(() => {
|
||||
fetch("/api/admin/brands?limit=200&sort=createdAt")
|
||||
.then((r) => r.json())
|
||||
.then((data: { brands: BrandOption[] }) => {
|
||||
const sorted = [...(data.brands ?? [])].sort((a, b) => a.name.localeCompare(b.name));
|
||||
setBrands(sorted);
|
||||
setBrandsLoading(false);
|
||||
})
|
||||
.catch(() => setBrandsLoading(false));
|
||||
}, []);
|
||||
|
||||
// Clear error on mode switch
|
||||
function handlePickerChange(value: string) {
|
||||
setPickerValue(value);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function triggerAudit(body: object) {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/admin/audits/trigger", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error((data as { error?: string }).error ?? "Failed to trigger audit");
|
||||
}
|
||||
const { auditId } = (await res.json()) as { auditId: string };
|
||||
router.push(`/admin/audits/${auditId}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Something went wrong");
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFirstPartySubmit(auditMode: AuditMode, isLocal: boolean) {
|
||||
if (!selectedBrand) return;
|
||||
triggerAudit({
|
||||
brandId: selectedBrand.id,
|
||||
website: selectedBrand.domain ?? "",
|
||||
brandName: selectedBrand.name,
|
||||
auditMode,
|
||||
isLocal,
|
||||
});
|
||||
}
|
||||
|
||||
function handleThirdPartySubmit(fields: {
|
||||
website: string;
|
||||
brandName: string;
|
||||
services: string[];
|
||||
competitors: string[];
|
||||
locations: string[];
|
||||
auditMode: AuditMode;
|
||||
isLocal: boolean;
|
||||
}) {
|
||||
triggerAudit({
|
||||
website: fields.website,
|
||||
brandName: fields.brandName,
|
||||
services: fields.services,
|
||||
competitors: fields.competitors,
|
||||
locations: fields.locations,
|
||||
auditMode: fields.auditMode,
|
||||
isLocal: fields.isLocal,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">meSEO Audit Engine</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Select a brand for a 1st-party audit, or run a standalone prospect audit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PublicAuditStats />
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* ── Brand picker ── */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Select brand or audit type
|
||||
</label>
|
||||
<select
|
||||
value={pickerValue}
|
||||
onChange={(e) => handlePickerChange(e.target.value)}
|
||||
disabled={brandsLoading}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300 disabled:opacity-50 bg-white"
|
||||
>
|
||||
<option value="" disabled>— Select a brand —</option>
|
||||
{brands.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.name}{b.domain ? ` (${b.domain})` : ""} · {b.siteTagActive ? "Tag active" : "No Tag"}
|
||||
</option>
|
||||
))}
|
||||
{brands.length > 0 && <option disabled>──────────────────────────────</option>}
|
||||
<option value="none">— Run 3rd-party audit (no brand) —</option>
|
||||
</select>
|
||||
|
||||
{formMode === "unselected" && !brandsLoading && (
|
||||
<p className="text-xs text-slate-400 mt-2">
|
||||
Choose an existing brand to include 1st-party data, or select the 3rd-party option for a standalone prospect audit.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Contextual form ── */}
|
||||
{formMode === "first-party" && selectedBrand && (
|
||||
<FirstPartyForm
|
||||
brand={selectedBrand}
|
||||
onSubmit={handleFirstPartySubmit}
|
||||
submitting={submitting}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{formMode === "third-party" && (
|
||||
<ThirdPartyForm
|
||||
onSubmit={handleThirdPartySubmit}
|
||||
submitting={submitting}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-400 text-center">
|
||||
Audits take 3–5 minutes. You'll be redirected to the live report.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Admin → Plans & Pricing
|
||||
* ───────────────────────
|
||||
* Coming-soon placeholder until Stripe ships. Reads the same
|
||||
* PLANS registry the customer pricing page renders so the catalog
|
||||
* stays in sync, but admin actions (subscription mgmt, manual
|
||||
* plan overrides at scale) wait on the billing integration.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { CreditCard, Lock, ExternalLink } from "lucide-react";
|
||||
import { PLANS } from "@/lib/plan-config";
|
||||
|
||||
export default function AdminBillingPage() {
|
||||
const plans = (Object.values(PLANS)).filter((p) => p.id !== "trial");
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
|
||||
<CreditCard className="w-5 h-5 text-slate-700" /> Plans & Pricing
|
||||
</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">
|
||||
Plan catalog + admin billing tooling.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-5 py-4 flex items-start gap-3">
|
||||
<Lock className="w-4 h-4 text-amber-600 mt-0.5 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-bold text-amber-900">Stripe integration coming soon</p>
|
||||
<p className="text-xs text-amber-800/80 mt-0.5 leading-relaxed">
|
||||
Per-brand plan changes, prorated upgrades, invoice history, and the customer portal
|
||||
land alongside Stripe. Today plan overrides happen via the per-brand PATCH at
|
||||
<span className="font-mono"> /admin/brands</span> → detail panel.
|
||||
</p>
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-amber-700 mt-2 hover:underline"
|
||||
>
|
||||
View customer-facing pricing <ExternalLink className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plan catalog reference — same data as /pricing reads */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-slate-100 font-bold text-slate-800 text-sm">
|
||||
Plan catalog
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 border-b border-slate-200">
|
||||
<tr className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">
|
||||
<th className="text-left px-4 py-3">Plan</th>
|
||||
<th className="text-right px-3 py-3">Monthly</th>
|
||||
<th className="text-right px-3 py-3">Annual / mo</th>
|
||||
<th className="text-right px-3 py-3">Brands</th>
|
||||
<th className="text-right px-3 py-3">Seats</th>
|
||||
<th className="text-right px-3 py-3">AI credits</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{plans.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td className="px-4 py-2.5 text-xs font-bold text-slate-800">{p.name}</td>
|
||||
<td className="px-3 py-2.5 text-right text-xs text-slate-700 tabular-nums">
|
||||
{p.monthlyPrice == null ? "Custom" : `$${p.monthlyPrice.toLocaleString()}`}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right text-xs text-slate-700 tabular-nums">
|
||||
{p.annualMonthlyPrice == null ? "Custom" : `$${p.annualMonthlyPrice.toLocaleString()}`}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right text-xs text-slate-700 tabular-nums">
|
||||
{p.limits.maxBrands === "unlimited" ? "∞" : p.limits.maxBrands}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right text-xs text-slate-700 tabular-nums">
|
||||
{p.limits.seats === "unlimited" ? "∞" : p.limits.seats}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right text-xs text-slate-700 tabular-nums">
|
||||
{p.limits.aiCredits === "unlimited" ? "∞" : p.limits.aiCredits.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Admin → Brands — premium operations-center surface.
|
||||
*
|
||||
* Layout: page header + stats bar + filter bar with grid/list
|
||||
* toggle. Grid view shows each brand as a card with a colored
|
||||
* health ring + integration dots + quick stats. List view keeps
|
||||
* the dense table for power users. Click-to-open detail drawer
|
||||
* unchanged from the prior commit.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Search, X, Loader2, ChevronLeft, ChevronRight, Globe, Activity,
|
||||
Users as UsersIcon, Trash2, ArrowRightLeft, ExternalLink, AlertTriangle,
|
||||
LayoutGrid, List as ListIcon, Building2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
PageHeader, StatCard, Card, CardHeader, EmptyState,
|
||||
TableSkeleton, DataTable, type Column,
|
||||
} from "@/components/admin";
|
||||
import { APP_URL } from "@/lib/config";
|
||||
|
||||
interface BrandRow {
|
||||
id: string;
|
||||
name: string;
|
||||
domain: string;
|
||||
logoUrl: string | null;
|
||||
orgName: string;
|
||||
ownerName: string | null;
|
||||
ownerEmail: string | null;
|
||||
memberCount: number;
|
||||
plan: string;
|
||||
billingCycle: string;
|
||||
createdAt: string;
|
||||
ga4Connected: boolean;
|
||||
gscConnected: boolean;
|
||||
gbpConnected: boolean;
|
||||
siteTagInstalled: boolean;
|
||||
totalEvents: number;
|
||||
lastAuditScore: number | null;
|
||||
lastAuditDate: string | null;
|
||||
lastActivityAt: string | null;
|
||||
siteTagLastEventAt: string | null;
|
||||
}
|
||||
|
||||
interface BrandList {
|
||||
brands: BrandRow[];
|
||||
total: number; page: number; limit: number; hasMore: boolean;
|
||||
}
|
||||
|
||||
interface BrandDetail {
|
||||
brand: {
|
||||
id: string; name: string; domain: string; logoUrl: string | null;
|
||||
industry: string | null; location: string | null;
|
||||
plan: string; billingCycle: string; planExpiresAt: string | null;
|
||||
healthcareMode: boolean; dataRetentionDays: number | null;
|
||||
dpaStatus: string; dpaSignedAt: string | null;
|
||||
createdAt: string; orgName: string;
|
||||
};
|
||||
members: Array<{ id: string; role: string; joinedAt: string; user: { id: string; name: string | null; email: string; image: string | null } }>;
|
||||
integrations: Array<{ integrationId: string; connected: boolean; status: string; lastSynced: string | null; syncError: string | null }>;
|
||||
siteTag: { siteId: string; status: string; lastEventAt: string | null; installedAt: string } | null;
|
||||
stats: {
|
||||
events: number; sessions: number; conversions: number; siteEvents: number;
|
||||
gscQueries: number; gscPages: number; pageRecords: number;
|
||||
gscClicks90d: number; gscImpressions90d: number;
|
||||
};
|
||||
latestAudit: { id: string; crawlHealth: number; status: string; createdAt: string } | null;
|
||||
recentActivity: Array<{ id: string; action: string; category: string; userId: string | null; metadata: unknown; timestamp: string }>;
|
||||
featureAdoption: Array<{ action: string; count: number; lastUsed: string | null }>;
|
||||
}
|
||||
|
||||
function formatDate(s: string | null): string {
|
||||
if (!s) return "—";
|
||||
try { return new Date(s).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }); } catch { return s; }
|
||||
}
|
||||
function formatRelative(s: string | null): string {
|
||||
if (!s) return "—";
|
||||
try {
|
||||
const sec = Math.floor((Date.now() - new Date(s).getTime()) / 1000);
|
||||
if (sec < 60) return "just now";
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
|
||||
if (sec < 86_400) return `${Math.floor(sec / 3600)}h ago`;
|
||||
return `${Math.floor(sec / 86_400)}d ago`;
|
||||
} catch { return s; }
|
||||
}
|
||||
function formatNumber(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
const PLAN_BADGE: Record<string, string> = {
|
||||
trial: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
growth: "bg-slate-100 text-slate-700 border-slate-200",
|
||||
professional: "bg-blue-50 text-blue-700 border-blue-200",
|
||||
agency: "bg-violet-50 text-violet-700 border-violet-200",
|
||||
agency_pro: "bg-purple-50 text-purple-700 border-purple-200",
|
||||
enterprise: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||
};
|
||||
|
||||
export default function AdminBrandsPage() {
|
||||
const [q, setQ] = useState("");
|
||||
const [planFilter, setPlanFilter] = useState("");
|
||||
const [view, setView] = useState<"grid" | "list">("list");
|
||||
const [page, setPage] = useState(1);
|
||||
const [data, setData] = useState<BrandList | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<BrandDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const show = (msg: string) => { setToast(msg); setTimeout(() => setToast(null), 2200); };
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = useState<BrandRow | null>(null);
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState("");
|
||||
const [deletingBrand, setDeletingBrand] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (q.trim()) params.set("q", q.trim());
|
||||
if (planFilter) params.set("plan", planFilter);
|
||||
params.set("page", String(page));
|
||||
setLoading(true);
|
||||
fetch(`/api/admin/brands?${params.toString()}`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((d) => setData(d))
|
||||
.finally(() => setLoading(false));
|
||||
}, 250);
|
||||
return () => clearTimeout(handle);
|
||||
}, [q, planFilter, page]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) { setDetail(null); return; }
|
||||
setDetailLoading(true);
|
||||
fetch(`/api/admin/brands/${selectedId}`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((d) => setDetail(d))
|
||||
.finally(() => setDetailLoading(false));
|
||||
}, [selectedId]);
|
||||
|
||||
const totalPages = useMemo(() => data ? Math.max(1, Math.ceil(data.total / data.limit)) : 1, [data]);
|
||||
|
||||
// ── Stats bar derived from current page ──
|
||||
const stats = useMemo(() => {
|
||||
if (!data) return null;
|
||||
const list = data.brands;
|
||||
const auditScores = list.filter((b) => b.lastAuditScore != null).map((b) => b.lastAuditScore!);
|
||||
const avgAudit = auditScores.length > 0
|
||||
? Math.round(auditScores.reduce((s, n) => s + n, 0) / auditScores.length)
|
||||
: null;
|
||||
return {
|
||||
total: data.total,
|
||||
withSiteTag: list.filter((b) => b.siteTagInstalled).length,
|
||||
withGsc: list.filter((b) => b.gscConnected).length,
|
||||
withGa4: list.filter((b) => b.ga4Connected).length,
|
||||
avgAudit,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
function openDeleteModal(b: BrandRow) {
|
||||
setDeleteTarget(b);
|
||||
setDeleteConfirmText("");
|
||||
}
|
||||
async function confirmDeleteBrand() {
|
||||
if (!deleteTarget || deleteConfirmText !== deleteTarget.name) return;
|
||||
setDeletingBrand(true);
|
||||
const res = await fetch(`/api/admin/brands/${deleteTarget.id}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
show("Brand deactivated — all data preserved");
|
||||
setSelectedId(null);
|
||||
setDeleteTarget(null);
|
||||
setDeleteConfirmText("");
|
||||
setPage(1);
|
||||
const params = new URLSearchParams();
|
||||
if (q.trim()) params.set("q", q.trim());
|
||||
if (planFilter) params.set("plan", planFilter);
|
||||
params.set("page", "1");
|
||||
fetch(`/api/admin/brands?${params.toString()}`).then((r) => r.json()).then(setData);
|
||||
} else show("Delete failed");
|
||||
setDeletingBrand(false);
|
||||
}
|
||||
async function transferBrand(b: BrandRow) {
|
||||
const email = window.prompt(`Transfer ${b.name} to which user's organization? Enter their email:`);
|
||||
if (!email) return;
|
||||
const res = await fetch(`/api/admin/brands/${b.id}/transfer`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ownerEmail: email.trim() }),
|
||||
});
|
||||
if (res.ok) { show("Transferred"); if (selectedId === b.id) { const r = await fetch(`/api/admin/brands/${b.id}`); if (r.ok) setDetail(await r.json()); } }
|
||||
else { const d = await res.json().catch(() => ({})); show(d.message || "Transfer failed"); }
|
||||
}
|
||||
|
||||
const columns: Array<Column<BrandRow>> = [
|
||||
{
|
||||
key: "brand",
|
||||
header: "Brand",
|
||||
cell: (b) => (
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
{b.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={b.logoUrl} alt="" className="w-8 h-8 rounded-lg object-contain bg-slate-50 border border-slate-100 shrink-0" />
|
||||
) : (
|
||||
<span className="w-8 h-8 rounded-lg bg-gradient-to-br from-slate-200 to-slate-300 text-slate-600 text-xs font-semibold flex items-center justify-center shrink-0">
|
||||
{b.name[0]?.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold text-slate-900 truncate max-w-[200px]">{b.name}</p>
|
||||
<p className="text-[10px] text-slate-500 font-mono truncate max-w-[200px]">{b.domain}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "owner",
|
||||
header: "Owner",
|
||||
cell: (b) => (
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-slate-700 truncate max-w-[160px]">{b.ownerName || "—"}</p>
|
||||
<p className="text-[10px] text-slate-500 truncate max-w-[160px]">{b.ownerEmail || ""}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "plan",
|
||||
header: "Plan",
|
||||
cell: (b) => (
|
||||
<div>
|
||||
<span className={`text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded border ${PLAN_BADGE[b.plan] ?? PLAN_BADGE.growth}`}>
|
||||
{b.plan.replace("_", " ")}
|
||||
</span>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5">{b.billingCycle}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "integrations",
|
||||
header: "Integrations",
|
||||
align: "center",
|
||||
cell: (b) => (
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
<Dot on={b.ga4Connected} label="GA4" />
|
||||
<Dot on={b.gscConnected} label="GSC" />
|
||||
<Dot on={b.gbpConnected} label="GBP" />
|
||||
<Dot on={b.siteTagInstalled} label="Tag" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "events",
|
||||
header: "Events",
|
||||
align: "right",
|
||||
cell: (b) => <span className="text-xs text-slate-700 tabular-nums">{formatNumber(b.totalEvents)}</span>,
|
||||
},
|
||||
{
|
||||
key: "audit",
|
||||
header: "Audit",
|
||||
align: "right",
|
||||
cell: (b) => {
|
||||
if (b.lastAuditScore == null) return <span className="text-xs text-slate-400">—</span>;
|
||||
const cls = b.lastAuditScore >= 80 ? "text-emerald-600" : b.lastAuditScore >= 50 ? "text-amber-600" : "text-rose-600";
|
||||
return <span className={`text-xs font-semibold tabular-nums ${cls}`}>{b.lastAuditScore}/100</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "active",
|
||||
header: "Last activity",
|
||||
align: "right",
|
||||
cell: (b) => <span className="text-[10px] text-slate-500 tabular-nums">{formatRelative(b.lastActivityAt ?? b.siteTagLastEventAt)}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6 transition-opacity duration-200">
|
||||
<PageHeader
|
||||
title="Brands"
|
||||
subtitle={data ? `${data.total.toLocaleString()} total brands across all organizations` : "Loading…"}
|
||||
icon={Building2}
|
||||
/>
|
||||
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<StatCard label="Total brands" value={stats.total.toLocaleString()} variant="compact" />
|
||||
<StatCard label="With Site Tag" value={stats.withSiteTag.toLocaleString()} variant="compact" valueClass="text-emerald-600" />
|
||||
<StatCard label="With GSC" value={stats.withGsc.toLocaleString()} variant="compact" valueClass="text-blue-600" />
|
||||
<StatCard label="With GA4" value={stats.withGa4.toLocaleString()} variant="compact" valueClass="text-orange-600" />
|
||||
<StatCard label="Avg health" value={stats.avgAudit != null ? `${stats.avgAudit}/100` : "—"} variant="compact"
|
||||
valueClass={stats.avgAudit != null && stats.avgAudit >= 80 ? "text-emerald-600" : stats.avgAudit != null && stats.avgAudit >= 50 ? "text-amber-600" : "text-rose-600"} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter bar with view toggle */}
|
||||
<div className="bg-white border border-slate-100 rounded-xl shadow-sm p-3 flex items-center gap-2 flex-wrap">
|
||||
<div className="relative flex-1 min-w-[220px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-400" />
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => { setQ(e.target.value); setPage(1); }}
|
||||
placeholder="Search name or domain…"
|
||||
className="w-full pl-9 pr-3 py-2 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={planFilter}
|
||||
onChange={(e) => { setPlanFilter(e.target.value); setPage(1); }}
|
||||
className="px-3 py-2 text-xs bg-white border border-slate-200 rounded-lg min-w-[140px]"
|
||||
>
|
||||
<option value="">All plans</option>
|
||||
<option value="trial">Trial</option>
|
||||
<option value="growth">Growth</option>
|
||||
<option value="professional">Professional</option>
|
||||
<option value="agency">Agency</option>
|
||||
<option value="agency_pro">Agency Pro</option>
|
||||
<option value="enterprise">Enterprise</option>
|
||||
</select>
|
||||
<div className="inline-flex bg-slate-100 rounded-lg p-0.5 ml-auto">
|
||||
<button
|
||||
onClick={() => setView("list")}
|
||||
className={`p-1.5 rounded-md transition-colors ${view === "list" ? "bg-white shadow-sm text-slate-900" : "text-slate-500 hover:text-slate-700"}`}
|
||||
title="List view"
|
||||
>
|
||||
<ListIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("grid")}
|
||||
className={`p-1.5 rounded-md transition-colors ${view === "grid" ? "bg-white shadow-sm text-slate-900" : "text-slate-500 hover:text-slate-700"}`}
|
||||
title="Grid view"
|
||||
>
|
||||
<LayoutGrid className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body — list or grid */}
|
||||
{loading && !data ? (
|
||||
view === "grid" ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => <div key={i} className="h-44 rounded-xl bg-slate-100 animate-pulse" />)}
|
||||
</div>
|
||||
) : (
|
||||
<TableSkeleton rows={6} cols={7} />
|
||||
)
|
||||
) : !data || data.brands.length === 0 ? (
|
||||
<Card>
|
||||
<EmptyState
|
||||
icon={Building2}
|
||||
title="No brands match these filters"
|
||||
description="Adjust the search or plan filter."
|
||||
/>
|
||||
</Card>
|
||||
) : view === "list" ? (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={data.brands}
|
||||
rowKey={(b) => b.id}
|
||||
onRowClick={(b) => setSelectedId(b.id)}
|
||||
highlightKey={selectedId}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{data.brands.map((b) => (
|
||||
<BrandCard
|
||||
key={b.id}
|
||||
brand={b}
|
||||
selected={selectedId === b.id}
|
||||
onClick={() => setSelectedId(b.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.total > data.limit && (
|
||||
<div className="flex items-center justify-between text-xs text-slate-500">
|
||||
<span>Page {page} of {totalPages}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1} className="inline-flex items-center gap-1 px-3 py-1.5 border border-slate-200 rounded-lg bg-white disabled:opacity-40">
|
||||
<ChevronLeft className="w-3 h-3" /> Prev
|
||||
</button>
|
||||
<button onClick={() => setPage((p) => p + 1)} disabled={!data.hasMore} className="inline-flex items-center gap-1 px-3 py-1.5 border border-slate-200 rounded-lg bg-white disabled:opacity-40">
|
||||
Next <ChevronRight className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail drawer — preserved from prior commit, lightly polished */}
|
||||
{selectedId && (
|
||||
<div className="fixed inset-0 z-40 flex animate-in fade-in duration-150" onClick={() => setSelectedId(null)}>
|
||||
<div className="flex-1 bg-slate-900/50" />
|
||||
<aside className="w-[520px] max-w-full bg-white border-l border-slate-200 overflow-y-auto" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="px-5 py-4 border-b border-slate-200 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-slate-900">Brand detail</h2>
|
||||
<button onClick={() => setSelectedId(null)} className="p-1.5 rounded-lg hover:bg-slate-100"><X className="w-4 h-4" /></button>
|
||||
</header>
|
||||
{detailLoading || !detail ? (
|
||||
<div className="p-8 text-center text-slate-400 text-sm"><Loader2 className="w-4 h-4 animate-spin mx-auto mb-2" /> Loading…</div>
|
||||
) : (
|
||||
<div className="p-5 space-y-5">
|
||||
<div className="flex items-start gap-3">
|
||||
{detail.brand.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={detail.brand.logoUrl} alt="" className="w-14 h-14 rounded-xl object-contain bg-slate-50 border border-slate-100" />
|
||||
) : (
|
||||
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-slate-200 to-slate-300 flex items-center justify-center text-lg font-semibold text-slate-600">
|
||||
{detail.brand.name[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-slate-900 truncate">{detail.brand.name}</p>
|
||||
<p className="text-xs text-slate-500 font-mono truncate">{detail.brand.domain}</p>
|
||||
<div className="flex items-center gap-1.5 mt-1.5 flex-wrap">
|
||||
<span className={`text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded border ${PLAN_BADGE[detail.brand.plan] ?? PLAN_BADGE.growth}`}>
|
||||
{detail.brand.plan.replace("_", " ")}
|
||||
</span>
|
||||
{detail.brand.healthcareMode && (
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-rose-50 text-rose-700 border border-rose-200">
|
||||
Healthcare mode
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action row */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<a href={`${APP_URL}?brandId=${detail.brand.id}`} target="_blank" rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-semibold border border-slate-200 rounded-lg bg-white hover:bg-slate-50">
|
||||
<ExternalLink className="w-3 h-3" /> View as user
|
||||
</a>
|
||||
<button onClick={() => transferBrand(data!.brands.find((b) => b.id === detail.brand.id)!)}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-semibold border border-slate-200 rounded-lg bg-white hover:bg-slate-50">
|
||||
<ArrowRightLeft className="w-3 h-3" /> Transfer
|
||||
</button>
|
||||
<button onClick={() => openDeleteModal(data!.brands.find((b) => b.id === detail.brand.id)!)}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-semibold border border-rose-200 rounded-lg bg-white text-rose-600 hover:bg-rose-50">
|
||||
<Trash2 className="w-3 h-3" /> Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats grid via shared StatCard */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<StatCard label="Tracked events" value={formatNumber(detail.stats.events)} variant="compact" />
|
||||
<StatCard label="Sessions" value={formatNumber(detail.stats.sessions)} variant="compact" />
|
||||
<StatCard label="Conversions" value={formatNumber(detail.stats.conversions)} variant="compact" valueClass="text-emerald-600" />
|
||||
<StatCard label="GSC clicks 90d" value={formatNumber(detail.stats.gscClicks90d)} variant="compact" valueClass="text-blue-600" />
|
||||
<StatCard label="GSC impr 90d" value={formatNumber(detail.stats.gscImpressions90d)} variant="compact" />
|
||||
<StatCard label="Pages" value={formatNumber(detail.stats.pageRecords)} variant="compact" />
|
||||
</div>
|
||||
|
||||
{/* Members + integrations + features (same as before) */}
|
||||
<Section icon={UsersIcon} title={`Members (${detail.members.length})`}>
|
||||
<ul className="space-y-1.5">
|
||||
{detail.members.map((m) => (
|
||||
<li key={m.id} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-slate-700 flex-1 truncate">{m.user.name || m.user.email}</span>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500">{m.role}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
<Section icon={Globe} title={`Integrations (${detail.integrations.filter(i => i.connected).length} connected)`}>
|
||||
{detail.integrations.length === 0 ? (
|
||||
<p className="text-xs text-slate-400 italic">None yet</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{detail.integrations.map((i) => (
|
||||
<li key={i.integrationId} className="flex items-center gap-2 text-xs">
|
||||
<Dot on={i.connected} label={i.integrationId} />
|
||||
<span className="font-bold uppercase text-[10px] tracking-wider text-slate-500 w-12">{i.integrationId}</span>
|
||||
<span className="flex-1 text-slate-700">{i.connected ? "Connected" : i.status || "Not connected"}</span>
|
||||
{i.lastSynced && <span className="text-[10px] text-slate-400">{formatDate(i.lastSynced)}</span>}
|
||||
{i.syncError && <AlertTriangle className="w-3 h-3 text-rose-500" />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
<Section icon={Activity} title={`Feature adoption (${detail.featureAdoption.length})`}>
|
||||
{detail.featureAdoption.length === 0 ? (
|
||||
<p className="text-xs text-slate-400 italic">No telemetry yet</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{detail.featureAdoption.slice(0, 12).map((f) => (
|
||||
<li key={f.action} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-slate-700 flex-1 truncate">{f.action}</span>
|
||||
<span className="text-slate-500 tabular-nums">{f.count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation modal */}
|
||||
{deleteTarget && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50" onClick={() => { setDeleteTarget(null); setDeleteConfirmText(""); }}>
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-xl w-full max-w-md mx-4 p-6" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-red-50 flex items-center justify-center shrink-0">
|
||||
<Trash2 className="w-5 h-5 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-slate-900">Delete brand</h3>
|
||||
<p className="text-xs text-slate-500 mt-0.5">This action cannot be undone.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg px-4 py-3 mb-4">
|
||||
<p className="text-sm text-red-800">
|
||||
Are you sure you want to delete <span className="font-bold">{deleteTarget.name}</span>? This will permanently delete all data including events, conversions, GSC history, and analytics.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="block text-sm font-semibold text-slate-700 mb-1.5">
|
||||
Type <span className="font-mono bg-slate-100 px-1.5 py-0.5 rounded text-red-600">{deleteTarget.name}</span> to confirm
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={deleteConfirmText}
|
||||
onChange={(e) => setDeleteConfirmText(e.target.value)}
|
||||
placeholder={deleteTarget.name}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500/40 focus:border-red-500 mb-4"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => { setDeleteTarget(null); setDeleteConfirmText(""); }}
|
||||
className="px-4 py-2 text-sm font-semibold text-slate-700 border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmDeleteBrand}
|
||||
disabled={deleteConfirmText !== deleteTarget.name || deletingBrand}
|
||||
className="px-4 py-2 text-sm font-semibold text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{deletingBrand && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
Delete Brand
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toast && (
|
||||
<div className="fixed top-4 right-4 z-50 px-4 py-2 rounded-lg bg-slate-900 text-white text-xs font-semibold shadow-lg max-w-sm">
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Brand card (grid view) ──────────────────────────────────────
|
||||
|
||||
function BrandCard({ brand: b, selected, onClick }: { brand: BrandRow; selected: boolean; onClick: () => void }) {
|
||||
const score = b.lastAuditScore;
|
||||
const ringColor = score == null ? "stroke-slate-200" : score >= 80 ? "stroke-emerald-500" : score >= 50 ? "stroke-amber-500" : "stroke-rose-500";
|
||||
const ringPct = score ?? 0;
|
||||
const C = 2 * Math.PI * 18; // circumference
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`bg-white rounded-xl shadow-sm border cursor-pointer transition-all hover:shadow-md ${
|
||||
selected ? "border-brand-300 ring-2 ring-brand-500/20" : "border-slate-100"
|
||||
}`}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
{b.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={b.logoUrl} alt="" className="w-10 h-10 rounded-lg object-contain bg-slate-50 border border-slate-100" />
|
||||
) : (
|
||||
<span className="w-10 h-10 rounded-lg bg-gradient-to-br from-slate-200 to-slate-300 text-slate-600 text-sm font-semibold flex items-center justify-center">
|
||||
{b.name[0]?.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-slate-900 truncate">{b.name}</p>
|
||||
<p className="text-[10px] text-slate-500 font-mono truncate">{b.domain}</p>
|
||||
</div>
|
||||
{/* Health ring — SVG ring, score in the center. */}
|
||||
<div className="relative w-12 h-12 shrink-0">
|
||||
<svg viewBox="0 0 40 40" className="w-12 h-12 -rotate-90">
|
||||
<circle cx="20" cy="20" r="18" stroke="#F1F5F9" strokeWidth="3" fill="none" />
|
||||
<circle
|
||||
cx="20" cy="20" r="18"
|
||||
strokeWidth="3" fill="none"
|
||||
strokeLinecap="round"
|
||||
className={ringColor}
|
||||
strokeDasharray={`${(ringPct / 100) * C} ${C}`}
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute inset-0 flex items-center justify-center text-[11px] font-semibold tabular-nums text-slate-700">
|
||||
{score ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plan + integration dots */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className={`text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded border ${PLAN_BADGE[b.plan] ?? PLAN_BADGE.growth}`}>
|
||||
{b.plan.replace("_", " ")}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 ml-auto" title={`GA4 ${b.ga4Connected ? "✓" : "✗"} · GSC ${b.gscConnected ? "✓" : "✗"} · GBP ${b.gbpConnected ? "✓" : "✗"} · Tag ${b.siteTagInstalled ? "✓" : "✗"}`}>
|
||||
<Dot on={b.ga4Connected} label="GA4" />
|
||||
<Dot on={b.gscConnected} label="GSC" />
|
||||
<Dot on={b.gbpConnected} label="GBP" />
|
||||
<Dot on={b.siteTagInstalled} label="Tag" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick stats */}
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div>
|
||||
<p className="text-[9px] font-bold uppercase tracking-widest text-slate-400">Events</p>
|
||||
<p className="text-xs font-semibold text-slate-800 tabular-nums">{formatNumber(b.totalEvents)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[9px] font-bold uppercase tracking-widest text-slate-400">Members</p>
|
||||
<p className="text-xs font-semibold text-slate-800 tabular-nums">{b.memberCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[9px] font-bold uppercase tracking-widest text-slate-400">Active</p>
|
||||
<p className="text-[11px] font-semibold text-slate-700">{formatRelative(b.lastActivityAt ?? b.siteTagLastEventAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Dot({ on, label }: { on: boolean; label: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${on ? "bg-emerald-500" : "bg-slate-300"}`}
|
||||
title={`${label}: ${on ? "Connected" : "Not connected"}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ icon: Icon, title, children }: { icon: typeof Globe; title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mb-1.5 flex items-center gap-1.5">
|
||||
<Icon className="w-3 h-3" /> {title}
|
||||
</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
void CardHeader;
|
||||
@@ -0,0 +1,422 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Admin → Compliance
|
||||
* ──────────────────
|
||||
* Platform compliance dashboard:
|
||||
* - Headline metrics (total brands, healthcare-mode coverage, DPA
|
||||
* status counts, platform compliance score, retention summary)
|
||||
* - Per-brand status table with traffic-light score + flags
|
||||
* - Right-to-deletion form (typed-confirmation guarded)
|
||||
* - Right-to-access export form
|
||||
* - ComplianceEvent log feed + CSV export
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ShieldCheck, AlertTriangle, Loader2, Trash2, Download, FileText,
|
||||
RefreshCw, Lock,
|
||||
} from "lucide-react";
|
||||
|
||||
interface Overview {
|
||||
summary: {
|
||||
totalBrands: number;
|
||||
healthcareEnabled: number;
|
||||
likelyHealthcareTotal: number;
|
||||
dpaSignedCount: number;
|
||||
dpaCounts: Array<{ status: string; count: number }>;
|
||||
platformScore: number;
|
||||
platformRetentionDays: number;
|
||||
purgeCountNext30Days: number;
|
||||
nextScheduledPurge: string;
|
||||
};
|
||||
perBrand: Array<{
|
||||
id: string; name: string; domain: string; industry: string | null;
|
||||
likelyHealthcare: boolean; healthcareMode: boolean;
|
||||
retentionDays: number; retentionOverride: boolean;
|
||||
dpaStatus: string; dpaSignedAt: string | null;
|
||||
score: number;
|
||||
flags: { healthcareModeMissing: boolean; dpaMissing: boolean };
|
||||
}>;
|
||||
recentEvents: Array<{
|
||||
id: string; action: string; brandId: string | null; identifier: string | null;
|
||||
details: unknown; requestedBy: string; timestamp: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function scoreColor(s: number): string {
|
||||
if (s >= 80) return "bg-emerald-500 text-white";
|
||||
if (s >= 50) return "bg-amber-500 text-white";
|
||||
return "bg-red-500 text-white";
|
||||
}
|
||||
|
||||
function formatDate(s: string | null): string {
|
||||
if (!s) return "—";
|
||||
try { return new Date(s).toLocaleString(undefined, { month: "short", day: "numeric", year: "numeric", hour: "2-digit", minute: "2-digit" }); } catch { return s; }
|
||||
}
|
||||
|
||||
export default function AdminCompliancePage() {
|
||||
const [data, setData] = useState<Overview | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const show = (msg: string) => { setToast(msg); setTimeout(() => setToast(null), 2500); };
|
||||
|
||||
// Right-to-deletion + export form state
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [confirmDelete, setConfirmDelete] = useState("");
|
||||
const [busyAction, setBusyAction] = useState<"delete" | "export" | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const res = await fetch("/api/admin/compliance/overview");
|
||||
if (res.ok) setData(await res.json());
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
async function runDelete() {
|
||||
if (!identifier.trim() || confirmDelete !== "DELETE") return;
|
||||
setBusyAction("delete");
|
||||
try {
|
||||
const res = await fetch("/api/admin/compliance/delete-user-data", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ identifier: identifier.trim() }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
const totals = d.deleted as { events: number; sessions: number; conversions: number; siteEvents: number };
|
||||
show(`Deleted: ${totals.events} events · ${totals.sessions} sessions · ${totals.conversions} conversions · ${totals.siteEvents} signals`);
|
||||
setIdentifier("");
|
||||
setConfirmDelete("");
|
||||
await refresh();
|
||||
} else {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
show(d.error || "Delete failed");
|
||||
}
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function runExport() {
|
||||
if (!identifier.trim()) return;
|
||||
setBusyAction("export");
|
||||
try {
|
||||
const res = await fetch("/api/admin/compliance/export-user-data", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ identifier: identifier.trim() }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `user-data-${identifier.trim().replace(/[^a-z0-9._-]/gi, "_")}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
show("Export downloaded");
|
||||
await refresh();
|
||||
} else {
|
||||
show("Export failed");
|
||||
}
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Loader2 className="w-5 h-5 text-slate-400 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { summary, perBrand, recentEvents } = data;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
|
||||
<ShieldCheck className="w-5 h-5 text-slate-700" /> Compliance
|
||||
</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">
|
||||
Platform compliance status, retention policy, and right-to-deletion / export tooling.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-lg border border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hero gauge + 3 supporting stat cards */}
|
||||
{/* ── Cookie usage banner ──
|
||||
The Site Tag is cookieless by construction (sessionStorage +
|
||||
localStorage only — neither is a cookie, neither is sent over
|
||||
HTTP, neither is covered by ePrivacy / cookie-consent regs).
|
||||
We surface this on the compliance page because brands fielding
|
||||
GDPR / CCPA / cookie-banner audits need it documented in the
|
||||
compliance posture, not buried in product copy. */}
|
||||
<div className="rounded-xl border border-emerald-200 bg-emerald-50/60 px-4 py-3 flex items-start gap-3">
|
||||
<span className="w-7 h-7 rounded-lg bg-emerald-100 text-emerald-700 flex items-center justify-center shrink-0 text-base">
|
||||
🍪
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-bold text-emerald-900">
|
||||
Cookie usage: <span className="text-emerald-700">None — cookieless tracking</span>
|
||||
</p>
|
||||
<p className="text-[11px] text-emerald-800/80 mt-0.5 leading-relaxed">
|
||||
The meSEO Site Tag sets zero cookies on visitors' browsers. Session and visitor identifiers live in <code className="font-mono text-[10px] bg-white/70 px-1 py-0.5 rounded">sessionStorage</code> / <code className="font-mono text-[10px] bg-white/70 px-1 py-0.5 rounded">localStorage</code> — neither is transmitted with HTTP requests and neither falls under ePrivacy / GDPR cookie-consent rules. Brands using only the Site Tag for analytics do not need a cookie consent banner for that tracking surface.
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 border border-emerald-200 shrink-0">
|
||||
Compliance advantage
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
{/* Gauge — lg:col-span-1; hero metric. */}
|
||||
<div className="bg-white border border-slate-100 rounded-xl shadow-sm p-5 flex items-center gap-5">
|
||||
<ComplianceGauge score={summary.platformScore} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-slate-500">Platform compliance</p>
|
||||
<p className="text-xs text-slate-500 mt-1 leading-relaxed">
|
||||
Average across {summary.totalBrands} brand{summary.totalBrands !== 1 ? "s" : ""}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-100 rounded-xl shadow-sm p-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-slate-500">Healthcare mode</p>
|
||||
<p className="text-3xl font-semibold text-slate-800 tabular-nums leading-tight mt-1">
|
||||
{summary.healthcareEnabled}
|
||||
<span className="text-base text-slate-400 font-normal"> / {summary.likelyHealthcareTotal}</span>
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-500 mt-1">enabled / suspected healthcare</p>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-100 rounded-xl shadow-sm p-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-slate-500">DPA signed</p>
|
||||
<p className="text-3xl font-semibold text-slate-800 tabular-nums leading-tight mt-1">{summary.dpaSignedCount}</p>
|
||||
<div className="flex items-center gap-1 mt-1 flex-wrap">
|
||||
{summary.dpaCounts.map((d) => (
|
||||
<span key={d.status} className="text-[9px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-slate-100 text-slate-600">
|
||||
{d.status}: {d.count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-100 rounded-xl shadow-sm p-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-slate-500">Retention</p>
|
||||
<p className="text-3xl font-semibold text-slate-800 tabular-nums leading-tight mt-1">
|
||||
{summary.platformRetentionDays}
|
||||
<span className="text-base text-slate-400 font-normal"> d</span>
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-500 mt-1">{summary.purgeCountNext30Days.toLocaleString()} signals due in 30d</p>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5">Cron: {summary.nextScheduledPurge}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Per-brand table */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-slate-100 flex items-center gap-2">
|
||||
<ShieldCheck className="w-4 h-4 text-brand-600" />
|
||||
<h2 className="font-bold text-slate-800 text-sm">Brand compliance status</h2>
|
||||
<span className="ml-auto text-xs text-slate-500">{perBrand.length} brands</span>
|
||||
</div>
|
||||
{perBrand.length === 0 ? (
|
||||
<div className="p-12 text-center text-slate-400 text-sm">No brands yet.</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 border-b border-slate-200">
|
||||
<tr className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">
|
||||
<th className="text-left px-4 py-3">Brand</th>
|
||||
<th className="text-center px-3 py-3">Healthcare mode</th>
|
||||
<th className="text-center px-3 py-3">Retention</th>
|
||||
<th className="text-center px-3 py-3">DPA</th>
|
||||
<th className="text-right px-3 py-3">Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{perBrand.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<td className="px-4 py-2.5">
|
||||
<p className="text-xs font-semibold text-slate-800 truncate max-w-[260px]">{b.name}</p>
|
||||
<p className="text-[10px] text-slate-500 font-mono truncate max-w-[260px]">{b.domain}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
{b.likelyHealthcare ? (
|
||||
b.healthcareMode ? (
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700 border border-emerald-200">Enabled</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-red-50 text-red-700 border border-red-200">
|
||||
<AlertTriangle className="w-3 h-3" /> Required
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-400">N/A</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center text-xs text-slate-700">
|
||||
{b.retentionDays}d
|
||||
{b.retentionOverride && <span className="ml-1 text-[10px] text-slate-400">(override)</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
<span className={`text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded border ${
|
||||
b.dpaStatus === "signed" ? "bg-emerald-50 text-emerald-700 border-emerald-200"
|
||||
: b.dpaStatus === "pending" ? "bg-amber-50 text-amber-700 border-amber-200"
|
||||
: "bg-slate-100 text-slate-600 border-slate-200"
|
||||
}`}>
|
||||
{b.dpaStatus}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right">
|
||||
<span className={`text-[10px] font-bold tabular-nums px-2 py-0.5 rounded-full ${scoreColor(b.score)}`}>
|
||||
{b.score}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right-to-deletion / export */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-slate-100 flex items-center gap-2">
|
||||
<Lock className="w-4 h-4 text-brand-600" />
|
||||
<h2 className="font-bold text-slate-800 text-sm">Subject access requests</h2>
|
||||
</div>
|
||||
<div className="px-4 py-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-slate-600 mb-1">Identifier (email or sessionId)</label>
|
||||
<input
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
placeholder="someone@example.com or session_abc123"
|
||||
className="w-full px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={runExport}
|
||||
disabled={!identifier.trim() || busyAction !== null}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold rounded-lg border border-slate-200 bg-white text-slate-700 hover:bg-slate-50 disabled:opacity-50"
|
||||
>
|
||||
{busyAction === "export" ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Download className="w-3.5 h-3.5" />}
|
||||
Export user data (Article 15)
|
||||
</button>
|
||||
<span className="text-[11px] text-slate-400">
|
||||
Returns a JSON dump of every event/session/conversion the platform holds for this identifier.
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-t border-slate-100 pt-3">
|
||||
<p className="text-[11px] font-semibold text-red-700 mb-2 flex items-center gap-1.5">
|
||||
<Trash2 className="w-3 h-3" /> Right to deletion (Article 17)
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-500 mb-2">
|
||||
Type <span className="font-mono font-bold text-slate-700">DELETE</span> to confirm. Wipes every TrackedEvent,
|
||||
TrackedSession, SiteConversion, and SiteEvent for the identifier. Logged immutably.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={confirmDelete}
|
||||
onChange={(e) => setConfirmDelete(e.target.value)}
|
||||
placeholder="Type DELETE"
|
||||
className="px-3 py-2 text-sm border border-red-200 rounded-lg font-mono w-32 focus:outline-none focus:ring-2 focus:ring-red-500/40"
|
||||
/>
|
||||
<button
|
||||
onClick={runDelete}
|
||||
disabled={!identifier.trim() || confirmDelete !== "DELETE" || busyAction !== null}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold rounded-lg bg-red-600 text-white hover:bg-red-700 disabled:opacity-40"
|
||||
>
|
||||
{busyAction === "delete" ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
|
||||
Delete all data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compliance event log */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-slate-100 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-brand-600" />
|
||||
<h2 className="font-bold text-slate-800 text-sm">Compliance event log</h2>
|
||||
<a
|
||||
href="/api/admin/compliance/events?format=csv"
|
||||
className="ml-auto inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-lg border border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
<Download className="w-3 h-3" /> Export CSV
|
||||
</a>
|
||||
</div>
|
||||
{recentEvents.length === 0 ? (
|
||||
<div className="p-12 text-center text-slate-400 text-sm">No compliance events yet.</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{recentEvents.map((e) => (
|
||||
<li key={e.id} className="px-4 py-2.5 flex items-center gap-3">
|
||||
<span className="text-[10px] tabular-nums text-slate-400 w-32 shrink-0">{formatDate(e.timestamp)}</span>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-slate-100 text-slate-700 shrink-0 min-w-[140px] text-center">
|
||||
{e.action}
|
||||
</span>
|
||||
{e.identifier && (
|
||||
<span className="text-xs text-slate-700 font-mono truncate">{e.identifier}</span>
|
||||
)}
|
||||
{e.brandId && (
|
||||
<span className="text-[10px] text-slate-400 font-mono truncate">brand: {e.brandId}</span>
|
||||
)}
|
||||
<span className="ml-auto text-[10px] text-slate-400 font-mono">{e.requestedBy.slice(0, 12)}…</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{toast && (
|
||||
<div className="fixed top-4 right-4 z-50 px-4 py-2 rounded-lg bg-slate-900 text-white text-xs font-semibold shadow-lg max-w-md">
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Hero gauge — circular progress with score in center ─────────
|
||||
|
||||
function ComplianceGauge({ score }: { score: number }) {
|
||||
const size = 96;
|
||||
const stroke = 8;
|
||||
const radius = (size - stroke) / 2;
|
||||
const C = 2 * Math.PI * radius;
|
||||
const offset = C - (score / 100) * C;
|
||||
const strokeColor = score >= 80 ? "#10B981" : score >= 50 ? "#F59E0B" : "#F43F5E";
|
||||
const textColor = score >= 80 ? "text-emerald-600" : score >= 50 ? "text-amber-600" : "text-rose-600";
|
||||
return (
|
||||
<div className="relative shrink-0" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
<circle
|
||||
cx={size / 2} cy={size / 2} r={radius}
|
||||
stroke="#F1F5F9" strokeWidth={stroke} fill="none"
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2} cy={size / 2} r={radius}
|
||||
stroke={strokeColor} strokeWidth={stroke} fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={C}
|
||||
strokeDashoffset={offset}
|
||||
style={{ transition: "stroke-dashoffset 600ms ease-out" }}
|
||||
/>
|
||||
</svg>
|
||||
<span className={`absolute inset-0 flex items-center justify-center text-2xl font-semibold tabular-nums ${textColor}`}>
|
||||
{score}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
LayoutDashboard, Building2, Globe, Brain, AlertTriangle, Activity,
|
||||
ExternalLink, BarChart3, Users, FileSearch, Radio, Flag,
|
||||
CreditCard, Zap, Shield, Target, Footprints, Megaphone,
|
||||
} from "lucide-react";
|
||||
import { APP_URL } from "@/lib/config";
|
||||
|
||||
// Sidebar is grouped per the admin portal spec. Each group has a
|
||||
// small-caps header + its item list. Keeping the groupings here as
|
||||
// data (rather than JSX) so new sections can be added without
|
||||
// reflowing the component body.
|
||||
type NavItem = { href: string; label: string; icon: typeof LayoutDashboard; comingSoon?: boolean };
|
||||
type NavGroup = { heading: string; items: NavItem[] };
|
||||
|
||||
const GROUPS: NavGroup[] = [
|
||||
{
|
||||
heading: "Overview",
|
||||
items: [
|
||||
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Platform",
|
||||
items: [
|
||||
{ href: "/admin/users", label: "Users", icon: Users },
|
||||
{ href: "/admin/organizations", label: "Organizations", icon: Building2 },
|
||||
{ href: "/admin/brands", label: "Brands", icon: Globe },
|
||||
{ href: "/admin/integrations", label: "Integrations", icon: Zap },
|
||||
{ href: "/admin/ai-usage", label: "Cost Center", icon: Brain },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Content",
|
||||
items: [
|
||||
{ href: "/admin/content", label: "All Audits & Reports", icon: FileSearch },
|
||||
{ href: "/admin/signals", label: "Signal Overview", icon: Radio },
|
||||
{ href: "/admin/industry", label: "Industry Data", icon: BarChart3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Intelligence",
|
||||
items: [
|
||||
{ href: "/admin/ai-monitor", label: "AI Monitor", icon: Shield },
|
||||
{ href: "/admin/analytics", label: "Platform Analytics", icon: Target },
|
||||
{ href: "/admin/sessions", label: "Session Explorer", icon: Footprints },
|
||||
{ href: "/admin/paid-media", label: "Paid Media", icon: Megaphone },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Data",
|
||||
items: [
|
||||
{ href: "/admin/historical", label: "Historical Data", icon: BarChart3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "System",
|
||||
items: [
|
||||
{ href: "/admin/health", label: "Health Monitor", icon: Activity },
|
||||
{ href: "/admin/errors", label: "Error Log", icon: AlertTriangle },
|
||||
{ href: "/admin/flags", label: "Feature Flags", icon: Flag, comingSoon: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Billing",
|
||||
items: [
|
||||
{ href: "/admin/billing", label: "Plans & Pricing", icon: CreditCard, comingSoon: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function AdminSidebar() {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<aside className="w-60 bg-slate-900 text-white flex flex-col shrink-0 h-screen sticky top-0">
|
||||
<div className="px-5 py-4 border-b border-slate-800 flex items-center gap-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/images/meseo-logo-white.png" alt="meSEO" className="h-7 w-auto" />
|
||||
<span className="text-[9px] font-bold uppercase tracking-widest text-red-400 bg-red-500/10 border border-red-500/30 px-1.5 py-0.5 rounded">
|
||||
Admin
|
||||
</span>
|
||||
</div>
|
||||
<nav className="flex-1 px-3 py-4 space-y-4 overflow-y-auto">
|
||||
{GROUPS.map((group) => (
|
||||
<div key={group.heading}>
|
||||
<p className="px-3 pb-1.5 text-[9px] font-bold uppercase tracking-widest text-slate-500">
|
||||
{group.heading}
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
{group.items.map((item) => {
|
||||
const active = pathname === item.href || (item.href !== "/admin" && pathname.startsWith(item.href));
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-2.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
active
|
||||
? "bg-slate-800 text-white"
|
||||
: "text-slate-400 hover:text-white hover:bg-slate-800/50"
|
||||
}`}
|
||||
>
|
||||
<item.icon className="w-3.5 h-3.5" />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{item.comingSoon && (
|
||||
<span className="text-[8px] font-bold uppercase tracking-wider text-slate-500 bg-slate-800 px-1 py-0.5 rounded">
|
||||
Soon
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="px-3 py-3 border-t border-slate-800 space-y-1">
|
||||
{/* Exit to App points at the app hostname explicitly rather
|
||||
than the relative /dashboard path — admins running the
|
||||
portal on admin.meseoapp.com need a cross-host link. */}
|
||||
<a
|
||||
href={APP_URL}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-slate-400 hover:text-white hover:bg-slate-800/50"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" /> Exit to App
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
|
||||
interface Props { name: string; status: string }
|
||||
|
||||
export default function HealthIndicator({ name, status }: Props) {
|
||||
const ok = status === "healthy";
|
||||
return (
|
||||
<div className={`flex items-center gap-2 px-4 py-3 rounded-lg border ${ok ? "bg-green-50 border-green-200" : "bg-red-50 border-red-200"}`}>
|
||||
{ok ? <CheckCircle2 className="w-4 h-4 text-green-600" /> : <XCircle className="w-4 h-4 text-red-500" />}
|
||||
<span className="text-sm font-semibold text-slate-800">{name}</span>
|
||||
<span className={`ml-auto text-xs font-bold ${ok ? "text-green-600" : "text-red-500"}`}>{ok ? "Operational" : status}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
interface Props { label: string; value: string | number; icon: LucideIcon; color?: string }
|
||||
|
||||
export default function StatsCard({ label, value, icon: Icon, color = "text-slate-500" }: Props) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 px-5 py-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">{label}</span>
|
||||
<Icon className={`w-4 h-4 ${color}`} />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-slate-900 tabular-nums">{typeof value === "number" ? value.toLocaleString() : value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Admin → All Audits & Reports
|
||||
* ────────────────────────────
|
||||
* Three tabs: Technical Audits / Performance Audits / Content
|
||||
* Briefs. Each tab lists the 100 most-recent rows across the
|
||||
* platform with the parent brand for context.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { FileSearch, Loader2, Gauge, FileText, AlertTriangle, RefreshCw } from "lucide-react";
|
||||
|
||||
interface Brand { id: string; name: string; domain: string }
|
||||
interface TechRow { id: string; crawlHealth: number; status: string; pagesScanned: number; createdAt: string; brand: Brand }
|
||||
interface PerfRow { id: string; status: string; device: string; score: number | null; createdAt: string; brand: Brand }
|
||||
interface BriefRow { id: string; title: string; type: string; primaryKeyword: string | null; status: string; createdAt: string; brand: Brand }
|
||||
|
||||
interface Payload {
|
||||
counts: { technicalAudits: number; performanceAudits: number; contentBriefs: number };
|
||||
technicalAudits: TechRow[];
|
||||
performanceAudits: PerfRow[];
|
||||
contentBriefs: BriefRow[];
|
||||
}
|
||||
|
||||
type Tab = "technical" | "performance" | "briefs";
|
||||
|
||||
function formatDate(s: string): string {
|
||||
try { return new Date(s).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); } catch { return s; }
|
||||
}
|
||||
|
||||
export default function AdminContentPage() {
|
||||
const [data, setData] = useState<Payload | null>(null);
|
||||
const [tab, setTab] = useState<Tab>("technical");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
// 10-second cap + structured error state so an API 500 / timeout
|
||||
// renders a "Failed to load" card with Retry instead of leaving
|
||||
// the page stuck on a spinner (previously: `if (!data) return
|
||||
// <spinner>` with no error path).
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10_000);
|
||||
setError(null);
|
||||
fetch("/api/admin/content", { signal: controller.signal })
|
||||
.then(async (r) => {
|
||||
if (!r.ok) {
|
||||
let msg = `HTTP ${r.status}`;
|
||||
try {
|
||||
const body = await r.json();
|
||||
if (body?.error) msg = `${msg}: ${body.error}`;
|
||||
} catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
return r.json() as Promise<Payload>;
|
||||
})
|
||||
.then((d) => setData(d))
|
||||
.catch((err: Error) => {
|
||||
if (err.name === "AbortError") {
|
||||
setError("Request timed out after 10s. Retry to try again.");
|
||||
} else {
|
||||
setError(err.message || "Failed to load audits.");
|
||||
}
|
||||
})
|
||||
.finally(() => clearTimeout(timeoutId));
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
controller.abort();
|
||||
};
|
||||
}, [retryKey]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto mt-16">
|
||||
<div className="rounded-xl border border-rose-200 bg-rose-50 p-6 text-center">
|
||||
<AlertTriangle className="w-8 h-8 text-rose-500 mx-auto mb-2" />
|
||||
<h2 className="text-sm font-bold text-slate-900">Couldn't load Audits & Reports</h2>
|
||||
<p className="text-xs text-slate-600 mt-1">{error}</p>
|
||||
<button
|
||||
onClick={() => setRetryKey((k) => k + 1)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 mt-4 rounded-lg bg-white border border-slate-200 text-xs font-semibold text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" /> Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Loader2 className="w-5 h-5 text-slate-400 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TABS: Array<{ id: Tab; label: string; count: number; icon: typeof FileSearch }> = [
|
||||
{ id: "technical", label: "Technical Audits", count: data.counts.technicalAudits, icon: FileSearch },
|
||||
{ id: "performance", label: "Performance Audits", count: data.counts.performanceAudits, icon: Gauge },
|
||||
{ id: "briefs", label: "Content Briefs", count: data.counts.contentBriefs, icon: FileText },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
|
||||
<FileSearch className="w-5 h-5 text-slate-700" /> All Audits & Reports
|
||||
</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">
|
||||
Most-recent content surfaces across every brand. Drill into a brand from the link in any row.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 border-b border-slate-200 overflow-x-auto">
|
||||
{TABS.map((t) => {
|
||||
const Icon = t.icon;
|
||||
const active = tab === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-semibold whitespace-nowrap border-b-2 transition-colors ${
|
||||
active
|
||||
? "border-slate-900 text-slate-900"
|
||||
: "border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{t.label}
|
||||
<span className="text-[10px] font-bold text-slate-400 bg-slate-100 rounded-full px-1.5 py-0.5 tabular-nums">
|
||||
{t.count.toLocaleString()}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{tab === "technical" && <TechTable rows={data.technicalAudits} />}
|
||||
{tab === "performance" && <PerfTable rows={data.performanceAudits} />}
|
||||
{tab === "briefs" && <BriefsTable rows={data.contentBriefs} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TechTable({ rows }: { rows: TechRow[] }) {
|
||||
if (rows.length === 0) return <Empty msg="No technical audits yet." />;
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 border-b border-slate-200">
|
||||
<tr className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">
|
||||
<th className="text-left px-4 py-3">Brand</th>
|
||||
<th className="text-left px-3 py-3">Status</th>
|
||||
<th className="text-right px-3 py-3">Score</th>
|
||||
<th className="text-right px-3 py-3">Pages</th>
|
||||
<th className="text-right px-3 py-3">When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-2.5">
|
||||
<Link href={`/admin/brands?q=${encodeURIComponent(r.brand.domain)}`} className="text-xs font-semibold text-slate-800 hover:text-brand-600 truncate block max-w-[260px]">
|
||||
{r.brand.name}
|
||||
</Link>
|
||||
<p className="text-[10px] text-slate-500 font-mono truncate max-w-[260px]">{r.brand.domain}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5"><StatusChip status={r.status} /></td>
|
||||
<td className={`px-3 py-2.5 text-right text-xs font-bold tabular-nums ${
|
||||
r.crawlHealth >= 80 ? "text-emerald-600" : r.crawlHealth >= 50 ? "text-amber-600" : "text-red-600"
|
||||
}`}>{r.crawlHealth}/100</td>
|
||||
<td className="px-3 py-2.5 text-right text-xs text-slate-700 tabular-nums">{r.pagesScanned}</td>
|
||||
<td className="px-3 py-2.5 text-right text-[10px] text-slate-500 tabular-nums">{formatDate(r.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PerfTable({ rows }: { rows: PerfRow[] }) {
|
||||
if (rows.length === 0) return <Empty msg="No performance audits yet." />;
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 border-b border-slate-200">
|
||||
<tr className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">
|
||||
<th className="text-left px-4 py-3">Brand</th>
|
||||
<th className="text-left px-3 py-3">Device</th>
|
||||
<th className="text-left px-3 py-3">Status</th>
|
||||
<th className="text-right px-3 py-3">When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-2.5">
|
||||
<Link href={`/admin/brands?q=${encodeURIComponent(r.brand.domain)}`} className="text-xs font-semibold text-slate-800 hover:text-brand-600 truncate block max-w-[260px]">
|
||||
{r.brand.name}
|
||||
</Link>
|
||||
<p className="text-[10px] text-slate-500 font-mono truncate max-w-[260px]">{r.brand.domain}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500">{r.device}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5"><StatusChip status={r.status} /></td>
|
||||
<td className="px-3 py-2.5 text-right text-[10px] text-slate-500 tabular-nums">{formatDate(r.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BriefsTable({ rows }: { rows: BriefRow[] }) {
|
||||
if (rows.length === 0) return <Empty msg="No content briefs yet." />;
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 border-b border-slate-200">
|
||||
<tr className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">
|
||||
<th className="text-left px-4 py-3">Brand</th>
|
||||
<th className="text-left px-3 py-3">Title</th>
|
||||
<th className="text-left px-3 py-3">Keyword</th>
|
||||
<th className="text-left px-3 py-3">Type</th>
|
||||
<th className="text-left px-3 py-3">Status</th>
|
||||
<th className="text-right px-3 py-3">When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-2.5">
|
||||
<Link href={`/admin/brands?q=${encodeURIComponent(r.brand.domain)}`} className="text-xs font-semibold text-slate-800 hover:text-brand-600 truncate block max-w-[200px]">
|
||||
{r.brand.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-xs text-slate-700 truncate max-w-[260px]">{r.title}</td>
|
||||
<td className="px-3 py-2.5 text-xs text-slate-500 truncate max-w-[180px]">{r.primaryKeyword ?? "—"}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500">{r.type}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5"><StatusChip status={r.status} /></td>
|
||||
<td className="px-3 py-2.5 text-right text-[10px] text-slate-500 tabular-nums">{formatDate(r.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusChip({ status }: { status: string }) {
|
||||
const cls =
|
||||
status === "completed" ? "bg-emerald-50 text-emerald-700 border-emerald-200"
|
||||
: status === "running" ? "bg-blue-50 text-blue-700 border-blue-200"
|
||||
: status === "failed" ? "bg-red-50 text-red-700 border-red-200"
|
||||
: status === "draft" ? "bg-slate-100 text-slate-600 border-slate-200"
|
||||
: "bg-amber-50 text-amber-700 border-amber-200";
|
||||
return <span className={`text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded border ${cls}`}>{status}</span>;
|
||||
}
|
||||
|
||||
function Empty({ msg }: { msg: string }) {
|
||||
return <div className="bg-white border border-slate-200 rounded-xl p-12 text-center text-slate-400 text-sm">{msg}</div>;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { AlertTriangle, ChevronDown, RefreshCw } from "lucide-react";
|
||||
|
||||
interface LogEntry { id: string; level: string; source: string; message: string; endpoint: string | null; details: unknown; timestamp: string }
|
||||
|
||||
export default function AdminErrors() {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [source, setSource] = useState("");
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const res = await fetch(`/api/admin/errors?limit=100${source ? `&source=${source}` : ""}`);
|
||||
if (res.ok) { const d = await res.json(); setLogs(d.errors ?? []); }
|
||||
setLoading(false);
|
||||
}, [source]);
|
||||
useEffect(() => { load(); const id = setInterval(load, 30000); return () => clearInterval(id); }, [load]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-6xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-slate-900">Error Log</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<select value={source} onChange={(e) => setSource(e.target.value)} className="text-xs border border-slate-200 rounded-lg px-3 py-2 bg-white">
|
||||
<option value="">All sources</option>
|
||||
{["api_route", "cron", "sync", "ai_call", "site_tag"].map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<button onClick={load} className="flex items-center gap-1 px-3 py-2 rounded-lg border border-slate-200 text-xs font-semibold text-slate-600 hover:bg-slate-50"><RefreshCw className="w-3 h-3" /> Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
{loading ? <div className="px-5 py-12 text-center text-slate-400">Loading...</div> :
|
||||
logs.length === 0 ? <div className="px-5 py-12 text-center text-slate-400">No errors found.</div> :
|
||||
<div className="divide-y divide-slate-100">
|
||||
{logs.map((l) => (
|
||||
<div key={l.id}>
|
||||
<button onClick={() => setExpanded(expanded === l.id ? null : l.id)} className="w-full flex items-center gap-3 px-5 py-3 text-left hover:bg-slate-50">
|
||||
<span className={`w-2 h-2 rounded-full shrink-0 ${l.level === "error" ? "bg-red-500" : "bg-amber-400"}`} />
|
||||
<span className="text-[10px] text-slate-400 w-24 shrink-0 tabular-nums">{new Date(l.timestamp).toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}</span>
|
||||
<span className="text-[10px] font-bold text-slate-500 w-16 shrink-0">{l.source}</span>
|
||||
{l.endpoint && <span className="text-[10px] font-mono text-slate-400 w-32 shrink-0 truncate">{l.endpoint}</span>}
|
||||
<span className="text-xs text-slate-700 flex-1 truncate">{l.message}</span>
|
||||
<ChevronDown className={`w-3.5 h-3.5 text-slate-400 shrink-0 transition-transform ${expanded === l.id ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{expanded === l.id && l.details != null && (
|
||||
<div className="px-5 pb-3 pl-14"><pre className="text-[11px] text-slate-600 bg-slate-50 rounded-lg p-3 overflow-x-auto whitespace-pre-wrap">{String(JSON.stringify(l.details, null, 2))}</pre></div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Admin → Data Export — premium operations-center surface.
|
||||
*
|
||||
* Each export rendered as a card with an icon chip, file-type
|
||||
* indicator, description, and a download CTA that flips to a
|
||||
* loading state while the file is being generated. After a
|
||||
* successful download the card stamps "Last exported X ago" +
|
||||
* record count + KB.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Download, Loader2, Database, Users as UsersIcon, Globe, TrendingUp,
|
||||
FileJson, FileSpreadsheet, ArrowRight,
|
||||
} from "lucide-react";
|
||||
import { PageHeader, Card, CardHeader } from "@/components/admin";
|
||||
|
||||
interface ExportCard {
|
||||
id: "platform-metrics" | "user-data" | "brand-data" | "growth-data";
|
||||
title: string;
|
||||
description: string;
|
||||
icon: typeof Database;
|
||||
endpoint: string;
|
||||
fileExt: "csv" | "json";
|
||||
/** Approximate row count we expect for the data dictionary
|
||||
* hint — purely informational. */
|
||||
hint: string;
|
||||
}
|
||||
|
||||
const CARDS: ExportCard[] = [
|
||||
{
|
||||
id: "platform-metrics",
|
||||
title: "Platform Metrics",
|
||||
description: "Headline totals + DAU/WAU/MAU + 30d deltas. Sourced from the latest monthly snapshot, falling back to live aggregation.",
|
||||
icon: Database,
|
||||
endpoint: "/api/admin/export/platform-metrics?format=csv",
|
||||
fileExt: "csv",
|
||||
hint: "~25 metric rows + dictionary",
|
||||
},
|
||||
{
|
||||
id: "user-data",
|
||||
title: "User Data (Anonymized)",
|
||||
description: "Per-user signup date, brand count, last login, feature-adoption score. UserId hashed; no email, name, or avatar.",
|
||||
icon: UsersIcon,
|
||||
endpoint: "/api/admin/export/user-data",
|
||||
fileExt: "csv",
|
||||
hint: "1 row per user account",
|
||||
},
|
||||
{
|
||||
id: "brand-data",
|
||||
title: "Brand Data",
|
||||
description: "Per-brand plan, member count, event/session/conversion totals, latest audit score, feature adoption, last activity.",
|
||||
icon: Globe,
|
||||
endpoint: "/api/admin/export/brand-data",
|
||||
fileExt: "csv",
|
||||
hint: "1 row per brand",
|
||||
},
|
||||
{
|
||||
id: "growth-data",
|
||||
title: "Growth Time Series",
|
||||
description: "Trailing 24 months of new users, new brands, events, sessions, conversions, AI calls, site tags deployed.",
|
||||
icon: TrendingUp,
|
||||
endpoint: "/api/admin/export/growth-data",
|
||||
fileExt: "csv",
|
||||
hint: "24 rows · monthly buckets",
|
||||
},
|
||||
];
|
||||
|
||||
interface ExportRecord {
|
||||
generatedAt: string;
|
||||
recordCount?: number;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
function formatBytes(b: number): string {
|
||||
if (b < 1024) return `${b} B`;
|
||||
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
|
||||
return `${(b / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
try {
|
||||
const sec = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (sec < 60) return "just now";
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
|
||||
if (sec < 86_400) return `${Math.floor(sec / 3600)}h ago`;
|
||||
return `${Math.floor(sec / 86_400)}d ago`;
|
||||
} catch { return ""; }
|
||||
}
|
||||
|
||||
export default function AdminExportPage() {
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [records, setRecords] = useState<Record<string, ExportRecord>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function run(card: ExportCard) {
|
||||
setBusy(card.id);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(card.endpoint);
|
||||
if (!res.ok) throw new Error(`Server returned ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
let recordCount: number | undefined;
|
||||
if (card.fileExt === "csv") {
|
||||
const text = await blob.text();
|
||||
const lines = text.split("\n").filter((l) => l.trim().length > 0);
|
||||
recordCount = Math.max(0, lines.length - 1);
|
||||
const refreshed = new Blob([text], { type: "text/csv" });
|
||||
triggerDownload(refreshed, `${card.id}-${new Date().toISOString().slice(0, 10)}.${card.fileExt}`);
|
||||
} else {
|
||||
triggerDownload(blob, `${card.id}-${new Date().toISOString().slice(0, 10)}.${card.fileExt}`);
|
||||
}
|
||||
setRecords((prev) => ({
|
||||
...prev,
|
||||
[card.id]: { generatedAt: new Date().toISOString(), recordCount, bytes: blob.size },
|
||||
}));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Export failed");
|
||||
} finally { setBusy(null); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 transition-opacity duration-200">
|
||||
<PageHeader
|
||||
title="Data Export"
|
||||
subtitle="One-click data-room exports — every download streams straight to your browser."
|
||||
icon={Download}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{CARDS.map((card) => {
|
||||
const record = records[card.id];
|
||||
const Icon = card.icon;
|
||||
const FileIcon = card.fileExt === "json" ? FileJson : FileSpreadsheet;
|
||||
const isBusy = busy === card.id;
|
||||
|
||||
return (
|
||||
<Card key={card.id} className="overflow-hidden">
|
||||
<div className="p-5 flex flex-col h-full">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<span className="w-10 h-10 rounded-xl bg-gradient-to-br from-slate-100 to-slate-200 text-slate-600 flex items-center justify-center shrink-0">
|
||||
<Icon className="w-5 h-5" />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold text-slate-900 truncate">{card.title}</h2>
|
||||
<span className="inline-flex items-center gap-1 text-[9px] font-bold uppercase tracking-widest text-slate-500 bg-slate-100 rounded px-1.5 py-0.5">
|
||||
<FileIcon className="w-2.5 h-2.5" />
|
||||
{card.fileExt}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">{card.hint}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-500 leading-relaxed flex-1 mb-4">{card.description}</p>
|
||||
|
||||
{/* Loading indicator — replaces the description line during generation */}
|
||||
{isBusy && (
|
||||
<div className="mb-3 px-3 py-2 rounded-lg bg-blue-50 border border-blue-100 flex items-center gap-2">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-blue-600" />
|
||||
<span className="text-[11px] text-blue-700 font-semibold">Generating…</span>
|
||||
{/* Indeterminate progress bar */}
|
||||
<span className="ml-auto block w-16 h-1 bg-blue-200 rounded-full overflow-hidden">
|
||||
<span className="block w-1/3 h-full bg-blue-500 rounded-full animate-pulse" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => run(card)}
|
||||
disabled={isBusy}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-brand-600 text-white text-xs font-semibold hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{isBusy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Download className="w-3.5 h-3.5" />}
|
||||
Download
|
||||
<ArrowRight className="w-3 h-3 opacity-50" />
|
||||
</button>
|
||||
{record && (
|
||||
<div className="text-[11px] text-slate-500">
|
||||
Last run <span className="font-semibold text-slate-700">{formatRelative(record.generatedAt)}</span>
|
||||
{record.recordCount != null && <span className="ml-1.5">· <span className="font-semibold text-slate-700">{record.recordCount.toLocaleString()}</span> rows</span>}
|
||||
<span className="ml-1.5">· {formatBytes(record.bytes)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-xs text-rose-700">{error}</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Data dictionary" />
|
||||
<div className="p-4 text-xs text-slate-600 leading-relaxed">
|
||||
Every export ships with a stable column shape. The Platform Metrics export
|
||||
includes a <code className="font-mono text-slate-700">dictionary</code> object documenting
|
||||
each field. The other three exports use self-describing CSV headers (brandId, plan, eventCount, etc.).
|
||||
Reach out before changing column names — downstream investor decks reference these directly.
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function triggerDownload(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Admin → Feature Flags
|
||||
* ─────────────────────
|
||||
* Coming-soon placeholder. A FeatureFlag Prisma model + a per-key
|
||||
* editor + per-user / per-brand override rules are the next iteration.
|
||||
* Until then, env-var-based flags are managed in vercel.json or via
|
||||
* the Vercel dashboard.
|
||||
*/
|
||||
|
||||
import { Flag, Settings, Lock } from "lucide-react";
|
||||
|
||||
const KNOWN_ENV_FLAGS: Array<{ key: string; description: string }> = [
|
||||
{
|
||||
key: "ANTHROPIC_API_KEY",
|
||||
description: "Enables every Claude-backed AI feature (AI Strategist, content briefs, recommendations). Without it features fall back to mock responses.",
|
||||
},
|
||||
{
|
||||
key: "OPENAI_API_KEY",
|
||||
description: "Secondary LLM provider used as a fallback when Anthropic rate-limits or returns errors.",
|
||||
},
|
||||
{
|
||||
key: "DATAFORSEO_LOGIN / DATAFORSEO_PASSWORD",
|
||||
description: "Live SERP + keyword data for Rank Tracker / Competitor Intel / Keyword Research. Without it those features fall back to AI-estimated data.",
|
||||
},
|
||||
{
|
||||
key: "PLATFORM_TELEMETRY_DISABLED",
|
||||
description: "Set to \"1\" to silence trackEvent writes globally — useful for noisy local dev.",
|
||||
},
|
||||
{
|
||||
key: "CRON_SECRET",
|
||||
description: "Required for every /api/cron/* route. Vercel cron sends this in the Authorization header.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function AdminFlagsPage() {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
|
||||
<Flag className="w-5 h-5 text-slate-700" /> Feature Flags
|
||||
</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">
|
||||
Per-key feature toggles for the platform.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Coming-soon banner */}
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-5 py-4 flex items-start gap-3">
|
||||
<Lock className="w-4 h-4 text-amber-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-bold text-amber-900">In-app feature flags coming soon</p>
|
||||
<p className="text-xs text-amber-800/80 mt-0.5 leading-relaxed">
|
||||
A FeatureFlag Prisma model + key-value editor + per-brand / per-user override rules
|
||||
land in a follow-up iteration. Until then, environment-variable flags are managed in
|
||||
Vercel (Project → Settings → Environment Variables).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Env-var reference */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-slate-100 flex items-center gap-2">
|
||||
<Settings className="w-4 h-4 text-slate-600" />
|
||||
<h2 className="font-bold text-slate-800 text-sm">Active env-var flags</h2>
|
||||
<span className="ml-auto text-[10px] text-slate-400">Read-only · edit in Vercel dashboard</span>
|
||||
</div>
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{KNOWN_ENV_FLAGS.map((flag) => (
|
||||
<li key={flag.key} className="px-4 py-3">
|
||||
<p className="text-xs font-mono font-bold text-slate-800">{flag.key}</p>
|
||||
<p className="text-xs text-slate-500 leading-relaxed mt-1">{flag.description}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Admin → System Health (Health Monitor)
|
||||
* ──────────────────────────────────────
|
||||
* Top section: live integration status (database, google api,
|
||||
* anthropic, smtp, twilio) — moved from the main /admin dashboard
|
||||
* so the dashboard surface can lead with platform metrics.
|
||||
* Below: heavy system rollups — table row counts, last-cron
|
||||
* timestamps, recent errors, plus an external link out to the
|
||||
* Neon dashboard for storage / connection-pool drilldowns.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
RefreshCw, Loader2, Database, Clock, AlertTriangle, ExternalLink, Activity,
|
||||
} from "lucide-react";
|
||||
import HealthIndicator from "../components/health-indicator";
|
||||
|
||||
interface HealthData { status: string; checks: Array<{ name: string; status: string }> }
|
||||
interface SystemHealth {
|
||||
tableCounts: Record<string, number>;
|
||||
totalRows: number;
|
||||
lastCronRuns: Array<{ name: string; lastRunAt: string | null; lastStatus: number | null }>;
|
||||
recentErrors: Array<{ id: string; source: string; message: string; timestamp: string; endpoint: string | null }>;
|
||||
}
|
||||
|
||||
const NEON_CONSOLE_URL = "https://console.neon.tech";
|
||||
const VERCEL_LOGS_URL = "https://vercel.com/dashboard/logs";
|
||||
|
||||
function formatRelative(iso: string | null): string {
|
||||
if (!iso) return "Never";
|
||||
try {
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
const sec = Math.floor(ms / 1000);
|
||||
if (sec < 60) return `${sec}s ago`;
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
|
||||
if (sec < 86_400) return `${Math.floor(sec / 3600)}h ago`;
|
||||
return `${Math.floor(sec / 86_400)}d ago`;
|
||||
} catch { return "—"; }
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
export default function AdminHealthPage() {
|
||||
const [data, setData] = useState<HealthData | null>(null);
|
||||
const [system, setSystem] = useState<SystemHealth | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
function load() {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
fetch("/api/admin/health").then((r) => (r.ok ? r.json() : null)).catch(() => null),
|
||||
fetch("/api/admin/system-health").then((r) => (r.ok ? r.json() : null)).catch(() => null),
|
||||
]).then(([h, s]) => {
|
||||
setData(h);
|
||||
setSystem(s);
|
||||
}).finally(() => setLoading(false));
|
||||
}
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
|
||||
<Activity className="w-5 h-5 text-slate-700" /> System Health
|
||||
</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">
|
||||
Live integration status, table row counts, cron history, and recent errors.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-slate-200 text-xs font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />} Check now
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Integration status — moved here from /admin */}
|
||||
{data && (
|
||||
<>
|
||||
<div className={`px-5 py-4 rounded-xl border text-center ${
|
||||
data.status === "healthy" ? "bg-green-50 border-green-200" : "bg-amber-50 border-amber-200"
|
||||
}`}>
|
||||
<p className={`text-lg font-bold ${data.status === "healthy" ? "text-green-700" : "text-amber-700"}`}>
|
||||
{data.status === "healthy" ? "All Systems Operational" : "Degraded Performance"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2">
|
||||
{data.checks.map((c) => (
|
||||
<HealthIndicator
|
||||
key={c.name}
|
||||
name={c.name.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||
status={c.status}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Table row counts */}
|
||||
{system && (
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-slate-100 flex items-center gap-2">
|
||||
<Database className="w-4 h-4 text-brand-600" />
|
||||
<h2 className="font-bold text-slate-800 text-sm">Database row counts</h2>
|
||||
<span className="ml-auto text-xs text-slate-500">
|
||||
{formatNumber(system.totalRows)} rows total
|
||||
</span>
|
||||
<a
|
||||
href={NEON_CONSOLE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs font-semibold text-brand-600 hover:underline"
|
||||
>
|
||||
Neon dashboard <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-px bg-slate-100">
|
||||
{Object.entries(system.tableCounts).map(([table, count]) => (
|
||||
<div key={table} className="bg-white px-4 py-3">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">{table}</p>
|
||||
<p className="text-lg font-bold text-slate-800 tabular-nums mt-0.5">{formatNumber(count)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cron jobs */}
|
||||
{system && (
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-slate-100 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-brand-600" />
|
||||
<h2 className="font-bold text-slate-800 text-sm">Cron jobs</h2>
|
||||
<span className="ml-auto text-xs text-slate-500">
|
||||
{system.lastCronRuns.filter((c) => c.lastRunAt).length} of {system.lastCronRuns.length} have run
|
||||
</span>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 border-b border-slate-200">
|
||||
<tr className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">
|
||||
<th className="text-left px-4 py-3">Cron</th>
|
||||
<th className="text-right px-3 py-3">Last run</th>
|
||||
<th className="text-right px-3 py-3">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{system.lastCronRuns.map((c) => (
|
||||
<tr key={c.name}>
|
||||
<td className="px-4 py-2.5 text-xs font-mono text-slate-700">/api/cron/{c.name}</td>
|
||||
<td className="px-3 py-2.5 text-right text-xs text-slate-600 tabular-nums">
|
||||
{formatRelative(c.lastRunAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right">
|
||||
{c.lastStatus == null ? (
|
||||
<span className="text-[10px] text-slate-400">—</span>
|
||||
) : c.lastStatus < 400 ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-emerald-700">
|
||||
{c.lastStatus} OK
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-red-700">
|
||||
{c.lastStatus}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="px-4 py-2 border-t border-slate-100 bg-slate-50/60 text-[11px] text-slate-500">
|
||||
Last-run timestamps come from PlatformLog rows tagged source="cron". A cron without a row hasn't logged yet — could be that it's genuinely never run, or that the handler doesn't emit a structured log.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent errors + Vercel link */}
|
||||
{system && (
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-slate-100 flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
<h2 className="font-bold text-slate-800 text-sm">Recent errors</h2>
|
||||
<a
|
||||
href={VERCEL_LOGS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-auto inline-flex items-center gap-1 text-xs font-semibold text-brand-600 hover:underline"
|
||||
>
|
||||
Vercel logs <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
{system.recentErrors.length === 0 ? (
|
||||
<div className="p-8 text-center text-xs text-slate-400">
|
||||
No errors logged. Either healthy or PlatformLog isn't wired into your error path yet.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{system.recentErrors.map((e) => (
|
||||
<li key={e.id} className="px-4 py-2.5 flex items-start gap-3">
|
||||
<span className="text-[10px] text-slate-400 w-24 shrink-0 tabular-nums">{formatRelative(e.timestamp)}</span>
|
||||
<span className="text-[10px] font-bold text-slate-500 w-20 shrink-0 truncate">{e.source}</span>
|
||||
<span className="text-xs text-slate-700 flex-1 truncate">{e.message}</span>
|
||||
{e.endpoint && (
|
||||
<span className="text-[10px] text-slate-400 font-mono truncate max-w-[200px]">{e.endpoint}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,813 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Database, TrendingUp, Users, MousePointerClick, Eye, Globe,
|
||||
BarChart3, DollarSign, Phone, FileText, CalendarCheck,
|
||||
ExternalLink, Zap, Shield, Clock, Archive,
|
||||
} from "lucide-react";
|
||||
import { BrandStatusBadge } from "@/components/admin/brand-status-badge";
|
||||
|
||||
interface Totals {
|
||||
gsc: { totalClicks: number; totalImpressions: number; avgPosition: number; avgCtr: number };
|
||||
ga4: { totalSessions: number; totalUsers: number; totalKeyEvents: number; totalRevenue: number; note?: string };
|
||||
siteTag: { totalSessions: number; totalPageViews: number; totalConversions: number; totalFormSubmits: number; totalPhoneCalls: number; totalBookings: number; totalEvents: number; totalOutboundClicks: number };
|
||||
platform: { totalAiCalls: number; totalAiCost: number };
|
||||
paid: { totalSpend: number; totalAdClicks: number; totalAdImpressions: number; totalAdConversions: number };
|
||||
}
|
||||
|
||||
interface BrandRow {
|
||||
brandId: string; name: string; domain: string; status?: string;
|
||||
statusChangedAt?: string | null; statusChangedBy?: string | null;
|
||||
snippetDetectedAt?: string | null;
|
||||
snippetInstallationMethod?: "direct" | "gtm" | "none" | null;
|
||||
snippetCheckedAt?: string | null;
|
||||
gscClicks: number; gscImpressions: number; ga4Sessions: number; ga4KeyEvents: number;
|
||||
siteTagSessions: number; siteTagConversions: number; siteTagEvents: number;
|
||||
firstDataDate: string | null; lastDataDate: string | null; snapshots: number;
|
||||
}
|
||||
|
||||
interface Retention {
|
||||
gscOldestData: string | null; gscRetentionMonths: number;
|
||||
siteTagOldestData: string | null; snapshotsOldestData: string | null; note: string;
|
||||
}
|
||||
|
||||
interface Data {
|
||||
totals: Totals; brands: BrandRow[];
|
||||
monthly: Array<{ month: string; gscClicks: number; gscImpressions: number; ga4Sessions: number; siteTagSessions: number; siteTagConversions: number; siteTagEvents: number }>;
|
||||
retention: Retention;
|
||||
valuation: { totalBrands: number; totalSnapshots: number; dataSpanDays: number };
|
||||
}
|
||||
|
||||
function formatRelative(date: Date | string | null): string {
|
||||
if (!date) return "";
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
const diffMs = Date.now() - d.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60_000);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
if (diffMin < 1) return "just now";
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
if (diffDay < 7) return `${diffDay}d ago`;
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
function fmt(n: number | null | undefined): string {
|
||||
if (n == null || typeof n !== "number" || !Number.isFinite(n)) return "0";
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
function fmtCurrency(n: number | null | undefined, decimals = 2): string {
|
||||
if (n == null || typeof n !== "number" || !Number.isFinite(n)) return "0.00";
|
||||
return n.toFixed(decimals);
|
||||
}
|
||||
|
||||
function downloadCsv(filename: string, rows: Record<string, string | number | null | undefined>[]) {
|
||||
if (rows.length === 0) return;
|
||||
const headers = Object.keys(rows[0]);
|
||||
const escape = (v: string | number | null | undefined): string => {
|
||||
if (v === null || v === undefined) return "";
|
||||
const s = String(v);
|
||||
if (s.includes(",") || s.includes('"') || s.includes("\n")) {
|
||||
return `"${s.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return s;
|
||||
};
|
||||
const csv = [
|
||||
headers.join(","),
|
||||
...rows.map(row => headers.map(h => escape(row[h])).join(",")),
|
||||
].join("\n");
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "meseo:admin:historical:filters";
|
||||
|
||||
type PersistedFilters = {
|
||||
statusFilter?: string;
|
||||
brandSearch?: string;
|
||||
sortField?: string;
|
||||
sortDir?: "asc" | "desc";
|
||||
};
|
||||
|
||||
function loadFilters(): PersistedFilters {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed !== "object" || parsed === null) return {};
|
||||
return parsed as PersistedFilters;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveFilters(filters: PersistedFilters) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(filters));
|
||||
} catch {
|
||||
// localStorage full or unavailable — silently no-op
|
||||
}
|
||||
}
|
||||
|
||||
export default function HistoricalPage() {
|
||||
const [data, setData] = useState<Data | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedBrand, setSelectedBrand] = useState<string>("all");
|
||||
const [lastUpdated, setLastUpdated] = useState<number>(0);
|
||||
const [, setTick] = useState(0);
|
||||
// Separate all-brands fetch — always brandId=all so the Per-Brand
|
||||
// Breakdown table shows every brand regardless of dropdown filter.
|
||||
const [allBrandsData, setAllBrandsData] = useState<BrandRow[]>([]);
|
||||
const [refetchKey, setRefetchKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = () => {
|
||||
const isInitial = !data;
|
||||
if (isInitial) setLoading(true);
|
||||
const params = selectedBrand === "all" ? "brandId=all" : `brandId=${encodeURIComponent(selectedBrand)}`;
|
||||
fetch(`/api/admin/historical?${params}`)
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((d: Data | null) => { if (d) { setData(d); setLastUpdated(Date.now()); } })
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedBrand]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Fetch all-brands breakdown once on mount, refresh every 30s.
|
||||
// This is independent of selectedBrand so the table always shows all brands.
|
||||
useEffect(() => {
|
||||
const fetchAll = () => {
|
||||
fetch("/api/admin/historical?brandId=all")
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((d: Data | null) => { if (d?.brands) setAllBrandsData(d.brands); })
|
||||
.catch(() => {});
|
||||
};
|
||||
fetchAll();
|
||||
const interval = setInterval(fetchAll, 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [refetchKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Tick every 5s to keep the "Xs ago" display fresh
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setTick((n) => n + 1), 5000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
const [exportBrand, setExportBrand] = useState<string>("all");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "non-active">(() => {
|
||||
const v = loadFilters().statusFilter;
|
||||
return v === "active" || v === "non-active" ? v : "all";
|
||||
});
|
||||
const [brandSearch, setBrandSearch] = useState<string>(() => loadFilters().brandSearch ?? "");
|
||||
const [reactivatingId, setReactivatingId] = useState<string | null>(null);
|
||||
const [deactivatingId, setDeactivatingId] = useState<string | null>(null);
|
||||
const [suspendingId, setSuspendingId] = useState<string | null>(null);
|
||||
const [checkingSnippetIds, setCheckingSnippetIds] = useState<Set<string>>(new Set());
|
||||
const [selectedBrandIds, setSelectedBrandIds] = useState<Set<string>>(new Set());
|
||||
const [bulkProcessing, setBulkProcessing] = useState(false);
|
||||
|
||||
async function handleReactivate(brandId: string, brandName: string) {
|
||||
if (!confirm(`Reactivate "${brandName}"? This sets the status back to active.`)) return;
|
||||
setReactivatingId(brandId);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/brands/${brandId}/reactivate`, { method: "POST" });
|
||||
const json = await res.json();
|
||||
if (!res.ok) { alert(`Reactivation failed: ${json.error ?? "Unknown error"}`); return; }
|
||||
setRefetchKey((k) => k + 1);
|
||||
} catch (err) {
|
||||
alert(`Reactivation failed: ${err instanceof Error ? err.message : "Unknown error"}`);
|
||||
} finally {
|
||||
setReactivatingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeactivate(brandId: string, brandName: string) {
|
||||
if (!confirm(`Deactivate "${brandName}"?\n\nThis sets the brand status to inactive. The brand's data is preserved and the brand can be reactivated at any time.`)) return;
|
||||
setDeactivatingId(brandId);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/brands/${brandId}/deactivate`, { method: "POST" });
|
||||
const json = await res.json();
|
||||
if (!res.ok) { alert(`Deactivation failed: ${json.error ?? "Unknown error"}`); return; }
|
||||
setRefetchKey((k) => k + 1);
|
||||
} catch (err) {
|
||||
alert(`Deactivation failed: ${err instanceof Error ? err.message : "Unknown error"}`);
|
||||
} finally {
|
||||
setDeactivatingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSuspend(brandId: string, brandName: string) {
|
||||
if (!confirm(`Suspend "${brandName}"?\n\nUse for brands violating terms or pending investigation. Suspended brands are hidden from customer-facing UI. All historical data is preserved. You can reactivate later.`)) return;
|
||||
setSuspendingId(brandId);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/brands/${brandId}/suspend`, { method: "POST" });
|
||||
const json = await res.json();
|
||||
if (!res.ok) { alert(`Suspension failed: ${json.error ?? "Unknown error"}`); return; }
|
||||
setRefetchKey((k) => k + 1);
|
||||
} catch (err) {
|
||||
alert(`Suspension failed: ${err instanceof Error ? err.message : "Unknown error"}`);
|
||||
} finally {
|
||||
setSuspendingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCheckSnippet(brandId: string) {
|
||||
setCheckingSnippetIds((prev) => new Set(prev).add(brandId));
|
||||
try {
|
||||
const res = await fetch(`/api/admin/brands/${brandId}/check-snippet`, { method: "POST" });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
alert(`Snippet check failed: ${(data as { error?: string }).error ?? res.statusText}`);
|
||||
return;
|
||||
}
|
||||
setRefetchKey((k) => k + 1);
|
||||
} finally {
|
||||
setCheckingSnippetIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(brandId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBulkReactivate() {
|
||||
const ids = Array.from(selectedBrandIds);
|
||||
const nonActiveIds = ids.filter((id) => {
|
||||
const b = filteredBrands.find((x) => x.brandId === id);
|
||||
return b && (b.status ?? "active").toLowerCase() !== "active";
|
||||
});
|
||||
if (nonActiveIds.length === 0) { alert("All selected brands are already active."); return; }
|
||||
if (!confirm(`Reactivate ${nonActiveIds.length} brand(s)?`)) return;
|
||||
setBulkProcessing(true);
|
||||
const results = await Promise.allSettled(
|
||||
nonActiveIds.map((id) =>
|
||||
fetch(`/api/admin/brands/${id}/reactivate`, { method: "POST" }).then((r) => r.json()),
|
||||
),
|
||||
);
|
||||
setBulkProcessing(false);
|
||||
const failed = results.filter((r) => r.status === "rejected").length;
|
||||
if (failed > 0) alert(`Reactivated ${nonActiveIds.length - failed} of ${nonActiveIds.length}. ${failed} failed.`);
|
||||
setSelectedBrandIds(new Set());
|
||||
setRefetchKey((k) => k + 1);
|
||||
}
|
||||
|
||||
async function handleBulkDeactivate() {
|
||||
const ids = Array.from(selectedBrandIds);
|
||||
const activeIds = ids.filter((id) => {
|
||||
const b = filteredBrands.find((x) => x.brandId === id);
|
||||
return b && (b.status ?? "active").toLowerCase() === "active";
|
||||
});
|
||||
if (activeIds.length === 0) { alert("No selected brands are currently active."); return; }
|
||||
if (!confirm(`Deactivate ${activeIds.length} brand(s)?\n\nBrands will be hidden from customer-facing UI but historical data is preserved.`)) return;
|
||||
setBulkProcessing(true);
|
||||
const results = await Promise.allSettled(
|
||||
activeIds.map((id) =>
|
||||
fetch(`/api/admin/brands/${id}/deactivate`, { method: "POST" }).then((r) => r.json()),
|
||||
),
|
||||
);
|
||||
setBulkProcessing(false);
|
||||
const failed = results.filter((r) => r.status === "rejected").length;
|
||||
if (failed > 0) alert(`Deactivated ${activeIds.length - failed} of ${activeIds.length}. ${failed} failed.`);
|
||||
setSelectedBrandIds(new Set());
|
||||
setRefetchKey((k) => k + 1);
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (filteredBrands.length > 0 && selectedBrandIds.size === filteredBrands.length) {
|
||||
setSelectedBrandIds(new Set());
|
||||
} else {
|
||||
setSelectedBrandIds(new Set(filteredBrands.map((b) => b.brandId)));
|
||||
}
|
||||
}
|
||||
|
||||
function handleExportCsv() {
|
||||
const source = selectedBrandIds.size > 0
|
||||
? filteredBrands.filter(b => selectedBrandIds.has(b.brandId))
|
||||
: filteredBrands;
|
||||
if (source.length === 0) { alert("No brands to export"); return; }
|
||||
const rows = source.map(b => ({
|
||||
name: b.name,
|
||||
domain: b.domain,
|
||||
status: b.status ?? "active",
|
||||
statusChangedAt: b.statusChangedAt ?? "",
|
||||
statusChangedBy: b.statusChangedBy ?? "",
|
||||
gscClicks: b.gscClicks,
|
||||
gscImpressions: b.gscImpressions,
|
||||
ga4Sessions: b.ga4Sessions,
|
||||
ga4KeyEvents: b.ga4KeyEvents,
|
||||
siteTagSessions: b.siteTagSessions,
|
||||
siteTagConversions: b.siteTagConversions,
|
||||
siteTagEvents: b.siteTagEvents,
|
||||
firstDataDate: b.firstDataDate ?? "",
|
||||
lastDataDate: b.lastDataDate ?? "",
|
||||
snapshots: b.snapshots,
|
||||
}));
|
||||
const timestamp = new Date().toISOString().slice(0, 10);
|
||||
const scope = selectedBrandIds.size > 0 ? "selected" : statusFilter;
|
||||
downloadCsv(`brands-${scope}-${timestamp}.csv`, rows);
|
||||
}
|
||||
|
||||
const filteredBrands = useMemo(() => {
|
||||
if (!allBrandsData) return [];
|
||||
const query = brandSearch.trim().toLowerCase();
|
||||
return allBrandsData.filter((b) => {
|
||||
const s = (b.status ?? "active").toLowerCase();
|
||||
if (statusFilter === "active" && s !== "active") return false;
|
||||
if (statusFilter === "non-active" && s === "active") return false;
|
||||
if (query && !b.name.toLowerCase().includes(query)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [allBrandsData, statusFilter, brandSearch]);
|
||||
|
||||
const [sortField, setSortField] = useState<string>(() => loadFilters().sortField ?? "gscClicks");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">(() => loadFilters().sortDir === "asc" ? "asc" : "desc");
|
||||
|
||||
const sortedBrands = useMemo(() => {
|
||||
const arr = [...filteredBrands];
|
||||
arr.sort((a, b) => {
|
||||
const aVal = (a as any)[sortField];
|
||||
const bVal = (b as any)[sortField];
|
||||
if (aVal == null && bVal == null) return 0;
|
||||
if (aVal == null) return 1;
|
||||
if (bVal == null) return -1;
|
||||
if (typeof aVal === "number" && typeof bVal === "number") {
|
||||
return sortDir === "asc" ? aVal - bVal : bVal - aVal;
|
||||
}
|
||||
const aStr = String(aVal).toLowerCase();
|
||||
const bStr = String(bVal).toLowerCase();
|
||||
return sortDir === "asc" ? aStr.localeCompare(bStr) : bStr.localeCompare(aStr);
|
||||
});
|
||||
return arr;
|
||||
}, [filteredBrands, sortField, sortDir]);
|
||||
|
||||
function handleSortClick(field: string) {
|
||||
if (sortField === field) {
|
||||
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDir("desc");
|
||||
}
|
||||
}
|
||||
|
||||
// If the selected brand is filtered out, fall back to "All Brands"
|
||||
useEffect(() => {
|
||||
if (selectedBrand !== "all" && !filteredBrands.find((b) => b.brandId === selectedBrand)) {
|
||||
setSelectedBrand("all");
|
||||
}
|
||||
}, [filteredBrands, selectedBrand]);
|
||||
|
||||
// Clear selection when filters change to avoid stale selections from hidden brands
|
||||
useEffect(() => {
|
||||
setSelectedBrandIds(new Set());
|
||||
}, [statusFilter, brandSearch]);
|
||||
|
||||
// Persist filter state across page refreshes
|
||||
useEffect(() => {
|
||||
saveFilters({ statusFilter, brandSearch, sortField, sortDir });
|
||||
}, [statusFilter, brandSearch, sortField, sortDir]);
|
||||
|
||||
const agoStr = lastUpdated ? `${Math.round((Date.now() - lastUpdated) / 1000)}s ago` : "";
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center py-24"><div className="w-6 h-6 border-2 border-brand-600 border-t-transparent rounded-full animate-spin" /></div>;
|
||||
if (!data) return <div className="max-w-5xl mx-auto px-6 py-12 text-center"><Database className="w-10 h-10 text-slate-200 mx-auto mb-3" /><p className="text-slate-500">Failed to load historical data.</p></div>;
|
||||
|
||||
// Defensive defaults — the API may omit sub-objects if a data source
|
||||
// (e.g. GA4, paid media) has never been connected for any brand. Any
|
||||
// numeric-formatting helper below would crash on an undefined field.
|
||||
const raw = data.totals ?? ({} as Partial<Totals>);
|
||||
const gsc = raw.gsc ?? {} as Partial<Totals["gsc"]>;
|
||||
const ga4 = raw.ga4 ?? {} as Partial<Totals["ga4"]>;
|
||||
const siteTag = raw.siteTag ?? {} as Partial<Totals["siteTag"]>;
|
||||
const platform = raw.platform ?? {} as Partial<Totals["platform"]>;
|
||||
const paid = raw.paid ?? {} as Partial<Totals["paid"]>;
|
||||
const t = {
|
||||
gsc: {
|
||||
totalClicks: gsc.totalClicks ?? 0,
|
||||
totalImpressions: gsc.totalImpressions ?? 0,
|
||||
avgPosition: gsc.avgPosition ?? 0,
|
||||
avgCtr: gsc.avgCtr ?? 0,
|
||||
},
|
||||
ga4: {
|
||||
totalSessions: ga4.totalSessions ?? 0,
|
||||
totalUsers: ga4.totalUsers ?? 0,
|
||||
totalKeyEvents: ga4.totalKeyEvents ?? 0,
|
||||
totalRevenue: ga4.totalRevenue ?? 0,
|
||||
},
|
||||
siteTag: {
|
||||
totalSessions: siteTag.totalSessions ?? 0,
|
||||
totalPageViews: siteTag.totalPageViews ?? 0,
|
||||
totalConversions: siteTag.totalConversions ?? 0,
|
||||
totalFormSubmits: siteTag.totalFormSubmits ?? 0,
|
||||
totalPhoneCalls: siteTag.totalPhoneCalls ?? 0,
|
||||
totalBookings: siteTag.totalBookings ?? 0,
|
||||
totalEvents: siteTag.totalEvents ?? 0,
|
||||
totalOutboundClicks: siteTag.totalOutboundClicks ?? 0,
|
||||
},
|
||||
platform: {
|
||||
totalAiCalls: platform.totalAiCalls ?? 0,
|
||||
totalAiCost: platform.totalAiCost ?? 0,
|
||||
},
|
||||
paid: {
|
||||
totalSpend: paid.totalSpend ?? 0,
|
||||
totalAdClicks: paid.totalAdClicks ?? 0,
|
||||
totalAdImpressions: paid.totalAdImpressions ?? 0,
|
||||
totalAdConversions: paid.totalAdConversions ?? 0,
|
||||
},
|
||||
};
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
|
||||
<Archive className="w-6 h-6 text-brand-600" /> Historical Tracking
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-700 bg-emerald-50 border border-emerald-200 rounded-full px-2 py-0.5 ml-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" /> LIVE
|
||||
</span>
|
||||
{agoStr && <span className="text-[10px] text-slate-400 font-normal ml-1">Updated {agoStr}</span>}
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">Permanent record of all metrics. Auto-refreshes every 30s.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={brandSearch}
|
||||
onChange={(e) => setBrandSearch(e.target.value)}
|
||||
placeholder="Search brands..."
|
||||
className="px-3 py-1.5 text-xs border border-slate-200 rounded-lg bg-white w-48"
|
||||
/>
|
||||
{brandSearch && (
|
||||
<button onClick={() => setBrandSearch("")} className="text-xs text-slate-500 hover:text-slate-700">
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
|
||||
className="px-3 py-1.5 text-xs border border-slate-200 rounded-lg bg-white">
|
||||
<option value="all">All statuses</option>
|
||||
<option value="active">Active only</option>
|
||||
<option value="non-active">Non-active only</option>
|
||||
</select>
|
||||
{(statusFilter !== "all" || brandSearch || sortField !== "gscClicks" || sortDir !== "desc") && (
|
||||
<button
|
||||
onClick={() => { setStatusFilter("all"); setBrandSearch(""); setSortField("gscClicks"); setSortDir("desc"); }}
|
||||
className="px-3 py-1.5 text-xs text-slate-500 hover:text-slate-700 underline"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
<select value={selectedBrand} onChange={(e) => { setSelectedBrand(e.target.value); setExportBrand(e.target.value); }}
|
||||
className="px-3 py-1.5 text-xs border border-slate-200 rounded-lg bg-white min-w-[160px]">
|
||||
<option value="all">All Brands</option>
|
||||
{filteredBrands.map((b) => <option key={b.brandId} value={b.brandId}>{b.name}{b.status && b.status !== "active" ? ` [${b.status}]` : ""}</option>)}
|
||||
</select>
|
||||
{selectedBrand !== "all" && (
|
||||
<a href={`/api/admin/historical/export?brandId=${selectedBrand}&source=all`}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-slate-200 bg-white text-xs font-semibold text-slate-700 hover:bg-slate-50">
|
||||
<ExternalLink className="w-3.5 h-3.5" /> Download CSV
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={handleExportCsv}
|
||||
className="px-3 py-1.5 text-xs border border-slate-200 rounded-lg bg-white hover:bg-slate-50 text-slate-700"
|
||||
>
|
||||
Export CSV ({selectedBrandIds.size > 0 ? `${selectedBrandIds.size} selected` : `${filteredBrands.length}`})
|
||||
</button>
|
||||
{loading && <span className="text-xs text-slate-400">Loading…</span>}
|
||||
</div>
|
||||
|
||||
{/* GSC */}
|
||||
<Section title="Google Search Console" icon={Globe} accent="text-blue-600">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<Card label="Total Clicks Ever" value={fmt(t.gsc.totalClicks)} accent="text-blue-600" />
|
||||
<Card label="Total Impressions Ever" value={fmt(t.gsc.totalImpressions)} />
|
||||
<Card label="Avg Position (Lifetime)" value={String(t.gsc.avgPosition)} />
|
||||
<Card label="Avg CTR (Lifetime)" value={`${t.gsc.avgCtr}%`} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* GA4 */}
|
||||
<Section title="Google Analytics 4" icon={BarChart3} accent="text-orange-600">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<Card label="Total Sessions Ever" value={fmt(t.ga4.totalSessions)} />
|
||||
<Card label="Total Users Ever" value={fmt(t.ga4.totalUsers)} />
|
||||
<Card label="GA4 Key Events" value={fmt(t.ga4.totalKeyEvents)} accent="text-emerald-600" />
|
||||
<Card label="Total Revenue" value={`$${fmt(t.ga4.totalRevenue)}`} accent="text-amber-600" />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Site Tag */}
|
||||
<Section title="Site Tag (First-Party)" icon={Zap} accent="text-emerald-600">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<Card label="Total Sessions" value={fmt(t.siteTag.totalSessions)} />
|
||||
<Card label="Total Page Views" value={fmt(t.siteTag.totalPageViews)} />
|
||||
<Card label="Total Conversions" value={fmt(t.siteTag.totalConversions)} accent="text-emerald-600" />
|
||||
<Card label="Total Form Submits" value={fmt(t.siteTag.totalFormSubmits)} />
|
||||
<Card label="Total Phone Calls" value={fmt(t.siteTag.totalPhoneCalls)} />
|
||||
<Card label="Total Bookings" value={fmt(t.siteTag.totalBookings)} />
|
||||
<Card label="Total Events" value={fmt(t.siteTag.totalEvents)} />
|
||||
<Card label="Total Outbound Clicks" value={fmt(t.siteTag.totalOutboundClicks)} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Platform + Paid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Section title="Platform" icon={Shield} accent="text-violet-600">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Card label="Total AI Calls" value={fmt(t.platform.totalAiCalls)} />
|
||||
<Card label="Total AI Cost" value={`$${fmtCurrency(t.platform?.totalAiCost)}`} accent="text-amber-600" />
|
||||
</div>
|
||||
</Section>
|
||||
<Section title="Paid Media" icon={DollarSign} accent="text-amber-600">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Card label="Total Ad Spend" value={`$${fmt(t.paid.totalSpend)}`} accent="text-amber-600" />
|
||||
<Card label="Total Ad Clicks" value={fmt(t.paid.totalAdClicks)} />
|
||||
<Card label="Total Ad Impressions" value={fmt(t.paid.totalAdImpressions)} />
|
||||
<Card label="Ad Conversions" value={fmt(t.paid.totalAdConversions)} accent="text-emerald-600" />
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
{/* Per-Brand Breakdown */}
|
||||
{allBrandsData.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-5 py-3.5 border-b border-slate-100 flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-brand-600" />
|
||||
<h3 className="font-bold text-slate-800 text-sm">Per-Brand Breakdown</h3>
|
||||
<span className="text-[10px] text-slate-400 ml-1">
|
||||
{statusFilter !== "all" && brandSearch ? "Filtered by status and name search" :
|
||||
statusFilter !== "all" ? "Filtered by status" :
|
||||
brandSearch ? "Filtered by name search" :
|
||||
"All brands"} · not filtered by brand dropdown
|
||||
</span>
|
||||
</div>
|
||||
{selectedBrandIds.size >= 1 && (
|
||||
<div className="flex items-center gap-2 px-5 py-2 bg-slate-50 border-b border-slate-100">
|
||||
<span className="text-xs text-slate-600 font-medium">{selectedBrandIds.size} selected</span>
|
||||
<button onClick={handleBulkReactivate} disabled={bulkProcessing}
|
||||
className="px-3 py-1 text-xs font-semibold rounded border border-emerald-200 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 disabled:opacity-50">
|
||||
Reactivate all
|
||||
</button>
|
||||
<button onClick={handleBulkDeactivate} disabled={bulkProcessing}
|
||||
className="px-3 py-1 text-xs font-semibold rounded border border-amber-200 bg-amber-50 text-amber-700 hover:bg-amber-100 disabled:opacity-50">
|
||||
Deactivate all
|
||||
</button>
|
||||
<button onClick={() => setSelectedBrandIds(new Set())}
|
||||
className="px-3 py-1 text-xs text-slate-500 hover:text-slate-700">
|
||||
Clear
|
||||
</button>
|
||||
{bulkProcessing && <span className="text-xs text-slate-400">Processing…</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="text-left text-[11px] font-semibold uppercase tracking-wider text-slate-400">
|
||||
<th className="px-3 py-2 w-8">
|
||||
<input type="checkbox" className="rounded"
|
||||
checked={filteredBrands.length > 0 && selectedBrandIds.size === filteredBrands.length}
|
||||
onChange={toggleSelectAll} />
|
||||
</th>
|
||||
<th className="px-5 py-2 cursor-pointer select-none hover:bg-slate-100" onClick={() => handleSortClick("name")}>Brand {sortField === "name" && (sortDir === "asc" ? "▲" : "▼")}</th>
|
||||
<th className="px-5 py-2 text-right cursor-pointer select-none hover:bg-slate-100" onClick={() => handleSortClick("gscClicks")}>GSC Clicks {sortField === "gscClicks" && (sortDir === "asc" ? "▲" : "▼")}</th>
|
||||
<th className="px-5 py-2 text-right cursor-pointer select-none hover:bg-slate-100" onClick={() => handleSortClick("gscImpressions")}>Impressions {sortField === "gscImpressions" && (sortDir === "asc" ? "▲" : "▼")}</th>
|
||||
<th className="px-5 py-2 text-right cursor-pointer select-none hover:bg-slate-100" onClick={() => handleSortClick("siteTagSessions")}>Sessions {sortField === "siteTagSessions" && (sortDir === "asc" ? "▲" : "▼")}</th>
|
||||
<th className="px-5 py-2 text-right cursor-pointer select-none hover:bg-slate-100" onClick={() => handleSortClick("siteTagConversions")}>Conversions {sortField === "siteTagConversions" && (sortDir === "asc" ? "▲" : "▼")}</th>
|
||||
<th className="px-5 py-2 text-right cursor-pointer select-none hover:bg-slate-100" onClick={() => handleSortClick("siteTagEvents")}>Events {sortField === "siteTagEvents" && (sortDir === "asc" ? "▲" : "▼")}</th>
|
||||
<th className="px-5 py-2 cursor-pointer select-none hover:bg-slate-100" onClick={() => handleSortClick("firstDataDate")}>Data Since {sortField === "firstDataDate" && (sortDir === "asc" ? "▲" : "▼")}</th>
|
||||
<th className="px-5 py-2 text-right cursor-pointer select-none hover:bg-slate-100" onClick={() => handleSortClick("snapshots")}>Snapshots {sortField === "snapshots" && (sortDir === "asc" ? "▲" : "▼")}</th>
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-slate-100">{sortedBrands.map((b) => (
|
||||
<tr key={b.brandId} className={`hover:bg-slate-50 ${selectedBrandIds.has(b.brandId) ? "bg-violet-50" : ""}`}>
|
||||
<td className="px-3 py-2 w-8">
|
||||
<input type="checkbox" className="rounded" checked={selectedBrandIds.has(b.brandId)}
|
||||
onChange={() => setSelectedBrandIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(b.brandId) ? next.delete(b.brandId) : next.add(b.brandId);
|
||||
return next;
|
||||
})} />
|
||||
</td>
|
||||
<td className="px-5 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-800">{b.name}</span>
|
||||
<BrandStatusBadge status={b.status} hideIfActive />
|
||||
{b.status && b.status.toLowerCase() !== "active" && (
|
||||
<button
|
||||
onClick={() => handleReactivate(b.brandId, b.name)}
|
||||
disabled={reactivatingId === b.brandId}
|
||||
className="inline-flex items-center rounded border border-slate-200 bg-white px-2 py-0.5 text-[10px] font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-50"
|
||||
>
|
||||
{reactivatingId === b.brandId ? "Reactivating…" : "Reactivate"}
|
||||
</button>
|
||||
)}
|
||||
{(!b.status || b.status.toLowerCase() === "active") && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleDeactivate(b.brandId, b.name)}
|
||||
disabled={deactivatingId === b.brandId}
|
||||
className="inline-flex items-center rounded border border-amber-200 bg-amber-50 px-2 py-0.5 text-[10px] font-semibold text-amber-700 hover:bg-amber-100 disabled:opacity-50"
|
||||
>
|
||||
{deactivatingId === b.brandId ? "Deactivating…" : "Deactivate"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSuspend(b.brandId, b.name)}
|
||||
disabled={suspendingId === b.brandId}
|
||||
className="inline-flex items-center rounded border border-red-200 bg-red-50 px-2 py-0.5 text-[10px] font-semibold text-red-700 hover:bg-red-100 disabled:opacity-50"
|
||||
>
|
||||
{suspendingId === b.brandId ? "Suspending…" : "Suspend"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{b.snippetCheckedAt ? (
|
||||
<span className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold ${
|
||||
b.snippetInstallationMethod === "direct"
|
||||
? "bg-green-50 text-green-700"
|
||||
: b.snippetInstallationMethod === "gtm"
|
||||
? "bg-blue-50 text-blue-700"
|
||||
: "bg-slate-100 text-slate-500"
|
||||
}`}>
|
||||
<span className={`inline-block w-1.5 h-1.5 rounded-full ${
|
||||
b.snippetInstallationMethod === "direct" ? "bg-green-500"
|
||||
: b.snippetInstallationMethod === "gtm" ? "bg-blue-500"
|
||||
: "bg-slate-400"
|
||||
}`} />
|
||||
{b.snippetInstallationMethod === "direct" ? "Direct"
|
||||
: b.snippetInstallationMethod === "gtm" ? "GTM"
|
||||
: "Not detected"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] text-slate-400">Unchecked</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleCheckSnippet(b.brandId)}
|
||||
disabled={checkingSnippetIds.has(b.brandId)}
|
||||
className="inline-flex items-center rounded-md border border-slate-200 bg-white px-2 py-0.5 text-xs font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-50"
|
||||
>
|
||||
{checkingSnippetIds.has(b.brandId) ? "Checking…" : "Check"}
|
||||
</button>
|
||||
</div>
|
||||
{b.statusChangedAt && (
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
Changed {formatRelative(b.statusChangedAt)}{b.statusChangedBy && ` by ${b.statusChangedBy}`}
|
||||
</div>
|
||||
)}
|
||||
{b.snippetCheckedAt && (
|
||||
<div className="text-xs text-slate-400 mt-0.5">Snippet checked {formatRelative(b.snippetCheckedAt)}</div>
|
||||
)}
|
||||
<span className="text-xs text-slate-400">{b.domain}</span>
|
||||
</td>
|
||||
<td className="px-5 py-2 text-right tabular-nums">{fmt(b.gscClicks)}</td>
|
||||
<td className="px-5 py-2 text-right tabular-nums">{fmt(b.gscImpressions)}</td>
|
||||
<td className="px-5 py-2 text-right tabular-nums">{fmt(b.siteTagSessions || b.ga4Sessions)}</td>
|
||||
<td className="px-5 py-2 text-right tabular-nums font-semibold text-emerald-700">{fmt(b.siteTagConversions || b.ga4KeyEvents)}</td>
|
||||
<td className="px-5 py-2 text-right tabular-nums">{fmt(b.siteTagEvents)}</td>
|
||||
<td className="px-5 py-2 text-xs text-slate-500">{b.firstDataDate ?? "—"}</td>
|
||||
<td className="px-5 py-2 text-right tabular-nums text-slate-500">{b.snapshots}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Data Preservation Status */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-5 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-brand-600" />
|
||||
<h3 className="font-bold text-slate-800 text-sm">Data Preservation Status</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 text-xs text-slate-600">
|
||||
<div className="bg-blue-50 rounded-lg p-3 border border-blue-100">
|
||||
<p className="font-semibold text-blue-800">GSC</p>
|
||||
<p>Since {data.retention.gscOldestData ?? "—"}</p>
|
||||
<p className="text-blue-600">{data.retention.gscRetentionMonths}mo retention — snapshots preserve beyond</p>
|
||||
</div>
|
||||
<div className="bg-emerald-50 rounded-lg p-3 border border-emerald-100">
|
||||
<p className="font-semibold text-emerald-800">Site Tag</p>
|
||||
<p>Since {data.retention.siteTagOldestData ?? "—"}</p>
|
||||
<p className="text-emerald-600">No expiration — you own this data</p>
|
||||
</div>
|
||||
<div className="bg-violet-50 rounded-lg p-3 border border-violet-100">
|
||||
<p className="font-semibold text-violet-800">Snapshots</p>
|
||||
<p>{fmt(data.valuation?.totalSnapshots)} daily snapshots</p>
|
||||
<p className="text-violet-600">Permanent — survives all retention windows</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-3 border border-slate-200">
|
||||
<p className="font-semibold text-slate-800">Platform</p>
|
||||
<p>{data.valuation.totalBrands} brands · {data.valuation.dataSpanDays}d span</p>
|
||||
<p className="text-slate-500">{data.retention.note}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backfill Controls */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-5 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="w-4 h-4 text-brand-600" />
|
||||
<h3 className="font-bold text-slate-800 text-sm">Data Backfill</h3>
|
||||
<p className="text-[10px] text-slate-400 ml-2">Pull historical data from Google APIs into permanent storage</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<BackfillButton label="Backfill GA4 (36mo)" endpoint="/api/admin/debug/backfill-ga4-history" brandId={selectedBrand} />
|
||||
<BackfillButton label="Backfill GSC (16mo)" endpoint="/api/admin/debug/backfill-gsc-history" brandId={selectedBrand} />
|
||||
<BackfillButton label="Rebuild Snapshots" endpoint="/api/admin/debug/backfill-snapshots" brandId={selectedBrand} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Monthly Trend */}
|
||||
{data.monthly.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-5 py-3.5 border-b border-slate-100 flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-brand-600" />
|
||||
<h3 className="font-bold text-slate-800 text-sm">Monthly Trend</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="text-left text-[11px] font-semibold uppercase tracking-wider text-slate-400">
|
||||
<th className="px-5 py-2">Month</th>
|
||||
<th className="px-5 py-2 text-right">GSC Clicks</th>
|
||||
<th className="px-5 py-2 text-right">Impressions</th>
|
||||
<th className="px-5 py-2 text-right">GA4 Sessions</th>
|
||||
<th className="px-5 py-2 text-right">ST Sessions</th>
|
||||
<th className="px-5 py-2 text-right">ST Conv</th>
|
||||
<th className="px-5 py-2 text-right">ST Events</th>
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-slate-100">{data.monthly.map((m) => (
|
||||
<tr key={m.month} className="hover:bg-slate-50">
|
||||
<td className="px-5 py-2 font-medium text-slate-800">{m.month}</td>
|
||||
<td className="px-5 py-2 text-right tabular-nums">{fmt(m.gscClicks)}</td>
|
||||
<td className="px-5 py-2 text-right tabular-nums">{fmt(m.gscImpressions)}</td>
|
||||
<td className="px-5 py-2 text-right tabular-nums">{fmt(m.ga4Sessions)}</td>
|
||||
<td className="px-5 py-2 text-right tabular-nums">{fmt(m.siteTagSessions)}</td>
|
||||
<td className="px-5 py-2 text-right tabular-nums text-emerald-700">{fmt(m.siteTagConversions)}</td>
|
||||
<td className="px-5 py-2 text-right tabular-nums">{fmt(m.siteTagEvents)}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, icon: Icon, accent = "text-brand-600", children }: { title: string; icon: typeof Database; accent?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={`w-4 h-4 ${accent}`} />
|
||||
<h2 className="font-bold text-slate-800 text-sm uppercase tracking-widest">{title}</h2>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackfillButton({ label, endpoint, brandId }: { label: string; endpoint: string; brandId: string }) {
|
||||
const [running, setRunning] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const run = async () => {
|
||||
setRunning(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const params = brandId !== "all" ? `?brandId=${encodeURIComponent(brandId)}` : "";
|
||||
const res = await fetch(`${endpoint}${params}`, { method: "POST" });
|
||||
const data = await res.json();
|
||||
setResult(res.ok ? `Done: ${JSON.stringify(data).slice(0, 200)}` : `Error: ${data.error ?? res.status}`);
|
||||
} catch (err) {
|
||||
setResult(`Failed: ${err instanceof Error ? err.message : "unknown"}`);
|
||||
} finally { setRunning(false); }
|
||||
};
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<button onClick={run} disabled={running}
|
||||
className="px-3 py-1.5 rounded-lg border border-slate-200 bg-white text-xs font-semibold text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5">
|
||||
{running ? <span className="w-3 h-3 border-2 border-brand-600 border-t-transparent rounded-full animate-spin" /> : <Database className="w-3.5 h-3.5" />}
|
||||
{label}
|
||||
</button>
|
||||
{result && <p className="text-[10px] text-slate-500 max-w-xs truncate" title={result}>{result}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ label, value, accent = "text-slate-900" }: { label: string; value: string; accent?: string }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 px-4 py-3">
|
||||
<p className="text-[10px] font-semibold text-slate-400 uppercase tracking-widest">{label}</p>
|
||||
<p className={`text-xl font-bold tabular-nums mt-0.5 ${accent}`}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { BarChart3, RefreshCw, Loader2 } from "lucide-react";
|
||||
import { isAbortError } from "@/lib/utils/is-abort-error";
|
||||
|
||||
interface Benchmark { metric: string; value: number; sampleSize: number }
|
||||
interface Rate { type: string; total: number; implemented: number; improved: number; avgImpact: number }
|
||||
interface IndustryOption { value: string; label: string; count: number; meetsThreshold: boolean }
|
||||
|
||||
export default function AdminIndustryPage() {
|
||||
const [industry, setIndustry] = useState("");
|
||||
const [industries, setIndustries] = useState<IndustryOption[]>([]);
|
||||
const [industriesLoading, setIndustriesLoading] = useState(true);
|
||||
const [benchmarks, setBenchmarks] = useState<Record<string, { value: number; sampleSize: number }>>({});
|
||||
const [rates, setRates] = useState<Rate[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [computing, setComputing] = useState(false);
|
||||
const [minSample, setMinSample] = useState(5);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/admin/industries")
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
const list: IndustryOption[] = d.industries ?? [];
|
||||
setIndustries(list);
|
||||
if (typeof d.minSample === "number") setMinSample(d.minSample);
|
||||
if (list.length > 0 && !industry) setIndustry(list[0].value);
|
||||
})
|
||||
.catch((err) => { if (!isAbortError(err)) console.error(err); })
|
||||
.finally(() => setIndustriesLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!industry) return;
|
||||
setLoading(true);
|
||||
const [bRes, rRes] = await Promise.all([
|
||||
fetch(`/api/benchmarks?industry=${encodeURIComponent(industry)}`),
|
||||
fetch(`/api/benchmarks/success-rates?industry=${encodeURIComponent(industry)}`),
|
||||
]);
|
||||
if (bRes.ok) { const d = await bRes.json(); setBenchmarks(d.benchmarks ?? {}); }
|
||||
if (rRes.ok) { const d = await rRes.json(); setRates(d.rates ?? []); }
|
||||
setLoading(false);
|
||||
}, [industry]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function compute() {
|
||||
setComputing(true);
|
||||
await fetch("/api/admin/compute-benchmarks", { method: "POST" });
|
||||
await load();
|
||||
setComputing(false);
|
||||
}
|
||||
|
||||
const bKeys = Object.keys(benchmarks);
|
||||
const selectedOption = industries.find((i) => i.value === industry);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-5xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-slate-900">Industry Data</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{industriesLoading ? (
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 text-xs text-slate-400">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Loading industries…
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={industry}
|
||||
onChange={(e) => setIndustry(e.target.value)}
|
||||
className="text-sm border border-slate-200 rounded-lg px-3 py-2 bg-white max-w-xs"
|
||||
>
|
||||
{industries.length === 0 && <option value="">No brand industry data</option>}
|
||||
{industries.map((ind) => (
|
||||
<option key={ind.value} value={ind.value}>
|
||||
{ind.label} ({ind.count} brand{ind.count !== 1 ? "s" : ""}{ind.meetsThreshold ? "" : ` — need ${minSample}+`})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button onClick={compute} disabled={computing || !industry} className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-brand-600 text-white text-xs font-semibold hover:bg-brand-700 disabled:opacity-50">
|
||||
{computing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />} Compute Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedOption && !selectedOption.meetsThreshold && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700">
|
||||
<strong>{selectedOption.label}</strong> has {selectedOption.count} brand{selectedOption.count !== 1 ? "s" : ""} — need {minSample - selectedOption.count} more to compute benchmarks (minimum {minSample}).
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? <div className="py-12 text-center"><Loader2 className="w-6 h-6 animate-spin text-slate-400 mx-auto" /></div> : (
|
||||
<>
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-slate-100 flex items-center gap-2"><BarChart3 className="w-4 h-4 text-brand-600" /><h2 className="font-bold text-slate-800 text-sm">Benchmarks ({bKeys.length} metrics)</h2></div>
|
||||
{bKeys.length === 0 ? <div className="px-5 py-8 text-center text-xs text-slate-400">No benchmarks for this industry yet. {selectedOption?.meetsThreshold ? 'Click "Compute Now" to generate.' : `Need ${minSample - (selectedOption?.count ?? 0)} more brands.`}</div> :
|
||||
<table className="w-full text-sm"><thead><tr className="border-b border-slate-100 bg-slate-50/80 text-[10px] font-bold uppercase tracking-widest text-slate-400"><th className="px-4 py-2 text-left">Metric</th><th className="px-4 py-2 text-right">Value</th><th className="px-4 py-2 text-right">Sample</th></tr></thead>
|
||||
<tbody>{bKeys.sort().map((k) => <tr key={k} className="border-b border-slate-50"><td className="px-4 py-2 text-slate-700 capitalize">{k.replace(/_/g, " ")}</td><td className="px-4 py-2 text-right font-bold tabular-nums">{benchmarks[k].value}</td><td className="px-4 py-2 text-right text-slate-500 tabular-nums">{benchmarks[k].sampleSize}</td></tr>)}</tbody></table>}
|
||||
</div>
|
||||
|
||||
{rates.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-slate-100"><h2 className="font-bold text-slate-800 text-sm">Recommendation Success Rates</h2></div>
|
||||
<table className="w-full text-sm"><thead><tr className="border-b border-slate-100 bg-slate-50/80 text-[10px] font-bold uppercase tracking-widest text-slate-400"><th className="px-4 py-2 text-left">Type</th><th className="px-4 py-2 text-right">Total</th><th className="px-4 py-2 text-right">Implemented</th><th className="px-4 py-2 text-right">Improved</th><th className="px-4 py-2 text-right">Avg Impact</th></tr></thead>
|
||||
<tbody>{rates.map((r) => <tr key={r.type} className="border-b border-slate-50"><td className="px-4 py-2 text-slate-700 capitalize">{r.type.replace(/_/g, " ")}</td><td className="px-4 py-2 text-right tabular-nums">{r.total}</td><td className="px-4 py-2 text-right tabular-nums">{r.implemented}</td><td className="px-4 py-2 text-right font-bold text-green-600 tabular-nums">{r.improved}</td><td className="px-4 py-2 text-right font-bold tabular-nums">{r.avgImpact > 0 ? "+" : ""}{r.avgImpact}%</td></tr>)}</tbody></table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Admin → Integrations
|
||||
* ────────────────────
|
||||
* Every BrandIntegration row across the platform. Headline cards
|
||||
* show connected / errored counts per integration type; the table
|
||||
* lists individual rows for triage with status, last-sync, and
|
||||
* any syncError surfaced inline.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Zap, Loader2, AlertTriangle, CheckCircle2, Clock } from "lucide-react";
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
integrationId: string;
|
||||
connected: boolean;
|
||||
status: string;
|
||||
lastSynced: string | null;
|
||||
syncError: string | null;
|
||||
syncRetryCount: number;
|
||||
tokenExpiresAt: string | null;
|
||||
// Present only for synthetic site_tag rows — the "installed
|
||||
// since" timestamp (first TrackedEvent). Not rendered in the
|
||||
// main table but kept on the payload so future UI tweaks can
|
||||
// surface it without another round-trip.
|
||||
installedAt?: string | null;
|
||||
brand: { id: string; name: string; domain: string };
|
||||
}
|
||||
|
||||
// Type-badge colour per integration id. site_tag gets its own teal
|
||||
// palette so it stands apart from the OAuth integrations without
|
||||
// breaking the uppercase/rounded visual convention.
|
||||
function typeBadgeClass(integrationId: string): string {
|
||||
if (integrationId === "site_tag") return "bg-teal-50 text-teal-700 border border-teal-200";
|
||||
return "bg-slate-100 text-slate-700";
|
||||
}
|
||||
function typeBadgeLabel(integrationId: string): string {
|
||||
// "site_tag" → "SITE TAG"; every other known id is already the
|
||||
// uppercase convention (gsc / ga4 / gbp / hubspot).
|
||||
return integrationId.replace(/_/g, " ").toUpperCase();
|
||||
}
|
||||
interface Payload {
|
||||
summary: {
|
||||
total: number;
|
||||
connected: number;
|
||||
withError: number;
|
||||
byType: Record<string, { total: number; connected: number; withError: number }>;
|
||||
};
|
||||
rows: Row[];
|
||||
}
|
||||
|
||||
function formatDate(s: string | null): string {
|
||||
if (!s) return "—";
|
||||
try {
|
||||
const d = new Date(s);
|
||||
const ageMin = (Date.now() - d.getTime()) / 60_000;
|
||||
if (ageMin < 60) return `${Math.floor(ageMin)}m ago`;
|
||||
if (ageMin < 60 * 24) return `${Math.floor(ageMin / 60)}h ago`;
|
||||
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
} catch { return s; }
|
||||
}
|
||||
|
||||
export default function AdminIntegrationsPage() {
|
||||
const [data, setData] = useState<Payload | null>(null);
|
||||
const [filter, setFilter] = useState<"all" | "connected" | "error">("all");
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/admin/integrations").then((r) => r.ok ? r.json() : null).then(setData).catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Loader2 className="w-5 h-5 text-slate-400 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filteredRows = data.rows.filter((r) =>
|
||||
filter === "all" ? true
|
||||
: filter === "connected" ? r.connected
|
||||
: !!r.syncError,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-slate-700" /> Integrations
|
||||
</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">
|
||||
{data.summary.total.toLocaleString()} integration row{data.summary.total === 1 ? "" : "s"} across all brands.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Headline cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Stat label="Total" value={data.summary.total} color="text-slate-800" />
|
||||
<Stat label="Connected" value={data.summary.connected} color="text-emerald-600" />
|
||||
<Stat label="With errors" value={data.summary.withError} color={data.summary.withError > 0 ? "text-red-600" : "text-slate-800"} />
|
||||
<Stat label="Types" value={Object.keys(data.summary.byType).length} color="text-slate-800" />
|
||||
</div>
|
||||
|
||||
{/* Per-type breakdown */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-slate-100 font-bold text-slate-800 text-sm">By integration type</div>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{Object.entries(data.summary.byType).map(([id, s]) => (
|
||||
<div key={id} className="px-4 py-2.5 flex items-center gap-3">
|
||||
<span className={`text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded min-w-[70px] text-center ${typeBadgeClass(id)}`}>
|
||||
{typeBadgeLabel(id)}
|
||||
</span>
|
||||
<div className="flex-1 grid grid-cols-3 gap-2 text-xs">
|
||||
<span className="text-slate-700">{s.total} total</span>
|
||||
<span className="text-emerald-600">{s.connected} connected</span>
|
||||
<span className={s.withError > 0 ? "text-red-600 font-semibold" : "text-slate-400"}>
|
||||
{s.withError} errored
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-3 flex items-center gap-2">
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as typeof filter)}
|
||||
className="px-3 py-1.5 text-xs bg-white border border-slate-200 rounded-lg"
|
||||
>
|
||||
<option value="all">All ({data.summary.total})</option>
|
||||
<option value="connected">Connected ({data.summary.connected})</option>
|
||||
<option value="error">Errored ({data.summary.withError})</option>
|
||||
</select>
|
||||
<span className="text-xs text-slate-500 ml-auto">{filteredRows.length} shown</span>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 border-b border-slate-200">
|
||||
<tr className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">
|
||||
<th className="text-left px-4 py-3">Brand</th>
|
||||
<th className="text-left px-3 py-3">Type</th>
|
||||
<th className="text-left px-3 py-3">Status</th>
|
||||
<th className="text-right px-3 py-3">Last sync</th>
|
||||
<th className="text-right px-3 py-3">Token expires</th>
|
||||
<th className="text-left px-3 py-3">Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{filteredRows.map((r) => (
|
||||
<tr key={r.id} className={`hover:bg-slate-50 ${r.syncError ? "bg-red-50/30" : ""}`}>
|
||||
<td className="px-4 py-2.5">
|
||||
<Link href={`/admin/brands?q=${encodeURIComponent(r.brand.domain)}`} className="text-xs font-semibold text-slate-800 hover:text-brand-600 truncate block max-w-[200px]">
|
||||
{r.brand.name}
|
||||
</Link>
|
||||
<p className="text-[10px] text-slate-500 font-mono truncate max-w-[200px]">{r.brand.domain}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<span className={`text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded ${typeBadgeClass(r.integrationId)}`}>
|
||||
{typeBadgeLabel(r.integrationId)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
{r.connected ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-emerald-700">
|
||||
<CheckCircle2 className="w-3 h-3" /> {r.status}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-slate-500">
|
||||
{r.status}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right text-[10px] text-slate-500 tabular-nums">
|
||||
{formatDate(r.lastSynced)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right text-[10px] text-slate-500 tabular-nums">
|
||||
{r.tokenExpiresAt ? (
|
||||
<span className={new Date(r.tokenExpiresAt) < new Date() ? "text-red-600 font-semibold inline-flex items-center gap-1" : ""}>
|
||||
{new Date(r.tokenExpiresAt) < new Date() && <Clock className="w-3 h-3" />}
|
||||
{formatDate(r.tokenExpiresAt)}
|
||||
</span>
|
||||
) : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
{r.syncError ? (
|
||||
<span className="text-[10px] text-red-700 font-mono inline-flex items-center gap-1 max-w-[280px]" title={r.syncError}>
|
||||
<AlertTriangle className="w-3 h-3 shrink-0" />
|
||||
<span className="truncate">{r.syncError}</span>
|
||||
{r.syncRetryCount > 0 && <span className="text-[9px] text-red-500 shrink-0">×{r.syncRetryCount}</span>}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">{label}</p>
|
||||
<p className={`text-2xl font-bold mt-1 tabular-nums ${color}`}>{value.toLocaleString()}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import AdminSidebar from "./components/admin-sidebar";
|
||||
|
||||
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const [checked, setChecked] = useState(false);
|
||||
const [allowed, setAllowed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/admin/health")
|
||||
.then((r) => { setAllowed(r.ok); if (!r.ok) router.replace("/dashboard"); })
|
||||
.catch(() => { setAllowed(false); router.replace("/dashboard"); })
|
||||
.finally(() => setChecked(true));
|
||||
}, [router]);
|
||||
|
||||
if (!checked) return <div className="flex items-center justify-center h-screen bg-slate-50"><div className="w-8 h-8 border-4 border-slate-200 border-t-slate-600 rounded-full animate-spin" /></div>;
|
||||
if (!allowed) return null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-slate-100">
|
||||
<AdminSidebar />
|
||||
<main className="flex-1 p-6 overflow-auto">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowLeft, Check, X, Globe, Users, Building2, BarChart3,
|
||||
Tag, Search, FileText, Bot, Plug, Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
interface BrandStats {
|
||||
sessions30d: number;
|
||||
conversions30d: number;
|
||||
gscClicks28d: number;
|
||||
gscImpressions28d: number;
|
||||
trackedKeywords: number;
|
||||
contentBriefs: number;
|
||||
pluginsDetected: string[];
|
||||
}
|
||||
|
||||
interface BrandDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
domain: string;
|
||||
plan: string | null;
|
||||
industry: string | null;
|
||||
createdAt: string;
|
||||
integrations: Array<{ integrationId: string; connected: boolean; lastSynced: string | null }>;
|
||||
siteTag: { siteId: string; status: string; firstEventAt: string | null; lastEventAt: string | null } | null;
|
||||
stats: BrandStats | null;
|
||||
}
|
||||
|
||||
interface OrgDetail {
|
||||
organization: {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
brands: BrandDetail[];
|
||||
memberships: Array<{ role: string; joinedAt: string; user: { id: string; name: string | null; email: string; image: string | null } }>;
|
||||
};
|
||||
aiUsage: { calls: number; cost: number };
|
||||
}
|
||||
|
||||
function fmt(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
function relDate(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
const sec = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (sec < 60) return "just now";
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
|
||||
if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
|
||||
return `${Math.floor(sec / 86400)}d ago`;
|
||||
}
|
||||
|
||||
export default function AdminOrgDetail() {
|
||||
const { id } = useParams();
|
||||
const [data, setData] = useState<OrgDetail | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/admin/organizations/${id}`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then(setData)
|
||||
.catch(() => {});
|
||||
}, [id]);
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-6 h-6 text-slate-400 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const org = data.organization;
|
||||
const totalSessions = org.brands.reduce((s, b) => s + (b.stats?.sessions30d ?? 0), 0);
|
||||
const totalConversions = org.brands.reduce((s, b) => s + (b.stats?.conversions30d ?? 0), 0);
|
||||
const totalClicks = org.brands.reduce((s, b) => s + (b.stats?.gscClicks28d ?? 0), 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-6xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/organizations" className="p-1.5 rounded-lg hover:bg-white text-slate-500">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">{org.name}</h1>
|
||||
<p className="text-xs text-slate-500">
|
||||
Created {new Date(org.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
|
||||
{" · "}{org.memberships.length} user{org.memberships.length !== 1 ? "s" : ""}
|
||||
{" · "}{org.brands.length} brand{org.brands.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<StatBox label="Brands" value={org.brands.length} icon={Building2} />
|
||||
<StatBox label="Sessions (30d)" value={fmt(totalSessions)} icon={BarChart3} color="text-emerald-600" />
|
||||
<StatBox label="Conversions (30d)" value={fmt(totalConversions)} icon={Tag} color="text-blue-600" />
|
||||
<StatBox label="GSC Clicks (28d)" value={fmt(totalClicks)} icon={Search} color="text-violet-600" />
|
||||
<StatBox label="AI Cost (30d)" value={`$${data.aiUsage.cost}`} icon={Bot} color="text-amber-600" />
|
||||
</div>
|
||||
|
||||
{/* Members */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-slate-100 flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-slate-500" />
|
||||
<h2 className="font-bold text-slate-800 text-sm">Members ({org.memberships.length})</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{org.memberships.map((m) => (
|
||||
<div key={m.user.id} className="px-5 py-2.5 flex items-center gap-3">
|
||||
{m.user.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={m.user.image} alt="" className="w-7 h-7 rounded-full" />
|
||||
) : (
|
||||
<div className="w-7 h-7 rounded-full bg-slate-200 flex items-center justify-center text-[11px] font-bold text-slate-600">
|
||||
{(m.user.name || m.user.email)[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-semibold text-slate-900 mr-2">{m.user.name || "—"}</span>
|
||||
<span className="text-xs text-slate-500">{m.user.email}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded">{m.role}</span>
|
||||
<span className="text-[10px] text-slate-400">{relDate(m.joinedAt)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Brands — detailed cards */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-slate-500" />
|
||||
<h2 className="font-bold text-slate-800 text-sm">Brands ({org.brands.length})</h2>
|
||||
</div>
|
||||
|
||||
{org.brands.map((brand) => (
|
||||
<BrandCard key={brand.id} brand={brand} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatBox({ label, value, icon: Icon, color }: { label: string; value: string | number; icon: typeof Building2; color?: string }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 px-5 py-4">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Icon className="w-3.5 h-3.5 text-slate-400" />
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">{label}</p>
|
||||
</div>
|
||||
<p className={`text-2xl font-bold ${color ?? "text-slate-900"}`}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BrandCard({ brand }: { brand: BrandDetail }) {
|
||||
const s = brand.stats;
|
||||
const has = (id: string) => brand.integrations.some((i) => i.integrationId === id && i.connected);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
{/* Brand header */}
|
||||
<div className="px-5 py-4 border-b border-slate-100 flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-slate-900">{brand.name}</h3>
|
||||
<a href={`https://${brand.domain}`} target="_blank" rel="noreferrer" className="text-xs font-mono text-brand-600 hover:underline">
|
||||
{brand.domain}
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{brand.plan && (
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-slate-100 text-slate-600 border border-slate-200">
|
||||
{brand.plan}
|
||||
</span>
|
||||
)}
|
||||
{brand.siteTag?.firstEventAt && (
|
||||
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700 border border-emerald-200">
|
||||
Site Tag Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Integrations + Site Tag info */}
|
||||
<div className="px-5 py-3 border-b border-slate-100 flex items-center gap-3 flex-wrap">
|
||||
<IntDot on={has("gsc")} label="GSC" />
|
||||
<IntDot on={has("ga4")} label="GA4" />
|
||||
<IntDot on={has("gbp")} label="GBP" />
|
||||
<IntDot on={!!brand.siteTag} label="Site Tag" />
|
||||
{brand.siteTag && (
|
||||
<>
|
||||
<span className="text-[10px] text-slate-400 ml-auto">
|
||||
ID: <span className="font-mono">{brand.siteTag.siteId.slice(0, 12)}…</span>
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
Last event: {relDate(brand.siteTag.lastEventAt)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Usage stats grid */}
|
||||
{s && (
|
||||
<div className="px-5 py-4 grid grid-cols-3 md:grid-cols-6 gap-3">
|
||||
<MiniStat label="Sessions (30d)" value={fmt(s.sessions30d)} />
|
||||
<MiniStat label="Conversions (30d)" value={fmt(s.conversions30d)} />
|
||||
<MiniStat label="GSC Clicks (28d)" value={fmt(s.gscClicks28d)} />
|
||||
<MiniStat label="GSC Impr (28d)" value={fmt(s.gscImpressions28d)} />
|
||||
<MiniStat label="Tracked Keywords" value={s.trackedKeywords} />
|
||||
<MiniStat label="Content Briefs" value={s.contentBriefs} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plugins detected */}
|
||||
{s && s.pluginsDetected.length > 0 && (
|
||||
<div className="px-5 py-3 border-t border-slate-100">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mb-1.5">Plugins Detected</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{s.pluginsDetected.map((p) => (
|
||||
<span key={p} className="text-[10px] font-semibold px-2 py-0.5 rounded bg-blue-50 text-blue-700 border border-blue-200">
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IntDot({ on, label }: { on: boolean; label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${on ? "bg-emerald-500" : "bg-slate-300"}`} />
|
||||
<span className={on ? "text-slate-700" : "text-slate-400"}>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniStat({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="bg-slate-50 rounded-lg px-3 py-2">
|
||||
<p className="text-[9px] font-bold uppercase tracking-widest text-slate-400">{label}</p>
|
||||
<p className="text-sm font-bold text-slate-800 tabular-nums mt-0.5">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Search, ChevronRight } from "lucide-react";
|
||||
|
||||
interface Org { id: string; name: string; createdAt: string; _count: { brands: number; memberships: number } }
|
||||
|
||||
export default function AdminOrganizations() {
|
||||
const [orgs, setOrgs] = useState<Org[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [query, setQuery] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const res = await fetch(`/api/admin/organizations?page=${page}${query ? `&q=${encodeURIComponent(query)}` : ""}`);
|
||||
if (res.ok) { const d = await res.json(); setOrgs(d.organizations ?? []); setTotal(d.total ?? 0); }
|
||||
setLoading(false);
|
||||
}, [page, query]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const pages = Math.ceil(total / 50);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-6xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-slate-900">Organizations</h1>
|
||||
<span className="text-sm text-slate-500">{total} total</span>
|
||||
</div>
|
||||
|
||||
<div className="relative"><Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" /><input value={query} onChange={(e) => { setQuery(e.target.value); setPage(1); }} placeholder="Search organizations..." className="w-full pl-10 pr-4 py-2.5 text-sm border border-slate-200 rounded-lg bg-white" /></div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-slate-100 bg-slate-50/80 text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
||||
<th className="px-4 py-2.5 text-left">Organization</th><th className="px-4 py-2.5 text-center">Brands</th><th className="px-4 py-2.5 text-center">Users</th><th className="px-4 py-2.5 text-left">Created</th><th className="w-10" />
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{loading ? <tr><td colSpan={5} className="px-4 py-12 text-center text-slate-400">Loading...</td></tr> :
|
||||
orgs.map((org) => (
|
||||
<tr key={org.id} className="border-b border-slate-50 hover:bg-slate-50">
|
||||
<td className="px-4 py-2.5 font-semibold text-slate-900">{org.name}</td>
|
||||
<td className="px-4 py-2.5 text-center tabular-nums">{org._count.brands}</td>
|
||||
<td className="px-4 py-2.5 text-center tabular-nums">{org._count.memberships}</td>
|
||||
<td className="px-4 py-2.5 text-slate-500">{new Date(org.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}</td>
|
||||
<td className="px-4 py-2.5"><Link href={`/admin/organizations/${org.id}`} className="text-brand-600 hover:text-brand-800"><ChevronRight className="w-4 h-4" /></Link></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{pages > 1 && <div className="flex items-center justify-center gap-2"><button disabled={page <= 1} onClick={() => setPage(page - 1)} className="px-3 py-1 rounded-md text-xs font-semibold border border-slate-200 disabled:opacity-40">Prev</button><span className="text-xs text-slate-500">Page {page} of {pages}</span><button disabled={page >= pages} onClick={() => setPage(page + 1)} className="px-3 py-1 rounded-md text-xs font-semibold border border-slate-200 disabled:opacity-40">Next</button></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
DollarSign, TrendingUp, MousePointerClick, Eye, Users, Home,
|
||||
GitBranch, Zap, Plug, Globe, BarChart3,
|
||||
} from "lucide-react";
|
||||
|
||||
interface Summary {
|
||||
totalSpend: number;
|
||||
totalClicks: number;
|
||||
totalImpressions: number;
|
||||
platformConversions: number;
|
||||
attributionPaths: number;
|
||||
adClickEvents: number;
|
||||
visitorProfiles: number;
|
||||
householdClusters: number;
|
||||
connectedPlatforms: number;
|
||||
}
|
||||
|
||||
interface BrandRow {
|
||||
brandId: string;
|
||||
name: string;
|
||||
domain: string;
|
||||
attributionPaths: number;
|
||||
integrations: Array<{ platform: string; status: string }>;
|
||||
}
|
||||
|
||||
interface Data {
|
||||
summary: Summary;
|
||||
topBrands: BrandRow[];
|
||||
}
|
||||
|
||||
export default function AdminPaidMediaPage() {
|
||||
const [data, setData] = useState<Data | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/admin/paid-media")
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((d: Data | null) => setData(d))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="w-6 h-6 border-2 border-brand-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-6 py-12 text-center">
|
||||
<Zap className="w-10 h-10 text-slate-200 mx-auto mb-3" />
|
||||
<h2 className="text-lg font-bold text-slate-900">Paid Media Intelligence</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">No data available. Run the paid-media rebuild cron first.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const s = data.summary;
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Paid Media — Platform Overview</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">Cross-brand paid media attribution, spend, and platform integrations (28d)</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
<KPI icon={DollarSign} label="Total Spend" value={`$${Math.round(s.totalSpend).toLocaleString()}`} accent="text-amber-600" />
|
||||
<KPI icon={MousePointerClick} label="Ad Clicks" value={s.totalClicks.toLocaleString()} />
|
||||
<KPI icon={Eye} label="Impressions" value={fmtLarge(s.totalImpressions)} />
|
||||
<KPI icon={TrendingUp} label="Platform Conv." value={s.platformConversions.toLocaleString()} accent="text-amber-600" />
|
||||
<KPI icon={GitBranch} label="Attribution Paths" value={s.attributionPaths.toLocaleString()} accent="text-emerald-600" />
|
||||
<KPI icon={Zap} label="Ad Click Events" value={s.adClickEvents.toLocaleString()} />
|
||||
<KPI icon={Users} label="Visitor Profiles" value={s.visitorProfiles.toLocaleString()} accent="text-violet-600" />
|
||||
<KPI icon={Home} label="Household Clusters" value={s.householdClusters.toLocaleString()} accent="text-violet-600" />
|
||||
<KPI icon={Plug} label="Connected Platforms" value={String(s.connectedPlatforms)} accent="text-emerald-600" />
|
||||
</div>
|
||||
|
||||
{data.topBrands.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-5 py-3.5 border-b border-slate-100 flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4 text-brand-600" />
|
||||
<h3 className="font-bold text-slate-800 text-sm">Top Brands by Attribution Paths</h3>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-[11px] font-semibold uppercase tracking-wider text-slate-400">
|
||||
<th className="px-5 py-2">Brand</th>
|
||||
<th className="px-5 py-2">Domain</th>
|
||||
<th className="px-5 py-2 text-right">Paths</th>
|
||||
<th className="px-5 py-2">Platforms</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{data.topBrands.map((b) => (
|
||||
<tr key={b.brandId} className="hover:bg-slate-50">
|
||||
<td className="px-5 py-2.5 font-medium text-slate-800">{b.name}</td>
|
||||
<td className="px-5 py-2.5 text-slate-500 text-xs">{b.domain}</td>
|
||||
<td className="px-5 py-2.5 text-right tabular-nums font-semibold text-slate-900">{b.attributionPaths}</td>
|
||||
<td className="px-5 py-2.5">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{b.integrations.length > 0 ? b.integrations.map((integ) => (
|
||||
<span key={integ.platform} className={`text-[10px] px-1.5 py-0.5 rounded ${
|
||||
integ.status === "connected" ? "bg-emerald-50 text-emerald-700" : "bg-slate-100 text-slate-500"
|
||||
}`}>
|
||||
{integ.platform.replace(/_/g, " ")}
|
||||
</span>
|
||||
)) : (
|
||||
<span className="text-[10px] text-slate-400">Site Tag only</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KPI({ icon: Icon, label, value, accent = "text-brand-600" }: {
|
||||
icon: typeof DollarSign; label: string; value: string; accent?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 px-4 py-3">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Icon className={`w-3.5 h-3.5 ${accent}`} />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-slate-400">{label}</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold text-slate-900 tabular-nums">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtLarge(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
@@ -0,0 +1,738 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* /admin/sessions/[sessionId] — Session Detail
|
||||
*
|
||||
* Full journey reconstruction for a single session: session metadata
|
||||
* card + chronological timeline of every TrackedEvent + SiteConversion
|
||||
* + repeat-interaction summary (phone numbers clicked multiple times
|
||||
* in the session — an important UX / operational signal).
|
||||
*
|
||||
* Requires brandId in the querystring to scope the lookup.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Footprints, ArrowLeft, Clock, Eye, Globe, Monitor, Smartphone,
|
||||
Tablet, FileCheck, CalendarCheck, Phone, MousePointerClick,
|
||||
ExternalLink, FileEdit, Zap, CheckCircle2, Play, Flag,
|
||||
AlertCircle, RefreshCw,
|
||||
// Added for the expanded timeline event-type coverage.
|
||||
Mail, Copy, MousePointer, ShoppingCart, CreditCard, MessageSquare,
|
||||
BarChart3, Download, FileText, Printer, Share2, Star, Users,
|
||||
ArrowLeftCircle, Route, BookOpen, Type, Layers, Search, Activity,
|
||||
AlertTriangle, FormInput, ArrowUp, Keyboard, FileX, Accessibility,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
PageHeader, Card, CardHeader, EmptyState, Skel,
|
||||
} from "@/components/admin";
|
||||
|
||||
interface TimelineEntry {
|
||||
kind: "event" | "conversion";
|
||||
id: string;
|
||||
type: string;
|
||||
canonicalType: string | null;
|
||||
timestamp: string;
|
||||
pageUrl: string;
|
||||
metadata: Record<string, unknown>;
|
||||
formFields?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
interface SessionDetail {
|
||||
session: {
|
||||
sessionId: string;
|
||||
internalId: string;
|
||||
brandId: string;
|
||||
startedAt: string;
|
||||
lastSeenAt: string;
|
||||
durationMs: number;
|
||||
pageCount: number;
|
||||
entryPage: string;
|
||||
referrer: string | null;
|
||||
source: string;
|
||||
medium: string | null;
|
||||
campaign: string | null;
|
||||
country: string | null;
|
||||
device: string | null;
|
||||
userAgent: string | null;
|
||||
browser?: string | null;
|
||||
os?: string | null;
|
||||
city?: string | null;
|
||||
region?: string | null;
|
||||
ipAddress?: string | null;
|
||||
};
|
||||
timeline: TimelineEntry[];
|
||||
aggregates: {
|
||||
eventCountByType: Record<string, number>;
|
||||
conversionCountByCanonical: Record<string, number>;
|
||||
interactionCountByCanonical: Record<string, number>;
|
||||
phoneClickCounts: Record<string, number>;
|
||||
uniquePhoneNumbers: number;
|
||||
totalEvents: number;
|
||||
totalConversions: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return "<1s";
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
const rs = s % 60;
|
||||
if (m < 60) return `${m}m ${rs}s`;
|
||||
const h = Math.floor(m / 60);
|
||||
const rm = m % 60;
|
||||
return `${h}h ${rm}m`;
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleTimeString(undefined, {
|
||||
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||
});
|
||||
} catch { return iso; }
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
|
||||
});
|
||||
} catch { return iso; }
|
||||
}
|
||||
|
||||
function extractPath(url: string): string {
|
||||
try { return new URL(url).pathname || url; } catch { return url; }
|
||||
}
|
||||
|
||||
// Colour palette for timeline chips. Keyed by semantic name so the
|
||||
// event-type table below can stay concise and every event with
|
||||
// the same semantic colour renders identically without drift.
|
||||
const TIMELINE_COLORS: Record<string, { color: string; bg: string; border: string }> = {
|
||||
blue: { color: "text-blue-700", bg: "bg-blue-50", border: "border-blue-300" },
|
||||
green: { color: "text-emerald-700", bg: "bg-emerald-50", border: "border-emerald-300" },
|
||||
purple: { color: "text-violet-700", bg: "bg-violet-50", border: "border-violet-300" },
|
||||
orange: { color: "text-orange-700", bg: "bg-orange-50", border: "border-orange-300" },
|
||||
teal: { color: "text-teal-700", bg: "bg-teal-50", border: "border-teal-300" },
|
||||
gray: { color: "text-slate-600", bg: "bg-slate-100", border: "border-slate-300" },
|
||||
slate: { color: "text-slate-700", bg: "bg-slate-50", border: "border-slate-200" },
|
||||
red: { color: "text-red-700", bg: "bg-red-50", border: "border-red-300" },
|
||||
yellow: { color: "text-yellow-700", bg: "bg-yellow-50", border: "border-yellow-300" },
|
||||
amber: { color: "text-amber-700", bg: "bg-amber-50", border: "border-amber-300" },
|
||||
cyan: { color: "text-cyan-700", bg: "bg-cyan-50", border: "border-cyan-300" },
|
||||
indigo: { color: "text-indigo-700", bg: "bg-indigo-50", border: "border-indigo-300" },
|
||||
};
|
||||
|
||||
type TimelineIconEntry = { Icon: typeof Phone; color: keyof typeof TIMELINE_COLORS; label: string };
|
||||
|
||||
// Single source of truth for every non-canonical event type
|
||||
// we know about. Canonical-conversion types are handled in
|
||||
// the switch at the top of entryIcon so they always win over
|
||||
// the raw-type lookup (e.g. a click_to_call whose canonical is
|
||||
// phone_call always renders as the phone-call chip, even though
|
||||
// the raw type is also in this table).
|
||||
const EVENT_TYPE_META: Record<string, TimelineIconEntry> = {
|
||||
// ── Core navigation / session ──
|
||||
page_view: { Icon: Eye, color: "gray", label: "Page View" },
|
||||
session_start: { Icon: Play, color: "green", label: "Session Start" },
|
||||
outbound_click: { Icon: ExternalLink, color: "cyan", label: "Outbound Click" },
|
||||
booking_step: { Icon: CheckCircle2, color: "teal", label: "Booking Step" },
|
||||
booking_widget_opened: { Icon: Zap, color: "orange", label: "Booking Widget Opened" },
|
||||
plugin_detected: { Icon: Zap, color: "indigo", label: "Plugin Detected" },
|
||||
form_iframe_present: { Icon: Zap, color: "indigo", label: "Form Iframe Present" },
|
||||
chat_opened: { Icon: MousePointerClick, color: "cyan", label: "Chat Opened" },
|
||||
chat_widget_detected: { Icon: MousePointerClick, color: "cyan", label: "Chat Widget Detected" },
|
||||
chat_message_sent: { Icon: MousePointerClick, color: "cyan", label: "Chat Message Sent" },
|
||||
form_intent: { Icon: MousePointerClick, color: "orange", label: "Form Intent" },
|
||||
// ── Email ──
|
||||
email_click: { Icon: Mail, color: "blue", label: "Email Click" },
|
||||
email_copied: { Icon: Copy, color: "blue", label: "Email Copied" },
|
||||
newsletter_signup: { Icon: Mail, color: "green", label: "Newsletter Signup" },
|
||||
// ── E-commerce ──
|
||||
product_view: { Icon: Eye, color: "purple", label: "Product View" },
|
||||
product_click: { Icon: MousePointer, color: "purple", label: "Product Click" },
|
||||
add_to_cart: { Icon: ShoppingCart, color: "orange", label: "Add to Cart" },
|
||||
cart_view: { Icon: ShoppingCart, color: "gray", label: "Cart View" },
|
||||
cart_update: { Icon: RefreshCw, color: "gray", label: "Cart Update" },
|
||||
cart_abandoned: { Icon: ShoppingCart, color: "red", label: "Cart Abandoned" },
|
||||
checkout_started: { Icon: CreditCard, color: "teal", label: "Checkout Started" },
|
||||
checkout_step: { Icon: CreditCard, color: "teal", label: "Checkout Step" },
|
||||
payment_method_selected: { Icon: CreditCard, color: "green", label: "Payment Selected" },
|
||||
purchase_completed: { Icon: CheckCircle2, color: "green", label: "Purchase Completed" },
|
||||
purchase: { Icon: CheckCircle2, color: "green", label: "Purchase" },
|
||||
// ── Phone / SMS ──
|
||||
phone_number_visible: { Icon: Phone, color: "gray", label: "Phone Visible" },
|
||||
sms_click: { Icon: MessageSquare, color: "blue", label: "SMS Click" },
|
||||
sms_submit: { Icon: MessageSquare, color: "blue", label: "SMS Submit" },
|
||||
call_tracking_detected: { Icon: Phone, color: "amber", label: "Call Tracking Detected" },
|
||||
// ── Video ──
|
||||
video_play: { Icon: Play, color: "purple", label: "Video Play" },
|
||||
video_progress: { Icon: Play, color: "purple", label: "Video Progress" },
|
||||
video_complete: { Icon: CheckCircle2, color: "green", label: "Video Complete" },
|
||||
video_engagement: { Icon: BarChart3, color: "purple", label: "Video Engagement" },
|
||||
// ── Files / docs ──
|
||||
file_download: { Icon: Download, color: "slate", label: "File Download" },
|
||||
pdf_opened: { Icon: FileText, color: "red", label: "PDF Opened" },
|
||||
pdf_time_spent: { Icon: Clock, color: "red", label: "PDF Time Spent" },
|
||||
page_printed: { Icon: Printer, color: "gray", label: "Page Printed" },
|
||||
// ── Social sharing / proof ──
|
||||
social_share: { Icon: Share2, color: "blue", label: "Social Share" },
|
||||
content_copied: { Icon: Copy, color: "gray", label: "Content Copied" },
|
||||
review_widget_visible: { Icon: Star, color: "yellow", label: "Review Widget Visible" },
|
||||
review_widget_clicked: { Icon: Star, color: "yellow", label: "Review Widget Clicked" },
|
||||
social_proof_visible: { Icon: Users, color: "green", label: "Social Proof Visible" },
|
||||
social_proof_attention: { Icon: Users, color: "green", label: "Social Proof Attention" },
|
||||
// ── Competitor ──
|
||||
competitor_visit: { Icon: ExternalLink, color: "red", label: "Competitor Visit" },
|
||||
competitor_referral: { Icon: ArrowLeftCircle, color: "amber", label: "From Competitor" },
|
||||
competitive_journey: { Icon: Route, color: "red", label: "Competitive Journey" },
|
||||
// ── Content consumption ──
|
||||
content_engagement: { Icon: BookOpen, color: "teal", label: "Content Engagement" },
|
||||
text_selected: { Icon: Type, color: "gray", label: "Text Selected" },
|
||||
tab_pattern: { Icon: Layers, color: "gray", label: "Tab Pattern" },
|
||||
site_search: { Icon: Search, color: "blue", label: "Site Search" },
|
||||
// ── Performance / errors ──
|
||||
web_vitals: { Icon: Activity, color: "green", label: "Web Vitals" },
|
||||
slow_interaction: { Icon: AlertTriangle, color: "amber", label: "Slow Interaction" },
|
||||
js_error: { Icon: AlertCircle, color: "red", label: "JS Error" },
|
||||
resource_error: { Icon: AlertCircle, color: "orange", label: "Resource Error" },
|
||||
// ── UX frustration ──
|
||||
rage_click: { Icon: MousePointer, color: "red", label: "Rage Click" },
|
||||
dead_click: { Icon: MousePointer, color: "orange", label: "Dead Click" },
|
||||
error_shown: { Icon: AlertTriangle, color: "red", label: "Error Shown" },
|
||||
form_abandoned: { Icon: FormInput, color: "amber", label: "Form Abandoned" },
|
||||
scroll_bounce: { Icon: ArrowUp, color: "red", label: "Scroll Bounce" },
|
||||
// ── Accessibility + navigation ──
|
||||
keyboard_navigation: { Icon: Keyboard, color: "blue", label: "Keyboard Navigation" },
|
||||
navigation_pattern: { Icon: ArrowLeft, color: "gray", label: "Navigation Pattern" },
|
||||
page_not_found: { Icon: FileX, color: "red", label: "404 Page" },
|
||||
accessibility_context: { Icon: Accessibility, color: "blue", label: "Accessibility Context" },
|
||||
// ── Scheduling ──
|
||||
calendar_add: { Icon: CalendarCheck, color: "blue", label: "Add to Calendar" },
|
||||
appointment_modify_intent: { Icon: RefreshCw, color: "amber", label: "Appointment Modify Intent" },
|
||||
availability_check: { Icon: CalendarCheck, color: "gray", label: "Availability Check" },
|
||||
waitlist_signup: { Icon: Clock, color: "amber", label: "Waitlist Signup" },
|
||||
// ── Cross-session ──
|
||||
return_visit: { Icon: ArrowLeftCircle, color: "teal", label: "Return Visit" },
|
||||
conversion_path_complete: { Icon: Route, color: "green", label: "Conversion Path" },
|
||||
multi_session_conversion: { Icon: Route, color: "green", label: "Multi-Session Conversion" },
|
||||
};
|
||||
|
||||
// Timeline entry → icon + colour + label. Canonical-conversion
|
||||
// types win over the raw type lookup so a click_to_call whose
|
||||
// server-side canonical is phone_call renders with the phone-call
|
||||
// chip semantics even when the raw type is also in the table.
|
||||
function entryIcon(entry: TimelineEntry): { Icon: typeof Phone; color: string; bg: string; border: string; label: string } {
|
||||
const canon = entry.canonicalType;
|
||||
const t = entry.type;
|
||||
if (canon === "appointment_booked") {
|
||||
return { Icon: CalendarCheck, color: "text-emerald-700", bg: "bg-emerald-50", border: "border-emerald-300", label: "Appointment Booked" };
|
||||
}
|
||||
if (canon === "form_submitted") {
|
||||
return { Icon: FileCheck, color: "text-blue-700", bg: "bg-blue-50", border: "border-blue-300", label: "Form Submitted" };
|
||||
}
|
||||
if (canon === "phone_call") {
|
||||
return { Icon: Phone, color: "text-violet-700", bg: "bg-violet-50", border: "border-violet-300", label: "Phone Clicked" };
|
||||
}
|
||||
if (canon === "email_contact") {
|
||||
return { Icon: Mail, color: "text-blue-700", bg: "bg-blue-50", border: "border-blue-300", label: "Email Contact" };
|
||||
}
|
||||
if (canon === "sms_contact") {
|
||||
return { Icon: MessageSquare, color: "text-violet-700", bg: "bg-violet-50", border: "border-violet-300", label: "SMS Contact" };
|
||||
}
|
||||
if (canon === "appointment_attempted") {
|
||||
return { Icon: CalendarCheck, color: "text-teal-700", bg: "bg-teal-50", border: "border-teal-300", label: "Booking Submitted" };
|
||||
}
|
||||
if (canon === "appointment_intent") {
|
||||
return { Icon: MousePointerClick, color: "text-orange-700", bg: "bg-orange-50", border: "border-orange-300", label: "Appointment Intent" };
|
||||
}
|
||||
if (canon === "form_started") {
|
||||
return { Icon: FileEdit, color: "text-slate-600", bg: "bg-slate-100", border: "border-slate-300", label: "Form Started" };
|
||||
}
|
||||
// Lookup by raw type — the EVENT_TYPE_META table covers every
|
||||
// event public/t.js emits today plus legacy raw conversion names.
|
||||
const meta = EVENT_TYPE_META[t];
|
||||
if (meta) {
|
||||
const palette = TIMELINE_COLORS[meta.color] ?? TIMELINE_COLORS.gray;
|
||||
return { Icon: meta.Icon, color: palette.color, bg: palette.bg, border: palette.border, label: meta.label };
|
||||
}
|
||||
// Unknown event type — humanise the raw string so the fallback
|
||||
// chip at least reads better than "form_foobar_baz".
|
||||
return {
|
||||
Icon: Flag,
|
||||
color: "text-slate-500",
|
||||
bg: "bg-slate-50",
|
||||
border: "border-slate-200",
|
||||
label: t.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
};
|
||||
}
|
||||
|
||||
// Human-readable description for a timeline entry. Pulls from
|
||||
// metadata when useful (CTA text, step number, phone number).
|
||||
function entryDescription(entry: TimelineEntry): string {
|
||||
const m = entry.metadata ?? {};
|
||||
const path = extractPath(entry.pageUrl);
|
||||
|
||||
if (entry.type === "session_start") {
|
||||
const src = typeof m.utm_source === "string" ? m.utm_source : "direct";
|
||||
return `${path} (from ${src})`;
|
||||
}
|
||||
if (entry.type === "page_view") {
|
||||
const title = typeof m.title === "string" ? m.title.slice(0, 80) : "";
|
||||
return title ? `${path} — ${title}` : path;
|
||||
}
|
||||
if (entry.type === "click_to_call" || entry.canonicalType === "phone_call") {
|
||||
const phone = typeof m.phone_number === "string" ? m.phone_number
|
||||
: typeof m.phone_normalized === "string" ? m.phone_normalized
|
||||
: null;
|
||||
return phone ? `${phone} · ${path}` : path;
|
||||
}
|
||||
if (entry.type === "outbound_click") {
|
||||
const domain = typeof m.link_domain === "string" ? m.link_domain : null;
|
||||
return domain ? `→ ${domain} from ${path}` : path;
|
||||
}
|
||||
if (entry.type === "form_start") {
|
||||
const field = typeof m.field_name === "string" ? m.field_name : "field";
|
||||
return `focused "${field}" on ${path}`;
|
||||
}
|
||||
if (entry.type === "booking_step") {
|
||||
const step = typeof m.step === "string" ? m.step : "?";
|
||||
const source = typeof m.source === "string" ? m.source : "plugin";
|
||||
const stepName = typeof m.stepName === "string" ? ` — ${m.stepName}` : "";
|
||||
return `${source} step ${step}${stepName} · ${path}`;
|
||||
}
|
||||
if (entry.type === "booking_widget_opened" || entry.type === "plugin_detected") {
|
||||
// Handle new consolidated payload { plugins: [...] } and legacy flat shape.
|
||||
let source: string;
|
||||
if (typeof m.source === "string") {
|
||||
source = m.source;
|
||||
} else if (Array.isArray(m.plugins) && m.plugins.length > 0) {
|
||||
const first = m.plugins[0] as Record<string, unknown>;
|
||||
source = typeof first.plugin === "string" ? first.plugin : "plugin";
|
||||
} else {
|
||||
source = typeof m.plugin === "string" ? m.plugin : "plugin";
|
||||
}
|
||||
return `${source} · ${path}`;
|
||||
}
|
||||
if (entry.type === "appointment_intent" || entry.type === "form_intent") {
|
||||
const cta = typeof m.cta_text === "string" ? m.cta_text : "CTA";
|
||||
return `"${cta}" on ${path}`;
|
||||
}
|
||||
if (entry.kind === "conversion") {
|
||||
const source = typeof m.formProvider === "string" ? m.formProvider
|
||||
: (entry.formFields && typeof entry.formFields._source === "string" ? entry.formFields._source : null);
|
||||
const provider = source ? ` via ${source}` : "";
|
||||
return `${path}${provider}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function DeviceIcon({ device }: { device: string | null }) {
|
||||
if (device === "mobile") return <Smartphone className="w-3 h-3 text-slate-400" />;
|
||||
if (device === "tablet") return <Tablet className="w-3 h-3 text-slate-400" />;
|
||||
return <Monitor className="w-3 h-3 text-slate-400" />;
|
||||
}
|
||||
|
||||
// ─── Page ──────────────────────────────────────────────────────────
|
||||
|
||||
export default function SessionDetailPage({
|
||||
params, searchParams,
|
||||
}: {
|
||||
params: { sessionId: string };
|
||||
searchParams: { brandId?: string };
|
||||
}) {
|
||||
// Next.js 14 App Router — params / searchParams are plain objects,
|
||||
// not Promises. (The React `use()` unwrap is a Next 15 pattern and
|
||||
// blew up at render with a React #438 hook-order error here.)
|
||||
const sessionId = params.sessionId;
|
||||
const brandId = searchParams.brandId;
|
||||
const [data, setData] = useState<SessionDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!brandId) { setLoading(false); return; }
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const url = `/api/admin/sessions/${encodeURIComponent(sessionId)}?brandId=${encodeURIComponent(brandId)}`;
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) {
|
||||
// Surface the server-provided error if there is one; otherwise
|
||||
// fall back to a plain status-code message.
|
||||
let msg = `Request failed (${r.status})`;
|
||||
try {
|
||||
const body = await r.json();
|
||||
if (body && typeof body.error === "string") msg = body.error;
|
||||
} catch { /* non-JSON body — keep the status message */ }
|
||||
setError(msg);
|
||||
setData(null);
|
||||
return;
|
||||
}
|
||||
const d: SessionDetail = await r.json();
|
||||
if (d && typeof d.error === "string") {
|
||||
setError(d.error);
|
||||
setData(null);
|
||||
return;
|
||||
}
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Network error");
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [sessionId, brandId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
if (!brandId) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Session detail"
|
||||
subtitle="brandId query param required"
|
||||
icon={Footprints}
|
||||
actions={
|
||||
<Link href="/admin/sessions" className="inline-flex items-center gap-1 text-[11px] text-slate-600">
|
||||
<ArrowLeft className="w-3 h-3" />Back to sessions
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<Card>
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="Missing brandId"
|
||||
description="Open this page via a session row in the Session Explorer — the URL needs ?brandId=<cuid>."
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <SessionDetailSkeleton brandId={brandId} />;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Session detail"
|
||||
subtitle={error ?? "Not found"}
|
||||
icon={Footprints}
|
||||
actions={
|
||||
<Link href={`/admin/sessions?brandId=${encodeURIComponent(brandId)}`} className="inline-flex items-center gap-1 text-[11px] text-slate-600">
|
||||
<ArrowLeft className="w-3 h-3" />Back to sessions
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<Card>
|
||||
<div className="px-5 py-8 flex flex-col items-center text-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-rose-50 text-rose-600 flex items-center justify-center">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">Couldn't load session</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5 max-w-md">
|
||||
{error ?? "The session was not found for this brand."}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={load}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-semibold rounded-md bg-slate-900 text-white hover:bg-slate-700"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" /> Retry
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { session, timeline, aggregates } = data;
|
||||
const phoneClickCounts = aggregates?.phoneClickCounts ?? {};
|
||||
const eventCountByType = aggregates?.eventCountByType ?? {};
|
||||
const conversionCountByCanonical = aggregates?.conversionCountByCanonical ?? {};
|
||||
const repeatClicks = Object.entries(phoneClickCounts).filter(([, count]) => count > 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Session detail"
|
||||
subtitle={session.sessionId}
|
||||
icon={Footprints}
|
||||
actions={
|
||||
<Link
|
||||
href={`/admin/sessions?brandId=${encodeURIComponent(brandId)}`}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-[11px] font-semibold rounded-md bg-white border border-slate-200 text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
<ArrowLeft className="w-3 h-3" />Back to sessions
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* ── Session header card ── */}
|
||||
<Card>
|
||||
<div className="p-5 grid grid-cols-2 md:grid-cols-4 gap-4 text-xs">
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500 mb-1">Duration</p>
|
||||
<p className="text-lg font-bold text-slate-900 tabular-nums inline-flex items-center gap-1">
|
||||
<Clock className="w-3.5 h-3.5 text-slate-400" />{formatDuration(session.durationMs)}
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5">{formatDate(session.startedAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500 mb-1">Pages viewed</p>
|
||||
<p className="text-lg font-bold text-slate-900 tabular-nums inline-flex items-center gap-1">
|
||||
<Eye className="w-3.5 h-3.5 text-slate-400" />{session.pageCount}
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5 truncate max-w-[240px]">entry: {extractPath(session.entryPage)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500 mb-1">Source</p>
|
||||
<p className="text-lg font-bold text-slate-900 inline-flex items-center gap-1">{session.source}</p>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5">
|
||||
{session.medium ?? "—"}{session.campaign ? ` · ${session.campaign}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500 mb-1">Device / location</p>
|
||||
<p className="text-lg font-bold text-slate-900 inline-flex items-center gap-1 capitalize">
|
||||
<DeviceIcon device={session.device} />
|
||||
{session.device ?? "Unknown"}
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5 inline-flex items-center gap-1">
|
||||
<Globe className="w-2.5 h-2.5" />
|
||||
{[session.city, session.region, session.country].filter(Boolean).join(", ") || "unknown"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Extended device + location detail — browser / OS / IP.
|
||||
Only rendered when at least one value is present so
|
||||
historical sessions (captured before the columns existed)
|
||||
collapse instead of showing an empty card. */}
|
||||
{(session.browser || session.os || session.ipAddress || session.userAgent) && (
|
||||
<div className="border-t border-slate-100 px-5 py-4 grid grid-cols-2 md:grid-cols-4 gap-4 text-[11px]">
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mb-0.5">Browser</p>
|
||||
<p className="text-slate-800 font-medium">{session.browser ?? "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mb-0.5">OS</p>
|
||||
<p className="text-slate-800 font-medium">{session.os ?? "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mb-0.5">IP (admin)</p>
|
||||
<p className="text-slate-800 font-mono text-[10px] truncate" title={session.ipAddress ?? undefined}>
|
||||
{session.ipAddress ? (session.ipAddress.length > 18 ? session.ipAddress.slice(0, 18) + "…" : session.ipAddress) : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mb-0.5">User-Agent</p>
|
||||
<p className="text-slate-800 font-mono text-[10px] truncate" title={session.userAgent ?? undefined}>
|
||||
{session.userAgent ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ── Interaction summary cards ── */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<SummaryCard
|
||||
icon={MousePointerClick} color="text-slate-600"
|
||||
label="Total events"
|
||||
value={aggregates?.totalEvents ?? 0}
|
||||
sub={`${Object.keys(eventCountByType).length} types`}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={CheckCircle2} color="text-emerald-600"
|
||||
label="Conversions"
|
||||
value={aggregates?.totalConversions ?? 0}
|
||||
sub={(aggregates?.totalConversions ?? 0) === 0 ? "no conversions" : "tier-1 + tier-2"}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={Phone} color="text-violet-600"
|
||||
label="Phone clicks"
|
||||
value={Object.values(phoneClickCounts).reduce((a, b) => a + b, 0)}
|
||||
sub={`${aggregates?.uniquePhoneNumbers ?? 0} unique number${(aggregates?.uniquePhoneNumbers ?? 0) === 1 ? "" : "s"}`}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={FileEdit} color="text-blue-600"
|
||||
label="Form interactions"
|
||||
value={(eventCountByType.form_start ?? 0) + (conversionCountByCanonical.form_submitted ?? 0)}
|
||||
sub={`${eventCountByType.form_start ?? 0} start · ${conversionCountByCanonical.form_submitted ?? 0} submit`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Repeat phone clicks — UX signal ── */}
|
||||
{repeatClicks.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Repeat interactions"
|
||||
right={<span className="text-[10px] text-slate-400">same action, multiple times</span>}
|
||||
/>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{(repeatClicks ?? []).map(([phone, count]) => (
|
||||
<div key={phone} className="px-5 py-3 flex items-center gap-3">
|
||||
<Phone className="w-4 h-4 text-violet-500 shrink-0" />
|
||||
<p className="font-mono text-sm font-semibold text-slate-900">{phone}</p>
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-50 text-amber-700 border border-amber-200">
|
||||
Clicked {count} times
|
||||
</span>
|
||||
<p className="text-[10px] text-slate-400 ml-auto">
|
||||
{count} clicks in one session may indicate a broken tel: link, voicemail retries, or number comparison.
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Timeline ── */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Interaction timeline"
|
||||
right={<span className="text-[10px] text-slate-400">{(timeline ?? []).length} events · chronological</span>}
|
||||
/>
|
||||
{(timeline ?? []).length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Footprints}
|
||||
title="No events in this session"
|
||||
description="The session row exists but no events were captured — likely a bot hit or an ad-blocker."
|
||||
/>
|
||||
) : (
|
||||
<ol className="relative">
|
||||
{(timeline ?? []).map((entry, i) => {
|
||||
const { Icon, color, bg, border, label } = entryIcon(entry);
|
||||
const isLast = i === (timeline ?? []).length - 1;
|
||||
return (
|
||||
<li key={entry.id} className="relative px-5 py-2.5 flex gap-3">
|
||||
{/* Connecting line */}
|
||||
{!isLast && (
|
||||
<span className="absolute left-[34px] top-8 bottom-[-0.5rem] w-px bg-slate-200" />
|
||||
)}
|
||||
{/* Icon dot */}
|
||||
<span className={`relative z-10 w-6 h-6 rounded-full ${bg} ${border} border-2 flex items-center justify-center shrink-0`}>
|
||||
<Icon className={`w-3 h-3 ${color}`} />
|
||||
</span>
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2 flex-wrap">
|
||||
<span className="text-[10px] font-mono text-slate-400 tabular-nums shrink-0">
|
||||
{formatTime(entry.timestamp)}
|
||||
</span>
|
||||
<span className={`inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide px-1.5 py-0.5 rounded border ${bg} ${color} ${border}`}>
|
||||
{label}
|
||||
</span>
|
||||
{entry.kind === "conversion" && (
|
||||
<span className="text-[9px] font-bold uppercase tracking-widest text-emerald-700">conversion</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-700 mt-0.5 truncate">{entryDescription(entry)}</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
<div className="px-5 py-3 border-t border-slate-100 bg-slate-50/40 text-[10px] text-slate-500">
|
||||
Session ended at {formatDate(session.lastSeenAt)} · {formatDuration(session.durationMs)} total
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-page skeleton that mirrors the loaded layout: header card with
|
||||
* 4 metric cells, a row of 4 summary cards, and a timeline with a few
|
||||
* placeholder rows. Keeps the visual jump between states small.
|
||||
*/
|
||||
function SessionDetailSkeleton({ brandId }: { brandId: string }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Session detail"
|
||||
subtitle="Loading…"
|
||||
icon={Footprints}
|
||||
actions={
|
||||
<Link
|
||||
href={`/admin/sessions?brandId=${encodeURIComponent(brandId)}`}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-[11px] font-semibold rounded-md bg-white border border-slate-200 text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
<ArrowLeft className="w-3 h-3" />Back to sessions
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<Card>
|
||||
<div className="p-5 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skel className="h-2.5 w-20 bg-slate-100 rounded" />
|
||||
<Skel className="h-6 w-24 bg-slate-200 rounded" />
|
||||
<Skel className="h-2.5 w-32 bg-slate-100 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<div className="p-4 space-y-2">
|
||||
<Skel className="h-2.5 w-20 bg-slate-100 rounded" />
|
||||
<Skel className="h-7 w-16 bg-slate-200 rounded" />
|
||||
<Skel className="h-2.5 w-28 bg-slate-100 rounded" />
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Card>
|
||||
<div className="px-5 py-4 border-b border-slate-100">
|
||||
<Skel className="h-3 w-40 bg-slate-200 rounded" />
|
||||
</div>
|
||||
<ol className="relative">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<li key={i} className="px-5 py-2.5 flex gap-3 items-center">
|
||||
<Skel className="h-6 w-6 rounded-full bg-slate-100" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skel className="h-2.5 w-48 bg-slate-200 rounded" />
|
||||
<Skel className="h-2.5 w-64 bg-slate-100 rounded" />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({
|
||||
icon: Icon, label, value, sub, color,
|
||||
}: {
|
||||
icon: typeof Phone;
|
||||
label: string;
|
||||
value: number;
|
||||
sub: string;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">{label}</p>
|
||||
<Icon className={`w-3.5 h-3.5 ${color}`} />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-slate-900 tabular-nums">{value.toLocaleString()}</p>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5">{sub}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* /admin/sessions — Session Explorer
|
||||
*
|
||||
* Superadmin view for drilling into individual visitor journeys.
|
||||
* Shared brand selector + range pills + filter chips above a
|
||||
* sortable session table. Clicking a row navigates to
|
||||
* /admin/sessions/[sessionId]?brandId=X for the full timeline.
|
||||
*
|
||||
* Designed to answer: "which sessions converted, and what did the
|
||||
* user actually do before they converted?" — the kind of question
|
||||
* that's been a raw-SQL panel until now.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Footprints, Phone, FileCheck, CalendarCheck, MousePointerClick,
|
||||
Clock, Eye, Loader2, ArrowRight, Users, Search,
|
||||
Monitor, Smartphone, Tablet, Globe,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
PageHeader, SectionHeader, Card, CardHeader, EmptyState,
|
||||
TableSkeleton,
|
||||
} from "@/components/admin";
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────
|
||||
|
||||
interface BrandOption { id: string; name: string }
|
||||
|
||||
interface SessionRow {
|
||||
sessionId: string;
|
||||
internalId: string;
|
||||
startedAt: string;
|
||||
lastSeenAt: string;
|
||||
durationMs: number;
|
||||
pageCount: number;
|
||||
eventCount: number;
|
||||
conversionCount: number;
|
||||
topConversionType: string | null;
|
||||
source: string;
|
||||
medium: string | null;
|
||||
campaign: string | null;
|
||||
referrer: string | null;
|
||||
country: string | null;
|
||||
device: string | null;
|
||||
browser?: string | null;
|
||||
os?: string | null;
|
||||
city?: string | null;
|
||||
region?: string | null;
|
||||
}
|
||||
|
||||
interface SessionsResponse {
|
||||
sessions: SessionRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
hasMore: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type Filter = "all" | "conversions" | "phone" | "form" | "booking";
|
||||
type Sort = "recent" | "interactions" | "duration";
|
||||
type Range = "1d" | "7d" | "30d" | "90d";
|
||||
|
||||
const FILTER_CHIPS: Array<{ id: Filter; label: string; icon: typeof Phone }> = [
|
||||
{ id: "all", label: "All", icon: Users },
|
||||
{ id: "conversions", label: "Conversions", icon: MousePointerClick },
|
||||
{ id: "phone", label: "Phone calls", icon: Phone },
|
||||
{ id: "form", label: "Form submits", icon: FileCheck },
|
||||
{ id: "booking", label: "Bookings", icon: CalendarCheck },
|
||||
];
|
||||
|
||||
const RANGES: Array<{ id: Range; label: string }> = [
|
||||
{ id: "1d", label: "Today" },
|
||||
{ id: "7d", label: "7d" },
|
||||
{ id: "30d", label: "30d" },
|
||||
{ id: "90d", label: "90d" },
|
||||
];
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return "<1s";
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
const rs = s % 60;
|
||||
if (m < 60) return `${m}m ${rs}s`;
|
||||
const h = Math.floor(m / 60);
|
||||
const rm = m % 60;
|
||||
return `${h}h ${rm}m`;
|
||||
}
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
try {
|
||||
const delta = Date.now() - new Date(iso).getTime();
|
||||
const s = Math.floor(delta / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
return `${d}d ago`;
|
||||
} catch { return ""; }
|
||||
}
|
||||
|
||||
// Canonical type → pill colour.
|
||||
function conversionPillColor(canonical: string | null): string {
|
||||
switch (canonical) {
|
||||
// Tier 1 — hard conversions
|
||||
case "appointment_booked": return "bg-emerald-50 text-emerald-700 border-emerald-200";
|
||||
case "form_submitted": return "bg-blue-50 text-blue-700 border-blue-200";
|
||||
case "phone_call": return "bg-violet-50 text-violet-700 border-violet-200";
|
||||
case "email_contact": return "bg-blue-50 text-blue-700 border-blue-200";
|
||||
case "sms_contact": return "bg-violet-50 text-violet-700 border-violet-200";
|
||||
case "purchase": return "bg-amber-50 text-amber-700 border-amber-200";
|
||||
// Tier 2 — soft / engagement
|
||||
case "appointment_attempted": return "bg-teal-50 text-teal-700 border-teal-200";
|
||||
case "form_started": return "bg-slate-100 text-slate-600 border-slate-200";
|
||||
case "appointment_intent": return "bg-orange-50 text-orange-700 border-orange-200";
|
||||
case "chat_initiated": return "bg-cyan-50 text-cyan-700 border-cyan-200";
|
||||
case "newsletter_signup": return "bg-cyan-50 text-cyan-700 border-cyan-200";
|
||||
case "add_to_cart": return "bg-orange-50 text-orange-700 border-orange-200";
|
||||
case "checkout_started": return "bg-teal-50 text-teal-700 border-teal-200";
|
||||
case "file_download": return "bg-slate-50 text-slate-700 border-slate-200";
|
||||
case "waitlist_signup": return "bg-amber-50 text-amber-700 border-amber-200";
|
||||
default: return "bg-slate-100 text-slate-600 border-slate-200";
|
||||
}
|
||||
}
|
||||
|
||||
// Device class → lucide icon. Falls back to Monitor so rows with
|
||||
// an unknown / missing device value still render a consistent chip
|
||||
// next to the country code rather than a "?" placeholder.
|
||||
function DeviceIcon({ device }: { device: string | null | undefined }) {
|
||||
const d = (device ?? "").toLowerCase();
|
||||
if (d === "mobile") return <Smartphone className="w-3 h-3 text-slate-500" />;
|
||||
if (d === "tablet") return <Tablet className="w-3 h-3 text-slate-500" />;
|
||||
return <Monitor className="w-3 h-3 text-slate-500" />;
|
||||
}
|
||||
|
||||
function deviceLabel(device: string | null | undefined): string {
|
||||
const d = (device ?? "").toLowerCase();
|
||||
if (d === "mobile") return "Mobile";
|
||||
if (d === "tablet") return "Tablet";
|
||||
if (d === "desktop") return "Desktop";
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
function conversionPillLabel(canonical: string | null): string {
|
||||
switch (canonical) {
|
||||
// Tier 1 — hard conversions
|
||||
case "appointment_booked": return "Appointment";
|
||||
case "form_submitted": return "Form";
|
||||
case "phone_call": return "Phone";
|
||||
case "email_contact": return "Email";
|
||||
case "sms_contact": return "SMS";
|
||||
case "purchase": return "Purchase";
|
||||
// Tier 2 — soft / engagement
|
||||
case "appointment_attempted": return "Booking attempt";
|
||||
case "form_started": return "Form started";
|
||||
case "appointment_intent": return "Intent";
|
||||
case "chat_initiated": return "Chat";
|
||||
case "newsletter_signup": return "Newsletter";
|
||||
case "add_to_cart": return "Add to cart";
|
||||
case "checkout_started": return "Checkout";
|
||||
case "file_download": return "Download";
|
||||
case "waitlist_signup": return "Waitlist";
|
||||
default: return canonical ?? "—";
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Page ──────────────────────────────────────────────────────────
|
||||
|
||||
export default function SessionExplorerPage() {
|
||||
const [brands, setBrands] = useState<BrandOption[]>([]);
|
||||
const [brandId, setBrandId] = useState<string>("");
|
||||
const [filter, setFilter] = useState<Filter>("conversions");
|
||||
const [range, setRange] = useState<Range>("30d");
|
||||
const [sort, setSort] = useState<Sort>("recent");
|
||||
const [page, setPage] = useState(1);
|
||||
const [query, setQuery] = useState("");
|
||||
const [feed, setFeed] = useState<SessionsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Mount-once: brand list.
|
||||
useEffect(() => {
|
||||
fetch("/api/admin/brands?limit=500")
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((d) => {
|
||||
if (Array.isArray(d?.brands)) {
|
||||
const list: BrandOption[] = d.brands.map((b: { id: string; name: string }) => ({ id: b.id, name: b.name }));
|
||||
setBrands(list);
|
||||
if (!brandId && list.length > 0) setBrandId(list[0].id);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const fetchSessions = useCallback(() => {
|
||||
if (!brandId) return;
|
||||
const params = new URLSearchParams({
|
||||
brandId, filter, range, sort,
|
||||
page: String(page), limit: "25",
|
||||
});
|
||||
setLoading(true);
|
||||
fetch(`/api/admin/sessions?${params.toString()}`)
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((d: SessionsResponse | null) => setFeed(d))
|
||||
.finally(() => setLoading(false));
|
||||
}, [brandId, filter, range, sort, page]);
|
||||
|
||||
useEffect(() => { fetchSessions(); }, [fetchSessions]);
|
||||
useEffect(() => { setPage(1); }, [brandId, filter, range, sort]);
|
||||
|
||||
const filteredSessions = useMemo(() => {
|
||||
if (!feed?.sessions) return [];
|
||||
if (!query.trim()) return feed.sessions;
|
||||
const q = query.toLowerCase();
|
||||
return feed.sessions.filter((s) =>
|
||||
s.sessionId.toLowerCase().includes(q)
|
||||
|| (s.source ?? "").toLowerCase().includes(q)
|
||||
|| (s.country ?? "").toLowerCase().includes(q)
|
||||
|| (s.referrer ?? "").toLowerCase().includes(q));
|
||||
}, [feed, query]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Session Explorer"
|
||||
subtitle="Individual visitor journeys with full event + conversion timelines"
|
||||
icon={Footprints}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={brandId}
|
||||
onChange={(e) => setBrandId(e.target.value)}
|
||||
className="px-3 py-1.5 text-xs bg-white border border-slate-200 rounded-lg min-w-[220px]"
|
||||
>
|
||||
{brands.length === 0 && <option value="">Loading brands…</option>}
|
||||
{brands.map((b) => (<option key={b.id} value={b.id}>{b.name}</option>))}
|
||||
</select>
|
||||
<div className="inline-flex bg-slate-100 rounded-lg p-0.5">
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => setRange(r.id)}
|
||||
className={`px-2.5 py-1 text-[11px] font-semibold rounded-md transition-colors ${
|
||||
range === r.id ? "bg-white text-slate-900 shadow-sm" : "text-slate-500 hover:text-slate-700"
|
||||
}`}
|
||||
>{r.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Filter chips + sort + search */}
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="Sessions"
|
||||
right={
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as Sort)}
|
||||
className="px-3 py-1.5 text-[11px] bg-white border border-slate-200 rounded-lg"
|
||||
>
|
||||
<option value="recent">Most recent</option>
|
||||
<option value="interactions">Most interactions</option>
|
||||
<option value="duration">Longest duration</option>
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-wrap mb-3">
|
||||
{FILTER_CHIPS.map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setFilter(id)}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-[11px] font-semibold rounded-md border transition-colors ${
|
||||
filter === id
|
||||
? "bg-slate-900 text-white border-slate-900"
|
||||
: "bg-white text-slate-600 border-slate-200 hover:bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3 h-3" />{label}
|
||||
</button>
|
||||
))}
|
||||
<div className="relative ml-auto">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-400" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Filter by sessionId, source, country…"
|
||||
className="pl-7 pr-3 py-1.5 text-[11px] bg-white border border-slate-200 rounded-lg min-w-[260px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Session list"
|
||||
right={feed && <span className="text-[10px] text-slate-400">{feed.total.toLocaleString()} total</span>}
|
||||
/>
|
||||
{loading && !feed ? (
|
||||
<TableSkeleton rows={6} />
|
||||
) : (!feed || filteredSessions.length === 0) ? (
|
||||
<EmptyState
|
||||
icon={Footprints}
|
||||
title="No sessions found"
|
||||
description={filter === "all"
|
||||
? "No TrackedSession rows in the selected window for this brand."
|
||||
: `No sessions matching filter \"${filter}\" in the selected window.`}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full text-xs">
|
||||
<thead className="text-[10px] font-semibold uppercase tracking-widest text-slate-400 border-b border-slate-100 bg-slate-50/40">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2">Session</th>
|
||||
<th className="text-left px-4 py-2">Source</th>
|
||||
<th className="text-right px-4 py-2">Duration</th>
|
||||
<th className="text-right px-4 py-2">Pages</th>
|
||||
<th className="text-right px-4 py-2">Events</th>
|
||||
<th className="text-right px-4 py-2">Conversions</th>
|
||||
<th className="text-right px-4 py-2">When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{filteredSessions.map((s) => (
|
||||
<tr key={s.internalId} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="px-4 py-2">
|
||||
<Link
|
||||
href={`/admin/sessions/${encodeURIComponent(s.sessionId)}?brandId=${encodeURIComponent(brandId)}`}
|
||||
className="inline-flex items-center gap-1.5 font-mono text-[11px] text-slate-700 hover:text-slate-900"
|
||||
>
|
||||
<span className="font-semibold">{s.sessionId.slice(0, 12)}</span>…
|
||||
<ArrowRight className="w-3 h-3 text-slate-400" />
|
||||
</Link>
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] text-slate-500 mt-0.5">
|
||||
<DeviceIcon device={s.device} />
|
||||
<span>{deviceLabel(s.device)}</span>
|
||||
{s.country && (
|
||||
<>
|
||||
<span className="text-slate-300">·</span>
|
||||
<Globe className="w-2.5 h-2.5 text-slate-400" />
|
||||
<span className="font-mono">{s.country}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-slate-700">
|
||||
{s.source}
|
||||
</span>
|
||||
{s.medium && <p className="text-[10px] text-slate-400 mt-0.5">{s.medium}</p>}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-600">
|
||||
<span className="inline-flex items-center gap-1 justify-end">
|
||||
<Clock className="w-3 h-3 text-slate-400" />{formatDuration(s.durationMs)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-600">
|
||||
<span className="inline-flex items-center gap-1 justify-end">
|
||||
<Eye className="w-3 h-3 text-slate-400" />{s.pageCount}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-600">{s.eventCount.toLocaleString()}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
{s.conversionCount > 0 ? (
|
||||
<span className={`inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide px-1.5 py-0.5 rounded border ${conversionPillColor(s.topConversionType)}`}>
|
||||
{s.conversionCount} · {conversionPillLabel(s.topConversionType)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right text-[10px] text-slate-500">
|
||||
{formatRelative(s.startedAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{feed.hasMore && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-100 bg-slate-50/40">
|
||||
<p className="text-[10px] text-slate-500">Page {feed.page}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="px-3 py-1 text-[11px] bg-white border border-slate-200 rounded-md disabled:opacity-50"
|
||||
>Prev</button>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="px-3 py-1 text-[11px] bg-slate-900 text-white rounded-md inline-flex items-center gap-1"
|
||||
>
|
||||
Next {loading && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Admin → Signal Overview
|
||||
* ───────────────────────
|
||||
* Platform-wide rollup of SiteEvent (the generic UX / intelligence
|
||||
* signal store). Headline counts by signal type + top 25 brands by
|
||||
* volume. Window selector (1d / 7d / 30d / 90d).
|
||||
*
|
||||
* Error handling: infinite-spinner bug fix. Previously the page
|
||||
* gated its render on `loading || !data` so a failing fetch (API
|
||||
* 500, timeout) sent loading → false but left data null, leaving
|
||||
* the spinner forever with no error message. Now:
|
||||
* • an `error` state carries the failure message
|
||||
* • a 10s AbortController prevents hangs when the API takes too
|
||||
* long (common symptom: the SiteEvent groupBy queries scanning
|
||||
* the full table instead of using an index)
|
||||
* • the loading check renders an error card when error is set
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Radio, Loader2, AlertTriangle, RefreshCw } from "lucide-react";
|
||||
|
||||
interface Payload {
|
||||
windowDays: number;
|
||||
totalCount: number;
|
||||
byType: Array<{ type: string; count: number }>;
|
||||
topBrands: Array<{ brandId: string; brandName: string | null; brandDomain: string | null; count: number }>;
|
||||
}
|
||||
|
||||
const SIGNAL_LABEL: Record<string, string> = {
|
||||
rage_click: "Rage clicks",
|
||||
dead_click: "Dead clicks",
|
||||
hesitation: "Hesitation",
|
||||
exit_intent: "Exit intent",
|
||||
js_error: "JS errors",
|
||||
outbound_click: "Outbound clicks",
|
||||
form_start: "Form starts",
|
||||
search_query: "On-site searches",
|
||||
copy_event: "Copy events",
|
||||
slow_resource: "Slow resources",
|
||||
third_party_impact: "Third-party impact",
|
||||
seo_change: "SEO changes",
|
||||
};
|
||||
|
||||
export default function AdminSignalsPage() {
|
||||
const [days, setDays] = useState(30);
|
||||
const [data, setData] = useState<Payload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// 10-second cap so a hung API doesn't leave the page in a
|
||||
// perpetual spinner. The API itself also enforces maxDuration=30
|
||||
// server-side; the client-side 10s aborts earlier to keep the
|
||||
// UI responsive. Operators see a "Failed to load" card with a
|
||||
// Retry button instead of a frozen loader.
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10_000);
|
||||
|
||||
fetch(`/api/admin/signals?days=${days}`, { signal: controller.signal })
|
||||
.then(async (r) => {
|
||||
if (!r.ok) {
|
||||
// Read the JSON error body when present so the UI surfaces
|
||||
// what the API actually said (rate limit, 500, etc.).
|
||||
let msg = `HTTP ${r.status}`;
|
||||
try {
|
||||
const body = await r.json();
|
||||
if (body?.error) msg = `${msg}: ${body.error}`;
|
||||
} catch {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
return r.json() as Promise<Payload>;
|
||||
})
|
||||
.then((d) => setData(d))
|
||||
.catch((err: Error) => {
|
||||
if (err.name === "AbortError") {
|
||||
setError("Request timed out after 10s — the signals API is taking longer than expected. Try a shorter window or retry.");
|
||||
} else {
|
||||
setError(err.message || "Failed to load signals.");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
clearTimeout(timeoutId);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
controller.abort();
|
||||
};
|
||||
}, [days, retryKey]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto mt-16">
|
||||
<div className="rounded-xl border border-rose-200 bg-rose-50 p-6 text-center">
|
||||
<AlertTriangle className="w-8 h-8 text-rose-500 mx-auto mb-2" />
|
||||
<h2 className="text-sm font-bold text-slate-900">Couldn't load Signal Overview</h2>
|
||||
<p className="text-xs text-slate-600 mt-1">{error}</p>
|
||||
<button
|
||||
onClick={() => setRetryKey((k) => k + 1)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 mt-4 rounded-lg bg-white border border-slate-200 text-xs font-semibold text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" /> Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Loader2 className="w-5 h-5 text-slate-400 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const max = Math.max(...data.byType.map((t) => t.count), 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
|
||||
<Radio className="w-5 h-5 text-slate-700" /> Signal Overview
|
||||
</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">
|
||||
{data.totalCount.toLocaleString()} signal{data.totalCount === 1 ? "" : "s"} across all brands in the last {data.windowDays} day{data.windowDays === 1 ? "" : "s"}.
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
value={days}
|
||||
onChange={(e) => setDays(Number(e.target.value))}
|
||||
className="px-3 py-1.5 text-xs bg-white border border-slate-200 rounded-lg"
|
||||
>
|
||||
<option value={1}>Last 24 hours</option>
|
||||
<option value={7}>Last 7 days</option>
|
||||
<option value={30}>Last 30 days</option>
|
||||
<option value={90}>Last 90 days</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* By type — bar list */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-slate-100 font-bold text-slate-800 text-sm">
|
||||
By signal type
|
||||
</div>
|
||||
{data.byType.length === 0 ? (
|
||||
<div className="p-12 text-center text-slate-400 text-sm">No signals in this window yet.</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{data.byType.map((t) => {
|
||||
const pct = (t.count / max) * 100;
|
||||
return (
|
||||
<li key={t.type} className="px-4 py-3 flex items-center gap-3">
|
||||
<span className="text-xs font-semibold text-slate-700 min-w-[180px]">
|
||||
{SIGNAL_LABEL[t.type] ?? t.type}
|
||||
</span>
|
||||
<div className="flex-1 h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full bg-violet-500" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-sm font-bold text-slate-800 tabular-nums w-20 text-right">
|
||||
{t.count.toLocaleString()}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top brands */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-slate-100 font-bold text-slate-800 text-sm">
|
||||
Brands with most signals (top 25)
|
||||
</div>
|
||||
{data.topBrands.length === 0 ? (
|
||||
<div className="p-12 text-center text-slate-400 text-sm">No brand signals in this window yet.</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{data.topBrands.map((b, i) => (
|
||||
<li key={b.brandId} className="px-4 py-2.5 flex items-center gap-3">
|
||||
<span className="text-[10px] font-bold text-slate-400 w-5 text-right">{i + 1}</span>
|
||||
<Link
|
||||
href={`/admin/brands?q=${encodeURIComponent(b.brandDomain ?? "")}`}
|
||||
className="flex-1 text-xs font-semibold text-slate-800 hover:text-brand-600 truncate"
|
||||
>
|
||||
{b.brandName}
|
||||
</Link>
|
||||
<span className="text-[10px] text-slate-400 truncate max-w-[180px] font-mono">{b.brandDomain}</span>
|
||||
<span className="text-sm font-bold text-slate-800 tabular-nums w-20 text-right">
|
||||
{b.count.toLocaleString()}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
Wrench, Loader2, Play, CheckCircle2, AlertTriangle, Database,
|
||||
Activity, Shield, Bot, Bug, ChevronDown, ChevronUp, RefreshCw,
|
||||
Server, Zap, Users, Clock, Trash2, Plus, XCircle, Heart,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BackfillResult {
|
||||
brandsProcessed: number; totalSessionsScanned: number;
|
||||
aiReferralsFound: number; googleAiOverviewFound: number;
|
||||
otherAiPlatformFound: number; attributionsCreated: number;
|
||||
queryContextPatched?: number;
|
||||
byBrand: Record<string, { scanned: number; found: number; created: number; googleAiOverview: number; otherPlatforms: number }>;
|
||||
}
|
||||
|
||||
interface MetricRow {
|
||||
brandId: string; name: string; domain: string;
|
||||
totalSnapshots: number; dateRange: string;
|
||||
totalConversions: number; totalSessions: number;
|
||||
status: "healthy" | "suspicious" | "corrupted";
|
||||
}
|
||||
|
||||
interface CronRow {
|
||||
route: string; schedule: string; lastRun: string | null;
|
||||
lastStatus: string; lastDuration: number | null; lastDetails: string | null;
|
||||
}
|
||||
|
||||
interface TableRow { name: string; rows: number }
|
||||
interface SiteRow { brandId: string; name: string; domain: string; lastEventAt: string | null; status: string; hoursSinceLastEvent: number }
|
||||
interface BrandRow { id: string; name: string; domain: string; organization: string; plan: string; status: string; createdAt: string; lastActivity: string | null }
|
||||
interface ErrorRow { id: string; route: string; method: string; status: number; message: string; brandId: string | null; createdAt: string }
|
||||
interface FreshnessSource { lastDate?: string | null; lastUpdatedAt?: string | null; lastEventAt?: string | null; status: "fresh" | "stale" | "critical" | "none" }
|
||||
interface FreshnessRow { brandId: string; name: string; domain: string; ga4: FreshnessSource; gsc: FreshnessSource; siteTag: FreshnessSource; dataforseo: FreshnessSource }
|
||||
|
||||
interface BrandHealthRow {
|
||||
brandId: string; brandName: string; domain: string; healthStatus: string;
|
||||
openAlert: {
|
||||
id: string; status: string; firstDetectedAt: string; expectedRate: number;
|
||||
actualRate: number; dropPct: number; consecutiveHoursDegraded: number; lastCheckedAt: string;
|
||||
} | null;
|
||||
}
|
||||
interface BrandHealthStatus { summary: { total: number; healthy: number; degraded: number; down: number }; brands: BrandHealthRow[] }
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function api(action: string, method: "GET" | "POST" = "GET", body?: Record<string, unknown>) {
|
||||
if (method === "GET") return fetch(`/api/admin/tools?action=${action}`).then((r) => r.json());
|
||||
return fetch("/api/admin/tools", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action, ...body }) }).then((r) => r.json());
|
||||
}
|
||||
|
||||
function useAction<T>(fetcher: () => Promise<T>) {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const run = useCallback(async () => {
|
||||
setLoading(true); setError(null);
|
||||
try { setData(await fetcher()); } catch (e) { setError(e instanceof Error ? e.message : "Failed"); }
|
||||
setLoading(false);
|
||||
}, [fetcher]);
|
||||
return { data, loading, error, run };
|
||||
}
|
||||
|
||||
function StatusDot({ status }: { status: string }) {
|
||||
const color = status === "healthy" || status === "active" || status === "success" ? "bg-emerald-500"
|
||||
: status === "suspicious" || status === "slow" || status === "warning" ? "bg-amber-400"
|
||||
: status === "never" ? "bg-slate-300"
|
||||
: "bg-red-500";
|
||||
return <span className={`inline-block w-2 h-2 rounded-full ${color}`} />;
|
||||
}
|
||||
|
||||
function Nfmt({ n }: { n: number | null | undefined }) {
|
||||
return <>{(n ?? 0).toLocaleString()}</>;
|
||||
}
|
||||
|
||||
function Section({ title, icon: Icon, children, defaultOpen = false }: {
|
||||
title: string; icon: typeof Database; children: React.ReactNode; defaultOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className="border border-slate-200 rounded-xl overflow-hidden">
|
||||
<button onClick={() => setOpen(!open)} className="w-full flex items-center gap-2 px-5 py-3.5 bg-slate-50 hover:bg-slate-100 transition-colors text-left">
|
||||
<Icon className="w-4 h-4 text-brand-600" />
|
||||
<span className="text-sm font-bold text-slate-800 flex-1">{title}</span>
|
||||
{open ? <ChevronUp className="w-4 h-4 text-slate-400" /> : <ChevronDown className="w-4 h-4 text-slate-400" />}
|
||||
</button>
|
||||
{open && <div className="p-5 space-y-4 bg-white">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-slate-800">{title}</h3>
|
||||
{description && <p className="text-xs text-slate-500 mt-0.5">{description}</p>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionBtn({ onClick, loading, icon: Icon, children, color = "brand" }: {
|
||||
onClick: () => void; loading: boolean; icon: typeof Play; children: React.ReactNode; color?: "brand" | "red" | "emerald";
|
||||
}) {
|
||||
const cls = color === "red" ? "bg-red-600 hover:bg-red-700" : color === "emerald" ? "bg-emerald-600 hover:bg-emerald-700" : "bg-brand-600 hover:bg-brand-700";
|
||||
return (
|
||||
<button onClick={onClick} disabled={loading} className={`inline-flex items-center gap-1.5 px-3 py-1.5 ${cls} text-white text-xs font-semibold rounded-lg disabled:opacity-50`}>
|
||||
{loading ? <Loader2 className="w-3 h-3 animate-spin" /> : <Icon className="w-3 h-3" />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorBanner({ error }: { error: string | null }) {
|
||||
if (!error) return null;
|
||||
return (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 text-red-600 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-red-700">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessBanner({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 p-3 bg-emerald-50 border border-emerald-200 rounded-lg">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-600 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-emerald-700 font-semibold">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const th = "px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-slate-400";
|
||||
const td = "px-3 py-2 text-xs";
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AdminToolsPage() {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8 space-y-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Wrench className="w-5 h-5 text-slate-400" />
|
||||
<h1 className="text-xl font-bold text-slate-900">Admin Tools</h1>
|
||||
</div>
|
||||
|
||||
<Section title="Data Management" icon={Database} defaultOpen>
|
||||
<MetricAuditCard />
|
||||
<Ga4PurgeCard />
|
||||
<BackfillCard />
|
||||
<FillQueriesCard />
|
||||
</Section>
|
||||
|
||||
<Section title="System Health" icon={Server}>
|
||||
<BrandHealthMonitorCard />
|
||||
<SourceFreshnessCard />
|
||||
<CronStatusCard />
|
||||
<TableSizesCard />
|
||||
<SiteTagHealthCard />
|
||||
</Section>
|
||||
|
||||
<Section title="User & Brand Management" icon={Users}>
|
||||
<BrandStatusCard />
|
||||
</Section>
|
||||
|
||||
<Section title="AI Attribution Tools" icon={Bot}>
|
||||
<TestReferralCard />
|
||||
</Section>
|
||||
|
||||
<Section title="Debugging" icon={Bug}>
|
||||
<ErrorLogCard />
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Data Management Cards ───────────────────────────────────────────────────────────
|
||||
|
||||
function MetricAuditCard() {
|
||||
const { data, loading, error, run } = useAction<{ brands: MetricRow[] }>(
|
||||
useCallback(() => api("metric-audit"), []),
|
||||
);
|
||||
return (
|
||||
<Card title="MetricSnapshot Audit" description="Auto-scan all brands for GA4 data anomalies.">
|
||||
<ActionBtn onClick={run} loading={loading} icon={Activity}>Scan All Brands</ActionBtn>
|
||||
<ErrorBanner error={error} />
|
||||
{data && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead><tr className="border-b border-slate-100">
|
||||
<th className={`${th} text-left`}>Brand</th>
|
||||
<th className={`${th} text-right`}>Snapshots</th>
|
||||
<th className={`${th} text-right`}>Conversions</th>
|
||||
<th className={`${th} text-right`}>Sessions</th>
|
||||
<th className={`${th} text-center`}>Status</th>
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{data.brands.map((b) => (
|
||||
<tr key={b.brandId}>
|
||||
<td className={`${td} font-semibold text-slate-800`}>{b.name}<br/><span className="text-[10px] text-slate-400">{b.dateRange}</span></td>
|
||||
<td className={`${td} text-right tabular-nums`}><Nfmt n={b.totalSnapshots} /></td>
|
||||
<td className={`${td} text-right tabular-nums`}><Nfmt n={b.totalConversions} /></td>
|
||||
<td className={`${td} text-right tabular-nums`}><Nfmt n={b.totalSessions} /></td>
|
||||
<td className={`${td} text-center`}><StatusDot status={b.status} /> <span className="ml-1">{b.status}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Ga4PurgeCard() {
|
||||
const [brandId, setBrandId] = useState("");
|
||||
const { data, loading, error, run } = useAction<{ purged: number }>(
|
||||
useCallback(() => api("ga4-purge", "POST", { brandId }), [brandId]),
|
||||
);
|
||||
return (
|
||||
<Card title="GA4 Data Purge" description="Remove corrupted MetricSnapshot rows where conversions > sessions × 5.">
|
||||
<div className="flex items-center gap-2">
|
||||
<input value={brandId} onChange={(e) => setBrandId(e.target.value)} placeholder="Brand ID (cuid)" className="flex-1 text-xs border border-slate-200 rounded-lg px-3 py-1.5" />
|
||||
<ActionBtn onClick={run} loading={loading} icon={Trash2} color="red">Purge</ActionBtn>
|
||||
</div>
|
||||
<ErrorBanner error={error} />
|
||||
{data && <SuccessBanner message={`Purged ${data.purged} corrupted rows`} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function BackfillCard() {
|
||||
const [result, setResult] = useState<BackfillResult | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const run = async () => {
|
||||
setRunning(true); setError(null); setResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/admin/backfill-ai-referrals", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}) });
|
||||
if (!res.ok) { setError(`HTTP ${res.status}`); return; }
|
||||
setResult(await res.json());
|
||||
} catch (e) { setError(e instanceof Error ? e.message : "Failed"); }
|
||||
setRunning(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title="AI Referral Backfill" description="Scan TrackedSession records for AI platform referrers and Google AI Overview clicks.">
|
||||
<ActionBtn onClick={run} loading={running} icon={Play}>Run Backfill</ActionBtn>
|
||||
<ErrorBanner error={error} />
|
||||
{result && (
|
||||
<div className="space-y-2">
|
||||
<SuccessBanner message={`Found ${result.aiReferralsFound} AI referrals, created ${result.attributionsCreated} attributions${result.queryContextPatched ? `, patched ${result.queryContextPatched} queries` : ""}`} />
|
||||
{Object.keys(result.byBrand).length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead><tr className="border-b border-slate-100">
|
||||
<th className={`${th} text-left`}>Brand</th>
|
||||
<th className={`${th} text-right`}>Scanned</th>
|
||||
<th className={`${th} text-right`}>Found</th>
|
||||
<th className={`${th} text-right`}>Google AIO</th>
|
||||
<th className={`${th} text-right`}>Other AI</th>
|
||||
<th className={`${th} text-right`}>Created</th>
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{Object.entries(result.byBrand).map(([name, s]) => (
|
||||
<tr key={name}>
|
||||
<td className={`${td} font-semibold text-slate-800`}>{name}</td>
|
||||
<td className={`${td} text-right tabular-nums`}><Nfmt n={s.scanned} /></td>
|
||||
<td className={`${td} text-right tabular-nums`}>{s.found}</td>
|
||||
<td className={`${td} text-right tabular-nums text-blue-600 font-semibold`}>{s.googleAiOverview}</td>
|
||||
<td className={`${td} text-right tabular-nums text-violet-600 font-semibold`}>{s.otherPlatforms}</td>
|
||||
<td className={`${td} text-right tabular-nums text-emerald-600 font-semibold`}>{s.created}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function FillQueriesCard() {
|
||||
const { data, loading, error, run } = useAction<{ nullBefore: number; patched: number }>(
|
||||
useCallback(() => api("fill-queries", "POST"), []),
|
||||
);
|
||||
return (
|
||||
<Card title="Query Inference Runner" description="Infer query context from landing page URL for AiAttribution records with null queryContext.">
|
||||
<ActionBtn onClick={run} loading={loading} icon={Zap}>Fill Missing Queries</ActionBtn>
|
||||
<ErrorBanner error={error} />
|
||||
{data && <SuccessBanner message={`${data.patched} records updated (${data.nullBefore} were null)`} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── System Health Cards ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function CronStatusCard() {
|
||||
const { data, loading, error, run } = useAction<{ crons: CronRow[] }>(
|
||||
useCallback(() => api("cron-status"), []),
|
||||
);
|
||||
const [triggering, setTriggering] = useState<string | null>(null);
|
||||
|
||||
const trigger = async (route: string) => {
|
||||
setTriggering(route);
|
||||
await api("trigger-cron", "POST", { route }).catch(() => null);
|
||||
setTriggering(null);
|
||||
run();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title="Cron Status Dashboard" description="All configured crons with last-run status. Add logCron() wrapper to crons for tracking.">
|
||||
<ActionBtn onClick={run} loading={loading} icon={RefreshCw}>Load Status</ActionBtn>
|
||||
<ErrorBanner error={error} />
|
||||
{data && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead><tr className="border-b border-slate-100">
|
||||
<th className={`${th} text-left`}>Route</th>
|
||||
<th className={`${th} text-left`}>Schedule</th>
|
||||
<th className={`${th} text-left`}>Last Run</th>
|
||||
<th className={`${th} text-center`}>Status</th>
|
||||
<th className={`${th} text-right`}>Duration</th>
|
||||
<th className={`${th} text-right`}>Action</th>
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{data.crons.map((c) => (
|
||||
<tr key={c.route}>
|
||||
<td className={`${td} font-mono text-slate-700`}>{c.route.replace("/api/cron/", "")}</td>
|
||||
<td className={`${td} text-slate-500`}>{c.schedule}</td>
|
||||
<td className={`${td} text-slate-500`}>{c.lastRun ? new Date(c.lastRun).toLocaleString() : "—"}</td>
|
||||
<td className={`${td} text-center`}><StatusDot status={c.lastStatus} /> <span className="ml-1">{c.lastStatus}</span></td>
|
||||
<td className={`${td} text-right tabular-nums text-slate-500`}>{c.lastDuration != null ? `${(c.lastDuration / 1000).toFixed(1)}s` : "—"}</td>
|
||||
<td className={`${td} text-right`}>
|
||||
<button
|
||||
onClick={() => trigger(c.route)}
|
||||
disabled={triggering === c.route}
|
||||
className="text-[10px] font-semibold text-brand-600 hover:text-brand-700 disabled:opacity-40"
|
||||
>
|
||||
{triggering === c.route ? "Running…" : "Trigger"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TableSizesCard() {
|
||||
const { data, loading, error, run } = useAction<{ tables: TableRow[] }>(
|
||||
useCallback(() => api("table-sizes"), []),
|
||||
);
|
||||
return (
|
||||
<Card title="Database Table Sizes" description="Row counts from pg_stat_user_tables, sorted by size.">
|
||||
<ActionBtn onClick={run} loading={loading} icon={Database}>Load Sizes</ActionBtn>
|
||||
<ErrorBanner error={error} />
|
||||
{data && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead><tr className="border-b border-slate-100">
|
||||
<th className={`${th} text-left`}>Table</th>
|
||||
<th className={`${th} text-right`}>Rows</th>
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{data.tables.map((t) => (
|
||||
<tr key={t.name} className={t.rows >= 10_000_000 ? "bg-red-50" : t.rows >= 1_000_000 ? "bg-amber-50" : ""}>
|
||||
<td className={`${td} font-mono text-slate-700`}>{t.name}</td>
|
||||
<td className={`${td} text-right tabular-nums font-semibold ${t.rows >= 10_000_000 ? "text-red-600" : t.rows >= 1_000_000 ? "text-amber-600" : "text-slate-800"}`}>
|
||||
<Nfmt n={t.rows} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SiteTagHealthCard() {
|
||||
const { data, loading, error, run } = useAction<{ sites: SiteRow[] }>(
|
||||
useCallback(() => api("site-tag-health"), []),
|
||||
);
|
||||
return (
|
||||
<Card title="Site Tag Health" description="Per-brand Site Tag activity status.">
|
||||
<ActionBtn onClick={run} loading={loading} icon={Activity}>Check Health</ActionBtn>
|
||||
<ErrorBanner error={error} />
|
||||
{data && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead><tr className="border-b border-slate-100">
|
||||
<th className={`${th} text-left`}>Brand</th>
|
||||
<th className={`${th} text-left`}>Last Event</th>
|
||||
<th className={`${th} text-center`}>Status</th>
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{data.sites.map((s) => (
|
||||
<tr key={s.brandId}>
|
||||
<td className={`${td} font-semibold text-slate-800`}>{s.name}<br/><span className="text-[10px] text-slate-400">{s.domain}</span></td>
|
||||
<td className={`${td} text-slate-500`}>{s.lastEventAt ? `${s.hoursSinceLastEvent}h ago` : "Never"}</td>
|
||||
<td className={`${td} text-center`}><StatusDot status={s.status} /> <span className="ml-1 capitalize">{s.status}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── User & Brand Management Cards ───────────────────────────────────────────────────────
|
||||
|
||||
function BrandStatusCard() {
|
||||
const { data, loading, error, run } = useAction<{ brands: BrandRow[] }>(
|
||||
useCallback(() => api("brand-status"), []),
|
||||
);
|
||||
return (
|
||||
<Card title="Brand Status Manager" description="All brands across all organizations.">
|
||||
<ActionBtn onClick={run} loading={loading} icon={Users}>Load Brands</ActionBtn>
|
||||
<ErrorBanner error={error} />
|
||||
{data && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead><tr className="border-b border-slate-100">
|
||||
<th className={`${th} text-left`}>Brand</th>
|
||||
<th className={`${th} text-left`}>Organization</th>
|
||||
<th className={`${th} text-center`}>Plan</th>
|
||||
<th className={`${th} text-center`}>Status</th>
|
||||
<th className={`${th} text-left`}>Last Activity</th>
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{data.brands.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<td className={`${td} font-semibold text-slate-800`}>{b.name}<br/><span className="text-[10px] text-slate-400">{b.domain}</span></td>
|
||||
<td className={`${td} text-slate-600`}>{b.organization}</td>
|
||||
<td className={`${td} text-center`}><span className="text-[10px] font-bold uppercase px-1.5 py-0.5 rounded bg-slate-100 text-slate-600">{b.plan}</span></td>
|
||||
<td className={`${td} text-center`}><StatusDot status={b.status === "active" ? "healthy" : "corrupted"} /> <span className="ml-1">{b.status}</span></td>
|
||||
<td className={`${td} text-slate-500`}>{b.lastActivity ? new Date(b.lastActivity).toLocaleDateString() : "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── AI Attribution Tools Cards ──────────────────────────────────────────────────────────
|
||||
|
||||
function TestReferralCard() {
|
||||
const [brandId, setBrandId] = useState("");
|
||||
const [platform, setPlatform] = useState("chatgpt");
|
||||
const [landingPage, setLandingPage] = useState("/");
|
||||
const [queryContext, setQueryContext] = useState("");
|
||||
const { data, loading, error, run } = useAction<{ ok: boolean; id: string }>(
|
||||
useCallback(() => api("test-ai-referral", "POST", { brandId, platform, landingPage, queryContext: queryContext || undefined }), [brandId, platform, landingPage, queryContext]),
|
||||
);
|
||||
return (
|
||||
<Card title="Test AI Referral" description="Create a test AiAttribution record for demos and UI verification.">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input value={brandId} onChange={(e) => setBrandId(e.target.value)} placeholder="Brand ID" className="text-xs border border-slate-200 rounded-lg px-3 py-1.5" />
|
||||
<select value={platform} onChange={(e) => setPlatform(e.target.value)} className="text-xs border border-slate-200 rounded-lg px-3 py-1.5 bg-white">
|
||||
<option value="chatgpt">ChatGPT</option>
|
||||
<option value="perplexity">Perplexity</option>
|
||||
<option value="gemini">Gemini</option>
|
||||
<option value="claude">Claude</option>
|
||||
<option value="copilot">Copilot</option>
|
||||
<option value="google_ai_overview">Google AI Overview</option>
|
||||
<option value="meta_ai">Meta AI</option>
|
||||
</select>
|
||||
<input value={landingPage} onChange={(e) => setLandingPage(e.target.value)} placeholder="Landing page (/about)" className="text-xs border border-slate-200 rounded-lg px-3 py-1.5" />
|
||||
<input value={queryContext} onChange={(e) => setQueryContext(e.target.value)} placeholder="Query context (optional)" className="text-xs border border-slate-200 rounded-lg px-3 py-1.5" />
|
||||
</div>
|
||||
<ActionBtn onClick={run} loading={loading} icon={Plus}>Create Test Referral</ActionBtn>
|
||||
<ErrorBanner error={error} />
|
||||
{data?.ok && <SuccessBanner message={`Created test attribution: ${data.id}`} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Debugging Cards ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function ErrorLogCard() {
|
||||
const { data, loading, error, run } = useAction<{ errors: ErrorRow[] }>(
|
||||
useCallback(() => api("error-log"), []),
|
||||
);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const clearOld = async () => {
|
||||
setClearing(true);
|
||||
await api("clear-errors", "POST").catch(() => null);
|
||||
setClearing(false);
|
||||
run();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title="Error Log Viewer" description="Recent server-side errors captured from API routes.">
|
||||
<div className="flex items-center gap-2">
|
||||
<ActionBtn onClick={run} loading={loading} icon={Bug}>Load Errors</ActionBtn>
|
||||
<ActionBtn onClick={clearOld} loading={clearing} icon={Trash2} color="red">Clear 7d+</ActionBtn>
|
||||
</div>
|
||||
<ErrorBanner error={error} />
|
||||
{data && data.errors.length === 0 && <SuccessBanner message="No errors logged" />}
|
||||
{data && data.errors.length > 0 && (
|
||||
<div className="overflow-x-auto max-h-80 overflow-y-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-white"><tr className="border-b border-slate-100">
|
||||
<th className={`${th} text-left`}>Route</th>
|
||||
<th className={`${th} text-center`}>Status</th>
|
||||
<th className={`${th} text-left`}>Message</th>
|
||||
<th className={`${th} text-left`}>Time</th>
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{data.errors.map((e) => (
|
||||
<tr key={e.id}>
|
||||
<td className={`${td} font-mono text-slate-700`}>{e.route}</td>
|
||||
<td className={`${td} text-center`}><span className={`font-bold ${e.status >= 500 ? "text-red-600" : "text-amber-600"}`}>{e.status}</span></td>
|
||||
<td className={`${td} text-slate-600 max-w-[300px] truncate`} title={e.message}>{e.message}</td>
|
||||
<td className={`${td} text-slate-400`}>{new Date(e.createdAt).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceFreshnessCard() {
|
||||
const { data, loading, error, run } = useAction<{ brands: FreshnessRow[] }>(
|
||||
useCallback(() => api("source-freshness"), []),
|
||||
);
|
||||
const statusColor = (s: string) =>
|
||||
s === "fresh" ? "bg-emerald-500" : s === "stale" ? "bg-amber-400" : s === "critical" ? "bg-red-500" : "bg-slate-300";
|
||||
const formatTs = (iso: string | null | undefined) => {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) + " " + d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||||
};
|
||||
return (
|
||||
<Card title="Source Freshness by Brand" description="Per-brand data source sync status. Green = fresh, Yellow = stale (12-48h), Red = critical (48h+).">
|
||||
<ActionBtn onClick={run} loading={loading} icon={RefreshCw} color="emerald">Check All Brands</ActionBtn>
|
||||
<ErrorBanner error={error} />
|
||||
{data && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead><tr className="border-b border-slate-100">
|
||||
<th className={`${th} text-left`}>Brand</th>
|
||||
<th className={`${th} text-center`}>Site Tag</th>
|
||||
<th className={`${th} text-center`}>GA4</th>
|
||||
<th className={`${th} text-center`}>GSC</th>
|
||||
<th className={`${th} text-center`}>DataForSEO</th>
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{data.brands.map((b) => (
|
||||
<tr key={b.brandId}>
|
||||
<td className={`${td} font-semibold text-slate-800`}>
|
||||
{b.name}
|
||||
<br/><span className="text-[10px] text-slate-400">{b.domain}</span>
|
||||
</td>
|
||||
<td className={`${td} text-center`}>
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${statusColor(b.siteTag.status)}`} />
|
||||
<span className="ml-1 text-slate-500">{formatTs(b.siteTag.lastEventAt)}</span>
|
||||
</td>
|
||||
<td className={`${td} text-center`}>
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${statusColor(b.ga4.status)}`} />
|
||||
<span className="ml-1 text-slate-500">{formatTs(b.ga4.lastUpdatedAt)}</span>
|
||||
</td>
|
||||
<td className={`${td} text-center`}>
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${statusColor(b.gsc.status)}`} />
|
||||
<span className="ml-1 text-slate-500">{formatTs(b.gsc.lastUpdatedAt)}</span>
|
||||
</td>
|
||||
<td className={`${td} text-center`}>
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${statusColor(b.dataforseo.status)}`} />
|
||||
<span className="ml-1 text-slate-500">{formatTs(b.dataforseo.lastUpdatedAt)}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Brand Health Monitor Card ───────────────────────────────────────────────────────────
|
||||
|
||||
function BrandHealthMonitorCard() {
|
||||
const { data, loading, error, run } = useAction<BrandHealthStatus>(
|
||||
useCallback(() => fetch("/api/admin/brand-health/status").then((r) => r.json()), []),
|
||||
);
|
||||
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [checkResult, setCheckResult] = useState<string | null>(null);
|
||||
|
||||
const runCheckNow = useCallback(async () => {
|
||||
setChecking(true);
|
||||
setCheckResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/admin/brand-health/check-now").then((r) => r.json()) as { summary: { opened: number; escalatedToDown: number; resolved: number } };
|
||||
setCheckResult(`Done — opened: ${res.summary.opened}, escalated: ${res.summary.escalatedToDown}, resolved: ${res.summary.resolved}`);
|
||||
await run();
|
||||
} catch {
|
||||
setCheckResult("Check failed — see console");
|
||||
}
|
||||
setChecking(false);
|
||||
}, [run]);
|
||||
|
||||
const statusColor = (s: string) =>
|
||||
s === "healthy" ? "text-emerald-700 bg-emerald-50 border-emerald-200"
|
||||
: s === "degraded" ? "text-amber-700 bg-amber-50 border-amber-200"
|
||||
: "text-red-700 bg-red-50 border-red-200";
|
||||
|
||||
const fmtPct = (p: number) => `${Math.round(p * 100)}%`;
|
||||
const fmtNum = (n: number) => Math.round(n).toLocaleString();
|
||||
const fmtAgo = (iso: string) => {
|
||||
const h = Math.floor((Date.now() - new Date(iso).getTime()) / 3_600_000);
|
||||
return h < 1 ? "<1h ago" : `${h}h ago`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title="Brand Health Monitor" description="Hourly event-rate check per brand. Alerts fire after 2 consecutive hours ≥90% below baseline.">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<ActionBtn onClick={run} loading={loading} icon={RefreshCw}>Load Status</ActionBtn>
|
||||
<ActionBtn onClick={runCheckNow} loading={checking} icon={Heart} color="emerald">Run Check Now</ActionBtn>
|
||||
</div>
|
||||
<ErrorBanner error={error} />
|
||||
{checkResult && <SuccessBanner message={checkResult} />}
|
||||
{data && (
|
||||
<>
|
||||
<div className="flex gap-3 text-xs text-slate-600">
|
||||
<span className="font-semibold">{data.summary.total} brands</span>
|
||||
<span className="text-emerald-700">{data.summary.healthy} healthy</span>
|
||||
{data.summary.degraded > 0 && <span className="text-amber-700 font-bold">{data.summary.degraded} degraded</span>}
|
||||
{data.summary.down > 0 && <span className="text-red-700 font-bold">{data.summary.down} DOWN</span>}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead><tr className="border-b border-slate-100">
|
||||
<th className={`${th} text-left`}>Brand</th>
|
||||
<th className={`${th} text-center`}>Status</th>
|
||||
<th className={`${th} text-right`}>Expected/hr</th>
|
||||
<th className={`${th} text-right`}>Actual/hr</th>
|
||||
<th className={`${th} text-right`}>Drop</th>
|
||||
<th className={`${th} text-right`}>Hrs</th>
|
||||
<th className={`${th} text-right`}>Since</th>
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{data.brands.map((b) => (
|
||||
<tr key={b.brandId}>
|
||||
<td className={`${td} font-semibold text-slate-800`}>
|
||||
{b.brandName}<br/>
|
||||
<span className="text-[10px] text-slate-400">{b.domain}</span>
|
||||
</td>
|
||||
<td className={`${td} text-center`}>
|
||||
<span className={`inline-flex items-center px-1.5 py-0.5 rounded border text-[10px] font-bold uppercase ${statusColor(b.healthStatus)}`}>
|
||||
{b.healthStatus}
|
||||
</span>
|
||||
</td>
|
||||
<td className={`${td} text-right text-slate-500`}>{b.openAlert ? fmtNum(b.openAlert.expectedRate) : "—"}</td>
|
||||
<td className={`${td} text-right text-slate-500`}>{b.openAlert ? fmtNum(b.openAlert.actualRate) : "—"}</td>
|
||||
<td className={`${td} text-right font-bold ${b.healthStatus === "down" ? "text-red-600" : b.healthStatus === "degraded" ? "text-amber-600" : "text-slate-400"}`}>
|
||||
{b.openAlert ? fmtPct(b.openAlert.dropPct) : "—"}
|
||||
</td>
|
||||
<td className={`${td} text-right text-slate-500`}>{b.openAlert ? b.openAlert.consecutiveHoursDegraded : "—"}</td>
|
||||
<td className={`${td} text-right text-slate-400`}>{b.openAlert ? fmtAgo(b.openAlert.firstDetectedAt) : "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||