From a55382aeda1e4a0c95fbf23d4b48de4808b19fbc Mon Sep 17 00:00:00 2001 From: vickytechkey Date: Mon, 19 Oct 2026 20:12:59 +0530 Subject: [PATCH] Initial commit for GetCito --- .changeset/README.md | 20 + .changeset/config.json | 11 + .cursorignore | 2 + .dockerignore | 99 + .env.example | 29 + .env.local | 174 + .gitignore | 99 + .nvmrc | 1 + AGENTS.md | 68 + CLA.md | 36 + CODE_OF_CONDUCT.md | 35 + CONTRIBUTING.md | 156 + LICENSE.md | 27 + README.md | 648 + SECURITY.md | 39 + apps/web/.env.example | 25 + apps/web/.env.local | 174 + apps/web/.storybook/main.ts | 65 + apps/web/.storybook/preview.tsx | 15 + apps/web/.storybook/vite.config.ts | 15 + apps/web/CHANGELOG.md | 271 + apps/web/biome.json | 8 + apps/web/instrument.server.mjs | 10 + apps/web/package.json | 85 + apps/web/public/apple-touch-icon.png | Bin 0 -> 14255 bytes apps/web/public/favicon.ico | Bin 0 -> 1574 bytes apps/web/public/icons/apple-touch-icon.png | Bin 0 -> 14255 bytes apps/web/public/icons/favicon.ico | Bin 0 -> 1574 bytes apps/web/public/icons/getcito-icon-192.png | Bin 0 -> 15833 bytes apps/web/public/icons/getcito-icon-512.png | Bin 0 -> 67186 bytes apps/web/public/icons/getcito-icon-96.png | Bin 0 -> 15833 bytes .../icons/getcito-icon-maskable-192.png | Bin 0 -> 15833 bytes .../icons/getcito-icon-maskable-512.png | Bin 0 -> 67186 bytes apps/web/public/robots.txt | 3 + .../scripts/benchmark-postgres-analytics.ts | 201 + apps/web/scripts/generate-brand-icons.tsx | 201 + apps/web/scripts/generate-brand-kit.tsx | 435 + apps/web/scripts/smoke-deployment-mode.ts | 183 + apps/web/src/components/app-sidebar.tsx | 216 + apps/web/src/components/base-chart-print.tsx | 172 + apps/web/src/components/base-chart.tsx | 354 + apps/web/src/components/brand-onboarding.tsx | 70 + .../src/components/brand-priority-button.tsx | 75 + .../src/components/cached-prompt-chart.tsx | 293 + .../src/components/chart-actions-footer.tsx | 103 + .../src/components/chart-download-footer.tsx | 30 + .../src/components/chart-export-preview.tsx | 123 + apps/web/src/components/chart-footer.tsx | 20 + apps/web/src/components/citations-display.tsx | 183 + .../citations/content-gaps-card.tsx | 65 + .../citations/google-shopping-card.tsx | 175 + .../citations/recent-changes-card.tsx | 137 + .../src/components/citations/reddit-card.tsx | 164 + apps/web/src/components/citations/shared.tsx | 94 + .../src/components/citations/stats-cards.tsx | 67 + .../components/citations/top-domains-card.tsx | 126 + .../components/citations/top-urls-card.tsx | 184 + .../citations/track-domain-popover.tsx | 182 + .../components/citations/trend-area-chart.tsx | 96 + apps/web/src/components/citations/types.ts | 57 + apps/web/src/components/col-head.tsx | 19 + .../web/src/components/competitors-editor.tsx | 200 + .../src/components/custom-range-calendar.tsx | 85 + apps/web/src/components/demo-mode-pill.tsx | 27 + apps/web/src/components/fanout-sections.tsx | 213 + apps/web/src/components/filter-bar.tsx | 463 + .../src/components/filtered-list-shell.tsx | 117 + apps/web/src/components/full-page-card.tsx | 63 + apps/web/src/components/history-button.tsx | 32 + apps/web/src/components/list-pagination.tsx | 63 + apps/web/src/components/logo.tsx | 64 + apps/web/src/components/lookback-selector.tsx | 98 + apps/web/src/components/missing-env-page.tsx | 45 + apps/web/src/components/nav-app-info.test.tsx | 18 + apps/web/src/components/nav-app-info.tsx | 43 + apps/web/src/components/nav-main.tsx | 72 + apps/web/src/components/nav-user.tsx | 128 + .../src/components/opportunities-report.tsx | 181 + apps/web/src/components/page-header.tsx | 51 + .../web/src/components/progress-bar-chart.tsx | 220 + .../web/src/components/prompt-chart-print.tsx | 218 + .../src/components/prompt-order-dropdown.tsx | 68 + apps/web/src/components/prompt-wizard.tsx | 545 + apps/web/src/components/prompts-display.tsx | 254 + apps/web/src/components/prompts-editor.tsx | 136 + .../src/components/prompts-list-editor.tsx | 393 + .../src/components/share-of-voice-donut.tsx | 60 + apps/web/src/components/site-header.tsx | 205 + apps/web/src/components/text-highlighter.tsx | 43 + apps/web/src/components/trend-chart.tsx | 88 + .../components/virtualized-prompt-list.tsx | 122 + .../src/components/visibility-bar-section.tsx | 70 + apps/web/src/components/visibility-bar.tsx | 153 + apps/web/src/components/web-logo.tsx | 92 + apps/web/src/components/word-cloud.tsx | 77 + apps/web/src/contexts/chart-data-context.tsx | 158 + apps/web/src/env.d.ts | 85 + apps/web/src/hooks/use-auth.tsx | 50 + apps/web/src/hooks/use-batch-chart-data.tsx | 57 + apps/web/src/hooks/use-brands.tsx | 122 + apps/web/src/hooks/use-chart-download.tsx | 35 + apps/web/src/hooks/use-chart-export.tsx | 87 + apps/web/src/hooks/use-citations.tsx | 48 + apps/web/src/hooks/use-dashboard-summary.tsx | 35 + .../web/src/hooks/use-filtered-visibility.tsx | 58 + apps/web/src/hooks/use-list-filters.ts | 103 + apps/web/src/hooks/use-opportunities.tsx | 39 + apps/web/src/hooks/use-prompt-chart-data.tsx | 68 + apps/web/src/hooks/use-prompt-runs-only.tsx | 50 + apps/web/src/hooks/use-prompt-stats.tsx | 33 + apps/web/src/hooks/use-prompts-summary.tsx | 66 + apps/web/src/hooks/use-query-fanout.tsx | 50 + apps/web/src/hooks/use-share-of-voice.tsx | 47 + .../integrations/tanstack-query/devtools.tsx | 6 + .../tanstack-query/root-provider.tsx | 26 + apps/web/src/lib/__tests__/app-locale.test.ts | 28 + .../src/lib/__tests__/brand-settings.test.ts | 102 + .../src/lib/__tests__/brand-website.test.ts | 71 + .../web/src/lib/__tests__/chart-utils.test.ts | 183 + .../lib/__tests__/domain-categories.test.ts | 248 + .../src/lib/__tests__/fanout-analysis.test.ts | 221 + .../src/lib/__tests__/model-filter.test.ts | 33 + .../web/src/lib/__tests__/queue-order.test.ts | 35 + .../lib/__tests__/visibility-stats.test.ts | 210 + apps/web/src/lib/analyze-brand-job.ts | 131 + .../web/src/lib/api/__tests__/handler.test.ts | 215 + apps/web/src/lib/api/handler.ts | 107 + apps/web/src/lib/app-locale.ts | 31 + .../src/lib/auth/__tests__/policies.test.ts | 613 + apps/web/src/lib/auth/helpers.ts | 103 + apps/web/src/lib/auth/policies.ts | 275 + apps/web/src/lib/auth/server.ts | 57 + apps/web/src/lib/auth/session.ts | 25 + apps/web/src/lib/boss-client.ts | 67 + apps/web/src/lib/brand-settings.ts | 69 + apps/web/src/lib/brand-website.ts | 31 + apps/web/src/lib/chart-utils.ts | 683 + apps/web/src/lib/config/server.ts | 14 + apps/web/src/lib/domain-categories.server.ts | 281 + apps/web/src/lib/domain-categories.ts | 438 + apps/web/src/lib/editorial-domains.ts | 25329 ++++++++++++++++ apps/web/src/lib/fanout-analysis.ts | 418 + apps/web/src/lib/format-duration.ts | 53 + apps/web/src/lib/google-module.ts | 134 + apps/web/src/lib/job-scheduler.ts | 331 + apps/web/src/lib/model-filter.ts | 20 + apps/web/src/lib/postgres-read.ts | 1350 + apps/web/src/lib/posthog.ts | 13 + apps/web/src/lib/prompt-order.ts | 76 + apps/web/src/lib/prompt-tags.ts | 16 + apps/web/src/lib/queue-order.ts | 23 + apps/web/src/lib/route-head.ts | 112 + apps/web/src/lib/share-of-voice-palette.ts | 35 + apps/web/src/lib/timezone-utils.ts | 119 + apps/web/src/lib/utils.ts | 13 + apps/web/src/lib/visibility-stats.ts | 293 + apps/web/src/middleware/auth.ts | 67 + apps/web/src/middleware/deployment.ts | 104 + apps/web/src/routeTree.gen.ts | 1164 + apps/web/src/router-default-components.tsx | 41 + apps/web/src/router.tsx | 42 + apps/web/src/routes/__root.tsx | 188 + apps/web/src/routes/_authed.tsx | 51 + apps/web/src/routes/_authed/admin.tsx | 56 + apps/web/src/routes/_authed/admin/index.tsx | 626 + apps/web/src/routes/_authed/admin/queue.tsx | 391 + apps/web/src/routes/_authed/admin/tools.tsx | 273 + apps/web/src/routes/_authed/admin/usage.tsx | 335 + .../src/routes/_authed/admin/workflows.tsx | 774 + apps/web/src/routes/_authed/app.tsx | 10 + apps/web/src/routes/_authed/app/$brand.tsx | 184 + apps/web/src/routes/_authed/app/$brand/$.tsx | 27 + .../routes/_authed/app/$brand/citations.tsx | 148 + .../src/routes/_authed/app/$brand/index.tsx | 483 + .../_authed/app/$brand/opportunities.tsx | 94 + .../_authed/app/$brand/prompts/$promptId.tsx | 675 + .../_authed/app/$brand/prompts/edit.tsx | 10 + .../_authed/app/$brand/prompts/index.tsx | 10 + .../_authed/app/$brand/query-fan-out.tsx | 516 + .../_authed/app/$brand/settings/brand.tsx | 358 + .../app/$brand/settings/competitors.tsx | 147 + .../_authed/app/$brand/settings/index.tsx | 10 + .../_authed/app/$brand/settings/llms.tsx | 129 + .../_authed/app/$brand/settings/prompts.tsx | 89 + .../_authed/app/$brand/share-of-voice.tsx | 233 + .../routes/_authed/app/$brand/visibility.tsx | 55 + apps/web/src/routes/_authed/app/index.tsx | 104 + apps/web/src/routes/_authed/app/new.tsx | 167 + apps/web/src/routes/_authed/reports.tsx | 11 + apps/web/src/routes/_authed/reports/index.tsx | 490 + .../_authed/reports/render/$reportId.tsx | 754 + apps/web/src/routes/api/auth/$.ts | 15 + apps/web/src/routes/api/manifest/index.ts | 109 + apps/web/src/routes/api/og/index.ts | 147 + .../src/routes/api/plausible/event/index.ts | 57 + .../routes/api/plausible/js/script/index.ts | 74 + apps/web/src/routes/api/setup-status/index.ts | 23 + apps/web/src/routes/api/v1/brands/$brandId.ts | 59 + apps/web/src/routes/api/v1/brands/index.ts | 69 + .../api/v1/competitors/$competitorId.ts | 92 + .../src/routes/api/v1/competitors/index.ts | 103 + .../src/routes/api/v1/prompts/$promptId.ts | 146 + .../api/v1/prompts/$promptId/snapshot.ts | 159 + apps/web/src/routes/api/v1/prompts/index.ts | 88 + .../src/routes/api/v1/reports/$reportId.ts | 115 + apps/web/src/routes/api/v1/reports/index.ts | 113 + apps/web/src/routes/api/v1/tools/analyze.ts | 44 + apps/web/src/routes/auth/login.tsx | 214 + apps/web/src/routes/auth/logout.ts | 32 + apps/web/src/routes/auth/register.tsx | 132 + apps/web/src/routes/index.tsx | 41 + apps/web/src/server.ts | 53 + apps/web/src/server/admin.ts | 1237 + apps/web/src/server/analysis.ts | 145 + apps/web/src/server/brand-cascade.ts | 52 + apps/web/src/server/brands.ts | 489 + apps/web/src/server/citations.ts | 435 + apps/web/src/server/config.ts | 64 + apps/web/src/server/dashboard.ts | 171 + apps/web/src/server/onboarding-core.ts | 435 + apps/web/src/server/onboarding.ts | 154 + apps/web/src/server/opportunities.ts | 510 + apps/web/src/server/prompt-resolution.ts | 84 + apps/web/src/server/prompts.ts | 775 + apps/web/src/server/query-fanout.ts | 95 + apps/web/src/server/reports.ts | 157 + apps/web/src/server/visibility.ts | 249 + apps/web/src/start.ts | 26 + .../src/stories/_mocks/chart-data-context.tsx | 47 + apps/web/src/stories/_mocks/config-client.ts | 82 + .../web/src/stories/_mocks/server-analysis.ts | 20 + apps/web/src/stories/_mocks/server-brands.ts | 38 + .../src/stories/_mocks/server-citations.ts | 6 + .../src/stories/_mocks/server-dashboard.ts | 27 + .../src/stories/_mocks/server-onboarding.ts | 68 + .../stories/_mocks/server-opportunities.ts | 19 + apps/web/src/stories/_mocks/server-prompts.ts | 15 + .../src/stories/_mocks/tanstack-router.tsx | 170 + apps/web/src/stories/_mocks/tanstack-start.ts | 42 + apps/web/src/stories/_mocks/use-auth.tsx | 25 + apps/web/src/stories/_mocks/use-brands.tsx | 62 + .../src/stories/_mocks/use-chart-download.tsx | 12 + .../src/stories/_mocks/use-chart-export.tsx | 11 + apps/web/src/stories/analytics-fixtures.ts | 170 + apps/web/src/stories/app-sidebar.stories.tsx | 259 + apps/web/src/stories/brand-kit.stories.tsx | 282 + .../stories/chart-export-preview.stories.tsx | 171 + apps/web/src/stories/login.stories.tsx | 21 + .../web/src/stories/opportunities.stories.tsx | 65 + apps/web/src/stories/overview.stories.tsx | 68 + apps/web/src/stories/prompt-chart.stories.tsx | 282 + .../web/src/stories/prompt-wizard.stories.tsx | 186 + .../src/stories/share-of-voice.stories.tsx | 64 + apps/web/src/stories/tags-input.stories.tsx | 124 + apps/web/src/styles.css | 179 + apps/web/src/test/mocks/auth.ts | 79 + apps/web/src/virtual-modules.d.ts | 1 + apps/web/tsconfig.json | 28 + apps/web/vite.config.ts | 80 + apps/web/vitest.config.ts | 43 + apps/worker/.env.local | 174 + apps/worker/CHANGELOG.md | 149 + apps/worker/package.json | 28 + apps/worker/scripts/test-provider.ts | 407 + apps/worker/src/boss.ts | 18 + apps/worker/src/handlers.ts | 78 + apps/worker/src/index.ts | 139 + apps/worker/src/jobs/analyze-brand.ts | 33 + apps/worker/src/jobs/generate-report.ts | 46 + apps/worker/src/jobs/process-prompt.ts | 371 + apps/worker/src/jobs/schedule-maintenance.ts | 265 + .../worker/src/jobs/sync-auth0-memberships.ts | 65 + apps/worker/src/report-worker.ts | 574 + apps/worker/src/telemetry.ts | 7 + apps/worker/tsconfig.json | 25 + biome.json | 36 + brand/architecture.svg | 102 + brand/icons/getcito-icon-512.png | Bin 0 -> 67186 bytes brand/icons/getcito-icon-dark-512.png | Bin 0 -> 67186 bytes brand/icons/getcito-icon-white-512.png | Bin 0 -> 67186 bytes brand/logos/getcito-logo-dark-xl.png | Bin 0 -> 13363 bytes brand/logos/getcito-logo-white-xl.png | Bin 0 -> 13363 bytes brand/logos/getcito-logo-xl.png | Bin 0 -> 19833 bytes docker-compose.yml | 87 + docker/Dockerfile | 131 + docker/entrypoint.sh | 5 + e2e/CHANGELOG.md | 35 + e2e/auth-setup.ts | 96 + e2e/cli-driver.ts | 270 + e2e/package.json | 17 + e2e/playwright.config.ts | 37 + e2e/run-local.sh | 39 + e2e/seed.ts | 373 + e2e/tests/api-docs.spec.ts | 18 + e2e/tests/api.spec.ts | 250 + e2e/tests/citations.spec.ts | 56 + e2e/tests/overview.spec.ts | 88 + e2e/tests/prompt-details.spec.ts | 82 + e2e/tests/visibility.spec.ts | 48 + e2e/tsconfig.json | 15 + knip.json | 29 + package.json | 36 + packages/api-spec/CHANGELOG.md | 51 + packages/api-spec/package.json | 10 + packages/api-spec/src/openapi.json | 1651 + packages/config/CHANGELOG.md | 54 + packages/config/package.json | 20 + packages/config/src/constants.ts | 54 + packages/config/src/env-registry.test.ts | 64 + packages/config/src/env-registry.ts | 368 + packages/config/src/env.ts | 185 + packages/config/src/scrape-targets.test.ts | 55 + packages/config/src/scrape-targets.ts | 57 + packages/config/src/types.ts | 156 + packages/config/tsconfig.json | 22 + packages/deployment/CHANGELOG.md | 139 + packages/deployment/package.json | 20 + packages/deployment/src/client.ts | 37 + packages/deployment/src/deployment.ts | 52 + packages/deployment/src/index.ts | 10 + packages/deployment/tsconfig.json | 20 + packages/lib/CHANGELOG.md | 80 + packages/lib/drizzle.config.ts | 9 + packages/lib/package.json | 56 + packages/lib/scripts/compare-onboarding.ts | 415 + packages/lib/scripts/generate-auth-schema.sh | 56 + packages/lib/src/auth/client.ts | 28 + packages/lib/src/auth/permissions.ts | 26 + packages/lib/src/auth/server.ts | 119 + packages/lib/src/brightdata-locations.ts | 259 + packages/lib/src/constants.ts | 80 + packages/lib/src/dataforseo.ts | 24 + packages/lib/src/db/auth-sync.test.ts | 49 + packages/lib/src/db/auth-sync.ts | 172 + packages/lib/src/db/db.ts | 15 + .../src/db/migrations/0000_hot_excalibur.sql | 72 + .../src/db/migrations/0001_sturdy_ares.sql | 111 + .../db/migrations/0002_legal_ultimatum.sql | 21 + .../0003_phase3-constraints-and-indices.sql | 10 + .../migrations/0004_nebulous_gunslinger.sql | 1 + .../src/db/migrations/0005_known_warhawk.sql | 4 + .../migrations/0006_multi_domain_aliases.sql | 11 + .../db/migrations/0007_report_progress.sql | 2 + .../db/migrations/0008_provider_engines.sql | 62 + .../migrations/0009_brand_opportunities.sql | 11 + .../db/migrations/0010_melodic_tombstone.sql | 5 + .../migrations/0011_overrated_hammerhead.sql | 15 + .../db/migrations/0012_neat_sally_floyd.sql | 2 + .../db/migrations/0013_fluffy_mockingbird.sql | 2 + .../src/db/migrations/meta/0000_snapshot.json | 547 + .../src/db/migrations/meta/0001_snapshot.json | 1325 + .../src/db/migrations/meta/0002_snapshot.json | 1511 + .../src/db/migrations/meta/0003_snapshot.json | 1511 + .../src/db/migrations/meta/0004_snapshot.json | 1518 + .../src/db/migrations/meta/0005_snapshot.json | 1547 + .../src/db/migrations/meta/0006_snapshot.json | 1569 + .../src/db/migrations/meta/0007_snapshot.json | 1576 + .../src/db/migrations/meta/0008_snapshot.json | 1613 + .../src/db/migrations/meta/0009_snapshot.json | 1694 ++ .../src/db/migrations/meta/0010_snapshot.json | 1726 ++ .../src/db/migrations/meta/0011_snapshot.json | 1844 ++ .../src/db/migrations/meta/0012_snapshot.json | 1874 ++ .../src/db/migrations/meta/0013_snapshot.json | 1901 ++ .../lib/src/db/migrations/meta/_journal.json | 104 + packages/lib/src/db/provisioning.test.ts | 27 + packages/lib/src/db/provisioning.ts | 149 + packages/lib/src/db/schema-auth.ts | 221 + packages/lib/src/db/schema.ts | 274 + packages/lib/src/expedite.test.ts | 26 + packages/lib/src/expedite.ts | 47 + packages/lib/src/languages.ts | 47 + packages/lib/src/location-languages.ts | 648 + packages/lib/src/locations.test.ts | 28 + packages/lib/src/locations.ts | 98 + packages/lib/src/onboarding/analyze.test.ts | 131 + packages/lib/src/onboarding/analyze.ts | 551 + packages/lib/src/onboarding/index.ts | 16 + packages/lib/src/onboarding/llm.test.ts | 131 + packages/lib/src/onboarding/llm.ts | 152 + packages/lib/src/onboarding/utils.test.ts | 64 + packages/lib/src/onboarding/utils.ts | 73 + .../lib/src/providers/concurrency.test.ts | 56 + packages/lib/src/providers/concurrency.ts | 39 + packages/lib/src/providers/config.test.ts | 291 + packages/lib/src/providers/config.ts | 24 + packages/lib/src/providers/index.ts | 54 + packages/lib/src/providers/locale.test.ts | 45 + packages/lib/src/providers/locale.ts | 51 + packages/lib/src/providers/models.test.ts | 8 + packages/lib/src/providers/models.ts | 32 + .../src/providers/registry/anthropic-api.ts | 230 + .../registry/azure-foundry-api.test.ts | 45 + .../providers/registry/azure-foundry-api.ts | 204 + .../lib/src/providers/registry/brightdata.ts | 301 + .../lib/src/providers/registry/cloro.test.ts | 401 + packages/lib/src/providers/registry/cloro.ts | 239 + .../src/providers/registry/dataforseo.test.ts | 150 + .../lib/src/providers/registry/dataforseo.ts | 279 + .../lib/src/providers/registry/mistral-api.ts | 166 + .../src/providers/registry/olostep.test.ts | 72 + .../lib/src/providers/registry/olostep.ts | 420 + .../lib/src/providers/registry/openai-api.ts | 100 + .../lib/src/providers/registry/openrouter.ts | 174 + .../lib/src/providers/registry/oxylabs.ts | 121 + packages/lib/src/providers/runner.test.ts | 49 + packages/lib/src/providers/runner.ts | 29 + packages/lib/src/providers/types.ts | 66 + packages/lib/src/providers/usage.ts | 75 + packages/lib/src/report-metrics.test.ts | 657 + packages/lib/src/report-metrics.ts | 535 + packages/lib/src/run-backoff.test.ts | 23 + packages/lib/src/run-backoff.ts | 32 + packages/lib/src/tag-utils.test.ts | 257 + packages/lib/src/tag-utils.ts | 121 + packages/lib/src/text-extraction.test.ts | 616 + packages/lib/src/text-extraction.ts | 684 + packages/lib/src/website-excerpt.ts | 35 + packages/lib/tsconfig.json | 9 + packages/local/CHANGELOG.md | 115 + packages/local/package.json | 21 + packages/local/src/auth-provider.ts | 33 + .../local/src/components/optimize-button.tsx | 13 + packages/local/src/index.ts | 9 + packages/local/tsconfig.json | 20 + packages/og/CHANGELOG.md | 99 + packages/og/package.json | 22 + packages/og/src/render.ts | 143 + packages/og/src/virtual.d.ts | 19 + packages/og/src/vite-plugin.ts | 32 + packages/ui/CHANGELOG.md | 49 + packages/ui/components.json | 20 + packages/ui/package.json | 47 + packages/ui/src/components/alert.tsx | 66 + packages/ui/src/components/avatar.tsx | 53 + packages/ui/src/components/badge.tsx | 46 + packages/ui/src/components/breadcrumb.tsx | 109 + packages/ui/src/components/button.tsx | 59 + packages/ui/src/components/card.tsx | 92 + packages/ui/src/components/chart.tsx | 362 + packages/ui/src/components/checkbox.tsx | 32 + packages/ui/src/components/collapsible.tsx | 33 + packages/ui/src/components/command.tsx | 184 + packages/ui/src/components/dialog.tsx | 143 + packages/ui/src/components/dropdown-menu.tsx | 257 + packages/ui/src/components/form.tsx | 167 + packages/ui/src/components/input.tsx | 21 + packages/ui/src/components/label.tsx | 24 + .../ui/src/components/navigation-menu.tsx | 168 + packages/ui/src/components/popover.tsx | 48 + packages/ui/src/components/progress.tsx | 31 + packages/ui/src/components/select.tsx | 185 + packages/ui/src/components/separator.tsx | 28 + packages/ui/src/components/sheet.tsx | 139 + packages/ui/src/components/sidebar.tsx | 726 + packages/ui/src/components/skeleton.tsx | 13 + packages/ui/src/components/switch.tsx | 31 + packages/ui/src/components/table.tsx | 116 + packages/ui/src/components/tabs.tsx | 66 + packages/ui/src/components/tags-input.tsx | 265 + packages/ui/src/components/textarea.tsx | 18 + packages/ui/src/components/tooltip.tsx | 61 + packages/ui/src/hooks/use-mobile.ts | 19 + packages/ui/src/lib/utils.ts | 6 + packages/ui/src/styles/globals.css | 128 + packages/ui/tsconfig.json | 22 + packages/whitelabel/CHANGELOG.md | 155 + packages/whitelabel/package.json | 28 + packages/whitelabel/src/auth-hooks.ts | 193 + .../src/components/optimize-button.tsx | 167 + packages/whitelabel/src/deployment.ts | 55 + packages/whitelabel/src/index.ts | 18 + packages/whitelabel/tsconfig.json | 20 + pnpm-lock.yaml | 11982 ++++++++ pnpm-workspace.yaml | 60 + scripts/check-licenses.mjs | 153 + scripts/generate-release-notes.mjs | 184 + scripts/gh-release.mjs | 90 + scripts/sync-root-version.mjs | 45 + tsconfig.json | 33 + turbo.json | 80 + 480 files changed, 121028 insertions(+) create mode 100644 .changeset/README.md create mode 100644 .changeset/config.json create mode 100644 .cursorignore create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .env.local create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 AGENTS.md create mode 100644 CLA.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 apps/web/.env.example create mode 100644 apps/web/.env.local create mode 100644 apps/web/.storybook/main.ts create mode 100644 apps/web/.storybook/preview.tsx create mode 100644 apps/web/.storybook/vite.config.ts create mode 100644 apps/web/CHANGELOG.md create mode 100644 apps/web/biome.json create mode 100644 apps/web/instrument.server.mjs create mode 100644 apps/web/package.json create mode 100644 apps/web/public/apple-touch-icon.png create mode 100644 apps/web/public/favicon.ico create mode 100644 apps/web/public/icons/apple-touch-icon.png create mode 100644 apps/web/public/icons/favicon.ico create mode 100644 apps/web/public/icons/getcito-icon-192.png create mode 100644 apps/web/public/icons/getcito-icon-512.png create mode 100644 apps/web/public/icons/getcito-icon-96.png create mode 100644 apps/web/public/icons/getcito-icon-maskable-192.png create mode 100644 apps/web/public/icons/getcito-icon-maskable-512.png create mode 100644 apps/web/public/robots.txt create mode 100644 apps/web/scripts/benchmark-postgres-analytics.ts create mode 100644 apps/web/scripts/generate-brand-icons.tsx create mode 100644 apps/web/scripts/generate-brand-kit.tsx create mode 100644 apps/web/scripts/smoke-deployment-mode.ts create mode 100644 apps/web/src/components/app-sidebar.tsx create mode 100644 apps/web/src/components/base-chart-print.tsx create mode 100644 apps/web/src/components/base-chart.tsx create mode 100644 apps/web/src/components/brand-onboarding.tsx create mode 100644 apps/web/src/components/brand-priority-button.tsx create mode 100644 apps/web/src/components/cached-prompt-chart.tsx create mode 100644 apps/web/src/components/chart-actions-footer.tsx create mode 100644 apps/web/src/components/chart-download-footer.tsx create mode 100644 apps/web/src/components/chart-export-preview.tsx create mode 100644 apps/web/src/components/chart-footer.tsx create mode 100644 apps/web/src/components/citations-display.tsx create mode 100644 apps/web/src/components/citations/content-gaps-card.tsx create mode 100644 apps/web/src/components/citations/google-shopping-card.tsx create mode 100644 apps/web/src/components/citations/recent-changes-card.tsx create mode 100644 apps/web/src/components/citations/reddit-card.tsx create mode 100644 apps/web/src/components/citations/shared.tsx create mode 100644 apps/web/src/components/citations/stats-cards.tsx create mode 100644 apps/web/src/components/citations/top-domains-card.tsx create mode 100644 apps/web/src/components/citations/top-urls-card.tsx create mode 100644 apps/web/src/components/citations/track-domain-popover.tsx create mode 100644 apps/web/src/components/citations/trend-area-chart.tsx create mode 100644 apps/web/src/components/citations/types.ts create mode 100644 apps/web/src/components/col-head.tsx create mode 100644 apps/web/src/components/competitors-editor.tsx create mode 100644 apps/web/src/components/custom-range-calendar.tsx create mode 100644 apps/web/src/components/demo-mode-pill.tsx create mode 100644 apps/web/src/components/fanout-sections.tsx create mode 100644 apps/web/src/components/filter-bar.tsx create mode 100644 apps/web/src/components/filtered-list-shell.tsx create mode 100644 apps/web/src/components/full-page-card.tsx create mode 100644 apps/web/src/components/history-button.tsx create mode 100644 apps/web/src/components/list-pagination.tsx create mode 100644 apps/web/src/components/logo.tsx create mode 100644 apps/web/src/components/lookback-selector.tsx create mode 100644 apps/web/src/components/missing-env-page.tsx create mode 100644 apps/web/src/components/nav-app-info.test.tsx create mode 100644 apps/web/src/components/nav-app-info.tsx create mode 100644 apps/web/src/components/nav-main.tsx create mode 100644 apps/web/src/components/nav-user.tsx create mode 100644 apps/web/src/components/opportunities-report.tsx create mode 100644 apps/web/src/components/page-header.tsx create mode 100644 apps/web/src/components/progress-bar-chart.tsx create mode 100644 apps/web/src/components/prompt-chart-print.tsx create mode 100644 apps/web/src/components/prompt-order-dropdown.tsx create mode 100644 apps/web/src/components/prompt-wizard.tsx create mode 100644 apps/web/src/components/prompts-display.tsx create mode 100644 apps/web/src/components/prompts-editor.tsx create mode 100644 apps/web/src/components/prompts-list-editor.tsx create mode 100644 apps/web/src/components/share-of-voice-donut.tsx create mode 100644 apps/web/src/components/site-header.tsx create mode 100644 apps/web/src/components/text-highlighter.tsx create mode 100644 apps/web/src/components/trend-chart.tsx create mode 100644 apps/web/src/components/virtualized-prompt-list.tsx create mode 100644 apps/web/src/components/visibility-bar-section.tsx create mode 100644 apps/web/src/components/visibility-bar.tsx create mode 100644 apps/web/src/components/web-logo.tsx create mode 100644 apps/web/src/components/word-cloud.tsx create mode 100644 apps/web/src/contexts/chart-data-context.tsx create mode 100644 apps/web/src/env.d.ts create mode 100644 apps/web/src/hooks/use-auth.tsx create mode 100644 apps/web/src/hooks/use-batch-chart-data.tsx create mode 100644 apps/web/src/hooks/use-brands.tsx create mode 100644 apps/web/src/hooks/use-chart-download.tsx create mode 100644 apps/web/src/hooks/use-chart-export.tsx create mode 100644 apps/web/src/hooks/use-citations.tsx create mode 100644 apps/web/src/hooks/use-dashboard-summary.tsx create mode 100644 apps/web/src/hooks/use-filtered-visibility.tsx create mode 100644 apps/web/src/hooks/use-list-filters.ts create mode 100644 apps/web/src/hooks/use-opportunities.tsx create mode 100644 apps/web/src/hooks/use-prompt-chart-data.tsx create mode 100644 apps/web/src/hooks/use-prompt-runs-only.tsx create mode 100644 apps/web/src/hooks/use-prompt-stats.tsx create mode 100644 apps/web/src/hooks/use-prompts-summary.tsx create mode 100644 apps/web/src/hooks/use-query-fanout.tsx create mode 100644 apps/web/src/hooks/use-share-of-voice.tsx create mode 100644 apps/web/src/integrations/tanstack-query/devtools.tsx create mode 100644 apps/web/src/integrations/tanstack-query/root-provider.tsx create mode 100644 apps/web/src/lib/__tests__/app-locale.test.ts create mode 100644 apps/web/src/lib/__tests__/brand-settings.test.ts create mode 100644 apps/web/src/lib/__tests__/brand-website.test.ts create mode 100644 apps/web/src/lib/__tests__/chart-utils.test.ts create mode 100644 apps/web/src/lib/__tests__/domain-categories.test.ts create mode 100644 apps/web/src/lib/__tests__/fanout-analysis.test.ts create mode 100644 apps/web/src/lib/__tests__/model-filter.test.ts create mode 100644 apps/web/src/lib/__tests__/queue-order.test.ts create mode 100644 apps/web/src/lib/__tests__/visibility-stats.test.ts create mode 100644 apps/web/src/lib/analyze-brand-job.ts create mode 100644 apps/web/src/lib/api/__tests__/handler.test.ts create mode 100644 apps/web/src/lib/api/handler.ts create mode 100644 apps/web/src/lib/app-locale.ts create mode 100644 apps/web/src/lib/auth/__tests__/policies.test.ts create mode 100644 apps/web/src/lib/auth/helpers.ts create mode 100644 apps/web/src/lib/auth/policies.ts create mode 100644 apps/web/src/lib/auth/server.ts create mode 100644 apps/web/src/lib/auth/session.ts create mode 100644 apps/web/src/lib/boss-client.ts create mode 100644 apps/web/src/lib/brand-settings.ts create mode 100644 apps/web/src/lib/brand-website.ts create mode 100644 apps/web/src/lib/chart-utils.ts create mode 100644 apps/web/src/lib/config/server.ts create mode 100644 apps/web/src/lib/domain-categories.server.ts create mode 100644 apps/web/src/lib/domain-categories.ts create mode 100644 apps/web/src/lib/editorial-domains.ts create mode 100644 apps/web/src/lib/fanout-analysis.ts create mode 100644 apps/web/src/lib/format-duration.ts create mode 100644 apps/web/src/lib/google-module.ts create mode 100644 apps/web/src/lib/job-scheduler.ts create mode 100644 apps/web/src/lib/model-filter.ts create mode 100644 apps/web/src/lib/postgres-read.ts create mode 100644 apps/web/src/lib/posthog.ts create mode 100644 apps/web/src/lib/prompt-order.ts create mode 100644 apps/web/src/lib/prompt-tags.ts create mode 100644 apps/web/src/lib/queue-order.ts create mode 100644 apps/web/src/lib/route-head.ts create mode 100644 apps/web/src/lib/share-of-voice-palette.ts create mode 100644 apps/web/src/lib/timezone-utils.ts create mode 100644 apps/web/src/lib/utils.ts create mode 100644 apps/web/src/lib/visibility-stats.ts create mode 100644 apps/web/src/middleware/auth.ts create mode 100644 apps/web/src/middleware/deployment.ts create mode 100644 apps/web/src/routeTree.gen.ts create mode 100644 apps/web/src/router-default-components.tsx create mode 100644 apps/web/src/router.tsx create mode 100644 apps/web/src/routes/__root.tsx create mode 100644 apps/web/src/routes/_authed.tsx create mode 100644 apps/web/src/routes/_authed/admin.tsx create mode 100644 apps/web/src/routes/_authed/admin/index.tsx create mode 100644 apps/web/src/routes/_authed/admin/queue.tsx create mode 100644 apps/web/src/routes/_authed/admin/tools.tsx create mode 100644 apps/web/src/routes/_authed/admin/usage.tsx create mode 100644 apps/web/src/routes/_authed/admin/workflows.tsx create mode 100644 apps/web/src/routes/_authed/app.tsx create mode 100644 apps/web/src/routes/_authed/app/$brand.tsx create mode 100644 apps/web/src/routes/_authed/app/$brand/$.tsx create mode 100644 apps/web/src/routes/_authed/app/$brand/citations.tsx create mode 100644 apps/web/src/routes/_authed/app/$brand/index.tsx create mode 100644 apps/web/src/routes/_authed/app/$brand/opportunities.tsx create mode 100644 apps/web/src/routes/_authed/app/$brand/prompts/$promptId.tsx create mode 100644 apps/web/src/routes/_authed/app/$brand/prompts/edit.tsx create mode 100644 apps/web/src/routes/_authed/app/$brand/prompts/index.tsx create mode 100644 apps/web/src/routes/_authed/app/$brand/query-fan-out.tsx create mode 100644 apps/web/src/routes/_authed/app/$brand/settings/brand.tsx create mode 100644 apps/web/src/routes/_authed/app/$brand/settings/competitors.tsx create mode 100644 apps/web/src/routes/_authed/app/$brand/settings/index.tsx create mode 100644 apps/web/src/routes/_authed/app/$brand/settings/llms.tsx create mode 100644 apps/web/src/routes/_authed/app/$brand/settings/prompts.tsx create mode 100644 apps/web/src/routes/_authed/app/$brand/share-of-voice.tsx create mode 100644 apps/web/src/routes/_authed/app/$brand/visibility.tsx create mode 100644 apps/web/src/routes/_authed/app/index.tsx create mode 100644 apps/web/src/routes/_authed/app/new.tsx create mode 100644 apps/web/src/routes/_authed/reports.tsx create mode 100644 apps/web/src/routes/_authed/reports/index.tsx create mode 100644 apps/web/src/routes/_authed/reports/render/$reportId.tsx create mode 100644 apps/web/src/routes/api/auth/$.ts create mode 100644 apps/web/src/routes/api/manifest/index.ts create mode 100644 apps/web/src/routes/api/og/index.ts create mode 100644 apps/web/src/routes/api/plausible/event/index.ts create mode 100644 apps/web/src/routes/api/plausible/js/script/index.ts create mode 100644 apps/web/src/routes/api/setup-status/index.ts create mode 100644 apps/web/src/routes/api/v1/brands/$brandId.ts create mode 100644 apps/web/src/routes/api/v1/brands/index.ts create mode 100644 apps/web/src/routes/api/v1/competitors/$competitorId.ts create mode 100644 apps/web/src/routes/api/v1/competitors/index.ts create mode 100644 apps/web/src/routes/api/v1/prompts/$promptId.ts create mode 100644 apps/web/src/routes/api/v1/prompts/$promptId/snapshot.ts create mode 100644 apps/web/src/routes/api/v1/prompts/index.ts create mode 100644 apps/web/src/routes/api/v1/reports/$reportId.ts create mode 100644 apps/web/src/routes/api/v1/reports/index.ts create mode 100644 apps/web/src/routes/api/v1/tools/analyze.ts create mode 100644 apps/web/src/routes/auth/login.tsx create mode 100644 apps/web/src/routes/auth/logout.ts create mode 100644 apps/web/src/routes/auth/register.tsx create mode 100644 apps/web/src/routes/index.tsx create mode 100644 apps/web/src/server.ts create mode 100644 apps/web/src/server/admin.ts create mode 100644 apps/web/src/server/analysis.ts create mode 100644 apps/web/src/server/brand-cascade.ts create mode 100644 apps/web/src/server/brands.ts create mode 100644 apps/web/src/server/citations.ts create mode 100644 apps/web/src/server/config.ts create mode 100644 apps/web/src/server/dashboard.ts create mode 100644 apps/web/src/server/onboarding-core.ts create mode 100644 apps/web/src/server/onboarding.ts create mode 100644 apps/web/src/server/opportunities.ts create mode 100644 apps/web/src/server/prompt-resolution.ts create mode 100644 apps/web/src/server/prompts.ts create mode 100644 apps/web/src/server/query-fanout.ts create mode 100644 apps/web/src/server/reports.ts create mode 100644 apps/web/src/server/visibility.ts create mode 100644 apps/web/src/start.ts create mode 100644 apps/web/src/stories/_mocks/chart-data-context.tsx create mode 100644 apps/web/src/stories/_mocks/config-client.ts create mode 100644 apps/web/src/stories/_mocks/server-analysis.ts create mode 100644 apps/web/src/stories/_mocks/server-brands.ts create mode 100644 apps/web/src/stories/_mocks/server-citations.ts create mode 100644 apps/web/src/stories/_mocks/server-dashboard.ts create mode 100644 apps/web/src/stories/_mocks/server-onboarding.ts create mode 100644 apps/web/src/stories/_mocks/server-opportunities.ts create mode 100644 apps/web/src/stories/_mocks/server-prompts.ts create mode 100644 apps/web/src/stories/_mocks/tanstack-router.tsx create mode 100644 apps/web/src/stories/_mocks/tanstack-start.ts create mode 100644 apps/web/src/stories/_mocks/use-auth.tsx create mode 100644 apps/web/src/stories/_mocks/use-brands.tsx create mode 100644 apps/web/src/stories/_mocks/use-chart-download.tsx create mode 100644 apps/web/src/stories/_mocks/use-chart-export.tsx create mode 100644 apps/web/src/stories/analytics-fixtures.ts create mode 100644 apps/web/src/stories/app-sidebar.stories.tsx create mode 100644 apps/web/src/stories/brand-kit.stories.tsx create mode 100644 apps/web/src/stories/chart-export-preview.stories.tsx create mode 100644 apps/web/src/stories/login.stories.tsx create mode 100644 apps/web/src/stories/opportunities.stories.tsx create mode 100644 apps/web/src/stories/overview.stories.tsx create mode 100644 apps/web/src/stories/prompt-chart.stories.tsx create mode 100644 apps/web/src/stories/prompt-wizard.stories.tsx create mode 100644 apps/web/src/stories/share-of-voice.stories.tsx create mode 100644 apps/web/src/stories/tags-input.stories.tsx create mode 100644 apps/web/src/styles.css create mode 100644 apps/web/src/test/mocks/auth.ts create mode 100644 apps/web/src/virtual-modules.d.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts create mode 100644 apps/web/vitest.config.ts create mode 100644 apps/worker/.env.local create mode 100644 apps/worker/CHANGELOG.md create mode 100644 apps/worker/package.json create mode 100644 apps/worker/scripts/test-provider.ts create mode 100644 apps/worker/src/boss.ts create mode 100644 apps/worker/src/handlers.ts create mode 100644 apps/worker/src/index.ts create mode 100644 apps/worker/src/jobs/analyze-brand.ts create mode 100644 apps/worker/src/jobs/generate-report.ts create mode 100644 apps/worker/src/jobs/process-prompt.ts create mode 100644 apps/worker/src/jobs/schedule-maintenance.ts create mode 100644 apps/worker/src/jobs/sync-auth0-memberships.ts create mode 100644 apps/worker/src/report-worker.ts create mode 100644 apps/worker/src/telemetry.ts create mode 100644 apps/worker/tsconfig.json create mode 100644 biome.json create mode 100644 brand/architecture.svg create mode 100644 brand/icons/getcito-icon-512.png create mode 100644 brand/icons/getcito-icon-dark-512.png create mode 100644 brand/icons/getcito-icon-white-512.png create mode 100644 brand/logos/getcito-logo-dark-xl.png create mode 100644 brand/logos/getcito-logo-white-xl.png create mode 100644 brand/logos/getcito-logo-xl.png create mode 100644 docker-compose.yml create mode 100644 docker/Dockerfile create mode 100644 docker/entrypoint.sh create mode 100644 e2e/CHANGELOG.md create mode 100644 e2e/auth-setup.ts create mode 100644 e2e/cli-driver.ts create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/run-local.sh create mode 100644 e2e/seed.ts create mode 100644 e2e/tests/api-docs.spec.ts create mode 100644 e2e/tests/api.spec.ts create mode 100644 e2e/tests/citations.spec.ts create mode 100644 e2e/tests/overview.spec.ts create mode 100644 e2e/tests/prompt-details.spec.ts create mode 100644 e2e/tests/visibility.spec.ts create mode 100644 e2e/tsconfig.json create mode 100644 knip.json create mode 100644 package.json create mode 100644 packages/api-spec/CHANGELOG.md create mode 100644 packages/api-spec/package.json create mode 100644 packages/api-spec/src/openapi.json create mode 100644 packages/config/CHANGELOG.md create mode 100644 packages/config/package.json create mode 100644 packages/config/src/constants.ts create mode 100644 packages/config/src/env-registry.test.ts create mode 100644 packages/config/src/env-registry.ts create mode 100644 packages/config/src/env.ts create mode 100644 packages/config/src/scrape-targets.test.ts create mode 100644 packages/config/src/scrape-targets.ts create mode 100644 packages/config/src/types.ts create mode 100644 packages/config/tsconfig.json create mode 100644 packages/deployment/CHANGELOG.md create mode 100644 packages/deployment/package.json create mode 100644 packages/deployment/src/client.ts create mode 100644 packages/deployment/src/deployment.ts create mode 100644 packages/deployment/src/index.ts create mode 100644 packages/deployment/tsconfig.json create mode 100644 packages/lib/CHANGELOG.md create mode 100644 packages/lib/drizzle.config.ts create mode 100644 packages/lib/package.json create mode 100644 packages/lib/scripts/compare-onboarding.ts create mode 100644 packages/lib/scripts/generate-auth-schema.sh create mode 100644 packages/lib/src/auth/client.ts create mode 100644 packages/lib/src/auth/permissions.ts create mode 100644 packages/lib/src/auth/server.ts create mode 100644 packages/lib/src/brightdata-locations.ts create mode 100644 packages/lib/src/constants.ts create mode 100644 packages/lib/src/dataforseo.ts create mode 100644 packages/lib/src/db/auth-sync.test.ts create mode 100644 packages/lib/src/db/auth-sync.ts create mode 100644 packages/lib/src/db/db.ts create mode 100644 packages/lib/src/db/migrations/0000_hot_excalibur.sql create mode 100644 packages/lib/src/db/migrations/0001_sturdy_ares.sql create mode 100644 packages/lib/src/db/migrations/0002_legal_ultimatum.sql create mode 100644 packages/lib/src/db/migrations/0003_phase3-constraints-and-indices.sql create mode 100644 packages/lib/src/db/migrations/0004_nebulous_gunslinger.sql create mode 100644 packages/lib/src/db/migrations/0005_known_warhawk.sql create mode 100644 packages/lib/src/db/migrations/0006_multi_domain_aliases.sql create mode 100644 packages/lib/src/db/migrations/0007_report_progress.sql create mode 100644 packages/lib/src/db/migrations/0008_provider_engines.sql create mode 100644 packages/lib/src/db/migrations/0009_brand_opportunities.sql create mode 100644 packages/lib/src/db/migrations/0010_melodic_tombstone.sql create mode 100644 packages/lib/src/db/migrations/0011_overrated_hammerhead.sql create mode 100644 packages/lib/src/db/migrations/0012_neat_sally_floyd.sql create mode 100644 packages/lib/src/db/migrations/0013_fluffy_mockingbird.sql create mode 100644 packages/lib/src/db/migrations/meta/0000_snapshot.json create mode 100644 packages/lib/src/db/migrations/meta/0001_snapshot.json create mode 100644 packages/lib/src/db/migrations/meta/0002_snapshot.json create mode 100644 packages/lib/src/db/migrations/meta/0003_snapshot.json create mode 100644 packages/lib/src/db/migrations/meta/0004_snapshot.json create mode 100644 packages/lib/src/db/migrations/meta/0005_snapshot.json create mode 100644 packages/lib/src/db/migrations/meta/0006_snapshot.json create mode 100644 packages/lib/src/db/migrations/meta/0007_snapshot.json create mode 100644 packages/lib/src/db/migrations/meta/0008_snapshot.json create mode 100644 packages/lib/src/db/migrations/meta/0009_snapshot.json create mode 100644 packages/lib/src/db/migrations/meta/0010_snapshot.json create mode 100644 packages/lib/src/db/migrations/meta/0011_snapshot.json create mode 100644 packages/lib/src/db/migrations/meta/0012_snapshot.json create mode 100644 packages/lib/src/db/migrations/meta/0013_snapshot.json create mode 100644 packages/lib/src/db/migrations/meta/_journal.json create mode 100644 packages/lib/src/db/provisioning.test.ts create mode 100644 packages/lib/src/db/provisioning.ts create mode 100644 packages/lib/src/db/schema-auth.ts create mode 100644 packages/lib/src/db/schema.ts create mode 100644 packages/lib/src/expedite.test.ts create mode 100644 packages/lib/src/expedite.ts create mode 100644 packages/lib/src/languages.ts create mode 100644 packages/lib/src/location-languages.ts create mode 100644 packages/lib/src/locations.test.ts create mode 100644 packages/lib/src/locations.ts create mode 100644 packages/lib/src/onboarding/analyze.test.ts create mode 100644 packages/lib/src/onboarding/analyze.ts create mode 100644 packages/lib/src/onboarding/index.ts create mode 100644 packages/lib/src/onboarding/llm.test.ts create mode 100644 packages/lib/src/onboarding/llm.ts create mode 100644 packages/lib/src/onboarding/utils.test.ts create mode 100644 packages/lib/src/onboarding/utils.ts create mode 100644 packages/lib/src/providers/concurrency.test.ts create mode 100644 packages/lib/src/providers/concurrency.ts create mode 100644 packages/lib/src/providers/config.test.ts create mode 100644 packages/lib/src/providers/config.ts create mode 100644 packages/lib/src/providers/index.ts create mode 100644 packages/lib/src/providers/locale.test.ts create mode 100644 packages/lib/src/providers/locale.ts create mode 100644 packages/lib/src/providers/models.test.ts create mode 100644 packages/lib/src/providers/models.ts create mode 100644 packages/lib/src/providers/registry/anthropic-api.ts create mode 100644 packages/lib/src/providers/registry/azure-foundry-api.test.ts create mode 100644 packages/lib/src/providers/registry/azure-foundry-api.ts create mode 100644 packages/lib/src/providers/registry/brightdata.ts create mode 100644 packages/lib/src/providers/registry/cloro.test.ts create mode 100644 packages/lib/src/providers/registry/cloro.ts create mode 100644 packages/lib/src/providers/registry/dataforseo.test.ts create mode 100644 packages/lib/src/providers/registry/dataforseo.ts create mode 100644 packages/lib/src/providers/registry/mistral-api.ts create mode 100644 packages/lib/src/providers/registry/olostep.test.ts create mode 100644 packages/lib/src/providers/registry/olostep.ts create mode 100644 packages/lib/src/providers/registry/openai-api.ts create mode 100644 packages/lib/src/providers/registry/openrouter.ts create mode 100644 packages/lib/src/providers/registry/oxylabs.ts create mode 100644 packages/lib/src/providers/runner.test.ts create mode 100644 packages/lib/src/providers/runner.ts create mode 100644 packages/lib/src/providers/types.ts create mode 100644 packages/lib/src/providers/usage.ts create mode 100644 packages/lib/src/report-metrics.test.ts create mode 100644 packages/lib/src/report-metrics.ts create mode 100644 packages/lib/src/run-backoff.test.ts create mode 100644 packages/lib/src/run-backoff.ts create mode 100644 packages/lib/src/tag-utils.test.ts create mode 100644 packages/lib/src/tag-utils.ts create mode 100644 packages/lib/src/text-extraction.test.ts create mode 100644 packages/lib/src/text-extraction.ts create mode 100644 packages/lib/src/website-excerpt.ts create mode 100644 packages/lib/tsconfig.json create mode 100644 packages/local/CHANGELOG.md create mode 100644 packages/local/package.json create mode 100644 packages/local/src/auth-provider.ts create mode 100644 packages/local/src/components/optimize-button.tsx create mode 100644 packages/local/src/index.ts create mode 100644 packages/local/tsconfig.json create mode 100644 packages/og/CHANGELOG.md create mode 100644 packages/og/package.json create mode 100644 packages/og/src/render.ts create mode 100644 packages/og/src/virtual.d.ts create mode 100644 packages/og/src/vite-plugin.ts create mode 100644 packages/ui/CHANGELOG.md create mode 100644 packages/ui/components.json create mode 100644 packages/ui/package.json create mode 100644 packages/ui/src/components/alert.tsx create mode 100644 packages/ui/src/components/avatar.tsx create mode 100644 packages/ui/src/components/badge.tsx create mode 100644 packages/ui/src/components/breadcrumb.tsx create mode 100644 packages/ui/src/components/button.tsx create mode 100644 packages/ui/src/components/card.tsx create mode 100644 packages/ui/src/components/chart.tsx create mode 100644 packages/ui/src/components/checkbox.tsx create mode 100644 packages/ui/src/components/collapsible.tsx create mode 100644 packages/ui/src/components/command.tsx create mode 100644 packages/ui/src/components/dialog.tsx create mode 100644 packages/ui/src/components/dropdown-menu.tsx create mode 100644 packages/ui/src/components/form.tsx create mode 100644 packages/ui/src/components/input.tsx create mode 100644 packages/ui/src/components/label.tsx create mode 100644 packages/ui/src/components/navigation-menu.tsx create mode 100644 packages/ui/src/components/popover.tsx create mode 100644 packages/ui/src/components/progress.tsx create mode 100644 packages/ui/src/components/select.tsx create mode 100644 packages/ui/src/components/separator.tsx create mode 100644 packages/ui/src/components/sheet.tsx create mode 100644 packages/ui/src/components/sidebar.tsx create mode 100644 packages/ui/src/components/skeleton.tsx create mode 100644 packages/ui/src/components/switch.tsx create mode 100644 packages/ui/src/components/table.tsx create mode 100644 packages/ui/src/components/tabs.tsx create mode 100644 packages/ui/src/components/tags-input.tsx create mode 100644 packages/ui/src/components/textarea.tsx create mode 100644 packages/ui/src/components/tooltip.tsx create mode 100644 packages/ui/src/hooks/use-mobile.ts create mode 100644 packages/ui/src/lib/utils.ts create mode 100644 packages/ui/src/styles/globals.css create mode 100644 packages/ui/tsconfig.json create mode 100644 packages/whitelabel/CHANGELOG.md create mode 100644 packages/whitelabel/package.json create mode 100644 packages/whitelabel/src/auth-hooks.ts create mode 100644 packages/whitelabel/src/components/optimize-button.tsx create mode 100644 packages/whitelabel/src/deployment.ts create mode 100644 packages/whitelabel/src/index.ts create mode 100644 packages/whitelabel/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/check-licenses.mjs create mode 100644 scripts/generate-release-notes.mjs create mode 100644 scripts/gh-release.mjs create mode 100644 scripts/sync-root-version.mjs create mode 100644 tsconfig.json create mode 100644 turbo.json diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000..9e10042 --- /dev/null +++ b/.changeset/README.md @@ -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. diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..3f81591 --- /dev/null +++ b/.changeset/config.json @@ -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": [] +} diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000..8baef75 --- /dev/null +++ b/.cursorignore @@ -0,0 +1,2 @@ +apps/web/.env +apps/web/.env.old diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7ce3d94 --- /dev/null +++ b/.dockerignore @@ -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 + diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fb1f2bb --- /dev/null +++ b/.env.example @@ -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= diff --git a/.env.local b/.env.local new file mode 100644 index 0000000..7ca2092 --- /dev/null +++ b/.env.local @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ccf69c --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1aa6236 --- /dev/null +++ b/AGENTS.md @@ -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= +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 `` 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. diff --git a/CLA.md b/CLA.md new file mode 100644 index 0000000..8727ec9 --- /dev/null +++ b/CLA.md @@ -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). \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..12a1ecd --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e12474d --- /dev/null +++ b/CONTRIBUTING.md @@ -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 diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..3f94671 --- /dev/null +++ b/LICENSE.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2c0c368 --- /dev/null +++ b/README.md @@ -0,0 +1,648 @@ +

