Initial commit for GetCito
20
.changeset/README.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Changesets
|
||||
|
||||
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
||||
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
||||
find the full documentation for it [in the changesets repository](https://github.com/changesets/changesets).
|
||||
|
||||
## Adding a changeset
|
||||
|
||||
To add a changeset, run:
|
||||
|
||||
```bash
|
||||
pnpm changeset
|
||||
```
|
||||
|
||||
This will prompt you to select which packages have changed and what type of change it is (major, minor, or patch).
|
||||
|
||||
## Note on fixed versioning
|
||||
|
||||
This monorepo uses **fixed versioning**, meaning all packages are always kept at the same version number.
|
||||
When you create a changeset, you only need to select one package - all packages will be versioned together.
|
||||
11
.changeset/config.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [["**"]],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "master",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": []
|
||||
}
|
||||
2
.cursorignore
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
apps/web/.env
|
||||
apps/web/.env.old
|
||||
99
.dockerignore
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# Dependencies
|
||||
node_modules
|
||||
**/node_modules
|
||||
.pnpm-store
|
||||
|
||||
# Build outputs
|
||||
.next
|
||||
**/.next
|
||||
.output
|
||||
**/.output
|
||||
out
|
||||
**/out
|
||||
dist
|
||||
**/dist
|
||||
build
|
||||
**/build
|
||||
|
||||
# Turborepo cache
|
||||
.turbo
|
||||
**/.turbo
|
||||
|
||||
# Git
|
||||
.git
|
||||
**/.git
|
||||
.gitignore
|
||||
**/.gitignore
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
**/.idea
|
||||
.vscode
|
||||
**/.vscode
|
||||
.cursor
|
||||
**/.cursor
|
||||
*.swp
|
||||
**/*.swp
|
||||
*.swo
|
||||
**/*.swo
|
||||
|
||||
# Environment files (use Docker env vars instead)
|
||||
.env
|
||||
**/.env
|
||||
.env.*
|
||||
**/.env.*
|
||||
!.env.example
|
||||
!**/.env.example
|
||||
|
||||
# Test files
|
||||
coverage
|
||||
**/coverage
|
||||
*.test.ts
|
||||
**/*.test.ts
|
||||
*.test.tsx
|
||||
**/*.test.tsx
|
||||
*.spec.ts
|
||||
**/*.spec.ts
|
||||
*.spec.tsx
|
||||
**/*.spec.tsx
|
||||
__tests__
|
||||
**/__tests__
|
||||
|
||||
# Documentation
|
||||
docs
|
||||
*.md
|
||||
**/*.md
|
||||
!README.md
|
||||
!**/README.md
|
||||
|
||||
# Docker (don't copy init scripts into build)
|
||||
docker/init-scripts
|
||||
|
||||
# Directories not needed in Docker builds
|
||||
e2e
|
||||
.getcito
|
||||
**/.getcito
|
||||
apps/cli
|
||||
|
||||
# pnpm config (use-node-version breaks Alpine/MUSL Docker builds)
|
||||
.npmrc
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
*.pem
|
||||
**/*.pem
|
||||
*.key
|
||||
**/*.key
|
||||
*.cert
|
||||
**/*.cert
|
||||
*.crt
|
||||
**/*.crt
|
||||
Thumbs.db
|
||||
**/Thumbs.db
|
||||
*.log
|
||||
**/*.log
|
||||
e2e/artifacts
|
||||
|
||||
docker-compose.whitelabel.yml
|
||||
|
||||
29
.env.example
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Docker Compose defaults. New installs use PostgreSQL 18. Existing PostgreSQL
|
||||
# 16 installs must set POSTGRES_IMAGE_TAG=16-alpine until after a dump/restore.
|
||||
POSTGRES_IMAGE_TAG=18-alpine
|
||||
DATABASE_URL=postgres://postgres:postgres@postgres:5432/getcito
|
||||
DEPLOYMENT_MODE=local
|
||||
VITE_DEPLOYMENT_MODE=local
|
||||
BETTER_AUTH_SECRET=replace-with-random-hex
|
||||
APP_URL=http://localhost:3001
|
||||
VITE_APP_URL=http://localhost:3001
|
||||
VITE_APP_TIMEZONE=Asia/Kolkata
|
||||
VITE_APP_LOCALE=en-IN
|
||||
DISABLE_TELEMETRY=1
|
||||
|
||||
# Select providers as model:provider[:version][:online].
|
||||
SCRAPE_TARGETS=chatgpt:openai-api
|
||||
ONBOARDING_LLM_TARGET=chatgpt:openai-api
|
||||
OPENAI_API_KEY=replace-me
|
||||
ANTHROPIC_API_KEY=
|
||||
MISTRAL_API_KEY=
|
||||
OPENROUTER_API_KEY=
|
||||
AZURE_FOUNDRY_API_KEY=
|
||||
AZURE_FOUNDRY_BASE_URL=
|
||||
CLORO_API_KEY=
|
||||
OLOSTEP_API_KEY=
|
||||
BRIGHTDATA_API_TOKEN=
|
||||
OXYLABS_USERNAME=
|
||||
OXYLABS_PASSWORD=
|
||||
DATAFORSEO_LOGIN=
|
||||
DATAFORSEO_PASSWORD=
|
||||
174
.env.local
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
# ==============================================================================
|
||||
# Core Database & Application Settings
|
||||
# ==============================================================================
|
||||
# PostgreSQL connection string. (Required)
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/getcito
|
||||
|
||||
# Deployment mode: local, demo, whitelabel, or cloud. (Required)
|
||||
DEPLOYMENT_MODE=local
|
||||
|
||||
# Client-visible copy of DEPLOYMENT_MODE. (Optional)
|
||||
VITE_DEPLOYMENT_MODE=local
|
||||
|
||||
# Session cookie encryption secret. (Required)
|
||||
BETTER_AUTH_SECRET=your_secret_here
|
||||
|
||||
# Public base URL of the web app. (Optional)
|
||||
APP_URL=http://localhost:3000
|
||||
|
||||
# Set to any value to disable telemetry. (Optional)
|
||||
DISABLE_TELEMETRY=1
|
||||
|
||||
# Fallback cadence (hours). Default is 24. (Optional)
|
||||
DEFAULT_DELAY_HOURS=24
|
||||
|
||||
# Demo email. (Optional)
|
||||
VITE_DEMO_EMAIL=team@getcito.com
|
||||
|
||||
# Demo password. (Optional)
|
||||
VITE_DEMO_PASSWORD=your_secret_here
|
||||
|
||||
# ==============================================================================
|
||||
# Scrapers & LLM Providers
|
||||
# ==============================================================================
|
||||
# Comma-separated model:provider[:version][:online] entries. (Required)
|
||||
# Example: chatgpt:brightdata:online,google-ai-mode:brightdata:online
|
||||
SCRAPE_TARGETS=chatgpt:brightdata:online,google-ai-mode:brightdata:online
|
||||
|
||||
# BrightData API token. (Required if referenced in SCRAPE_TARGETS)
|
||||
BRIGHTDATA_API_TOKEN=your_api_key_here
|
||||
|
||||
# Olostep API key. (Required if referenced in SCRAPE_TARGETS)
|
||||
OLOSTEP_API_KEY=your_api_key_here
|
||||
|
||||
# Oxylabs Web Scraper API username. (Required if referenced in SCRAPE_TARGETS)
|
||||
OXYLABS_USERNAME=change_me
|
||||
|
||||
# Oxylabs Web Scraper API password. (Required if referenced in SCRAPE_TARGETS)
|
||||
OXYLABS_PASSWORD=your_secret_here
|
||||
|
||||
# DataForSEO account login. (Required if referenced in SCRAPE_TARGETS)
|
||||
DATAFORSEO_LOGIN=change_me
|
||||
|
||||
# DataForSEO account password. (Required if referenced in SCRAPE_TARGETS)
|
||||
DATAFORSEO_PASSWORD=your_secret_here
|
||||
|
||||
# OpenRouter API key. (Required if referenced in SCRAPE_TARGETS)
|
||||
OPENROUTER_API_KEY=your_api_key_here
|
||||
|
||||
# OpenAI API key. (Required if referenced in SCRAPE_TARGETS)
|
||||
OPENAI_API_KEY=your_api_key_here
|
||||
|
||||
# Anthropic API key. (Required if referenced in SCRAPE_TARGETS)
|
||||
ANTHROPIC_API_KEY=your_api_key_here
|
||||
|
||||
# Mistral API key. (Required if referenced in SCRAPE_TARGETS)
|
||||
MISTRAL_API_KEY=your_api_key_here
|
||||
|
||||
# ==============================================================================
|
||||
# White-label & Auth0 (Required for whitelabel mode)
|
||||
# ==============================================================================
|
||||
# Auth0 client ID. (Required for whitelabel)
|
||||
AUTH0_CLIENT_ID=change_me
|
||||
|
||||
# Auth0 client secret. (Required for whitelabel)
|
||||
AUTH0_CLIENT_SECRET=your_secret_here
|
||||
|
||||
# Auth0 Management API domain. (Required for whitelabel)
|
||||
AUTH0_MGMT_API_DOMAIN=change_me
|
||||
|
||||
# Auth0 tenant domain (used for whitelabel logout redirects). (Optional)
|
||||
AUTH0_DOMAIN=change_me
|
||||
|
||||
# Auth0 API audience. (Optional)
|
||||
AUTH0_AUDIENCE=change_me
|
||||
|
||||
# Auth0 OAuth scopes. (Optional)
|
||||
AUTH0_SCOPE=change_me
|
||||
|
||||
# Auth0 subject claim granted admin access. (Optional)
|
||||
ADMIN_AUTH0_SUB=change_me
|
||||
|
||||
# Comma-separated bearer tokens accepted by the admin API. (Optional)
|
||||
ADMIN_API_KEYS=your_secret_here
|
||||
|
||||
# Application display name (e.g., 'Acme AI Search'). (Required for whitelabel)
|
||||
VITE_APP_NAME="Acme AI Search"
|
||||
|
||||
# Application icon URL (must be an external URL, e.g., 'https://cdn.example.com/icon.png'). (Required for whitelabel)
|
||||
VITE_APP_ICON=https://cdn.example.com/icon.png
|
||||
|
||||
# Application URL (e.g., 'https://ai.example.com/'). (Required for whitelabel)
|
||||
VITE_APP_URL=http://localhost:3000
|
||||
|
||||
# Parent application name (e.g., 'Acme'). (Required for whitelabel)
|
||||
VITE_APP_PARENT_NAME="Acme"
|
||||
|
||||
# Parent application URL (e.g., 'https://app.example.com/'). (Required for whitelabel)
|
||||
VITE_APP_PARENT_URL=http://localhost:3000
|
||||
|
||||
# URL template for optimization with placeholders {brandId}, {prompt}, {webQuery}. (Required for whitelabel)
|
||||
VITE_OPTIMIZATION_URL_TEMPLATE=https://app.example.com/optimize?org_id={brandId}&prompt={prompt}&web_query={webQuery}
|
||||
|
||||
# Redirect URL template (with {brandId} placeholder) for sending users back to the parent app after onboarding. (Optional)
|
||||
VITE_ONBOARDING_REDIRECT_URL_TEMPLATE=https://app.example.com/onboarding/{brandId}
|
||||
|
||||
# Auth0 domain exposed to the client. (Optional)
|
||||
VITE_AUTH0_DOMAIN=change_me
|
||||
|
||||
# Auth0 client ID exposed to the client. (Optional)
|
||||
VITE_AUTH0_CLIENT_ID=change_me
|
||||
|
||||
# ==============================================================================
|
||||
# Additional Features & Analytics
|
||||
# ==============================================================================
|
||||
# Comma-separated domains added as default brands. (Optional)
|
||||
DEFAULT_BRAND_DOMAINS=example.com
|
||||
|
||||
# Environment name reported to Sentry (e.g. production). (Optional)
|
||||
ENVIRONMENT=development
|
||||
|
||||
# Sentry DSN for server-side error reporting. (Optional)
|
||||
SENTRY_DSN=change_me
|
||||
|
||||
# Sentry org slug for sourcemap upload at build time. (Optional)
|
||||
SENTRY_ORG=change_me
|
||||
|
||||
# Sentry project slug for sourcemap upload at build time. (Optional)
|
||||
SENTRY_PROJECT=change_me
|
||||
|
||||
# Sentry auth token for sourcemap upload at build time. (Optional)
|
||||
SENTRY_AUTH_TOKEN=your_secret_here
|
||||
|
||||
# Sentry DSN for browser error reporting. (Optional)
|
||||
VITE_SENTRY_DSN=change_me
|
||||
|
||||
# PostHog project API key override. (Optional)
|
||||
VITE_POSTHOG_KEY=your_api_key_here
|
||||
|
||||
# Plausible analytics domain. (Optional)
|
||||
VITE_PLAUSIBLE_DOMAIN=change_me
|
||||
|
||||
# Microsoft Clarity project ID. (Optional)
|
||||
VITE_CLARITY_PROJECT_ID=change_me
|
||||
|
||||
# Comma-separated chart color palette override. (Optional)
|
||||
VITE_CHART_COLORS=#FF0000,#00FF00,#0000FF
|
||||
|
||||
# ==============================================================================
|
||||
# Marketing Site (www) Specific
|
||||
# ==============================================================================
|
||||
# Upstash Redis REST URL (www caching: status, GitHub stars/releases). (Optional)
|
||||
UPSTASH_REDIS_REST_URL=change_me
|
||||
|
||||
# Upstash Redis REST token. (Optional)
|
||||
UPSTASH_REDIS_REST_TOKEN=your_secret_here
|
||||
|
||||
# Upstash Redis endpoint. (Optional)
|
||||
UPSTASH_REDIS_ENDPOINT=change_me
|
||||
|
||||
# Vercel Blob token (www competitor screenshots). (Optional)
|
||||
BLOB_READ_WRITE_TOKEN=your_secret_here
|
||||
|
||||
# Override for the DBOS system database URL (read by the DBOS runtime). (Optional)
|
||||
DBOS_SYSTEM_DATABASE_URL=change_me
|
||||
99
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
|
||||
# turbo
|
||||
.turbo
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
.next/
|
||||
out/
|
||||
|
||||
# tanstack start
|
||||
.output/
|
||||
.tanstack/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc / secrets
|
||||
.DS_Store
|
||||
*.pem
|
||||
*.key
|
||||
*.cert
|
||||
*.crt
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
debug-storybook.log
|
||||
storybook-static/
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env
|
||||
!.env.example
|
||||
!.env.whitelabel.example
|
||||
!.env.local.example
|
||||
|
||||
# playwright
|
||||
playwright-report/
|
||||
test-results/
|
||||
blob-report/
|
||||
|
||||
# e2e generated config (created by `getcito init --dev --defaults`)
|
||||
e2e/.getcito/
|
||||
e2e/.auth/
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# brand kit (generated assets)
|
||||
getcito-brand-kit.zip
|
||||
og-default.png
|
||||
|
||||
# to remove
|
||||
prompt_runs.ndjson.gz
|
||||
prompt_runs_rows.csv
|
||||
prompt_runs.meta.json
|
||||
|
||||
# docker
|
||||
getcito.yaml
|
||||
e2e/artifacts
|
||||
|
||||
# retroactive cleanup
|
||||
/features/
|
||||
.agents/
|
||||
.cursor/
|
||||
apps/web/storybook-static/
|
||||
|
||||
.claude/settings.local.json
|
||||
.claude/worktrees/
|
||||
.readonlydb
|
||||
|
||||
# Rebranding
|
||||
REBRANDING_PLAN.md
|
||||
|
||||
# CLI is being ignored for now until it is published
|
||||
apps/cli/
|
||||
# transient GitHub Release notes body (scripts/gh-release.mjs)
|
||||
.release-notes.md
|
||||
|
||||
.claude/skills/add-changeset/
|
||||
docs/
|
||||
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
24
|
||||
68
AGENTS.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# AGENTS.md
|
||||
|
||||
## Cursor Cloud specific instructions
|
||||
|
||||
### Project overview
|
||||
|
||||
GetCito is an open-source AI visibility tracking platform (Answer Engine Optimization), forked from [Elmo](https://github.com/elmohq/elmo). It is a **pnpm monorepo** using **Turborepo**, **Node.js 24**, and **PostgreSQL 16**.
|
||||
|
||||
Key apps: `apps/web` (TanStack Start dashboard, port 3000), `apps/worker` (pg-boss background jobs), `apps/cli` (deployment CLI).
|
||||
|
||||
### Running services
|
||||
|
||||
- **PostgreSQL**: Start with `sudo pg_ctlcluster 16 main start`. Verify with `pg_isready`.
|
||||
- **Web app dev server**: From repo root, run with env vars loaded (see below). Uses Vite on port 3000.
|
||||
- **Worker**: Reads env from `apps/web/.env` via `--env-file`. Only needed for background job processing (AI evaluations, reports).
|
||||
|
||||
### Environment variables
|
||||
|
||||
The `.env` file must exist at **both** the repo root AND `apps/web/.env` (Vite reads from its project root; the worker reads from `apps/web/.env` via `--env-file`). Required minimum for local mode:
|
||||
|
||||
```
|
||||
DATABASE_URL=postgres://getcito:getcito@localhost:5432/getcito
|
||||
DEPLOYMENT_MODE=local
|
||||
VITE_DEPLOYMENT_MODE=local
|
||||
BETTER_AUTH_SECRET=<random-hex>
|
||||
APP_URL=http://localhost:3000
|
||||
VITE_APP_URL=http://localhost:3000
|
||||
DISABLE_TELEMETRY=1
|
||||
```
|
||||
|
||||
For the env validation to pass fully, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `DATAFORSEO_LOGIN`, and `DATAFORSEO_PASSWORD` must also be set. Placeholder values work for UI testing.
|
||||
|
||||
### Database migrations
|
||||
|
||||
Run from `packages/lib`: `DATABASE_URL=... npx drizzle-kit migrate`
|
||||
|
||||
### Common commands
|
||||
|
||||
See `README.md` for the full commands reference. Key ones:
|
||||
|
||||
- `pnpm dev` — start all dev servers (turbo)
|
||||
- `pnpm lint` — Biome linter (pre-existing CSS/Tailwind directive warnings are expected)
|
||||
- `pnpm test` — Vitest unit tests
|
||||
- `pnpm build` — build all packages
|
||||
- `pnpm format` — format with Biome
|
||||
|
||||
### Gotchas
|
||||
|
||||
- **Changesets**: Keep changesets short and end-user focused (what changed in the product), not internal implementation details.
|
||||
- **PR screenshots**: Do **not** commit before/after screenshots or one-off screenshot capture scripts to the repo. Add proof screenshots as PR artifacts (or attach in the PR description) instead.
|
||||
|
||||
- **Node.js 24 required**: The repo enforces `engines.node: "24.x"`. Use `nvm use 24`.
|
||||
- **Biome CSS lint warnings**: The linter reports Tailwind `@apply` directive warnings in CSS files — these are pre-existing and expected.
|
||||
- **pnpm build warnings**: Some dependencies have ignored build scripts (sentry-cli, msw, protobufjs, vue-demi). This is configured via `pnpm.onlyBuiltDependencies` and `pnpm.ignoredBuiltDependencies` in root `package.json`.
|
||||
- **E2E tests**: Require Playwright browsers (`pnpm exec playwright install`) and a running app instance. They are separate from unit tests.
|
||||
- **Worker env loading**: The worker's `dev` script uses `--env-file=../web/.env`, so the env file must be in `apps/web/`.
|
||||
|
||||
## Pull request guidelines
|
||||
|
||||
- **Do not commit image artifacts** (screenshots, videos, Playwright reports) to the repo. Generate artifacts locally and embed them in the PR description via `<img src="/opt/cursor/artifacts/..." />` so they are uploaded automatically.
|
||||
- **Prefer uploading screenshots as PR artifacts** inline in the PR description (avoid committing files or linking to agent dashboards).
|
||||
- **Do not add "screenshot-only" tests** whose sole purpose is to produce PR images. If you add test coverage, make it a real assertion-based test that is valuable long-term.
|
||||
- **Do not bump versions** (or add placeholder versions) unless the change explicitly requires a release/versioning action.
|
||||
|
||||
## Changesets
|
||||
|
||||
- When adding a Changeset, keep it **scoped to the packages actually affected**.
|
||||
- If a non-package directory (like `e2e/`) breaks Changesets tooling, fix the tooling configuration rather than inventing versions.
|
||||
36
CLA.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Contributor License Agreement (CLA)
|
||||
|
||||
## 1. Purpose of the CLA
|
||||
Thank you for your interest in contributing to Getcito, an open-source project managed by GetCito (the "Company"). This Contributor License Agreement ("CLA") clarifies the intellectual property licenses granted with contributions. It protects you, the project, the Company, and its users. It does not change your right to use your own contributions for any other purpose.
|
||||
|
||||
## 2. Copyright Ownership
|
||||
You retain copyright ownership, title, and all other rights and interests in your contributions, except for the license specifically granted to the project under this agreement.
|
||||
|
||||
## 3. License Granted to the Project
|
||||
You hereby grant to the Company and all recipients of the software distributed by the Company a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute your contributions and such derivative works.
|
||||
|
||||
## 4. Patent Grant
|
||||
Subject to the terms and conditions of this Agreement, you hereby grant to the Company and recipients of the software a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the project. This applies only to those patent claims licensable by you that are necessarily infringed by your contributions alone or by combination with the project.
|
||||
|
||||
## 5. Right to Modify Contributions
|
||||
The Company reserves the right to review, accept, modify, or reject any contribution submitted to the project. You acknowledge that your contribution may be modified, adapted, or combined with other code within the project.
|
||||
|
||||
## 6. Contributor Representations
|
||||
You represent that you are legally entitled to grant this license. If your employer has rights to the intellectual property you create, you represent that you have received permission to make contributions on behalf of that employer, that your employer has waived such rights for your contributions, or that your employer has executed a separate Corporate CLA.
|
||||
|
||||
## 7. Original Work Declaration
|
||||
You represent that each of your contributions is your original creation. You also represent that your submissions include complete details of any third-party license or other restrictions (including patents and trademarks) of which you are aware.
|
||||
|
||||
## 8. Third-Party Code Declaration
|
||||
Should you wish to submit work that is not your original creation, you may submit it to the Company separately from any contribution, identifying the complete details of its source and any license or other restrictions. Such work must be conspicuously marked as "Submitted on behalf of a third-party: [named here]".
|
||||
|
||||
## 9. Legal Disclaimer
|
||||
You are not expected to provide support for your contributions. Unless required by applicable law or agreed to in writing, you provide your contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
|
||||
## 10. Acceptance Process
|
||||
You can sign this CLA as part of your first contribution pull request. No separate sign-up step is required.
|
||||
1. Add your GitHub username (lowercase, on its own line) to [`.github/contributors.txt`](.github/contributors.txt).
|
||||
2. The PR must be opened from your own GitHub account. The merged commit acts as your electronic signature.
|
||||
|
||||
## 11. Contact
|
||||
For any legal inquiries or questions regarding this CLA, please contact the maintainers at [team@getcito.com](mailto:team@getcito.com).
|
||||
35
CODE_OF_CONDUCT.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as contributors and maintainers pledge to make participation in the Getcito project a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the overall community
|
||||
|
||||
Examples of unacceptable behavior:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Enforcement
|
||||
|
||||
The Getcito repository maintainers are responsible for enforcing this Code of Conduct. Maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, issues, and other contributions that are not aligned with this Code of Conduct, and may **warn, temporarily ban, or permanently ban** any contributor for behaviors they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
## Reporting
|
||||
|
||||
If you experience or witness behavior that violates this Code of Conduct, please report it by emailing [team@getcito.com](mailto:team@getcito.com).
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant, version 2.0](https://www.contributor-covenant.org/version/2/0/code_of_conduct/), condensed for our project.
|
||||
156
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
# Contributing to Getcito
|
||||
|
||||
## Welcome
|
||||
|
||||
Thank you for your interest in contributing to Getcito! We welcome developers, designers, and enthusiasts to help us build a better open-source AEO and GEO platform. Whether you are fixing a bug, writing documentation, or proposing new features, your contributions are highly valued.
|
||||
|
||||
For minor fixes, feel free to open a pull request directly. For larger architectural changes or major features, please open an issue first so we can discuss the approach before you begin coding.
|
||||
|
||||
## Ways to Contribute
|
||||
|
||||
There are many ways you can contribute to the project, including but not limited to:
|
||||
- **Bug reports**: Identify and report issues in our issue tracker.
|
||||
- **Feature requests**: Propose new functionalities or enhancements.
|
||||
- **Documentation improvements**: Fix typos, clarify guides, or write tutorials.
|
||||
- **Code contributions**: Fix bugs or implement new features.
|
||||
- **Performance improvements**: Optimize database queries, reduce bundle sizes, or speed up processing.
|
||||
- **Security improvements**: Identify vulnerabilities and responsibly disclose them.
|
||||
- **Tests**: Expand our unit (Vitest) and E2E (Playwright) test coverage.
|
||||
- **Refactoring**: Clean up code and improve architecture.
|
||||
- **Examples**: Build out use cases or provide sample configurations.
|
||||
- **UI/UX improvements**: Improve dashboard accessibility, design, and usability.
|
||||
|
||||
## Development Setup
|
||||
|
||||
Getcito is a Turborepo monorepo using `pnpm` and Node.js 24.
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/ai-search-guru/getcito-worlds-first-open-source-aio-aeo-or-geo-tool.git
|
||||
cd getcito-worlds-first-open-source-aio-aeo-or-geo-tool
|
||||
```
|
||||
|
||||
2. **Configure environment**
|
||||
Create a `.env.local` file by copying the template. Fill in your necessary API keys and set `DEPLOYMENT_MODE=local`.
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
cp .env.local .env
|
||||
cp apps/web/.env.local apps/web/.env
|
||||
```
|
||||
|
||||
3. **Install dependencies**
|
||||
Ensure you have Node.js 24 installed. We recommend using `nvm`.
|
||||
```bash
|
||||
nvm use 24
|
||||
corepack enable pnpm
|
||||
pnpm install
|
||||
```
|
||||
|
||||
4. **Start the development server**
|
||||
Use Turborepo to start all development servers concurrently.
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
5. **Run tests**
|
||||
```bash
|
||||
pnpm test # Run unit tests
|
||||
pnpm test:e2e # Run end-to-end tests
|
||||
```
|
||||
|
||||
6. **Build the project**
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## Branch Naming Convention
|
||||
|
||||
Please use descriptive branch names based on the following prefixes:
|
||||
- `feature/` - For new features
|
||||
- `fix/` - For bug fixes
|
||||
- `docs/` - For documentation updates
|
||||
- `refactor/` - For code restructuring without behavior changes
|
||||
- `test/` - For adding or updating tests
|
||||
- `chore/` - For maintenance tasks, dependency updates, etc.
|
||||
- `hotfix/` - For urgent production fixes
|
||||
|
||||
## Commit Message Convention
|
||||
|
||||
We strictly follow Conventional Commits for our commit messages. This helps us auto-generate changelogs and release notes.
|
||||
|
||||
**Examples:**
|
||||
- `feat: add Google AI Mode scraping provider`
|
||||
- `fix: resolve hydration error on prompt dashboard`
|
||||
- `docs: update deployment prerequisites`
|
||||
- `style: format trailing commas`
|
||||
- `refactor: extract chart components to ui package`
|
||||
- `perf: optimize postgres prompt querying`
|
||||
- `test: add playwright cases for auth flow`
|
||||
- `build: update dockerfile node version`
|
||||
- `ci: migrate to new GitHub action runner`
|
||||
- `chore: bump dependencies in workspace`
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. **Fork the repository** to your own GitHub account.
|
||||
2. **Create a branch** using our branch naming conventions.
|
||||
3. **Keep commits clean** and logical using Conventional Commits.
|
||||
4. **Run linting and formatting** locally using Biome:
|
||||
```bash
|
||||
pnpm lint
|
||||
pnpm format
|
||||
```
|
||||
5. **Run tests** (`pnpm test`) to ensure you haven't broken existing functionality.
|
||||
6. **Update documentation** if your changes impact the setup, CLI, or UI.
|
||||
7. **Submit a Pull Request** against the `main` branch.
|
||||
*(Note: For your first PR, sign the CLA by adding your username to `.github/contributors.txt`)*
|
||||
8. **Address review comments** promptly and push updates to the same branch.
|
||||
|
||||
## Coding Standards
|
||||
|
||||
- **Formatting & Linting**: We use [Biome](https://biomejs.dev/). Do not use Prettier or ESLint. Run `pnpm format` before committing.
|
||||
- **Naming Conventions**: Use `camelCase` for variables and functions, `PascalCase` for React components and Types/Interfaces, and `kebab-case` for file names.
|
||||
- **Folder Structure**: Follow the Turborepo standard. UI components go in `packages/ui`, database logic in `packages/lib`, and route pages in `apps/web/src/routes`.
|
||||
- **Testing Expectations**: New features must include Vitest unit tests. Critical UI paths must be covered by Playwright E2E tests.
|
||||
- **Documentation Expectations**: If you introduce a new environment variable, it must be added to `packages/config/src/env-registry.ts` and documented in the `README.md`.
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
When reporting an issue, please include:
|
||||
- A clear, descriptive title.
|
||||
- Steps to reproduce the bug.
|
||||
- Expected vs. actual behavior.
|
||||
- Context (OS, Browser, Node.js version, deployment mode).
|
||||
- Screenshots or logs if applicable.
|
||||
|
||||
## Code Review Expectations
|
||||
|
||||
- **Review Process**: PRs will be reviewed by at least one core maintainer. We aim to review PRs within a few business days.
|
||||
- **Required Approvals**: 1 maintainer approval is required before merging. All CI checks (lint, test, typecheck) must pass.
|
||||
- **Quality Expectations**: Code must be typed (no `any`), well-documented, formatted, and strictly scoped to the PR's stated objective.
|
||||
|
||||
## Community Guidelines
|
||||
|
||||
We expect all contributors to maintain a respectful, inclusive, and collaborative environment. Be kind, provide constructive feedback, and assume positive intent. By participating in this project, you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
## Project Maintainers
|
||||
|
||||
This project is actively maintained by:
|
||||
|
||||
### AI Search Guru
|
||||
|
||||
GitHub:
|
||||
https://github.com/ai-search-guru
|
||||
|
||||
### Sachiiinnn
|
||||
|
||||
GitHub:
|
||||
https://github.com/sachiiinnn
|
||||
|
||||
They are the primary maintainers responsible for:
|
||||
- Reviewing pull requests
|
||||
- Managing releases
|
||||
- Maintaining project quality
|
||||
- Reviewing architectural decisions
|
||||
- Maintaining documentation
|
||||
- Guiding the project roadmap
|
||||
27
LICENSE.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Blue Whale Software, LLC
|
||||
Copyright (c) 2026 GetCito
|
||||
|
||||
This project is a derivative work of Elmo (https://github.com/elmohq/elmo),
|
||||
Copyright (c) 2026 Blue Whale Software, LLC, used under the MIT License.
|
||||
Modifications and additions made in this repository are Copyright (c) 2026
|
||||
GetCito and are released under the same license.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
648
README.md
Normal file
|
|
@ -0,0 +1,648 @@
|
|||
<p align="center">
|
||||
<a href="https://github.com/ai-search-guru/getcito-worlds-first-open-source-aio-aeo-or-geo-tool">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="brand/logos/getcito-logo-dark-xl.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="brand/logos/getcito-logo-xl.png">
|
||||
<img alt="Getcito" src="brand/logos/getcito-logo-dark-xl.png" width="300">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Open source AI visibility tracking and optimization — self-hosted.
|
||||
<br />
|
||||
<br />
|
||||
<a href="https://www.getcito.com/"><strong>Learn more »</strong></a>
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/ai-search-guru/getcito-worlds-first-open-source-aio-aeo-or-geo-tool/"><img src="https://img.shields.io/badge/Docs-2563eb?style=flat&logo=readthedocs&logoColor=white" alt="Docs"></a>
|
||||
<a href="https://demo.getcito.com"><img src="https://img.shields.io/badge/Demo-22c55e?style=flat&logoColor=white" alt="Demo"></a>
|
||||
<a href="https://github.com/ai-search-guru/getcito-worlds-first-open-source-aio-aeo-or-geo-tool/issues"><img src="https://img.shields.io/badge/Issues-f95738?style=flat&logo=github&logoColor=white" alt="Issues"></a>
|
||||
<a href="https://github.com/ai-search-guru/"><img src="https://img.shields.io/badge/Roadmap-ee964b?style=flat&logo=github&logoColor=white" alt="Roadmap"></a>
|
||||
<img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT">
|
||||
<img src="https://img.shields.io/badge/node-24.x-339933?logo=node.js&logoColor=white" alt="Node 24.x">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [About](#about)
|
||||
- [Features](#features)
|
||||
- [Demo](#demo)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [Architecture Overview](#architecture-overview)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Installation](#installation)
|
||||
- [Option A — Docker Compose (recommended)](#option-a--docker-compose-recommended)
|
||||
- [Option B — Local development](#option-b--local-development)
|
||||
- [Configuration](#configuration)
|
||||
- [Usage](#usage)
|
||||
- [Available Scripts](#available-scripts)
|
||||
- [API Documentation](#api-documentation)
|
||||
- [Database](#database)
|
||||
- [Deployment Documentation](#deployment-documentation)
|
||||
- [1. Local Deployment](#1-local-deployment)
|
||||
- [2. Demo Deployment](#2-demo-deployment)
|
||||
- [3. White Label Deployment](#3-white-label-deployment)
|
||||
- [Testing](#testing)
|
||||
- [Linting & Formatting](#linting--formatting)
|
||||
- [CI/CD](#cicd)
|
||||
- [Security Notes](#security-notes)
|
||||
- [Contributing](#contributing)
|
||||
- [Roadmap](#roadmap)
|
||||
- [License](#license)
|
||||
- [Acknowledgements](#acknowledgements)
|
||||
- [Contact](#contact)
|
||||
|
||||
---
|
||||
|
||||
## About
|
||||
|
||||
**Getcito** is an open-source, self-hosted platform for tracking and optimizing your **AI visibility** — also known as:
|
||||
|
||||
- **AEO** — Answer Engine Optimization
|
||||
- **GEO** — Generative Engine Optimization
|
||||
- **LLMO** — LLM Optimization
|
||||
|
||||
Getcito tracks how AI answer engines (ChatGPT, Google AI Mode, Perplexity, Gemini, Copilot, Grok, and others) mention, cite, and describe your brand — so you can benchmark competitors and grow your presence in AI answers. Run it on your own infrastructure, own your data, and audit exactly how every metric is calculated.
|
||||
|
||||
It is a free alternative to hosted tools like Profound, Peec, and Otterly.
|
||||
|
||||
## Features
|
||||
|
||||
- 📊 **Prompt tracking** — schedule prompts and record how AI engines answer them over time.
|
||||
- 🔎 **Citation & mention analysis** — extract which brands, domains, and sources each AI response cites.
|
||||
- 🏷️ **Brand & competitor benchmarking** — track your brand alongside competitors on the same prompts.
|
||||
- 🤖 **Multiple AI surfaces** — ChatGPT, Google AI Mode, Google AI Overview, Perplexity, Gemini, Copilot, Grok.
|
||||
- 🔌 **Pluggable scrapers & LLM providers** — BrightData, Oxylabs, Olostep, Cloro, and DataForSEO for scraping; OpenRouter, Anthropic, OpenAI, Mistral, and Azure AI Foundry for direct LLM calls.
|
||||
- 📄 **Reports** — background report generation over collected data.
|
||||
- ⚙️ **Background job engine** — durable scheduling and retries via `pg-boss` on PostgreSQL.
|
||||
- 🚦 **Admin operations** — inspect and reprioritize the prompt queue, review provider usage and latency, and safely delete brands.
|
||||
- 📅 **Flexible reporting** — share custom date ranges and compare range-average visibility with the latest result.
|
||||
- 🔑 **REST API** — Bearer-authenticated `/api/v1` for brands, competitors, prompts, and reports.
|
||||
- 🎨 **White-label mode** — rebrandable deployment with Auth0 SSO.
|
||||
|
||||
## Demo
|
||||
|
||||
Try the live demo at **[demo.getcito.com](https://demo.getcito.com)** to see prompt tracking and citation analysis in action.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Language | [TypeScript](https://www.typescriptlang.org/) 6, [Node.js](https://nodejs.org/) 24 |
|
||||
| Monorepo | [pnpm](https://pnpm.io/) workspaces + [Turborepo](https://turbo.build/repo) |
|
||||
| Web app | [TanStack Start](https://tanstack.com/start) / [TanStack Router](https://tanstack.com/router) + [React 19](https://react.dev/), [Vite](https://vitejs.dev/), [Nitro](https://nitro.build/) |
|
||||
| UI | [Tailwind CSS 4](https://tailwindcss.com/), [Radix UI](https://www.radix-ui.com/), [Recharts](https://recharts.org/), [Storybook](https://storybook.js.org/) |
|
||||
| Background jobs | [pg-boss](https://github.com/timgit/pg-boss) |
|
||||
| Database | [PostgreSQL 16+](https://www.postgresql.org/) (Docker Compose defaults to 18) + [Drizzle ORM](https://orm.drizzle.team/) / drizzle-kit |
|
||||
| Auth | [Better Auth](https://www.better-auth.com/) + [Auth0](https://auth0.com/) (white-label SSO) |
|
||||
| AI SDKs | [Vercel AI SDK](https://sdk.vercel.ai/), `@ai-sdk/anthropic`, `@ai-sdk/openai`, `@anthropic-ai/sdk` |
|
||||
| Tooling | [Biome](https://biomejs.dev/) (lint/format), [Vitest](https://vitest.dev/), [Playwright](https://playwright.dev/), [Knip](https://knip.dev/), [Changesets](https://github.com/changesets/changesets) |
|
||||
| Delivery | [Docker](https://www.docker.com/) / Docker Compose |
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Getcito is a pnpm/Turborepo monorepo of two apps backed by shared packages. The **web** app serves the dashboard and REST API; the **worker** runs scheduled scraping, analysis, and report jobs off a `pg-boss` queue in PostgreSQL. Scraper and LLM providers are called by the worker to collect AI answers.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
User([User / Browser]) -->|HTTP| Web[apps/web<br/>TanStack Start dashboard + /api/v1]
|
||||
Web -->|read/write| DB[(PostgreSQL<br/>Drizzle schema)]
|
||||
Web -->|enqueue jobs| DB
|
||||
Worker[apps/worker<br/>pg-boss jobs] -->|poll queues| DB
|
||||
Worker -->|scrape AI answers| Scrapers[BrightData / Oxylabs<br/>Olostep / DataForSEO]
|
||||
Worker -->|direct LLM calls| LLM[OpenRouter / Anthropic<br/>OpenAI / Mistral]
|
||||
```
|
||||
|
||||
**Prompt tracking lifecycle:**
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Sched as schedule-maintenance (cron */5)
|
||||
participant Q as pg-boss queue
|
||||
participant W as process-prompt job
|
||||
participant P as Scraper / LLM provider
|
||||
participant DB as PostgreSQL
|
||||
|
||||
Sched->>Q: enqueue due prompts
|
||||
Q->>W: dispatch process-prompt
|
||||
W->>P: run prompt on AI surface
|
||||
P-->>W: answer + citations
|
||||
W->>DB: persist snapshot & metrics
|
||||
```
|
||||
|
||||
Worker queues (from `apps/worker/src`): `process-prompt`, `generate-report`, `analyze-brand`, `schedule-maintenance` (cron `*/5 * * * *`), and `sync-auth0-memberships` (white-label only, `*/15 * * * *`).
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
getcito/
|
||||
├── apps/
|
||||
│ ├── web/ # TanStack Start dashboard + /api/v1 (Vite dev :3000, Nitro build)
|
||||
│ └── worker/ # pg-boss background jobs (tsx runtime)
|
||||
├── packages/
|
||||
│ ├── api-spec/ # OpenAPI 3 spec (openapi.json)
|
||||
│ ├── config/ # Env registry/validation, scrape-target parsing, shared constants
|
||||
│ ├── deployment/ # Deployment-mode wiring (local / demo / whitelabel)
|
||||
│ ├── lib/ # DB (Drizzle), auth (Better Auth), providers, onboarding, DataForSEO
|
||||
│ ├── local/ # Local-mode UI/components
|
||||
│ ├── og/ # Open Graph image rendering (Takumi)
|
||||
│ ├── ui/ # Design system (Radix + Tailwind)
|
||||
│ └── whitelabel/ # White-label theming + Auth0 hooks
|
||||
├── docker/ # Multi-stage Dockerfile + entrypoint
|
||||
├── e2e/ # Playwright end-to-end tests
|
||||
├── scripts/ # License check, release notes, version sync
|
||||
├── docker-compose.yml
|
||||
├── docker-compose.whitelabel.yml
|
||||
├── turbo.json
|
||||
└── pnpm-workspace.yaml
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
**For Docker deployment:**
|
||||
|
||||
- [Docker](https://docs.docker.com/get-docker/) with Docker Compose
|
||||
|
||||
**For local development:**
|
||||
|
||||
- [Node.js](https://nodejs.org/) **24.x** (enforced via `engines`; use `nvm use 24`)
|
||||
- [pnpm](https://pnpm.io/) **11.10.0** (`corepack enable pnpm`)
|
||||
- [PostgreSQL 16 or newer](https://www.postgresql.org/) (or run it via Docker, which defaults to 18)
|
||||
|
||||
## Installation
|
||||
|
||||
### Option A — Docker Compose (recommended)
|
||||
|
||||
Builds and runs the full stack (PostgreSQL, migrations, web, worker, Adminer) from source.
|
||||
|
||||
```bash
|
||||
# Clone
|
||||
git clone https://github.com/ai-search-guru/getcito-worlds-first-open-source-aio-aeo-or-geo-tool.git
|
||||
cd getcito-worlds-first-open-source-aio-aeo-or-geo-tool
|
||||
|
||||
# Create the environment file and fill in real values (see Configuration)
|
||||
cp .env.example .env
|
||||
|
||||
# Build and start
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Services exposed on the host:
|
||||
|
||||
| Service | URL | Notes |
|
||||
|---------|-----|-------|
|
||||
| Web app | http://localhost:3001 | Maps to container port 3000 |
|
||||
| Adminer (DB UI) | http://localhost:8081 | PostgreSQL admin |
|
||||
| PostgreSQL | localhost:5433 | Maps to container port 5432 |
|
||||
|
||||
> [!TIP]
|
||||
> **Watch** this repo's **releases** to get notified of major updates.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Upgrading an existing Compose installation does not upgrade its database files. Set `POSTGRES_IMAGE_TAG=16-alpine` in `.env` before pulling this release. Move to PostgreSQL 18 only after dumping the PostgreSQL 16 database and restoring it into a fresh PostgreSQL 18 volume.
|
||||
|
||||
### Option B — Local development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
corepack enable pnpm
|
||||
pnpm install
|
||||
|
||||
# Env file is read from the repo root AND apps/web/.env (see Configuration)
|
||||
# Copy the committed templates, then replace their placeholder values.
|
||||
cp .env.example .env
|
||||
cp apps/web/.env.example apps/web/.env
|
||||
|
||||
# Run all dev servers via Turborepo
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Dev port: **web** → http://localhost:3000.
|
||||
The **worker** loads env from `apps/web/.env` (via `--env-file`) and is only needed for background job processing.
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables are the single source of truth. The canonical registry lives in `packages/config/src/env-registry.ts`; startup validation reports any missing **required** vars for the active `DEPLOYMENT_MODE`.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Start from `.env.example` at the repo root and `apps/web/.env.example`. Because the worker reads `apps/web/.env` during `pnpm dev`, copy the app template there as shown above.
|
||||
|
||||
**Deployment modes** (`DEPLOYMENT_MODE`): `local`, `demo`, `whitelabel`, `cloud`.
|
||||
Required-column legend: **local** = required in local mode · **wl** = required in white-label mode · **scraper** = required only when `SCRAPE_TARGETS` references that provider · _optional_ = never required at startup.
|
||||
|
||||
### Core
|
||||
|
||||
| Variable | Scope | Required | Description |
|
||||
|----------|-------|----------|-------------|
|
||||
| `DATABASE_URL` | server | local, demo, wl | PostgreSQL connection string. |
|
||||
| `DEPLOYMENT_MODE` | server | local, demo, wl | `local`, `demo`, `whitelabel`, or `cloud`. |
|
||||
| `BETTER_AUTH_SECRET` | server | local, demo, wl | Session cookie encryption secret (random hex). |
|
||||
| `SCRAPE_TARGETS` | server | local, demo, wl | Comma-separated `model:provider[:version][:online]` entries, e.g. `chatgpt:brightdata:online,google-ai-mode:brightdata:online`. |
|
||||
| `APP_URL` | server | optional | Public base URL of the web app. |
|
||||
| `VITE_APP_URL` | client | wl | Client-visible app URL. |
|
||||
| `VITE_DEPLOYMENT_MODE` | client | optional | Client-visible copy of `DEPLOYMENT_MODE`. |
|
||||
| `VITE_APP_TIMEZONE` | client | optional | IANA timezone used across charts and dates. Falls back to the runtime timezone; `Asia/Kolkata` is an example deployment value. |
|
||||
| `VITE_APP_LOCALE` | client | optional | BCP 47 locale used for date/number formatting. Falls back to the runtime locale; `en-IN` is an example deployment value. |
|
||||
| `ONBOARDING_LLM_TARGET` | server | optional | Explicit `model:provider` used for structured onboarding research. |
|
||||
| `DISABLE_TELEMETRY` | server | optional | Set to any value to disable telemetry. |
|
||||
| `ENVIRONMENT` | server | optional | Environment name reported to Sentry. |
|
||||
|
||||
### Scraper providers (required when referenced by `SCRAPE_TARGETS`)
|
||||
|
||||
| Variable | Provider | Description |
|
||||
|----------|----------|-------------|
|
||||
| `BRIGHTDATA_API_TOKEN` | brightdata | BrightData API token. |
|
||||
| `OXYLABS_USERNAME` / `OXYLABS_PASSWORD` | oxylabs | Oxylabs Web Scraper API credentials. |
|
||||
| `OLOSTEP_API_KEY` | olostep | Olostep API key. |
|
||||
| `DATAFORSEO_LOGIN` / `DATAFORSEO_PASSWORD` | dataforseo | DataForSEO account credentials. |
|
||||
| `CLORO_API_KEY` | cloro | Cloro API key. Cloro targets must use `:online`. |
|
||||
|
||||
### Direct LLM providers (required when referenced by `SCRAPE_TARGETS`)
|
||||
|
||||
| Variable | Provider | Description |
|
||||
|----------|----------|-------------|
|
||||
| `OPENROUTER_API_KEY` | openrouter | OpenRouter API key (one key, many models). |
|
||||
| `ANTHROPIC_API_KEY` | anthropic-api | Anthropic API key. |
|
||||
| `OPENAI_API_KEY` | openai-api | OpenAI API key. |
|
||||
| `MISTRAL_API_KEY` | mistral-api | Mistral API key. |
|
||||
| `AZURE_FOUNDRY_API_KEY` / `AZURE_FOUNDRY_BASE_URL` | azure-foundry-api | Azure AI Foundry key and endpoint URL. |
|
||||
|
||||
### White-label (Auth0)
|
||||
|
||||
| Variable | Scope | Required | Description |
|
||||
|----------|-------|----------|-------------|
|
||||
| `AUTH0_CLIENT_ID` | server | wl | Auth0 client ID. |
|
||||
| `AUTH0_CLIENT_SECRET` | server | wl | Auth0 client secret. |
|
||||
| `AUTH0_MGMT_API_DOMAIN` | server | wl | Auth0 Management API domain. |
|
||||
| `AUTH0_DOMAIN` / `AUTH0_AUDIENCE` / `AUTH0_SCOPE` | server | optional | Auth0 tenant/API config. |
|
||||
| `VITE_APP_NAME` / `VITE_APP_ICON` | client | wl | White-label app name and icon URL. |
|
||||
| `VITE_APP_PARENT_NAME` / `VITE_APP_PARENT_URL` | client | wl | Parent brand name and URL. |
|
||||
| `VITE_OPTIMIZATION_URL_TEMPLATE` | client | wl | Optimization link template (`{brandId}`, `{prompt}`, `{webQuery}`). |
|
||||
| `VITE_AUTH0_DOMAIN` / `VITE_AUTH0_CLIENT_ID` | client | optional | Client-exposed Auth0 config. |
|
||||
|
||||
### Optional integrations
|
||||
|
||||
| Variable | Scope | Description |
|
||||
|----------|-------|-------------|
|
||||
| `SENTRY_DSN` / `VITE_SENTRY_DSN` | server / client | Error reporting DSNs. |
|
||||
| `SENTRY_ORG` / `SENTRY_PROJECT` / `SENTRY_AUTH_TOKEN` | server | Sourcemap upload at build time. |
|
||||
| `VITE_POSTHOG_KEY` | client | PostHog project API key. |
|
||||
| `VITE_PLAUSIBLE_DOMAIN` / `VITE_CLARITY_PROJECT_ID` | client | Analytics config. |
|
||||
| `VITE_CHART_COLORS` | client | Comma-separated chart palette override. |
|
||||
| `ADMIN_AUTH0_SUB` / `ADMIN_API_KEYS` | server | Admin access (subject claim / bearer tokens). |
|
||||
| `DEFAULT_BRAND_DOMAINS` | server | Comma-separated domains added as default brands. |
|
||||
| `DBOS_SYSTEM_DATABASE_URL` | server | Override for the DBOS system database URL. |
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
pnpm dev # start all dev servers (web :3000) via Turborepo
|
||||
pnpm build # build all apps/packages for production
|
||||
pnpm test # run unit tests (Vitest)
|
||||
pnpm lint # lint with Biome
|
||||
pnpm format # format with Biome
|
||||
```
|
||||
|
||||
**Production (per app):** `pnpm build` emits a Nitro server bundle for `web`; start with `node .output/server/index.mjs` (see the app's `start` script). The worker runs via `tsx src/index.ts`.
|
||||
|
||||
## Available Scripts
|
||||
|
||||
**Root** (`package.json`):
|
||||
|
||||
| Script | Command | Purpose |
|
||||
|--------|---------|---------|
|
||||
| `dev` | `turbo dev` | Start all dev servers. |
|
||||
| `build` | `turbo build` | Build all packages. |
|
||||
| `lint` | `turbo lint` | Biome lint across the workspace. |
|
||||
| `test` | `turbo test` | Run unit tests. |
|
||||
| `format` | `biome format --write .` | Format the codebase. |
|
||||
| `knip` | `knip` | Report unused files/deps/exports. |
|
||||
| `changeset` | `changeset` | Create a changeset. |
|
||||
| `version-packages` | `changeset version && node scripts/sync-root-version.mjs` | Bump versions. |
|
||||
| `release` | `pnpm build && changeset publish` | Build & publish. |
|
||||
| `test:e2e` | `pnpm --filter e2e test:e2e` | Run Playwright E2E tests. |
|
||||
| `test:e2e-local` | `bash e2e/run-local.sh` | Run E2E against a local stack. |
|
||||
| `license-check` | `node scripts/check-licenses.mjs` | Audit dependency licenses. |
|
||||
|
||||
**Per app / package (selected):**
|
||||
|
||||
| Package | Scripts |
|
||||
|---------|---------|
|
||||
| `apps/web` | `dev` (`vite dev --port 3000`), `build`, `start`, `preview`, `test`, `test:storybook`, `storybook`, `lint`, `check-types` |
|
||||
| `apps/worker` | `dev` (`tsx --env-file=../web/.env src/index.ts`), `start`, `build`, `check-types` |
|
||||
| `packages/lib` | `test`, `generate:auth-schema`, `compare:onboarding` |
|
||||
|
||||
## API Documentation
|
||||
|
||||
The web app exposes a REST API under **`/api/v1`**. The full contract is defined in [`packages/api-spec/src/openapi.json`](packages/api-spec/src/openapi.json) (OpenAPI 3).
|
||||
|
||||
**Authentication:** Bearer token — `Authorization: Bearer <token>`.
|
||||
|
||||
| Resource | Endpoints |
|
||||
|----------|-----------|
|
||||
| Brands | `GET`/`POST` `/brands` · `GET`/`PATCH` `/brands/{brandId}` |
|
||||
| Competitors | `GET`/`POST` `/competitors` · `GET`/`PATCH`/`DELETE` `/competitors/{competitorId}` |
|
||||
| Prompts | `GET`/`POST` `/prompts` · `GET`/`PATCH`/`DELETE` `/prompts/{promptId}` · `GET` `/prompts/{promptId}/snapshot` |
|
||||
| Reports | `GET`/`POST` `/reports` · `GET` `/reports/{reportId}` |
|
||||
| Tools | `POST` `/tools/analyze` |
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $GETCITO_TOKEN" \
|
||||
http://localhost:3001/api/v1/brands
|
||||
```
|
||||
|
||||
## Database
|
||||
|
||||
- **PostgreSQL 16+**, accessed via **Drizzle ORM** (schema in `packages/lib/src/db`). Docker Compose defaults new installations to PostgreSQL 18.
|
||||
- Migrations are managed with **drizzle-kit**. Run from `packages/lib`:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/getcito \
|
||||
npx drizzle-kit migrate
|
||||
```
|
||||
|
||||
Under Docker Compose, the `db-migrate` service runs migrations automatically before `web` and `worker` start. `pg-boss` also creates its own queue schema on worker startup.
|
||||
|
||||
## Deployment Documentation
|
||||
|
||||
Getcito supports multiple deployment models via Docker Compose. Below are the comprehensive guides for deploying Getcito locally, as a demo, or as a fully white-labeled instance.
|
||||
|
||||
### 1. Local Deployment
|
||||
|
||||
The local deployment builds and runs the full stack (PostgreSQL, automatic migrations, web dashboard, background worker, and Adminer DB UI) from source.
|
||||
|
||||
**Prerequisites:**
|
||||
- [Docker](https://docs.docker.com/get-docker/) with Docker Compose plugin.
|
||||
- A clone of this repository.
|
||||
|
||||
**Step-by-step Setup:**
|
||||
|
||||
1. **Configure Environment Variables**
|
||||
Copy the committed template to `.env`, then fill in your actual values. Ensure `DEPLOYMENT_MODE` is set to `local`:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
*Required variables include: `DATABASE_URL`, `DEPLOYMENT_MODE`, `BETTER_AUTH_SECRET`, and any required `SCRAPE_TARGETS` API keys.*
|
||||
|
||||
2. **Build Docker Images**
|
||||
```bash
|
||||
docker compose build
|
||||
```
|
||||
|
||||
3. **Start the Stack**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**What happens at startup:**
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant D as Docker Compose
|
||||
participant DB as PostgreSQL
|
||||
participant M as db-migrate
|
||||
participant W as Web / Worker
|
||||
D->>DB: Start Database
|
||||
DB-->>D: Healthy (port 5433)
|
||||
D->>M: Run Migrations (drizzle-kit)
|
||||
M-->>D: Completed
|
||||
D->>W: Start Web and Worker
|
||||
```
|
||||
|
||||
**Access URLs:**
|
||||
- **Web App**: http://localhost:3001
|
||||
- **Adminer (Database UI)**: http://localhost:8081
|
||||
- **PostgreSQL**: `localhost:5433` (Username: `postgres`, Password: `postgres`, DB: `getcito`)
|
||||
|
||||
**Common Commands:**
|
||||
- View logs: `docker compose logs -f web`
|
||||
- Stop services: `docker compose down`
|
||||
- Rebuild containers: `docker compose build --no-cache`
|
||||
- Wipe database volume: `docker compose down -v`
|
||||
|
||||
### 2. Demo Deployment
|
||||
|
||||
The Demo deployment mode (`DEPLOYMENT_MODE=demo`) is designed to host a public or staging instance where users can test the application.
|
||||
|
||||
**Demo Capabilities Verified in Repo:**
|
||||
- The login screen will automatically read `VITE_DEMO_EMAIL` and `VITE_DEMO_PASSWORD` from your environment variables to pre-fill the login form for test users.
|
||||
- Relies on the same standard infrastructure (PostgreSQL, web, worker).
|
||||
|
||||
**Setup:**
|
||||
1. Configure your `.env` file with `DEPLOYMENT_MODE=demo` and `VITE_DEPLOYMENT_MODE=demo`.
|
||||
2. Add your demo credentials:
|
||||
```env
|
||||
VITE_DEMO_EMAIL=team@getcito.com
|
||||
VITE_DEMO_PASSWORD=your_demo_password
|
||||
```
|
||||
3. Deploy using the standard Compose commands:
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
*(Note: External cloud hosting or demo-specific infrastructure is not provided in this repository; it relies solely on the `demo` mode application flag).*
|
||||
|
||||
### 3. White Label Deployment
|
||||
|
||||
Getcito supports a fully rebrandable **White Label** mode powered by Auth0 SSO.
|
||||
|
||||
**White Label Capabilities:**
|
||||
- Custom branding baked into the client bundle at build time: App Name, App Icon, Parent Brand URLs, and Custom Optimization URL Templates.
|
||||
- Authentication delegated completely to Auth0.
|
||||
- Includes a Docker Compose file with configurable host ports (Web `3000`, DB `5432`, Adminer `8080` by default).
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. **Review the Compose Template**
|
||||
The repository includes [`docker-compose.whitelabel.yml`](docker-compose.whitelabel.yml). It contains no secrets; all credentials and branding come from `.env`.
|
||||
|
||||
<details>
|
||||
<summary>Click to view <code>docker-compose.whitelabel.yml</code> template</summary>
|
||||
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:${POSTGRES_IMAGE_TAG:-18-alpine}
|
||||
ports:
|
||||
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: getcito
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
adminer:
|
||||
image: adminer
|
||||
restart: always
|
||||
ports:
|
||||
- "${ADMINER_PORT:-8080}:8080"
|
||||
environment:
|
||||
ADMINER_DEFAULT_SERVER: postgres
|
||||
start_period: 30s
|
||||
|
||||
db-migrate:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile
|
||||
target: migrate
|
||||
environment:
|
||||
- DATABASE_URL=postgres://postgres:postgres@postgres:5432/getcito
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile
|
||||
target: web
|
||||
args:
|
||||
DEPLOYMENT_MODE: whitelabel
|
||||
VITE_DEPLOYMENT_MODE: whitelabel
|
||||
VITE_APP_NAME: ${VITE_APP_NAME}
|
||||
VITE_APP_ICON: ${VITE_APP_ICON}
|
||||
VITE_APP_URL: ${VITE_APP_URL}
|
||||
VITE_APP_PARENT_NAME: ${VITE_APP_PARENT_NAME}
|
||||
VITE_APP_PARENT_URL: ${VITE_APP_PARENT_URL}
|
||||
VITE_OPTIMIZATION_URL_TEMPLATE: ${VITE_OPTIMIZATION_URL_TEMPLATE}
|
||||
VITE_ONBOARDING_REDIRECT_URL_TEMPLATE: ${VITE_ONBOARDING_REDIRECT_URL_TEMPLATE}
|
||||
VITE_AUTH0_DOMAIN: ${VITE_AUTH0_DOMAIN}
|
||||
VITE_AUTH0_CLIENT_ID: ${VITE_AUTH0_CLIENT_ID}
|
||||
VITE_APP_TIMEZONE: ${VITE_APP_TIMEZONE:-}
|
||||
VITE_APP_LOCALE: ${VITE_APP_LOCALE:-}
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "${WEB_PORT:-3000}:3000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
db-migrate:
|
||||
condition: service_completed_successfully
|
||||
|
||||
worker:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile
|
||||
target: worker
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
db-migrate:
|
||||
condition: service_completed_successfully
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
```
|
||||
</details>
|
||||
|
||||
2. **Configure Environment Variables**
|
||||
Copy `.env.whitelabel.example` to `.env`, then provide the required Auth0, branding, and provider values. Existing PostgreSQL 16 deployments must keep `POSTGRES_IMAGE_TAG=16-alpine` until completing a dump/restore migration.
|
||||
```env
|
||||
POSTGRES_IMAGE_TAG=18-alpine
|
||||
DEPLOYMENT_MODE=whitelabel
|
||||
VITE_DEPLOYMENT_MODE=whitelabel
|
||||
AUTH0_CLIENT_ID=your_client_id
|
||||
AUTH0_CLIENT_SECRET=your_client_secret
|
||||
AUTH0_MGMT_API_DOMAIN=your_mgmt_api_domain
|
||||
VITE_APP_NAME="Acme AI Search"
|
||||
VITE_APP_ICON="https://cdn.example.com/icon.png"
|
||||
VITE_APP_URL="http://localhost:3000"
|
||||
VITE_APP_PARENT_NAME="Acme"
|
||||
VITE_APP_PARENT_URL="http://localhost:3000"
|
||||
VITE_OPTIMIZATION_URL_TEMPLATE="..."
|
||||
VITE_APP_TIMEZONE=Asia/Kolkata
|
||||
VITE_APP_LOCALE=en-IN
|
||||
```
|
||||
|
||||
3. **Build and Deploy**
|
||||
*Important: Client variables (`VITE_*`) are read during the build step and baked into the image.*
|
||||
```bash
|
||||
docker compose -f docker-compose.whitelabel.yml build
|
||||
docker compose -f docker-compose.whitelabel.yml up -d
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
pnpm test # unit tests (Vitest) across the workspace
|
||||
pnpm --filter @workspace/web test:storybook # Storybook interaction tests
|
||||
|
||||
# End-to-end (Playwright) — requires browsers + a running app
|
||||
pnpm exec playwright install
|
||||
pnpm test:e2e # or: pnpm test:e2e-local
|
||||
```
|
||||
|
||||
E2E tests live in `e2e/` and are separate from unit tests.
|
||||
|
||||
## Linting & Formatting
|
||||
|
||||
Both linting and formatting are handled by **[Biome](https://biomejs.dev/)** (config in `biome.json`).
|
||||
|
||||
```bash
|
||||
pnpm lint # turbo lint (biome lint per package)
|
||||
pnpm format # biome format --write .
|
||||
pnpm knip # detect unused files, dependencies, and exports
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Biome reports Tailwind `@apply` directive warnings in CSS files — these are pre-existing and expected.
|
||||
|
||||
## CI/CD
|
||||
|
||||
Releases are managed on `master` by the GitHub Actions release workflow and **Changesets** (`pnpm changeset` → `pnpm version-packages` → GitHub Release).
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **Never commit secrets.** API keys, `.env` contents, and auth secrets must stay out of version control. Generate `BETTER_AUTH_SECRET` as random hex per deployment.
|
||||
- Docker runtime images run as **non-root** users.
|
||||
- `pnpm-workspace.yaml` pins security-patched transitive dependency versions (see the `overrides` block) and enforces a minimum release age / no-downgrade trust policy for new packages.
|
||||
- Report vulnerabilities privately per [`SECURITY.md`](SECURITY.md) — email [team@getcito.com](mailto:team@getcito.com), do not open a public issue.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. For minor fixes, open a PR directly; for larger changes, open an issue first. See [`CONTRIBUTING.md`](CONTRIBUTING.md) and [`AGENTS.md`](AGENTS.md).
|
||||
|
||||
- **Branching / PRs:** open PRs from your own fork/account against `master`.
|
||||
- **Coding standards:** TypeScript; format & lint with Biome (`pnpm format`, `pnpm lint`); keep changes type-clean (`check-types`).
|
||||
- **Changesets:** add a short, end-user-focused changeset scoped to the affected packages (`pnpm changeset`).
|
||||
- **CLA:** before your first PR merges, add your GitHub username to [`.github/contributors.txt`](.github/contributors.txt) — see [`CLA.md`](CLA.md).
|
||||
- **Code of Conduct:** see [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md).
|
||||
|
||||
## Roadmap
|
||||
|
||||
Tracked on the [GitHub Project board](https://github.com/orgs/ai-search-guru/projects/3/views/1) and in [Issues](https://github.com/ai-search-guru/getcito-worlds-first-open-source-aio-aeo-or-geo-tool/issues).
|
||||
|
||||
## License
|
||||
|
||||
Released under the [MIT License](LICENSE.md).
|
||||
|
||||
GetCito v9 is a fork of [Elmo](https://github.com/elmohq/elmo), © 2026 Blue Whale Software, LLC, used under the MIT License. Modifications and additions in this repository are © 2026 GetCito. Both copyright notices are retained in [LICENSE.md](LICENSE.md).
|
||||
|
||||
Contributions are covered by the [Contributor License Agreement](CLA.md).
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
Based on [Elmo](https://github.com/elmohq/elmo) and built with TanStack Start, Drizzle ORM, Better Auth, pg-boss, Fumadocs, Biome, and Turborepo, and integrates BrightData, Oxylabs, Olostep, Cloro, DataForSEO, Azure AI Foundry, OpenRouter, Anthropic, OpenAI, and Mistral.
|
||||
|
||||
## Contact
|
||||
|
||||
- 📧 [team@getcito.com](mailto:team@getcito.com)
|
||||
- 📞 India: +91-96505-10773 · US: +1-623-223-7423
|
||||
39
SECURITY.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We apply security fixes only to the **latest released version** of Getcito. If you are running an older version, please upgrade before reporting an issue.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do not open a public GitHub issue for security vulnerabilities.**
|
||||
|
||||
We take the security of Getcito seriously. If you discover a vulnerability, we ask that you report it to us confidentially so we can investigate and patch it before it is exploited.
|
||||
|
||||
Email [team@getcito.com](mailto:team@getcito.com) with:
|
||||
- A description of the issue and its potential impact.
|
||||
- Detailed steps to reproduce (proof-of-concept code or scripts are highly appreciated).
|
||||
- The version, branch, or commit hash you tested against.
|
||||
- Your name or handle if you would like to be credited in the responsible disclosure.
|
||||
|
||||
Please adhere to responsible disclosure expectations and give us a reasonable amount of time to investigate, resolve, and release a fix before publishing any details publicly.
|
||||
|
||||
## Response Timeline
|
||||
|
||||
- **Initial acknowledgement**: Within 48 hours of receiving your report.
|
||||
- **Investigation**: We aim to confirm the vulnerability and assess its severity within 7 days.
|
||||
- **Resolution**: A patch will be prioritized and released as soon as possible, depending on the complexity of the fix. We will keep you updated on our progress.
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
When contributing to or deploying Getcito, please follow these security best practices:
|
||||
- **Secrets**: Never commit API keys, Auth0 secrets, database passwords, or `.env` files to version control. Use `.env.local` for local overrides.
|
||||
- **Environment Variables**: Always validate environment variables at startup. Use `packages/config/src/env-registry.ts` to enforce required variables securely.
|
||||
- **Dependencies**: Keep dependencies up to date. Avoid installing unverified third-party npm packages.
|
||||
- **Authentication**: All API endpoints under `/api/v1` must strictly enforce Bearer token authentication.
|
||||
- **Docker Security**: Our Docker containers run as a non-root user (`getcito` or `worker`). Do not alter the Dockerfile to run processes as root. Keep your host machine's Docker daemon secure.
|
||||
|
||||
## Contact
|
||||
|
||||
For all security-related inquiries, please contact:
|
||||
**Email**: [team@getcito.com](mailto:team@getcito.com)
|
||||
25
apps/web/.env.example
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/getcito
|
||||
DEPLOYMENT_MODE=local
|
||||
VITE_DEPLOYMENT_MODE=local
|
||||
BETTER_AUTH_SECRET=replace-with-random-hex
|
||||
APP_URL=http://localhost:3000
|
||||
VITE_APP_URL=http://localhost:3000
|
||||
VITE_APP_TIMEZONE=Asia/Kolkata
|
||||
VITE_APP_LOCALE=en-IN
|
||||
DISABLE_TELEMETRY=1
|
||||
|
||||
SCRAPE_TARGETS=chatgpt:openai-api
|
||||
ONBOARDING_LLM_TARGET=chatgpt:openai-api
|
||||
OPENAI_API_KEY=replace-me
|
||||
ANTHROPIC_API_KEY=
|
||||
MISTRAL_API_KEY=
|
||||
OPENROUTER_API_KEY=
|
||||
AZURE_FOUNDRY_API_KEY=
|
||||
AZURE_FOUNDRY_BASE_URL=
|
||||
CLORO_API_KEY=
|
||||
OLOSTEP_API_KEY=
|
||||
BRIGHTDATA_API_TOKEN=
|
||||
OXYLABS_USERNAME=
|
||||
OXYLABS_PASSWORD=
|
||||
DATAFORSEO_LOGIN=
|
||||
DATAFORSEO_PASSWORD=
|
||||
174
apps/web/.env.local
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
# ==============================================================================
|
||||
# Core Database & Application Settings
|
||||
# ==============================================================================
|
||||
# PostgreSQL connection string. (Required)
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/getcito
|
||||
|
||||
# Deployment mode: local, demo, whitelabel, or cloud. (Required)
|
||||
DEPLOYMENT_MODE=local
|
||||
|
||||
# Client-visible copy of DEPLOYMENT_MODE. (Optional)
|
||||
VITE_DEPLOYMENT_MODE=local
|
||||
|
||||
# Session cookie encryption secret. (Required)
|
||||
BETTER_AUTH_SECRET=your_secret_here
|
||||
|
||||
# Public base URL of the web app. (Optional)
|
||||
APP_URL=http://localhost:3000
|
||||
|
||||
# Set to any value to disable telemetry. (Optional)
|
||||
DISABLE_TELEMETRY=1
|
||||
|
||||
# Fallback cadence (hours). Default is 24. (Optional)
|
||||
DEFAULT_DELAY_HOURS=24
|
||||
|
||||
# Demo email. (Optional)
|
||||
VITE_DEMO_EMAIL=team@getcito.com
|
||||
|
||||
# Demo password. (Optional)
|
||||
VITE_DEMO_PASSWORD=your_secret_here
|
||||
|
||||
# ==============================================================================
|
||||
# Scrapers & LLM Providers
|
||||
# ==============================================================================
|
||||
# Comma-separated model:provider[:version][:online] entries. (Required)
|
||||
# Example: chatgpt:brightdata:online,google-ai-mode:brightdata:online
|
||||
SCRAPE_TARGETS=chatgpt:brightdata:online,google-ai-mode:brightdata:online
|
||||
|
||||
# BrightData API token. (Required if referenced in SCRAPE_TARGETS)
|
||||
BRIGHTDATA_API_TOKEN=your_api_key_here
|
||||
|
||||
# Olostep API key. (Required if referenced in SCRAPE_TARGETS)
|
||||
OLOSTEP_API_KEY=your_api_key_here
|
||||
|
||||
# Oxylabs Web Scraper API username. (Required if referenced in SCRAPE_TARGETS)
|
||||
OXYLABS_USERNAME=change_me
|
||||
|
||||
# Oxylabs Web Scraper API password. (Required if referenced in SCRAPE_TARGETS)
|
||||
OXYLABS_PASSWORD=your_secret_here
|
||||
|
||||
# DataForSEO account login. (Required if referenced in SCRAPE_TARGETS)
|
||||
DATAFORSEO_LOGIN=change_me
|
||||
|
||||
# DataForSEO account password. (Required if referenced in SCRAPE_TARGETS)
|
||||
DATAFORSEO_PASSWORD=your_secret_here
|
||||
|
||||
# OpenRouter API key. (Required if referenced in SCRAPE_TARGETS)
|
||||
OPENROUTER_API_KEY=your_api_key_here
|
||||
|
||||
# OpenAI API key. (Required if referenced in SCRAPE_TARGETS)
|
||||
OPENAI_API_KEY=your_api_key_here
|
||||
|
||||
# Anthropic API key. (Required if referenced in SCRAPE_TARGETS)
|
||||
ANTHROPIC_API_KEY=your_api_key_here
|
||||
|
||||
# Mistral API key. (Required if referenced in SCRAPE_TARGETS)
|
||||
MISTRAL_API_KEY=your_api_key_here
|
||||
|
||||
# ==============================================================================
|
||||
# White-label & Auth0 (Required for whitelabel mode)
|
||||
# ==============================================================================
|
||||
# Auth0 client ID. (Required for whitelabel)
|
||||
AUTH0_CLIENT_ID=change_me
|
||||
|
||||
# Auth0 client secret. (Required for whitelabel)
|
||||
AUTH0_CLIENT_SECRET=your_secret_here
|
||||
|
||||
# Auth0 Management API domain. (Required for whitelabel)
|
||||
AUTH0_MGMT_API_DOMAIN=change_me
|
||||
|
||||
# Auth0 tenant domain (used for whitelabel logout redirects). (Optional)
|
||||
AUTH0_DOMAIN=change_me
|
||||
|
||||
# Auth0 API audience. (Optional)
|
||||
AUTH0_AUDIENCE=change_me
|
||||
|
||||
# Auth0 OAuth scopes. (Optional)
|
||||
AUTH0_SCOPE=change_me
|
||||
|
||||
# Auth0 subject claim granted admin access. (Optional)
|
||||
ADMIN_AUTH0_SUB=change_me
|
||||
|
||||
# Comma-separated bearer tokens accepted by the admin API. (Optional)
|
||||
ADMIN_API_KEYS=your_secret_here
|
||||
|
||||
# Application display name (e.g., 'Acme AI Search'). (Required for whitelabel)
|
||||
VITE_APP_NAME="Acme AI Search"
|
||||
|
||||
# Application icon URL (must be an external URL, e.g., 'https://cdn.example.com/icon.png'). (Required for whitelabel)
|
||||
VITE_APP_ICON=https://cdn.example.com/icon.png
|
||||
|
||||
# Application URL (e.g., 'https://ai.example.com/'). (Required for whitelabel)
|
||||
VITE_APP_URL=http://localhost:3000
|
||||
|
||||
# Parent application name (e.g., 'Acme'). (Required for whitelabel)
|
||||
VITE_APP_PARENT_NAME="Acme"
|
||||
|
||||
# Parent application URL (e.g., 'https://app.example.com/'). (Required for whitelabel)
|
||||
VITE_APP_PARENT_URL=http://localhost:3000
|
||||
|
||||
# URL template for optimization with placeholders {brandId}, {prompt}, {webQuery}. (Required for whitelabel)
|
||||
VITE_OPTIMIZATION_URL_TEMPLATE=https://app.example.com/optimize?org_id={brandId}&prompt={prompt}&web_query={webQuery}
|
||||
|
||||
# Redirect URL template (with {brandId} placeholder) for sending users back to the parent app after onboarding. (Optional)
|
||||
VITE_ONBOARDING_REDIRECT_URL_TEMPLATE=https://app.example.com/onboarding/{brandId}
|
||||
|
||||
# Auth0 domain exposed to the client. (Optional)
|
||||
VITE_AUTH0_DOMAIN=change_me
|
||||
|
||||
# Auth0 client ID exposed to the client. (Optional)
|
||||
VITE_AUTH0_CLIENT_ID=change_me
|
||||
|
||||
# ==============================================================================
|
||||
# Additional Features & Analytics
|
||||
# ==============================================================================
|
||||
# Comma-separated domains added as default brands. (Optional)
|
||||
DEFAULT_BRAND_DOMAINS=example.com
|
||||
|
||||
# Environment name reported to Sentry (e.g. production). (Optional)
|
||||
ENVIRONMENT=development
|
||||
|
||||
# Sentry DSN for server-side error reporting. (Optional)
|
||||
SENTRY_DSN=change_me
|
||||
|
||||
# Sentry org slug for sourcemap upload at build time. (Optional)
|
||||
SENTRY_ORG=change_me
|
||||
|
||||
# Sentry project slug for sourcemap upload at build time. (Optional)
|
||||
SENTRY_PROJECT=change_me
|
||||
|
||||
# Sentry auth token for sourcemap upload at build time. (Optional)
|
||||
SENTRY_AUTH_TOKEN=your_secret_here
|
||||
|
||||
# Sentry DSN for browser error reporting. (Optional)
|
||||
VITE_SENTRY_DSN=change_me
|
||||
|
||||
# PostHog project API key override. (Optional)
|
||||
VITE_POSTHOG_KEY=your_api_key_here
|
||||
|
||||
# Plausible analytics domain. (Optional)
|
||||
VITE_PLAUSIBLE_DOMAIN=change_me
|
||||
|
||||
# Microsoft Clarity project ID. (Optional)
|
||||
VITE_CLARITY_PROJECT_ID=change_me
|
||||
|
||||
# Comma-separated chart color palette override. (Optional)
|
||||
VITE_CHART_COLORS=#FF0000,#00FF00,#0000FF
|
||||
|
||||
# ==============================================================================
|
||||
# Marketing Site (www) Specific
|
||||
# ==============================================================================
|
||||
# Upstash Redis REST URL (www caching: status, GitHub stars/releases). (Optional)
|
||||
UPSTASH_REDIS_REST_URL=change_me
|
||||
|
||||
# Upstash Redis REST token. (Optional)
|
||||
UPSTASH_REDIS_REST_TOKEN=your_secret_here
|
||||
|
||||
# Upstash Redis endpoint. (Optional)
|
||||
UPSTASH_REDIS_ENDPOINT=change_me
|
||||
|
||||
# Vercel Blob token (www competitor screenshots). (Optional)
|
||||
BLOB_READ_WRITE_TOKEN=your_secret_here
|
||||
|
||||
# Override for the DBOS system database URL (read by the DBOS runtime). (Optional)
|
||||
DBOS_SYSTEM_DATABASE_URL=change_me
|
||||
65
apps/web/.storybook/main.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const mocksDir = path.resolve(__dirname, "../src/stories/_mocks");
|
||||
|
||||
const config: StorybookConfig = {
|
||||
framework: {
|
||||
name: "@storybook/react-vite",
|
||||
// Use a plugin-light vite config dedicated to Storybook; the app's main
|
||||
// vite.config.ts pulls in TanStack Start + Nitro plugins that conflict
|
||||
// with Storybook's multi-iframe bundling.
|
||||
options: {
|
||||
builder: {
|
||||
viteConfigPath: ".storybook/vite.config.ts",
|
||||
},
|
||||
},
|
||||
},
|
||||
stories: ["../src/stories/**/*.stories.{ts,tsx}"],
|
||||
addons: ["@storybook/addon-vitest"],
|
||||
core: {
|
||||
disableTelemetry: true,
|
||||
},
|
||||
async viteFinal(config) {
|
||||
const { mergeConfig } = await import("vite");
|
||||
const pkg = await import("../package.json", { with: { type: "json" } });
|
||||
|
||||
return mergeConfig(config, {
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
alias: [
|
||||
{ find: /^@\/hooks\/use-brands(\.tsx)?$/, replacement: path.resolve(mocksDir, "use-brands.tsx") },
|
||||
{ find: /^@\/hooks\/use-auth(\.tsx)?$/, replacement: path.resolve(mocksDir, "use-auth.tsx") },
|
||||
{
|
||||
find: /^@\/hooks\/use-chart-download(\.tsx)?$/,
|
||||
replacement: path.resolve(mocksDir, "use-chart-download.tsx"),
|
||||
},
|
||||
{ find: /^@\/hooks\/use-chart-export(\.tsx)?$/, replacement: path.resolve(mocksDir, "use-chart-export.tsx") },
|
||||
{
|
||||
find: /^@\/contexts\/chart-data-context(\.tsx)?$/,
|
||||
replacement: path.resolve(mocksDir, "chart-data-context.tsx"),
|
||||
},
|
||||
{ find: /^@\/lib\/config\/client(\.ts)?$/, replacement: path.resolve(mocksDir, "config-client.ts") },
|
||||
{ find: /^@\/server\/onboarding(\.ts)?$/, replacement: path.resolve(mocksDir, "server-onboarding.ts") },
|
||||
{ find: /^@\/server\/brands(\.ts)?$/, replacement: path.resolve(mocksDir, "server-brands.ts") },
|
||||
{ find: /^@\/server\/prompts(\.ts)?$/, replacement: path.resolve(mocksDir, "server-prompts.ts") },
|
||||
{ find: /^@\/server\/citations(\.ts)?$/, replacement: path.resolve(mocksDir, "server-citations.ts") },
|
||||
{ find: /^@\/server\/dashboard(\.ts)?$/, replacement: path.resolve(mocksDir, "server-dashboard.ts") },
|
||||
{ find: /^@\/server\/analysis(\.ts)?$/, replacement: path.resolve(mocksDir, "server-analysis.ts") },
|
||||
{ find: /^@\/server\/opportunities(\.ts)?$/, replacement: path.resolve(mocksDir, "server-opportunities.ts") },
|
||||
{ find: /^@tanstack\/react-router$/, replacement: path.resolve(mocksDir, "tanstack-router.tsx") },
|
||||
{ find: /^@tanstack\/react-start\/server$/, replacement: path.resolve(mocksDir, "tanstack-start.ts") },
|
||||
{ find: /^@tanstack\/react-start$/, replacement: path.resolve(mocksDir, "tanstack-start.ts") },
|
||||
{ find: /^@\//, replacement: path.resolve(__dirname, "../src") + "/" },
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
15
apps/web/.storybook/preview.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { Decorator } from "@storybook/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import "../src/styles.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
||||
});
|
||||
|
||||
const withQueryClient: Decorator = (Story) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Story />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
export const decorators: Decorator[] = [withQueryClient];
|
||||
15
apps/web/.storybook/vite.config.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* Minimal Vite config used by Storybook (and by the Vitest storybook project).
|
||||
*
|
||||
* The app's main `vite.config.ts` loads the full TanStack Start + Nitro + Sentry
|
||||
* plugin chain. Those plugins assume a single SSR entrypoint and conflict with
|
||||
* Storybook's multi-iframe bundling. This file gives the Storybook builder a
|
||||
* dedicated, plugin-light Vite config so stories can render in isolation.
|
||||
*/
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import viteReact from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), viteReact()],
|
||||
});
|
||||
271
apps/web/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
# @workspace/web
|
||||
|
||||
## 9.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Added custom date ranges and range-average visibility metrics.
|
||||
- Added admin queue controls, provider usage and latency reporting, and safe brand deletion.
|
||||
- Added configurable application timezone and locale formatting.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Prioritized first-time prompt runs, fixed server-only deletion code leaking into the browser bundle, and improved wordmark loading.
|
||||
- Updated dependencies across the fixed workspace release group.
|
||||
|
||||
## 0.2.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- bb674b9: Onboarding brand analysis now runs in the background and is more resilient to failures, avoiding timeouts on slow analyses.
|
||||
- @workspace/api-spec@0.2.14
|
||||
- @workspace/config@0.2.14
|
||||
- @workspace/deployment@0.2.14
|
||||
- @workspace/lib@0.2.14
|
||||
- @workspace/og@0.2.14
|
||||
- @workspace/ui@0.2.14
|
||||
- @workspace/whitelabel@0.2.14
|
||||
|
||||
## 0.2.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 4272c19: Added new citation source categories and a Google AI Mode breakdown to the citations dashboard.
|
||||
- 222fd4f: Local-mode deployments now support multiple brands. The brand switcher shows a "Create new brand" option that provisions a new org + admin membership for the user.
|
||||
- 3f17708: Added the Google Shopping breakdown to a prompt's Citations tab on the prompt details page.
|
||||
- 532d592: Added a sort control to the Visibility page.
|
||||
- cf5a7da: Added a Query Fan-Out section showing the searches AI engines run for your prompts, with a prompt-to-query diff, the words engines add or drop, and the searches you're missing.
|
||||
- c4505ba: Breaking: `/api/v1` DELETE endpoints now return the deleted resource directly instead of a `{ message, data }` wrapper (the deleted prompt includes a `deletedRunsCount` field), PATCH endpoints reject an empty body with a 400, an unparseable `website` on `/tools/analyze` is now a 400 instead of a 500, and 500 responses no longer echo internal error messages.
|
||||
- Updated dependencies [c4505ba]
|
||||
- @workspace/api-spec@0.2.13
|
||||
- @workspace/config@0.2.13
|
||||
- @workspace/deployment@0.2.13
|
||||
- @workspace/lib@0.2.13
|
||||
- @workspace/og@0.2.13
|
||||
- @workspace/ui@0.2.13
|
||||
- @workspace/whitelabel@0.2.13
|
||||
|
||||
## 0.2.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @workspace/api-spec@0.2.12
|
||||
- @workspace/config@0.2.12
|
||||
- @workspace/deployment@0.2.12
|
||||
- @workspace/lib@0.2.12
|
||||
- @workspace/og@0.2.12
|
||||
- @workspace/ui@0.2.12
|
||||
- @workspace/whitelabel@0.2.12
|
||||
|
||||
## 0.2.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 4ccba7a: Add "Share of Voice" and "Opportunities" pages.
|
||||
- 4ccba7a: Fix "current visibility" to show the latest point of the carried-forward trend (the value the line ends on) instead of the whole-window average, so the headline number matches the chart beside it.
|
||||
- 4ccba7a: Fix the sidebar showing only "Overview" after the onboarding wizard finishes in local mode, until the page is refreshed.
|
||||
- 4ccba7a: Fix the visibility page failing to load for brands with many active prompts.
|
||||
- @workspace/api-spec@0.2.11
|
||||
- @workspace/config@0.2.11
|
||||
- @workspace/deployment@0.2.11
|
||||
- @workspace/lib@0.2.11
|
||||
- @workspace/og@0.2.11
|
||||
- @workspace/ui@0.2.11
|
||||
- @workspace/whitelabel@0.2.11
|
||||
|
||||
## 0.2.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b716c43: Fix the dashboard briefly showing "No Data Yet" with "none are currently enabled" right after the onboarding wizard finishes in local mode.
|
||||
- 2d8fd8c: Bulk-select prompts in the editor (settings and onboarding) to enable or disable many at once.
|
||||
- Updated dependencies [520aef4]
|
||||
- @workspace/lib@0.2.10
|
||||
- @workspace/whitelabel@0.2.10
|
||||
- @workspace/deployment@0.2.10
|
||||
- @workspace/api-spec@0.2.10
|
||||
- @workspace/config@0.2.10
|
||||
- @workspace/og@0.2.10
|
||||
- @workspace/ui@0.2.10
|
||||
|
||||
## 0.2.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 2173aa8: Accept bare domains (e.g. `example.com`) in the brand website field and normalize the stored value to the origin (`https://example.com/products` is saved as `https://example.com/`).
|
||||
- 1ee1955: Show the signup screen instead of login on a fresh local deployment.
|
||||
- @workspace/api-spec@0.2.9
|
||||
- @workspace/config@0.2.9
|
||||
- @workspace/deployment@0.2.9
|
||||
- @workspace/lib@0.2.9
|
||||
- @workspace/og@0.2.9
|
||||
- @workspace/ui@0.2.9
|
||||
- @workspace/whitelabel@0.2.9
|
||||
|
||||
## 0.2.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @workspace/api-spec@0.2.8
|
||||
- @workspace/config@0.2.8
|
||||
- @workspace/deployment@0.2.8
|
||||
- @workspace/lib@0.2.8
|
||||
- @workspace/og@0.2.8
|
||||
- @workspace/ui@0.2.8
|
||||
- @workspace/whitelabel@0.2.8
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 1e770ff: Publish multi-arch (`linux/amd64` + `linux/arm64`) Docker images for `ai-search-guru/getcito-worlds-first-open-source-aio-aeo-or-geo-tool-web` and `ai-search-guru/getcito-worlds-first-open-source-aio-aeo-or-geo-tool-worker`, so Apple Silicon and other arm64 hosts can pull them.
|
||||
- 6ab2b42: Fix OG image generation: `og:image` is now an absolute URL and renders the current page's title/description. Adds `og:url`, `og:site_name`, `og:locale`, and `og:logo` to the document head.
|
||||
- @workspace/api-spec@0.2.7
|
||||
- @workspace/config@0.2.7
|
||||
- @workspace/deployment@0.2.7
|
||||
- @workspace/lib@0.2.7
|
||||
- @workspace/og@0.2.7
|
||||
- @workspace/ui@0.2.7
|
||||
- @workspace/whitelabel@0.2.7
|
||||
|
||||
## 0.2.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 1a1005a: Admin `/api/v1/brands` endpoints (POST, GET, PATCH) now accept and return a single `domains` list instead of `website` + `additionalDomains`. This future-proofs against a future db model change.
|
||||
- Updated dependencies [1a1005a]
|
||||
- @workspace/api-spec@0.2.6
|
||||
- @workspace/config@0.2.6
|
||||
- @workspace/deployment@0.2.6
|
||||
- @workspace/lib@0.2.6
|
||||
- @workspace/og@0.2.6
|
||||
- @workspace/ui@0.2.6
|
||||
- @workspace/whitelabel@0.2.6
|
||||
|
||||
## 0.2.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- edf97d4: Add Mistral as a direct API provider. Set `MISTRAL_API_KEY` and target via `mistral:mistral-api:<model>[:online]`.
|
||||
- 7cba46d: License Getcito under the MIT License. Add Code of Conduct, Contributing guide, Security policy, and a lightweight CLA process.
|
||||
- 839b98b: REST-style brand management API: `GET/POST /api/v1/brands`, `GET/PATCH /api/v1/brands/{brandId}`, `POST /api/v1/tools/analyze`, and full CRUD for `/api/v1/competitors`. API-created brands skip onboarding — callers hit `tools/analyze` first if they want suggestions, then create brands with whatever they choose to keep.
|
||||
- 839b98b: Brand onboarding is now a single screen: paste a website and review the suggested products, competitors (with their own domains and aliases), additional brand domains, aliases, and tagged starter prompts before saving. Powered by whichever direct LLM API you've configured (OpenRouter, Anthropic, OpenAI, or Mistral) with web search.
|
||||
- Updated dependencies [7990382]
|
||||
- Updated dependencies [edf97d4]
|
||||
- Updated dependencies [7cba46d]
|
||||
- Updated dependencies [839b98b]
|
||||
- @workspace/lib@0.2.5
|
||||
- @workspace/config@0.2.5
|
||||
- @workspace/api-spec@0.2.5
|
||||
- @workspace/deployment@0.2.5
|
||||
- @workspace/og@0.2.5
|
||||
- @workspace/ui@0.2.5
|
||||
- @workspace/whitelabel@0.2.5
|
||||
|
||||
## 0.2.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 67a0389: Fix local-mode registration end-to-end and lock down the auth surface. The first `/auth/register` submission in local mode now atomically creates the default org + admin membership, so register → brand onboarding works in one pass; any subsequent signup is rejected. Demo mode narrows writable `/api/auth/**` endpoints to a whitelist of just sign-in and sign-out. Drops the unused `DEFAULT_ORG_ID` and `DEFAULT_ORG_NAME` env vars.
|
||||
- d0b2925: Redesign the Visibility and Citations filter bar (model / tags / lookback dropdowns + search), wire model filters to `brand.enabledModels`, and move the visibility-bar rollup into a single SQL query — cuts load time from ~10s to under 1s on large brands. Also fixes the "unbranded" tag filter and a search clear-X flicker.
|
||||
- Updated dependencies [67a0389]
|
||||
- @workspace/lib@0.2.4
|
||||
- @workspace/config@0.2.4
|
||||
- @workspace/whitelabel@0.2.4
|
||||
- @workspace/deployment@0.2.4
|
||||
- @workspace/og@0.2.4
|
||||
- @workspace/api-spec@0.2.4
|
||||
- @workspace/ui@0.2.4
|
||||
|
||||
## 0.2.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b635a99: Make default brand cadence configurable via `DEFAULT_DELAY_HOURS` env var. `brand.delayOverrideHours` still takes precedence. The default changed from the hard-coded 72h to 24h.
|
||||
- a62ef89: Restyle the demo login and add a preview to Storybook.
|
||||
- e9be023: Ensure icons/favicons are comprehensive for non-whitelabel deployments.
|
||||
- f3604e2: Replace the page-top demo-mode banner with a compact "Demo" pill next to the sidebar logo (with a tooltip explaining the read-only behavior), and move version / Getcito.com / GitHub links into the sidebar footer for every deployment mode except whitelabel. Also reads the better-auth `user.image` field so avatars actually render.
|
||||
- @workspace/api-spec@0.2.3
|
||||
- @workspace/config@0.2.3
|
||||
- @workspace/deployment@0.2.3
|
||||
- @workspace/lib@0.2.3
|
||||
- @workspace/og@0.2.3
|
||||
- @workspace/ui@0.2.3
|
||||
- @workspace/whitelabel@0.2.3
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 63a6c22: Demo mode: visitors can now actually sign in — better-auth endpoints are exempt from the read-only write-block, and the login form pre-fills the seeded demo credentials.
|
||||
- d3839b1: Demo deployments (`READ_ONLY=true`) now enable `supportsMultiOrg`, so the `/app` brand switcher renders when the demo user is seeded into multiple organizations. Pure local deployments continue to auto-redirect to the default org.
|
||||
- 0ae9fc1: Fix missing stylesheet and favicon in Docker builds caused by `@tailwindcss/vite` emitting different CSS hashes in the client and SSR passes.
|
||||
- 06fb190: Worker dispatch now reads `SCRAPE_TARGETS` end-to-end via the provider registry. Deployments that configure non-default providers no longer hit `AI_LoadAPIKeyError` for providers they never set up, the worker fails fast at startup on misconfigured `SCRAPE_TARGETS`, and `brand.enabledModels` filters per brand.
|
||||
- Updated dependencies [63a6c22]
|
||||
- Updated dependencies [06fb190]
|
||||
- @workspace/lib@0.2.2
|
||||
- @workspace/whitelabel@0.2.2
|
||||
- @workspace/deployment@0.2.2
|
||||
- @workspace/api-spec@0.2.2
|
||||
- @workspace/config@0.2.2
|
||||
- @workspace/og@0.2.2
|
||||
- @workspace/ui@0.2.2
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [adf7642]
|
||||
- @workspace/lib@0.2.1
|
||||
- @workspace/whitelabel@0.2.1
|
||||
- @workspace/deployment@0.2.1
|
||||
- @workspace/api-spec@0.2.1
|
||||
- @workspace/config@0.2.1
|
||||
- @workspace/og@0.2.1
|
||||
- @workspace/ui@0.2.1
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 95b71db: Replace visibility % with Share of Voice metric across reports, add reports API, and redesign report for print
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 4ce1911: show opportunities where prompts have competitor but not brand citations
|
||||
- 7acf16a: Keep the brand sidebar visible when navigating to a non-existent route under `/app/:brand/*`.
|
||||
- 1dcaf44: Chart PNG exports now include deployment branding and use a cleaner fixed-size layout
|
||||
- 37e9e16: Prompt Details now shows when the prompt is scheduled to run next.
|
||||
- Updated dependencies [95b71db]
|
||||
- @workspace/lib@0.2.0
|
||||
- @workspace/api-spec@0.2.0
|
||||
- @workspace/whitelabel@0.2.0
|
||||
- @workspace/deployment@0.2.0
|
||||
- @workspace/config@0.2.0
|
||||
- @workspace/og@0.2.0
|
||||
- @workspace/ui@0.2.0
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- optimize prompt page loading and render with proper virtualization
|
||||
- @workspace/config@0.1.2
|
||||
- @workspace/demo@0.1.2
|
||||
- @workspace/deployment@0.1.2
|
||||
- @workspace/lib@0.1.2
|
||||
- @workspace/local@0.1.2
|
||||
- @workspace/ui@0.1.2
|
||||
- @workspace/whitelabel@0.1.2
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Added changesets to track versions.
|
||||
- Updated dependencies
|
||||
- @workspace/whitelabel@0.1.1
|
||||
- @workspace/config@0.1.1
|
||||
- @workspace/local@0.1.1
|
||||
- @workspace/demo@0.1.1
|
||||
- @workspace/ui@0.1.1
|
||||
8
apps/web/biome.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
|
||||
"extends": ["../../biome.json"],
|
||||
"files": {
|
||||
"includes": ["**/*"],
|
||||
"ignoreUnknown": true
|
||||
}
|
||||
}
|
||||
10
apps/web/instrument.server.mjs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import * as Sentry from "@sentry/tanstackstart-react";
|
||||
|
||||
if (process.env.SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
environment: process.env.ENVIRONMENT || "development",
|
||||
sendDefaultPii: true,
|
||||
tracesSampleRate: 1.0,
|
||||
});
|
||||
}
|
||||
85
apps/web/package.json
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
{
|
||||
"name": "@workspace/web",
|
||||
"version": "9.1.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 3000",
|
||||
"build": "vite build",
|
||||
"start": "node .output/server/index.mjs",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run --project=unit",
|
||||
"test:storybook": "vitest run --project=storybook",
|
||||
"lint": "biome lint",
|
||||
"check-types": "tsc --noEmit",
|
||||
"generate-icons": "tsx scripts/generate-brand-icons.tsx",
|
||||
"generate-brand-kit": "tsx scripts/generate-brand-kit.tsx",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"storybook:build": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/geist-sans": "^5.2.5",
|
||||
"@fontsource/titan-one": "^5.2.8",
|
||||
"@sentry/tanstackstart-react": "^10.57.0",
|
||||
"@tabler/icons-react": "^3.44.0",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@takumi-rs/core": "^1.8.5",
|
||||
"@takumi-rs/image-response": "^1.8.5",
|
||||
"@takumi-rs/wasm": "^1.8.5",
|
||||
"@tanstack/devtools-vite": "^0.7.0",
|
||||
"@tanstack/react-devtools": "^0.10.5",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-query-devtools": "^5.101.0",
|
||||
"@tanstack/react-router": "^1.170.15",
|
||||
"@tanstack/react-router-ssr-query": "^1.167.1",
|
||||
"@tanstack/react-start": "^1.168.25",
|
||||
"@tanstack/react-virtual": "^3.14.2",
|
||||
"@workspace/api-spec": "workspace:*",
|
||||
"@workspace/config": "workspace:*",
|
||||
"@workspace/deployment": "workspace:*",
|
||||
"@workspace/lib": "workspace:*",
|
||||
"@workspace/og": "workspace:*",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"@workspace/whitelabel": "workspace:*",
|
||||
"auth0": "^5.12.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"html2canvas-pro": "^2.0.4",
|
||||
"lucide-react": "^1.18.0",
|
||||
"pg": "^8.21.0",
|
||||
"pg-boss": "^12.19.1",
|
||||
"posthog-js": "^1.386.6",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.8.1",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"tslib": "^2.8.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.0",
|
||||
"@storybook/addon-vitest": "^10.4.4",
|
||||
"@storybook/react": "^10.4.4",
|
||||
"@storybook/react-vite": "^10.4.4",
|
||||
"@types/archiver": "^8.0.0",
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/browser-playwright": "^4.1.8",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"archiver": "^8.0.0",
|
||||
"nitro": "npm:nitro-nightly@3.0.1-20260223-102354-c0b46421",
|
||||
"playwright": "^1.60.0",
|
||||
"png-to-ico": "^3.0.1",
|
||||
"storybook": "^10.4.4",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
BIN
apps/web/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/web/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
apps/web/public/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/web/public/icons/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
apps/web/public/icons/getcito-icon-192.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
apps/web/public/icons/getcito-icon-512.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
apps/web/public/icons/getcito-icon-96.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
apps/web/public/icons/getcito-icon-maskable-192.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
apps/web/public/icons/getcito-icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
3
apps/web/public/robots.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
201
apps/web/scripts/benchmark-postgres-analytics.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Benchmark: Postgres analytics query performance.
|
||||
*
|
||||
* Imports the production functions from postgres-read.ts and measures
|
||||
* their latency across the top N brands.
|
||||
*
|
||||
* Usage:
|
||||
* cd apps/web
|
||||
* pnpm tsx --env-file=../../.env scripts/benchmark-postgres-analytics.ts
|
||||
* pnpm tsx --env-file=../../.env scripts/benchmark-postgres-analytics.ts --iterations=10
|
||||
* pnpm tsx --env-file=../../.env scripts/benchmark-postgres-analytics.ts --brands=3
|
||||
*/
|
||||
|
||||
import * as pgRead from "../src/lib/postgres-read";
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
const db = drizzle(process.env.DATABASE_URL!);
|
||||
|
||||
const ITERATIONS = parseInt(process.argv.find((a) => a.startsWith("--iterations="))?.split("=")[1] || "5");
|
||||
const WARMUP = 1;
|
||||
|
||||
// ============================================================================
|
||||
// Timing
|
||||
// ============================================================================
|
||||
|
||||
interface BenchResult {
|
||||
name: string;
|
||||
avg: number;
|
||||
min: number;
|
||||
p95: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
async function timeOne(fn: () => Promise<unknown>): Promise<{ avg: number; min: number; p95: number; rows: number }> {
|
||||
let rows = 0;
|
||||
for (let i = 0; i < WARMUP; i++) {
|
||||
const r = await fn();
|
||||
rows = Array.isArray(r) ? r.length : 1;
|
||||
}
|
||||
const times: number[] = [];
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
const start = performance.now();
|
||||
await fn();
|
||||
times.push(Math.round(performance.now() - start));
|
||||
}
|
||||
times.sort((a, b) => a - b);
|
||||
return {
|
||||
avg: Math.round(times.reduce((a, b) => a + b, 0) / times.length),
|
||||
min: times[0],
|
||||
p95: times[Math.floor(times.length * 0.95)],
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
async function bench(name: string, pgFn: () => Promise<unknown>): Promise<BenchResult> {
|
||||
process.stdout.write(` ${name} ... `);
|
||||
const timing = await timeOne(pgFn);
|
||||
console.log(`${timing.avg}ms (min ${timing.min}ms, p95 ${timing.p95}ms, ${timing.rows} rows)`);
|
||||
return { name, ...timing };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main
|
||||
// ============================================================================
|
||||
|
||||
async function benchBrand(
|
||||
brandId: string,
|
||||
runCount: number,
|
||||
hasCitations: boolean,
|
||||
): Promise<BenchResult[]> {
|
||||
const promptResult = await db.execute<{ id: string }>(sql`
|
||||
SELECT id FROM prompts WHERE brand_id = ${brandId} AND enabled = true LIMIT 20
|
||||
`);
|
||||
const promptIds = promptResult.rows.map((r) => r.id);
|
||||
const firstPromptId = promptIds[0];
|
||||
if (!firstPromptId) { console.log(` Skipping ${brandId} — no enabled prompts`); return []; }
|
||||
|
||||
const toDate = new Date().toISOString().split("T")[0];
|
||||
const fromDate = new Date(Date.now() - 30 * 86400000).toISOString().split("T")[0];
|
||||
const tz = "UTC";
|
||||
const brandedPromptIds = promptIds.slice(0, Math.ceil(promptIds.length / 2));
|
||||
|
||||
console.log(`\n### Brand: ${brandId.slice(0, 8)}... (${runCount} runs, ${promptIds.length} prompts)\n`);
|
||||
|
||||
const results: BenchResult[] = [];
|
||||
|
||||
results.push(await bench("getDashboardSummary",
|
||||
() => pgRead.getDashboardSummary(brandId, fromDate, toDate, tz, promptIds)));
|
||||
|
||||
results.push(await bench("getVisibilityTimeSeries",
|
||||
() => pgRead.getVisibilityTimeSeries(brandId, fromDate, toDate, tz, brandedPromptIds, promptIds)));
|
||||
|
||||
results.push(await bench("getPromptsSummary",
|
||||
() => pgRead.getPromptsSummary(brandId, fromDate, toDate, tz)));
|
||||
|
||||
results.push(await bench("getPromptsFirstEvaluatedAt",
|
||||
() => pgRead.getPromptsFirstEvaluatedAt(brandId, promptIds)));
|
||||
|
||||
results.push(await bench("getPromptDailyStats",
|
||||
() => pgRead.getPromptDailyStats(firstPromptId, fromDate, toDate, tz)));
|
||||
|
||||
results.push(await bench("getPromptCompetitorDailyStats",
|
||||
() => pgRead.getPromptCompetitorDailyStats(firstPromptId, fromDate, toDate, tz)));
|
||||
|
||||
results.push(await bench("getPromptWebQueriesForMapping",
|
||||
() => pgRead.getPromptWebQueriesForMapping(firstPromptId, fromDate, toDate, tz)));
|
||||
|
||||
results.push(await bench("getPromptMentionSummary",
|
||||
() => pgRead.getPromptMentionSummary(firstPromptId, fromDate, toDate, tz)));
|
||||
|
||||
results.push(await bench("getPromptTopCompetitorMentions",
|
||||
() => pgRead.getPromptTopCompetitorMentions(firstPromptId, fromDate, toDate, tz, 10)));
|
||||
|
||||
results.push(await bench("getBatchChartData",
|
||||
() => pgRead.getBatchChartData(brandId, promptIds, fromDate, toDate, tz)));
|
||||
|
||||
results.push(await bench("getBrandEarliestRunDate",
|
||||
() => pgRead.getBrandEarliestRunDate(brandId)));
|
||||
|
||||
if (hasCitations) {
|
||||
results.push(await bench("getCitationDomainStats",
|
||||
() => pgRead.getCitationDomainStats(brandId, fromDate, toDate, tz, promptIds)));
|
||||
|
||||
results.push(await bench("getCitationUrlStats",
|
||||
() => pgRead.getCitationUrlStats(brandId, fromDate, toDate, tz, promptIds)));
|
||||
|
||||
results.push(await bench("getDailyCitationStats",
|
||||
() => pgRead.getDailyCitationStats(brandId, fromDate, toDate, tz, promptIds)));
|
||||
|
||||
results.push(await bench("getPromptCitationUrlStats",
|
||||
() => pgRead.getPromptCitationUrlStats(firstPromptId, fromDate, toDate, tz)));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const NUM_BRANDS = parseInt(process.argv.find((a) => a.startsWith("--brands="))?.split("=")[1] || "5");
|
||||
console.log(`Benchmark: ${ITERATIONS} iterations + ${WARMUP} warmup, top ${NUM_BRANDS} brands\n`);
|
||||
|
||||
await db.execute(sql`SET statement_timeout = '30s'`);
|
||||
|
||||
const topBrands = await db.execute<{ brand_id: string; run_count: number }>(sql`
|
||||
SELECT brand_id, count(*)::int AS run_count
|
||||
FROM prompt_runs WHERE brand_id IS NOT NULL
|
||||
GROUP BY brand_id ORDER BY run_count DESC LIMIT ${NUM_BRANDS}
|
||||
`);
|
||||
if (topBrands.rows.length === 0) { console.error("No prompt_runs with brand_id."); process.exit(1); }
|
||||
|
||||
const citResult = await db.execute<{ count: number }>(sql`SELECT count(*)::int AS count FROM citations`);
|
||||
const hasCitations = citResult.rows[0].count > 0;
|
||||
if (!hasCitations) console.log("Citations table empty — skipping citation benchmarks\n");
|
||||
|
||||
const allResults: BenchResult[] = [];
|
||||
|
||||
for (const brand of topBrands.rows) {
|
||||
const brandResults = await benchBrand(brand.brand_id, brand.run_count, hasCitations);
|
||||
allResults.push(...brandResults);
|
||||
}
|
||||
|
||||
// === Admin queries (run once) ===
|
||||
console.log("\n### Admin queries\n");
|
||||
|
||||
allResults.push(await bench("getAdminRunsOverTime", () => pgRead.getAdminRunsOverTime()));
|
||||
allResults.push(await bench("getAdminBrandRunStats", () => pgRead.getAdminBrandRunStats()));
|
||||
|
||||
// ============================================================================
|
||||
// Output
|
||||
// ============================================================================
|
||||
|
||||
console.log("\n## Summary\n");
|
||||
console.log(`| Query | Avg (ms) | Min (ms) | P95 (ms) | Rows |`);
|
||||
console.log(`|-------|----------|----------|----------|------|`);
|
||||
|
||||
let totalAvg = 0;
|
||||
for (const r of allResults) {
|
||||
totalAvg += r.avg;
|
||||
console.log(`| ${r.name} | ${r.avg} | ${r.min} | ${r.p95} | ${r.rows} |`);
|
||||
}
|
||||
console.log(`| **TOTAL** | **${totalAvg}** | | | |`);
|
||||
|
||||
const over200 = allResults.filter((r) => r.avg > 200 && !r.name.startsWith("getAdmin"));
|
||||
const over600 = allResults.filter((r) => r.avg > 600 && r.name.startsWith("getAdmin"));
|
||||
if (over200.length > 0 || over600.length > 0) {
|
||||
console.log("\nQueries exceeding target latency:");
|
||||
for (const r of [...over200, ...over600]) {
|
||||
console.log(` - ${r.name}: ${r.avg}ms avg`);
|
||||
}
|
||||
} else {
|
||||
console.log("\nAll queries within target latency (user-facing <200ms, admin <600ms)");
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Fatal error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
201
apps/web/scripts/generate-brand-icons.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Generates static Getcito brand icon assets served from /icons/*.
|
||||
*
|
||||
* Output directory: apps/web/public/icons/
|
||||
*
|
||||
* SVG (vector, used by modern browsers as `<link rel="icon">`):
|
||||
* - getcito-icon.svg Standard "e" icon (transparent background)
|
||||
* - getcito-icon-maskable.svg Maskable variant with extra padding for safe-zone
|
||||
*
|
||||
* PNG (raster, required by the PWA manifest + iOS touch icon):
|
||||
* - getcito-icon-96.png Standard, 96×96 (desktop PNG favicon)
|
||||
* - getcito-icon-192.png Standard, 192×192 (PWA manifest)
|
||||
* - getcito-icon-512.png Standard, 512×512 (PWA manifest)
|
||||
* - getcito-icon-maskable-192.png Maskable, 192×192 (PWA manifest)
|
||||
* - getcito-icon-maskable-512.png Maskable, 512×512 (PWA manifest)
|
||||
* - apple-touch-icon.png iOS home-screen icon, 180×180, opaque bg
|
||||
*
|
||||
* ICO (classic Windows favicon, referenced as `/icons/favicon.ico`):
|
||||
* - favicon.ico Multi-resolution (16, 32, 48) PNG-in-ICO
|
||||
*
|
||||
* SVGs embed the Titan One WOFF2 font as base64 so they render without any
|
||||
* external fetches. PNGs are rasterized with Takumi (same pipeline used by
|
||||
* the brand-kit generator) so the glyph matches across formats.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx apps/web/scripts/generate-brand-icons.tsx
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { ImageResponse } from "@takumi-rs/image-response";
|
||||
import pngToIco from "png-to-ico";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const BRAND_COLOR = "#2563eb";
|
||||
const OUTPUT_DIR = resolve(__dirname, "../public/icons");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SVG icons — hand-built strings with Titan One embedded as base64
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function loadFontBase64(): string {
|
||||
const fontPath = require.resolve(
|
||||
"@fontsource/titan-one/files/titan-one-latin-400-normal.woff2",
|
||||
);
|
||||
return readFileSync(fontPath).toString("base64");
|
||||
}
|
||||
|
||||
function fontFaceRule(base64: string): string {
|
||||
return `@font-face { font-family: 'Titan One'; src: url(data:font/woff2;base64,${base64}) format('woff2'); }`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard icon — "e" filling nearly the entire 128×128 viewBox, transparent bg.
|
||||
*
|
||||
* Titan One's lowercase "e" x-height is ~62% of the em size.
|
||||
* At font-size 190 the glyph is ~118px tall — nearly filling the 128 box.
|
||||
* The y baseline is set so the glyph is vertically centered.
|
||||
*/
|
||||
function buildStandardSvg(fontBase64: string): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
|
||||
<style>${fontFaceRule(fontBase64)}</style>
|
||||
<text x="50%" y="93.5%" font-family="'Titan One', sans-serif" font-size="190" font-weight="400" fill="${BRAND_COLOR}" text-anchor="middle">e</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maskable icon — "e" inside the safe-zone (inner ~80%) of a 128×128 box.
|
||||
* White background so adaptive icon contexts have a clean fill.
|
||||
* The glyph is sized to nearly fill the safe area.
|
||||
*/
|
||||
function buildMaskableSvg(fontBase64: string): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
|
||||
<style>${fontFaceRule(fontBase64)}</style>
|
||||
<rect width="128" height="128" fill="#ffffff"/>
|
||||
<text x="50%" y="78%" font-family="'Titan One', sans-serif" font-size="120" font-weight="400" fill="${BRAND_COLOR}" text-anchor="middle">e</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PNG icons — rendered via Takumi (JSX → image), matches brand-kit sizing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function loadFont(path: string): ArrayBuffer {
|
||||
const buf = readFileSync(require.resolve(path));
|
||||
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
||||
}
|
||||
|
||||
const takumiFonts = [
|
||||
{
|
||||
name: "Titan One",
|
||||
data: loadFont("@fontsource/titan-one/files/titan-one-latin-400-normal.woff2"),
|
||||
style: "normal" as const,
|
||||
weight: 400 as const,
|
||||
},
|
||||
];
|
||||
|
||||
async function renderPng(element: React.ReactElement, size: number): Promise<Buffer> {
|
||||
const response = new ImageResponse(element, {
|
||||
width: size,
|
||||
height: size,
|
||||
fonts: takumiFonts,
|
||||
});
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
function StandardIcon({ bg, size }: { bg?: string; size: number }) {
|
||||
return (
|
||||
<div
|
||||
tw="flex items-center justify-center w-full h-full"
|
||||
style={{ backgroundColor: bg || "transparent" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "Titan One",
|
||||
fontSize: size,
|
||||
color: BRAND_COLOR,
|
||||
lineHeight: 1,
|
||||
transform: "scale(1.4)",
|
||||
marginTop: -Math.round(size * 0.28),
|
||||
}}
|
||||
>
|
||||
e
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MaskableIcon({ size }: { size: number }) {
|
||||
return (
|
||||
<div tw="flex items-center justify-center w-full h-full bg-white">
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "Titan One",
|
||||
fontSize: Math.round(size * 0.94),
|
||||
color: BRAND_COLOR,
|
||||
lineHeight: 1,
|
||||
marginTop: -Math.round(size * 0.18),
|
||||
}}
|
||||
>
|
||||
e
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
|
||||
const fontBase64 = loadFontBase64();
|
||||
|
||||
const svgIcons = [
|
||||
{ name: "getcito-icon.svg", build: buildStandardSvg },
|
||||
{ name: "getcito-icon-maskable.svg", build: buildMaskableSvg },
|
||||
];
|
||||
|
||||
for (const { name, build } of svgIcons) {
|
||||
writeFileSync(resolve(OUTPUT_DIR, name), build(fontBase64), "utf-8");
|
||||
console.log(` ✓ ${name}`);
|
||||
}
|
||||
|
||||
const pngIcons = [
|
||||
{ name: "getcito-icon-96.png", element: <StandardIcon size={96} />, size: 96 },
|
||||
{ name: "getcito-icon-192.png", element: <StandardIcon size={192} />, size: 192 },
|
||||
{ name: "getcito-icon-512.png", element: <StandardIcon size={512} />, size: 512 },
|
||||
{ name: "getcito-icon-maskable-192.png", element: <MaskableIcon size={192} />, size: 192 },
|
||||
{ name: "getcito-icon-maskable-512.png", element: <MaskableIcon size={512} />, size: 512 },
|
||||
// Apple touch icons must be opaque — iOS otherwise adds its own background.
|
||||
{ name: "apple-touch-icon.png", element: <StandardIcon bg="#ffffff" size={180} />, size: 180 },
|
||||
];
|
||||
|
||||
for (const { name, element, size } of pngIcons) {
|
||||
const buffer = await renderPng(element, size);
|
||||
writeFileSync(resolve(OUTPUT_DIR, name), buffer);
|
||||
console.log(` ✓ ${name}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ICO — multi-resolution PNG-in-ICO built from Takumi-rendered PNGs.
|
||||
// Kept at /icons/favicon.ico (not /favicon.ico at the root) so whitelabel
|
||||
// deployments don't end up serving Getcito's ICO for default browser requests.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Transparent background — browsers rasterize this onto the tab strip, which
|
||||
// is often dark-themed. apple-touch-icon above keeps its white bg because iOS
|
||||
// would otherwise composite it onto its own (usually dark) background.
|
||||
const icoPngs: Buffer[] = [];
|
||||
for (const size of [16, 32, 48]) {
|
||||
icoPngs.push(await renderPng(<StandardIcon size={size} />, size));
|
||||
}
|
||||
writeFileSync(resolve(OUTPUT_DIR, "favicon.ico"), await pngToIco(icoPngs));
|
||||
console.log(" ✓ favicon.ico");
|
||||
|
||||
console.log(`\nIcons written to ${OUTPUT_DIR}`);
|
||||
435
apps/web/scripts/generate-brand-kit.tsx
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Generates the Getcito brand kit as a zip of PNGs using Takumi (JSX → Image).
|
||||
*
|
||||
* Output: apps/web/getcito-brand-kit.zip
|
||||
*
|
||||
* Icons (square, "e" glyph):
|
||||
* getcito-icon-{size}.png Transparent background
|
||||
* getcito-icon-white-{size}.png White background
|
||||
* getcito-icon-dark-{size}.png Dark background
|
||||
* getcito-icon-maskable-{size}.png Maskable/PWA (white bg, safe-zone padding)
|
||||
*
|
||||
* Logos ("Getcito" wordmark, sm/md/lg × 3 backgrounds):
|
||||
* getcito-logo[-white|-dark]-{sm|md|lg}.png
|
||||
*
|
||||
* OG images (1200×630):
|
||||
* og-default.png Default Open Graph image
|
||||
*
|
||||
* Social banners:
|
||||
* twitter-banner.png 3000×1000 (Twitter/X header)
|
||||
* linkedin-banner.png 3384×573 (LinkedIn personal)
|
||||
*
|
||||
* Usage:
|
||||
* pnpm --filter @workspace/web generate-brand-kit
|
||||
*/
|
||||
import { readFileSync, createWriteStream } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { ImageResponse } from "@takumi-rs/image-response";
|
||||
import { ZipArchive } from "archiver";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const BRAND_COLOR = "#2563eb";
|
||||
const ACCENT_COLORS = ["#2563eb", "#f4d35e", "#ee964b", "#f95738"];
|
||||
const TAGLINE = "AI Search Optimization";
|
||||
const DESCRIPTION = "Track and optimize your brand's visibility across AI models.";
|
||||
const OUTPUT_ZIP = resolve(__dirname, "../getcito-brand-kit.zip");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fonts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function loadFont(path: string): ArrayBuffer {
|
||||
const buf = readFileSync(require.resolve(path));
|
||||
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
||||
}
|
||||
|
||||
const fonts = [
|
||||
{
|
||||
name: "Titan One",
|
||||
data: loadFont("@fontsource/titan-one/files/titan-one-latin-400-normal.woff2"),
|
||||
style: "normal" as const,
|
||||
weight: 400 as const,
|
||||
},
|
||||
{
|
||||
name: "Geist Sans",
|
||||
data: loadFont("@fontsource/geist-sans/files/geist-sans-latin-400-normal.woff2"),
|
||||
style: "normal" as const,
|
||||
weight: 400 as const,
|
||||
},
|
||||
{
|
||||
name: "Geist Sans",
|
||||
data: loadFont("@fontsource/geist-sans/files/geist-sans-latin-500-normal.woff2"),
|
||||
style: "normal" as const,
|
||||
weight: 500 as const,
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function render(
|
||||
element: React.ReactElement,
|
||||
width: number,
|
||||
height: number,
|
||||
): Promise<Buffer> {
|
||||
const response = new ImageResponse(element, {
|
||||
width,
|
||||
height,
|
||||
fonts,
|
||||
});
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Icon — "e" glyph scaled to fill the canvas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Icon({ fill, bg, size }: { fill: string; bg?: string; size: number }) {
|
||||
return (
|
||||
<div
|
||||
tw="flex items-center justify-center w-full h-full"
|
||||
style={{ backgroundColor: bg || "transparent" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "Titan One",
|
||||
fontSize: size,
|
||||
color: fill,
|
||||
lineHeight: 1,
|
||||
transform: "scale(1.4)",
|
||||
marginTop: -Math.round(size * 0.28),
|
||||
}}
|
||||
>
|
||||
e
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MaskableIcon({ size }: { size: number }) {
|
||||
return (
|
||||
<div tw="flex items-center justify-center w-full h-full bg-white">
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "Titan One",
|
||||
fontSize: Math.round(size * 0.94),
|
||||
color: BRAND_COLOR,
|
||||
lineHeight: 1,
|
||||
marginTop: -Math.round(size * 0.18),
|
||||
}}
|
||||
>
|
||||
e
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logo — "Getcito" wordmark with equal padding on all sides
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Logo({ bg, fontSize }: { bg?: string; fontSize: number }) {
|
||||
return (
|
||||
<div
|
||||
tw="flex items-center justify-center w-full h-full"
|
||||
style={{ backgroundColor: bg || "transparent" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "Titan One",
|
||||
fontSize,
|
||||
color: BRAND_COLOR,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
Getcito
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PatternBanner — repeating "Getcito" wordmark at an angle, wrapping-paper style.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PatternBanner({
|
||||
width,
|
||||
height,
|
||||
bg,
|
||||
colors,
|
||||
angle,
|
||||
fontScale,
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
bg: string;
|
||||
colors: readonly string[];
|
||||
angle: number;
|
||||
fontScale: number;
|
||||
}) {
|
||||
const fontSize = Math.round(height * fontScale);
|
||||
const wordWidth = Math.round(fontSize * 3.2);
|
||||
const gap = Math.round(fontSize * 0.5);
|
||||
const cellWidth = wordWidth + gap;
|
||||
const rowHeight = Math.round(fontSize * 1.5);
|
||||
|
||||
const diagonal = Math.ceil(Math.sqrt(width * width + height * height));
|
||||
const gridSize = Math.round(diagonal * 1.5);
|
||||
|
||||
const cols = Math.ceil(gridSize / cellWidth) + 2;
|
||||
const rows = Math.ceil(gridSize / rowHeight) + 2;
|
||||
|
||||
const offsetX = -Math.round((gridSize - width) / 2);
|
||||
const offsetY = -Math.round((gridSize - height) / 2);
|
||||
|
||||
const rowElements: React.ReactElement[] = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const cells: React.ReactElement[] = [];
|
||||
const brickOffset = r % 2 === 0 ? 0 : Math.round(cellWidth * 0.5);
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const colorIdx = (r * 3 + c) % colors.length;
|
||||
cells.push(
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "Titan One",
|
||||
fontSize,
|
||||
color: colors[colorIdx],
|
||||
lineHeight: 1,
|
||||
marginRight: gap,
|
||||
}}
|
||||
>
|
||||
Getcito
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
rowElements.push(
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
marginLeft: brickOffset,
|
||||
height: rowHeight,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{cells}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
tw="flex w-full h-full relative overflow-hidden"
|
||||
style={{ backgroundColor: bg, opacity: 0.99 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
transform: `rotate(${angle}deg)`,
|
||||
left: offsetX,
|
||||
top: offsetY,
|
||||
width: gridSize,
|
||||
height: gridSize,
|
||||
}}
|
||||
>
|
||||
{rowElements}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OG image
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function OgImage({ title }: { title: string }) {
|
||||
return (
|
||||
<div
|
||||
tw="flex w-full h-full relative overflow-hidden"
|
||||
style={{ backgroundColor: "#ffffff" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
fontFamily: "Titan One",
|
||||
fontSize: 700,
|
||||
color: "rgba(37,99,235,0.04)",
|
||||
lineHeight: 1,
|
||||
right: -60,
|
||||
top: -60,
|
||||
}}
|
||||
>
|
||||
e
|
||||
</div>
|
||||
|
||||
<div tw="flex flex-col justify-center h-full" style={{ paddingLeft: 80, paddingRight: 80 }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "Titan One",
|
||||
fontSize: 80,
|
||||
color: BRAND_COLOR,
|
||||
lineHeight: 1,
|
||||
marginBottom: 28,
|
||||
}}
|
||||
>
|
||||
Getcito
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "Geist Sans",
|
||||
fontSize: 44,
|
||||
fontWeight: 500,
|
||||
color: "#1e293b",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div style={{ fontFamily: "Geist Sans", fontSize: 24, color: "#64748b" }}>
|
||||
{DESCRIPTION}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: 6,
|
||||
backgroundImage: `linear-gradient(to right, ${ACCENT_COLORS.join(", ")})`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generate all assets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const files: { name: string; data: Buffer }[] = [];
|
||||
|
||||
async function addFile(name: string, data: Buffer | Promise<Buffer>) {
|
||||
const resolved = await data;
|
||||
files.push({ name, data: resolved });
|
||||
console.log(` ✓ ${name}`);
|
||||
}
|
||||
|
||||
console.log("Generating brand kit…\n");
|
||||
|
||||
// Icons — always brand blue, bg varies
|
||||
const iconVariants = [
|
||||
{ suffix: "", bg: undefined },
|
||||
{ suffix: "-white", bg: "#ffffff" },
|
||||
{ suffix: "-dark", bg: "#111827" },
|
||||
];
|
||||
const iconSizes = [16, 32, 64, 128, 256, 512];
|
||||
|
||||
console.log("Icons:");
|
||||
for (const v of iconVariants) {
|
||||
for (const size of iconSizes) {
|
||||
await addFile(
|
||||
`icons/getcito-icon${v.suffix}-${size}.png`,
|
||||
render(<Icon fill={BRAND_COLOR} bg={v.bg} size={size} />, size, size),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nIcons — Maskable (PWA):");
|
||||
for (const size of [64, 128, 256, 512]) {
|
||||
await addFile(
|
||||
`icons/getcito-icon-maskable-${size}.png`,
|
||||
render(<MaskableIcon size={size} />, size, size),
|
||||
);
|
||||
}
|
||||
|
||||
// Logos — always brand blue text
|
||||
console.log("\nLogos:");
|
||||
const logoBgs = [
|
||||
{ suffix: "", bg: undefined },
|
||||
{ suffix: "-white", bg: "#ffffff" },
|
||||
{ suffix: "-dark", bg: "#111827" },
|
||||
];
|
||||
const logoSizes = [
|
||||
{ label: "sm", fontSize: 64, w: 190, h: 90 },
|
||||
{ label: "md", fontSize: 100, w: 290, h: 140 },
|
||||
{ label: "lg", fontSize: 160, w: 470, h: 220 },
|
||||
{ label: "xl", fontSize: 240, w: 700, h: 330 },
|
||||
{ label: "2xl", fontSize: 360, w: 1050, h: 500 },
|
||||
];
|
||||
for (const bg of logoBgs) {
|
||||
for (const sz of logoSizes) {
|
||||
await addFile(
|
||||
`logos/getcito-logo${bg.suffix}-${sz.label}.png`,
|
||||
render(<Logo bg={bg.bg} fontSize={sz.fontSize} />, sz.w, sz.h),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// OG
|
||||
console.log("\nOG Images:");
|
||||
const ogData = await render(<OgImage title={TAGLINE} />, 1200, 630);
|
||||
files.push({ name: "og/og-default.png", data: ogData });
|
||||
console.log(" ✓ og/og-default.png (1200×630)");
|
||||
|
||||
// Banners — wrapping-paper style, wildly different per platform
|
||||
console.log("\nSocial Banners:");
|
||||
|
||||
const sharedBannerStyle = {
|
||||
bg: "#ffffff",
|
||||
colors: ["#2563eb", "#f4d35e", "#ee964b", "#f95738", "#93c5fd", "#fbbf24"],
|
||||
angle: 15,
|
||||
} as const;
|
||||
|
||||
await addFile(
|
||||
"banners/twitter-banner.png",
|
||||
render(
|
||||
<PatternBanner width={3000} height={1000} {...sharedBannerStyle} fontScale={0.105} />,
|
||||
3000,
|
||||
1000,
|
||||
),
|
||||
);
|
||||
|
||||
await addFile(
|
||||
"banners/linkedin-banner.png",
|
||||
render(
|
||||
<PatternBanner width={3384} height={573} {...sharedBannerStyle} fontScale={0.18} />,
|
||||
3384,
|
||||
573,
|
||||
),
|
||||
);
|
||||
|
||||
// Write zip
|
||||
console.log("\nPacking zip…");
|
||||
|
||||
const output = createWriteStream(OUTPUT_ZIP);
|
||||
const archive = new ZipArchive({ zlib: { level: 9 } });
|
||||
|
||||
const done = new Promise<void>((res, rej) => {
|
||||
output.on("close", res);
|
||||
archive.on("error", rej);
|
||||
});
|
||||
|
||||
archive.pipe(output);
|
||||
|
||||
for (const file of files) {
|
||||
archive.append(file.data, { name: file.name });
|
||||
}
|
||||
|
||||
await archive.finalize();
|
||||
await done;
|
||||
|
||||
const zipSize = readFileSync(OUTPUT_ZIP).length;
|
||||
const kb = (zipSize / 1024).toFixed(1);
|
||||
console.log(
|
||||
`\n✅ getcito-brand-kit.zip (${kb} KB, ${files.length} files) → ${OUTPUT_ZIP}`,
|
||||
);
|
||||
183
apps/web/scripts/smoke-deployment-mode.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
/**
|
||||
* Mode-compatibility boot smoke test (issue #341).
|
||||
*
|
||||
* For each non-cloud DEPLOYMENT_MODE, proves the boot path resolves:
|
||||
* 1. env validation — the canonical minimal env satisfies the registry
|
||||
* 2. factory resolution — getDeployment() returns the expected mode
|
||||
* 3. auth initialization — createAuth() constructs with the mode's options
|
||||
*
|
||||
* This guards the getcito-cloud build-out: open-source, demo, and whitelabel
|
||||
* deployments must keep booting unchanged while cloud lands (issue #8).
|
||||
*
|
||||
* Runs the real shared-package boot path via tsx — no build, no live DB.
|
||||
* createAuth() only constructs the better-auth instance (drizzle's pg pool is
|
||||
* lazy), so a dummy DATABASE_URL is enough.
|
||||
*
|
||||
* Lives in apps/web because that package depends on every @workspace/* package
|
||||
* this script boots; the script itself imports only @workspace/* entrypoints
|
||||
* (never apps/web's @/ alias), so tsx resolves it with no app bundling.
|
||||
*
|
||||
* Usage (from repo root):
|
||||
* pnpm -C apps/web exec tsx scripts/smoke-deployment-mode.ts # all modes
|
||||
* pnpm -C apps/web exec tsx scripts/smoke-deployment-mode.ts whitelabel # one mode
|
||||
*
|
||||
* Cloud is intentionally skipped: the factory throws "not yet implemented"
|
||||
* and ENV_REQUIREMENTS.cloud is empty until #342.
|
||||
*/
|
||||
import type { DeploymentMode } from "@workspace/config/types";
|
||||
import { getDeployment, resetDeploymentCache } from "@workspace/deployment";
|
||||
import { getEnvValidationState } from "@workspace/config/env";
|
||||
|
||||
type SmokeMode = Exclude<DeploymentMode, "cloud">;
|
||||
|
||||
const SMOKE_MODES: SmokeMode[] = ["local", "demo", "whitelabel"];
|
||||
|
||||
/**
|
||||
* Canonical minimal env that should satisfy each mode's startup requirements.
|
||||
*
|
||||
* Values are dummies — nothing here connects to a real service. If a PR adds a
|
||||
* new hard-required registry var without updating this map, env validation
|
||||
* below fails and the guard catches it.
|
||||
*
|
||||
* DATABASE_URL is never connected; APP_URL / VITE_APP_URL are read by
|
||||
* createAuth(); the AUTH0_* vars are read by getWhitelabelAuthOptions() and the
|
||||
* whitelabel env requirements. SCRAPE_TARGETS=chatgpt:olostep:online pulls in
|
||||
* the OLOSTEP_API_KEY provider-key requirement.
|
||||
*/
|
||||
const SHARED_ENV: Record<string, string> = {
|
||||
DATABASE_URL: "postgres://smoke:smoke@127.0.0.1:5432/smoke",
|
||||
BETTER_AUTH_SECRET: "smoke-test-better-auth-secret-0000000000",
|
||||
SCRAPE_TARGETS: "chatgpt:olostep:online",
|
||||
OLOSTEP_API_KEY: "smoke-olostep-key",
|
||||
};
|
||||
|
||||
const MINIMAL_ENV: Record<SmokeMode, Record<string, string>> = {
|
||||
local: {
|
||||
...SHARED_ENV,
|
||||
DEPLOYMENT_MODE: "local",
|
||||
APP_URL: "http://localhost:3000",
|
||||
},
|
||||
demo: {
|
||||
...SHARED_ENV,
|
||||
DEPLOYMENT_MODE: "demo",
|
||||
APP_URL: "http://localhost:3000",
|
||||
},
|
||||
whitelabel: {
|
||||
...SHARED_ENV,
|
||||
DEPLOYMENT_MODE: "whitelabel",
|
||||
AUTH0_DOMAIN: "smoke.auth0.com",
|
||||
AUTH0_CLIENT_ID: "smoke-client-id",
|
||||
AUTH0_CLIENT_SECRET: "smoke-client-secret",
|
||||
AUTH0_MGMT_API_DOMAIN: "smoke.auth0.com",
|
||||
VITE_APP_NAME: "Smoke App",
|
||||
VITE_APP_ICON: "https://example.com/icon.png",
|
||||
VITE_APP_URL: "https://smoke.example.com/",
|
||||
VITE_APP_PARENT_NAME: "Smoke Parent",
|
||||
VITE_APP_PARENT_URL: "https://parent.example.com/",
|
||||
VITE_OPTIMIZATION_URL_TEMPLATE: "https://parent.example.com/optimize?org_id={brandId}&prompt={prompt}",
|
||||
},
|
||||
};
|
||||
|
||||
/** Every env key this script manages, so we can clear leakage between modes. */
|
||||
const MANAGED_KEYS = [...new Set(Object.values(MINIMAL_ENV).flatMap((env) => Object.keys(env)))];
|
||||
|
||||
/**
|
||||
* Replace the managed env vars in process.env with this mode's values.
|
||||
* createAuth(), db.ts, and getWhitelabelAuthOptions() read the global
|
||||
* process.env, so the mode's env must be live there before they run.
|
||||
*/
|
||||
function applyEnv(mode: SmokeMode): Record<string, string> {
|
||||
for (const key of MANAGED_KEYS) delete process.env[key];
|
||||
const env = MINIMAL_ENV[mode];
|
||||
for (const [key, value] of Object.entries(env)) process.env[key] = value;
|
||||
return env;
|
||||
}
|
||||
|
||||
function getAuthOptions(mode: SmokeMode, getWhitelabelAuthOptions: () => unknown) {
|
||||
switch (mode) {
|
||||
case "demo":
|
||||
return { disableSignUp: true };
|
||||
case "whitelabel":
|
||||
return getWhitelabelAuthOptions();
|
||||
default:
|
||||
// Mirrors apps/web's getDeploymentAuthOptions default branch. Local's
|
||||
// real databaseHooks fire only at user-create runtime, not at init, so
|
||||
// constructing with no options is faithful for a boot smoke.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function smokeMode(mode: SmokeMode): Promise<string[]> {
|
||||
const failures: string[] = [];
|
||||
const env = applyEnv(mode);
|
||||
|
||||
// 1. env validation
|
||||
const validation = getEnvValidationState(env);
|
||||
if (!validation.isValid) {
|
||||
failures.push(`env invalid — missing: ${validation.missing.map((m) => m.id).join(", ")}`);
|
||||
} else if (validation.mode !== mode) {
|
||||
failures.push(`env validated as mode "${validation.mode}", expected "${mode}"`);
|
||||
}
|
||||
|
||||
// 2. deployment factory resolution
|
||||
try {
|
||||
resetDeploymentCache();
|
||||
const deployment = getDeployment({ env });
|
||||
if (deployment.mode !== mode) {
|
||||
failures.push(`factory resolved mode "${deployment.mode}", expected "${mode}"`);
|
||||
}
|
||||
if (!deployment.features || !deployment.branding) {
|
||||
failures.push("factory returned an incomplete deployment (missing features/branding)");
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push(`factory threw: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
// 3. auth initialization — dynamic import so DATABASE_URL is live before db.ts runs
|
||||
try {
|
||||
const { createAuth } = await import("@workspace/lib/auth/server");
|
||||
const { getWhitelabelAuthOptions } = await import("@workspace/whitelabel/auth-hooks");
|
||||
const options = getAuthOptions(mode, getWhitelabelAuthOptions);
|
||||
// biome-ignore lint/suspicious/noExplicitAny: options shape varies per mode
|
||||
const auth = createAuth(options as any);
|
||||
if (typeof auth.handler !== "function" || typeof auth.api !== "object") {
|
||||
failures.push("auth initialized without a handler/api");
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push(`auth init threw: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return failures;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const requested = process.argv[2];
|
||||
if (requested && !SMOKE_MODES.includes(requested as SmokeMode)) {
|
||||
console.error(`Unknown mode "${requested}". Expected one of: ${SMOKE_MODES.join(", ")}`);
|
||||
process.exit(2);
|
||||
}
|
||||
const modes = requested ? [requested as SmokeMode] : SMOKE_MODES;
|
||||
|
||||
let failed = false;
|
||||
for (const mode of modes) {
|
||||
const failures = await smokeMode(mode);
|
||||
if (failures.length === 0) {
|
||||
console.log(`✓ ${mode}: env valid, factory resolved, auth initialized`);
|
||||
} else {
|
||||
failed = true;
|
||||
console.error(`✗ ${mode}:`);
|
||||
for (const failure of failures) console.error(` - ${failure}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
console.error("\nMode-compatibility smoke FAILED.");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\nMode-compatibility smoke passed for: ${modes.join(", ")}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("Smoke runner crashed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
216
apps/web/src/components/app-sidebar.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import {
|
||||
IconBuilding,
|
||||
IconBuildings,
|
||||
IconChartBar,
|
||||
IconCpu,
|
||||
IconDashboard,
|
||||
IconGauge,
|
||||
IconLink,
|
||||
IconListDetails,
|
||||
IconListNumbers,
|
||||
IconReport,
|
||||
IconSitemap,
|
||||
IconSpeakerphone,
|
||||
IconTable,
|
||||
IconTarget,
|
||||
IconTimeline,
|
||||
IconTool,
|
||||
} from "@tabler/icons-react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import type { BrandWithPrompts } from "@workspace/lib/db/schema";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@workspace/ui/components/sidebar";
|
||||
import type * as React from "react";
|
||||
import { DemoModePill } from "@/components/demo-mode-pill";
|
||||
import { Logo } from "@/components/logo";
|
||||
import { NavAppInfo } from "@/components/nav-app-info";
|
||||
import { type NavGroup, NavMain } from "@/components/nav-main";
|
||||
import { NavUser } from "@/components/nav-user";
|
||||
|
||||
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
isAdmin?: boolean;
|
||||
hasReportAccess?: boolean;
|
||||
/** When true, only show admin section (no brand-specific nav) */
|
||||
adminOnly?: boolean;
|
||||
/** Brand data from route loader — avoids a separate client-side fetch */
|
||||
brand?: BrandWithPrompts | null;
|
||||
}
|
||||
|
||||
export function AppSidebar({
|
||||
isAdmin = false,
|
||||
hasReportAccess = false,
|
||||
adminOnly = false,
|
||||
brand,
|
||||
...props
|
||||
}: AppSidebarProps) {
|
||||
const { setOpenMobile } = useSidebar();
|
||||
|
||||
const showAdminSection = isAdmin || hasReportAccess;
|
||||
|
||||
const groups: NavGroup[] = [];
|
||||
|
||||
// Dashboard section - only show if we have a brand context and not admin-only
|
||||
if (!adminOnly) {
|
||||
const dashboardItems = [
|
||||
{
|
||||
title: "Overview",
|
||||
url: "/",
|
||||
icon: IconDashboard,
|
||||
},
|
||||
];
|
||||
|
||||
// Only show Visibility and Citations if the brand is onboarded
|
||||
if (brand?.onboarded) {
|
||||
dashboardItems.push(
|
||||
{
|
||||
title: "Visibility",
|
||||
url: "/visibility",
|
||||
icon: IconChartBar,
|
||||
},
|
||||
{
|
||||
title: "Share of Voice",
|
||||
url: "/share-of-voice",
|
||||
icon: IconSpeakerphone,
|
||||
},
|
||||
{
|
||||
title: "Query Fan-Out",
|
||||
url: "/query-fan-out",
|
||||
icon: IconSitemap,
|
||||
},
|
||||
{
|
||||
title: "Citations",
|
||||
url: "/citations",
|
||||
icon: IconLink,
|
||||
},
|
||||
{
|
||||
title: "Opportunities",
|
||||
url: "/opportunities",
|
||||
icon: IconTarget,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
groups.push({
|
||||
label: "Dashboard",
|
||||
items: dashboardItems,
|
||||
});
|
||||
|
||||
// Settings section - always show so users can access brand settings or delete the brand
|
||||
groups.push({
|
||||
label: "Settings",
|
||||
items: [
|
||||
{
|
||||
title: "Brand",
|
||||
url: "/settings/brand",
|
||||
icon: IconBuilding,
|
||||
},
|
||||
{
|
||||
title: "Competitors",
|
||||
url: "/settings/competitors",
|
||||
icon: IconBuildings,
|
||||
},
|
||||
{
|
||||
title: "Prompts",
|
||||
url: "/settings/prompts",
|
||||
icon: IconListDetails,
|
||||
},
|
||||
{
|
||||
title: "LLMs",
|
||||
url: "/settings/llms",
|
||||
icon: IconCpu,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Admin section
|
||||
if (showAdminSection) {
|
||||
const adminItems = isAdmin
|
||||
? [
|
||||
{
|
||||
title: "Brands",
|
||||
url: "/admin",
|
||||
icon: IconTable,
|
||||
absolute: true,
|
||||
},
|
||||
{
|
||||
title: "Reports",
|
||||
url: "/reports",
|
||||
icon: IconReport,
|
||||
absolute: true,
|
||||
},
|
||||
{
|
||||
title: "Workflows",
|
||||
url: "/admin/workflows",
|
||||
icon: IconTimeline,
|
||||
absolute: true,
|
||||
},
|
||||
{
|
||||
title: "Queue",
|
||||
url: "/admin/queue",
|
||||
icon: IconListNumbers,
|
||||
absolute: true,
|
||||
},
|
||||
{
|
||||
title: "API Usage",
|
||||
url: "/admin/usage",
|
||||
icon: IconGauge,
|
||||
absolute: true,
|
||||
},
|
||||
{
|
||||
title: "Tools",
|
||||
url: "/admin/tools",
|
||||
icon: IconTool,
|
||||
absolute: true,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
title: "Reports",
|
||||
url: "/reports",
|
||||
icon: IconReport,
|
||||
absolute: true,
|
||||
},
|
||||
];
|
||||
|
||||
groups.push({
|
||||
label: "Admin",
|
||||
items: adminItems,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar variant="inset" {...props}>
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<Link to="/app" onClick={() => setOpenMobile(false)}>
|
||||
<Logo iconClassName="!size-5" />
|
||||
<div className="ml-auto group-data-[collapsible=icon]:hidden">
|
||||
<DemoModePill />
|
||||
</div>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain groups={groups} />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser />
|
||||
<NavAppInfo />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
172
apps/web/src/components/base-chart-print.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
|
||||
import * as React from "react";
|
||||
import { Bar, BarChart, XAxis, YAxis, ResponsiveContainer, Cell } from "recharts";
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import type { ClientConfig } from "@workspace/config/types";
|
||||
import { ChartDataPoint, getBadgeVariant, getBadgeClassName } from "@/lib/chart-utils";
|
||||
import type { Brand, Competitor } from "@workspace/lib/db/schema";
|
||||
|
||||
interface BaseChartPrintProps {
|
||||
data: ChartDataPoint[];
|
||||
title?: string;
|
||||
visibility?: number | null;
|
||||
showTitle?: boolean;
|
||||
showBadge?: boolean;
|
||||
brand: Brand;
|
||||
competitors: Competitor[];
|
||||
}
|
||||
|
||||
interface BarData {
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
isBrand: boolean;
|
||||
}
|
||||
|
||||
function CustomXAxisTick(props: any) {
|
||||
const { x, y, payload, brandName } = props;
|
||||
const isCurrentBrand = payload.value === brandName;
|
||||
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<text
|
||||
x={0}
|
||||
y={0}
|
||||
dy={8}
|
||||
textAnchor="middle"
|
||||
fill="#374151"
|
||||
fontSize="10"
|
||||
fontWeight={isCurrentBrand ? "bold" : "normal"}
|
||||
>
|
||||
{payload.value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function BaseChartPrint({
|
||||
data,
|
||||
title,
|
||||
visibility,
|
||||
showTitle = false,
|
||||
showBadge = false,
|
||||
brand,
|
||||
competitors,
|
||||
}: BaseChartPrintProps) {
|
||||
const routeContext = useRouteContext({ strict: false }) as { clientConfig?: ClientConfig };
|
||||
// Get the most recent data point that has actual data
|
||||
const latestDataPoint = data
|
||||
.filter((point) => {
|
||||
// Check if any brand or competitor has non-null data
|
||||
const allIds = [brand.id, ...competitors.map((c) => c.id)];
|
||||
return allIds.some((id) => point[id] !== null && point[id] !== undefined);
|
||||
})
|
||||
.pop();
|
||||
|
||||
if (!latestDataPoint) {
|
||||
return (
|
||||
<div className="flex-1 space-y-2 print:space-y-1">
|
||||
{showTitle && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{title && <h3 className="text-sm font-medium capitalize print:text-xs">{title}</h3>}
|
||||
</div>
|
||||
)}
|
||||
<div className="h-[200px] print:h-[150px] flex items-center justify-center text-muted-foreground text-sm print:text-xs">
|
||||
No data available
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Create bar data for all entities (brand + competitors)
|
||||
const chartColors = routeContext.clientConfig?.branding.chartColors ?? [];
|
||||
const allEntities: BarData[] = [];
|
||||
|
||||
// Add brand data
|
||||
const brandValue = latestDataPoint[brand.id] as number;
|
||||
if (brandValue !== null && brandValue !== undefined) {
|
||||
allEntities.push({
|
||||
name: brand.name,
|
||||
value: brandValue,
|
||||
color: chartColors[0],
|
||||
isBrand: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Add competitor data
|
||||
competitors.forEach((competitor, index) => {
|
||||
const competitorValue = latestDataPoint[competitor.id] as number;
|
||||
if (competitorValue !== null && competitorValue !== undefined) {
|
||||
const colorIndex = (index + 1) % chartColors.length;
|
||||
allEntities.push({
|
||||
name: competitor.name,
|
||||
value: competitorValue,
|
||||
color: chartColors[colorIndex],
|
||||
isBrand: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort all entities by value (highest first), then limit to top 6 (including brand if present)
|
||||
const sortedEntities = allEntities.sort((a, b) => b.value - a.value).slice(0, 6);
|
||||
|
||||
return (
|
||||
<div className="flex-1">
|
||||
{showTitle && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{title && <h3 className="text-sm font-medium capitalize print:text-xs">{title}</h3>}
|
||||
{showBadge && visibility !== null && (
|
||||
<Badge
|
||||
variant={getBadgeVariant(visibility!)}
|
||||
className={`text-xs ${getBadgeClassName(visibility!)} print:text-xs`}
|
||||
>
|
||||
{visibility}%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="h-[300px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%" initialDimension={{ width: 1, height: 1 }}>
|
||||
<BarChart data={sortedEntities} margin={{ top: 20, right: 15, left: 20, bottom: 0 }} barCategoryGap="20%">
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={<CustomXAxisTick brandName={brand.name} />}
|
||||
height={30}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, "auto"]}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{
|
||||
fontSize: 10,
|
||||
fill: "#6B7280",
|
||||
}}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
width={40}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="value"
|
||||
radius={[4, 4, 0, 0]}
|
||||
minPointSize={2}
|
||||
label={{
|
||||
position: "top",
|
||||
fontSize: 11,
|
||||
fontWeight: "bold",
|
||||
fill: "#374151",
|
||||
formatter: (value: unknown) => `${value}%`,
|
||||
}}
|
||||
>
|
||||
{sortedEntities.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
354
apps/web/src/components/base-chart.tsx
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
|
||||
import * as React from "react";
|
||||
import { Line, LineChart, Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@workspace/ui/components/chart";
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import type { ClientConfig } from "@workspace/config/types";
|
||||
import {
|
||||
LookbackPeriod,
|
||||
ChartDataPoint,
|
||||
filterAndCompleteChartData,
|
||||
extendLinesToChartEdges,
|
||||
isExtendedDataPoint,
|
||||
getBadgeVariant,
|
||||
getBadgeClassName,
|
||||
selectCompetitorsToDisplay,
|
||||
} from "@/lib/chart-utils";
|
||||
import type { Brand, Competitor } from "@workspace/lib/db/schema";
|
||||
import { WebLogo } from "@/components/web-logo";
|
||||
import { formatDateStr } from "@/lib/app-locale";
|
||||
|
||||
interface BaseChartProps {
|
||||
data: ChartDataPoint[];
|
||||
lookback: LookbackPeriod;
|
||||
title?: string;
|
||||
visibility?: number | null;
|
||||
showTitle?: boolean;
|
||||
showBadge?: boolean;
|
||||
brand: Brand;
|
||||
competitors: Competitor[];
|
||||
isAnimationActive?: boolean;
|
||||
chartType?: "bar" | "line";
|
||||
chartColors?: string[];
|
||||
chartHeight?: string;
|
||||
}
|
||||
|
||||
export function BaseChart({
|
||||
data,
|
||||
lookback,
|
||||
title,
|
||||
visibility,
|
||||
showTitle = false,
|
||||
showBadge = false,
|
||||
brand,
|
||||
competitors,
|
||||
isAnimationActive = false,
|
||||
chartType = "line",
|
||||
chartColors: chartColorsProp,
|
||||
chartHeight = "250px",
|
||||
}: BaseChartProps) {
|
||||
const completeData = filterAndCompleteChartData(data, lookback);
|
||||
const context = useRouteContext({ strict: false }) as { clientConfig?: ClientConfig };
|
||||
|
||||
// Sort all competitors alphabetically for consistent color assignment
|
||||
const sortedAllCompetitors = [...competitors].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Select top competitors to display (max 3)
|
||||
const selectedCompetitors = selectCompetitorsToDisplay(competitors, completeData, 3);
|
||||
|
||||
// Sort selected competitors alphabetically to maintain consistent ordering
|
||||
const sortedSelectedCompetitors = [...selectedCompetitors].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Create dynamic chart config based on brand and ALL competitors (for consistent colors)
|
||||
const chartColors = chartColorsProp ?? context.clientConfig?.branding.chartColors ?? [];
|
||||
const chartConfig: ChartConfig = {
|
||||
visitors: {
|
||||
label: "Visibility",
|
||||
},
|
||||
[brand.id]: {
|
||||
label: brand.name,
|
||||
logo: brand.website ? <WebLogo domain={brand.website} size={14} /> : undefined,
|
||||
color: chartColors[0], // Brand gets first color
|
||||
},
|
||||
};
|
||||
|
||||
// Add ALL competitors to config with consistent colors based on their position in the full sorted list
|
||||
sortedAllCompetitors.forEach((competitor, index) => {
|
||||
const colorIndex = (index + 1) % chartColors.length;
|
||||
chartConfig[competitor.id] = {
|
||||
label: competitor.name,
|
||||
logo: competitor.domains?.[0] ? <WebLogo domain={competitor.domains[0]} size={14} /> : undefined,
|
||||
color: chartColors[colorIndex],
|
||||
};
|
||||
});
|
||||
|
||||
// Get data keys for rendering lines (only selected competitors + brand)
|
||||
// Brand comes last so it renders on top when dots overlap
|
||||
const dataKeys = [...sortedSelectedCompetitors.map((c) => c.id), brand.id];
|
||||
|
||||
// Build custom legend payload to only show brand + selected competitors (not duplicates from dashed/solid lines)
|
||||
const legendPayload = [
|
||||
{ value: brand.name, dataKey: brand.id, color: chartConfig[brand.id].color },
|
||||
...sortedSelectedCompetitors.map((c) => ({
|
||||
value: c.name,
|
||||
dataKey: c.id,
|
||||
color: chartConfig[c.id].color,
|
||||
})),
|
||||
];
|
||||
|
||||
// For bar charts, filter out days where ALL entities have null values
|
||||
// For line charts, keep all days to maintain proper time-based spacing on x-axis
|
||||
// and extend lines to chart edges to fill gaps at start/end of data collection
|
||||
const chartData = chartType === "bar"
|
||||
? completeData.filter(point => {
|
||||
// Keep the data point if ANY tracked entity has a non-null value
|
||||
return dataKeys.some(key => {
|
||||
const value = point[key];
|
||||
return value !== null && value !== undefined;
|
||||
});
|
||||
})
|
||||
: extendLinesToChartEdges(completeData, dataKeys).map(point => {
|
||||
// Add _solid versions of each key that have null for extended points
|
||||
const newPoint = { ...point };
|
||||
for (const key of dataKeys) {
|
||||
if (isExtendedDataPoint(point, key)) {
|
||||
newPoint[`${key}_solid`] = null;
|
||||
} else {
|
||||
newPoint[`${key}_solid`] = point[key];
|
||||
}
|
||||
}
|
||||
return newPoint;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-2">
|
||||
{showTitle && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{title && <h3 className="text-sm font-medium capitalize">{title}</h3>}
|
||||
{showBadge && visibility !== null && (
|
||||
<Badge variant={getBadgeVariant(visibility!)} className={`text-xs ${getBadgeClassName(visibility!)}`}>
|
||||
{visibility}%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{chartType === "bar" ? (
|
||||
<ChartContainer config={chartConfig} className="aspect-auto w-full" style={{ height: chartHeight }}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
domain={["dataMin", "dataMax"]}
|
||||
type="category"
|
||||
tickFormatter={(value) => formatDateStr(value, { month: "short", day: "numeric" })}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, "auto"]}
|
||||
type="number"
|
||||
allowDataOverflow={false}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickCount={6}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
/>
|
||||
<ChartTooltip
|
||||
isAnimationActive={false}
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(value) => formatDateStr(String(value), { month: "short", day: "numeric" })}
|
||||
indicator="dot"
|
||||
formatter={(value, name, item, index) => {
|
||||
const indicatorColor = chartConfig[name as string]?.color;
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="shrink-0 rounded-[2px] h-2.5 w-2.5"
|
||||
style={{
|
||||
backgroundColor: indicatorColor,
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-1 justify-between gap-4 leading-none items-center">
|
||||
<div className="grid gap-1.5">
|
||||
<span className="text-muted-foreground">{chartConfig[name as string]?.label || name}</span>
|
||||
</div>
|
||||
{value !== null && value !== undefined && (
|
||||
<span className="text-foreground font-mono font-xs tabular-nums">{value}%</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{dataKeys.map((key, index) => (
|
||||
<Bar key={key} dataKey={key} fill={`var(--color-${key})`} minPointSize={2} radius={2} />
|
||||
))}
|
||||
<ChartLegend content={() => <ChartLegendContent payload={legendPayload} className="flex-wrap gap-x-4 gap-y-1" />} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<ChartContainer config={chartConfig} className="aspect-auto w-full" style={{ height: chartHeight }}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
domain={["dataMin", "dataMax"]}
|
||||
type="category"
|
||||
tickFormatter={(value) => formatDateStr(value, { month: "short", day: "numeric" })}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, "auto"]}
|
||||
type="number"
|
||||
allowDataOverflow={false}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickCount={6}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
/>
|
||||
<ChartTooltip
|
||||
isAnimationActive={false}
|
||||
cursor={false}
|
||||
content={({ active, payload, label }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
|
||||
// Filter to only show:
|
||||
// 1. Original keys (not _solid versions) from the dashed lines
|
||||
// 2. Only non-extended values
|
||||
const filteredPayload = payload.filter((item: any) => {
|
||||
const key = item.dataKey as string;
|
||||
// Skip _solid keys - we only want the original keys from dashed lines
|
||||
if (key.endsWith('_solid')) return false;
|
||||
// Skip extended data points
|
||||
if (item.payload && isExtendedDataPoint(item.payload, key)) return false;
|
||||
// Skip null/undefined values
|
||||
if (item.value === null || item.value === undefined) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// If no real data to show, hide tooltip entirely
|
||||
if (filteredPayload.length === 0) return null;
|
||||
|
||||
// Format the date label
|
||||
const formattedDate = formatDateStr(label as string, { month: "short", day: "numeric" });
|
||||
|
||||
return (
|
||||
<div className="border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl">
|
||||
<div className="font-medium">{formattedDate}</div>
|
||||
<div className="grid gap-1.5">
|
||||
{filteredPayload.map((item: any) => {
|
||||
const indicatorColor = chartConfig[item.dataKey as string]?.color;
|
||||
return (
|
||||
<div key={item.dataKey} className="flex w-full items-center gap-2">
|
||||
<div
|
||||
className="shrink-0 rounded-[2px] h-2.5 w-2.5"
|
||||
style={{ backgroundColor: indicatorColor }}
|
||||
/>
|
||||
<div className="flex flex-1 justify-between gap-4 leading-none items-center">
|
||||
<span className="text-muted-foreground">
|
||||
{chartConfig[item.dataKey as string]?.label || item.dataKey}
|
||||
</span>
|
||||
<span className="text-foreground font-mono font-xs tabular-nums">
|
||||
{item.value}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{/* Render two lines per entity: dashed for extended data, solid for real data */}
|
||||
{dataKeys.flatMap((key) => [
|
||||
// First: Dashed line showing extended/extrapolated portions (hidden from legend)
|
||||
<Line
|
||||
key={`${key}-dashed`}
|
||||
dataKey={key}
|
||||
name={`${key}-dashed`}
|
||||
type="bump"
|
||||
stroke={`var(--color-${key})`}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4 4"
|
||||
dot={false}
|
||||
activeDot={false}
|
||||
connectNulls={true}
|
||||
isAnimationActive={isAnimationActive}
|
||||
legendType="none"
|
||||
/>,
|
||||
// Second: Solid line overlay for real data (shows in legend)
|
||||
<Line
|
||||
key={`${key}-solid`}
|
||||
dataKey={`${key}_solid`}
|
||||
name={key}
|
||||
type="bump"
|
||||
stroke={`var(--color-${key})`}
|
||||
strokeWidth={2}
|
||||
// Custom dot that only shows for real data points
|
||||
dot={({ cx, cy, payload, value }: any) => {
|
||||
// Don't render dot for extended points or null values
|
||||
// Return empty <g> element instead of null to satisfy Recharts types
|
||||
if (!payload || isExtendedDataPoint(payload, key) || value === null || value === undefined) {
|
||||
return <g key={`dot-empty-${key}-${cx}`} />;
|
||||
}
|
||||
return (
|
||||
<circle
|
||||
key={`dot-${key}-${cx}`}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={2}
|
||||
fill={`var(--color-${key})`}
|
||||
stroke={`var(--color-${key})`}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
activeDot={({ cx, cy, payload, value }: any) => {
|
||||
// Don't render active dot for extended points or null values
|
||||
// Return empty <g> element instead of null to satisfy Recharts types
|
||||
if (!payload || isExtendedDataPoint(payload, key) || value === null || value === undefined) {
|
||||
return <g key={`activedot-empty-${key}-${cx}`} />;
|
||||
}
|
||||
return (
|
||||
<circle
|
||||
key={`activedot-${key}-${cx}`}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={4}
|
||||
fill={`var(--color-${key})`}
|
||||
stroke={`var(--color-${key})`}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
connectNulls={true}
|
||||
isAnimationActive={isAnimationActive}
|
||||
/>,
|
||||
])}
|
||||
<ChartLegend content={() => <ChartLegendContent payload={legendPayload} className="flex-wrap gap-x-4 gap-y-1" />} />
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
apps/web/src/components/brand-onboarding.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { Label } from "@workspace/ui/components/label";
|
||||
|
||||
import { useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import FullPageCard from "@/components/full-page-card";
|
||||
import { createBrandFn } from "@/server/brands";
|
||||
import { trackEvent } from "@/lib/posthog";
|
||||
|
||||
interface BrandOnboardingProps {
|
||||
brandId: string;
|
||||
brandName: string;
|
||||
}
|
||||
|
||||
export default function BrandOnboarding({ brandId, brandName }: BrandOnboardingProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const navigate = useNavigate();
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const website = formData.get("website") as string;
|
||||
await createBrandFn({
|
||||
data: { brandId, brandName, website },
|
||||
});
|
||||
trackEvent("brand_created", { has_website: Boolean(website) });
|
||||
|
||||
await router.invalidate();
|
||||
await navigate({ to: "/app/$brand", params: { brand: brandId } });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FullPageCard title={`Setup ${brandName}`} subtitle="Configure your brand to get started" showBackButton={true}>
|
||||
<form action={handleSubmit} className="space-y-4">
|
||||
<input type="hidden" name="brandId" value={brandId} />
|
||||
<input type="hidden" name="brandName" value={brandName} />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="website">Website</Label>
|
||||
<Input
|
||||
id="website"
|
||||
name="website"
|
||||
type="text"
|
||||
placeholder="example.com"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Enter your brand's website</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? "Setting up..." : "Complete Setup"}
|
||||
</Button>
|
||||
</form>
|
||||
</FullPageCard>
|
||||
);
|
||||
}
|
||||
75
apps/web/src/components/brand-priority-button.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* Moves a brand's waiting prompts to the front of the `process-prompt` queue.
|
||||
*
|
||||
* Shared between the admin brand table and the queue page because both offer the
|
||||
* same action on the same row of data, and the failure/confirmation handling is
|
||||
* the only interesting part of it.
|
||||
*/
|
||||
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { ArrowUp, Check, Loader2, Undo2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { setBrandQueuePriorityFn } from "@/server/admin";
|
||||
|
||||
export function BrandPriorityButton({
|
||||
brandId,
|
||||
isPrioritised,
|
||||
onUpdate,
|
||||
}: {
|
||||
brandId: string;
|
||||
/** Omit when the caller has no priority data - the button then only offers to promote. */
|
||||
isPrioritised?: boolean;
|
||||
onUpdate?: () => void;
|
||||
}) {
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const demote = isPrioritised === true;
|
||||
|
||||
const handleClick = async () => {
|
||||
setIsUpdating(true);
|
||||
setError(null);
|
||||
try {
|
||||
await setBrandQueuePriorityFn({ data: { brandId, priority: demote ? "normal" : "high" } });
|
||||
setDone(true);
|
||||
setTimeout(() => {
|
||||
setDone(false);
|
||||
onUpdate?.();
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to update priority");
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClick}
|
||||
disabled={isUpdating || done}
|
||||
className="cursor-pointer"
|
||||
title={
|
||||
demote
|
||||
? "Return this brand's queued prompts to normal priority"
|
||||
: "Run this brand's prompts before the rest of the queue"
|
||||
}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : done ? (
|
||||
<Check className="h-3 w-3 text-emerald-500" />
|
||||
) : demote ? (
|
||||
<Undo2 className="h-3 w-3" />
|
||||
) : (
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
)}
|
||||
<span className="ml-1">{done ? "Updated" : demote ? "Normal" : "Run first"}</span>
|
||||
</Button>
|
||||
{error && <span className="text-xs text-red-500">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
293
apps/web/src/components/cached-prompt-chart.tsx
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
|
||||
import { memo, useMemo, useCallback } from "react";
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@workspace/ui/components/card";
|
||||
import { Separator } from "@workspace/ui/components/separator";
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { Skeleton } from "@workspace/ui/components/skeleton";
|
||||
import { BaseChart } from "./base-chart";
|
||||
import { ChartActionsFooter } from "./chart-actions-footer";
|
||||
import { TextHighlighter } from "./text-highlighter";
|
||||
import { useChartExport } from "@/hooks/use-chart-export";
|
||||
import { useOptionalChartDataContext } from "@/contexts/chart-data-context";
|
||||
import type { LookbackPeriod } from "@/hooks/use-prompt-chart-data";
|
||||
import {
|
||||
getBadgeVariant,
|
||||
getBadgeClassName,
|
||||
} from "@/lib/chart-utils";
|
||||
|
||||
const PLACEHOLDER_BARS_NO_DATA = [20, 35, 15, 45, 25, 40, 30, 50, 20, 35, 45, 28].map(
|
||||
(h, i) => ({ key: String(i), h }),
|
||||
);
|
||||
const PLACEHOLDER_BARS_NO_VISIBILITY = [10, 15, 8, 12, 10, 14, 8, 12, 10, 15, 12, 9].map(
|
||||
(h, i) => ({ key: String(i), h }),
|
||||
);
|
||||
|
||||
function PromptTitle({ name, highlight }: { name: string; highlight: string }) {
|
||||
return (
|
||||
<CardTitle className="text-sm">
|
||||
<TextHighlighter text={name} highlight={highlight} />
|
||||
</CardTitle>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CachedPromptChartProps {
|
||||
promptId: string;
|
||||
promptName: string;
|
||||
brandId: string;
|
||||
lookback: LookbackPeriod;
|
||||
/** Current model filter from the URL. "all" = no filter. */
|
||||
selectedModel?: string;
|
||||
/** Concrete model ids this brand runs — passed down so the export / optimize
|
||||
* button can offer them; don't include the "all" sentinel here. */
|
||||
availableModels?: string[];
|
||||
searchHighlight?: string;
|
||||
// Whether this prompt has ever been evaluated (all-time)
|
||||
// Used to distinguish "never evaluated" vs "no data in selected window"
|
||||
hasEverBeenEvaluated?: boolean;
|
||||
}
|
||||
|
||||
// Memoized: when a sibling filter / react-query state change re-renders
|
||||
// VirtualizedPromptList with identical props, 30+ of these don't need to
|
||||
// walk their internal useMemos again.
|
||||
export const CachedPromptChart = memo(function CachedPromptChart({
|
||||
promptId,
|
||||
promptName,
|
||||
brandId,
|
||||
lookback = "1m",
|
||||
selectedModel = "all",
|
||||
availableModels = [],
|
||||
searchHighlight = "",
|
||||
hasEverBeenEvaluated = false,
|
||||
}: CachedPromptChartProps) {
|
||||
// Get data from context (pre-loaded)
|
||||
const chartContext = useOptionalChartDataContext();
|
||||
|
||||
// Get processed chart data for this specific prompt
|
||||
const chartData = useMemo(() => {
|
||||
if (!chartContext) return null;
|
||||
return chartContext.getChartDataForPrompt(promptId);
|
||||
}, [chartContext, promptId]);
|
||||
|
||||
// Setup export functionality
|
||||
const fileName = chartContext?.brand
|
||||
? `${chartContext.brand.name}-${promptName.replace(/[^a-zA-Z0-9]/g, "_").substring(0, 50)}`
|
||||
: `chart-${promptName.replace(/[^a-zA-Z0-9]/g, "_").substring(0, 50)}`;
|
||||
const { isExporting, handleExport, portal: exportPortal } = useChartExport(fileName);
|
||||
|
||||
const brand = chartContext?.brand ?? null;
|
||||
const competitors = chartContext?.competitors;
|
||||
const data = chartData?.chartData;
|
||||
const totalRuns = chartData?.totalRuns ?? 0;
|
||||
const hasVisibilityData = chartData?.hasVisibilityData ?? false;
|
||||
const lastBrandVisibility = chartData?.lastBrandVisibility ?? null;
|
||||
const avgBrandVisibility = chartData?.avgBrandVisibility ?? null;
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!brand || !data || !competitors) return;
|
||||
handleExport({
|
||||
promptName,
|
||||
// Export the same headline number the card shows.
|
||||
visibility: avgBrandVisibility,
|
||||
data,
|
||||
lookback,
|
||||
brand,
|
||||
competitors,
|
||||
});
|
||||
}, [handleExport, promptName, avgBrandVisibility, data, lookback, brand, competitors]);
|
||||
|
||||
// Loading state — structure matches the success state card exactly:
|
||||
// CardHeader (title + badge), Separator, CardContent (pl-0 pr-6, h-[250px]), footer
|
||||
if (!chartContext || chartContext.isLoading || !chartData) {
|
||||
return (
|
||||
<Card className="py-3 gap-3">
|
||||
<CardHeader className="flex justify-between items-center px-3">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-5 w-24 rounded-full" />
|
||||
</CardHeader>
|
||||
<Separator className="py-0 my-0" />
|
||||
<CardContent className="pl-0 pr-6">
|
||||
<div className="h-[250px] flex items-center justify-center">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-32 mx-auto" />
|
||||
<div className="flex justify-center space-x-2">
|
||||
<div className="h-2 w-2 bg-primary/20 rounded-full animate-pulse" />
|
||||
<div className="h-2 w-2 bg-primary/20 rounded-full animate-pulse [animation-delay:0.2s]" />
|
||||
<div className="h-2 w-2 bg-primary/20 rounded-full animate-pulse [animation-delay:0.4s]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<Separator className="py-0 my-0" />
|
||||
<CardFooter className="flex items-center justify-between px-3 pt-3 pb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-6 w-16 rounded" />
|
||||
<Skeleton className="h-6 w-24 rounded" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-20 rounded" />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// No runs state - distinguish between "never evaluated" vs "no data in selected window"
|
||||
if (totalRuns === 0) {
|
||||
const isFirstEval = !hasEverBeenEvaluated;
|
||||
|
||||
return (
|
||||
<Card className="py-3 gap-3">
|
||||
<CardHeader className="flex justify-between items-center px-3">
|
||||
<PromptTitle name={promptName} highlight={searchHighlight} />
|
||||
</CardHeader>
|
||||
<Separator className="py-0 my-0" />
|
||||
{/* h-[300px] instead of h-[250px] to compensate for the missing footer section,
|
||||
keeping overall card height consistent with data-filled cards for virtualization */}
|
||||
<CardContent className="px-3">
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<div className="flex flex-col items-center text-center max-w-xs">
|
||||
{isFirstEval ? (
|
||||
<>
|
||||
<div className="flex space-x-1.5 mb-3">
|
||||
<div className="h-2 w-2 rounded-full bg-muted-foreground/30 animate-pulse" />
|
||||
<div className="h-2 w-2 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:0.2s]" />
|
||||
<div className="h-2 w-2 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:0.4s]" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Evaluating for the first time
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
Results will appear here shortly.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-16 w-full mb-3 flex items-end justify-center gap-[3px]">
|
||||
{PLACEHOLDER_BARS_NO_DATA.map((bar) => (
|
||||
<div
|
||||
key={bar.key}
|
||||
className="w-1.5 rounded-sm bg-muted-foreground/10"
|
||||
style={{ height: `${bar.h}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
No data in selected time range
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
Try selecting a longer time period to see historical data.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// No visibility data state
|
||||
if (!hasVisibilityData) {
|
||||
return (
|
||||
<>
|
||||
{exportPortal}
|
||||
<Card className="py-3 gap-3">
|
||||
<CardHeader className="flex justify-between items-center px-3">
|
||||
<PromptTitle name={promptName} highlight={searchHighlight} />
|
||||
</CardHeader>
|
||||
<Separator className="py-0 my-0" />
|
||||
<CardContent className="px-3">
|
||||
<div className="h-[250px] flex items-center justify-center">
|
||||
<div className="flex flex-col items-center text-center max-w-xs">
|
||||
<div className="h-16 w-full mb-3 flex items-end justify-center gap-[3px]">
|
||||
{PLACEHOLDER_BARS_NO_VISIBILITY.map((bar) => (
|
||||
<div
|
||||
key={bar.key}
|
||||
className="w-1.5 rounded-sm bg-muted-foreground/10"
|
||||
style={{ height: `${bar.h}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
No brands found in responses
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
Your brand and competitors weren't mentioned in the evaluated responses for this prompt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<div className="print:hidden">
|
||||
<ChartActionsFooter
|
||||
promptId={promptId}
|
||||
brandId={brandId}
|
||||
promptName={promptName}
|
||||
onDownload={handleDownload}
|
||||
isDownloading={isExporting}
|
||||
selectedModel={selectedModel}
|
||||
availableModels={availableModels}
|
||||
lookback={lookback}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Success state with chart
|
||||
return (
|
||||
<>
|
||||
{exportPortal}
|
||||
<Card className="py-3 gap-3">
|
||||
<CardHeader className="flex justify-between items-center px-3">
|
||||
<PromptTitle name={promptName} highlight={searchHighlight} />
|
||||
{/* Average over the selected window is the headline number (it matches the
|
||||
page-level visibility bar); the latest day sits beside it as context. */}
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{avgBrandVisibility !== null && (
|
||||
<Badge
|
||||
variant={getBadgeVariant(avgBrandVisibility)}
|
||||
className={getBadgeClassName(avgBrandVisibility)}
|
||||
title="Average visibility across the selected time range"
|
||||
>
|
||||
{avgBrandVisibility}% Visibility
|
||||
</Badge>
|
||||
)}
|
||||
{lastBrandVisibility !== null && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="font-normal text-muted-foreground"
|
||||
title="Visibility on the most recent day with data"
|
||||
>
|
||||
{lastBrandVisibility}% latest
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<Separator className="py-0 my-0" />
|
||||
<CardContent className="pl-0 pr-6">
|
||||
{brand && data && competitors && (
|
||||
<BaseChart
|
||||
data={data}
|
||||
lookback={lookback}
|
||||
brand={brand}
|
||||
competitors={competitors}
|
||||
isAnimationActive={false}
|
||||
chartType="line"
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
<div className="print:hidden">
|
||||
<ChartActionsFooter
|
||||
promptId={promptId}
|
||||
brandId={brandId}
|
||||
promptName={promptName}
|
||||
onDownload={handleDownload}
|
||||
isDownloading={isExporting}
|
||||
selectedModel={selectedModel}
|
||||
availableModels={availableModels}
|
||||
lookback={lookback}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
});
|
||||
103
apps/web/src/components/chart-actions-footer.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import type { ClientConfig } from "@workspace/config/types";
|
||||
import { getOptimizeButtonForMode } from "@workspace/deployment/client";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Download } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import { APP_TIMEZONE } from "@/lib/app-locale";
|
||||
import type { LookbackPeriod } from "@/lib/chart-utils";
|
||||
import { getPromptWebQueryFn } from "@/server/prompts";
|
||||
import { ChartFooter } from "./chart-footer";
|
||||
import { HistoryButton } from "./history-button";
|
||||
|
||||
interface ChartActionsFooterProps {
|
||||
promptId?: string;
|
||||
promptName?: string;
|
||||
brandId?: string;
|
||||
|
||||
// For export
|
||||
onDownload?: () => void;
|
||||
isDownloading?: boolean;
|
||||
|
||||
// For optimization
|
||||
/** Current model filter ("all" = no filter). */
|
||||
selectedModel?: string;
|
||||
/** Concrete model ids this brand runs — no "all" sentinel. */
|
||||
availableModels?: string[];
|
||||
lookback?: LookbackPeriod;
|
||||
}
|
||||
|
||||
export function ChartActionsFooter({
|
||||
promptId,
|
||||
promptName,
|
||||
brandId,
|
||||
onDownload,
|
||||
isDownloading = false,
|
||||
selectedModel = "all",
|
||||
availableModels = [],
|
||||
lookback = "1m",
|
||||
}: ChartActionsFooterProps) {
|
||||
const isSinglePrompt = Boolean(promptId && brandId);
|
||||
|
||||
const context = useRouteContext({ strict: false }) as { clientConfig?: ClientConfig };
|
||||
const mode = context.clientConfig?.mode ?? "local";
|
||||
const showOptimizeButton = context.clientConfig?.features.showOptimizeButton ?? false;
|
||||
const { parentName, optimizationUrlTemplate } = context.clientConfig?.branding ?? {};
|
||||
const OptimizeButton = getOptimizeButtonForMode(mode);
|
||||
|
||||
const fetchWebQuery = useCallback(
|
||||
async (pId: string, lb: string, model?: string) => {
|
||||
if (!brandId) throw new Error("No brand ID");
|
||||
return getPromptWebQueryFn({
|
||||
data: {
|
||||
brandId,
|
||||
promptId: pId,
|
||||
lookback: lb,
|
||||
model,
|
||||
timezone: APP_TIMEZONE,
|
||||
},
|
||||
});
|
||||
},
|
||||
[brandId],
|
||||
);
|
||||
|
||||
if (!isSinglePrompt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartFooter>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 w-full">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<HistoryButton promptName={promptName} promptId={promptId} brandId={brandId} />
|
||||
{onDownload && (
|
||||
<Button
|
||||
onClick={onDownload}
|
||||
disabled={isDownloading}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="text-xs cursor-pointer h-6 flex items-center px-2"
|
||||
title="Download chart as PNG"
|
||||
>
|
||||
<Download className="size-3 mr-0.5" />
|
||||
<span className="text-xs font-normal">{isDownloading ? "Exporting..." : "Export (PNG)"}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{showOptimizeButton && (
|
||||
<OptimizeButton
|
||||
promptName={promptName}
|
||||
promptId={promptId}
|
||||
brandId={brandId}
|
||||
selectedModel={selectedModel}
|
||||
availableModels={availableModels}
|
||||
lookback={lookback}
|
||||
parentName={parentName ?? ""}
|
||||
optimizationUrlTemplate={optimizationUrlTemplate ?? ""}
|
||||
fetchWebQuery={fetchWebQuery}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ChartFooter>
|
||||
);
|
||||
}
|
||||
30
apps/web/src/components/chart-download-footer.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
|
||||
import { Download } from "lucide-react";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { ChartFooter } from "./chart-footer";
|
||||
|
||||
interface ChartDownloadFooterProps {
|
||||
onDownload: () => void;
|
||||
isDownloading: boolean;
|
||||
}
|
||||
|
||||
export function ChartDownloadFooter({ onDownload, isDownloading }: ChartDownloadFooterProps) {
|
||||
return (
|
||||
<div className="print:hidden">
|
||||
<ChartFooter>
|
||||
<Button
|
||||
onClick={onDownload}
|
||||
disabled={isDownloading}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="text-xs cursor-pointer h-6 flex items-center px-2"
|
||||
title="Download chart as PNG"
|
||||
>
|
||||
<Download className="size-3 mr-0.5" />
|
||||
<span className="text-xs font-normal">{isDownloading ? "Exporting..." : "Export (PNG)"}</span>
|
||||
</Button>
|
||||
</ChartFooter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
123
apps/web/src/components/chart-export-preview.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { BaseChart } from "./base-chart";
|
||||
import { DEFAULT_APP_ICON, DEFAULT_APP_NAME } from "@workspace/config/constants";
|
||||
import type { Brand, Competitor } from "@workspace/lib/db/schema";
|
||||
import type { ChartDataPoint, LookbackPeriod } from "@/lib/chart-utils";
|
||||
import { getBadgeVariant, getBadgeClassName } from "@/lib/chart-utils";
|
||||
|
||||
export interface ChartExportBranding {
|
||||
name?: string;
|
||||
icon?: string;
|
||||
parentUrl?: string;
|
||||
isWhitelabel: boolean;
|
||||
chartColors: string[];
|
||||
}
|
||||
|
||||
export interface ChartExportPreviewProps {
|
||||
promptName: string;
|
||||
visibility: number | null;
|
||||
data: ChartDataPoint[];
|
||||
lookback: LookbackPeriod;
|
||||
brand: Brand;
|
||||
competitors: Competitor[];
|
||||
branding: ChartExportBranding;
|
||||
}
|
||||
|
||||
export const EXPORT_W = 1200;
|
||||
export const EXPORT_H = 628;
|
||||
|
||||
const HEADER_H = 56;
|
||||
const HEADER_TOP = 16;
|
||||
const GAP_HEADER_CARD = 16;
|
||||
const CARD_PADDING_Y = 24;
|
||||
const FOOTER_REGION = 80;
|
||||
const CHART_H = EXPORT_H - HEADER_TOP - HEADER_H - GAP_HEADER_CARD - CARD_PADDING_Y - FOOTER_REGION;
|
||||
|
||||
export function ChartExportPreview({
|
||||
promptName,
|
||||
visibility,
|
||||
data,
|
||||
lookback,
|
||||
brand,
|
||||
competitors,
|
||||
branding,
|
||||
}: ChartExportPreviewProps) {
|
||||
const name = branding.name || DEFAULT_APP_NAME;
|
||||
const isWhitelabel = branding.isWhitelabel && branding.name !== DEFAULT_APP_NAME;
|
||||
const domain = isWhitelabel
|
||||
? branding.parentUrl?.replace(/^https?:\/\//, "").replace(/\/$/, "") || ""
|
||||
: "Getcito.com";
|
||||
const hasCustomIcon = branding.icon && branding.icon !== DEFAULT_APP_ICON;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ width: EXPORT_W, height: EXPORT_H, paddingTop: HEADER_TOP, fontSize: 16 }}
|
||||
className="bg-white overflow-hidden flex flex-col"
|
||||
>
|
||||
{/* Title bar */}
|
||||
<div
|
||||
style={{ height: HEADER_H, marginBottom: GAP_HEADER_CARD }}
|
||||
className="flex items-center justify-between px-10 gap-6 shrink-0"
|
||||
>
|
||||
<h2
|
||||
className="font-semibold text-gray-900 truncate flex-1 min-w-0"
|
||||
style={{ fontSize: 22 }}
|
||||
title={promptName}
|
||||
>
|
||||
{promptName}
|
||||
</h2>
|
||||
{visibility !== null && (
|
||||
<Badge
|
||||
variant={getBadgeVariant(visibility)}
|
||||
className={`${getBadgeClassName(visibility)} shrink-0`}
|
||||
style={{ fontSize: 16, padding: "4px 14px" }}
|
||||
>
|
||||
{visibility}% Visibility
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chart card */}
|
||||
<div className="px-8 shrink-0">
|
||||
<div
|
||||
className="rounded-xl border border-gray-200 overflow-hidden pl-0"
|
||||
style={{ paddingRight: 12, paddingTop: 12, paddingBottom: 8 }}
|
||||
>
|
||||
<BaseChart
|
||||
data={data}
|
||||
lookback={lookback}
|
||||
brand={brand}
|
||||
competitors={competitors}
|
||||
isAnimationActive={false}
|
||||
chartType="line"
|
||||
chartColors={branding.chartColors}
|
||||
chartHeight={`${CHART_H}px`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Branding footer — fills remaining space, content vertically centered */}
|
||||
<div className="flex-1 flex items-center justify-between px-10 min-h-0">
|
||||
<div className="flex items-center gap-3">
|
||||
{isWhitelabel && hasCustomIcon && (
|
||||
<img
|
||||
src={branding.icon}
|
||||
alt={`${name} logo`}
|
||||
style={{ width: 28, height: 28 }}
|
||||
className="object-contain"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
)}
|
||||
{isWhitelabel ? (
|
||||
<span style={{ fontSize: 18 }} className="text-gray-500 font-semibold">{name}</span>
|
||||
) : (
|
||||
<span className="font-titan-one font-normal lowercase text-blue-600" style={{ fontSize: 24 }}>
|
||||
Getcito
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ fontSize: 18 }} className="text-gray-400 font-medium">{domain}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
apps/web/src/components/chart-footer.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
|
||||
import { Separator } from "@workspace/ui/components/separator";
|
||||
import { CardFooter } from "@workspace/ui/components/card";
|
||||
|
||||
interface ChartFooterProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChartFooter({ children, className = "" }: ChartFooterProps) {
|
||||
return (
|
||||
<>
|
||||
<Separator className="py-0 my-0" />
|
||||
<CardFooter className={`flex justify-between items-center px-3 pt-3 pb-0 ${className}`}>
|
||||
{children}
|
||||
</CardFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
183
apps/web/src/components/citations-display.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
type CitationCategory,
|
||||
CATEGORY_CONFIG,
|
||||
CITATION_CATEGORIES,
|
||||
CITATION_PAGE_TYPES,
|
||||
PAGE_TYPE_CONFIG,
|
||||
} from "@/lib/domain-categories";
|
||||
import type { CitationData } from "@/components/citations/types";
|
||||
import { CATEGORY_META, PAGE_TYPE_META } from "@/components/citations/shared";
|
||||
import { CitationStatsCards } from "@/components/citations/stats-cards";
|
||||
import { TrendAreaChart } from "@/components/citations/trend-area-chart";
|
||||
import { RecentChangesCard } from "@/components/citations/recent-changes-card";
|
||||
import { ContentGapsCard } from "@/components/citations/content-gaps-card";
|
||||
import { TopDomainsCard } from "@/components/citations/top-domains-card";
|
||||
import { TopUrlsCard } from "@/components/citations/top-urls-card";
|
||||
import { GoogleShoppingCard } from "@/components/citations/google-shopping-card";
|
||||
import { RedditCard, useSubredditData } from "@/components/citations/reddit-card";
|
||||
|
||||
export type {
|
||||
CitationData,
|
||||
GoogleModuleData,
|
||||
GoogleProductRow,
|
||||
GoogleQueryRow,
|
||||
} from "@/components/citations/types";
|
||||
|
||||
interface CitationsDisplayProps {
|
||||
citationData: CitationData;
|
||||
brandId?: string;
|
||||
brandName?: string;
|
||||
showStats?: boolean;
|
||||
maxDomains?: number;
|
||||
maxUrls?: number;
|
||||
days?: number;
|
||||
onCompetitorAdded?: () => void;
|
||||
}
|
||||
|
||||
/** Composes the citation sections. Each card owns its own in-card filter
|
||||
* state (search, tabs, pagination); this component only derives the data
|
||||
* every section shares. Section visibility keys off the UNFILTERED data —
|
||||
* in-card filters must never hide a whole section (issue #322). */
|
||||
export function CitationsDisplay({
|
||||
citationData,
|
||||
brandId,
|
||||
brandName,
|
||||
showStats = false,
|
||||
maxDomains = 10,
|
||||
maxUrls = 20,
|
||||
days = 7,
|
||||
onCompetitorAdded,
|
||||
}: CitationsDisplayProps) {
|
||||
// Match the last point of the Citation Categories chart exactly (smoothed daily
|
||||
// brand share), falling back to the window aggregate if there's no time series.
|
||||
const lastTrendPoint = citationData.citationTimeSeries?.[citationData.citationTimeSeries.length - 1];
|
||||
const brandShare = lastTrendPoint
|
||||
? (lastTrendPoint.brand ?? 0)
|
||||
: citationData.totalCitations > 0
|
||||
? Math.round((citationData.categoryCounts.brand / citationData.totalCitations) * 100)
|
||||
: 0;
|
||||
|
||||
const hasGaps = !!(citationData.competitorOnlyPrompts && citationData.competitorOnlyPrompts.length > 0 && brandId);
|
||||
|
||||
// Single source of truth for which categories / page types appear. Derived from
|
||||
// the RAW aggregates (categoryCounts / pageTypeDistribution), NOT the smoothed %
|
||||
// time series: a tiny category that rounds to 0% on every day would otherwise
|
||||
// vanish from both the chart keys and the tab filters despite having real
|
||||
// citations (and being filterable in the URL list). The same lists feed the tab
|
||||
// filters and the chart `keys`, so the two stay consistent.
|
||||
const chartSourceCategories = useMemo(
|
||||
() => CITATION_CATEGORIES.filter((c: CitationCategory) => (citationData.categoryCounts[c] ?? 0) > 0),
|
||||
[citationData.categoryCounts],
|
||||
);
|
||||
const chartPageTypes = useMemo(() => {
|
||||
const present = new Set((citationData.pageTypeDistribution ?? []).filter((d) => d.count > 0).map((d) => d.pageType));
|
||||
return CITATION_PAGE_TYPES.filter((p) => present.has(p));
|
||||
}, [citationData.pageTypeDistribution]);
|
||||
const urlSourceTabs = useMemo<{ key: string; label: string }[]>(
|
||||
() => [{ key: "all", label: "All Sources" }, ...chartSourceCategories.map((c) => ({ key: c as string, label: CATEGORY_CONFIG[c].label }))],
|
||||
[chartSourceCategories],
|
||||
);
|
||||
const domainSourceTabs = urlSourceTabs; // identical by construction (same chart-category list)
|
||||
const urlPageTypeTabs = useMemo<{ key: string; label: string }[]>(
|
||||
() => [{ key: "all", label: "All Page Types" }, ...chartPageTypes.map((p) => ({ key: p as string, label: PAGE_TYPE_CONFIG[p].label }))],
|
||||
[chartPageTypes],
|
||||
);
|
||||
|
||||
const googleModule = citationData.googleModule;
|
||||
const subredditData = useSubredditData(citationData.specificUrls, citationData.whatsChanged);
|
||||
const whatsChanged = citationData.whatsChanged;
|
||||
const totalChanges = whatsChanged
|
||||
? whatsChanged.newUrls.length +
|
||||
whatsChanged.droppedUrls.length +
|
||||
whatsChanged.titleChanges.length +
|
||||
whatsChanged.newDomains.length +
|
||||
whatsChanged.droppedDomains.length
|
||||
: 0;
|
||||
|
||||
// Bail out only AFTER every hook above has run unconditionally (Rules of Hooks).
|
||||
if (citationData.totalCitations === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showStats && (
|
||||
<CitationStatsCards
|
||||
brandShare={brandShare}
|
||||
uniqueDomains={citationData.uniqueDomains}
|
||||
totalCitations={citationData.totalCitations}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Citation Categories over time */}
|
||||
{citationData.citationTimeSeries && citationData.citationTimeSeries.length > 0 && (
|
||||
<TrendAreaChart
|
||||
title="Citation Categories"
|
||||
tooltip="Share of citations by source category over time, as a percentage of all citations each day. Smoothed to account for staggered prompt schedules; Google AI Mode search/shopping are excluded (see the Google Shopping section)."
|
||||
data={(citationData.citationTimeSeries ?? []) as unknown as Array<Record<string, number | string>>}
|
||||
keys={chartSourceCategories}
|
||||
meta={CATEGORY_META}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Citation Page Types over time */}
|
||||
{citationData.pageTypeTimeSeries && citationData.pageTypeTimeSeries.length > 0 && (
|
||||
<TrendAreaChart
|
||||
title="Citation Page Types"
|
||||
tooltip="Share of citations by page type over time — what kind of page each citation points to, inferred from the URL and title."
|
||||
data={(citationData.pageTypeTimeSeries ?? []) as unknown as Array<Record<string, number | string>>}
|
||||
keys={chartPageTypes}
|
||||
meta={PAGE_TYPE_META}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Recent Changes + Content Gaps (side by side) */}
|
||||
{(totalChanges > 0 || hasGaps) && (
|
||||
<div className={totalChanges > 0 && hasGaps ? "grid grid-cols-1 lg:grid-cols-2 gap-4 items-stretch" : "contents"}>
|
||||
{totalChanges > 0 && whatsChanged && <RecentChangesCard whatsChanged={whatsChanged} days={days} />}
|
||||
{hasGaps && (
|
||||
<ContentGapsCard
|
||||
prompts={citationData.competitorOnlyPrompts!}
|
||||
brandId={brandId!}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Cited Domains */}
|
||||
{citationData.domainDistribution.length > 0 && (
|
||||
<TopDomainsCard
|
||||
domains={citationData.domainDistribution}
|
||||
sourceTabs={domainSourceTabs}
|
||||
maxDomains={maxDomains}
|
||||
brandId={brandId}
|
||||
brandName={brandName}
|
||||
competitors={citationData.competitors}
|
||||
onCompetitorAdded={onCompetitorAdded}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Top Cited URLs */}
|
||||
{citationData.specificUrls.length > 0 && (
|
||||
<TopUrlsCard
|
||||
urls={citationData.specificUrls}
|
||||
sourceTabs={urlSourceTabs}
|
||||
pageTypeTabs={urlPageTypeTabs}
|
||||
maxUrls={maxUrls}
|
||||
brandId={brandId}
|
||||
brandName={brandName}
|
||||
brandShare={brandShare}
|
||||
brandIsCited={citationData.categoryCounts.brand > 0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Google Shopping */}
|
||||
{googleModule && googleModule.shopping.products.length > 0 && (
|
||||
<GoogleShoppingCard googleModule={googleModule} brandId={brandId} />
|
||||
)}
|
||||
|
||||
{/* Top Cited Subreddits */}
|
||||
{subredditData.length > 0 && <RedditCard subreddits={subredditData} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
65
apps/web/src/components/citations/content-gaps-card.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@workspace/ui/components/card";
|
||||
import { Separator } from "@workspace/ui/components/separator";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@workspace/ui/components/tooltip";
|
||||
import { IconInfoCircle, IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ListPagination, usePagedList } from "@/components/list-pagination";
|
||||
|
||||
const PAGE_SIZE = 6;
|
||||
|
||||
export function ContentGapsCard({
|
||||
prompts,
|
||||
brandId,
|
||||
}: {
|
||||
prompts: Array<{ id: string; value: string; competitorCitationCount: number; uniqueCompetitors: number }>;
|
||||
brandId: string;
|
||||
}) {
|
||||
const { page, setPage, pageItems, totalItems } = usePagedList(prompts, PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-1.5">
|
||||
Content Gaps
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconInfoCircle className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-sm font-normal">
|
||||
Prompts where competitors are cited but your brand isn't — opportunities to improve your citation presence.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Prompts where competitors are cited but your brand isn't
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
<CardContent className="flex-1 flex flex-col">
|
||||
<div className="divide-y divide-border/50 flex-1">
|
||||
{pageItems.map((prompt) => (
|
||||
<Link
|
||||
key={prompt.id}
|
||||
to="/app/$brand/prompts/$promptId"
|
||||
params={{ brand: brandId, promptId: prompt.id }}
|
||||
className="flex items-start gap-2.5 py-2 group"
|
||||
>
|
||||
<div className="shrink-0 mt-0.5">
|
||||
<IconAlertTriangle className="h-3.5 w-3.5 text-amber-500" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium truncate text-foreground group-hover:underline">{prompt.value}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{prompt.uniqueCompetitors} {prompt.uniqueCompetitors === 1 ? "competitor" : "competitors"} cited {prompt.competitorCitationCount} {prompt.competitorCitationCount === 1 ? "time" : "times"} — your brand cited 0 times
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<ListPagination page={page} pageSize={PAGE_SIZE} totalItems={totalItems} onPageChange={setPage} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
175
apps/web/src/components/citations/google-shopping-card.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@workspace/ui/components/card";
|
||||
import { Separator } from "@workspace/ui/components/separator";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@workspace/ui/components/tooltip";
|
||||
import { IconInfoCircle, IconSearch, IconChevronDown } from "@tabler/icons-react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ListPagination, usePagedList } from "@/components/list-pagination";
|
||||
import { attributionDotClass } from "@/components/citations/shared";
|
||||
import type { GoogleModuleData } from "@/components/citations/types";
|
||||
|
||||
const PRODUCTS_PAGE_SIZE = 10;
|
||||
|
||||
function PromptCountList({
|
||||
prompts,
|
||||
brandId,
|
||||
}: {
|
||||
prompts: { id: string; value: string; count: number }[];
|
||||
brandId?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="pl-5 pb-2 space-y-0.5">
|
||||
{prompts.map((p) => (
|
||||
brandId ? (
|
||||
<Link key={p.id} to="/app/$brand/prompts/$promptId" params={{ brand: brandId, promptId: p.id }} className="flex items-center justify-between py-1 group text-xs">
|
||||
<span className="text-muted-foreground group-hover:text-foreground group-hover:underline truncate min-w-0">{p.value}</span>
|
||||
<span className="tabular-nums text-muted-foreground shrink-0 ml-3">{p.count.toLocaleString()}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div key={p.id} className="flex items-center justify-between py-1 text-xs">
|
||||
<span className="text-muted-foreground truncate min-w-0">{p.value}</span>
|
||||
<span className="tabular-nums text-muted-foreground shrink-0 ml-3">{p.count.toLocaleString()}</span>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GoogleShoppingCard({
|
||||
googleModule,
|
||||
brandId,
|
||||
}: {
|
||||
googleModule: GoogleModuleData;
|
||||
brandId?: string;
|
||||
}) {
|
||||
const [expandedProduct, setExpandedProduct] = useState<string | null>(null);
|
||||
const [productFilter, setProductFilter] = useState<"all" | "brand" | "competitor">("all");
|
||||
const [expandedQuery, setExpandedQuery] = useState<string | null>(null);
|
||||
const [showAllQueries, setShowAllQueries] = useState(false);
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
const ps = googleModule.shopping.products;
|
||||
return productFilter === "all" ? ps : ps.filter((p) => p.attribution === productFilter);
|
||||
}, [googleModule, productFilter]);
|
||||
const productCounts = useMemo(() => {
|
||||
const ps = googleModule.shopping.products;
|
||||
return {
|
||||
all: ps.length,
|
||||
brand: ps.filter((p) => p.attribution === "brand").length,
|
||||
competitor: ps.filter((p) => p.attribution === "competitor").length,
|
||||
};
|
||||
}, [googleModule]);
|
||||
|
||||
const { page, setPage, pageItems, totalItems } = usePagedList(filteredProducts, PRODUCTS_PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-1.5">
|
||||
Google Shopping
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconInfoCircle className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-sm font-normal">
|
||||
Product cards Google AI Mode showed when answering your prompts. The number next to each is how many times that card appeared across results (card inclusions, not unique products). Kept separate from the citation mix above.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Products Google AI Mode surfaced — <span className="font-medium text-emerald-600">{googleModule.shopping.brandCount.toLocaleString()}</span> appearances for yours vs <span className="font-medium text-red-600">{googleModule.shopping.competitorCount.toLocaleString()}</span> for competitors
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
<CardContent className="space-y-6">
|
||||
{googleModule.shopping.products.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2 gap-2">
|
||||
<h4 className="text-sm font-medium shrink-0">Products</h4>
|
||||
<div className="flex items-center gap-1">
|
||||
{([["all", "All"], ["brand", "Yours"], ["competitor", "Competitors"]] as const).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => { setProductFilter(key); setPage(0); }}
|
||||
className={`px-2 py-0.5 rounded text-[11px] cursor-pointer transition-colors ${productFilter === key ? "bg-muted text-foreground font-medium" : "text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{label} ({productCounts[key].toLocaleString()})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-border/50">
|
||||
{pageItems.map((product) => {
|
||||
const isExpanded = expandedProduct === product.name;
|
||||
return (
|
||||
<div key={product.name}>
|
||||
<div className="flex items-center justify-between py-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedProduct(isExpanded ? null : product.name)}
|
||||
className="flex items-center gap-1.5 min-w-0 cursor-pointer group text-left"
|
||||
>
|
||||
<IconChevronDown className={`h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform ${isExpanded ? "" : "-rotate-90"}`} />
|
||||
<span className={`shrink-0 rounded-full h-2 w-2 ${attributionDotClass(product.attribution)}`} />
|
||||
<span className="text-sm font-medium text-foreground group-hover:underline truncate">{product.name}</span>
|
||||
{product.attribution === "competitor" && product.competitorName && (
|
||||
<span className="text-[10px] text-muted-foreground whitespace-nowrap shrink-0">({product.competitorName})</span>
|
||||
)}
|
||||
</button>
|
||||
<span className="text-sm font-semibold tabular-nums shrink-0">{product.count.toLocaleString()}</span>
|
||||
</div>
|
||||
{isExpanded && product.prompts.length > 0 && (
|
||||
<PromptCountList prompts={product.prompts} brandId={brandId} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ListPagination page={page} pageSize={PRODUCTS_PAGE_SIZE} totalItems={totalItems} onPageChange={setPage} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{googleModule.search.queries.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Search queries</h4>
|
||||
<div className="divide-y divide-border/50">
|
||||
{(showAllQueries ? googleModule.search.queries : googleModule.search.queries.slice(0, 5)).map((q) => {
|
||||
const isExpanded = expandedQuery === q.query;
|
||||
return (
|
||||
<div key={q.query}>
|
||||
<div className="flex items-center justify-between py-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedQuery(isExpanded ? null : q.query)}
|
||||
className="flex items-center gap-1.5 min-w-0 cursor-pointer group text-left"
|
||||
>
|
||||
<IconChevronDown className={`h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform ${isExpanded ? "" : "-rotate-90"}`} />
|
||||
<IconSearch className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-foreground group-hover:underline truncate">{q.query}</span>
|
||||
</button>
|
||||
<span className="text-sm font-semibold tabular-nums shrink-0">{q.count.toLocaleString()}</span>
|
||||
</div>
|
||||
{isExpanded && q.prompts.length > 0 && (
|
||||
<PromptCountList prompts={q.prompts} brandId={brandId} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{googleModule.search.queries.length > 5 && !showAllQueries && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllQueries(true)}
|
||||
className="mt-3 text-xs text-muted-foreground hover:text-foreground cursor-pointer px-3 py-1.5 rounded-md border border-border hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
Show {googleModule.search.queries.length - 5} more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
137
apps/web/src/components/citations/recent-changes-card.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@workspace/ui/components/card";
|
||||
import { Separator } from "@workspace/ui/components/separator";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@workspace/ui/components/tooltip";
|
||||
import { IconExternalLink, IconInfoCircle, IconPlus, IconArrowDownRight, IconSwitchHorizontal } from "@tabler/icons-react";
|
||||
import type { CitationData } from "@/components/citations/types";
|
||||
import { formatPeriodLabel, formatUrlForDisplay, UnderlineTabs } from "@/components/citations/shared";
|
||||
|
||||
type ChangeType = "new_pages" | "dropped_pages" | "title" | "new_domains" | "dropped_domains";
|
||||
|
||||
const CHANGE_TYPE_TABS: { key: ChangeType; label: string }[] = [
|
||||
{ key: "new_pages", label: "New Pages" },
|
||||
{ key: "dropped_pages", label: "Dropped Pages" },
|
||||
{ key: "title", label: "Title Changes" },
|
||||
{ key: "new_domains", label: "New Domains" },
|
||||
{ key: "dropped_domains", label: "Dropped Domains" },
|
||||
];
|
||||
|
||||
export function RecentChangesCard({
|
||||
whatsChanged,
|
||||
days,
|
||||
}: {
|
||||
whatsChanged: NonNullable<CitationData["whatsChanged"]>;
|
||||
days: number;
|
||||
}) {
|
||||
const [changeTypeFilter, setChangeTypeFilter] = useState<ChangeType>("new_pages");
|
||||
|
||||
const allChanges = useMemo(() => {
|
||||
return [
|
||||
...whatsChanged.newUrls.map((u) => ({ type: "new_pages" as const, ...u })),
|
||||
...whatsChanged.droppedUrls.map((u) => ({ type: "dropped_pages" as const, ...u })),
|
||||
...whatsChanged.titleChanges.map((u) => ({ type: "title" as const, ...u })),
|
||||
...whatsChanged.newDomains.map((d) => ({ type: "new_domains" as const, ...d })),
|
||||
...whatsChanged.droppedDomains.map((d) => ({ type: "dropped_domains" as const, ...d })),
|
||||
];
|
||||
}, [whatsChanged]);
|
||||
|
||||
const filteredChanges = useMemo(() => {
|
||||
return allChanges.filter((c) => c.type === changeTypeFilter);
|
||||
}, [allChanges, changeTypeFilter]);
|
||||
|
||||
const visibleChanges = filteredChanges.slice(0, 6);
|
||||
|
||||
return (
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-1.5">
|
||||
Recent Changes
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconInfoCircle className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-sm font-normal">
|
||||
Compares this {formatPeriodLabel(days)} with the {formatPeriodLabel(days)} before it. Shows new and dropped pages, title changes, and new and dropped domains.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
How AI citations have shifted over the past {formatPeriodLabel(days)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
<CardContent className="flex-1">
|
||||
<UnderlineTabs
|
||||
tabs={CHANGE_TYPE_TABS}
|
||||
activeKey={changeTypeFilter}
|
||||
onSelect={(key) => setChangeTypeFilter(key)}
|
||||
/>
|
||||
<div className="divide-y divide-border/50">
|
||||
{visibleChanges.map((change) => {
|
||||
const isDomainChange = change.type === "new_domains" || change.type === "dropped_domains";
|
||||
const rawUrl = "url" in change ? (change.url as string) : undefined;
|
||||
const domain = "domain" in change ? (change.domain as string) : undefined;
|
||||
const url = rawUrl ?? (isDomainChange && domain ? `https://${domain}` : undefined);
|
||||
const displayLabel = isDomainChange ? domain ?? "" : rawUrl ? formatUrlForDisplay(rawUrl) : "";
|
||||
const key = isDomainChange ? `${change.type}-${domain}` : `${change.type}-${url ?? ""}`;
|
||||
|
||||
const icon = (change.type === "new_pages" || change.type === "new_domains")
|
||||
? <IconPlus className="h-3.5 w-3.5 text-green-600" />
|
||||
: (change.type === "dropped_pages" || change.type === "dropped_domains")
|
||||
? <IconArrowDownRight className="h-3.5 w-3.5 text-red-600" />
|
||||
: <IconSwitchHorizontal className="h-3.5 w-3.5 text-amber-600" />;
|
||||
|
||||
let description: React.ReactNode = null;
|
||||
if (change.type === "new_pages" && "promptCount" in change) {
|
||||
description = `0 → ${change.count} citations across ${change.promptCount} prompt${change.promptCount !== 1 ? "s" : ""}`;
|
||||
} else if (change.type === "dropped_pages" && "previousCount" in change) {
|
||||
description = `${change.previousCount} → ${change.currentCount} citations`;
|
||||
} else if (change.type === "title" && "currentTitle" in change && "previousTitle" in change) {
|
||||
description = (
|
||||
<>
|
||||
<span className="line-through opacity-60">{change.previousTitle}</span>
|
||||
{" → "}
|
||||
<span className="font-medium text-foreground">{change.currentTitle}</span>
|
||||
</>
|
||||
);
|
||||
} else if (change.type === "new_domains" && "count" in change) {
|
||||
description = `${change.count} citation${change.count !== 1 ? "s" : ""} in the current period`;
|
||||
} else if (change.type === "dropped_domains" && "previousCount" in change) {
|
||||
description = `${change.previousCount} citation${change.previousCount !== 1 ? "s" : ""} last period, none now`;
|
||||
}
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
<div className="shrink-0 mt-0.5">{icon}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-sm font-medium truncate text-foreground${url ? " group-hover:underline" : ""}`}>{displayLabel}</span>
|
||||
{url && <IconExternalLink className="h-3 w-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />}
|
||||
</div>
|
||||
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<a
|
||||
key={key}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-start gap-2.5 py-2 group"
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
{visibleChanges.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No {CHANGE_TYPE_TABS.find((t) => t.key === changeTypeFilter)?.label.toLowerCase() ?? changeTypeFilter} changes in this period.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
164
apps/web/src/components/citations/reddit-card.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@workspace/ui/components/card";
|
||||
import { Separator } from "@workspace/ui/components/separator";
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@workspace/ui/components/tooltip";
|
||||
import { IconExternalLink, IconInfoCircle, IconChevronDown } from "@tabler/icons-react";
|
||||
import { ListPagination, usePagedList } from "@/components/list-pagination";
|
||||
import { extractSubreddit, formatUrlForDisplay } from "@/components/citations/shared";
|
||||
import type { CitationData } from "@/components/citations/types";
|
||||
|
||||
const SUBREDDITS_PAGE_SIZE = 8;
|
||||
|
||||
/** Exact-host check (citation `domain` is a normalized lowercase hostname).
|
||||
* A substring match would also catch lookalikes such as
|
||||
* "notreddit.com" or "reddit.com.evil.net" (CodeQL js/incomplete-url-substring-sanitization). */
|
||||
const isRedditDomain = (domain: string) =>
|
||||
domain === "reddit.com" || domain.endsWith(".reddit.com");
|
||||
|
||||
export function useSubredditData(
|
||||
specificUrls: CitationData["specificUrls"],
|
||||
whatsChanged: CitationData["whatsChanged"],
|
||||
) {
|
||||
return useMemo(() => {
|
||||
const droppedUrlSet = new Set(
|
||||
whatsChanged?.droppedUrls
|
||||
.filter((u) => isRedditDomain(u.domain))
|
||||
.map((u) => extractSubreddit(u.url))
|
||||
.filter(Boolean) ?? [],
|
||||
);
|
||||
|
||||
const map = new Map<string, {
|
||||
count: number;
|
||||
newPages: number;
|
||||
totalPages: number;
|
||||
urls: { url: string; title?: string; count: number; isNew?: boolean }[];
|
||||
}>();
|
||||
for (const u of specificUrls) {
|
||||
if (!isRedditDomain(u.domain)) continue;
|
||||
const sub = extractSubreddit(u.url);
|
||||
if (!sub) continue;
|
||||
const existing = map.get(sub);
|
||||
if (existing) {
|
||||
existing.count += u.count;
|
||||
existing.totalPages += 1;
|
||||
if (u.isNew) existing.newPages += 1;
|
||||
existing.urls.push({ url: u.url, title: u.title, count: u.count, isNew: u.isNew });
|
||||
} else {
|
||||
map.set(sub, {
|
||||
count: u.count,
|
||||
newPages: u.isNew ? 1 : 0,
|
||||
totalPages: 1,
|
||||
urls: [{ url: u.url, title: u.title, count: u.count, isNew: u.isNew }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.entries())
|
||||
.map(([name, data]) => ({
|
||||
name,
|
||||
count: data.count,
|
||||
newPages: data.newPages,
|
||||
totalPages: data.totalPages,
|
||||
allNew: data.newPages === data.totalPages,
|
||||
hasDropped: droppedUrlSet.has(name),
|
||||
urls: data.urls.sort((a, b) => b.count - a.count),
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}, [specificUrls, whatsChanged]);
|
||||
}
|
||||
|
||||
export function RedditCard({
|
||||
subreddits,
|
||||
}: {
|
||||
subreddits: ReturnType<typeof useSubredditData>;
|
||||
}) {
|
||||
const [expandedSubreddit, setExpandedSubreddit] = useState<string | null>(null);
|
||||
const { page, setPage, pageItems, totalItems } = usePagedList(subreddits, SUBREDDITS_PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-1.5">
|
||||
Reddit
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconInfoCircle className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-sm font-normal">
|
||||
Reddit communities most frequently cited by AI models. Extracted from all reddit.com URLs in your citation data.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Top cited subreddits — which Reddit communities AI models reference when answering your prompts
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
<CardContent>
|
||||
<div className="divide-y divide-border/50">
|
||||
{pageItems.map((sub) => {
|
||||
const isExpanded = expandedSubreddit === sub.name;
|
||||
return (
|
||||
<div key={sub.name}>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedSubreddit(isExpanded ? null : sub.name)}
|
||||
className="flex items-center gap-1.5 min-w-0 cursor-pointer group"
|
||||
>
|
||||
<IconChevronDown className={`h-3.5 w-3.5 text-muted-foreground transition-transform ${isExpanded ? "" : "-rotate-90"}`} />
|
||||
<span className="text-sm font-medium text-foreground group-hover:underline truncate">{sub.name}</span>
|
||||
{sub.allNew && (
|
||||
<Badge className="text-[10px] px-1.5 py-0 h-[18px] border-0 shadow-none bg-green-100 text-green-700">NEW</Badge>
|
||||
)}
|
||||
{!sub.allNew && sub.newPages > 0 && (
|
||||
<span className="text-[10px] text-green-600 whitespace-nowrap">+{sub.newPages} new</span>
|
||||
)}
|
||||
{sub.hasDropped && (
|
||||
<span className="text-[10px] text-red-500 whitespace-nowrap">some dropped</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex items-center gap-2 shrink-0 ml-3">
|
||||
<span className="text-sm font-semibold tabular-nums">
|
||||
{sub.count.toLocaleString()}
|
||||
</span>
|
||||
<a
|
||||
href={`https://reddit.com/${sub.name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<IconExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && sub.urls.length > 0 && (
|
||||
<div className="pl-5 pb-2 space-y-0.5">
|
||||
{sub.urls.map((u) => (
|
||||
<a
|
||||
key={u.url}
|
||||
href={u.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between py-1 group text-xs"
|
||||
>
|
||||
<span className="text-muted-foreground group-hover:text-foreground group-hover:underline truncate min-w-0 flex items-center gap-1.5">
|
||||
{u.title || formatUrlForDisplay(u.url)}
|
||||
{u.isNew && <Badge className="text-[9px] px-1 py-0 h-[14px] border-0 shadow-none bg-green-100 text-green-700 shrink-0">NEW</Badge>}
|
||||
</span>
|
||||
<span className="tabular-nums text-muted-foreground shrink-0 ml-3">{u.count.toLocaleString()}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ListPagination page={page} pageSize={SUBREDDITS_PAGE_SIZE} totalItems={totalItems} onPageChange={setPage} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
94
apps/web/src/components/citations/shared.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import {
|
||||
type CitationCategory,
|
||||
CATEGORY_CONFIG,
|
||||
CITATION_CATEGORIES,
|
||||
CITATION_PAGE_TYPES,
|
||||
PAGE_TYPE_CONFIG,
|
||||
} from "@/lib/domain-categories";
|
||||
|
||||
export const getCategoryLabel = (category: string) =>
|
||||
CATEGORY_CONFIG[category as CitationCategory]?.label ?? category;
|
||||
|
||||
export const getCategoryColorClass = (category: string) =>
|
||||
CATEGORY_CONFIG[category as CitationCategory]?.badgeClass ?? "bg-gray-500/90 text-white";
|
||||
|
||||
export const formatUrlForDisplay = (url: string) => {
|
||||
let displayUrl = url.replace(/^https?:\/\//, "");
|
||||
displayUrl = displayUrl.replace(/^www\./, "");
|
||||
displayUrl = displayUrl.replace(/#:~:text=[^&]*/, "");
|
||||
if (displayUrl.endsWith("#")) displayUrl = displayUrl.slice(0, -1);
|
||||
const maxLength = 80;
|
||||
if (displayUrl.length > maxLength) {
|
||||
displayUrl = `${displayUrl.substring(0, maxLength)}...`;
|
||||
}
|
||||
return displayUrl;
|
||||
};
|
||||
|
||||
export function formatPeriodLabel(days: number): string {
|
||||
if (days === 1) return "24 hours";
|
||||
if (days === 7) return "week";
|
||||
if (days === 14) return "2 weeks";
|
||||
if (days === 30) return "month";
|
||||
if (days === 60) return "2 months";
|
||||
if (days === 90) return "3 months";
|
||||
return `${days} days`;
|
||||
}
|
||||
|
||||
export const extractSubreddit = (url: string): string | null => {
|
||||
try {
|
||||
const match = url.match(/reddit\.com\/r\/([^/?#]+)/i);
|
||||
return match ? `r/${match[1]}` : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const extractFilenameFromUrl = (url: string) => {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const segments = urlObj.pathname.split("/").filter(Boolean);
|
||||
if (segments.length === 0) return urlObj.hostname.replace(/^www\./, "");
|
||||
return segments[segments.length - 1];
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
export const CATEGORY_META: Record<string, { label: string; color: string }> = Object.fromEntries(
|
||||
CITATION_CATEGORIES.map((c) => [c, { label: CATEGORY_CONFIG[c].label, color: CATEGORY_CONFIG[c].chartColor }]),
|
||||
);
|
||||
export const PAGE_TYPE_META: Record<string, { label: string; color: string }> = Object.fromEntries(
|
||||
CITATION_PAGE_TYPES.map((p) => [p, { label: PAGE_TYPE_CONFIG[p].label, color: PAGE_TYPE_CONFIG[p].chartColor }]),
|
||||
);
|
||||
|
||||
export const attributionDotClass = (a: "brand" | "competitor" | "other") =>
|
||||
a === "brand" ? "bg-emerald-500" : a === "competitor" ? "bg-red-500" : "bg-gray-400";
|
||||
|
||||
export function UnderlineTabs<T extends string>({
|
||||
tabs,
|
||||
activeKey,
|
||||
onSelect,
|
||||
}: {
|
||||
tabs: readonly { key: T; label: string }[];
|
||||
activeKey: T;
|
||||
onSelect: (key: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<nav className="-mb-px flex gap-4 border-b border-border" aria-label="Tabs">
|
||||
{tabs.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => onSelect(key)}
|
||||
className={`cursor-pointer whitespace-nowrap pb-2.5 text-xs font-medium transition-colors border-b-2 ${
|
||||
activeKey === key
|
||||
? "border-foreground text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
67
apps/web/src/components/citations/stats-cards.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "@workspace/ui/components/card";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@workspace/ui/components/tooltip";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
tooltip,
|
||||
value,
|
||||
}: {
|
||||
title: string;
|
||||
tooltip: React.ReactNode;
|
||||
value: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className="flex flex-col">
|
||||
<CardHeader className="gap-0">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-1.5">
|
||||
{title}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconInfoCircle className="h-3.5 w-3.5 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-sm font-normal">{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex items-center">
|
||||
<div className="text-2xl sm:text-3xl lg:text-4xl font-bold">{value}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function CitationStatsCards({
|
||||
brandShare,
|
||||
uniqueDomains,
|
||||
totalCitations,
|
||||
}: {
|
||||
brandShare: number;
|
||||
uniqueDomains: number;
|
||||
totalCitations: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<StatCard
|
||||
title="Brand Citation Share"
|
||||
tooltip="The percentage of all citations that link to your brand's domain. A higher share means AI models are more likely to reference your content."
|
||||
value={`${brandShare}%`}
|
||||
/>
|
||||
<StatCard
|
||||
title="Unique Domains"
|
||||
tooltip="The number of distinct domains cited across all prompt evaluations in this period."
|
||||
value={uniqueDomains.toLocaleString()}
|
||||
/>
|
||||
{/* Kept deliberately simple: the user doesn't need the Google AI Mode
|
||||
search/shopping nuance. Those surfaces aren't citations in the
|
||||
traditional sense (they point back into Google's own product/search
|
||||
results, not an external domain w.r.t. the model), so they're
|
||||
excluded from this count and broken out in the Google Shopping card. */}
|
||||
<StatCard
|
||||
title="Total Citations"
|
||||
tooltip="The total external websites cited by AI models across prompt evaluations."
|
||||
value={totalCitations.toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
apps/web/src/components/citations/top-domains-card.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@workspace/ui/components/card";
|
||||
import { Separator } from "@workspace/ui/components/separator";
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@workspace/ui/components/tooltip";
|
||||
import { IconInfoCircle, IconSearch } from "@tabler/icons-react";
|
||||
import { ProgressBarChart, DOMAIN_CATEGORY_COLORS } from "@/components/progress-bar-chart";
|
||||
import { TrackDomainPopover } from "@/components/citations/track-domain-popover";
|
||||
import { UnderlineTabs } from "@/components/citations/shared";
|
||||
import type { CitationData } from "@/components/citations/types";
|
||||
|
||||
export function TopDomainsCard({
|
||||
domains,
|
||||
sourceTabs,
|
||||
maxDomains,
|
||||
brandId,
|
||||
brandName,
|
||||
competitors,
|
||||
onCompetitorAdded,
|
||||
}: {
|
||||
domains: CitationData["domainDistribution"];
|
||||
sourceTabs: { key: string; label: string }[];
|
||||
maxDomains: number;
|
||||
brandId?: string;
|
||||
brandName?: string;
|
||||
competitors?: CitationData["competitors"];
|
||||
onCompetitorAdded?: () => void;
|
||||
}) {
|
||||
const [domainSearch, setDomainSearch] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("all");
|
||||
const [visibleDomains, setVisibleDomains] = useState(maxDomains);
|
||||
|
||||
const filteredDomains = useMemo(() => {
|
||||
let result = domains;
|
||||
if (selectedCategory !== "all") {
|
||||
result = result.filter((d) => d.category === selectedCategory);
|
||||
}
|
||||
if (domainSearch) {
|
||||
const q = domainSearch.toLowerCase();
|
||||
result = result.filter((d) => d.domain.toLowerCase().includes(q));
|
||||
}
|
||||
return result;
|
||||
}, [domains, selectedCategory, domainSearch]);
|
||||
|
||||
return (
|
||||
<Card className="gap-4">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<CardTitle className="flex items-center gap-1.5">
|
||||
Top Cited Domains
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconInfoCircle className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-sm font-normal">
|
||||
The most frequently cited domains across all prompt evaluations. Each domain is colored by its category (brand, competitor, etc.).
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Which domains LLMs reference most when responding to your prompts
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="relative w-full sm:w-48 shrink-0">
|
||||
<IconSearch className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search domains..."
|
||||
value={domainSearch}
|
||||
onChange={(e) => { setDomainSearch(e.target.value); setVisibleDomains(maxDomains); }}
|
||||
className="h-8 pl-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
<CardContent>
|
||||
{sourceTabs.length > 2 && (
|
||||
<div className="mb-3">
|
||||
<UnderlineTabs
|
||||
tabs={sourceTabs}
|
||||
activeKey={selectedCategory}
|
||||
onSelect={(key) => { setSelectedCategory(key); setVisibleDomains(maxDomains); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* The card itself is gated on the UNFILTERED list by the parent;
|
||||
only the rows react to the search/tab filters, so a no-match
|
||||
search shows this message instead of hiding the section (#322). */}
|
||||
{filteredDomains.length > 0 ? (
|
||||
<>
|
||||
<ProgressBarChart
|
||||
items={filteredDomains.slice(0, visibleDomains).map((domain) => ({
|
||||
label: domain.domain,
|
||||
count: domain.count,
|
||||
category: domain.category || "other",
|
||||
action: domain.category === "other" && brandId && competitors ? (
|
||||
<TrackDomainPopover
|
||||
domain={domain.domain}
|
||||
brandId={brandId}
|
||||
brandName={brandName}
|
||||
competitors={competitors}
|
||||
onAdded={onCompetitorAdded}
|
||||
/>
|
||||
) : undefined,
|
||||
}))}
|
||||
colorMapping={DOMAIN_CATEGORY_COLORS}
|
||||
percentageMode="max"
|
||||
/>
|
||||
{filteredDomains.length > visibleDomains && visibleDomains < 100 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisibleDomains((prev) => Math.min(prev + 20, 100))}
|
||||
className="mt-6 text-xs text-muted-foreground hover:text-foreground cursor-pointer px-3 py-1.5 rounded-md border border-border hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
Show more
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No domains match the current filters.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
184
apps/web/src/components/citations/top-urls-card.tsx
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@workspace/ui/components/card";
|
||||
import { Separator } from "@workspace/ui/components/separator";
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@workspace/ui/components/tooltip";
|
||||
import { IconExternalLink, IconInfoCircle, IconSearch } from "@tabler/icons-react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import {
|
||||
UnderlineTabs,
|
||||
formatUrlForDisplay,
|
||||
extractFilenameFromUrl,
|
||||
getCategoryLabel,
|
||||
getCategoryColorClass,
|
||||
} from "@/components/citations/shared";
|
||||
import type { CitationData } from "@/components/citations/types";
|
||||
|
||||
export function TopUrlsCard({
|
||||
urls,
|
||||
sourceTabs,
|
||||
pageTypeTabs,
|
||||
maxUrls,
|
||||
brandId,
|
||||
brandName,
|
||||
brandShare,
|
||||
brandIsCited,
|
||||
}: {
|
||||
urls: CitationData["specificUrls"];
|
||||
sourceTabs: { key: string; label: string }[];
|
||||
pageTypeTabs: { key: string; label: string }[];
|
||||
maxUrls: number;
|
||||
brandId?: string;
|
||||
brandName?: string;
|
||||
brandShare: number;
|
||||
brandIsCited: boolean;
|
||||
}) {
|
||||
const [urlSearch, setUrlSearch] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("all");
|
||||
const [selectedPageType, setSelectedPageType] = useState<string>("all");
|
||||
|
||||
const filteredUrls = useMemo(() => {
|
||||
let result = urls;
|
||||
if (selectedCategory !== "all") {
|
||||
result = result.filter((u) => u.category === selectedCategory);
|
||||
}
|
||||
if (selectedPageType !== "all") {
|
||||
result = result.filter((u) => u.pageType === selectedPageType);
|
||||
}
|
||||
if (urlSearch) {
|
||||
const q = urlSearch.toLowerCase();
|
||||
result = result.filter((u) =>
|
||||
u.url.toLowerCase().includes(q) ||
|
||||
(u.title?.toLowerCase().includes(q)) ||
|
||||
u.domain.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return result.slice(0, maxUrls);
|
||||
}, [urls, selectedCategory, selectedPageType, urlSearch, maxUrls]);
|
||||
|
||||
return (
|
||||
<Card className="gap-4">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<CardTitle className="flex items-center gap-1.5">
|
||||
Top Cited URLs
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconInfoCircle className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-sm font-normal">
|
||||
<p className="mb-2">The specific pages most frequently cited by AI models. Filter by category to focus on brand, competitor, or other sources.</p>
|
||||
<p><strong>Competitor</strong> domains are only those in your {brandId ? <Link to="/app/$brand/settings/competitors" params={{ brand: brandId }} className="underline">tracked competitors list</Link> : "tracked competitors list"}.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Individual pages cited by LLMs{brandIsCited && brandName && (
|
||||
<> — {brandName} accounts for <strong>{brandShare}%</strong> of all citations</>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="relative w-full sm:w-48 shrink-0">
|
||||
<IconSearch className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search URLs..."
|
||||
value={urlSearch}
|
||||
onChange={(e) => setUrlSearch(e.target.value)}
|
||||
className="h-8 pl-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
<CardContent>
|
||||
{sourceTabs.length > 2 && (
|
||||
<UnderlineTabs
|
||||
tabs={sourceTabs}
|
||||
activeKey={selectedCategory}
|
||||
onSelect={setSelectedCategory}
|
||||
/>
|
||||
)}
|
||||
{pageTypeTabs.length > 2 && (
|
||||
<div className="flex items-center flex-wrap gap-1.5 mt-3">
|
||||
{pageTypeTabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setSelectedPageType(t.key)}
|
||||
className={`px-2 py-0.5 rounded text-[11px] cursor-pointer transition-colors ${selectedPageType === t.key ? "bg-muted text-foreground font-medium" : "text-muted-foreground hover:text-foreground hover:bg-muted/60"}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="divide-y divide-border mt-1">
|
||||
{filteredUrls.map((citation) => {
|
||||
const displayUrl = formatUrlForDisplay(citation.url);
|
||||
const domainEndIndex = displayUrl.indexOf("/");
|
||||
const domainPart = domainEndIndex > 0 ? displayUrl.substring(0, domainEndIndex) : displayUrl;
|
||||
const pathPart = domainEndIndex > 0 ? displayUrl.substring(domainEndIndex) : "";
|
||||
|
||||
return (
|
||||
<a
|
||||
key={citation.url}
|
||||
href={citation.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-start justify-between gap-3 py-3 group"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<Badge className={`text-[10px] px-1.5 py-0 h-[18px] border-0 shadow-none ${getCategoryColorClass(citation.category)}`}>
|
||||
{getCategoryLabel(citation.category)}
|
||||
</Badge>
|
||||
{citation.isNew && (
|
||||
<Badge className="text-[10px] px-1.5 py-0 h-[18px] border-0 shadow-none bg-green-100 text-green-700">NEW</Badge>
|
||||
)}
|
||||
<span className="text-sm font-medium truncate group-hover:underline">
|
||||
{citation.title || extractFilenameFromUrl(citation.url)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
<span className="font-semibold">{domainPart}</span>
|
||||
{pathPart && <span>{pathPart}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0 pt-0.5">
|
||||
{citation.avgPosition != null && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">avg {citation.avgPosition.toFixed(1)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-xs">
|
||||
Average citation position (lower = cited earlier in the response)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-sm font-semibold tabular-nums min-w-[2rem] text-right">
|
||||
{citation.count.toLocaleString()}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-xs">
|
||||
Total times this URL was cited across all prompt evaluations
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<IconExternalLink className="h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
{filteredUrls.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center pt-8 pb-4">
|
||||
No URLs match the current filters.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
182
apps/web/src/components/citations/track-domain-popover.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { useState } from "react";
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@workspace/ui/components/tooltip";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@workspace/ui/components/popover";
|
||||
import { IconInfoCircle, IconPlus } from "@tabler/icons-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import * as Sentry from "@sentry/tanstackstart-react";
|
||||
import { addDomainToBrandFn, addDomainToCompetitorFn, createCompetitorFromDomainFn } from "@/server/brands";
|
||||
|
||||
export function TrackDomainPopover({
|
||||
domain,
|
||||
brandId,
|
||||
brandName,
|
||||
competitors,
|
||||
onAdded,
|
||||
}: {
|
||||
domain: string;
|
||||
brandId: string;
|
||||
brandName?: string;
|
||||
competitors: Array<{ id: string; name: string; domains: string[] }>;
|
||||
onAdded?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSuccess = () => {
|
||||
setSaving(false);
|
||||
setSaved(true);
|
||||
setError("");
|
||||
setOpen(false);
|
||||
onAdded?.();
|
||||
};
|
||||
|
||||
const handleError = (e: unknown) => {
|
||||
setSaving(false);
|
||||
setError("Something went wrong. Please try again.");
|
||||
Sentry.captureException(e);
|
||||
};
|
||||
|
||||
const handleAddToBrand = async () => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await addDomainToBrandFn({ data: { brandId, domain } });
|
||||
handleSuccess();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToExisting = async (competitorId: string) => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await addDomainToCompetitorFn({ data: { brandId, competitorId, domain } });
|
||||
handleSuccess();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNew = async () => {
|
||||
if (!newName.trim()) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await createCompetitorFromDomainFn({ data: { brandId, name: newName.trim(), domain } });
|
||||
setNewName("");
|
||||
handleSuccess();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
if (saved) {
|
||||
return (
|
||||
<span className="shrink-0 p-1 text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 p-1 rounded hover:bg-muted cursor-pointer text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={`Track ${domain}`}
|
||||
>
|
||||
<IconPlus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 p-3" align="end">
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-medium">Track <strong>{domain}</strong></p>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive bg-destructive/10 rounded px-2 py-1.5">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="text-[11px] text-muted-foreground">Add as brand domain</p>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconInfoCircle className="h-3 w-3 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-xs font-normal">
|
||||
Applies <strong>retroactively</strong> — all existing and future citations from this domain will be classified as your brand.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddToBrand}
|
||||
disabled={saving}
|
||||
className="w-full text-left text-xs px-2 py-1.5 rounded hover:bg-muted cursor-pointer disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{brandName || "My brand"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{competitors.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="text-[11px] text-muted-foreground">Add to existing competitor</p>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconInfoCircle className="h-3 w-3 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-xs font-normal">
|
||||
Applies <strong>retroactively</strong> — all existing and future citations from this domain will be classified under the selected competitor.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="max-h-32 overflow-y-auto space-y-0.5">
|
||||
{competitors.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => handleAddToExisting(c.id)}
|
||||
disabled={saving}
|
||||
className="w-full text-left text-xs px-2 py-1.5 rounded hover:bg-muted cursor-pointer disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[11px] text-muted-foreground">Or create new competitor:</p>
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Competitor name"
|
||||
className="h-7 text-xs"
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleCreateNew(); } }}
|
||||
disabled={saving}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleCreateNew}
|
||||
disabled={saving || !newName.trim()}
|
||||
className="h-7 px-2 text-xs cursor-pointer shrink-0"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
96
apps/web/src/components/citations/trend-area-chart.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "@workspace/ui/components/card";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@workspace/ui/components/tooltip";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
} from "@workspace/ui/components/chart";
|
||||
import { formatDateStr } from "@/lib/app-locale";
|
||||
|
||||
export function TrendAreaChart({
|
||||
title,
|
||||
tooltip,
|
||||
data,
|
||||
keys,
|
||||
meta,
|
||||
}: {
|
||||
title: string;
|
||||
tooltip: string;
|
||||
data: Array<Record<string, number | string>>;
|
||||
keys: string[];
|
||||
meta: Record<string, { label: string; color: string }>;
|
||||
}) {
|
||||
// Callers pass exactly the keys that appear (same lists the tab filters use).
|
||||
const present = keys;
|
||||
// Display order: largest band first, "other" always last.
|
||||
const totals = new Map(present.map((k) => [k, data.reduce((s, d) => s + (typeof d[k] === "number" ? (d[k] as number) : 0), 0)]));
|
||||
const ordered = [...present].sort((a, b) => (a === "other" ? 1 : b === "other" ? -1 : (totals.get(b) ?? 0) - (totals.get(a) ?? 0)));
|
||||
const config: ChartConfig = Object.fromEntries(
|
||||
ordered.map((k) => [k, { label: meta[k]?.label ?? k, color: meta[k]?.color ?? "#9ca3af" }]),
|
||||
);
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="gap-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-1.5">
|
||||
{title}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconInfoCircle className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-sm font-normal">{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={config} className="aspect-auto h-[200px] w-full">
|
||||
<AreaChart data={data} margin={{ top: 10, right: 10, left: -10, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={50}
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(value) => formatDateStr(String(value), { month: "short", day: "numeric" })}
|
||||
/>
|
||||
<YAxis tickLine={false} axisLine={false} tickMargin={8} domain={[0, 100]} ticks={[0, 25, 50, 75, 100]} tick={{ fontSize: 11 }} tickFormatter={(value) => `${value}%`} />
|
||||
<ChartTooltip
|
||||
isAnimationActive={false}
|
||||
cursor={false}
|
||||
content={({ active, payload, label }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const dp = payload[0]?.payload as Record<string, number | string> | undefined;
|
||||
const formattedDate = formatDateStr(String(label), { month: "long", day: "numeric", year: "numeric" });
|
||||
const rows = ordered
|
||||
.map((k) => ({ k, value: (dp?.[k] as number | undefined) ?? 0 }))
|
||||
.filter((r) => r.value > 0);
|
||||
return (
|
||||
<div className="border-border/50 bg-background grid min-w-[10rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl">
|
||||
<div className="font-medium">{formattedDate}</div>
|
||||
<div className="grid gap-1">
|
||||
{rows.map((r) => (
|
||||
<div key={r.k} className="flex items-center gap-2">
|
||||
<span className="shrink-0 rounded-[2px] h-2.5 w-2.5" style={{ backgroundColor: meta[r.k]?.color ?? "#9ca3af" }} />
|
||||
<span className="text-muted-foreground">{meta[r.k]?.label ?? r.k}</span>
|
||||
<span className="ml-auto font-mono tabular-nums">{r.value}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{/* Render bottom-up (reverse of display order) so the largest band sits
|
||||
on top and Other at the bottom; tooltip lists in the same order. */}
|
||||
{[...ordered].reverse().map((k) => (
|
||||
<Area key={k} dataKey={k} type="monotone" stackId="1" stroke={`var(--color-${k})`} fill={`var(--color-${k})`} fillOpacity={0.8} strokeWidth={0} />
|
||||
))}
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
57
apps/web/src/components/citations/types.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import type { CitationCategory, CitationPageType } from "@/lib/domain-categories";
|
||||
|
||||
export interface GoogleProductRow {
|
||||
name: string;
|
||||
count: number;
|
||||
attribution: "brand" | "competitor" | "other";
|
||||
competitorName?: string;
|
||||
prompts: { id: string; value: string; count: number }[];
|
||||
urls: { url: string; count: number }[];
|
||||
}
|
||||
export interface GoogleQueryRow {
|
||||
query: string;
|
||||
count: number;
|
||||
prompts: { id: string; value: string; count: number }[];
|
||||
}
|
||||
export interface GoogleModuleData {
|
||||
shopping: { totalCitations: number; brandCount: number; competitorCount: number; products: GoogleProductRow[] };
|
||||
search: { totalCitations: number; queries: GoogleQueryRow[] };
|
||||
}
|
||||
|
||||
export interface CitationData {
|
||||
totalCitations: number;
|
||||
uniqueDomains: number;
|
||||
categoryCounts: Record<CitationCategory, number>;
|
||||
domainDistribution: {
|
||||
domain: string;
|
||||
count: number;
|
||||
category: CitationCategory;
|
||||
exampleTitle?: string;
|
||||
previousCount?: number;
|
||||
changePercent?: number | null;
|
||||
}[];
|
||||
specificUrls: {
|
||||
url: string;
|
||||
title?: string;
|
||||
domain: string;
|
||||
count: number;
|
||||
category: CitationCategory;
|
||||
pageType?: CitationPageType;
|
||||
avgPosition?: number | null;
|
||||
promptCount?: number;
|
||||
isNew?: boolean;
|
||||
}[];
|
||||
pageTypeDistribution?: { pageType: CitationPageType; count: number }[];
|
||||
googleModule?: GoogleModuleData;
|
||||
citationTimeSeries?: Array<{ date: string } & Partial<Record<CitationCategory, number>>>;
|
||||
pageTypeTimeSeries?: Array<{ date: string } & Partial<Record<CitationPageType, number>>>;
|
||||
competitors?: Array<{ id: string; name: string; domains: string[] }>;
|
||||
competitorOnlyPrompts?: Array<{ id: string; value: string; competitorCitationCount: number; uniqueCompetitors: number }>;
|
||||
whatsChanged?: {
|
||||
newUrls: { url: string; domain: string; count: number; promptCount: number; category: CitationCategory }[];
|
||||
droppedUrls: { url: string; domain: string; previousCount: number; currentCount: number; category: CitationCategory }[];
|
||||
titleChanges: { url: string; domain: string; currentTitle: string; previousTitle: string; category: CitationCategory }[];
|
||||
newDomains: { domain: string; count: number; category: CitationCategory }[];
|
||||
droppedDomains: { domain: string; previousCount: number; category: CitationCategory }[];
|
||||
};
|
||||
}
|
||||
19
apps/web/src/components/col-head.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@workspace/ui/components/tooltip";
|
||||
|
||||
/** A table column header with an info tooltip. Render inside a TooltipProvider. */
|
||||
export function ColHead({ label, tip, right }: { label: string; tip: string; right?: boolean }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 ${right ? "w-full justify-end" : ""}`}>
|
||||
{label}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-help text-muted-foreground/60">
|
||||
<IconInfoCircle className="size-3.5" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[240px] text-xs font-normal">{tip}</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
200
apps/web/src/components/competitors-editor.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
/**
|
||||
* Shared competitor list editor — used by both the settings/competitors page
|
||||
* (long-lived edits) and the prompt wizard's Review step (one-shot setup).
|
||||
*
|
||||
* Controlled component: caller owns the `competitors` array + the change
|
||||
* callbacks. State helpers like expand/collapse and the "X/MAX competitors
|
||||
* configured" footer live here so both surfaces look identical.
|
||||
*/
|
||||
import { useCallback } from "react";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { Label } from "@workspace/ui/components/label";
|
||||
import { TagsInput } from "@workspace/ui/components/tags-input";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@workspace/ui/components/tooltip";
|
||||
import { Plus, Pencil, Trash2 } from "lucide-react";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { MAX_COMPETITORS } from "@workspace/lib/constants";
|
||||
import { cleanAndValidateDomain } from "@/lib/domain-categories";
|
||||
import { WebLogo } from "@/components/web-logo";
|
||||
|
||||
export interface CompetitorEntry {
|
||||
_key: string;
|
||||
name: string;
|
||||
domains: string[];
|
||||
aliases: string[];
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
interface CompetitorsEditorProps {
|
||||
competitors: CompetitorEntry[];
|
||||
onChange: (next: CompetitorEntry[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function newCompetitorEntry(partial?: Partial<CompetitorEntry>): CompetitorEntry {
|
||||
return {
|
||||
_key: crypto.randomUUID(),
|
||||
name: partial?.name ?? "",
|
||||
domains: partial?.domains ?? [],
|
||||
aliases: partial?.aliases ?? [],
|
||||
expanded: partial?.expanded ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
export function CompetitorsEditor({ competitors, onChange, disabled }: CompetitorsEditorProps) {
|
||||
const validateDomain = useCallback((val: string): true | string => {
|
||||
const cleaned = cleanAndValidateDomain(val);
|
||||
if (!cleaned) return `"${val}" is not a valid domain`;
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const update = (key: string, patch: Partial<CompetitorEntry>) => {
|
||||
onChange(competitors.map((c) => (c._key === key ? { ...c, ...patch } : c)));
|
||||
};
|
||||
const remove = (index: number) => onChange(competitors.filter((_, i) => i !== index));
|
||||
const add = () => {
|
||||
if (competitors.length >= MAX_COMPETITORS) return;
|
||||
onChange([...competitors, newCompetitorEntry()]);
|
||||
};
|
||||
|
||||
const validCount = competitors.filter((c) => c.name.trim() && c.domains.some((d) => d.trim())).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{competitors.map((competitor, index) => (
|
||||
<div key={competitor._key} className="border rounded-lg overflow-hidden">
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
<WebLogo domain={competitor.domains[0]} size={28} />
|
||||
<div className="flex-1 min-w-0">
|
||||
{competitor.name ? (
|
||||
<span className="text-sm font-medium">{competitor.name}</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground italic">Unnamed competitor</span>
|
||||
)}
|
||||
{competitor.domains.some(Boolean) && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{competitor.domains.filter(Boolean)[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => update(competitor._key, { expanded: !competitor.expanded })}
|
||||
className="p-1.5 h-auto cursor-pointer shrink-0"
|
||||
disabled={disabled}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => remove(index)}
|
||||
className="p-1.5 h-auto cursor-pointer shrink-0 text-muted-foreground hover:text-destructive"
|
||||
disabled={disabled}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{competitor.expanded && (
|
||||
<div className="px-3 pb-3 pt-0 space-y-3 border-t bg-muted/30">
|
||||
<div className="space-y-1.5 pt-3">
|
||||
<Label className="text-xs font-medium flex items-center gap-1.5">
|
||||
Name
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconInfoCircle className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-xs font-normal">
|
||||
The primary name used to detect this competitor in AI responses. Mention detection applies to <strong>future</strong> prompt runs only.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={competitor.name}
|
||||
onChange={(e) => update(competitor._key, { name: e.target.value })}
|
||||
placeholder="Competitor name"
|
||||
className="bg-background"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium flex items-center gap-1.5">
|
||||
Domains
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconInfoCircle className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-xs font-normal">
|
||||
All domains owned by this competitor. Citation categorization updates retroactively — existing citations from these domains will immediately be classified as "competitor".
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Label>
|
||||
<TagsInput
|
||||
value={competitor.domains.filter(Boolean)}
|
||||
onValueChange={(values) => update(competitor._key, { domains: values })}
|
||||
placeholder="Add domain..."
|
||||
maxItems={10}
|
||||
normalizeValue={(raw) => cleanAndValidateDomain(raw) ?? raw.trim()}
|
||||
onValidate={validateDomain}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium flex items-center gap-1.5">
|
||||
Aliases
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconInfoCircle className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-xs font-normal">
|
||||
Alternative names for this competitor (sub-brands, product names, abbreviations). Used for mention detection in <strong>future</strong> prompt runs only — does not apply retroactively.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Label>
|
||||
<TagsInput
|
||||
value={competitor.aliases}
|
||||
onValueChange={(values) => update(competitor._key, { aliases: values })}
|
||||
placeholder="Add alias..."
|
||||
maxItems={10}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{competitors.length < MAX_COMPETITORS && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={add}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
disabled={disabled}
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Add Competitor
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{competitors.length >= MAX_COMPETITORS && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Maximum of {MAX_COMPETITORS} competitors allowed. Remove a competitor to add a new one.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>
|
||||
{validCount}/{MAX_COMPETITORS}
|
||||
</strong>{" "}
|
||||
competitors configured
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
apps/web/src/components/custom-range-calendar.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* Date-range picker for the "custom range" timeframe option.
|
||||
*
|
||||
* The picked range is written back into the same `lookback` search param the presets
|
||||
* use, encoded as `custom:YYYY-MM-DD:YYYY-MM-DD` — see `parseCustomLookback`. Shared
|
||||
* by both timeframe widgets (the filter-bar dropdown and the prompt page's button
|
||||
* group) so the two stay in sync.
|
||||
*
|
||||
* Uses native `<input type="date">` — it already gives us a calendar popup, keyboard
|
||||
* entry, locale-aware display and min/max clamping, and it emits exactly the
|
||||
* `YYYY-MM-DD` strings the lookback encoding wants.
|
||||
*/
|
||||
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { useId, useState } from "react";
|
||||
import { formatDateStr, todayDateStr } from "@/lib/app-locale";
|
||||
import {
|
||||
type CustomLookbackPeriod,
|
||||
formatCustomLookback,
|
||||
type LookbackPeriod,
|
||||
parseCustomLookback,
|
||||
} from "@/lib/chart-utils";
|
||||
|
||||
export function formatCustomRangeLabel(from: string, to: string): string {
|
||||
const label = (dateStr: string) => formatDateStr(dateStr, { month: "short", day: "numeric", year: "numeric" });
|
||||
return `${label(from)} – ${label(to)}`;
|
||||
}
|
||||
|
||||
export function CustomRangeCalendar({
|
||||
selected,
|
||||
onApply,
|
||||
}: {
|
||||
selected: LookbackPeriod;
|
||||
onApply: (lookback: CustomLookbackPeriod) => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
const current = parseCustomLookback(selected);
|
||||
const [from, setFrom] = useState(current?.from ?? "");
|
||||
const [to, setTo] = useState(current?.to ?? "");
|
||||
|
||||
// Clamp to today in the app's timezone, not the browser's — the data only goes that far.
|
||||
const today = todayDateStr();
|
||||
const isValid = Boolean(from && to && from <= to);
|
||||
|
||||
return (
|
||||
<div className="flex w-64 flex-col gap-3 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor={`${id}-from`} className="text-xs text-muted-foreground">
|
||||
From
|
||||
</label>
|
||||
<Input
|
||||
id={`${id}-from`}
|
||||
type="date"
|
||||
value={from}
|
||||
max={to || today}
|
||||
onChange={(e) => setFrom(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor={`${id}-to`} className="text-xs text-muted-foreground">
|
||||
To
|
||||
</label>
|
||||
<Input
|
||||
id={`${id}-to`}
|
||||
type="date"
|
||||
value={to}
|
||||
min={from || undefined}
|
||||
max={today}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
disabled={!isValid}
|
||||
onClick={() => isValid && onApply(formatCustomLookback(from, to))}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
apps/web/src/components/demo-mode-pill.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@workspace/ui/components/tooltip";
|
||||
import type { ClientConfig } from "@workspace/config/types";
|
||||
|
||||
export function DemoModePill() {
|
||||
const context = useRouteContext({ strict: false }) as { clientConfig?: ClientConfig };
|
||||
const isReadOnly = context.clientConfig?.features.readOnly ?? false;
|
||||
|
||||
if (!isReadOnly) return null;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-amber-500/30 bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-amber-700 dark:text-amber-400">
|
||||
<IconInfoCircle className="size-3" />
|
||||
Demo
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
This is a read-only demo. Any edits will fail.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
213
apps/web/src/components/fanout-sections.tsx
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
/**
|
||||
* Shared fan-out UI sections, used by the Query Fan-Out page and the prompt
|
||||
* details "Web Queries" tab: variation lines with prompt-keyword bolding and
|
||||
* run counts, a per-model variations breakdown, and the Query Words section
|
||||
* (term cloud + Added/Preserved/Dropped word changes).
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@workspace/ui/components/card";
|
||||
import { Separator } from "@workspace/ui/components/separator";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@workspace/ui/components/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@workspace/ui/components/tooltip";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { getModelDisplayName } from "@/lib/utils";
|
||||
import { ProgressBarChart } from "@/components/progress-bar-chart";
|
||||
import { WordCloud } from "@/components/word-cloud";
|
||||
import {
|
||||
normTok,
|
||||
type FanoutQueryStat,
|
||||
type ModelFanoutStat,
|
||||
type TermStat,
|
||||
type WordChanges,
|
||||
type WordChangeStat,
|
||||
} from "@/lib/fanout-analysis";
|
||||
|
||||
export const FANOUT_PURPLE = "#8b5cf6";
|
||||
|
||||
export function InfoTip({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-help">
|
||||
<IconInfoCircle className="text-muted-foreground/60 size-3.5" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-sm font-normal">{children}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Engines that ran with web search but contributed no usable queries — they
|
||||
* searched with the prompt itself or don't reveal their searches. Purely
|
||||
* data-derived (search runs without exposed queries), so it stays correct for
|
||||
* any provider/model combination. Renders nothing when every engine exposed
|
||||
* queries.
|
||||
*/
|
||||
export function UnknownQueriesNote({ byModel }: { byModel: ModelFanoutStat[] }) {
|
||||
const hidden = byModel.filter((m) => m.runs > 0 && m.totalQueries === 0);
|
||||
if (hidden.length === 0) return null;
|
||||
return (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{hidden.map((m) => getModelDisplayName(m.model)).join(", ")} ran with web search enabled but the queries are
|
||||
unknown — the engine may not have searched, searched with just the prompt itself, or searched without revealing
|
||||
its queries.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variations — fan-out queries with the prompt's keywords bolded + run counts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Per-model share of one variation's runs, shown inline as "2× ChatGPT". */
|
||||
export interface VariationModelCount {
|
||||
model: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function VariationLine({
|
||||
variation,
|
||||
keywords,
|
||||
modelCounts,
|
||||
}: {
|
||||
variation: FanoutQueryStat;
|
||||
keywords: Set<string>;
|
||||
/** When provided, replaces the plain total with per-model counts. */
|
||||
modelCounts?: VariationModelCount[];
|
||||
}) {
|
||||
const seen = new Map<string, number>();
|
||||
const segs = variation.query
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((w) => {
|
||||
const n = seen.get(w) ?? 0;
|
||||
seen.set(w, n + 1);
|
||||
return { text: w, bold: keywords.has(normTok(w)), key: `${w}:${n}` };
|
||||
});
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<div className="min-w-0 text-sm leading-6 break-words">
|
||||
{segs.map((s) => (
|
||||
<span key={s.key} className={s.bold ? "text-foreground font-semibold" : "text-muted-foreground"}>
|
||||
{s.text}{" "}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{modelCounts?.length ? (
|
||||
<span className="text-muted-foreground shrink-0 text-right text-xs tabular-nums leading-6" title="Times each engine ran this search">
|
||||
{modelCounts.map((mc) => `${mc.count.toLocaleString()}× ${getModelDisplayName(mc.model)}`).join(" · ")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground shrink-0 text-sm tabular-nums" title="Times engines ran this search">
|
||||
{variation.count.toLocaleString()}×
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VariationsList({
|
||||
variations,
|
||||
keywords,
|
||||
totalUnique,
|
||||
modelCounts,
|
||||
}: {
|
||||
variations: FanoutQueryStat[];
|
||||
keywords: Set<string>;
|
||||
/** Full distinct count, when `variations` is a capped slice of it. */
|
||||
totalUnique?: number;
|
||||
/** query → per-model counts, for the inline "2× ChatGPT" breakdown. */
|
||||
modelCounts?: Map<string, VariationModelCount[]>;
|
||||
}) {
|
||||
if (variations.length === 0) {
|
||||
return <div className="text-muted-foreground py-4 text-sm">No web queries for this selection.</div>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{variations.map((v) => (
|
||||
<VariationLine key={v.query} variation={v} keywords={keywords} modelCounts={modelCounts?.get(v.query)} />
|
||||
))}
|
||||
{totalUnique !== undefined && totalUnique > variations.length && (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Top {variations.length} of {totalUnique.toLocaleString()} variations shown
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query Words — the term cloud + Added / Preserved / Dropped word changes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type WordTab = "added" | "preserved" | "dropped";
|
||||
|
||||
const WORD_TAB_HELP: Record<WordTab, string> = {
|
||||
added: "Words engines add that weren't in your prompt — the intent they layer on (e.g. “best”, “2026”, “vs”).",
|
||||
preserved: "Words from your prompt engines keep in their searches.",
|
||||
dropped: "Words from your prompt engines leave out of their searches.",
|
||||
};
|
||||
|
||||
export function QueryWordsSection({ terms, wordChanges }: { terms: TermStat[]; wordChanges: WordChanges }) {
|
||||
const [tab, setTab] = useState<WordTab>("added");
|
||||
const [hideStop, setHideStop] = useState(true);
|
||||
|
||||
const words: WordChangeStat[] = wordChanges[tab];
|
||||
const shown = hideStop ? words.filter((w) => !w.isStop) : words;
|
||||
const items = shown.slice(0, 18).map((w) => ({
|
||||
label: w.word,
|
||||
count: w.count,
|
||||
suffix: <span className="text-muted-foreground tabular-nums text-xs">{w.share}%</span>,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="py-4">
|
||||
<CardContent>
|
||||
<WordCloud terms={terms} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="gap-4">
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-1.5 text-base">
|
||||
Word Changes
|
||||
<InfoTip>{WORD_TAB_HELP[tab]}</InfoTip>
|
||||
</CardTitle>
|
||||
<CardDescription>How engines rewrite your prompt wording.</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch id="qf-hide-stop" checked={hideStop} onCheckedChange={setHideStop} />
|
||||
<label htmlFor="qf-hide-stop" className="text-muted-foreground cursor-pointer text-sm">
|
||||
Hide stop words
|
||||
</label>
|
||||
</div>
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as WordTab)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="added">Added</TabsTrigger>
|
||||
<TabsTrigger value="preserved">Preserved</TabsTrigger>
|
||||
<TabsTrigger value="dropped">Dropped</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
<CardContent>
|
||||
{items.length > 0 ? (
|
||||
<ProgressBarChart items={items} defaultColor={FANOUT_PURPLE} />
|
||||
) : (
|
||||
<div className="text-muted-foreground py-6 text-center text-sm">
|
||||
No {tab} words{hideStop ? " (try showing stop words)" : ""}.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
463
apps/web/src/components/filter-bar.tsx
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
import { useSearch } from "@tanstack/react-router";
|
||||
import { getModelMeta } from "@workspace/lib/providers/models";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Checkbox } from "@workspace/ui/components/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@workspace/ui/components/dropdown-menu";
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@workspace/ui/components/popover";
|
||||
import { CalendarDays as CalendarIcon, ChevronDown, Clock, Search, Sparkles, Tag as TagIcon, X } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { MdSelectAll } from "react-icons/md";
|
||||
import { SiAnthropic, SiGithubcopilot, SiGoogle, SiMistralai, SiOpenai, SiPerplexity, SiX } from "react-icons/si";
|
||||
import { CustomRangeCalendar, formatCustomRangeLabel } from "@/components/custom-range-calendar";
|
||||
import { useBrand } from "@/hooks/use-brands";
|
||||
import { getDefaultLookbackPeriod, type LookbackPeriod, parseCustomLookback } from "@/lib/chart-utils";
|
||||
|
||||
export { ALL_MODELS_VALUE, getAvailableModels } from "@/lib/model-filter";
|
||||
|
||||
// Filter state lives in the URL, validated by the `$brand` layout route's
|
||||
// search schema (see `validateBrandFilterSearch`). The widgets here keep
|
||||
// per-key `useSearch` selectors so one filter's click doesn't re-render the
|
||||
// others, and write through `useFilterNavigate` (replace, no scroll reset).
|
||||
// The router commits search updates synchronously within the interaction, so
|
||||
// no optimistic layer is needed (nuqs throttled URL writes, which is why the
|
||||
// old code wrapped every change in `useOptimistic` + `startTransition`).
|
||||
import { coerceLookback, joinTags, splitTags, useFilterNavigate } from "@/hooks/use-list-filters";
|
||||
import { ALL_MODELS_VALUE } from "@/lib/model-filter";
|
||||
|
||||
/** "all" is the no-filter sentinel; any other string is a concrete model id
|
||||
* from the deployment's `SCRAPE_TARGETS`. Deployments can configure arbitrary
|
||||
* model ids, so we don't constrain this to a literal union. */
|
||||
export type ModelFilterValue = string;
|
||||
|
||||
/** Map a provider `iconId` (see `getModelMeta`) to the react-icons component
|
||||
* that renders it. `generic` and any unknown id fall through to a sparkle,
|
||||
* so a deployment that configures a new model id we haven't seen still gets
|
||||
* a reasonable trigger glyph. */
|
||||
export function iconForModel(model: string, className = "size-3.5") {
|
||||
if (model === ALL_MODELS_VALUE) return <MdSelectAll className={className} />;
|
||||
const { iconId } = getModelMeta(model);
|
||||
switch (iconId) {
|
||||
case "openai":
|
||||
return <SiOpenai className={className} />;
|
||||
case "anthropic":
|
||||
return <SiAnthropic className={className} />;
|
||||
case "google":
|
||||
return <SiGoogle className={className} />;
|
||||
case "microsoft":
|
||||
return <SiGithubcopilot className={className} />;
|
||||
case "perplexity":
|
||||
return <SiPerplexity className={className} />;
|
||||
case "x":
|
||||
return <SiX className={className} />;
|
||||
case "mistral":
|
||||
return <SiMistralai className={className} />;
|
||||
default:
|
||||
return <Sparkles className={className} />;
|
||||
}
|
||||
}
|
||||
|
||||
export function labelForModel(model: string): string {
|
||||
if (model === ALL_MODELS_VALUE) return "All models";
|
||||
return getModelMeta(model).label;
|
||||
}
|
||||
|
||||
const LOOKBACK_OPTIONS: { value: LookbackPeriod; label: string }[] = [
|
||||
{ value: "1w", label: "Last 7 days" },
|
||||
{ value: "1m", label: "Last 30 days" },
|
||||
{ value: "3m", label: "Last 3 months" },
|
||||
{ value: "6m", label: "Last 6 months" },
|
||||
{ value: "1y", label: "Last 12 months" },
|
||||
{ value: "all", label: "All time" },
|
||||
];
|
||||
|
||||
function getLookbackLabel(lookback: LookbackPeriod): string {
|
||||
const custom = parseCustomLookback(lookback);
|
||||
if (custom) return formatCustomRangeLabel(custom.from, custom.to);
|
||||
return LOOKBACK_OPTIONS.find((o) => o.value === lookback)?.label ?? lookback;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Trigger button (used by every dropdown)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
type FilterTriggerButtonProps = {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
badgeCount?: number;
|
||||
} & React.ComponentProps<"button">;
|
||||
|
||||
// Props forward to the underlying Button so `<DropdownMenuTrigger asChild>` /
|
||||
// `<PopoverTrigger asChild>` can hand their ref + data-state directly to the
|
||||
// button element (wrapping in a div would make Slot target the div instead).
|
||||
// Exported so page-specific bar controls (e.g. the prompts sort dropdown)
|
||||
// share the same trigger look without re-implementing it.
|
||||
export function FilterTriggerButton({
|
||||
icon,
|
||||
label,
|
||||
active,
|
||||
badgeCount,
|
||||
className,
|
||||
...props
|
||||
}: FilterTriggerButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
{...props}
|
||||
className={`h-8 gap-1.5 cursor-pointer font-normal ${
|
||||
active ? "border-foreground/30 bg-accent/50" : ""
|
||||
} ${className ?? ""}`}
|
||||
>
|
||||
<span className="text-muted-foreground flex items-center">{icon}</span>
|
||||
<span className="text-foreground">{label}</span>
|
||||
{badgeCount !== undefined && badgeCount > 0 && (
|
||||
<span className="ml-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
|
||||
{badgeCount}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Model dropdown — subscribes to only the "model" URL key.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
export function ModelDropdown({ availableModels }: { availableModels: string[] }) {
|
||||
const defaultModel = availableModels.includes(ALL_MODELS_VALUE)
|
||||
? ALL_MODELS_VALUE
|
||||
: (availableModels[0] ?? ALL_MODELS_VALUE);
|
||||
const urlModel = useSearch({ strict: false, select: (s) => s.model });
|
||||
const setFilters = useFilterNavigate();
|
||||
// If the URL has a model that isn't valid for this brand (e.g. stale deep
|
||||
// link after a deployment change), fall back to the default rather than
|
||||
// showing a trigger with an unknown value.
|
||||
const selected = urlModel && availableModels.includes(urlModel) ? urlModel : defaultModel;
|
||||
|
||||
const handleChange = (next: string) => {
|
||||
setFilters({ model: next === defaultModel ? undefined : next });
|
||||
};
|
||||
|
||||
if (availableModels.length <= 1) return null;
|
||||
const isFiltered = selected !== ALL_MODELS_VALUE;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<FilterTriggerButton icon={iconForModel(selected)} label={labelForModel(selected)} active={isFiltered} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48">
|
||||
<DropdownMenuRadioGroup value={selected} onValueChange={handleChange}>
|
||||
{availableModels.map((model) => (
|
||||
<DropdownMenuRadioItem key={model} value={model} className="cursor-pointer gap-2">
|
||||
{iconForModel(model)}
|
||||
{labelForModel(model)}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Lookback dropdown — subscribes to only the "lookback" URL key.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// A Popover rather than a DropdownMenu: the custom-range calendar needs arrow keys
|
||||
// for day navigation, which a menu's own roving focus would swallow.
|
||||
export function LookbackDropdown() {
|
||||
const { brand } = useBrand();
|
||||
const defaultLookback = useMemo(() => getDefaultLookbackPeriod(brand?.earliestDataDate), [brand?.earliestDataDate]);
|
||||
const urlLookback = useSearch({ strict: false, select: (s) => s.lookback });
|
||||
const setFilters = useFilterNavigate();
|
||||
const selected = coerceLookback(urlLookback, defaultLookback);
|
||||
const isCustom = Boolean(parseCustomLookback(selected));
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [showCalendar, setShowCalendar] = useState(false);
|
||||
|
||||
const handleChange = (next: LookbackPeriod) => {
|
||||
setFilters({ lookback: next === defaultLookback ? undefined : next });
|
||||
setOpen(false);
|
||||
setShowCalendar(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) setShowCalendar(false);
|
||||
}}
|
||||
modal={false}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<FilterTriggerButton
|
||||
icon={<Clock className="size-3.5" />}
|
||||
label={getLookbackLabel(selected)}
|
||||
active={isCustom}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-auto p-0">
|
||||
{showCalendar ? (
|
||||
<CustomRangeCalendar selected={selected} onApply={handleChange} />
|
||||
) : (
|
||||
<div className="w-48 py-1">
|
||||
{LOOKBACK_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => handleChange(opt.value)}
|
||||
className={`w-full px-3 py-1.5 text-left text-sm cursor-pointer ${
|
||||
selected === opt.value ? "bg-accent" : "hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCalendar(true)}
|
||||
className={`mt-1 flex w-full items-center gap-2 border-t px-3 pt-2 pb-1.5 text-left text-sm cursor-pointer ${
|
||||
isCustom ? "bg-accent" : "hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<CalendarIcon className="size-3.5 text-muted-foreground" />
|
||||
Custom range…
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Tags dropdown — subscribes to only the "tags" URL key.
|
||||
// Consumer passes `availableTags` (derived from prompts summary) so the
|
||||
// dropdown doesn't need to fetch.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
export function TagsDropdown({ availableTags }: { availableTags: readonly string[] }) {
|
||||
const urlTags = useSearch({ strict: false, select: (s) => s.tags });
|
||||
const setFilters = useFilterNavigate();
|
||||
const selected = useMemo(() => splitTags(urlTags), [urlTags]);
|
||||
|
||||
const commit = (next: string[]) => {
|
||||
setFilters({ tags: joinTags(next) });
|
||||
};
|
||||
const toggle = (tag: string) => {
|
||||
commit(selected.includes(tag) ? selected.filter((t) => t !== tag) : [...selected, tag]);
|
||||
};
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen} modal={false}>
|
||||
<PopoverTrigger asChild>
|
||||
<FilterTriggerButton
|
||||
icon={<TagIcon className="size-3.5" />}
|
||||
label="Tags"
|
||||
active={selected.length > 0}
|
||||
badgeCount={selected.length > 0 ? selected.length : undefined}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-64 p-0" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<div className="flex items-center justify-between px-3 h-10 border-b">
|
||||
<span className="font-medium text-sm">Tags</span>
|
||||
{selected.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => commit([])}
|
||||
className="text-xs text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{availableTags.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-6 text-center">No tags available</p>
|
||||
) : (
|
||||
<div className="py-1 max-h-64 overflow-y-auto">
|
||||
{availableTags.map((tag) => {
|
||||
const checked = selected.includes(tag);
|
||||
return (
|
||||
<div
|
||||
key={tag}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggle(tag);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
toggle(tag);
|
||||
}
|
||||
}}
|
||||
className={`flex items-center gap-2.5 py-1.5 px-3 cursor-pointer text-left text-sm ${
|
||||
checked ? "bg-accent" : "hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<Checkbox checked={checked} className="pointer-events-none" />
|
||||
<span className="capitalize flex-1">{tag}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Search input — subscribes to only the "q" URL key.
|
||||
// Debounces keystrokes and uses an effect-based sync (no render-time
|
||||
// setState) to avoid flashing back when the URL echo races with typing.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
export function SearchInput({ placeholder = "Search prompts..." }: { placeholder?: string }) {
|
||||
const urlValue = useSearch({ strict: false, select: (s) => s.q });
|
||||
const setFilters = useFilterNavigate();
|
||||
const value = urlValue ?? "";
|
||||
|
||||
const [local, setLocal] = useState(value);
|
||||
// `pendingTargetRef` holds the value we're currently pushing to the URL
|
||||
// while the navigation commits. While set, the sync effect ignores the
|
||||
// stale `value` — without this, a re-render that fires after
|
||||
// `setLocal("")` but before the router commits would see `value='abc'`
|
||||
// and snap `local` back to 'abc'. That's what caused the
|
||||
// "empty → abc → empty" flash when clicking the X.
|
||||
const pendingTargetRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingTargetRef.current !== null) {
|
||||
if (value === pendingTargetRef.current) {
|
||||
pendingTargetRef.current = null; // our push committed
|
||||
return;
|
||||
}
|
||||
// The URL moved to something other than what we were pushing —
|
||||
// an external clearFilters() or direct navigation wins.
|
||||
pendingTargetRef.current = null;
|
||||
setLocal(value);
|
||||
return;
|
||||
}
|
||||
if (value !== local) setLocal(value);
|
||||
}, [value]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (local === value) return;
|
||||
if (local === pendingTargetRef.current) return;
|
||||
const timer = setTimeout(() => {
|
||||
pendingTargetRef.current = local;
|
||||
setFilters({ q: local.length ? local : undefined });
|
||||
}, 250);
|
||||
return () => clearTimeout(timer);
|
||||
}, [local, value, setFilters]);
|
||||
|
||||
const clear = () => {
|
||||
setLocal("");
|
||||
if (value !== "") {
|
||||
pendingTargetRef.current = "";
|
||||
setFilters({ q: undefined });
|
||||
} else {
|
||||
pendingTargetRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
value={local}
|
||||
onChange={(e) => setLocal(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="h-8 pl-8 pr-8 text-sm"
|
||||
/>
|
||||
{local && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Result count — subscribes only to the two URL keys that gate its
|
||||
// visibility (tags + q). Parent passes the count as a prop so the
|
||||
// prompts-summary query is read once by a single owner.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
export function ResultCount({ count, total }: { count: number | undefined; total?: number }) {
|
||||
const tags = useSearch({ strict: false, select: (s) => s.tags });
|
||||
const q = useSearch({ strict: false, select: (s) => s.q });
|
||||
const active = Boolean(tags) || Boolean(q);
|
||||
if (!active || count === undefined) return null;
|
||||
const showTotal = total !== undefined && total !== count;
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground tabular-nums ml-1">
|
||||
{count.toLocaleString()}
|
||||
{showTotal && ` of ${total.toLocaleString()}`} {count === 1 && !showTotal ? "result" : "results"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Composed FilterBar
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
export function FilterBar({
|
||||
availableTags,
|
||||
availableModels,
|
||||
showSearch,
|
||||
showModelSelector,
|
||||
resultCount,
|
||||
resultTotal,
|
||||
extraControls,
|
||||
}: {
|
||||
availableTags: readonly string[];
|
||||
availableModels: string[];
|
||||
showSearch: boolean;
|
||||
showModelSelector: boolean;
|
||||
/** Only passed by pages that filter a list; omit on pages with a single aggregate view (e.g. Citations). */
|
||||
resultCount?: number;
|
||||
/** Unfiltered count — when it differs from `resultCount` the line reads "n of m results". */
|
||||
resultTotal?: number;
|
||||
/** Page-specific controls rendered inline with the dropdown group
|
||||
* (e.g. the prompts list's sort dropdown). */
|
||||
extraControls?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{showModelSelector && <ModelDropdown availableModels={availableModels} />}
|
||||
<TagsDropdown availableTags={availableTags} />
|
||||
<LookbackDropdown />
|
||||
{extraControls}
|
||||
<ResultCount count={resultCount} total={resultTotal} />
|
||||
</div>
|
||||
{showSearch && <SearchInput />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Data-fetching consumers that need the full filter set use
|
||||
// `useListFilters` from "@/hooks/use-list-filters".
|
||||
117
apps/web/src/components/filtered-list-shell.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { Inbox } from "lucide-react";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { FilterBar } from "@/components/filter-bar";
|
||||
import { FilterSection } from "@/components/page-header";
|
||||
import type { ListFilterState } from "@/hooks/use-list-filters";
|
||||
|
||||
interface FilteredListShellProps {
|
||||
/** The page's `useListFilters()` result. */
|
||||
filters: ListFilterState;
|
||||
availableTags: readonly string[];
|
||||
availableModels: string[];
|
||||
showSearch?: boolean;
|
||||
showModelSelector?: boolean;
|
||||
/** Show "n results" / "n of m results" next to the filter dropdowns. */
|
||||
showResultCount?: boolean;
|
||||
/** Page-specific controls rendered inline with the filter-bar dropdown
|
||||
* group (e.g. the prompts list's sort dropdown). */
|
||||
filterBarExtras?: ReactNode;
|
||||
/** Extra content inside the sticky filter section, below the bar
|
||||
* (e.g. the visibility bar on the prompts page). */
|
||||
filterSectionExtras?: ReactNode;
|
||||
isLoading?: boolean;
|
||||
loadingState?: ReactNode;
|
||||
isError?: boolean;
|
||||
errorState?: ReactNode;
|
||||
/** UNFILTERED result count. The "truly empty" vs "no matches" decision
|
||||
* keys off this plus `filters.isFiltered` — never off the filtered
|
||||
* count, which is the class of bug behind #322 (sections vanishing
|
||||
* because a search filtered them to zero). */
|
||||
totalCount: number | undefined;
|
||||
/** Count after client-side filtering. Defaults to `totalCount` for pages
|
||||
* whose filters are applied server-side. */
|
||||
filteredCount?: number;
|
||||
/** Shown when there is no data at all (no narrowing filters active). */
|
||||
emptyState: ReactNode;
|
||||
noMatchesTitle?: string;
|
||||
noMatchesDescription?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** The "FilterBar → fetch → filter → list" composition every dashboard page
|
||||
* was rebuilding by hand: filter bar + result count, the loading/error
|
||||
* states, and the two DISTINCT empty states ("no data yet" vs "no matches
|
||||
* for your filters" with a Clear filters escape hatch). The page keeps
|
||||
* owning data fetching; the shell owns the plumbing around it. */
|
||||
export function FilteredListShell({
|
||||
filters,
|
||||
availableTags,
|
||||
availableModels,
|
||||
showSearch = false,
|
||||
showModelSelector = true,
|
||||
showResultCount = false,
|
||||
filterBarExtras,
|
||||
filterSectionExtras,
|
||||
isLoading = false,
|
||||
loadingState,
|
||||
isError = false,
|
||||
errorState,
|
||||
totalCount,
|
||||
filteredCount,
|
||||
emptyState,
|
||||
noMatchesTitle,
|
||||
noMatchesDescription,
|
||||
children,
|
||||
}: FilteredListShellProps) {
|
||||
const effectiveFilteredCount = filteredCount ?? totalCount;
|
||||
|
||||
let body: ReactNode;
|
||||
if (isLoading) {
|
||||
body = loadingState;
|
||||
} else if (isError) {
|
||||
body = errorState;
|
||||
} else if ((totalCount ?? 0) === 0 && !filters.isFiltered) {
|
||||
body = emptyState;
|
||||
} else if ((effectiveFilteredCount ?? 0) === 0) {
|
||||
body = (
|
||||
<div className="border-2 border-dashed border-muted rounded-lg min-h-48 flex items-center justify-center">
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Inbox className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="mb-2">{noMatchesTitle ?? "No results match your filters."}</p>
|
||||
<p className="text-sm mb-4">
|
||||
{noMatchesDescription ?? "Try adjusting your search or filters."}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={filters.clearFilters}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
body = children;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterSection>
|
||||
<FilterBar
|
||||
availableTags={availableTags}
|
||||
availableModels={availableModels}
|
||||
showSearch={showSearch}
|
||||
showModelSelector={showModelSelector}
|
||||
resultCount={showResultCount && !isLoading ? effectiveFilteredCount : undefined}
|
||||
resultTotal={showResultCount && !isLoading ? totalCount : undefined}
|
||||
extraControls={filterBarExtras}
|
||||
/>
|
||||
{filterSectionExtras}
|
||||
</FilterSection>
|
||||
<div className="space-y-6">{body}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
63
apps/web/src/components/full-page-card.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { ReactNode } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@workspace/ui/components/card";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Separator } from "@workspace/ui/components/separator";
|
||||
import { Logo } from "@/components/logo";
|
||||
|
||||
interface FullPageCardProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
children?: ReactNode;
|
||||
showBackButton?: boolean;
|
||||
backButtonHref?: string;
|
||||
backButtonText?: string;
|
||||
customBackButton?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function FullPageCard({
|
||||
title,
|
||||
subtitle,
|
||||
children = undefined,
|
||||
showBackButton = false,
|
||||
backButtonHref = "/app",
|
||||
backButtonText = "Go Back",
|
||||
customBackButton,
|
||||
className = "w-md",
|
||||
}: FullPageCardProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/30 flex items-center justify-center p-4">
|
||||
<div className={`mx-auto ${className}`}>
|
||||
<div className="flex items-center justify-center space-x-3">
|
||||
<Logo />
|
||||
</div>
|
||||
<Card className="my-8">
|
||||
{(title || subtitle) && (
|
||||
<CardHeader className={subtitle ? "text-center" : "text-center grid-rows-1 gap-0"}>
|
||||
{title && <CardTitle className="text-xl">{title}</CardTitle>}
|
||||
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
|
||||
</CardHeader>
|
||||
)}
|
||||
{children && (
|
||||
<>
|
||||
{(title || subtitle) && <Separator />}
|
||||
<CardContent className={title || subtitle ? "" : "flex flex-col items-center space-y-6 py-4 px-12"}>
|
||||
{children}
|
||||
</CardContent>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
{customBackButton ? (
|
||||
<div className="flex justify-center">{customBackButton}</div>
|
||||
) : showBackButton ? (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to={backButtonHref}>{backButtonText}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
apps/web/src/components/history-button.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
|
||||
import { GoStack } from "react-icons/go";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
interface HistoryButtonProps {
|
||||
brandId?: string;
|
||||
promptName?: string;
|
||||
promptId?: string;
|
||||
/** Prompt-details tab to land on (e.g. "web-queries"); defaults to the first tab. */
|
||||
tab?: "mentions" | "web-queries" | "citations" | "responses";
|
||||
}
|
||||
|
||||
export function HistoryButton({
|
||||
brandId,
|
||||
promptName,
|
||||
promptId,
|
||||
tab,
|
||||
}: HistoryButtonProps) {
|
||||
if (!brandId || !promptId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button size="sm" variant="secondary" className="text-xs cursor-pointer h-6 flex items-center px-2" asChild>
|
||||
<Link to="/app/$brand/prompts/$promptId" params={{ brand: brandId, promptId }} search={tab ? { tab } : undefined}>
|
||||
<GoStack className="size-3 mr-0.5" />
|
||||
<span className="text-xs font-normal">View Details</span>
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
63
apps/web/src/components/list-pagination.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { useMemo, useState } from "react";
|
||||
|
||||
const PAGER_BUTTON_CLASS =
|
||||
"text-xs text-muted-foreground hover:text-foreground cursor-pointer px-2.5 py-1 rounded-md border border-border hover:bg-muted/60 transition-colors disabled:opacity-40 disabled:cursor-not-allowed";
|
||||
|
||||
interface ListPaginationProps {
|
||||
/** Zero-based current page. */
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalItems: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
/** Page-based pagination footer ("1–10 of 42" + Previous/Next). Controlled,
|
||||
* so the same props work whether the caller paginates client-side over
|
||||
* fetched data (v1, via `usePagedList`) or drives page/total from the
|
||||
* server later. Renders nothing when everything fits on one page. */
|
||||
export function ListPagination({ page, pageSize, totalItems, onPageChange }: ListPaginationProps) {
|
||||
const totalPages = Math.ceil(totalItems / pageSize);
|
||||
if (totalPages <= 1) return null;
|
||||
const start = page * pageSize + 1;
|
||||
const end = Math.min((page + 1) * pageSize, totalItems);
|
||||
return (
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{start.toLocaleString()}–{end.toLocaleString()} of {totalItems.toLocaleString()}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(Math.max(0, page - 1))}
|
||||
disabled={page === 0}
|
||||
className={PAGER_BUTTON_CLASS}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(Math.min(totalPages - 1, page + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
className={PAGER_BUTTON_CLASS}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Client-side page state over already-fetched items. The page is clamped
|
||||
* when the list shrinks (e.g. a filter change) so we never show an
|
||||
* out-of-range empty page; callers that want a hard reset to page 0 on
|
||||
* filter change can still call `setPage(0)` in their filter handler. */
|
||||
export function usePagedList<T>(items: readonly T[], pageSize: number) {
|
||||
const [rawPage, setPage] = useState(0);
|
||||
const maxPage = Math.max(0, Math.ceil(items.length / pageSize) - 1);
|
||||
const page = Math.min(rawPage, maxPage);
|
||||
const pageItems = useMemo(
|
||||
() => items.slice(page * pageSize, (page + 1) * pageSize),
|
||||
[items, page, pageSize],
|
||||
);
|
||||
return { page, setPage, pageItems, pageSize, totalItems: items.length };
|
||||
}
|
||||
64
apps/web/src/components/logo.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { cn } from "@workspace/ui/lib/utils";
|
||||
import { DEFAULT_APP_ICON, DEFAULT_APP_NAME } from "@workspace/config/constants";
|
||||
import type { ClientConfig } from "@workspace/config/types";
|
||||
|
||||
interface LogoProps extends ComponentPropsWithoutRef<"div"> {
|
||||
iconClassName?: string;
|
||||
textClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this deployment renders the Getcito wordmark, which is the only thing
|
||||
* that uses Titan One. A whitelabel deployment renders its own icon and name in
|
||||
* the system font instead, so the font is never needed there — which is why the
|
||||
* preload in routes/__root.tsx is conditional on this too.
|
||||
*/
|
||||
export function usesWordmarkFont(branding: { icon?: string; name?: string } | undefined): boolean {
|
||||
const hasCustomBranding =
|
||||
Boolean(branding?.icon && branding?.name) &&
|
||||
(branding?.icon !== DEFAULT_APP_ICON || branding?.name !== DEFAULT_APP_NAME);
|
||||
return !hasCustomBranding;
|
||||
}
|
||||
|
||||
export function Logo({
|
||||
className,
|
||||
iconClassName,
|
||||
textClassName,
|
||||
...props
|
||||
}: LogoProps) {
|
||||
const context = useRouteContext({ strict: false }) as { clientConfig?: ClientConfig };
|
||||
const branding = context.clientConfig?.branding;
|
||||
|
||||
if (usesWordmarkFont(branding)) {
|
||||
return (
|
||||
<div {...props} className={cn("flex items-center gap-2", className)}>
|
||||
<span
|
||||
className={cn(
|
||||
"font-titan-one text-3xl font-normal lowercase",
|
||||
textClassName,
|
||||
)}
|
||||
>
|
||||
Getcito
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...props} className={cn("flex items-center gap-2", className)}>
|
||||
{branding?.icon && (
|
||||
<img
|
||||
src={branding.icon}
|
||||
alt={`${branding.name} logo`}
|
||||
className={cn("size-5", iconClassName)}
|
||||
fetchPriority="low"
|
||||
/>
|
||||
)}
|
||||
<span className={cn("text-base font-semibold", textClassName)}>
|
||||
{branding?.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
apps/web/src/components/lookback-selector.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { useSearch } from "@tanstack/react-router";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@workspace/ui/components/popover";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { CustomRangeCalendar, formatCustomRangeLabel } from "@/components/custom-range-calendar";
|
||||
import { useBrand } from "@/hooks/use-brands";
|
||||
import { coerceLookback, useFilterNavigate } from "@/hooks/use-list-filters";
|
||||
import { getDefaultLookbackPeriod, type LookbackPeriod, parseCustomLookback } from "@/lib/chart-utils";
|
||||
|
||||
function getLookbackLabel(lookback: LookbackPeriod): string {
|
||||
switch (lookback) {
|
||||
case "1w":
|
||||
return "1w";
|
||||
case "1m":
|
||||
return "1mo";
|
||||
case "3m":
|
||||
return "3mo";
|
||||
case "6m":
|
||||
return "6mo";
|
||||
case "1y":
|
||||
return "1yr";
|
||||
default:
|
||||
return "all";
|
||||
}
|
||||
}
|
||||
|
||||
interface LookbackSelectorProps {
|
||||
defaultPeriod?: LookbackPeriod;
|
||||
onLookbackChange?: (lookback: LookbackPeriod) => void;
|
||||
}
|
||||
|
||||
export function LookbackSelector({ defaultPeriod, onLookbackChange }: LookbackSelectorProps) {
|
||||
const { brand } = useBrand();
|
||||
const computedDefaultPeriod = useMemo(
|
||||
() => defaultPeriod ?? getDefaultLookbackPeriod(brand?.earliestDataDate),
|
||||
[defaultPeriod, brand?.earliestDataDate],
|
||||
);
|
||||
|
||||
const urlLookback = useSearch({ strict: false, select: (s) => s.lookback });
|
||||
const setFilters = useFilterNavigate();
|
||||
const selectedLookback = coerceLookback(urlLookback, computedDefaultPeriod);
|
||||
const customRange = parseCustomLookback(selectedLookback);
|
||||
|
||||
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
|
||||
|
||||
const handleChange = (period: LookbackPeriod) => {
|
||||
setFilters({ lookback: period === computedDefaultPeriod ? undefined : period });
|
||||
onLookbackChange?.(period);
|
||||
setIsCalendarOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex rounded-md bg-muted p-1">
|
||||
{(["1w", "1m", "3m", "6m", "1y", "all"] as LookbackPeriod[]).map((period) => (
|
||||
<button
|
||||
key={period}
|
||||
onClick={() => handleChange(period)}
|
||||
className={`px-3 py-1 text-sm rounded cursor-pointer ${
|
||||
selectedLookback === period
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
type="button"
|
||||
>
|
||||
{getLookbackLabel(period)}
|
||||
</button>
|
||||
))}
|
||||
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen} modal={false}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
title={customRange ? formatCustomRangeLabel(customRange.from, customRange.to) : "Pick a custom date range"}
|
||||
className={`flex items-center gap-1 px-3 py-1 text-sm rounded cursor-pointer ${
|
||||
customRange ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<CalendarDays className="size-3.5" />
|
||||
{customRange ? formatCustomRangeLabel(customRange.from, customRange.to) : "custom"}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-auto p-0">
|
||||
<CustomRangeCalendar selected={selectedLookback} onApply={handleChange} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLookbackPeriod(defaultPeriod?: LookbackPeriod) {
|
||||
const { brand } = useBrand();
|
||||
const computedDefaultPeriod = useMemo(
|
||||
() => defaultPeriod ?? getDefaultLookbackPeriod(brand?.earliestDataDate),
|
||||
[defaultPeriod, brand?.earliestDataDate],
|
||||
);
|
||||
|
||||
const urlLookback = useSearch({ strict: false, select: (s) => s.lookback });
|
||||
return coerceLookback(urlLookback, computedDefaultPeriod);
|
||||
}
|
||||
45
apps/web/src/components/missing-env-page.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type { DeploymentMode } from "@workspace/config/types";
|
||||
import type { MissingEnvVar } from "@workspace/config/env";
|
||||
import FullPageCard from "@/components/full-page-card";
|
||||
|
||||
interface MissingEnvPageProps {
|
||||
mode: DeploymentMode;
|
||||
missing: MissingEnvVar[];
|
||||
}
|
||||
|
||||
export default function MissingEnvPage({ mode, missing }: MissingEnvPageProps) {
|
||||
const sortedMissing = [...missing].sort((a, b) =>
|
||||
a.label.localeCompare(b.label),
|
||||
);
|
||||
|
||||
const localHint =
|
||||
mode === "local"
|
||||
? "Set these via the CLI."
|
||||
: "Set these in the deployment environment, then redeploy or restart.";
|
||||
|
||||
return (
|
||||
<FullPageCard
|
||||
title="Missing environment configuration"
|
||||
subtitle={`Deployment mode: ${mode}`}
|
||||
className="max-w-2xl"
|
||||
>
|
||||
<div className="space-y-4 text-sm">
|
||||
<p>{localHint}</p>
|
||||
<ul className="space-y-3 rounded-md border bg-background p-4">
|
||||
{sortedMissing.map((item) => (
|
||||
<li key={item.id} className="flex flex-col gap-1">
|
||||
<span className="font-mono text-xs text-foreground">
|
||||
{item.label}
|
||||
</span>
|
||||
{item.description ? (
|
||||
<span className="text-muted-foreground">
|
||||
{item.description}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</FullPageCard>
|
||||
);
|
||||
}
|
||||
18
apps/web/src/components/nav-app-info.test.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
useRouteContext: () => ({ clientConfig: { mode: "whitelabel" } }),
|
||||
}));
|
||||
|
||||
import { NavAppInfo } from "./nav-app-info";
|
||||
|
||||
describe("NavAppInfo", () => {
|
||||
it("shows release and project links in whitelabel mode", () => {
|
||||
const html = renderToStaticMarkup(<NavAppInfo />);
|
||||
|
||||
expect(html).toContain(`v${__APP_VERSION__}`);
|
||||
expect(html).toContain("https://www.Getcito.com/");
|
||||
expect(html).toContain("https://github.com/ai-search-guru/getcito-worlds-first-open-source-aio-aeo-or-geo-tool");
|
||||
});
|
||||
});
|
||||
43
apps/web/src/components/nav-app-info.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { IconBrandGithub, IconWorld } from "@tabler/icons-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@workspace/ui/components/tooltip";
|
||||
|
||||
export function NavAppInfo() {
|
||||
const linkClass =
|
||||
"text-muted-foreground hover:text-foreground inline-flex size-7 items-center justify-center rounded-md transition-colors";
|
||||
|
||||
return (
|
||||
<div className="mx-2 mt-1 flex items-center gap-2 border-t border-sidebar-border/60 px-1 pt-2">
|
||||
<a
|
||||
href={`https://github.com/ai-search-guru/getcito-worlds-first-open-source-aio-aeo-or-geo-tool/releases/tag/v${__APP_VERSION__}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex-1 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
v{__APP_VERSION__}
|
||||
</a>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a href="https://www.Getcito.com/" target="_blank" className={linkClass} rel="noopener">
|
||||
<IconWorld className="size-4" />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Getcito.com</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href="https://github.com/ai-search-guru/getcito-worlds-first-open-source-aio-aeo-or-geo-tool"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={linkClass}
|
||||
>
|
||||
<IconBrandGithub className="size-4" />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>View on GitHub</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
apps/web/src/components/nav-main.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { type Icon } from "@tabler/icons-react";
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@workspace/ui/components/sidebar";
|
||||
import { Link, useLocation, useParams } from "@tanstack/react-router";
|
||||
|
||||
export interface NavItem {
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: Icon;
|
||||
absolute?: boolean;
|
||||
}
|
||||
|
||||
export interface NavGroup {
|
||||
label: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
export function NavMain({ groups }: { groups: NavGroup[] }) {
|
||||
const params = useParams({ strict: false }) as { brand?: string };
|
||||
const brandId = params.brand;
|
||||
const { setOpenMobile } = useSidebar();
|
||||
const location = useLocation();
|
||||
const pathname = location.pathname;
|
||||
|
||||
const getHref = (url: string, absolute?: boolean) => {
|
||||
return absolute ? url : `/app/${brandId}${url}`;
|
||||
};
|
||||
|
||||
const isActive = (url: string, absolute?: boolean) => {
|
||||
const href = getHref(url, absolute);
|
||||
if (href === `/app/${brandId}` || href === `/app/${brandId}/`) {
|
||||
return pathname === `/app/${brandId}` || pathname === `/app/${brandId}/`;
|
||||
}
|
||||
return pathname.startsWith(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{groups.map((group) => (
|
||||
<SidebarGroup key={group.label}>
|
||||
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={item.title}
|
||||
isActive={isActive(item.url, item.absolute)}
|
||||
>
|
||||
<Link
|
||||
to={getHref(item.url, item.absolute)}
|
||||
onClick={() => setOpenMobile(false)}
|
||||
>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
128
apps/web/src/components/nav-user.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import {
|
||||
IconSelector,
|
||||
IconExternalLink,
|
||||
IconLogout,
|
||||
IconStatusChange,
|
||||
IconUser,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@workspace/ui/components/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@workspace/ui/components/dropdown-menu";
|
||||
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "@workspace/ui/components/sidebar";
|
||||
|
||||
import { Link, useRouteContext } from "@tanstack/react-router";
|
||||
import type { ClientConfig } from "@workspace/config/types";
|
||||
import { authClient } from "@workspace/lib/auth/client";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { resetPostHog } from "@/lib/posthog";
|
||||
|
||||
export function NavUser() {
|
||||
const { user } = useAuth();
|
||||
const { isMobile, setOpenMobile } = useSidebar();
|
||||
const context = useRouteContext({ strict: false }) as { clientConfig?: ClientConfig };
|
||||
const clientConfig = context.clientConfig;
|
||||
|
||||
// NavUser only renders inside _authed routes, which redirect to /auth/login
|
||||
// when there's no session — so `user` is always present at this point.
|
||||
if (!user) return null;
|
||||
|
||||
const isNameEmailSame =
|
||||
user.name?.trim().toLowerCase() === user.email?.trim().toLowerCase();
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground cursor-pointer"
|
||||
>
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarImage src={user.picture} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg bg-primary/10 text-primary">
|
||||
<IconUser className="size-4" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="truncate text-xs">{isNameEmailSame ? "Your Account" : user.email}</span>
|
||||
</div>
|
||||
<IconSelector className="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarImage src={user.picture} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg bg-primary/10 text-primary">
|
||||
<IconUser className="size-4" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="truncate text-xs">{isNameEmailSame ? "Your Account" : user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
{clientConfig?.features.readOnly ? (
|
||||
<DropdownMenuItem disabled>
|
||||
<IconStatusChange />
|
||||
Switch Brand (Disabled in Demo)
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link to="/app" onClick={() => setOpenMobile(false)}>
|
||||
<IconStatusChange />
|
||||
Switch Brand
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{clientConfig?.branding.parentUrl && clientConfig?.branding.parentName && (
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<a href={clientConfig.branding.parentUrl} target="_blank" rel="noreferrer">
|
||||
<IconExternalLink />
|
||||
{clientConfig.branding.parentName} Dashboard
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
resetPostHog();
|
||||
window.location.href = "/auth/logout";
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<IconLogout />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
181
apps/web/src/components/opportunities-report.tsx
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* Renders the AI-generated Opportunities dashboard from getOpportunitiesFn.
|
||||
* Opportunities are grouped into Creation / Existing content / Outreach / Social;
|
||||
* each card leads with a plain-language "why", then three drill-downs — Prompts /
|
||||
* Your citations / Competitor citations — to explore the underlying data.
|
||||
*/
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import type { CitedPage, OpportunitiesReport as OpportunitiesReportData, ReportPrompt } from "@/server/opportunities";
|
||||
|
||||
const CATEGORY_META = [
|
||||
{
|
||||
key: "creation",
|
||||
label: "Creation",
|
||||
desc: "Net-new content to publish or earn — comparisons, guides, and 'best of' angles for topics you're absent on.",
|
||||
},
|
||||
{
|
||||
key: "existing-content",
|
||||
label: "Existing Content",
|
||||
desc: "Pages already getting cited that are slipping, or could win the mention with a refresh.",
|
||||
},
|
||||
{
|
||||
key: "outreach",
|
||||
label: "Outreach",
|
||||
desc: "Earn placements on the third-party review sites and editorial roundups assistants cite.",
|
||||
},
|
||||
{
|
||||
key: "social",
|
||||
label: "Social",
|
||||
desc: "Show up in the community conversations — Reddit, YouTube, forums — assistants pull from.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
type Opportunity = OpportunitiesReportData["opportunities"][number];
|
||||
type Tab = "prompts" | "your" | "comp";
|
||||
|
||||
function Bullet() {
|
||||
return <span className="mt-2 size-1.5 shrink-0 rounded-full bg-muted-foreground/50" aria-hidden />;
|
||||
}
|
||||
|
||||
function BulletList({ items }: { items: string[] }) {
|
||||
return (
|
||||
<ul className="space-y-2.5">
|
||||
{items.map((item, i) => (
|
||||
<li key={`${i}-${item}`} className="flex gap-2.5 text-pretty text-base">
|
||||
<Bullet />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
const ROW = "block truncate rounded px-1.5 py-1 text-xs hover:bg-muted hover:text-foreground";
|
||||
|
||||
function PromptLink({ prompt, brandId }: { prompt: ReportPrompt; brandId: string }) {
|
||||
if (!prompt.promptId) return <span className={`${ROW} text-muted-foreground`}>{prompt.text}</span>;
|
||||
return (
|
||||
<Link to="/app/$brand/prompts/$promptId" params={{ brand: brandId, promptId: prompt.promptId }} className={ROW}>
|
||||
{prompt.text}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function CiteLink({ page }: { page: CitedPage }) {
|
||||
return (
|
||||
<a href={page.url} target="_blank" rel="noopener noreferrer" className={ROW}>
|
||||
{page.title || page.domain} <span className="text-muted-foreground">· {page.domain}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({ children }: { children: React.ReactNode }) {
|
||||
return <div className="mt-2 rounded-md bg-muted/30 p-1">{children}</div>;
|
||||
}
|
||||
|
||||
function OpportunityCard({ o, brandId }: { o: Opportunity; brandId: string }) {
|
||||
const [open, setOpen] = useState<Tab | null>(null);
|
||||
const tabs: { key: Tab; label: string; count: number }[] = [
|
||||
{ key: "prompts", label: "Prompts", count: o.relatedPrompts.length },
|
||||
{ key: "your", label: "Your citations", count: o.yourCitations.length },
|
||||
{ key: "comp", label: "Competitor citations", count: o.competitorCitations.length },
|
||||
];
|
||||
return (
|
||||
<div className="rounded-xl border border-border p-4">
|
||||
<h3 className="text-pretty text-base font-semibold">{o.title}</h3>
|
||||
<p className="mt-1 text-pretty text-sm text-muted-foreground">{o.why}</p>
|
||||
|
||||
<div className="mt-3 border-t border-border/60 pt-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setOpen(open === t.key ? null : t.key)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs ${open === t.key ? "bg-muted" : "hover:bg-muted/50"}`}
|
||||
>
|
||||
{t.label} <span className="tabular-nums text-muted-foreground">({t.count})</span>
|
||||
<span className={`text-[0.625rem] text-muted-foreground ${open === t.key ? "rotate-180" : ""}`}>▾</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{open === "prompts" && (
|
||||
<Panel>
|
||||
{o.relatedPrompts.length === 0 ? (
|
||||
<p className="px-1.5 py-1 text-xs text-muted-foreground">No specific prompts linked.</p>
|
||||
) : (
|
||||
o.relatedPrompts.map((p, i) => <PromptLink key={`${i}-${p.text}`} prompt={p} brandId={brandId} />)
|
||||
)}
|
||||
</Panel>
|
||||
)}
|
||||
{open === "your" && (
|
||||
<Panel>
|
||||
{o.yourCitations.length === 0 ? (
|
||||
<p className="px-1.5 py-1 text-xs text-muted-foreground">You're not cited for these prompts yet.</p>
|
||||
) : (
|
||||
o.yourCitations.map((c, i) => <CiteLink key={`${i}-${c.url}`} page={c} />)
|
||||
)}
|
||||
</Panel>
|
||||
)}
|
||||
{open === "comp" && (
|
||||
<Panel>
|
||||
{o.competitorCitations.length === 0 ? (
|
||||
<p className="px-1.5 py-1 text-xs text-muted-foreground">No competitor pages cited for these prompts.</p>
|
||||
) : (
|
||||
o.competitorCitations.map((c, i) => <CiteLink key={`${i}-${c.url}`} page={c} />)
|
||||
)}
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpportunitiesReport({ report, brandId }: { report: OpportunitiesReportData; brandId: string }) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{report.summary.length > 0 && (
|
||||
<section className="rounded-xl border border-border bg-muted/30 p-5">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">Summary</h2>
|
||||
<div className="mt-2.5">
|
||||
<BulletList items={report.summary} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{CATEGORY_META.map((c) => {
|
||||
const opps = report.opportunities.filter((o) => o.category === c.key);
|
||||
if (opps.length === 0) return null;
|
||||
return (
|
||||
<section key={c.key} className="space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<h2 className="text-base font-semibold">
|
||||
{c.label} <span className="font-normal text-muted-foreground">({opps.length})</span>
|
||||
</h2>
|
||||
<p className="text-pretty text-sm text-muted-foreground">{c.desc}</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{opps.map((o, i) => (
|
||||
<OpportunityCard key={`${i}-${o.title}`} o={o} brandId={brandId} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{report.risks.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-base font-semibold">Reality Check</h2>
|
||||
<BulletList items={report.risks} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Generated by AI from your tracked citation data. Suggestions are a starting point — apply your own judgment
|
||||
before acting.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
apps/web/src/components/page-header.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { ReactNode } from "react";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@workspace/ui/components/tooltip";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { Skeleton } from "@workspace/ui/components/skeleton";
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
infoContent?: ReactNode;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/** Title + subtitle block. No filter state, no data fetching — callers
|
||||
* compose the filter section and content as children. */
|
||||
export function PageHeader({ title, subtitle, infoContent, children }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
<div className="mb-4">
|
||||
<h1 className="text-3xl font-bold flex items-center gap-2">
|
||||
{title}
|
||||
{infoContent && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconInfoCircle className="h-5 w-5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-sm font-normal">
|
||||
{infoContent}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">{subtitle}</p>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageHeaderTitleSkeleton() {
|
||||
return (
|
||||
<div className="mb-4 space-y-2">
|
||||
<Skeleton className="h-9 w-48" />
|
||||
<Skeleton className="h-5 w-80" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Wrapper for the filter bar + visibility bar sitting under the page title. */
|
||||
export function FilterSection({ children }: { children: ReactNode }) {
|
||||
return <div className="pt-2 pb-4">{children}</div>;
|
||||
}
|
||||
220
apps/web/src/components/progress-bar-chart.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import React from "react";
|
||||
import { cn } from "@workspace/ui/lib/utils";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@workspace/ui/components/tooltip";
|
||||
import { getModelMeta, KNOWN_MODELS } from "@workspace/lib/providers/models";
|
||||
|
||||
export type ProgressBarItem = {
|
||||
/** The label to display */
|
||||
label: string;
|
||||
/** The count/value to display */
|
||||
count: number;
|
||||
/** Optional subtitle shown below the label */
|
||||
subtitle?: string;
|
||||
/** Optional suffix rendered after the count (e.g. trend arrows) */
|
||||
suffix?: React.ReactNode;
|
||||
/** Optional category for color mapping */
|
||||
category?: string;
|
||||
/** Optional custom color (overrides category color) */
|
||||
color?: string;
|
||||
/** Optional click handler for future extensibility */
|
||||
onClick?: () => void;
|
||||
/** Optional tooltip text shown on hover over the label */
|
||||
tooltip?: string;
|
||||
/** Optional icon element rendered before the label */
|
||||
icon?: React.ReactNode;
|
||||
/** Optional action element rendered next to the label */
|
||||
action?: React.ReactNode;
|
||||
/** Optional additional metadata */
|
||||
metadata?: Record<string, any>;
|
||||
};
|
||||
|
||||
export type ColorMapping = {
|
||||
[category: string]: string;
|
||||
};
|
||||
|
||||
export type ProgressBarChartProps = {
|
||||
/** Array of items to display */
|
||||
items: ProgressBarItem[];
|
||||
/** Color mapping for categories */
|
||||
colorMapping?: ColorMapping;
|
||||
/** Default color if no category match */
|
||||
defaultColor?: string;
|
||||
/** Background color of the progress bar track */
|
||||
trackColor?: string;
|
||||
/** Height of the progress bar (tailwind class like 'h-2' or 'h-3') */
|
||||
barHeight?: string;
|
||||
/** How to calculate percentages: 'max' (relative to max count) or 'total' (relative to sum of all counts) */
|
||||
percentageMode?: "max" | "total";
|
||||
/** Custom total for percentage calculation (overrides percentageMode) */
|
||||
customTotal?: number;
|
||||
/** Spacing between items (tailwind class) */
|
||||
spacing?: string;
|
||||
/** Show label in bold if it matches this value */
|
||||
highlightLabel?: string;
|
||||
/** Whether to show a color dot before the label */
|
||||
showColorIndicator?: boolean;
|
||||
/** Additional CSS classes for the container */
|
||||
className?: string;
|
||||
/** Whether labels should be truncated if too long */
|
||||
truncateLabels?: boolean;
|
||||
/** Use flex layout to fill parent height and distribute items evenly */
|
||||
fillHeight?: boolean;
|
||||
};
|
||||
|
||||
export function ProgressBarChart({
|
||||
items,
|
||||
colorMapping = {},
|
||||
defaultColor = "#3b82f6",
|
||||
trackColor = "bg-primary/10",
|
||||
barHeight = "h-2",
|
||||
percentageMode = "max",
|
||||
customTotal,
|
||||
spacing = "space-y-4",
|
||||
highlightLabel,
|
||||
showColorIndicator = false,
|
||||
className,
|
||||
truncateLabels = true,
|
||||
fillHeight = false,
|
||||
}: ProgressBarChartProps) {
|
||||
// Calculate the total for percentage calculations
|
||||
const total = React.useMemo(() => {
|
||||
if (customTotal !== undefined) {
|
||||
return customTotal;
|
||||
}
|
||||
|
||||
if (percentageMode === "total") {
|
||||
return items.reduce((sum, item) => sum + item.count, 0);
|
||||
}
|
||||
|
||||
// percentageMode === "max"
|
||||
return Math.max(...items.map(item => item.count), 1);
|
||||
}, [items, percentageMode, customTotal]);
|
||||
|
||||
const getItemColor = (item: ProgressBarItem): string => {
|
||||
// Custom color takes precedence
|
||||
if (item.color) {
|
||||
return item.color;
|
||||
}
|
||||
|
||||
// Category-based color
|
||||
if (item.category && colorMapping[item.category]) {
|
||||
return colorMapping[item.category];
|
||||
}
|
||||
|
||||
// Default color
|
||||
return defaultColor;
|
||||
};
|
||||
|
||||
const calculatePercentage = (count: number): number => {
|
||||
if (total === 0) return 0;
|
||||
return (count / total) * 100;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn(fillHeight ? "flex flex-col justify-between h-full" : spacing, className)}>
|
||||
{items.map((item) => {
|
||||
const percentage = calculatePercentage(item.count);
|
||||
const color = getItemColor(item);
|
||||
const isHighlighted = highlightLabel && item.label === highlightLabel;
|
||||
const isClickable = !!item.onClick;
|
||||
|
||||
return (
|
||||
<div key={item.label} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
{showColorIndicator && (
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
)}
|
||||
{item.tooltip ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm cursor-default",
|
||||
isHighlighted ? "font-bold" : "font-medium",
|
||||
truncateLabels && "truncate",
|
||||
isClickable && "cursor-pointer hover:underline"
|
||||
)}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-xs font-normal">{item.tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
isHighlighted ? "font-bold" : "font-medium",
|
||||
truncateLabels && "truncate",
|
||||
isClickable && "cursor-pointer hover:underline"
|
||||
)}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
{item.icon}
|
||||
{item.action}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-2 shrink-0">
|
||||
<span className="text-sm">{item.count.toLocaleString()}</span>
|
||||
{item.suffix}
|
||||
</div>
|
||||
</div>
|
||||
{item.subtitle && (
|
||||
<p className="text-xs text-muted-foreground truncate -mt-1">{item.subtitle}</p>
|
||||
)}
|
||||
<div className={cn("relative w-full overflow-hidden rounded-full", trackColor, barHeight)}>
|
||||
<div
|
||||
className="h-full transition-all rounded-full"
|
||||
style={{
|
||||
width: `${percentage}%`,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { DOMAIN_CATEGORY_COLORS } from "@/lib/domain-categories";
|
||||
|
||||
// Colors are keyed by the provider family (iconId from `getModelMeta`), so a
|
||||
// deployment that adds any google-flavored model, anthropic-flavored model,
|
||||
// etc. via `SCRAPE_TARGETS` gets a sensible color without needing per-model
|
||||
// config here. Anything the `generic` branch can't match falls through to
|
||||
// `ProgressBarChart`'s `defaultColor`.
|
||||
const COLOR_BY_ICON: Record<string, string> = {
|
||||
openai: "#10b981", // green
|
||||
anthropic: "#f59e0b", // amber/orange
|
||||
google: "#3b82f6", // blue
|
||||
microsoft: "#06b6d4", // cyan
|
||||
perplexity: "#8b5cf6", // purple
|
||||
x: "#111827", // near-black
|
||||
};
|
||||
|
||||
/** Resolve a model id to a display color. "all" is a no-filter sentinel. */
|
||||
export function getModelColor(model: string): string | undefined {
|
||||
if (model === "all") return "#8b5cf6";
|
||||
const meta = getModelMeta(model);
|
||||
return meta.color ?? COLOR_BY_ICON[meta.iconId];
|
||||
}
|
||||
|
||||
/** Colors for every model id in `KNOWN_MODELS` plus "all". Unknown deployment
|
||||
* models fall back to `ProgressBarChart`'s `defaultColor`. */
|
||||
export const MODEL_COLORS: ColorMapping = {
|
||||
all: "#8b5cf6",
|
||||
...Object.fromEntries(
|
||||
Object.keys(KNOWN_MODELS)
|
||||
.map((m) => [m, getModelColor(m)])
|
||||
.filter((entry): entry is [string, string] => entry[1] !== undefined),
|
||||
),
|
||||
};
|
||||
218
apps/web/src/components/prompt-chart-print.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@workspace/ui/components/card";
|
||||
import { Separator } from "@workspace/ui/components/separator";
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { BaseChartPrint } from "./base-chart-print";
|
||||
import { ChartDownloadFooter } from "./chart-download-footer";
|
||||
import { useChartDownload } from "@/hooks/use-chart-download";
|
||||
import type { Brand, Competitor } from "@workspace/lib/db/schema";
|
||||
import {
|
||||
LookbackPeriod,
|
||||
getBadgeVariant,
|
||||
getBadgeClassName,
|
||||
calculateVisibilityPercentages,
|
||||
selectCompetitorsToDisplay,
|
||||
type ChartDataPoint,
|
||||
} from "@/lib/chart-utils";
|
||||
import { getSoVBadgeClasses, type PromptCategory } from "@workspace/lib/report-metrics";
|
||||
|
||||
interface PromptRunData {
|
||||
id: string;
|
||||
promptId: string;
|
||||
brandId: string;
|
||||
brandMentioned: boolean;
|
||||
competitorsMentioned: string[];
|
||||
createdAt: Date;
|
||||
model: string;
|
||||
provider: string | null;
|
||||
version: string;
|
||||
webSearchEnabled: boolean;
|
||||
rawOutput: any;
|
||||
webQueries: string[];
|
||||
}
|
||||
|
||||
interface PromptChartPrintProps {
|
||||
lookback: LookbackPeriod;
|
||||
promptName: string;
|
||||
promptId: string;
|
||||
brand: Brand;
|
||||
competitors: Competitor[];
|
||||
promptRuns: PromptRunData[];
|
||||
// Whether this prompt has ever been evaluated (all-time)
|
||||
// Used to distinguish "never evaluated" vs "no data in selected window"
|
||||
hasEverBeenEvaluated?: boolean;
|
||||
// Optional category for report display
|
||||
category?: PromptCategory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute SoV for each entity (brand + competitors) from prompt runs.
|
||||
* Returns data shaped for BaseChartPrint: one data point with entity IDs as keys.
|
||||
*/
|
||||
function computeSoVChartData(
|
||||
runs: PromptRunData[],
|
||||
brand: Brand,
|
||||
competitors: Competitor[],
|
||||
): ChartDataPoint[] | null {
|
||||
if (runs.length === 0) return null;
|
||||
|
||||
// Count mentions
|
||||
let brandMentions = 0;
|
||||
const competitorMentions: Record<string, number> = {};
|
||||
for (const comp of competitors) {
|
||||
competitorMentions[comp.id] = 0;
|
||||
}
|
||||
|
||||
for (const run of runs) {
|
||||
if (run.brandMentioned) brandMentions++;
|
||||
if (run.competitorsMentioned) {
|
||||
for (const comp of competitors) {
|
||||
if (run.competitorsMentioned.includes(comp.name)) {
|
||||
competitorMentions[comp.id]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Total mentions across all entities
|
||||
const totalMentions = brandMentions + Object.values(competitorMentions).reduce((s, c) => s + c, 0);
|
||||
if (totalMentions === 0) return null;
|
||||
|
||||
// Build chart data point with SoV percentages
|
||||
const dataPoint: ChartDataPoint = { date: "sov" };
|
||||
dataPoint[brand.id] = Math.round((brandMentions / totalMentions) * 100);
|
||||
for (const comp of competitors) {
|
||||
dataPoint[comp.id] = Math.round((competitorMentions[comp.id] / totalMentions) * 100);
|
||||
}
|
||||
|
||||
return [dataPoint];
|
||||
}
|
||||
|
||||
export function PromptChartPrint({
|
||||
lookback = "1m",
|
||||
promptName,
|
||||
promptId,
|
||||
brand,
|
||||
competitors,
|
||||
promptRuns,
|
||||
hasEverBeenEvaluated = false,
|
||||
category,
|
||||
}: PromptChartPrintProps) {
|
||||
const fileName = `${brand.name}-${promptName.replace(/[^a-zA-Z0-9]/g, "_").substring(0, 50)}`;
|
||||
const { chartRef, isDownloading, handleDownload } = useChartDownload(fileName);
|
||||
|
||||
// Filter prompt runs for this specific prompt
|
||||
const promptSpecificRuns = promptRuns?.filter((run) => run.promptId === promptId) || [];
|
||||
|
||||
// Check if we have no prompt runs
|
||||
const hasNoRuns = promptSpecificRuns.length === 0;
|
||||
|
||||
// For report context: use SoV-based chart data. For dashboard: use visibility time-series.
|
||||
const isReportContext = !!category;
|
||||
const sovChartData = isReportContext ? computeSoVChartData(promptSpecificRuns, brand, competitors) : null;
|
||||
|
||||
// Dashboard mode: time-series visibility
|
||||
const chartData = isReportContext ? (sovChartData ?? []) : calculateVisibilityPercentages(promptSpecificRuns, brand, competitors, lookback);
|
||||
|
||||
// Select top competitors by visibility, filling with alphabetical order if needed
|
||||
const selectedCompetitors = selectCompetitorsToDisplay(competitors, chartData, 5);
|
||||
|
||||
// Check if there's any non-zero data for brand or selected competitors
|
||||
const hasVisibilityData = chartData.some((dataPoint) => {
|
||||
const brandValue = dataPoint[brand.id] as number;
|
||||
if (brandValue !== null && brandValue !== undefined && Number(brandValue) > 0) {
|
||||
return true;
|
||||
}
|
||||
return selectedCompetitors.some((competitor) => {
|
||||
const value = dataPoint[competitor.id] as number;
|
||||
return value !== null && value !== undefined && Number(value) > 0;
|
||||
});
|
||||
});
|
||||
|
||||
// Badge value: SoV for reports, visibility for dashboard
|
||||
const badgeValue = isReportContext
|
||||
? (sovChartData ? (sovChartData[0][brand.id] as number) : null)
|
||||
: (() => {
|
||||
const lastDataPoint = chartData.filter((point) => brand && point[brand.id] !== null).pop();
|
||||
return lastDataPoint && brand ? (lastDataPoint[brand.id] as number) : null;
|
||||
})();
|
||||
|
||||
const badgeLabel = isReportContext ? "SoV" : "Visibility";
|
||||
|
||||
if (hasNoRuns) {
|
||||
const message = hasEverBeenEvaluated
|
||||
? "No data in selected time range"
|
||||
: "Evaluating for the first time...";
|
||||
|
||||
return (
|
||||
<Card ref={chartRef} className="py-3 gap-3 print:shadow-none print:border">
|
||||
<CardHeader className="flex justify-between items-center px-3">
|
||||
<CardTitle className="text-sm print:text-xs">{promptName}</CardTitle>
|
||||
</CardHeader>
|
||||
<Separator className="py-0 my-0" />
|
||||
<CardContent className="px-3">
|
||||
<div>
|
||||
<span className="font-semibold text-xl sm:text-2xl md:text-3xl lg:text-4xl text-muted-foreground print:text-lg">
|
||||
{message}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
<ChartDownloadFooter onDownload={handleDownload} isDownloading={isDownloading} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Show "No brands found" message when there's no data
|
||||
if (!hasVisibilityData) {
|
||||
return (
|
||||
<Card ref={chartRef} className="py-3 gap-3 print:shadow-none print:border">
|
||||
<CardHeader className="flex justify-between items-center px-3">
|
||||
<CardTitle className="text-sm print:text-xs">{promptName}</CardTitle>
|
||||
</CardHeader>
|
||||
<Separator className="py-0 my-0" />
|
||||
<CardContent className="px-3">
|
||||
<div className="h-[250px] flex items-center justify-center">
|
||||
<div className="flex flex-col items-center text-center max-w-xs">
|
||||
<p className="text-sm font-medium text-muted-foreground print:text-xs">
|
||||
No brands found in responses
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1 print:text-[10px]">
|
||||
Your brand and competitors weren't mentioned in the evaluated responses for this prompt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<ChartDownloadFooter onDownload={handleDownload} isDownloading={isDownloading} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const badgeClasses = isReportContext && badgeValue !== null
|
||||
? getSoVBadgeClasses(badgeValue)
|
||||
: badgeValue !== null
|
||||
? { variant: getBadgeVariant(badgeValue) as "default" | "secondary" | "destructive", className: getBadgeClassName(badgeValue) }
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card ref={chartRef} className="py-3 gap-3 print:shadow-none print:border print:break-inside-avoid">
|
||||
<CardHeader className="flex justify-between items-center px-3">
|
||||
<CardTitle className="text-sm print:text-xs">{promptName}</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{badgeClasses && badgeValue !== null && (
|
||||
<Badge
|
||||
variant={badgeClasses.variant}
|
||||
className={`${badgeClasses.className} print:text-xs`}
|
||||
>
|
||||
{badgeValue}% {badgeLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<Separator className="py-0 my-0" />
|
||||
<CardContent className="p-0">
|
||||
<BaseChartPrint data={chartData} brand={brand} competitors={selectedCompetitors} />
|
||||
</CardContent>
|
||||
<ChartDownloadFooter onDownload={handleDownload} isDownloading={isDownloading} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
68
apps/web/src/components/prompt-order-dropdown.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { ArrowUpDown } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@workspace/ui/components/dropdown-menu";
|
||||
import { FilterTriggerButton } from "@/components/filter-bar";
|
||||
import {
|
||||
PROMPT_ORDER_OPTIONS,
|
||||
DEFAULT_PROMPT_ORDER,
|
||||
coercePromptOrder,
|
||||
type PromptOrder,
|
||||
} from "@/lib/prompt-order";
|
||||
|
||||
/** Sort control for the prompts list (#60). Reads/writes the `order` URL key
|
||||
* the visibility route declares in its `validateSearch`. Like the filter-bar
|
||||
* widgets it subscribes to just its own key, and writes with `replace` + no
|
||||
* scroll reset, dropping the key when set back to the default so default
|
||||
* state keeps a clean URL. */
|
||||
export function PromptOrderDropdown() {
|
||||
const navigate = useNavigate();
|
||||
const selected = useSearch({
|
||||
strict: false,
|
||||
select: (s) => coercePromptOrder((s as { order?: unknown }).order),
|
||||
});
|
||||
|
||||
const setOrder = (next: PromptOrder) =>
|
||||
navigate({
|
||||
to: ".",
|
||||
search: (prev: Record<string, unknown>) => ({
|
||||
...prev,
|
||||
order: next === DEFAULT_PROMPT_ORDER ? undefined : next,
|
||||
}),
|
||||
replace: true,
|
||||
resetScroll: false,
|
||||
});
|
||||
|
||||
// The button reads "Sort" in the default state and otherwise echoes the
|
||||
// chosen order's menu label (arrows and all).
|
||||
const label =
|
||||
selected === DEFAULT_PROMPT_ORDER
|
||||
? "Sort"
|
||||
: (PROMPT_ORDER_OPTIONS.find((o) => o.value === selected)?.label ?? "Sort");
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<FilterTriggerButton
|
||||
icon={<ArrowUpDown className="size-3.5" />}
|
||||
label={label}
|
||||
active={selected !== DEFAULT_PROMPT_ORDER}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-60">
|
||||
<DropdownMenuRadioGroup value={selected} onValueChange={(v) => setOrder(v as PromptOrder)}>
|
||||
{PROMPT_ORDER_OPTIONS.map((o) => (
|
||||
<DropdownMenuRadioItem key={o.value} value={o.value} className="cursor-pointer whitespace-nowrap">
|
||||
{o.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
545
apps/web/src/components/prompt-wizard.tsx
Normal file
|
|
@ -0,0 +1,545 @@
|
|||
/**
|
||||
* Single-step onboarding wizard.
|
||||
*
|
||||
* One LLM call returns brand info + competitors + prompts; the user reviews
|
||||
* and edits before saving. Replaces the prior 4-step wizard that required
|
||||
* DataForSEO + Anthropic in tandem.
|
||||
*/
|
||||
import { useState, useCallback, useEffect, memo, useMemo, useRef } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { Textarea } from "@workspace/ui/components/textarea";
|
||||
import { Loader2, AlertCircle, Play, Rocket } from "lucide-react";
|
||||
import { TagsInput } from "@workspace/ui/components/tags-input";
|
||||
import { Separator } from "@workspace/ui/components/separator";
|
||||
import { useBrand, brandKeys } from "@/hooks/use-brands";
|
||||
import { citationKeys } from "@/hooks/use-citations";
|
||||
import { dashboardKeys } from "@/hooks/use-dashboard-summary";
|
||||
import { promptsSummaryKeys } from "@/hooks/use-prompts-summary";
|
||||
import {
|
||||
analyzeBrandInfoFn,
|
||||
analyzeCompetitorsFn,
|
||||
analyzePromptsFn,
|
||||
cancelAnalyzeBrandFn,
|
||||
updateOnboardedBrandFn,
|
||||
} from "@/server/onboarding";
|
||||
import { trackEvent } from "@/lib/posthog";
|
||||
import { CompetitorsEditor, newCompetitorEntry, type CompetitorEntry } from "@/components/competitors-editor";
|
||||
import { PromptsListEditor, newPromptEntry, type EditablePrompt } from "@/components/prompts-list-editor";
|
||||
import { WebLogo } from "./web-logo";
|
||||
|
||||
interface PromptWizardProps {
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
/** Brand analysis runs in the worker (LLM + web search, ~1 min); the client polls for the result. */
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const ANALYZE_TIMEOUT_MS = 6 * 60 * 1000; // give up after ~6 minutes
|
||||
|
||||
const analyzeStatusKey = (brandId: string) => ["analyze-brand", "status", brandId] as const;
|
||||
|
||||
interface WizardData {
|
||||
brandName: string;
|
||||
website: string;
|
||||
additionalDomains: string[];
|
||||
aliases: string[];
|
||||
competitors: CompetitorEntry[];
|
||||
prompts: EditablePrompt[];
|
||||
shortDescription?: string;
|
||||
productsAndServices?: string[];
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
const EditableTagsInput = memo(
|
||||
({
|
||||
items,
|
||||
onValueChange,
|
||||
placeholder = "Add item...",
|
||||
maxItems = 10,
|
||||
}: {
|
||||
items: string[];
|
||||
onValueChange: (value: string[]) => void;
|
||||
placeholder?: string;
|
||||
maxItems?: number;
|
||||
}) => (
|
||||
<div className="space-y-2">
|
||||
<TagsInput
|
||||
value={items}
|
||||
onValueChange={onValueChange}
|
||||
placeholder={placeholder}
|
||||
searchPlaceholder={placeholder}
|
||||
maxItems={maxItems}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>
|
||||
{items.length}/{maxItems}
|
||||
</strong>{" "}
|
||||
{items.length >= maxItems ? "items added. Remove an item to add a new one." : "items entered."}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
EditableTagsInput.displayName = "EditableTagsInput";
|
||||
|
||||
export default function PromptWizard({ onComplete }: PromptWizardProps) {
|
||||
const { brand } = useBrand();
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const STORAGE_KEY = brand?.id ? `getcito_onboarding_${brand.id}` : null;
|
||||
|
||||
const [phase, setPhase] = useState<"idle" | "analyzing" | "review">(() => {
|
||||
if (typeof window !== "undefined" && STORAGE_KEY) {
|
||||
if (localStorage.getItem(STORAGE_KEY)) return "review";
|
||||
}
|
||||
return "idle";
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [data, setData] = useState<WizardData>(() => {
|
||||
if (typeof window !== "undefined" && STORAGE_KEY) {
|
||||
const cached = localStorage.getItem(STORAGE_KEY);
|
||||
if (cached) {
|
||||
try {
|
||||
return JSON.parse(cached);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
brandName: brand?.name || "",
|
||||
website: brand?.website || "",
|
||||
additionalDomains: brand?.additionalDomains || [],
|
||||
aliases: brand?.aliases || [],
|
||||
competitors: [],
|
||||
prompts: [],
|
||||
};
|
||||
});
|
||||
|
||||
const brandId = brand?.id;
|
||||
|
||||
// Stop polling, drop the cached status so the next run starts clean, and
|
||||
// (best-effort) cancel the worker job. `errorMessage` surfaces a reason
|
||||
// (timeout); a bare cancel passes null.
|
||||
const stopAnalyzing = useCallback(
|
||||
(errorMessage: string | null) => {
|
||||
setPhase("idle");
|
||||
setError(errorMessage);
|
||||
if (brandId) {
|
||||
queryClient.removeQueries({ queryKey: analyzeStatusKey(brandId) });
|
||||
cancelAnalyzeBrandFn({ data: { brandId } }).catch(() => {});
|
||||
}
|
||||
},
|
||||
[brandId, queryClient],
|
||||
);
|
||||
|
||||
const { mutate: enqueueAnalysis, isPending: isAnalyzingBrand } = useMutation({
|
||||
mutationFn: (vars: { brandId: string; website: string; brandName?: string }) =>
|
||||
analyzeBrandInfoFn({ data: vars }),
|
||||
onMutate: () => {
|
||||
setError(null);
|
||||
setPhase("analyzing");
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
const newData: WizardData = {
|
||||
...data,
|
||||
brandName: result.brandName || brand?.name || "",
|
||||
website: brand?.website || result.website || "",
|
||||
additionalDomains: result.additionalDomains,
|
||||
aliases: result.aliases,
|
||||
competitors: result.competitors.map((c) =>
|
||||
newCompetitorEntry({ name: c.name, domains: c.domains, aliases: c.aliases, expanded: false }),
|
||||
),
|
||||
prompts: result.suggestedPrompts.map((p) =>
|
||||
newPromptEntry({ value: p.prompt, tags: p.tags, enabled: true }),
|
||||
),
|
||||
shortDescription: result.shortDescription,
|
||||
productsAndServices: result.productsAndServices,
|
||||
keywords: result.keywords,
|
||||
};
|
||||
setData(newData);
|
||||
if (STORAGE_KEY) localStorage.setItem(STORAGE_KEY, JSON.stringify(newData));
|
||||
setPhase("review");
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err instanceof Error ? err.message : "Analysis failed");
|
||||
setPhase("idle");
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: enqueueCompetitorsAnalysis, isPending: isAnalyzingCompetitors } = useMutation({
|
||||
mutationFn: (vars: { brandId: string; website: string; brandName: string }) =>
|
||||
analyzeCompetitorsFn({ data: vars }),
|
||||
onSuccess: (result) => {
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
competitors: result.competitors.map((c) =>
|
||||
newCompetitorEntry({
|
||||
name: c.name,
|
||||
domains: c.domains,
|
||||
aliases: c.aliases,
|
||||
expanded: false,
|
||||
}),
|
||||
),
|
||||
}));
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err instanceof Error ? err.message : "Competitors analysis failed");
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: enqueuePromptsAnalysis, isPending: isAnalyzingPrompts } = useMutation({
|
||||
mutationFn: (vars: { brandId: string; website: string; brandName: string; competitorNames: string[] }) =>
|
||||
analyzePromptsFn({ data: vars }),
|
||||
onSuccess: (result) => {
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
prompts: result.suggestedPrompts.map((p) =>
|
||||
newPromptEntry({ value: p.prompt, tags: p.tags, enabled: true }),
|
||||
),
|
||||
}));
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err instanceof Error ? err.message : "Prompts analysis failed");
|
||||
},
|
||||
});
|
||||
|
||||
const handleAnalyze = useCallback(() => {
|
||||
if (!brand?.website || !brand?.id) return;
|
||||
enqueueAnalysis({ brandId: brand.id, website: brand.website, brandName: brand.name });
|
||||
}, [brand?.website, brand?.id, brand?.name, enqueueAnalysis]);
|
||||
|
||||
const handleAnalyzeCompetitors = useCallback(() => {
|
||||
if (!brand?.id) return;
|
||||
enqueueCompetitorsAnalysis({
|
||||
brandId: brand.id,
|
||||
website: data.website || brand.website,
|
||||
brandName: data.brandName || brand.name,
|
||||
});
|
||||
}, [brand, data.website, data.brandName, enqueueCompetitorsAnalysis]);
|
||||
|
||||
const handleAnalyzePrompts = useCallback(() => {
|
||||
if (!brand?.id) return;
|
||||
enqueuePromptsAnalysis({
|
||||
brandId: brand.id,
|
||||
website: data.website || brand.website,
|
||||
brandName: data.brandName || brand.name,
|
||||
competitorNames: data.competitors.map((c) => c.name).filter(Boolean),
|
||||
});
|
||||
}, [brand, data.website, data.brandName, data.competitors, enqueuePromptsAnalysis]);
|
||||
|
||||
const hasAutoAnalyzed = useRef(false);
|
||||
|
||||
// Synchronize form progress to localStorage so hard refreshes don't lose data
|
||||
useEffect(() => {
|
||||
if (STORAGE_KEY && phase === "review") {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
}
|
||||
}, [data, phase, STORAGE_KEY]);
|
||||
|
||||
// Auto-start analysis when component mounts
|
||||
useEffect(() => {
|
||||
if (phase === "idle" && !hasAutoAnalyzed.current && brand?.website && brand?.id) {
|
||||
hasAutoAnalyzed.current = true;
|
||||
handleAnalyze();
|
||||
}
|
||||
}, [brand?.website, brand?.id, phase, handleAnalyze]);
|
||||
|
||||
// Update phase state to respect isAnalyzingBrand
|
||||
useEffect(() => {
|
||||
if (isAnalyzingBrand) setPhase("analyzing");
|
||||
}, [isAnalyzingBrand]);
|
||||
|
||||
// Give up on a stuck analysis instead of polling forever.
|
||||
useEffect(() => {
|
||||
if (phase !== "analyzing") return;
|
||||
const timer = window.setTimeout(
|
||||
() => stopAnalyzing("Brand analysis timed out. Please try again."),
|
||||
ANALYZE_TIMEOUT_MS,
|
||||
);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [phase, stopAnalyzing]);
|
||||
|
||||
const updateBrandName = useCallback((brandName: string) => setData((p) => ({ ...p, brandName })), []);
|
||||
const updateWebsite = useCallback((website: string) => setData((p) => ({ ...p, website })), []);
|
||||
const updateAliases = useCallback((aliases: string[]) => setData((p) => ({ ...p, aliases })), []);
|
||||
const updateAdditionalDomains = useCallback(
|
||||
(additionalDomains: string[]) => setData((p) => ({ ...p, additionalDomains })),
|
||||
[],
|
||||
);
|
||||
const updateCompetitors = useCallback(
|
||||
(competitors: CompetitorEntry[]) => setData((p) => ({ ...p, competitors })),
|
||||
[],
|
||||
);
|
||||
const updatePrompts = useCallback(
|
||||
(prompts: EditablePrompt[]) => setData((p) => ({ ...p, prompts })),
|
||||
[],
|
||||
);
|
||||
const updateShortDescription = useCallback((shortDescription: string) => setData((p) => ({ ...p, shortDescription })), []);
|
||||
const updateProductsAndServices = useCallback(
|
||||
(productsAndServices: string[]) => setData((p) => ({ ...p, productsAndServices })),
|
||||
[],
|
||||
);
|
||||
const updateKeywords = useCallback(
|
||||
(keywords: string[]) => setData((p) => ({ ...p, keywords })),
|
||||
[],
|
||||
);
|
||||
|
||||
const previewCounts = useMemo(() => {
|
||||
const enabled = data.prompts.filter((p) => p.enabled && p.value.trim().length > 0).length;
|
||||
return { totalNew: enabled };
|
||||
}, [data.prompts]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!brand?.id) return;
|
||||
setSubmitError(null);
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const competitorsPayload = data.competitors
|
||||
.filter((c) => c.name.trim() && c.domains.some((d) => d.trim()))
|
||||
.map((c) => ({
|
||||
name: c.name.trim(),
|
||||
domains: c.domains.filter((d) => d.trim()),
|
||||
aliases: c.aliases,
|
||||
}));
|
||||
|
||||
const promptsPayload = data.prompts
|
||||
.filter((p) => p.enabled && p.value.trim())
|
||||
.map((p) => ({ value: p.value.trim(), tags: p.tags, enabled: true }));
|
||||
|
||||
await updateOnboardedBrandFn({
|
||||
data: {
|
||||
brandId: brand.id,
|
||||
brandName: data.brandName.trim() || brand.name,
|
||||
website: data.website.trim() || brand.website,
|
||||
additionalDomains: data.additionalDomains,
|
||||
aliases: data.aliases,
|
||||
competitors: competitorsPayload,
|
||||
prompts: promptsPayload,
|
||||
shortDescription: data.shortDescription,
|
||||
productsAndServices: data.productsAndServices,
|
||||
keywords: data.keywords,
|
||||
},
|
||||
});
|
||||
|
||||
trackEvent("wizard_completed", {
|
||||
prompts_created: promptsPayload.length,
|
||||
competitors_created: competitorsPayload.length,
|
||||
skipped: false,
|
||||
});
|
||||
|
||||
// Deployments without an onboardingRedirectUrlTemplate (e.g. local mode) skip the full reload, so caches fetched while !onboarded must be busted explicitly.
|
||||
queryClient.invalidateQueries({ queryKey: brandKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: dashboardKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: citationKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: promptsSummaryKeys.all });
|
||||
// The $brand route loader feeds `brand` into AppSidebar; invalidate it so the sidebar picks up onboarded=true.
|
||||
await router.invalidate();
|
||||
|
||||
if (STORAGE_KEY) localStorage.removeItem(STORAGE_KEY);
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
setSubmitError(err instanceof Error ? err.message : "Failed to save");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [brand, data, queryClient, router, onComplete]);
|
||||
|
||||
if (phase === "idle" || phase === "analyzing") {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-bold">Analyzing {brand?.name || "Brand"}</h2>
|
||||
<p className="text-muted-foreground text-balance">
|
||||
Please wait while we analyze {brand?.website} to find competitors, additional domains, and the best generative AI prompts to track. This process may take a couple of minutes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-2 rounded-md border border-red-200 bg-red-50 p-2 text-sm text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-200">
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleAnalyze}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Play className="h-4 w-4" /> Retry Analysis
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
<span className="font-medium text-foreground">Analyzing brand data...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<WebLogo domain={data.website} size={48} />
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Brand details</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Confirm the brand identity, additional domains, and aliases used for tracking.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 mt-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Brand name</p>
|
||||
<Input
|
||||
value={data.brandName}
|
||||
onChange={(e) => updateBrandName(e.target.value)}
|
||||
placeholder="Brand name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Website URL</p>
|
||||
<Input
|
||||
type="url"
|
||||
value={data.website}
|
||||
onChange={(e) => updateWebsite(e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Additional domains</p>
|
||||
<EditableTagsInput
|
||||
items={data.additionalDomains}
|
||||
onValueChange={updateAdditionalDomains}
|
||||
placeholder="Add domain..."
|
||||
maxItems={10}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Aliases</p>
|
||||
<EditableTagsInput
|
||||
items={data.aliases}
|
||||
onValueChange={updateAliases}
|
||||
placeholder="Add alias..."
|
||||
maxItems={10}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 mt-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Short description</p>
|
||||
<Textarea
|
||||
value={data.shortDescription || ""}
|
||||
onChange={(e) => updateShortDescription(e.target.value)}
|
||||
placeholder="Brief summary of the brand..."
|
||||
rows={3}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Products and services</p>
|
||||
<EditableTagsInput
|
||||
items={data.productsAndServices || []}
|
||||
onValueChange={updateProductsAndServices}
|
||||
placeholder="Add product or service..."
|
||||
maxItems={20}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Keywords</p>
|
||||
<EditableTagsInput
|
||||
items={data.keywords || []}
|
||||
onValueChange={updateKeywords}
|
||||
placeholder="Add keyword..."
|
||||
maxItems={20}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Competitors</h2>
|
||||
<p className="text-sm text-muted-foreground">Companies you want tracked alongside your brand.</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAnalyzeCompetitors}
|
||||
disabled={isAnalyzingCompetitors || isSaving}
|
||||
>
|
||||
{isAnalyzingCompetitors ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Finding…
|
||||
</>
|
||||
) : (
|
||||
"Find Competitors"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<CompetitorsEditor competitors={data.competitors} onChange={updateCompetitors} disabled={isSaving} />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Prompts</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pick which AI tracking prompts to start with. <br /> Untick any you don't want, edit tags, or add your own at the bottom.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAnalyzePrompts}
|
||||
disabled={isAnalyzingPrompts || isSaving}
|
||||
>
|
||||
{isAnalyzingPrompts ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Suggesting…
|
||||
</>
|
||||
) : (
|
||||
"Suggest Prompts"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<PromptsListEditor prompts={data.prompts} onChange={updatePrompts} showSystemTags={false} />
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 p-4 text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-200">
|
||||
<AlertCircle className="h-5 w-5 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">{submitError}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSaving || previewCounts.totalNew === 0}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Saving…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Rocket className="h-4 w-4" /> Start tracking ({previewCounts.totalNew} new prompts)
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
254
apps/web/src/components/prompts-display.tsx
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import { useMemo } from "react";
|
||||
import { Card, CardContent, CardFooter, CardHeader } from "@workspace/ui/components/card";
|
||||
import { Separator } from "@workspace/ui/components/separator";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Inbox } from "lucide-react";
|
||||
import { IconEditCircle } from "@tabler/icons-react";
|
||||
import { usePromptsSummary } from "@/hooks/use-prompts-summary";
|
||||
import { useBatchChartData } from "@/hooks/use-batch-chart-data";
|
||||
import { useBrand, useCompetitors } from "@/hooks/use-brands";
|
||||
import { useListFilters } from "@/hooks/use-list-filters";
|
||||
import { Link, useSearch } from "@tanstack/react-router";
|
||||
import { VirtualizedPromptList } from "@/components/virtualized-prompt-list";
|
||||
import { ChartDataProvider } from "@/contexts/chart-data-context";
|
||||
import { Skeleton } from "@workspace/ui/components/skeleton";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { getAvailableModels, ALL_MODELS_VALUE } from "@/components/filter-bar";
|
||||
import { FilteredListShell } from "@/components/filtered-list-shell";
|
||||
import { PromptOrderDropdown } from "@/components/prompt-order-dropdown";
|
||||
import { VisibilityBarSection } from "@/components/visibility-bar-section";
|
||||
import { coercePromptOrder, orderPrompts } from "@/lib/prompt-order";
|
||||
import type { LookbackPeriod } from "@/lib/chart-utils";
|
||||
import type { Brand, Competitor } from "@workspace/lib/db/schema";
|
||||
|
||||
interface PromptsDisplayProps {
|
||||
pageTitle: string;
|
||||
pageDescription: string;
|
||||
pageInfoContent?: React.ReactNode;
|
||||
editLink: string;
|
||||
}
|
||||
|
||||
/** Host component: renders the page shell (title, sticky bar, content)
|
||||
* and composes independent sub-sections. It doesn't subscribe to any
|
||||
* filter state itself — each section reads the URL keys it cares about
|
||||
* so a filter change only re-renders the sections that depend on it. */
|
||||
export function PromptsDisplay({ pageTitle, pageDescription, pageInfoContent, editLink }: PromptsDisplayProps) {
|
||||
const { brand } = useBrand();
|
||||
return (
|
||||
<PageHeader title={pageTitle} subtitle={pageDescription} infoContent={pageInfoContent}>
|
||||
<PromptsContent brandId={brand?.id} editLink={editLink} />
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
|
||||
/** Owns the single `usePromptsSummary` subscription for the page. Derives
|
||||
* `availableTags`, the search-filtered prompt id list (used by both the
|
||||
* visibility bar and the chart list), and passes them down. Child
|
||||
* components still hold their own subscriptions to whichever URL keys
|
||||
* they need, so a click on "Lookback" only invalidates the data users
|
||||
* and not `FilterBar` itself. */
|
||||
function PromptsContent({ brandId, editLink }: { brandId: string | undefined; editLink: string }) {
|
||||
const { brand } = useBrand(brandId);
|
||||
const filters = useListFilters();
|
||||
const { model, lookback, tags, search } = filters;
|
||||
// `order` is this route's own search key (not a narrowing filter), so it
|
||||
// rides outside `useListFilters` / `isFiltered`.
|
||||
const order = useSearch({
|
||||
strict: false,
|
||||
select: (s) => coercePromptOrder((s as { order?: unknown }).order),
|
||||
});
|
||||
|
||||
// Server hands us `effectiveModels` — the deployment-configured model ids
|
||||
// this brand actually runs, after applying `enabledModels`. FilterBar
|
||||
// adds the "all" sentinel on top; per-prompt chart controls only care
|
||||
// about the concrete list.
|
||||
const effectiveModels = brand?.effectiveModels ?? [];
|
||||
const availableModels = useMemo(() => getAvailableModels(effectiveModels), [effectiveModels]);
|
||||
const availableIndividualModels = effectiveModels;
|
||||
|
||||
const modelParam = model === ALL_MODELS_VALUE ? undefined : model;
|
||||
const {
|
||||
promptsSummary,
|
||||
isLoading: isLoadingSummary,
|
||||
isError: summaryError,
|
||||
} = usePromptsSummary(brandId, {
|
||||
lookback,
|
||||
model: modelParam,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
});
|
||||
|
||||
const availableTags = promptsSummary?.availableTags ?? [];
|
||||
|
||||
// The prompt list is still search-filtered client-side for display, then
|
||||
// re-ordered per the `order` control (#60). The chart/visibility sections
|
||||
// no longer receive this id list — they resolve the same prompts
|
||||
// server-side from the tag + search filters (issue #68).
|
||||
const sortedPrompts = useMemo(() => {
|
||||
if (!promptsSummary) return [];
|
||||
const allPrompts = promptsSummary.prompts;
|
||||
const filtered = search
|
||||
? allPrompts.filter((p) => p.value.toLowerCase().includes(search.toLowerCase()))
|
||||
: allPrompts;
|
||||
return orderPrompts(filtered, order);
|
||||
}, [promptsSummary, search, order]);
|
||||
|
||||
const isInitialLoad = isLoadingSummary && !promptsSummary;
|
||||
|
||||
return (
|
||||
<FilteredListShell
|
||||
filters={filters}
|
||||
availableTags={availableTags}
|
||||
availableModels={availableModels}
|
||||
showSearch
|
||||
showModelSelector
|
||||
showResultCount
|
||||
filterBarExtras={<PromptOrderDropdown />}
|
||||
filterSectionExtras={<VisibilityBarSection brandId={brandId} />}
|
||||
isLoading={isInitialLoad}
|
||||
loadingState={<ContentLoadingSkeleton />}
|
||||
isError={Boolean(summaryError)}
|
||||
errorState={
|
||||
<Card className="p-6">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p className="mb-2">Failed to load prompts data</p>
|
||||
<p className="text-sm">Try refreshing the page</p>
|
||||
</div>
|
||||
</Card>
|
||||
}
|
||||
totalCount={promptsSummary?.prompts?.length}
|
||||
filteredCount={sortedPrompts.length}
|
||||
noMatchesTitle="No prompts match your filters."
|
||||
noMatchesDescription="Try adjusting your search or tag filters."
|
||||
emptyState={
|
||||
<div className="border-2 border-dashed border-muted rounded-lg min-h-48 flex items-center justify-center">
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Inbox className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="mb-4">No prompts yet.</p>
|
||||
<Button asChild size="sm" className="h-7 flex cursor-pointer">
|
||||
<Link to={editLink}>
|
||||
<IconEditCircle />
|
||||
<span>Edit</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ChartSection
|
||||
brandId={brandId}
|
||||
lookback={lookback}
|
||||
selectedModel={model}
|
||||
modelParam={modelParam}
|
||||
searchQuery={search}
|
||||
selectedTags={tags}
|
||||
sortedPrompts={sortedPrompts}
|
||||
availableIndividualModels={availableIndividualModels}
|
||||
brand={brand ?? null}
|
||||
/>
|
||||
</FilteredListShell>
|
||||
);
|
||||
}
|
||||
|
||||
/** Heavy chart subtree. Split out so it gets its own render boundary —
|
||||
* `React.memo` on `VirtualizedPromptList` means this block only walks
|
||||
* 30 chart cards when its own props change, not every time a sibling
|
||||
* state (like visibility refetch) moves. */
|
||||
function ChartSection({
|
||||
brandId,
|
||||
lookback,
|
||||
selectedModel,
|
||||
modelParam,
|
||||
searchQuery,
|
||||
selectedTags,
|
||||
sortedPrompts,
|
||||
availableIndividualModels,
|
||||
brand,
|
||||
}: {
|
||||
brandId: string | undefined;
|
||||
lookback: LookbackPeriod;
|
||||
selectedModel: string;
|
||||
modelParam: string | undefined;
|
||||
searchQuery: string;
|
||||
selectedTags: string[];
|
||||
sortedPrompts: { id: string; value: string; firstEvaluatedAt?: Date | string | null }[];
|
||||
availableIndividualModels: string[];
|
||||
brand: Brand | null;
|
||||
}) {
|
||||
const { batchChartData, isLoading: isLoadingChartData } = useBatchChartData(brandId, {
|
||||
lookback,
|
||||
model: modelParam,
|
||||
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
||||
search: searchQuery || undefined,
|
||||
});
|
||||
|
||||
const { startDate, endDate } = useMemo(() => {
|
||||
if (!batchChartData?.dateRange) {
|
||||
const now = new Date();
|
||||
return { startDate: now, endDate: now };
|
||||
}
|
||||
return {
|
||||
startDate: new Date(batchChartData.dateRange.fromDate),
|
||||
endDate: new Date(batchChartData.dateRange.toDate),
|
||||
};
|
||||
}, [batchChartData?.dateRange]);
|
||||
|
||||
const brandForProvider: Brand | null = brand ?? null;
|
||||
const { competitors } = useCompetitors(brandId);
|
||||
const competitorsForProvider: Competitor[] = competitors || [];
|
||||
|
||||
return (
|
||||
<ChartDataProvider
|
||||
batchData={batchChartData?.chartData || null}
|
||||
brand={brandForProvider}
|
||||
competitors={competitorsForProvider}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
isLoading={isLoadingChartData}
|
||||
>
|
||||
<VirtualizedPromptList
|
||||
prompts={sortedPrompts}
|
||||
brandId={brandId || ""}
|
||||
lookback={lookback}
|
||||
selectedModel={selectedModel}
|
||||
availableModels={availableIndividualModels}
|
||||
searchHighlight={searchQuery}
|
||||
/>
|
||||
</ChartDataProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentLoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Card key={i} className="py-3 gap-3">
|
||||
<CardHeader className="flex justify-between items-center px-3">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-5 w-24 rounded-full" />
|
||||
</CardHeader>
|
||||
<Separator className="py-0 my-0" />
|
||||
<CardContent className="pl-0 pr-6">
|
||||
<div className="h-[250px] flex items-center justify-center">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-32 mx-auto" />
|
||||
<div className="flex justify-center space-x-2">
|
||||
<div className="h-2 w-2 bg-primary/20 rounded-full animate-pulse" />
|
||||
<div className="h-2 w-2 bg-primary/20 rounded-full animate-pulse [animation-delay:0.2s]" />
|
||||
<div className="h-2 w-2 bg-primary/20 rounded-full animate-pulse [animation-delay:0.4s]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<Separator className="py-0 my-0" />
|
||||
<CardFooter className="flex items-center justify-between px-3 pt-3 pb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-6 w-16 rounded" />
|
||||
<Skeleton className="h-6 w-24 rounded" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-20 rounded" />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
apps/web/src/components/prompts-editor.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { useState, useRef, useCallback } from "react";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Save } from "lucide-react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useInvalidatePromptsSummary } from "@/hooks/use-prompts-summary";
|
||||
import { updatePromptsFn } from "@/server/prompts";
|
||||
import { trackEvent } from "@/lib/posthog";
|
||||
import { PromptsListEditor, type EditablePrompt } from "@/components/prompts-list-editor";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import type { ClientConfig } from "@workspace/config/types";
|
||||
import { computeSystemTags } from "@workspace/lib/tag-utils";
|
||||
|
||||
interface Prompt {
|
||||
id: string;
|
||||
brandId: string;
|
||||
value: string;
|
||||
enabled: boolean;
|
||||
tags?: string[];
|
||||
systemTags?: string[];
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface PromptsEditorProps {
|
||||
initialPrompts: Prompt[];
|
||||
brandId: string;
|
||||
brandName?: string;
|
||||
brandWebsite?: string;
|
||||
pageTitle: string;
|
||||
pageDescription: string;
|
||||
}
|
||||
|
||||
export function PromptsEditor({ initialPrompts, brandId, brandName, brandWebsite, pageTitle, pageDescription }: PromptsEditorProps) {
|
||||
const [prompts, setPrompts] = useState<EditablePrompt[]>(() =>
|
||||
initialPrompts.map((p) => ({
|
||||
id: p.id,
|
||||
_key: p.id,
|
||||
value: p.value,
|
||||
enabled: p.enabled,
|
||||
tags: p.tags || [],
|
||||
systemTags: p.systemTags || [],
|
||||
})),
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const saveInProgress = useRef(false);
|
||||
const navigate = useNavigate();
|
||||
const invalidatePromptsSummary = useInvalidatePromptsSummary();
|
||||
const routeContext = useRouteContext({ strict: false }) as { clientConfig?: ClientConfig };
|
||||
const isReadOnly = routeContext.clientConfig?.features.readOnly;
|
||||
|
||||
const savePrompts = async () => {
|
||||
if (saveInProgress.current) {
|
||||
console.warn("Save already in progress, ignoring duplicate request");
|
||||
return;
|
||||
}
|
||||
|
||||
saveInProgress.current = true;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const validPrompts = prompts.filter((p) => p.value.trim());
|
||||
|
||||
const currentIds = new Set(validPrompts.filter((p) => p.id).map((p) => p.id));
|
||||
const removedPrompts = initialPrompts
|
||||
.filter((p) => !currentIds.has(p.id))
|
||||
.map((p) => ({ id: p.id, value: p.value, enabled: false, tags: p.tags || [] }));
|
||||
|
||||
const allPrompts = [
|
||||
...validPrompts.map((p) => ({
|
||||
...(p.id ? { id: p.id } : {}),
|
||||
value: p.value.trim(),
|
||||
enabled: p.enabled,
|
||||
tags: p.tags,
|
||||
})),
|
||||
...removedPrompts,
|
||||
];
|
||||
|
||||
await updatePromptsFn({ data: { brandId, prompts: allPrompts } });
|
||||
|
||||
const added = validPrompts.filter((p) => !p.id).length;
|
||||
const edited = validPrompts.filter((p) => p.id).length;
|
||||
const deleted = removedPrompts.length;
|
||||
trackEvent("prompts_updated", { added, edited, deleted });
|
||||
|
||||
invalidatePromptsSummary(brandId);
|
||||
navigate({ to: `/app/${brandId}/visibility` });
|
||||
} catch (error) {
|
||||
console.error("Error saving prompts:", error);
|
||||
alert(`Failed to save prompts: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
saveInProgress.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleComputeSystemTags = useCallback(
|
||||
(text: string) => {
|
||||
if (!brandName || !brandWebsite) return [];
|
||||
return computeSystemTags(text, brandName, brandWebsite);
|
||||
},
|
||||
[brandName, brandWebsite],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{pageTitle}</h1>
|
||||
<p className="text-muted-foreground">{pageDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PromptsListEditor
|
||||
prompts={prompts}
|
||||
onChange={setPrompts}
|
||||
computeSystemTags={handleComputeSystemTags}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button
|
||||
onClick={savePrompts}
|
||||
disabled={isLoading || isReadOnly}
|
||||
size="sm"
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>Saving...</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4" />
|
||||
Save Prompts
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
393
apps/web/src/components/prompts-list-editor.tsx
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
/**
|
||||
* Shared prompts table — used by the settings/prompts page (manages a brand's
|
||||
* full prompt list) and the prompt wizard's Review step (picks from
|
||||
* AI-suggested prompts + custom additions).
|
||||
*
|
||||
* Controlled component: the caller owns the `prompts` array and the change
|
||||
* callback. The settings page wraps it with save/server logic; the wizard
|
||||
* keeps it inline. The `showSystemTags` prop hides the System Tags column
|
||||
* in the wizard since onboarding hasn't yet computed any system tags.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { Checkbox } from "@workspace/ui/components/checkbox";
|
||||
import { Switch } from "@workspace/ui/components/switch";
|
||||
import { TagsInput } from "@workspace/ui/components/tags-input";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@workspace/ui/components/tooltip";
|
||||
import { Textarea } from "@workspace/ui/components/textarea";
|
||||
import { Plus, Inbox, ListPlus, Trash2 } from "lucide-react";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { MAX_PROMPTS } from "@workspace/lib/constants";
|
||||
|
||||
export interface EditablePrompt {
|
||||
id?: string;
|
||||
_key: string;
|
||||
value: string;
|
||||
enabled: boolean;
|
||||
tags: string[];
|
||||
systemTags: string[];
|
||||
}
|
||||
|
||||
export function newPromptEntry(partial?: Partial<EditablePrompt>): EditablePrompt {
|
||||
return {
|
||||
_key: crypto.randomUUID(),
|
||||
value: partial?.value ?? "",
|
||||
enabled: partial?.enabled ?? true,
|
||||
tags: partial?.tags ?? [],
|
||||
systemTags: partial?.systemTags ?? [],
|
||||
...(partial?.id ? { id: partial.id } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
interface PromptsListEditorProps {
|
||||
prompts: EditablePrompt[];
|
||||
onChange: (next: EditablePrompt[]) => void;
|
||||
/** Show the read-only System Tags column. Default true. */
|
||||
showSystemTags?: boolean;
|
||||
/** Optional function to dynamically compute system tags as the user types */
|
||||
computeSystemTags?: (text: string) => string[];
|
||||
}
|
||||
|
||||
export function PromptsListEditor({ prompts, onChange, showSystemTags = true, computeSystemTags }: PromptsListEditorProps) {
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||
|
||||
const allTagOptions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const p of prompts) for (const t of p.tags) set.add(t);
|
||||
return [...set].sort().map((t) => ({ value: t }));
|
||||
}, [prompts]);
|
||||
|
||||
const update = (index: number, patch: Partial<EditablePrompt>) => {
|
||||
onChange(
|
||||
prompts.map((p, i) => {
|
||||
if (i !== index) return p;
|
||||
const next = { ...p, ...patch };
|
||||
// Auto-compute system tags if text changed and a compute function was provided
|
||||
if ("value" in patch && computeSystemTags) {
|
||||
next.systemTags = computeSystemTags(patch.value as string);
|
||||
}
|
||||
return next;
|
||||
}),
|
||||
);
|
||||
};
|
||||
const add = () => {
|
||||
if (prompts.length >= MAX_PROMPTS) return;
|
||||
onChange([...prompts, newPromptEntry({ tags: ["custom"] })]);
|
||||
};
|
||||
|
||||
// Count selection against current prompts so stale keys (e.g. after the
|
||||
// wizard regenerates suggestions) don't linger.
|
||||
const liveSelectedCount = prompts.reduce((n, p) => (selectedKeys.has(p._key) ? n + 1 : n), 0);
|
||||
const allSelected = prompts.length > 0 && liveSelectedCount === prompts.length;
|
||||
|
||||
const toggleSelect = (key: string) => {
|
||||
setSelectedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const toggleSelectAll = () => {
|
||||
if (allSelected) setSelectedKeys(new Set());
|
||||
else setSelectedKeys(new Set(prompts.map((p) => p._key)));
|
||||
};
|
||||
const applyEnabledToSelection = (enabled: boolean) => {
|
||||
if (liveSelectedCount === 0) return;
|
||||
onChange(prompts.map((p) => (selectedKeys.has(p._key) ? { ...p, enabled } : p)));
|
||||
};
|
||||
const clearSelection = () => setSelectedKeys(new Set());
|
||||
|
||||
const validCount = prompts.filter((p) => p.enabled && p.value.trim().length > 0).length;
|
||||
|
||||
const [isBulkMode, setIsBulkMode] = useState(false);
|
||||
const [bulkInput, setBulkInput] = useState("");
|
||||
|
||||
const handleBulkAdd = () => {
|
||||
const newLines = bulkInput
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
if (newLines.length === 0) {
|
||||
setIsBulkMode(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const spaceLeft = MAX_PROMPTS - prompts.length;
|
||||
const toAdd = newLines.slice(0, spaceLeft).map((text) =>
|
||||
newPromptEntry({
|
||||
value: text,
|
||||
tags: ["custom"],
|
||||
...(computeSystemTags ? { systemTags: computeSystemTags(text) } : {})
|
||||
})
|
||||
);
|
||||
|
||||
onChange([...prompts, ...toAdd]);
|
||||
setBulkInput("");
|
||||
setIsBulkMode(false);
|
||||
};
|
||||
|
||||
// Desktop layout only — column order is [select] [text] [system?] [tags] [switch+delete].
|
||||
// Mobile renders a stacked per-prompt block instead (no selection, no bulk).
|
||||
const gridCols = showSystemTags
|
||||
? "md:grid-cols-[2.25rem_minmax(0,1fr)_6rem_minmax(14rem,1fr)_5rem]"
|
||||
: "md:grid-cols-[2.25rem_minmax(0,1fr)_minmax(14rem,1fr)_5rem]";
|
||||
|
||||
const remove = (index: number) => {
|
||||
onChange(prompts.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{liveSelectedCount > 0 && (
|
||||
<div className="hidden md:flex flex-wrap items-center justify-between gap-x-2 gap-y-2 rounded-md border bg-muted/40 px-3 py-2 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
<strong className="text-foreground">{liveSelectedCount}</strong> selected
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => applyEnabledToSelection(true)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Enable
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => applyEnabledToSelection(false)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Disable
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={clearSelection}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`hidden md:grid ${gridCols} gap-2 text-sm font-medium text-muted-foreground border-b pb-2`}>
|
||||
<div className="flex justify-center">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
disabled={prompts.length === 0}
|
||||
aria-label={allSelected ? "Deselect all prompts" : "Select all prompts"}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
Prompt Text
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconInfoCircle className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-xs">The question or query that will be sent to AI models for evaluation.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{showSystemTags && (
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
System
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconInfoCircle className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-xs">Auto-generated tags like "branded" or "unbranded" based on prompt content.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
Tags
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconInfoCircle className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-xs">Custom labels to organize and filter prompts.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<span className="sr-only">Enabled</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{prompts.length === 0 ? (
|
||||
<div className="border-2 border-dashed border-muted rounded-lg min-h-48 flex items-center justify-center">
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Inbox className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No prompts yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{prompts.map((prompt, index) => (
|
||||
<div key={prompt._key} className={!prompt.enabled ? "opacity-60" : ""}>
|
||||
{/* Mobile: stacked, no selection/bulk */}
|
||||
<div
|
||||
className={`md:hidden flex flex-col gap-2 pb-3 ${
|
||||
index < prompts.length - 1 ? "border-b" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Input
|
||||
value={prompt.value}
|
||||
onChange={(e) => update(index, { value: e.target.value })}
|
||||
placeholder="Enter prompt text..."
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<div className="pt-2 flex items-center gap-2">
|
||||
<Switch
|
||||
checked={prompt.enabled}
|
||||
onCheckedChange={(checked) => update(index, { enabled: checked })}
|
||||
aria-label={prompt.enabled ? "Disable prompt" : "Enable prompt"}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => remove(index)}
|
||||
className="h-auto p-1.5 text-muted-foreground hover:text-destructive shrink-0 cursor-pointer"
|
||||
aria-label="Delete prompt"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TagsInput
|
||||
value={prompt.tags}
|
||||
onValueChange={(tags) => update(index, { tags })}
|
||||
options={allTagOptions}
|
||||
placeholder="Add tag..."
|
||||
searchPlaceholder="Search or create tag..."
|
||||
normalizeValue={(raw) => raw.toLowerCase().trim()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Desktop (md+): single-line grid */}
|
||||
<div className={`hidden md:grid ${gridCols} gap-2 items-start`}>
|
||||
<div className="flex justify-center pt-2">
|
||||
<Checkbox
|
||||
checked={selectedKeys.has(prompt._key)}
|
||||
onCheckedChange={() => toggleSelect(prompt._key)}
|
||||
aria-label="Select prompt"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
value={prompt.value}
|
||||
onChange={(e) => update(index, { value: e.target.value })}
|
||||
placeholder="Enter prompt text..."
|
||||
className="min-w-0"
|
||||
/>
|
||||
{showSystemTags && (
|
||||
<TagsInput value={prompt.systemTags} onValueChange={() => {}} disabled placeholder="—" />
|
||||
)}
|
||||
<TagsInput
|
||||
value={prompt.tags}
|
||||
onValueChange={(tags) => update(index, { tags })}
|
||||
options={allTagOptions}
|
||||
placeholder="Add tag..."
|
||||
searchPlaceholder="Search or create tag..."
|
||||
normalizeValue={(raw) => raw.toLowerCase().trim()}
|
||||
/>
|
||||
<div className="flex justify-center items-center gap-1 pt-2">
|
||||
<Switch
|
||||
checked={prompt.enabled}
|
||||
onCheckedChange={(checked) => update(index, { enabled: checked })}
|
||||
aria-label={prompt.enabled ? "Disable prompt" : "Enable prompt"}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => remove(index)}
|
||||
className="h-auto p-1.5 text-muted-foreground hover:text-destructive shrink-0 cursor-pointer"
|
||||
aria-label="Delete prompt"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{prompts.length < MAX_PROMPTS && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{isBulkMode ? (
|
||||
<div className="space-y-2 border rounded-md p-3 bg-muted/20">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-sm font-medium">Bulk Add Prompts</span>
|
||||
<span className="text-xs text-muted-foreground">{MAX_PROMPTS - prompts.length} slots remaining</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={bulkInput}
|
||||
onChange={(e) => setBulkInput(e.target.value)}
|
||||
placeholder="Paste multiple prompts here, each on a new line..."
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
<div className="flex gap-2 justify-end pt-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => setIsBulkMode(false)} className="cursor-pointer">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleBulkAdd} className="cursor-pointer">
|
||||
Add to list
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={add}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Add Prompt
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => setIsBulkMode(true)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<ListPlus className="h-4 w-4" /> Bulk Add
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{prompts.length >= MAX_PROMPTS && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Maximum of {MAX_PROMPTS} prompts allowed. Remove a prompt to add a new one.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>
|
||||
{validCount}/{MAX_PROMPTS}
|
||||
</strong>{" "}
|
||||
prompts configured
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
apps/web/src/components/share-of-voice-donut.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* Donut of share of voice: the brand plus its top competitors, with the long
|
||||
* tail bucketed into "Others". Sits beside the headline share number.
|
||||
*/
|
||||
import { Cell, Pie, PieChart, Tooltip } from "recharts";
|
||||
import { ChartContainer } from "@workspace/ui/components/chart";
|
||||
import type { ShareOfVoiceEntry } from "@/server/analysis";
|
||||
import { BRAND_COLOR, COMPETITOR_PALETTE as PALETTE, OTHERS_COLOR } from "@/lib/share-of-voice-palette";
|
||||
|
||||
interface Slice {
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function ShareOfVoiceDonut({ entries, topN = 6 }: { entries: ShareOfVoiceEntry[]; topN?: number }) {
|
||||
const slices: Slice[] = [];
|
||||
let paletteIdx = 0;
|
||||
let shownCompetitors = 0;
|
||||
let othersValue = 0;
|
||||
|
||||
for (const e of entries) {
|
||||
if (e.mentions <= 0) continue;
|
||||
if (e.isBrand) {
|
||||
slices.push({ name: e.name, value: e.mentions, color: BRAND_COLOR });
|
||||
} else if (shownCompetitors < topN) {
|
||||
slices.push({ name: e.name, value: e.mentions, color: PALETTE[paletteIdx++ % PALETTE.length] });
|
||||
shownCompetitors++;
|
||||
} else {
|
||||
othersValue += e.mentions;
|
||||
}
|
||||
}
|
||||
if (othersValue > 0) slices.push({ name: "Others", value: othersValue, color: OTHERS_COLOR });
|
||||
|
||||
const total = slices.reduce((s, x) => s + x.value, 0);
|
||||
if (total === 0) return null;
|
||||
|
||||
return (
|
||||
<ChartContainer config={{}} className="aspect-square h-[180px] w-[180px]">
|
||||
<PieChart>
|
||||
<Pie data={slices} dataKey="value" nameKey="name" innerRadius={48} outerRadius={84} paddingAngle={1} strokeWidth={1}>
|
||||
{slices.map((s) => (
|
||||
<Cell key={s.name} fill={s.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const s = payload[0].payload as Slice;
|
||||
return (
|
||||
<div className="rounded-md border bg-background px-2 py-1 text-xs shadow-md">
|
||||
{s.name}: {Math.round((s.value / total) * 100)}%
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
205
apps/web/src/components/site-header.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
|
||||
import { useLocation } from "@tanstack/react-router";
|
||||
|
||||
import { Separator } from "@workspace/ui/components/separator";
|
||||
import { SidebarTrigger } from "@workspace/ui/components/sidebar";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@workspace/ui/components/breadcrumb";
|
||||
import { useBrand } from "@/hooks/use-brands";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
/** Map of page segments to display names */
|
||||
const PAGE_NAMES: Record<string, string> = {
|
||||
visibility: "Visibility",
|
||||
"share-of-voice": "Share of Voice",
|
||||
"query-fan-out": "Query Fan-Out",
|
||||
opportunities: "Opportunities",
|
||||
prompts: "Prompts",
|
||||
citations: "Citations",
|
||||
brand: "Brand",
|
||||
competitors: "Competitors",
|
||||
llms: "LLMs",
|
||||
workflows: "Workflows",
|
||||
tools: "Tools",
|
||||
};
|
||||
|
||||
function getPageDisplayName(segment: string): string {
|
||||
return PAGE_NAMES[segment] || segment.charAt(0).toUpperCase() + segment.slice(1);
|
||||
}
|
||||
|
||||
function AdminBreadcrumbs({ pathname }: { pathname: string }) {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
// /admin -> ["admin"]
|
||||
// /admin/workflows -> ["admin", "workflows"]
|
||||
// /admin/tools -> ["admin", "tools"]
|
||||
// /reports -> ["reports"]
|
||||
|
||||
if (segments[0] === "reports") {
|
||||
// /reports/render/[id] - keep existing behavior
|
||||
if (segments.length > 1) {
|
||||
return (
|
||||
<>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to="/reports">Reports</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>View Report</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
// /reports
|
||||
return (
|
||||
<>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<span className="text-muted-foreground">Admin</span>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Reports</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// /admin - show Admin > Brands
|
||||
if (segments.length === 1) {
|
||||
return (
|
||||
<>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<span className="text-muted-foreground">Admin</span>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Brands</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// /admin/workflows, /admin/tools, etc.
|
||||
const subPage = segments[1];
|
||||
return (
|
||||
<>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<span className="text-muted-foreground">Admin</span>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{getPageDisplayName(subPage)}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BrandBreadcrumbs({ pathname, brandId, brandName }: { pathname: string; brandId: string | undefined; brandName: string }) {
|
||||
// Extract the page segment from the path (e.g., /app/foo/prompts -> prompts)
|
||||
const pathSegments = pathname.split("/");
|
||||
const brandIndex = pathSegments.findIndex((segment) => segment === "app");
|
||||
const pageSegment = brandIndex >= 0 && pathSegments[brandIndex + 2] ? pathSegments[brandIndex + 2] : "";
|
||||
const subSegment = brandIndex >= 0 && pathSegments[brandIndex + 3] ? pathSegments[brandIndex + 3] : "";
|
||||
|
||||
// Check if we're on a specific prompt detail page (e.g., /app/foo/prompts/uuid)
|
||||
const isPromptDetailPage =
|
||||
pageSegment === "prompts" &&
|
||||
subSegment &&
|
||||
subSegment !== "edit" &&
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(subSegment);
|
||||
|
||||
// Check if we're on an edit page
|
||||
const isEditPage = pathname.endsWith("/edit");
|
||||
|
||||
// Settings sub-pages: /app/brandId/settings/brand, /app/brandId/settings/competitors, etc.
|
||||
const isSettingsSubPage = pageSegment === "settings" && subSegment;
|
||||
|
||||
// Determine page name
|
||||
const pageName = pageSegment ? getPageDisplayName(pageSegment) : "Overview";
|
||||
|
||||
return (
|
||||
<>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbLink asChild>
|
||||
{brandId ? <Link to="/app/$brand" params={{ brand: brandId }}>{brandName}</Link> : <span>{brandName}</span>}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
{isPromptDetailPage ? (
|
||||
<>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbLink asChild>
|
||||
{brandId ? <Link to="/app/$brand/visibility" params={{ brand: brandId }}>Visibility</Link> : <span>Visibility</span>}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Prompt History</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
) : isSettingsSubPage ? (
|
||||
<>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<span className="text-muted-foreground">Settings</span>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{getPageDisplayName(subSegment)}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
) : isEditPage ? (
|
||||
<>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to={pathname.slice(0, -5)}>{pageName}</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Edit</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
) : (
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{pageName}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SiteHeader() {
|
||||
const { brandId, brand } = useBrand();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const isAdminPage = pathname.startsWith("/admin") || pathname.startsWith("/reports");
|
||||
|
||||
return (
|
||||
<header className="bg-background sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
||||
<SidebarTrigger className="-ml-1 cursor-pointer" />
|
||||
<Separator orientation="vertical" className="mx-2 data-[orientation=vertical]:h-4" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{isAdminPage ? (
|
||||
<AdminBreadcrumbs pathname={pathname} />
|
||||
) : (
|
||||
<BrandBreadcrumbs
|
||||
pathname={pathname}
|
||||
brandId={brandId}
|
||||
brandName={brand?.name || "Dashboard"}
|
||||
/>
|
||||
)}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
43
apps/web/src/components/text-highlighter.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
|
||||
import { Fragment } from "react";
|
||||
|
||||
interface TextHighlighterProps {
|
||||
text: string;
|
||||
highlight: string;
|
||||
className?: string;
|
||||
highlightClassName?: string;
|
||||
}
|
||||
|
||||
export function TextHighlighter({
|
||||
text,
|
||||
highlight,
|
||||
className = "",
|
||||
highlightClassName = "bg-yellow-200 dark:bg-yellow-800 rounded-sm",
|
||||
}: TextHighlighterProps) {
|
||||
if (!highlight || highlight.trim() === "") {
|
||||
return <span className={className}>{text}</span>;
|
||||
}
|
||||
|
||||
const escapedHighlight = highlight.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(`(${escapedHighlight})`, "gi");
|
||||
const segments = text.split(regex).map((part, i) => ({
|
||||
key: `${i}-${part.slice(0, 8)}`,
|
||||
part,
|
||||
isMatch: part.toLowerCase() === highlight.toLowerCase(),
|
||||
}));
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
{segments.map((seg) =>
|
||||
seg.isMatch ? (
|
||||
<mark key={seg.key} className={highlightClassName}>
|
||||
{seg.part}
|
||||
</mark>
|
||||
) : (
|
||||
<Fragment key={seg.key}>{seg.part}</Fragment>
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
88
apps/web/src/components/trend-chart.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* Shared trend area-chart used by the overview's AI Visibility and Share of
|
||||
* Voice sections and the Share of Voice page. It takes only a label, a color,
|
||||
* and a {date, value} series — everything else (axis formatting, the
|
||||
* auto-ranged y-axis, the tooltip, the softened fill) is fixed here so the
|
||||
* stacked trends stay visually identical without being tuned in two places.
|
||||
*/
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import { ChartContainer, ChartTooltip, type ChartConfig } from "@workspace/ui/components/chart";
|
||||
import { formatDateStr } from "@/lib/app-locale";
|
||||
|
||||
export interface TrendPoint {
|
||||
date: string;
|
||||
value: number | null;
|
||||
}
|
||||
|
||||
export function TrendChart({
|
||||
data,
|
||||
label,
|
||||
color,
|
||||
className = "aspect-auto h-full w-full",
|
||||
}: {
|
||||
data: TrendPoint[];
|
||||
label: string;
|
||||
color: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const config = { value: { label, color } } satisfies ChartConfig;
|
||||
|
||||
return (
|
||||
<ChartContainer config={config} className={className}>
|
||||
<AreaChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={50}
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(value: string) => formatDateStr(value, { month: "short", day: "numeric" })}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, "auto"]}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickCount={4}
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(value: number) => `${value}%`}
|
||||
/>
|
||||
<ChartTooltip
|
||||
isAnimationActive={false}
|
||||
cursor={false}
|
||||
content={({ active, payload, label: dateLabel }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const value = payload[0]?.value as number | null;
|
||||
if (value == null) return null;
|
||||
const formattedDate = formatDateStr(dateLabel as string, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
return (
|
||||
<div className="border-border/50 bg-background grid min-w-[12rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl">
|
||||
<div className="font-medium">{formattedDate}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="shrink-0 rounded-[2px] h-2.5 w-2.5" style={{ background: color }} />
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="ml-auto font-mono tabular-nums">{value}%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
dataKey="value"
|
||||
type="monotone"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
fill={color}
|
||||
fillOpacity={0.18}
|
||||
connectNulls
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
122
apps/web/src/components/virtualized-prompt-list.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
|
||||
import { memo, useRef, useMemo, useState, useCallback, useLayoutEffect, useEffect } from "react";
|
||||
import { useWindowVirtualizer } from "@tanstack/react-virtual";
|
||||
import { CachedPromptChart } from "./cached-prompt-chart";
|
||||
import type { LookbackPeriod } from "@/hooks/use-prompt-chart-data";
|
||||
|
||||
interface PromptItem {
|
||||
id: string;
|
||||
value: string;
|
||||
// All-time first evaluation date (null if never evaluated)
|
||||
// Note: Date objects are serialized to strings in JSON responses
|
||||
firstEvaluatedAt?: Date | string | null;
|
||||
}
|
||||
|
||||
interface VirtualizedPromptListProps {
|
||||
prompts: PromptItem[];
|
||||
brandId: string;
|
||||
lookback: LookbackPeriod;
|
||||
/** Current model filter ("all" means no filter). */
|
||||
selectedModel: string;
|
||||
/** Concrete model ids this brand runs — no "all" sentinel. */
|
||||
availableModels: string[];
|
||||
searchHighlight?: string;
|
||||
}
|
||||
|
||||
// All chart cards use a uniform height (empty states match chart height via h-[250px])
|
||||
const CHART_CARD_HEIGHT = 380;
|
||||
const CHART_GAP = 24; // px - gap between cards (space-y-6)
|
||||
|
||||
// Memoized so react-query `isFetching` state changes on the parent
|
||||
// (which re-render prompts-display but leave these props untouched) don't
|
||||
// cascade into 30+ CachedPromptChart re-renders.
|
||||
export const VirtualizedPromptList = memo(function VirtualizedPromptList({
|
||||
prompts,
|
||||
brandId,
|
||||
lookback,
|
||||
selectedModel,
|
||||
availableModels,
|
||||
searchHighlight = "",
|
||||
}: VirtualizedPromptListProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollMargin, setScrollMargin] = useState(0);
|
||||
|
||||
const orderedPrompts = prompts;
|
||||
|
||||
// Measure the offset of the list from the top of the page
|
||||
useLayoutEffect(() => {
|
||||
if (listRef.current) {
|
||||
setScrollMargin(listRef.current.offsetTop);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Create a stable key that changes when the prompts list changes
|
||||
const promptsKey = useMemo(() => {
|
||||
return orderedPrompts.map(p => p.id).join(",");
|
||||
}, [orderedPrompts]);
|
||||
|
||||
// Uniform height estimate — all cards (loading, empty, full) have matching content areas
|
||||
const estimateSize = useCallback(() => CHART_CARD_HEIGHT + CHART_GAP, []);
|
||||
|
||||
const virtualizer = useWindowVirtualizer({
|
||||
count: orderedPrompts.length,
|
||||
estimateSize,
|
||||
overscan: 3, // Render 3 extra items above and below viewport
|
||||
scrollMargin,
|
||||
});
|
||||
|
||||
// Force virtualizer to recalculate when prompts list changes (e.g., after filtering)
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
virtualizer.measure();
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, [virtualizer, promptsKey]);
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
|
||||
return (
|
||||
<div ref={listRef} className="space-y-6">
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{virtualItems.map((virtualItem) => {
|
||||
const prompt = orderedPrompts[virtualItem.index];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={prompt.id}
|
||||
data-index={virtualItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${virtualItem.start - scrollMargin}px)`,
|
||||
contain: "layout style",
|
||||
}}
|
||||
>
|
||||
<div style={{ paddingBottom: CHART_GAP }}>
|
||||
<CachedPromptChart
|
||||
promptId={prompt.id}
|
||||
promptName={prompt.value}
|
||||
brandId={brandId}
|
||||
lookback={lookback}
|
||||
selectedModel={selectedModel}
|
||||
availableModels={availableModels}
|
||||
searchHighlight={searchHighlight}
|
||||
hasEverBeenEvaluated={Boolean(prompt.firstEvaluatedAt)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
70
apps/web/src/components/visibility-bar-section.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { useRef } from "react";
|
||||
import { useFilteredVisibility } from "@/hooks/use-filtered-visibility";
|
||||
import { VisibilityBar, VisibilityBarSkeleton, VisibilityBarEmpty } from "@/components/visibility-bar";
|
||||
import { useListFilters } from "@/hooks/use-list-filters";
|
||||
import { ALL_MODELS_VALUE } from "@/lib/model-filter";
|
||||
|
||||
/**
|
||||
* Self-contained visibility bar. Subscribes directly to the filter URL
|
||||
* keys it needs (lookback, model, tags, search) and fetches
|
||||
* `useFilteredVisibility` itself, so it's a sibling of the chart section
|
||||
* rather than something the parent has to wire through. The tag + search
|
||||
* filters are resolved to prompt IDs server-side, so we pass the criteria
|
||||
* rather than a prompt-id list (issue #68).
|
||||
*
|
||||
* The skeleton stays mounted inside a grid overlay so the bar reserves
|
||||
* its vertical space on load and doesn't shove the charts down when the
|
||||
* real bar comes in.
|
||||
*/
|
||||
export function VisibilityBarSection({
|
||||
brandId,
|
||||
}: {
|
||||
brandId: string | undefined;
|
||||
}) {
|
||||
const { lookback, model, tags, search } = useListFilters();
|
||||
const modelParam = model === ALL_MODELS_VALUE ? undefined : model;
|
||||
|
||||
const {
|
||||
filteredVisibility,
|
||||
isLoading: isLoadingVisibility,
|
||||
isValidating: isValidatingVisibility,
|
||||
} = useFilteredVisibility(brandId, {
|
||||
lookback,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
search: search || undefined,
|
||||
model: modelParam,
|
||||
});
|
||||
|
||||
// Keep the last-known visibility around so we don't flash the skeleton
|
||||
// on a refetch (react-query's isValidating flips true while the new data
|
||||
// is in flight even though we already have a prior result to show).
|
||||
const lastRef = useRef(filteredVisibility);
|
||||
if (filteredVisibility) lastRef.current = filteredVisibility;
|
||||
const stable = filteredVisibility || lastRef.current;
|
||||
|
||||
const hasLoaded = stable && !isLoadingVisibility;
|
||||
const hasData = hasLoaded && stable.totalRuns > 0;
|
||||
const isEmpty = hasLoaded && stable.totalRuns === 0;
|
||||
// While refetching on an empty previous result, keep showing the skeleton
|
||||
// rather than flashing "no data" between states.
|
||||
const showingSkeleton = !stable || (stable.totalRuns === 0 && isValidatingVisibility);
|
||||
|
||||
return (
|
||||
<div className="mt-3 grid [&>*]:col-start-1 [&>*]:row-start-1">
|
||||
<div className={hasData || (isEmpty && !showingSkeleton) ? "opacity-0 pointer-events-none" : undefined}>
|
||||
<VisibilityBarSkeleton />
|
||||
</div>
|
||||
{hasData && (
|
||||
<VisibilityBar
|
||||
currentVisibility={stable.currentVisibility}
|
||||
totalRuns={stable.totalRuns}
|
||||
totalPrompts={stable.totalPrompts}
|
||||
totalCitations={stable.totalCitations}
|
||||
visibilityTimeSeries={stable.visibilityTimeSeries}
|
||||
lookback={stable.lookback}
|
||||
/>
|
||||
)}
|
||||
{isEmpty && !showingSkeleton && <VisibilityBarEmpty />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
apps/web/src/components/visibility-bar.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
|
||||
import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts";
|
||||
import { Skeleton } from "@workspace/ui/components/skeleton";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@workspace/ui/components/tooltip";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
interface VisibilityTimeSeriesPoint {
|
||||
date: string;
|
||||
visibility: number | null;
|
||||
}
|
||||
|
||||
interface VisibilityBarProps {
|
||||
currentVisibility: number;
|
||||
totalRuns: number;
|
||||
totalPrompts: number;
|
||||
totalCitations: number;
|
||||
visibilityTimeSeries: VisibilityTimeSeriesPoint[];
|
||||
lookback: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
function getVisibilityColors(value: number) {
|
||||
if (value > 75) {
|
||||
return {
|
||||
bg: "bg-emerald-50 dark:bg-emerald-950/40",
|
||||
text: "text-emerald-600 dark:text-emerald-400",
|
||||
border: "border-emerald-200 dark:border-emerald-800/60",
|
||||
muted: "text-emerald-600/70 dark:text-emerald-400/70",
|
||||
stroke: "#10b981",
|
||||
fill: "#10b981",
|
||||
};
|
||||
}
|
||||
if (value > 45) {
|
||||
return {
|
||||
bg: "bg-amber-50 dark:bg-amber-950/40",
|
||||
text: "text-amber-600 dark:text-amber-400",
|
||||
border: "border-amber-200 dark:border-amber-800/60",
|
||||
muted: "text-amber-600/70 dark:text-amber-400/70",
|
||||
stroke: "#f59e0b",
|
||||
fill: "#f59e0b",
|
||||
};
|
||||
}
|
||||
return {
|
||||
bg: "bg-rose-50 dark:bg-rose-950/40",
|
||||
text: "text-rose-600 dark:text-rose-400",
|
||||
border: "border-rose-200 dark:border-rose-800/60",
|
||||
muted: "text-rose-600/70 dark:text-rose-400/70",
|
||||
stroke: "#ef4444",
|
||||
fill: "#ef4444",
|
||||
};
|
||||
}
|
||||
|
||||
export function VisibilityBar({
|
||||
currentVisibility,
|
||||
totalRuns,
|
||||
totalPrompts,
|
||||
totalCitations,
|
||||
visibilityTimeSeries,
|
||||
lookback,
|
||||
isLoading = false,
|
||||
}: VisibilityBarProps) {
|
||||
if (isLoading) {
|
||||
return <VisibilityBarSkeleton />;
|
||||
}
|
||||
|
||||
// Don't render if no data
|
||||
if (totalRuns === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const colors = getVisibilityColors(currentVisibility);
|
||||
const showChart = lookback !== "1w";
|
||||
|
||||
// Prepare chart data
|
||||
const chartData = visibilityTimeSeries.map(point => ({
|
||||
date: point.date,
|
||||
value: point.visibility ?? 0,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between sm:gap-3 min-h-10 px-3 py-2 rounded-lg border ${colors.bg} ${colors.border}`}>
|
||||
{/* Left side: visibility + chart + info */}
|
||||
<div className="flex items-center gap-2 min-w-0 shrink-0">
|
||||
<span className={`text-base sm:text-lg font-semibold whitespace-nowrap ${colors.text}`}>
|
||||
{currentVisibility}% <span className="font-normal">Visibility</span>
|
||||
</span>
|
||||
|
||||
{showChart && (
|
||||
<div className="w-24 h-6 hidden sm:block shrink-0">
|
||||
<ResponsiveContainer width="100%" height="100%" initialDimension={{ width: 1, height: 1 }}>
|
||||
<AreaChart data={chartData} margin={{ top: 2, right: 0, left: 0, bottom: 2 }}>
|
||||
<YAxis domain={[0, "auto"]} hide />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={colors.stroke}
|
||||
fill={colors.fill}
|
||||
fillOpacity={0.2}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
connectNulls
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconInfoCircle className={`h-3.5 w-3.5 shrink-0 ${colors.muted} cursor-help`} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs text-sm">
|
||||
AI visibility for the {totalPrompts.toLocaleString()} prompt{totalPrompts !== 1 ? 's' : ''} shown below, calculated as the percentage of AI responses that mention your brand over the time period for the selected filters.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Right side: stats */}
|
||||
<div className={`flex items-center gap-x-3 text-xs sm:text-sm ${colors.muted}`}>
|
||||
<span><span className="font-medium">{totalPrompts.toLocaleString()}</span> prompts</span>
|
||||
<span><span className="font-medium">{totalRuns.toLocaleString()}</span> runs</span>
|
||||
<span><span className="font-medium">{totalCitations.toLocaleString()}</span> citations</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VisibilityBarSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between sm:gap-3 min-h-10 px-3 py-2 rounded-lg border bg-muted/30">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-6 sm:h-7 w-36" />
|
||||
<Skeleton className="h-6 w-24 hidden sm:block" />
|
||||
</div>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<Skeleton className="h-4 sm:h-5 w-16" />
|
||||
<Skeleton className="h-4 sm:h-5 w-14" />
|
||||
<Skeleton className="h-4 sm:h-5 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VisibilityBarEmpty() {
|
||||
return (
|
||||
<div className="flex items-center min-h-10 px-3 py-2 rounded-lg border border-border/60 bg-muted/20">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No visibility data for the selected time range and filters.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
apps/web/src/components/web-logo.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { useState } from 'react';
|
||||
import { Globe } from 'lucide-react';
|
||||
|
||||
interface WebLogoProps {
|
||||
domain?: string | null;
|
||||
className?: string;
|
||||
size?: number; // Container size - favicon will use closest allowed size (16, 32, 40, 48, 64)
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
export function WebLogo({
|
||||
domain,
|
||||
className = '',
|
||||
size = 64,
|
||||
alt
|
||||
}: WebLogoProps) {
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Handle undefined or empty domain
|
||||
if (!domain || typeof domain !== 'string') {
|
||||
return (
|
||||
<div
|
||||
className={`relative flex shrink-0 items-center justify-center bg-muted border border-border overflow-hidden ${className}`}
|
||||
style={{
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
<Globe className="h-1/2 w-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Clean the domain to ensure it's in the right format
|
||||
const cleanDomain = domain.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0];
|
||||
|
||||
// Construct the full URL with https:// prefix (no www) as required by the service
|
||||
const fullUrl = `https://${cleanDomain}`;
|
||||
|
||||
// Ensure size is one of the allowed values (16, 32, 40, 48, 64)
|
||||
// If not, default to closest smaller size to prevent stretching
|
||||
const allowedSizes = [16, 32, 40, 48, 64];
|
||||
const actualSize = allowedSizes.find(s => s >= size) || 64;
|
||||
|
||||
// Construct the favicon URL using the correct format
|
||||
const faviconUrl = `https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(fullUrl)}&size=${actualSize}`;
|
||||
|
||||
const handleImageLoad = () => {
|
||||
setIsLoading(false);
|
||||
setHasError(false);
|
||||
};
|
||||
|
||||
const handleImageError = () => {
|
||||
setIsLoading(false);
|
||||
setHasError(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex shrink-0 items-center justify-center bg-muted border border-border overflow-hidden ${className}`}
|
||||
style={{
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
borderRadius: '8px', // Using slightly less round borders matching normal design
|
||||
}}
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-muted animate-pulse z-10">
|
||||
<Globe className="h-1/2 w-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasError ? (
|
||||
<div className="flex items-center justify-center w-full h-full bg-muted">
|
||||
<Globe className="h-1/2 w-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt={alt || `${cleanDomain} logo`}
|
||||
width={size}
|
||||
height={size}
|
||||
className={`object-cover w-full h-full ${isLoading ? 'opacity-0' : 'opacity-100 transition-opacity duration-200'}`}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
apps/web/src/components/word-cloud.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* Dependency-free term cloud. Sizes each term by sqrt(count) (so a 100× term
|
||||
* isn't 100× the height), colors it by frequency, and centers the biggest terms
|
||||
* so it reads like a cloud rather than a sorted list. Deterministic — no random
|
||||
* placement — so it's SSR-stable.
|
||||
*/
|
||||
import { cn } from "@workspace/ui/lib/utils";
|
||||
|
||||
export interface WordCloudTerm {
|
||||
term: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// Frequent → rare. Index 0 is for the biggest terms.
|
||||
const PALETTE = [
|
||||
"#7c3aed", // violet
|
||||
"#4f46e5", // indigo
|
||||
"#2563eb", // blue
|
||||
"#0891b2", // cyan
|
||||
"#0d9488", // teal
|
||||
"#059669", // emerald
|
||||
"#d97706", // amber
|
||||
];
|
||||
|
||||
const MIN_PX = 13;
|
||||
const MAX_PX = 40;
|
||||
|
||||
export function WordCloud({
|
||||
terms,
|
||||
maxItems = 48,
|
||||
className,
|
||||
}: {
|
||||
terms: WordCloudTerm[];
|
||||
maxItems?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
// Sort here rather than relying on the caller: slicing the top terms and the
|
||||
// center-weighted ordering below both assume descending counts.
|
||||
const items = [...terms].sort((a, b) => b.count - a.count).slice(0, maxItems);
|
||||
if (items.length === 0) {
|
||||
return <div className="text-muted-foreground py-6 text-center text-sm">No terms for this period.</div>;
|
||||
}
|
||||
|
||||
const counts = items.map((i) => i.count);
|
||||
const max = Math.max(...counts);
|
||||
const min = Math.min(...counts);
|
||||
const rootMax = Math.sqrt(max);
|
||||
const rootMin = Math.sqrt(min);
|
||||
const scale = (count: number) => (max === min ? 0.6 : (Math.sqrt(count) - rootMin) / (rootMax - rootMin)); // 0..1
|
||||
|
||||
// Center-weight: place the largest terms toward the middle so wrapping reads
|
||||
// like a cloud (big in the core, small at the edges) instead of top-left heavy.
|
||||
const ordered: WordCloudTerm[] = [];
|
||||
items.forEach((it, i) => {
|
||||
if (i % 2 === 0) ordered.push(it);
|
||||
else ordered.unshift(it);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-wrap items-center justify-center gap-x-3 gap-y-1 leading-tight", className)}>
|
||||
{ordered.map((it) => {
|
||||
const t = scale(it.count);
|
||||
const color = PALETTE[Math.min(PALETTE.length - 1, Math.round((1 - t) * (PALETTE.length - 1)))];
|
||||
return (
|
||||
<span
|
||||
key={it.term}
|
||||
title={`${it.term} · ${it.count.toLocaleString()}`}
|
||||
className="font-semibold"
|
||||
style={{ fontSize: Math.round(MIN_PX + t * (MAX_PX - MIN_PX)), color, opacity: 0.62 + t * 0.38 }}
|
||||
>
|
||||
{it.term}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
apps/web/src/contexts/chart-data-context.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import type { Brand, Competitor } from "@workspace/lib/db/schema";
|
||||
import { createContext, type ReactNode, useContext, useMemo } from "react";
|
||||
import { generateDateRange, runWeightedVisibility } from "@/lib/chart-utils";
|
||||
import type { ProcessedBatchChartDataPoint } from "@/lib/postgres-read";
|
||||
|
||||
// Chart data for a single prompt (pre-processed for rendering)
|
||||
export interface ProcessedChartData {
|
||||
chartData: Array<{
|
||||
date: string;
|
||||
[key: string]: number | string | null;
|
||||
}>;
|
||||
totalRuns: number;
|
||||
hasVisibilityData: boolean;
|
||||
/** Brand visibility on the most recent day that has data. */
|
||||
lastBrandVisibility: number | null;
|
||||
/** Brand visibility across the whole selected window (run-weighted). */
|
||||
avgBrandVisibility: number | null;
|
||||
}
|
||||
|
||||
// Context value
|
||||
interface ChartDataContextValue {
|
||||
batchData: ProcessedBatchChartDataPoint[] | null;
|
||||
brand: Brand | null;
|
||||
competitors: Competitor[];
|
||||
dateRange: string[];
|
||||
getChartDataForPrompt: (promptId: string) => ProcessedChartData | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const ChartDataContext = createContext<ChartDataContextValue | null>(null);
|
||||
|
||||
interface ChartDataProviderProps {
|
||||
children: ReactNode;
|
||||
batchData: ProcessedBatchChartDataPoint[] | null;
|
||||
brand: Brand | null;
|
||||
competitors: Competitor[];
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function ChartDataProvider({
|
||||
children,
|
||||
batchData,
|
||||
brand,
|
||||
competitors,
|
||||
startDate,
|
||||
endDate,
|
||||
isLoading,
|
||||
}: ChartDataProviderProps) {
|
||||
const dateRange = useMemo(() => {
|
||||
return generateDateRange(startDate, endDate);
|
||||
}, [startDate, endDate]);
|
||||
|
||||
const sortedCompetitors = useMemo(() => {
|
||||
return [...competitors].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [competitors]);
|
||||
|
||||
// Index batch data by prompt_id for O(1) lookup
|
||||
const dataByPrompt = useMemo(() => {
|
||||
if (!batchData) return new Map<string, ProcessedBatchChartDataPoint[]>();
|
||||
|
||||
const map = new Map<string, ProcessedBatchChartDataPoint[]>();
|
||||
for (const point of batchData) {
|
||||
const existing = map.get(point.prompt_id) || [];
|
||||
existing.push(point);
|
||||
map.set(point.prompt_id, existing);
|
||||
}
|
||||
return map;
|
||||
}, [batchData]);
|
||||
|
||||
const getChartDataForPrompt = useMemo(() => {
|
||||
return (promptId: string): ProcessedChartData | null => {
|
||||
if (!brand || !batchData) return null;
|
||||
|
||||
const promptData = dataByPrompt.get(promptId) || [];
|
||||
|
||||
const dailyStatsMap = new Map<string, ProcessedBatchChartDataPoint>();
|
||||
for (const stat of promptData) {
|
||||
dailyStatsMap.set(String(stat.date), stat);
|
||||
}
|
||||
|
||||
const chartData: Array<{ date: string; [key: string]: number | string | null }> = dateRange.map((date) => {
|
||||
const dayStat = dailyStatsMap.get(date);
|
||||
const totalRuns = dayStat ? Number(dayStat.total_runs) : 0;
|
||||
|
||||
const dataPoint: { date: string; [key: string]: number | string | null } = { date };
|
||||
|
||||
if (totalRuns === 0) {
|
||||
dataPoint[brand.id] = null;
|
||||
sortedCompetitors.forEach((competitor) => {
|
||||
dataPoint[competitor.id] = null;
|
||||
});
|
||||
return dataPoint;
|
||||
}
|
||||
|
||||
const brandMentions = dayStat ? Number(dayStat.brand_mentioned_count) : 0;
|
||||
const brandVisibility = Math.round((brandMentions / totalRuns) * 100);
|
||||
dataPoint[brand.id] = brandVisibility;
|
||||
|
||||
const competitorCounts = dayStat?.competitor_counts || {};
|
||||
sortedCompetitors.forEach((competitor) => {
|
||||
const competitorMentions = competitorCounts[competitor.name] || 0;
|
||||
const competitorVisibility = Math.round((competitorMentions / totalRuns) * 100);
|
||||
dataPoint[competitor.id] = competitorVisibility;
|
||||
});
|
||||
|
||||
return dataPoint;
|
||||
});
|
||||
|
||||
const totalRuns = promptData.reduce((sum, s) => sum + Number(s.total_runs), 0);
|
||||
const totalBrandMentions = promptData.reduce((sum, s) => sum + Number(s.brand_mentioned_count), 0);
|
||||
const avgBrandVisibility = runWeightedVisibility(totalBrandMentions, totalRuns);
|
||||
|
||||
const hasVisibilityData = chartData.some((dataPoint) => {
|
||||
const allIds = [brand.id, ...sortedCompetitors.map((c) => c.id)];
|
||||
return allIds.some((id) => {
|
||||
const visibility = dataPoint[id];
|
||||
return visibility !== null && visibility !== undefined && Number(visibility) > 0;
|
||||
});
|
||||
});
|
||||
|
||||
const lastDataPoint = chartData.filter((point) => point[brand.id] !== null).pop();
|
||||
const lastBrandVisibility = lastDataPoint ? (lastDataPoint[brand.id] as number) : null;
|
||||
|
||||
return {
|
||||
chartData,
|
||||
totalRuns,
|
||||
hasVisibilityData,
|
||||
lastBrandVisibility,
|
||||
avgBrandVisibility,
|
||||
};
|
||||
};
|
||||
}, [brand, batchData, dataByPrompt, dateRange, sortedCompetitors]);
|
||||
|
||||
const value: ChartDataContextValue = {
|
||||
batchData,
|
||||
brand,
|
||||
competitors: sortedCompetitors,
|
||||
dateRange,
|
||||
getChartDataForPrompt,
|
||||
isLoading,
|
||||
};
|
||||
|
||||
return <ChartDataContext.Provider value={value}>{children}</ChartDataContext.Provider>;
|
||||
}
|
||||
|
||||
export function useChartDataContext() {
|
||||
const context = useContext(ChartDataContext);
|
||||
if (!context) {
|
||||
throw new Error("useChartDataContext must be used within a ChartDataProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useOptionalChartDataContext() {
|
||||
return useContext(ChartDataContext);
|
||||
}
|
||||
85
apps/web/src/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
// Every var here must have an entry in packages/config/src/env-registry.ts
|
||||
// (enforced by env-registry.test.ts).
|
||||
|
||||
interface ImportMetaEnv {
|
||||
// Deployment mode
|
||||
readonly VITE_DEPLOYMENT_MODE: string;
|
||||
|
||||
// Branding (whitelabel only - local/demo use server-side defaults)
|
||||
readonly VITE_APP_NAME?: string;
|
||||
readonly VITE_APP_ICON?: string;
|
||||
readonly VITE_APP_URL?: string;
|
||||
readonly VITE_APP_TIMEZONE?: string;
|
||||
readonly VITE_APP_LOCALE?: string;
|
||||
readonly VITE_APP_PARENT_NAME?: string;
|
||||
readonly VITE_APP_PARENT_URL?: string;
|
||||
readonly VITE_OPTIMIZATION_URL_TEMPLATE?: string;
|
||||
readonly VITE_ONBOARDING_REDIRECT_URL_TEMPLATE?: string;
|
||||
readonly VITE_CHART_COLORS?: string;
|
||||
|
||||
// Auth0 (whitelabel)
|
||||
readonly VITE_AUTH0_DOMAIN?: string;
|
||||
readonly VITE_AUTH0_CLIENT_ID?: string;
|
||||
|
||||
// Analytics
|
||||
readonly VITE_PLAUSIBLE_DOMAIN?: string;
|
||||
readonly VITE_CLARITY_PROJECT_ID?: string;
|
||||
readonly VITE_POSTHOG_KEY?: string;
|
||||
|
||||
// Sentry
|
||||
readonly VITE_SENTRY_DSN?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
// Server-side environment variables (accessed via process.env in server functions)
|
||||
// and Vite define globals
|
||||
declare global {
|
||||
// App version injected by Vite define
|
||||
const __APP_VERSION__: string;
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
readonly DEPLOYMENT_MODE: string;
|
||||
readonly DATABASE_URL: string;
|
||||
readonly APP_URL?: string;
|
||||
readonly SCRAPE_TARGETS?: string;
|
||||
readonly OPENAI_API_KEY: string;
|
||||
readonly ANTHROPIC_API_KEY: string;
|
||||
readonly MISTRAL_API_KEY?: string;
|
||||
readonly AZURE_FOUNDRY_API_KEY?: string;
|
||||
readonly AZURE_FOUNDRY_BASE_URL?: string;
|
||||
readonly OPENROUTER_API_KEY?: string;
|
||||
readonly ONBOARDING_LLM_TARGET?: string;
|
||||
readonly OLOSTEP_API_KEY?: string;
|
||||
readonly BRIGHTDATA_API_TOKEN?: string;
|
||||
readonly OXYLABS_USERNAME?: string;
|
||||
readonly OXYLABS_PASSWORD?: string;
|
||||
readonly CLORO_API_KEY?: string;
|
||||
readonly DATAFORSEO_LOGIN: string;
|
||||
readonly DATAFORSEO_PASSWORD: string;
|
||||
readonly BETTER_AUTH_SECRET?: string;
|
||||
readonly AUTH0_DOMAIN?: string;
|
||||
readonly AUTH0_AUDIENCE?: string;
|
||||
readonly AUTH0_SCOPE?: string;
|
||||
readonly AUTH0_MGMT_API_DOMAIN?: string;
|
||||
readonly AUTH0_CLIENT_ID?: string;
|
||||
readonly AUTH0_CLIENT_SECRET?: string;
|
||||
readonly ADMIN_AUTH0_SUB?: string;
|
||||
readonly ADMIN_API_KEYS?: string;
|
||||
readonly DEFAULT_BRAND_DOMAINS?: string;
|
||||
readonly ENVIRONMENT?: string;
|
||||
readonly DBOS_SYSTEM_DATABASE_URL?: string;
|
||||
readonly SENTRY_DSN?: string;
|
||||
readonly SENTRY_ORG?: string;
|
||||
readonly SENTRY_PROJECT?: string;
|
||||
readonly SENTRY_AUTH_TOKEN?: string;
|
||||
readonly DISABLE_TELEMETRY?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
50
apps/web/src/hooks/use-auth.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Auth hook for TanStack Start.
|
||||
*
|
||||
* Reads the session from route context (set by _authed layout).
|
||||
*/
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
picture?: string;
|
||||
given_name?: string;
|
||||
family_name?: string;
|
||||
}
|
||||
|
||||
export interface UseAuthResult {
|
||||
user: AuthUser | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
loginUrl: string;
|
||||
logoutUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user across different auth providers.
|
||||
* Session is loaded server-side in _authed layout's beforeLoad.
|
||||
*/
|
||||
export function useAuth(): UseAuthResult {
|
||||
const context = useRouteContext({ strict: false }) as {
|
||||
session?: {
|
||||
user: { id: string; name?: string; email?: string; image?: string | null };
|
||||
} | null;
|
||||
};
|
||||
const session = context.session;
|
||||
return {
|
||||
user: session?.user
|
||||
? {
|
||||
id: session.user.id,
|
||||
name: session.user.name,
|
||||
email: session.user.email,
|
||||
picture: session.user.image ?? undefined,
|
||||
}
|
||||
: null,
|
||||
isLoading: false,
|
||||
isAuthenticated: !!session?.user,
|
||||
loginUrl: "/auth/login",
|
||||
logoutUrl: "/auth/logout",
|
||||
};
|
||||
}
|
||||
57
apps/web/src/hooks/use-batch-chart-data.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "@tanstack/react-router";
|
||||
import { APP_TIMEZONE } from "@/lib/app-locale";
|
||||
import { type BatchChartDataResponse, getBatchChartDataFn } from "@/server/visibility";
|
||||
|
||||
export type { LookbackPeriod } from "@/lib/chart-utils";
|
||||
|
||||
import type { LookbackPeriod } from "@/lib/chart-utils";
|
||||
|
||||
export interface BatchChartDataFilters {
|
||||
lookback?: LookbackPeriod;
|
||||
model?: string;
|
||||
/** Tag filter (resolved to prompt IDs server-side). */
|
||||
tags?: string[];
|
||||
/** Search term applied to prompt text (resolved server-side). */
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export function useBatchChartData(brandId?: string, filters?: BatchChartDataFilters) {
|
||||
const params = useParams({ strict: false }) as { brand?: string };
|
||||
const resolvedBrandId = brandId || params.brand;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [
|
||||
"batch-chart-data",
|
||||
resolvedBrandId,
|
||||
filters?.lookback,
|
||||
filters?.model,
|
||||
filters?.tags?.join(","),
|
||||
filters?.search,
|
||||
],
|
||||
queryFn: () =>
|
||||
getBatchChartDataFn({
|
||||
data: {
|
||||
brandId: resolvedBrandId!,
|
||||
lookback: filters?.lookback || "1m",
|
||||
model: filters?.model,
|
||||
tags: filters?.tags?.join(","),
|
||||
search: filters?.search,
|
||||
timezone: APP_TIMEZONE,
|
||||
},
|
||||
}),
|
||||
enabled: !!resolvedBrandId,
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
placeholderData: (prev) => prev, // Keep previous data while loading
|
||||
});
|
||||
|
||||
return {
|
||||
batchChartData: query.data,
|
||||
isLoading: query.isLoading,
|
||||
isValidating: query.isFetching,
|
||||
isError: query.error,
|
||||
revalidate: query.refetch,
|
||||
};
|
||||
}
|
||||
122
apps/web/src/hooks/use-brands.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams } from "@tanstack/react-router";
|
||||
import type { BrandWithPrompts, Competitor } from "@workspace/lib/db/schema";
|
||||
import type { ModelConfig } from "@workspace/lib/providers/types";
|
||||
import { getBrands, getBrand, getCompetitors } from "@/server/brands";
|
||||
|
||||
export type BrandWithPromptsAndDataInfo = BrandWithPrompts & {
|
||||
earliestDataDate?: string | null;
|
||||
/** Deployment-configured model ids this brand actually runs, after
|
||||
* `brand.enabledModels` is applied. Comes from the server so the UI
|
||||
* doesn't have to hardcode a model list. */
|
||||
effectiveModels: string[];
|
||||
/** Same as `effectiveModels` but with provider / version / webSearch
|
||||
* metadata, for pages that render per-model details. */
|
||||
effectiveModelConfigs: ModelConfig[];
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Query keys
|
||||
// ============================================================================
|
||||
|
||||
export const brandKeys = {
|
||||
all: ["brands"] as const,
|
||||
list: () => [...brandKeys.all, "list"] as const,
|
||||
detail: (brandId: string) => [...brandKeys.all, "detail", brandId] as const,
|
||||
competitors: (brandId: string) => [...brandKeys.all, "competitors", brandId] as const,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Hooks
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all brands the user has access to
|
||||
*/
|
||||
export function useBrands() {
|
||||
const query = useQuery({
|
||||
queryKey: brandKeys.list(),
|
||||
queryFn: () => getBrands(),
|
||||
staleTime: 30_000, // 30 seconds
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
});
|
||||
|
||||
return {
|
||||
brands: query.data,
|
||||
isLoading: query.isLoading,
|
||||
isError: query.error,
|
||||
revalidate: query.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single brand by ID.
|
||||
* If no brandId provided, extracts from route params.
|
||||
*/
|
||||
export function useBrand(brandId?: string) {
|
||||
// Try to get brandId from route params if not provided
|
||||
const params = useParams({ strict: false }) as { brand?: string };
|
||||
const resolvedBrandId = brandId || params.brand;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: brandKeys.detail(resolvedBrandId || ""),
|
||||
queryFn: () => getBrand({ data: { brandId: resolvedBrandId! } }),
|
||||
enabled: !!resolvedBrandId,
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
});
|
||||
|
||||
const revalidate = async () => {
|
||||
await query.refetch();
|
||||
// Also invalidate the brands list
|
||||
queryClient.invalidateQueries({ queryKey: brandKeys.list() });
|
||||
};
|
||||
|
||||
return {
|
||||
brandId: resolvedBrandId,
|
||||
brand: query.data as BrandWithPromptsAndDataInfo | undefined,
|
||||
isLoading: query.isLoading,
|
||||
isError: query.error,
|
||||
revalidate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get competitors for a brand
|
||||
*/
|
||||
export function useCompetitors(brandId?: string) {
|
||||
const params = useParams({ strict: false }) as { brand?: string };
|
||||
const resolvedBrandId = brandId || params.brand;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: brandKeys.competitors(resolvedBrandId || ""),
|
||||
queryFn: () => getCompetitors({ data: { brandId: resolvedBrandId! } }),
|
||||
enabled: !!resolvedBrandId,
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
});
|
||||
|
||||
return {
|
||||
competitors: query.data || [],
|
||||
isLoading: query.isLoading,
|
||||
isError: query.error,
|
||||
revalidate: query.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility for invalidating all brand-related queries
|
||||
*/
|
||||
export function useBrandsRevalidation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const revalidateAll = () => {
|
||||
queryClient.invalidateQueries({ queryKey: brandKeys.all });
|
||||
};
|
||||
|
||||
return { revalidateAll };
|
||||
}
|
||||