+ + + + + Getcito + + +

+ +

+ Open source AI visibility tracking and optimization — self-hosted. +
+
+ Learn more » +

+ +
+ +

+ Docs  + Demo  + Issues  + Roadmap  + License: MIT  + Node 24.x +

+ +--- + +## 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
TanStack Start dashboard + /api/v1] + Web -->|read/write| DB[(PostgreSQL
Drizzle schema)] + Web -->|enqueue jobs| DB + Worker[apps/worker
pg-boss jobs] -->|poll queues| DB + Worker -->|scrape AI answers| Scrapers[BrightData / Oxylabs
Olostep / DataForSEO] + Worker -->|direct LLM calls| LLM[OpenRouter / Anthropic
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 `. + +| 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`. + +
+ Click to view docker-compose.whitelabel.yml template + + ```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: + ``` +
+ +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 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..442d6f5 --- /dev/null +++ b/SECURITY.md @@ -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) diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..ac9d256 --- /dev/null +++ b/apps/web/.env.example @@ -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= diff --git a/apps/web/.env.local b/apps/web/.env.local new file mode 100644 index 0000000..7ca2092 --- /dev/null +++ b/apps/web/.env.local @@ -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 diff --git a/apps/web/.storybook/main.ts b/apps/web/.storybook/main.ts new file mode 100644 index 0000000..21f6463 --- /dev/null +++ b/apps/web/.storybook/main.ts @@ -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; diff --git a/apps/web/.storybook/preview.tsx b/apps/web/.storybook/preview.tsx new file mode 100644 index 0000000..ab28af5 --- /dev/null +++ b/apps/web/.storybook/preview.tsx @@ -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) => ( + + + +); + +export const decorators: Decorator[] = [withQueryClient]; diff --git a/apps/web/.storybook/vite.config.ts b/apps/web/.storybook/vite.config.ts new file mode 100644 index 0000000..ff6b534 --- /dev/null +++ b/apps/web/.storybook/vite.config.ts @@ -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()], +}); diff --git a/apps/web/CHANGELOG.md b/apps/web/CHANGELOG.md new file mode 100644 index 0000000..f1f6dae --- /dev/null +++ b/apps/web/CHANGELOG.md @@ -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:[: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 diff --git a/apps/web/biome.json b/apps/web/biome.json new file mode 100644 index 0000000..8fc287b --- /dev/null +++ b/apps/web/biome.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.4/schema.json", + "extends": ["../../biome.json"], + "files": { + "includes": ["**/*"], + "ignoreUnknown": true + } +} diff --git a/apps/web/instrument.server.mjs b/apps/web/instrument.server.mjs new file mode 100644 index 0000000..8484ff6 --- /dev/null +++ b/apps/web/instrument.server.mjs @@ -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, + }); +} diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..05a29bc --- /dev/null +++ b/apps/web/package.json @@ -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" + } +} diff --git a/apps/web/public/apple-touch-icon.png b/apps/web/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6980d29b58f788a53ce93133c8b881d552f90225 GIT binary patch literal 14255 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�PXx}f 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 Y0Q=xECBwe?od5s;07*qoM6N<$f*E7(KL7v# literal 0 HcmV?d00001 diff --git a/apps/web/public/icons/apple-touch-icon.png b/apps/web/public/icons/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6980d29b58f788a53ce93133c8b881d552f90225 GIT binary patch literal 14255 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�PXx}f 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 Y0Q=xECBwe?od5s;07*qoM6N<$f*E7(KL7v# literal 0 HcmV?d00001 diff --git a/apps/web/public/icons/getcito-icon-192.png b/apps/web/public/icons/getcito-icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..43638a84227f66ee77a80eac82219a0c677b09bc GIT binary patch literal 15833 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 IGqXUgK@ zkB$MgSJZ1*oG@+;0E{Jm90dsU-M#_>A$Zwj5yIES9ig6vK4 zR!P6NW7a7s#BmQ__H7Cy%i#+2Z3W_ zfZODtax`Fkt4#uDNK3yPd}@TP*TQmKOt7OC-q+AeipYTbiIP*8Kd?QUnaSCNHEq+; z-!|K-LxioRsA1gZirhiW!`wOYe|P}(qleEycaS(af{*cvFo^q!8t|;z)-7!ohIdFy4&n@e$s6@-D9LPn4FOp+=9hciNwm2UB&Z+@EF&)2I$7?& zx3P`PR8(&1xYQ$y{Amj-sA8CP~%etb^j!n{hzY3Q#5vik*4bjGWY zQ)9gN7OVwOAeU4~HR862rx!gkj#kxT<+ohCzRPnCayr~BH2WvG^-j49!eB!HaOu-i zg~7b?>*!(S_3DpHH#@ygTV~vP%Fb~dGyMJBPi(>aB-w;J8%{C20N`{9BM*sNZ=(HxYo5`Uv zy1#9vT;*Z!5d4CU6_bCb8P(fR()Z!LoL7TVUr>DvH}IV#6Lx?lb@o2v#v_HQP39-( z+Q2EhXK8a1P$ZGgnQc~6`FoohyO~un&I(1q7@i2T^PECT%~z#rx+nc_ z4R6EyG4LFh+O7vg5bGNJ;){?En?TpGcjVD?4^?7B9X zhfH1qk+S!~D}*y=_e_kAvEAiHhm-a94wquRtv@BPT%H)4_&#k^pYR({!9Z~^LNAbB zHOD-39nr0dKu5EkUb=aW$H$el<56HJ9#G_ff%fUgvFYl;%da?Y|y^TJZAU>=y zXb}h8f4S!4FBXJd@{_txbD!{U6zYYMJ7hJi=wIjXd+LCy_W3>CI8bkLC{)*;E=)nB zj~gil482PGs?=ZFOaX3R;|#gHV^h{@JXn>)q4%zN#ay0fNe5*vN>s@6b~j93x1!Fr z?WBC+@G`!g3ar0UPOHZpccDeY>%Y=1c!Y!VXB0|he|4$L+rE5!{FS8Z=7D_rrGOgU zZE;EI&ODpDvuEl$y+w#1PFzyA8u!h5L~U?v&9ho zUg=<1m>*O8m)A0F@na@7PMH>uexoPHvsrtOOCz3j1^E>8isVZ1Rg~Ed3&E7#+M9Pe^xUw$?O*fdi24c`W z%l|>rVg&OO;yMXpX9ZZMhyGgBJB_1{<}H!GA3=%eH^RBuC6btGlsRwrOP9w0_LbHY zo@WsN|JZt-caS8-jk_F;(`J9%weEaQSnIviHp;Yyc<{sV^R12Czt=kXIW;!nh7d>A zV;BeKwl}5L^7>^7WU`@tddBBrT>c#BEFxs6?-28)=aKZ{!ZWOsWjk1h6>qT`!0DQH z^|HDIaL0()OX$mkVGzN{B7|_<$`)(^YeGuG*+O#oc2~c4x(he}U(O$KH>+YtE`^hb zZQR2p+a*`k{=|jkG-43@VT1c`dr5Y(zHT$GR*`FfT@i)HXVPhVGK+V{DtVf+W!g1H zn_u-)K8gSSe(C$Dn3cIR6MM0EaF}=N-yyzOV021wOU$=kP?g=JQLyK}hC7P_w%Leu z8$`>aJsO*R_3aG{z#{dJY=_1wd>%W%`9;TGl|wShR{r4ZVj<1AijU0|r>0h{>{)52 zCj+al&H&u?_n8gNKvs9VMy=7E(rZMt58L))BsFv|83eWFFh8}6ar}c40vpXDT9%>R zwwNgP^iF73FV0crnDA*2h@Z3GA!@r8M_?fkeeGTC`Zo}amA(C*oddR{>gQKtelM_M zPH=wbIg$t~DM@Srs>9?H{irno!amk$8h~LC!2H|x;yHB%m6za^qk*^-4Y9FXk-a~c z72RVgz?VUFxJ=EaU^Msi?1dT$jd7~;K-WAH^&u=5K@xZGq;#HZOudJ68<;~9xOD83|{xh%eFzTa~+�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 IGqXUgK@ zkB$MgSJZ1*oG@+;0E{Jm90dsU-M#_>A$Zwj5yIES9ig6vK4 zR!P6NW7a7s#BmQ__H7Cy%i#+2Z3W_ zfZODtax`Fkt4#uDNK3yPd}@TP*TQmKOt7OC-q+AeipYTbiIP*8Kd?QUnaSCNHEq+; z-!|K-LxioRsA1gZirhiW!`wOYe|P}(qleEycaS(af{*cvFo^q!8t|;z)-7!ohIdFy4&n@e$s6@-D9LPn4FOp+=9hciNwm2UB&Z+@EF&)2I$7?& zx3P`PR8(&1xYQ$y{Amj-sA8CP~%etb^j!n{hzY3Q#5vik*4bjGWY zQ)9gN7OVwOAeU4~HR862rx!gkj#kxT<+ohCzRPnCayr~BH2WvG^-j49!eB!HaOu-i zg~7b?>*!(S_3DpHH#@ygTV~vP%Fb~dGyMJBPi(>aB-w;J8%{C20N`{9BM*sNZ=(HxYo5`Uv zy1#9vT;*Z!5d4CU6_bCb8P(fR()Z!LoL7TVUr>DvH}IV#6Lx?lb@o2v#v_HQP39-( z+Q2EhXK8a1P$ZGgnQc~6`FoohyO~un&I(1q7@i2T^PECT%~z#rx+nc_ z4R6EyG4LFh+O7vg5bGNJ;){?En?TpGcjVD?4^?7B9X zhfH1qk+S!~D}*y=_e_kAvEAiHhm-a94wquRtv@BPT%H)4_&#k^pYR({!9Z~^LNAbB zHOD-39nr0dKu5EkUb=aW$H$el<56HJ9#G_ff%fUgvFYl;%da?Y|y^TJZAU>=y zXb}h8f4S!4FBXJd@{_txbD!{U6zYYMJ7hJi=wIjXd+LCy_W3>CI8bkLC{)*;E=)nB zj~gil482PGs?=ZFOaX3R;|#gHV^h{@JXn>)q4%zN#ay0fNe5*vN>s@6b~j93x1!Fr z?WBC+@G`!g3ar0UPOHZpccDeY>%Y=1c!Y!VXB0|he|4$L+rE5!{FS8Z=7D_rrGOgU zZE;EI&ODpDvuEl$y+w#1PFzyA8u!h5L~U?v&9ho zUg=<1m>*O8m)A0F@na@7PMH>uexoPHvsrtOOCz3j1^E>8isVZ1Rg~Ed3&E7#+M9Pe^xUw$?O*fdi24c`W z%l|>rVg&OO;yMXpX9ZZMhyGgBJB_1{<}H!GA3=%eH^RBuC6btGlsRwrOP9w0_LbHY zo@WsN|JZt-caS8-jk_F;(`J9%weEaQSnIviHp;Yyc<{sV^R12Czt=kXIW;!nh7d>A zV;BeKwl}5L^7>^7WU`@tddBBrT>c#BEFxs6?-28)=aKZ{!ZWOsWjk1h6>qT`!0DQH z^|HDIaL0()OX$mkVGzN{B7|_<$`)(^YeGuG*+O#oc2~c4x(he}U(O$KH>+YtE`^hb zZQR2p+a*`k{=|jkG-43@VT1c`dr5Y(zHT$GR*`FfT@i)HXVPhVGK+V{DtVf+W!g1H zn_u-)K8gSSe(C$Dn3cIR6MM0EaF}=N-yyzOV021wOU$=kP?g=JQLyK}hC7P_w%Leu z8$`>aJsO*R_3aG{z#{dJY=_1wd>%W%`9;TGl|wShR{r4ZVj<1AijU0|r>0h{>{)52 zCj+al&H&u?_n8gNKvs9VMy=7E(rZMt58L))BsFv|83eWFFh8}6ar}c40vpXDT9%>R zwwNgP^iF73FV0crnDA*2h@Z3GA!@r8M_?fkeeGTC`Zo}amA(C*oddR{>gQKtelM_M zPH=wbIg$t~DM@Srs>9?H{irno!amk$8h~LC!2H|x;yHB%m6za^qk*^-4Y9FXk-a~c z72RVgz?VUFxJ=EaU^Msi?1dT$jd7~;K-WAH^&u=5K@xZGq;#HZOudJ68<;~9xOD83|{xh%eFzTa~+�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 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): 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): Promise { + 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 { + 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 { + 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); +}); diff --git a/apps/web/scripts/generate-brand-icons.tsx b/apps/web/scripts/generate-brand-icons.tsx new file mode 100644 index 0000000..28e6e73 --- /dev/null +++ b/apps/web/scripts/generate-brand-icons.tsx @@ -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 ``): + * - 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 ` + + e +`; +} + +/** + * 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 ` + + + e +`; +} + +// --------------------------------------------------------------------------- +// 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 { + 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 ( +
+
+ e +
+
+ ); +} + +function MaskableIcon({ size }: { size: number }) { + return ( +
+
+ e +
+
+ ); +} + +// --------------------------------------------------------------------------- +// 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: , size: 96 }, + { name: "getcito-icon-192.png", element: , size: 192 }, + { name: "getcito-icon-512.png", element: , size: 512 }, + { name: "getcito-icon-maskable-192.png", element: , size: 192 }, + { name: "getcito-icon-maskable-512.png", element: , size: 512 }, + // Apple touch icons must be opaque — iOS otherwise adds its own background. + { name: "apple-touch-icon.png", element: , 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(, size)); +} +writeFileSync(resolve(OUTPUT_DIR, "favicon.ico"), await pngToIco(icoPngs)); +console.log(" ✓ favicon.ico"); + +console.log(`\nIcons written to ${OUTPUT_DIR}`); diff --git a/apps/web/scripts/generate-brand-kit.tsx b/apps/web/scripts/generate-brand-kit.tsx new file mode 100644 index 0000000..bc235a2 --- /dev/null +++ b/apps/web/scripts/generate-brand-kit.tsx @@ -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 { + 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 ( +
+
+ e +
+
+ ); +} + +function MaskableIcon({ size }: { size: number }) { + return ( +
+
+ e +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Logo — "Getcito" wordmark with equal padding on all sides +// --------------------------------------------------------------------------- + +function Logo({ bg, fontSize }: { bg?: string; fontSize: number }) { + return ( +
+
+ Getcito +
+
+ ); +} + +// --------------------------------------------------------------------------- +// 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( +
+ Getcito +
, + ); + } + rowElements.push( +
+ {cells} +
, + ); + } + + return ( +
+
+ {rowElements} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// OG image +// --------------------------------------------------------------------------- + +function OgImage({ title }: { title: string }) { + return ( +
+
+ e +
+ +
+
+ Getcito +
+
+ {title} +
+
+ {DESCRIPTION} +
+
+ +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Generate all assets +// --------------------------------------------------------------------------- + +const files: { name: string; data: Buffer }[] = []; + +async function addFile(name: string, data: Buffer | Promise) { + 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(, size, size), + ); + } +} + +console.log("\nIcons — Maskable (PWA):"); +for (const size of [64, 128, 256, 512]) { + await addFile( + `icons/getcito-icon-maskable-${size}.png`, + render(, 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(, sz.w, sz.h), + ); + } +} + +// OG +console.log("\nOG Images:"); +const ogData = await render(, 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( + , + 3000, + 1000, + ), +); + +await addFile( + "banners/linkedin-banner.png", + render( + , + 3384, + 573, + ), +); + +// Write zip +console.log("\nPacking zip…"); + +const output = createWriteStream(OUTPUT_ZIP); +const archive = new ZipArchive({ zlib: { level: 9 } }); + +const done = new Promise((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}`, +); diff --git a/apps/web/scripts/smoke-deployment-mode.ts b/apps/web/scripts/smoke-deployment-mode.ts new file mode 100644 index 0000000..c6feb8f --- /dev/null +++ b/apps/web/scripts/smoke-deployment-mode.ts @@ -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; + +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 = { + 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> = { + 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 { + 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 { + 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 { + 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); +}); diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx new file mode 100644 index 0000000..4a9f8c7 --- /dev/null +++ b/apps/web/src/components/app-sidebar.tsx @@ -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 { + 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 ( + + + + + + setOpenMobile(false)}> + +
+ +
+ +
+
+
+
+ + + + + + + +
+ ); +} diff --git a/apps/web/src/components/base-chart-print.tsx b/apps/web/src/components/base-chart-print.tsx new file mode 100644 index 0000000..bc6e831 --- /dev/null +++ b/apps/web/src/components/base-chart-print.tsx @@ -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 ( + + + {payload.value} + + + ); +} + +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 ( +
+ {showTitle && ( +
+ {title &&

{title}

} +
+ )} +
+ No data available +
+
+ ); + } + + // 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 ( +
+ {showTitle && ( +
+ {title &&

{title}

} + {showBadge && visibility !== null && ( + + {visibility}% + + )} +
+ )} +
+ + + } + height={30} + interval={0} + /> + `${value}%`} + width={40} + /> + `${value}%`, + }} + > + {sortedEntities.map((entry) => ( + + ))} + + + +
+
+ ); +} diff --git a/apps/web/src/components/base-chart.tsx b/apps/web/src/components/base-chart.tsx new file mode 100644 index 0000000..ecfd7a4 --- /dev/null +++ b/apps/web/src/components/base-chart.tsx @@ -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 ? : 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] ? : 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 ( +
+ {showTitle && ( +
+ {title &&

{title}

} + {showBadge && visibility !== null && ( + + {visibility}% + + )} +
+ )} + {chartType === "bar" ? ( + + + + formatDateStr(value, { month: "short", day: "numeric" })} + /> + `${value}%`} + /> + formatDateStr(String(value), { month: "short", day: "numeric" })} + indicator="dot" + formatter={(value, name, item, index) => { + const indicatorColor = chartConfig[name as string]?.color; + return ( + <> +
+
+
+ {chartConfig[name as string]?.label || name} +
+ {value !== null && value !== undefined && ( + {value}% + )} +
+ + ); + }} + /> + } + /> + {dataKeys.map((key, index) => ( + + ))} + } /> + + + ) : ( + + + + formatDateStr(value, { month: "short", day: "numeric" })} + /> + `${value}%`} + /> + { + 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 ( +
+
{formattedDate}
+
+ {filteredPayload.map((item: any) => { + const indicatorColor = chartConfig[item.dataKey as string]?.color; + return ( +
+
+
+ + {chartConfig[item.dataKey as string]?.label || item.dataKey} + + + {item.value}% + +
+
+ ); + })} +
+
+ ); + }} + /> + {/* 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) + , + // Second: Solid line overlay for real data (shows in legend) + { + // Don't render dot for extended points or null values + // Return empty element instead of null to satisfy Recharts types + if (!payload || isExtendedDataPoint(payload, key) || value === null || value === undefined) { + return ; + } + return ( + + ); + }} + activeDot={({ cx, cy, payload, value }: any) => { + // Don't render active dot for extended points or null values + // Return empty element instead of null to satisfy Recharts types + if (!payload || isExtendedDataPoint(payload, key) || value === null || value === undefined) { + return ; + } + return ( + + ); + }} + connectNulls={true} + isAnimationActive={isAnimationActive} + />, + ])} + } /> + + + )} +
+ ); +} diff --git a/apps/web/src/components/brand-onboarding.tsx b/apps/web/src/components/brand-onboarding.tsx new file mode 100644 index 0000000..01227d7 --- /dev/null +++ b/apps/web/src/components/brand-onboarding.tsx @@ -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 ( + +
+ + + +
+ + +

Enter your brand's website

+
+ + {error &&

{error}

} + + +
+
+ ); +} diff --git a/apps/web/src/components/brand-priority-button.tsx b/apps/web/src/components/brand-priority-button.tsx new file mode 100644 index 0000000..b29432b --- /dev/null +++ b/apps/web/src/components/brand-priority-button.tsx @@ -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(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 ( +
+ + {error && {error}} +
+ ); +} diff --git a/apps/web/src/components/cached-prompt-chart.tsx b/apps/web/src/components/cached-prompt-chart.tsx new file mode 100644 index 0000000..b92628c --- /dev/null +++ b/apps/web/src/components/cached-prompt-chart.tsx @@ -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 ( + + + + ); +} + +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 ( + + + + + + + +
+
+ +
+
+
+
+
+
+
+ + + +
+ + +
+ +
+ + ); + } + + // No runs state - distinguish between "never evaluated" vs "no data in selected window" + if (totalRuns === 0) { + const isFirstEval = !hasEverBeenEvaluated; + + return ( + + + + + + {/* h-[300px] instead of h-[250px] to compensate for the missing footer section, + keeping overall card height consistent with data-filled cards for virtualization */} + +
+
+ {isFirstEval ? ( + <> +
+
+
+
+
+

+ Evaluating for the first time +

+

+ Results will appear here shortly. +

+ + ) : ( + <> +
+ {PLACEHOLDER_BARS_NO_DATA.map((bar) => ( +
+ ))} +
+

+ No data in selected time range +

+

+ Try selecting a longer time period to see historical data. +

+ + )} +
+
+ + + ); + } + + // No visibility data state + if (!hasVisibilityData) { + return ( + <> + {exportPortal} + + + + + + +
+
+
+ {PLACEHOLDER_BARS_NO_VISIBILITY.map((bar) => ( +
+ ))} +
+

+ No brands found in responses +

+

+ Your brand and competitors weren't mentioned in the evaluated responses for this prompt. +

+
+
+ +
+ +
+ + + ); + } + + // Success state with chart + return ( + <> + {exportPortal} + + + + {/* Average over the selected window is the headline number (it matches the + page-level visibility bar); the latest day sits beside it as context. */} +
+ {avgBrandVisibility !== null && ( + + {avgBrandVisibility}% Visibility + + )} + {lastBrandVisibility !== null && ( + + {lastBrandVisibility}% latest + + )} +
+
+ + + {brand && data && competitors && ( + + )} + +
+ +
+
+ + ); +}); diff --git a/apps/web/src/components/chart-actions-footer.tsx b/apps/web/src/components/chart-actions-footer.tsx new file mode 100644 index 0000000..1bafc8f --- /dev/null +++ b/apps/web/src/components/chart-actions-footer.tsx @@ -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 ( + +
+
+ + {onDownload && ( + + )} +
+ {showOptimizeButton && ( + + )} +
+
+ ); +} diff --git a/apps/web/src/components/chart-download-footer.tsx b/apps/web/src/components/chart-download-footer.tsx new file mode 100644 index 0000000..aecef76 --- /dev/null +++ b/apps/web/src/components/chart-download-footer.tsx @@ -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 ( +
+ + + +
+ ); +} + diff --git a/apps/web/src/components/chart-export-preview.tsx b/apps/web/src/components/chart-export-preview.tsx new file mode 100644 index 0000000..e1de687 --- /dev/null +++ b/apps/web/src/components/chart-export-preview.tsx @@ -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 ( +
+ {/* Title bar */} +
+

+ {promptName} +

+ {visibility !== null && ( + + {visibility}% Visibility + + )} +
+ + {/* Chart card */} +
+
+ +
+
+ + {/* Branding footer — fills remaining space, content vertically centered */} +
+
+ {isWhitelabel && hasCustomIcon && ( + {`${name} + )} + {isWhitelabel ? ( + {name} + ) : ( + + Getcito + + )} +
+ {domain} +
+
+ ); +} diff --git a/apps/web/src/components/chart-footer.tsx b/apps/web/src/components/chart-footer.tsx new file mode 100644 index 0000000..9236714 --- /dev/null +++ b/apps/web/src/components/chart-footer.tsx @@ -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 ( + <> + + + {children} + + + ); +} + diff --git a/apps/web/src/components/citations-display.tsx b/apps/web/src/components/citations-display.tsx new file mode 100644 index 0000000..6089de5 --- /dev/null +++ b/apps/web/src/components/citations-display.tsx @@ -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 && ( + + )} + + {/* Citation Categories over time */} + {citationData.citationTimeSeries && citationData.citationTimeSeries.length > 0 && ( + >} + keys={chartSourceCategories} + meta={CATEGORY_META} + /> + )} + + {/* Citation Page Types over time */} + {citationData.pageTypeTimeSeries && citationData.pageTypeTimeSeries.length > 0 && ( + >} + keys={chartPageTypes} + meta={PAGE_TYPE_META} + /> + )} + + {/* Recent Changes + Content Gaps (side by side) */} + {(totalChanges > 0 || hasGaps) && ( +
0 && hasGaps ? "grid grid-cols-1 lg:grid-cols-2 gap-4 items-stretch" : "contents"}> + {totalChanges > 0 && whatsChanged && } + {hasGaps && ( + + )} +
+ )} + + {/* Top Cited Domains */} + {citationData.domainDistribution.length > 0 && ( + + )} + + {/* Top Cited URLs */} + {citationData.specificUrls.length > 0 && ( + 0} + /> + )} + + {/* Google Shopping */} + {googleModule && googleModule.shopping.products.length > 0 && ( + + )} + + {/* Top Cited Subreddits */} + {subredditData.length > 0 && } + + ); +} diff --git a/apps/web/src/components/citations/content-gaps-card.tsx b/apps/web/src/components/citations/content-gaps-card.tsx new file mode 100644 index 0000000..a0a20c6 --- /dev/null +++ b/apps/web/src/components/citations/content-gaps-card.tsx @@ -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 ( + + + + Content Gaps + + + + + + Prompts where competitors are cited but your brand isn't — opportunities to improve your citation presence. + + + + + Prompts where competitors are cited but your brand isn't + + + + +
+ {pageItems.map((prompt) => ( + +
+ +
+
+
+ {prompt.value} +
+

+ {prompt.uniqueCompetitors} {prompt.uniqueCompetitors === 1 ? "competitor" : "competitors"} cited {prompt.competitorCitationCount} {prompt.competitorCitationCount === 1 ? "time" : "times"} — your brand cited 0 times +

+
+ + ))} +
+ +
+
+ ); +} diff --git a/apps/web/src/components/citations/google-shopping-card.tsx b/apps/web/src/components/citations/google-shopping-card.tsx new file mode 100644 index 0000000..98dd555 --- /dev/null +++ b/apps/web/src/components/citations/google-shopping-card.tsx @@ -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 ( +
+ {prompts.map((p) => ( + brandId ? ( + + {p.value} + {p.count.toLocaleString()} + + ) : ( +
+ {p.value} + {p.count.toLocaleString()} +
+ ) + ))} +
+ ); +} + +export function GoogleShoppingCard({ + googleModule, + brandId, +}: { + googleModule: GoogleModuleData; + brandId?: string; +}) { + const [expandedProduct, setExpandedProduct] = useState(null); + const [productFilter, setProductFilter] = useState<"all" | "brand" | "competitor">("all"); + const [expandedQuery, setExpandedQuery] = useState(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 ( + + + + Google Shopping + + + + + + 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. + + + + + Products Google AI Mode surfaced — {googleModule.shopping.brandCount.toLocaleString()} appearances for yours vs {googleModule.shopping.competitorCount.toLocaleString()} for competitors + + + + + {googleModule.shopping.products.length > 0 && ( +
+
+

Products

+
+ {([["all", "All"], ["brand", "Yours"], ["competitor", "Competitors"]] as const).map(([key, label]) => ( + + ))} +
+
+
+ {pageItems.map((product) => { + const isExpanded = expandedProduct === product.name; + return ( +
+
+ + {product.count.toLocaleString()} +
+ {isExpanded && product.prompts.length > 0 && ( + + )} +
+ ); + })} +
+ +
+ )} + + {googleModule.search.queries.length > 0 && ( +
+

Search queries

+
+ {(showAllQueries ? googleModule.search.queries : googleModule.search.queries.slice(0, 5)).map((q) => { + const isExpanded = expandedQuery === q.query; + return ( +
+
+ + {q.count.toLocaleString()} +
+ {isExpanded && q.prompts.length > 0 && ( + + )} +
+ ); + })} +
+ {googleModule.search.queries.length > 5 && !showAllQueries && ( + + )} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/citations/recent-changes-card.tsx b/apps/web/src/components/citations/recent-changes-card.tsx new file mode 100644 index 0000000..a2a3a74 --- /dev/null +++ b/apps/web/src/components/citations/recent-changes-card.tsx @@ -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; + days: number; +}) { + const [changeTypeFilter, setChangeTypeFilter] = useState("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 ( + + + + Recent Changes + + + + + + Compares this {formatPeriodLabel(days)} with the {formatPeriodLabel(days)} before it. Shows new and dropped pages, title changes, and new and dropped domains. + + + + + How AI citations have shifted over the past {formatPeriodLabel(days)} + + + + + setChangeTypeFilter(key)} + /> +
+ {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") + ? + : (change.type === "dropped_pages" || change.type === "dropped_domains") + ? + : ; + + 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 = ( + <> + {change.previousTitle} + {" → "} + {change.currentTitle} + + ); + } 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 = ( + <> +
{icon}
+
+
+ {displayLabel} + {url && } +
+ {description &&

{description}

} +
+ + ); + + return ( + + {inner} + + ); + })} + {visibleChanges.length === 0 && ( +

+ No {CHANGE_TYPE_TABS.find((t) => t.key === changeTypeFilter)?.label.toLowerCase() ?? changeTypeFilter} changes in this period. +

+ )} +
+
+
+ ); +} diff --git a/apps/web/src/components/citations/reddit-card.tsx b/apps/web/src/components/citations/reddit-card.tsx new file mode 100644 index 0000000..b9cf030 --- /dev/null +++ b/apps/web/src/components/citations/reddit-card.tsx @@ -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(); + 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; +}) { + const [expandedSubreddit, setExpandedSubreddit] = useState(null); + const { page, setPage, pageItems, totalItems } = usePagedList(subreddits, SUBREDDITS_PAGE_SIZE); + + return ( + + + + Reddit + + + + + + Reddit communities most frequently cited by AI models. Extracted from all reddit.com URLs in your citation data. + + + + + Top cited subreddits — which Reddit communities AI models reference when answering your prompts + + + + +
+ {pageItems.map((sub) => { + const isExpanded = expandedSubreddit === sub.name; + return ( +
+
+ + +
+ {isExpanded && sub.urls.length > 0 && ( + + )} +
+ ); + })} +
+ +
+
+ ); +} diff --git a/apps/web/src/components/citations/shared.tsx b/apps/web/src/components/citations/shared.tsx new file mode 100644 index 0000000..14deb82 --- /dev/null +++ b/apps/web/src/components/citations/shared.tsx @@ -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 = Object.fromEntries( + CITATION_CATEGORIES.map((c) => [c, { label: CATEGORY_CONFIG[c].label, color: CATEGORY_CONFIG[c].chartColor }]), +); +export const PAGE_TYPE_META: Record = 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({ + tabs, + activeKey, + onSelect, +}: { + tabs: readonly { key: T; label: string }[]; + activeKey: T; + onSelect: (key: T) => void; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/citations/stats-cards.tsx b/apps/web/src/components/citations/stats-cards.tsx new file mode 100644 index 0000000..4bd3ac0 --- /dev/null +++ b/apps/web/src/components/citations/stats-cards.tsx @@ -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 ( + + + + {title} + + + + + {tooltip} + + + + +
{value}
+
+
+ ); +} + +export function CitationStatsCards({ + brandShare, + uniqueDomains, + totalCitations, +}: { + brandShare: number; + uniqueDomains: number; + totalCitations: number; +}) { + return ( +
+ + + {/* 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. */} + +
+ ); +} diff --git a/apps/web/src/components/citations/top-domains-card.tsx b/apps/web/src/components/citations/top-domains-card.tsx new file mode 100644 index 0000000..7326b26 --- /dev/null +++ b/apps/web/src/components/citations/top-domains-card.tsx @@ -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("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 ( + + +
+
+ + Top Cited Domains + + + + + + The most frequently cited domains across all prompt evaluations. Each domain is colored by its category (brand, competitor, etc.). + + + + + Which domains LLMs reference most when responding to your prompts + +
+
+ + { setDomainSearch(e.target.value); setVisibleDomains(maxDomains); }} + className="h-8 pl-8 text-xs" + /> +
+
+
+ + + {sourceTabs.length > 2 && ( +
+ { setSelectedCategory(key); setVisibleDomains(maxDomains); }} + /> +
+ )} + {/* 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 ? ( + <> + ({ + label: domain.domain, + count: domain.count, + category: domain.category || "other", + action: domain.category === "other" && brandId && competitors ? ( + + ) : undefined, + }))} + colorMapping={DOMAIN_CATEGORY_COLORS} + percentageMode="max" + /> + {filteredDomains.length > visibleDomains && visibleDomains < 100 && ( + + )} + + ) : ( +

No domains match the current filters.

+ )} +
+
+ ); +} diff --git a/apps/web/src/components/citations/top-urls-card.tsx b/apps/web/src/components/citations/top-urls-card.tsx new file mode 100644 index 0000000..a6ffd34 --- /dev/null +++ b/apps/web/src/components/citations/top-urls-card.tsx @@ -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("all"); + const [selectedPageType, setSelectedPageType] = useState("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 ( + + +
+
+ + Top Cited URLs + + + + + +

The specific pages most frequently cited by AI models. Filter by category to focus on brand, competitor, or other sources.

+

Competitor domains are only those in your {brandId ? tracked competitors list : "tracked competitors list"}.

+
+
+
+ + Individual pages cited by LLMs{brandIsCited && brandName && ( + <> — {brandName} accounts for {brandShare}% of all citations + )} + +
+
+ + setUrlSearch(e.target.value)} + className="h-8 pl-8 text-xs" + /> +
+
+
+ + + {sourceTabs.length > 2 && ( + + )} + {pageTypeTabs.length > 2 && ( +
+ {pageTypeTabs.map((t) => ( + + ))} +
+ )} +
+ {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 ( + +
+
+ + {getCategoryLabel(citation.category)} + + {citation.isNew && ( + NEW + )} + + {citation.title || extractFilenameFromUrl(citation.url)} + +
+
+ {domainPart} + {pathPart && {pathPart}} +
+
+
+ {citation.avgPosition != null && ( + + + avg {citation.avgPosition.toFixed(1)} + + + Average citation position (lower = cited earlier in the response) + + + )} + + + + {citation.count.toLocaleString()} + + + + Total times this URL was cited across all prompt evaluations + + + +
+
+ ); + })} + {filteredUrls.length === 0 && ( +

+ No URLs match the current filters. +

+ )} +
+
+
+ ); +} diff --git a/apps/web/src/components/citations/track-domain-popover.tsx b/apps/web/src/components/citations/track-domain-popover.tsx new file mode 100644 index 0000000..4d85621 --- /dev/null +++ b/apps/web/src/components/citations/track-domain-popover.tsx @@ -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 ( + + + + ); + } + + return ( + + + + + +
+

Track {domain}

+ + {error && ( +

{error}

+ )} + +
+
+

Add as brand domain

+ + + + + + Applies retroactively — all existing and future citations from this domain will be classified as your brand. + + +
+ +
+ + {competitors.length > 0 && ( +
+
+

Add to existing competitor

+ + + + + + Applies retroactively — all existing and future citations from this domain will be classified under the selected competitor. + + +
+
+ {competitors.map((c) => ( + + ))} +
+
+ )} + +
+

Or create new competitor:

+
+ setNewName(e.target.value)} + placeholder="Competitor name" + className="h-7 text-xs" + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleCreateNew(); } }} + disabled={saving} + /> + +
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/citations/trend-area-chart.tsx b/apps/web/src/components/citations/trend-area-chart.tsx new file mode 100644 index 0000000..011dcec --- /dev/null +++ b/apps/web/src/components/citations/trend-area-chart.tsx @@ -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>; + keys: string[]; + meta: Record; +}) { + // 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 ( + + + + {title} + + + + + {tooltip} + + + + + + + + formatDateStr(String(value), { month: "short", day: "numeric" })} + /> + `${value}%`} /> + { + if (!active || !payload?.length) return null; + const dp = payload[0]?.payload as Record | 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 ( +
+
{formattedDate}
+
+ {rows.map((r) => ( +
+ + {meta[r.k]?.label ?? r.k} + {r.value}% +
+ ))} +
+
+ ); + }} + /> + {/* 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) => ( + + ))} +
+
+
+
+ ); +} diff --git a/apps/web/src/components/citations/types.ts b/apps/web/src/components/citations/types.ts new file mode 100644 index 0000000..1b70c88 --- /dev/null +++ b/apps/web/src/components/citations/types.ts @@ -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; + 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>>; + pageTypeTimeSeries?: Array<{ date: string } & Partial>>; + 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 }[]; + }; +} diff --git a/apps/web/src/components/col-head.tsx b/apps/web/src/components/col-head.tsx new file mode 100644 index 0000000..df2ae89 --- /dev/null +++ b/apps/web/src/components/col-head.tsx @@ -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 ( + + {label} + + + + + + + {tip} + + + ); +} diff --git a/apps/web/src/components/competitors-editor.tsx b/apps/web/src/components/competitors-editor.tsx new file mode 100644 index 0000000..289291b --- /dev/null +++ b/apps/web/src/components/competitors-editor.tsx @@ -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 { + 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) => { + 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 ( +
+ {competitors.map((competitor, index) => ( +
+
+ +
+ {competitor.name ? ( + {competitor.name} + ) : ( + Unnamed competitor + )} + {competitor.domains.some(Boolean) && ( + + {competitor.domains.filter(Boolean)[0]} + + )} +
+ + +
+ + {competitor.expanded && ( +
+
+ + update(competitor._key, { name: e.target.value })} + placeholder="Competitor name" + className="bg-background" + disabled={disabled} + /> +
+ +
+ + update(competitor._key, { domains: values })} + placeholder="Add domain..." + maxItems={10} + normalizeValue={(raw) => cleanAndValidateDomain(raw) ?? raw.trim()} + onValidate={validateDomain} + /> +
+ +
+ + update(competitor._key, { aliases: values })} + placeholder="Add alias..." + maxItems={10} + /> +
+
+ )} +
+ ))} + + {competitors.length < MAX_COMPETITORS && ( + + )} + + {competitors.length >= MAX_COMPETITORS && ( +

+ Maximum of {MAX_COMPETITORS} competitors allowed. Remove a competitor to add a new one. +

+ )} + +

+ + {validCount}/{MAX_COMPETITORS} + {" "} + competitors configured +

+
+ ); +} diff --git a/apps/web/src/components/custom-range-calendar.tsx b/apps/web/src/components/custom-range-calendar.tsx new file mode 100644 index 0000000..194fad6 --- /dev/null +++ b/apps/web/src/components/custom-range-calendar.tsx @@ -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 `` — 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 ( +
+
+ + setFrom(e.target.value)} + className="h-8 text-sm" + /> +
+
+ + setTo(e.target.value)} + className="h-8 text-sm" + /> +
+ +
+ ); +} diff --git a/apps/web/src/components/demo-mode-pill.tsx b/apps/web/src/components/demo-mode-pill.tsx new file mode 100644 index 0000000..43eb939 --- /dev/null +++ b/apps/web/src/components/demo-mode-pill.tsx @@ -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 ( + + + + + + Demo + + + + This is a read-only demo. Any edits will fail. + + + + ); +} diff --git a/apps/web/src/components/fanout-sections.tsx b/apps/web/src/components/fanout-sections.tsx new file mode 100644 index 0000000..05d9626 --- /dev/null +++ b/apps/web/src/components/fanout-sections.tsx @@ -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 ( + + + + + + + {children} + + ); +} + +/** + * 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 ( +
+ {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. +
+ ); +} + +// --------------------------------------------------------------------------- +// 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; + /** When provided, replaces the plain total with per-model counts. */ + modelCounts?: VariationModelCount[]; +}) { + const seen = new Map(); + 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 ( +
+
+ {segs.map((s) => ( + + {s.text}{" "} + + ))} +
+ {modelCounts?.length ? ( + + {modelCounts.map((mc) => `${mc.count.toLocaleString()}× ${getModelDisplayName(mc.model)}`).join(" · ")} + + ) : ( + + {variation.count.toLocaleString()}× + + )} +
+ ); +} + +export function VariationsList({ + variations, + keywords, + totalUnique, + modelCounts, +}: { + variations: FanoutQueryStat[]; + keywords: Set; + /** 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; +}) { + if (variations.length === 0) { + return
No web queries for this selection.
; + } + return ( +
+ {variations.map((v) => ( + + ))} + {totalUnique !== undefined && totalUnique > variations.length && ( +
+ Top {variations.length} of {totalUnique.toLocaleString()} variations shown +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Query Words — the term cloud + Added / Preserved / Dropped word changes +// --------------------------------------------------------------------------- + +type WordTab = "added" | "preserved" | "dropped"; + +const WORD_TAB_HELP: Record = { + 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("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: {w.share}%, + })); + + return ( +
+ + + + + + + + +
+
+ + Word Changes + {WORD_TAB_HELP[tab]} + + How engines rewrite your prompt wording. +
+
+
+ + +
+ setTab(v as WordTab)}> + + Added + Preserved + Dropped + + +
+
+
+ + + {items.length > 0 ? ( + + ) : ( +
+ No {tab} words{hideStop ? " (try showing stop words)" : ""}. +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/components/filter-bar.tsx b/apps/web/src/components/filter-bar.tsx new file mode 100644 index 0000000..cf76a6a --- /dev/null +++ b/apps/web/src/components/filter-bar.tsx @@ -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 ; + const { iconId } = getModelMeta(model); + switch (iconId) { + case "openai": + return ; + case "anthropic": + return ; + case "google": + return ; + case "microsoft": + return ; + case "perplexity": + return ; + case "x": + return ; + case "mistral": + return ; + default: + return ; + } +} + +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 `` / +// `` 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 ( + + ); +} + +// ------------------------------------------------------------------ +// 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 ( + + + + + + + {availableModels.map((model) => ( + + {iconForModel(model)} + {labelForModel(model)} + + ))} + + + + ); +} + +// ------------------------------------------------------------------ +// 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 ( + { + setOpen(next); + if (!next) setShowCalendar(false); + }} + modal={false} + > + + } + label={getLookbackLabel(selected)} + active={isCustom} + /> + + + {showCalendar ? ( + + ) : ( +
+ {LOOKBACK_OPTIONS.map((opt) => ( + + ))} + +
+ )} +
+
+ ); +} + +// ------------------------------------------------------------------ +// 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 ( + + + } + label="Tags" + active={selected.length > 0} + badgeCount={selected.length > 0 ? selected.length : undefined} + /> + + e.preventDefault()}> +
+ Tags + {selected.length > 0 && ( + + )} +
+ {availableTags.length === 0 ? ( +

No tags available

+ ) : ( +
+ {availableTags.map((tag) => { + const checked = selected.includes(tag); + return ( +
{ + 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" + }`} + > + + {tag} +
+ ); + })} +
+ )} +
+
+ ); +} + +// ------------------------------------------------------------------ +// 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(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 ( +
+ + setLocal(e.target.value)} + placeholder={placeholder} + className="h-8 pl-8 pr-8 text-sm" + /> + {local && ( + + )} +
+ ); +} + +// ------------------------------------------------------------------ +// 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 ( + + {count.toLocaleString()} + {showTotal && ` of ${total.toLocaleString()}`} {count === 1 && !showTotal ? "result" : "results"} + + ); +} + +// ------------------------------------------------------------------ +// 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 ( +
+
+ {showModelSelector && } + + + {extraControls} + +
+ {showSearch && } +
+ ); +} + +// Data-fetching consumers that need the full filter set use +// `useListFilters` from "@/hooks/use-list-filters". diff --git a/apps/web/src/components/filtered-list-shell.tsx b/apps/web/src/components/filtered-list-shell.tsx new file mode 100644 index 0000000..83eadc7 --- /dev/null +++ b/apps/web/src/components/filtered-list-shell.tsx @@ -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 = ( +
+
+ +

{noMatchesTitle ?? "No results match your filters."}

+

+ {noMatchesDescription ?? "Try adjusting your search or filters."} +

+ +
+
+ ); + } else { + body = children; + } + + return ( + <> + + + {filterSectionExtras} + +
{body}
+ + ); +} diff --git a/apps/web/src/components/full-page-card.tsx b/apps/web/src/components/full-page-card.tsx new file mode 100644 index 0000000..8db0725 --- /dev/null +++ b/apps/web/src/components/full-page-card.tsx @@ -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 ( +
+
+
+ +
+ + {(title || subtitle) && ( + + {title && {title}} + {subtitle &&

{subtitle}

} +
+ )} + {children && ( + <> + {(title || subtitle) && } + + {children} + + + )} +
+ {customBackButton ? ( +
{customBackButton}
+ ) : showBackButton ? ( +
+ +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/history-button.tsx b/apps/web/src/components/history-button.tsx new file mode 100644 index 0000000..4f1b8f0 --- /dev/null +++ b/apps/web/src/components/history-button.tsx @@ -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 ( + + ); +} diff --git a/apps/web/src/components/list-pagination.tsx b/apps/web/src/components/list-pagination.tsx new file mode 100644 index 0000000..ddc716a --- /dev/null +++ b/apps/web/src/components/list-pagination.tsx @@ -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 ( +
+ + {start.toLocaleString()}–{end.toLocaleString()} of {totalItems.toLocaleString()} + +
+ + +
+
+ ); +} + +/** 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(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 }; +} diff --git a/apps/web/src/components/logo.tsx b/apps/web/src/components/logo.tsx new file mode 100644 index 0000000..82d7c8e --- /dev/null +++ b/apps/web/src/components/logo.tsx @@ -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 ( +
+ + Getcito + +
+ ); + } + + return ( +
+ {branding?.icon && ( + {`${branding.name} + )} + + {branding?.name} + +
+ ); +} diff --git a/apps/web/src/components/lookback-selector.tsx b/apps/web/src/components/lookback-selector.tsx new file mode 100644 index 0000000..7932ae1 --- /dev/null +++ b/apps/web/src/components/lookback-selector.tsx @@ -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 ( +
+ {(["1w", "1m", "3m", "6m", "1y", "all"] as LookbackPeriod[]).map((period) => ( + + ))} + + + + + + + + +
+ ); +} + +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); +} diff --git a/apps/web/src/components/missing-env-page.tsx b/apps/web/src/components/missing-env-page.tsx new file mode 100644 index 0000000..eed2d0e --- /dev/null +++ b/apps/web/src/components/missing-env-page.tsx @@ -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 ( + +
+

{localHint}

+
    + {sortedMissing.map((item) => ( +
  • + + {item.label} + + {item.description ? ( + + {item.description} + + ) : null} +
  • + ))} +
+
+
+ ); +} diff --git a/apps/web/src/components/nav-app-info.test.tsx b/apps/web/src/components/nav-app-info.test.tsx new file mode 100644 index 0000000..be315e2 --- /dev/null +++ b/apps/web/src/components/nav-app-info.test.tsx @@ -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(); + + 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"); + }); +}); diff --git a/apps/web/src/components/nav-app-info.tsx b/apps/web/src/components/nav-app-info.tsx new file mode 100644 index 0000000..8e925cc --- /dev/null +++ b/apps/web/src/components/nav-app-info.tsx @@ -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 ( +
+ + v{__APP_VERSION__} + +
+ + + + + + + Getcito.com + + + + + + + + View on GitHub + +
+
+ ); +} diff --git a/apps/web/src/components/nav-main.tsx b/apps/web/src/components/nav-main.tsx new file mode 100644 index 0000000..e5275b8 --- /dev/null +++ b/apps/web/src/components/nav-main.tsx @@ -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) => ( + + {group.label} + + {group.items.map((item) => ( + + + setOpenMobile(false)} + > + {item.icon && } + {item.title} + + + + ))} + + + ))} + + ); +} diff --git a/apps/web/src/components/nav-user.tsx b/apps/web/src/components/nav-user.tsx new file mode 100644 index 0000000..17ef7e6 --- /dev/null +++ b/apps/web/src/components/nav-user.tsx @@ -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 ( + + + + + + + + + + + +
+ {user.name} + {isNameEmailSame ? "Your Account" : user.email} +
+ +
+
+ + +
+ + + + + + +
+ {user.name} + {isNameEmailSame ? "Your Account" : user.email} +
+
+
+ + + {clientConfig?.features.readOnly ? ( + + + Switch Brand (Disabled in Demo) + + ) : ( + + setOpenMobile(false)}> + + Switch Brand + + + )} + {clientConfig?.branding.parentUrl && clientConfig?.branding.parentName && ( + + + + {clientConfig.branding.parentName} Dashboard + + + )} + + + { + authClient.signOut({ + fetchOptions: { + onSuccess: () => { + resetPostHog(); + window.location.href = "/auth/logout"; + }, + }, + }); + }} + > + + Log out + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/opportunities-report.tsx b/apps/web/src/components/opportunities-report.tsx new file mode 100644 index 0000000..462cfce --- /dev/null +++ b/apps/web/src/components/opportunities-report.tsx @@ -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 ; +} + +function BulletList({ items }: { items: string[] }) { + return ( +
    + {items.map((item, i) => ( +
  • + + {item} +
  • + ))} +
+ ); +} + +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 {prompt.text}; + return ( + + {prompt.text} + + ); +} + +function CiteLink({ page }: { page: CitedPage }) { + return ( + + {page.title || page.domain} · {page.domain} + + ); +} + +function Panel({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function OpportunityCard({ o, brandId }: { o: Opportunity; brandId: string }) { + const [open, setOpen] = useState(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 ( +
+

{o.title}

+

{o.why}

+ +
+
+ {tabs.map((t) => ( + + ))} +
+ + {open === "prompts" && ( + + {o.relatedPrompts.length === 0 ? ( +

No specific prompts linked.

+ ) : ( + o.relatedPrompts.map((p, i) => ) + )} +
+ )} + {open === "your" && ( + + {o.yourCitations.length === 0 ? ( +

You're not cited for these prompts yet.

+ ) : ( + o.yourCitations.map((c, i) => ) + )} +
+ )} + {open === "comp" && ( + + {o.competitorCitations.length === 0 ? ( +

No competitor pages cited for these prompts.

+ ) : ( + o.competitorCitations.map((c, i) => ) + )} +
+ )} +
+
+ ); +} + +export function OpportunitiesReport({ report, brandId }: { report: OpportunitiesReportData; brandId: string }) { + return ( +
+ {report.summary.length > 0 && ( +
+

Summary

+
+ +
+
+ )} + + {CATEGORY_META.map((c) => { + const opps = report.opportunities.filter((o) => o.category === c.key); + if (opps.length === 0) return null; + return ( +
+
+

+ {c.label} ({opps.length}) +

+

{c.desc}

+
+
+ {opps.map((o, i) => ( + + ))} +
+
+ ); + })} + + {report.risks.length > 0 && ( +
+

Reality Check

+ +
+ )} + +

+ Generated by AI from your tracked citation data. Suggestions are a starting point — apply your own judgment + before acting. +

+
+ ); +} diff --git a/apps/web/src/components/page-header.tsx b/apps/web/src/components/page-header.tsx new file mode 100644 index 0000000..6131902 --- /dev/null +++ b/apps/web/src/components/page-header.tsx @@ -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 ( +
+
+

+ {title} + {infoContent && ( + + + + + + {infoContent} + + + )} +

+

{subtitle}

+
+ {children} +
+ ); +} + +export function PageHeaderTitleSkeleton() { + return ( +
+ + +
+ ); +} + +/** Wrapper for the filter bar + visibility bar sitting under the page title. */ +export function FilterSection({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/apps/web/src/components/progress-bar-chart.tsx b/apps/web/src/components/progress-bar-chart.tsx new file mode 100644 index 0000000..50f50b0 --- /dev/null +++ b/apps/web/src/components/progress-bar-chart.tsx @@ -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; +}; + +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 ( +
+ {items.map((item) => { + const percentage = calculatePercentage(item.count); + const color = getItemColor(item); + const isHighlighted = highlightLabel && item.label === highlightLabel; + const isClickable = !!item.onClick; + + return ( +
+
+
+ {showColorIndicator && ( +
+ )} + {item.tooltip ? ( + + + + {item.label} + + + {item.tooltip} + + ) : ( + + {item.label} + + )} + {item.icon} + {item.action} +
+
+ {item.count.toLocaleString()} + {item.suffix} +
+
+ {item.subtitle && ( +

{item.subtitle}

+ )} +
+
+
+
+ ); + })} +
+ ); +} + +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 = { + 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), + ), +}; diff --git a/apps/web/src/components/prompt-chart-print.tsx b/apps/web/src/components/prompt-chart-print.tsx new file mode 100644 index 0000000..f26f69c --- /dev/null +++ b/apps/web/src/components/prompt-chart-print.tsx @@ -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 = {}; + 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 ( + + + {promptName} + + + +
+ + {message} + +
+
+ +
+ ); + } + + // Show "No brands found" message when there's no data + if (!hasVisibilityData) { + return ( + + + {promptName} + + + +
+
+

+ No brands found in responses +

+

+ Your brand and competitors weren't mentioned in the evaluated responses for this prompt. +

+
+
+
+ +
+ ); + } + + const badgeClasses = isReportContext && badgeValue !== null + ? getSoVBadgeClasses(badgeValue) + : badgeValue !== null + ? { variant: getBadgeVariant(badgeValue) as "default" | "secondary" | "destructive", className: getBadgeClassName(badgeValue) } + : null; + + return ( + + + {promptName} +
+ {badgeClasses && badgeValue !== null && ( + + {badgeValue}% {badgeLabel} + + )} +
+
+ + + + + +
+ ); +} diff --git a/apps/web/src/components/prompt-order-dropdown.tsx b/apps/web/src/components/prompt-order-dropdown.tsx new file mode 100644 index 0000000..82a7a6b --- /dev/null +++ b/apps/web/src/components/prompt-order-dropdown.tsx @@ -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) => ({ + ...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 ( + + + } + label={label} + active={selected !== DEFAULT_PROMPT_ORDER} + /> + + + setOrder(v as PromptOrder)}> + {PROMPT_ORDER_OPTIONS.map((o) => ( + + {o.label} + + ))} + + + + ); +} diff --git a/apps/web/src/components/prompt-wizard.tsx b/apps/web/src/components/prompt-wizard.tsx new file mode 100644 index 0000000..344eb2f --- /dev/null +++ b/apps/web/src/components/prompt-wizard.tsx @@ -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; + }) => ( +
+ +

+ + {items.length}/{maxItems} + {" "} + {items.length >= maxItems ? "items added. Remove an item to add a new one." : "items entered."} +

+
+ ), +); +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(null); + const [submitError, setSubmitError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + const [data, setData] = useState(() => { + 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 ( +
+
+

Analyzing {brand?.name || "Brand"}

+

+ 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. +

+
+ + {error ? ( +
+
+ + {error} +
+ +
+ ) : ( +
+ + Analyzing brand data... +
+ )} +
+ ); + } + + return ( +
+
+
+ +
+

Brand details

+

+ Confirm the brand identity, additional domains, and aliases used for tracking. +

+
+
+
+
+

Brand name

+ updateBrandName(e.target.value)} + placeholder="Brand name" + /> +
+
+

Website URL

+ updateWebsite(e.target.value)} + placeholder="https://example.com" + /> +
+
+

Additional domains

+ +
+
+

Aliases

+ +
+
+
+
+

Short description

+