From faf7a294a3d0b51e5990929b8cdb4d5e95ae2ba0 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:48:38 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20AI=20Visibility=20=E2=80=94=20Brand=20L?= =?UTF-8?q?ookup=20and=20Prompt=20Explorer=20(#128)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 + pnpm-lock.yaml | 891 +++++++++++++++++- scripts/brand-lookup-cost-profile.ts | 191 ++++ .../components/table/SortableHeader.tsx | 43 + src/client/components/table/nullSafeSort.ts | 76 ++ .../features/ai-search/BrandLookupPage.tsx | 210 +++++ .../features/ai-search/PromptExplorerPage.tsx | 256 +++++ .../ai-search/brandLookupFilterTypes.ts | 33 + .../ai-search/brandLookupFiltering.ts | 93 ++ .../components/AiSearchLoadingState.tsx | 29 + .../components/AiSearchPaidPlanGate.tsx | 49 + .../components/BrandLookupCitationTables.tsx | 210 +++++ .../components/BrandLookupFilterPanel.tsx | 235 +++++ .../components/BrandLookupHistorySection.tsx | 24 + .../BrandLookupMentionTrendCard.tsx | 95 ++ .../components/BrandLookupResults.tsx | 420 +++++++++ .../components/BrandLookupSearchCard.tsx | 90 ++ .../ai-search/components/MarkdownAnswer.tsx | 282 ++++++ .../components/PromptExplorerForm.tsx | 190 ++++ .../PromptExplorerHistorySection.tsx | 32 + .../components/PromptExplorerLoadingState.tsx | 32 + .../components/PromptExplorerResults.tsx | 214 +++++ .../components/SearchHistorySection.tsx | 94 ++ .../features/ai-search/platformLabels.ts | 96 ++ src/client/features/ai-search/urlDisplay.ts | 24 + .../ai-search/useBrandLookupFilters.ts | 94 ++ .../backlinks/BacklinksTableColumns.tsx | 90 +- .../backlinks/BacklinksTableHeaders.tsx | 182 ---- .../backlinks/ReferringDomainsTable.tsx | 207 +++- .../features/backlinks/TopPagesTable.tsx | 155 ++- .../features/backlinks/backlinksFiltering.ts | 6 +- .../backlinks/backlinksTableSorting.ts | 167 ---- .../domain/components/DomainSearchCard.tsx | 11 +- .../hooks/useBrandLookupSearchHistory.ts | 20 + .../hooks/usePromptExplorerSearchHistory.ts | 48 + .../hooks/useTimestampedSearchHistory.ts | 46 + src/client/layout/AppShell.tsx | 4 +- src/client/navigation/items.ts | 23 +- src/client/styles/app.css | 4 +- src/routeTree.gen.ts | 44 + .../_project/p/$projectId/brand-lookup.tsx | 30 + .../_project/p/$projectId/prompt-explorer.tsx | 11 + src/server/features/ai-search/safeUrl.test.ts | 50 + src/server/features/ai-search/safeUrl.ts | 39 + .../ai-search/services/brandLookup.ts | 413 ++++++++ .../ai-search/services/promptExplorer.ts | 297 ++++++ .../ai-search/targetDetection.test.ts | 48 + .../features/ai-search/targetDetection.ts | 30 + src/server/lib/dataforseoClient.test.ts | 37 + src/server/lib/dataforseoClient.ts | 33 +- src/server/lib/dataforseoLlm.ts | 362 +++++++ src/server/lib/dataforseoLlmSchemas.ts | 159 ++++ src/serverFunctions/ai-search.ts | 41 + src/shared/billing.ts | 12 + src/types/schemas/ai-search.ts | 203 ++++ 55 files changed, 6289 insertions(+), 489 deletions(-) create mode 100644 scripts/brand-lookup-cost-profile.ts create mode 100644 src/client/components/table/SortableHeader.tsx create mode 100644 src/client/components/table/nullSafeSort.ts create mode 100644 src/client/features/ai-search/BrandLookupPage.tsx create mode 100644 src/client/features/ai-search/PromptExplorerPage.tsx create mode 100644 src/client/features/ai-search/brandLookupFilterTypes.ts create mode 100644 src/client/features/ai-search/brandLookupFiltering.ts create mode 100644 src/client/features/ai-search/components/AiSearchLoadingState.tsx create mode 100644 src/client/features/ai-search/components/AiSearchPaidPlanGate.tsx create mode 100644 src/client/features/ai-search/components/BrandLookupCitationTables.tsx create mode 100644 src/client/features/ai-search/components/BrandLookupFilterPanel.tsx create mode 100644 src/client/features/ai-search/components/BrandLookupHistorySection.tsx create mode 100644 src/client/features/ai-search/components/BrandLookupMentionTrendCard.tsx create mode 100644 src/client/features/ai-search/components/BrandLookupResults.tsx create mode 100644 src/client/features/ai-search/components/BrandLookupSearchCard.tsx create mode 100644 src/client/features/ai-search/components/MarkdownAnswer.tsx create mode 100644 src/client/features/ai-search/components/PromptExplorerForm.tsx create mode 100644 src/client/features/ai-search/components/PromptExplorerHistorySection.tsx create mode 100644 src/client/features/ai-search/components/PromptExplorerLoadingState.tsx create mode 100644 src/client/features/ai-search/components/PromptExplorerResults.tsx create mode 100644 src/client/features/ai-search/components/SearchHistorySection.tsx create mode 100644 src/client/features/ai-search/platformLabels.ts create mode 100644 src/client/features/ai-search/urlDisplay.ts create mode 100644 src/client/features/ai-search/useBrandLookupFilters.ts delete mode 100644 src/client/features/backlinks/BacklinksTableHeaders.tsx delete mode 100644 src/client/features/backlinks/backlinksTableSorting.ts create mode 100644 src/client/hooks/useBrandLookupSearchHistory.ts create mode 100644 src/client/hooks/usePromptExplorerSearchHistory.ts create mode 100644 src/client/hooks/useTimestampedSearchHistory.ts create mode 100644 src/routes/_project/p/$projectId/brand-lookup.tsx create mode 100644 src/routes/_project/p/$projectId/prompt-explorer.tsx create mode 100644 src/server/features/ai-search/safeUrl.test.ts create mode 100644 src/server/features/ai-search/safeUrl.ts create mode 100644 src/server/features/ai-search/services/brandLookup.ts create mode 100644 src/server/features/ai-search/services/promptExplorer.ts create mode 100644 src/server/features/ai-search/targetDetection.test.ts create mode 100644 src/server/features/ai-search/targetDetection.ts create mode 100644 src/server/lib/dataforseoLlm.ts create mode 100644 src/server/lib/dataforseoLlmSchemas.ts create mode 100644 src/serverFunctions/ai-search.ts create mode 100644 src/types/schemas/ai-search.ts diff --git a/package.json b/package.json index 38d9cfb..e95a5e9 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "test:watch": "vitest", "test:ci": "vitest run --reporter=dot", "billing:backlinks": "tsx scripts/backlinks-cost-profile.ts", + "billing:brand-lookup": "tsx scripts/brand-lookup-cost-profile.ts", "seed:rank-tracking": "tsx scripts/seed-rank-tracking.ts", "ci:check": "prettier --check . && knip && tsc --noEmit && oxlint . --type-aware" }, @@ -71,7 +72,9 @@ "posthog-node": "^5.28.5", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-markdown": "^10.1.0", "recharts": "^3.7.0", + "remark-gfm": "^4.0.1", "remeda": "^2.33.6", "robots-parser": "^3.0.1", "sonner": "^2.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a44ab0d..a91b456 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,10 +34,10 @@ importers: version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) autumn-js: specifier: ^1.1.7 - version: 1.1.7(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))(react@19.2.4) + version: 1.1.7(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))(react@19.2.4) better-auth: specifier: ^1.5.5 - version: 1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + version: 1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) cheerio: specifier: ^1.2.0 version: 1.2.0 @@ -77,9 +77,15 @@ importers: react-dom: specifier: ^19.0.0 version: 19.2.4(react@19.2.4) + react-markdown: + specifier: ^10.1.0 + version: 10.1.0(@types/react@19.2.14)(react@19.2.4) recharts: specifier: ^3.7.0 version: 3.7.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.4)(react@19.2.4)(redux@5.0.1) + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 remeda: specifier: ^2.33.6 version: 2.33.6 @@ -164,7 +170,7 @@ importers: version: 5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) wrangler: specifier: ^4.45.3 version: 4.67.0(@cloudflare/workers-types@4.20260302.0) @@ -2082,12 +2088,27 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node-fetch@2.6.13': resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} @@ -2111,6 +2132,12 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} @@ -2123,6 +2150,9 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -2219,6 +2249,9 @@ packages: babel-dead-code-elimination@1.0.12: resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + baseline-browser-mapping@2.10.0: resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} engines: {node: '>=6.0.0'} @@ -2331,6 +2364,9 @@ packages: caniuse-lite@1.0.30001774: resolution: {integrity: sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -2339,6 +2375,18 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} @@ -2365,6 +2413,9 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -2461,6 +2512,9 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + decode-uri-component@0.4.1: resolution: {integrity: sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==} engines: {node: '>=14.16'} @@ -2476,6 +2530,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.0.2: resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} engines: {node: '>=8'} @@ -2484,6 +2542,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diff@8.0.4: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} @@ -2675,11 +2736,18 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -2697,6 +2765,9 @@ packages: exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -2822,6 +2893,15 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} @@ -2838,14 +2918,26 @@ packages: immer@11.1.4: resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + internmap@2.0.3: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -2854,10 +2946,17 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + isbot@5.1.37: resolution: {integrity: sha512-5bcicX81xf6NlTEV8rWdg7Pk01LFizDetuYGHx6d/f6y3lR2/oo8IfxjzJqn1UdDEyCcwT9e7NRloj8DwCYujQ==} engines: {node: '>=18'} @@ -2999,6 +3098,9 @@ packages: long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -3013,10 +3115,58 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + memory-pager@1.5.0: resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==} @@ -3024,6 +3174,90 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -3135,6 +3369,9 @@ packages: papaparse@5.5.3: resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse5-htmlparser2-tree-adapter@7.1.0: resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} @@ -3210,6 +3447,9 @@ packages: promise-limit@2.7.0: resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==} + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + protobufjs@7.5.4: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} engines: {node: '>=12.0.0'} @@ -3236,6 +3476,12 @@ packages: react-is@19.2.4: resolution: {integrity: sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==} + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + react-redux@9.2.0: resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} peerDependencies: @@ -3280,6 +3526,18 @@ packages: redux@5.0.1: resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + remeda@2.33.6: resolution: {integrity: sha512-tazDGH7s75kUPGBKLvhgBEHMgW+TdDFhjUAMdQj57IoWz6HsGa5D2RX5yDUz6IIqiRRvZiaEHzCzWdTeixc/Kg==} @@ -3389,6 +3647,9 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + sparse-bitfield@3.0.3: resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==} @@ -3407,6 +3668,9 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-json-comments@5.0.3: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} @@ -3417,6 +3681,12 @@ packages: strnum@2.1.2: resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} @@ -3471,6 +3741,12 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} @@ -3514,6 +3790,24 @@ packages: unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + unplugin@2.3.11: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} @@ -3529,6 +3823,12 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + victory-vendor@37.3.6: resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} @@ -3742,6 +4042,9 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@babel/code-frame@7.27.1': @@ -5271,10 +5574,28 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.8 + '@types/estree@1.0.8': {} + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + '@types/node-fetch@2.6.13': dependencies: '@types/node': 22.19.11 @@ -5303,6 +5624,10 @@ snapshots: '@types/trusted-types@2.0.7': optional: true + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + '@types/use-sync-external-store@0.0.6': {} '@types/webidl-conversions@7.0.3': {} @@ -5315,6 +5640,8 @@ snapshots: dependencies: '@types/node': 22.19.11 + '@ungap/structured-clone@1.3.0': {} + '@vitejs/plugin-react@4.7.0(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 @@ -5396,13 +5723,13 @@ snapshots: asynckit@0.4.0: {} - autumn-js@1.1.7(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))(react@19.2.4): + autumn-js@1.1.7(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))(react@19.2.4): dependencies: query-string: 9.3.1 rou3: 0.6.3 zod: 4.3.6 optionalDependencies: - better-auth: 1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + better-auth: 1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) better-call: 1.3.2(zod@4.3.6) react: 19.2.4 @@ -5415,9 +5742,11 @@ snapshots: transitivePeerDependencies: - supports-color + bail@2.0.2: {} + baseline-browser-mapping@2.10.0: {} - better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)): + better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)): dependencies: '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1) '@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)) @@ -5444,7 +5773,7 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) solid-js: 1.9.11 - vitest: 3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) + vitest: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) transitivePeerDependencies: - '@cloudflare/workers-types' @@ -5488,6 +5817,8 @@ snapshots: caniuse-lite@1.0.30001774: {} + ccount@2.0.1: {} + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -5498,6 +5829,14 @@ snapshots: chalk@5.6.2: {} + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + check-error@2.1.3: {} cheerio-select@2.1.0: @@ -5553,6 +5892,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} + convert-source-map@2.0.0: {} cookie-es@2.0.1: {} @@ -5631,6 +5972,10 @@ snapshots: decimal.js-light@2.5.1: {} + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + decode-uri-component@0.4.1: {} deep-eql@5.0.2: {} @@ -5639,10 +5984,16 @@ snapshots: delayed-stream@1.0.0: {} + dequal@2.0.3: {} + detect-libc@2.0.2: {} detect-libc@2.1.2: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + diff@8.0.4: {} dom-serializer@2.0.0: @@ -5820,8 +6171,12 @@ snapshots: escalade@3.2.0: {} + escape-string-regexp@5.0.0: {} + esprima@4.0.1: {} + estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -5834,6 +6189,8 @@ snapshots: exsolve@1.0.8: {} + extend@3.0.2: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -5955,6 +6312,32 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.8 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + html-url-attributes@3.0.1: {} + htmlparser2@10.1.0: dependencies: domelementtype: 2.3.0 @@ -5974,20 +6357,35 @@ snapshots: immer@11.1.4: {} + inline-style-parser@0.2.7: {} + internmap@2.0.3: {} + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 + is-decimal@2.0.1: {} + is-extglob@2.1.1: {} is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} + is-number@7.0.0: {} + is-plain-obj@4.1.0: {} + isbot@5.1.37: {} isexe@2.0.0: {} @@ -6104,6 +6502,8 @@ snapshots: long@5.3.2: {} + longest-streak@3.1.0: {} + loupe@3.2.1: {} lru-cache@5.1.1: @@ -6118,12 +6518,358 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + markdown-table@3.0.4: {} + math-intrinsics@1.1.0: {} + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + memory-pager@1.5.0: {} merge2@1.4.1: {} + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -6243,6 +6989,16 @@ snapshots: papaparse@5.5.3: {} + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + parse5-htmlparser2-tree-adapter@7.1.0: dependencies: domhandler: 5.0.3 @@ -6310,6 +7066,8 @@ snapshots: promise-limit@2.7.0: {} + property-information@7.1.0: {} + protobufjs@7.5.4: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -6344,6 +7102,24 @@ snapshots: react-is@19.2.4: {} + react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4): + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/react': 19.2.14 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 19.2.4 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1): dependencies: '@types/use-sync-external-store': 0.0.6 @@ -6395,6 +7171,40 @@ snapshots: redux@5.0.1: {} + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + remeda@2.33.6: {} reselect@5.1.1: {} @@ -6527,6 +7337,8 @@ snapshots: source-map@0.7.6: {} + space-separated-tokens@2.0.2: {} + sparse-bitfield@3.0.3: dependencies: memory-pager: 1.5.0 @@ -6539,6 +7351,11 @@ snapshots: std-env@3.10.0: {} + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-json-comments@5.0.3: {} strip-literal@3.1.0: @@ -6547,6 +7364,14 @@ snapshots: strnum@2.1.2: {} + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + supports-color@10.2.2: {} tailwindcss@4.2.1: {} @@ -6586,6 +7411,10 @@ snapshots: dependencies: punycode: 2.3.1 + trim-lines@3.0.1: {} + + trough@2.2.0: {} + tsconfck@3.1.6(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 @@ -6615,6 +7444,39 @@ snapshots: dependencies: pathe: 2.0.3 + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + unplugin@2.3.11: dependencies: '@jridgewell/remapping': 2.3.5 @@ -6632,6 +7494,16 @@ snapshots: dependencies: react: 19.2.4 + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + victory-vendor@37.3.6: dependencies: '@types/d3-array': 3.2.2 @@ -6700,7 +7572,7 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) - vitest@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0): + vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -6726,6 +7598,7 @@ snapshots: vite-node: 3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: + '@types/debug': 4.1.13 '@types/node': 22.19.11 transitivePeerDependencies: - jiti @@ -6836,3 +7709,5 @@ snapshots: zod@3.25.76: {} zod@4.3.6: {} + + zwitch@2.0.4: {} diff --git a/scripts/brand-lookup-cost-profile.ts b/scripts/brand-lookup-cost-profile.ts new file mode 100644 index 0000000..620626d --- /dev/null +++ b/scripts/brand-lookup-cost-profile.ts @@ -0,0 +1,191 @@ +import process from "node:process"; +import { + buildLlmTarget, + CHATGPT_LANGUAGE_CODE, + CHATGPT_LOCATION_CODE, + fetchLlmAggregatedMetricsRaw, + fetchLlmMentionsSearchRaw, + fetchLlmTopPagesRaw, + type LlmPlatform, +} from "@/server/lib/dataforseoLlm"; +import { applyBillingMarkupUsd } from "@/shared/billing"; +import { loadLocalEnv, parseArgs } from "./cli-utils"; + +loadLocalEnv(); + +const args = parseArgs(process.argv.slice(2)); + +await main(); + +/** + * Confirm what a single Brand Lookup actually costs against DataForSEO. + * Mirrors `backlinks-cost-profile.ts` but reports per-call USD cost so we can + * verify the on-screen "Est. $X" against reality. + */ +async function main() { + if (process.env.CI === "true" && args.allowCi !== "true") { + printUsageAndExit( + "Refusing to run live billing checks in CI without --allowCi=true.", + ); + } + + if (args.confirmLive !== "true") { + printUsageAndExit( + "This command makes live, billable DataForSEO requests. Re-run with --confirmLive=true.", + ); + } + + if (!process.env.DATAFORSEO_API_KEY) { + printUsageAndExit("Missing DATAFORSEO_API_KEY."); + } + + const target = args.target; + if (!target) { + printUsageAndExit("Missing --target."); + } + + const targetType = parseTargetType(args.targetType); + const userLocationCode = parsePositiveInteger(args.locationCode, 2840); + const userLanguageCode = args.languageCode ?? "en"; + const repeat = parsePositiveInteger(args.repeat, 1); + + const llmTarget = buildLlmTarget({ type: targetType, value: target }); + const platforms: LlmPlatform[] = ["chat_gpt", "google"]; + + const allRuns: RunSummary[] = []; + + for (let runIndex = 0; runIndex < repeat; runIndex += 1) { + const calls: CallRecord[] = []; + + for (const platform of platforms) { + // ChatGPT data is only indexed for US/en, mirroring the production + // brandLookup service. + const locationCode = + platform === "chat_gpt" ? CHATGPT_LOCATION_CODE : userLocationCode; + const languageCode = + platform === "chat_gpt" ? CHATGPT_LANGUAGE_CODE : userLanguageCode; + + const aggregated = await fetchLlmAggregatedMetricsRaw({ + target: llmTarget, + platform, + locationCode, + languageCode, + internalListLimit: 20, + }); + calls.push(toRecord(platform, "aggregated_metrics", aggregated.billing)); + + const topPages = await fetchLlmTopPagesRaw({ + target: llmTarget, + platform, + locationCode, + languageCode, + itemsListLimit: 10, + }); + calls.push(toRecord(platform, "top_pages", topPages.billing)); + + const mentions = await fetchLlmMentionsSearchRaw({ + target: llmTarget, + platform, + locationCode, + languageCode, + limit: 25, + }); + calls.push(toRecord(platform, "mentions_search", mentions.billing)); + } + + const totalRawUsd = sum(calls.map((c) => c.rawUsd)); + allRuns.push({ + run: runIndex + 1, + calls, + totalRawUsd: round(totalRawUsd), + totalBilledUsd: applyBillingMarkupUsd(totalRawUsd), + }); + } + + const aggregateRawUsd = sum(allRuns.map((r) => r.totalRawUsd)); + const aggregateBilledUsd = applyBillingMarkupUsd(aggregateRawUsd); + + console.log( + JSON.stringify( + { + input: { + target, + targetType, + userLocationCode, + userLanguageCode, + repeat, + }, + runs: allRuns, + aggregate: { + totalRawUsd: round(aggregateRawUsd), + totalBilledUsd: aggregateBilledUsd, + avgRawPerLookupUsd: round(aggregateRawUsd / allRuns.length), + avgBilledPerLookupUsd: round(aggregateBilledUsd / allRuns.length), + }, + }, + null, + 2, + ), + ); +} + +type CallRecord = { + platform: LlmPlatform; + endpoint: string; + path: string; + resultCount: number | null; + rawUsd: number; + billedUsd: number; +}; + +type RunSummary = { + run: number; + calls: CallRecord[]; + totalRawUsd: number; + totalBilledUsd: number; +}; + +function toRecord( + platform: LlmPlatform, + endpoint: string, + billing: { costUsd: number; path: string[]; resultCount: number | null }, +): CallRecord { + return { + platform, + endpoint, + path: billing.path.join("/"), + resultCount: billing.resultCount, + rawUsd: round(billing.costUsd), + billedUsd: applyBillingMarkupUsd(billing.costUsd), + }; +} + +function parseTargetType(value: string | undefined): "domain" | "keyword" { + if (!value || value === "domain") return "domain"; + if (value === "keyword") return "keyword"; + printUsageAndExit( + `Invalid --targetType: ${value}. Expected domain or keyword.`, + ); +} + +function parsePositiveInteger(value: string | undefined, fallback: number) { + if (!value) return fallback; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function sum(values: number[]): number { + return values.reduce((total, value) => total + value, 0); +} + +function round(value: number): number { + return Math.round(value * 1_000_000) / 1_000_000; +} + +function printUsageAndExit(message: string): never { + console.error(message); + console.error( + "Usage: pnpm billing:brand-lookup --target=example.com --confirmLive=true [--targetType=domain|keyword] [--locationCode=2840] [--languageCode=en] [--repeat=1] [--allowCi=true]", + ); + process.exit(1); +} diff --git a/src/client/components/table/SortableHeader.tsx b/src/client/components/table/SortableHeader.tsx new file mode 100644 index 0000000..353dd63 --- /dev/null +++ b/src/client/components/table/SortableHeader.tsx @@ -0,0 +1,43 @@ +import { ArrowDown, ArrowUp } from "lucide-react"; +import { HeaderHelpLabel } from "@/client/features/keywords/components"; + +type SortableColumn = { + getIsSorted: () => false | "asc" | "desc"; + getToggleSortingHandler: () => ((event: unknown) => void) | undefined; +}; + +export function SortableHeader({ + column, + label, + helpText, + align, +}: { + column: SortableColumn; + label: string; + helpText?: string; + align?: "left" | "right"; +}) { + const sorted = column.getIsSorted(); + const content = ( + + ); + + if (align === "right") { + return {content}; + } + + return content; +} diff --git a/src/client/components/table/nullSafeSort.ts b/src/client/components/table/nullSafeSort.ts new file mode 100644 index 0000000..66e8333 --- /dev/null +++ b/src/client/components/table/nullSafeSort.ts @@ -0,0 +1,76 @@ +import type { Row } from "@tanstack/react-table"; + +/** + * Null/undefined-aware sorting functions that keep blank rows at the bottom + * regardless of direction. TanStack's built-in `sortUndefined: "last"` gets + * inverted by the desc sign flip — these helpers read the column's sort + * direction from the cell context and return a value that survives the flip. + */ + +export function isDescending( + row: Row, + columnId: string, +): boolean { + const cell = row.getAllCells().find((c) => c.column.id === columnId); + return cell?.column.getIsSorted() === "desc"; +} + +/** + * Compare two nullable numeric values with nulls always at the bottom, + * regardless of the column's current sort direction. Use this directly for + * tiebreakers or when the value isn't the column's accessor. + */ +export function compareNumericNullsLast( + a: number | null | undefined, + b: number | null | undefined, + descending: boolean, +): number { + if (a == null && b == null) return 0; + if (a == null || b == null) { + const sign = descending ? -1 : 1; + return (a == null ? 1 : -1) * sign; + } + return a - b; +} + +export function numericNullsLast( + rowA: Row, + rowB: Row, + columnId: string, +): number { + return compareNumericNullsLast( + rowA.getValue(columnId), + rowB.getValue(columnId), + isDescending(rowA, columnId), + ); +} + +export function stringNullsLast( + rowA: Row, + rowB: Row, + columnId: string, +): number { + const a = rowA.getValue(columnId); + const b = rowB.getValue(columnId); + if (!a && !b) return 0; + if (!a || !b) { + const sign = isDescending(rowA, columnId) ? -1 : 1; + return (!a ? 1 : -1) * sign; + } + return a.toLowerCase().localeCompare(b.toLowerCase()); +} + +export function dateNullsLast( + rowA: Row, + rowB: Row, + columnId: string, +): number { + const a = rowA.getValue(columnId); + const b = rowB.getValue(columnId); + if (!a && !b) return 0; + if (!a || !b) { + const sign = isDescending(rowA, columnId) ? -1 : 1; + return (!a ? 1 : -1) * sign; + } + return Date.parse(a) - Date.parse(b); +} diff --git a/src/client/features/ai-search/BrandLookupPage.tsx b/src/client/features/ai-search/BrandLookupPage.tsx new file mode 100644 index 0000000..2c69fe7 --- /dev/null +++ b/src/client/features/ai-search/BrandLookupPage.tsx @@ -0,0 +1,210 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { AutumnProvider, useCustomer } from "autumn-js/react"; +import { + AlertCircle, + ArrowLeft, + BarChart3, + Quote, + TrendingUp, +} from "lucide-react"; +import { lookupBrand } from "@/serverFunctions/ai-search"; +import { useSession } from "@/lib/auth-client"; +import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection"; +import { getStandardErrorMessage } from "@/client/lib/error-messages"; +import { BrandLookupResults } from "@/client/features/ai-search/components/BrandLookupResults"; +import { BrandLookupSearchCard } from "@/client/features/ai-search/components/BrandLookupSearchCard"; +import { BrandLookupHistorySection } from "@/client/features/ai-search/components/BrandLookupHistorySection"; +import { AiSearchLoadingState } from "@/client/features/ai-search/components/AiSearchLoadingState"; +import { AiSearchPaidPlanGate } from "@/client/features/ai-search/components/AiSearchPaidPlanGate"; +import { useBrandLookupSearchHistory } from "@/client/hooks/useBrandLookupSearchHistory"; +import { BRAND_LOOKUP_MAX_INPUT_LENGTH } from "@/types/schemas/ai-search"; + +type Props = { + projectId: string; + initialQuery: string; + onQueryChange: (next: string) => void; +}; + +const BRAND_LOOKUP_BULLETS = [ + { + icon: TrendingUp, + title: "Track AI visibility", + body: "Count how often ChatGPT and Google AI Overview cite your brand, and watch the trend month over month.", + }, + { + icon: Quote, + title: "See the prompts", + body: "View the actual user questions where LLMs reference your domain — the real demand driving AI traffic.", + }, + { + icon: BarChart3, + title: "Map the competition", + body: "Spot the pages LLMs cite alongside you so you know who's competing for attention in AI answers.", + }, +]; + +export function BrandLookupPage(props: Props) { + return ( + + + + ); +} + +function BrandLookupPageInner({ + projectId, + initialQuery, + onQueryChange, +}: Props) { + const [query, setQuery] = useState(initialQuery); + const [validationError, setValidationError] = useState(null); + + const { data: session } = useSession(); + const customerQuery = useCustomer({ + queryOptions: { enabled: Boolean(session?.user?.id) }, + }); + const planKnown = customerQuery.isSuccess || customerQuery.isError; + const isFreePlan = + !!customerQuery.data && + getCustomerPlanStatus(customerQuery.data) === "free"; + + const trimmedInitialQuery = initialQuery.trim(); + const hasActiveQuery = trimmedInitialQuery.length > 0; + + const lookupQuery = useQuery({ + queryKey: ["brand-lookup", projectId, trimmedInitialQuery], + queryFn: () => + lookupBrand({ + data: { + projectId, + query: trimmedInitialQuery, + locationCode: 2840, + languageCode: "en", + }, + }), + enabled: hasActiveQuery && !isFreePlan, + staleTime: 5 * 60 * 1000, + retry: false, + }); + + const { + history, + isLoaded: historyLoaded, + addSearch, + removeHistoryItem, + } = useBrandLookupSearchHistory(projectId); + + useEffect(() => { + if (hasActiveQuery && lookupQuery.isSuccess) { + addSearch({ query: trimmedInitialQuery }); + } + }, [hasActiveQuery, lookupQuery.isSuccess, trimmedInitialQuery, addSearch]); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + const trimmed = query.trim(); + if (trimmed.length === 0) { + setValidationError("Enter a brand name or domain"); + return; + } + if (trimmed.length > BRAND_LOOKUP_MAX_INPUT_LENGTH) { + setValidationError( + `Keep it under ${BRAND_LOOKUP_MAX_INPUT_LENGTH} characters`, + ); + return; + } + setValidationError(null); + onQueryChange(trimmed); + }; + + const handleSelectHistoryItem = (item: { query: string }) => { + setQuery(item.query); + setValidationError(null); + onQueryChange(item.query); + }; + + const handleShowRecentSearches = () => { + setQuery(""); + setValidationError(null); + onQueryChange(""); + }; + + const isLoading = hasActiveQuery && lookupQuery.isPending; + const errorMessage = + hasActiveQuery && lookupQuery.isError + ? getStandardErrorMessage(lookupQuery.error) + : null; + const resultData = hasActiveQuery ? lookupQuery.data : undefined; + + if (!planKnown) return null; + + return ( +
+
+
+

Brand Lookup

+

+ See how AI search cites any brand name or domain. +

+
+ + {isFreePlan ? ( + + ) : ( + <> + { + setQuery(next); + if (validationError) setValidationError(null); + }} + onSubmit={handleSubmit} + isLoading={isLoading} + validationError={validationError} + /> + + {errorMessage ? ( +
+ + {errorMessage} +
+ ) : null} + + {isLoading ? ( + + ) : resultData ? ( + <> +
+ +
+ + + ) : !errorMessage ? ( + + ) : null} + + )} +
+
+ ); +} diff --git a/src/client/features/ai-search/PromptExplorerPage.tsx b/src/client/features/ai-search/PromptExplorerPage.tsx new file mode 100644 index 0000000..e70b03b --- /dev/null +++ b/src/client/features/ai-search/PromptExplorerPage.tsx @@ -0,0 +1,256 @@ +import { useState, type FormEvent } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { AutumnProvider, useCustomer } from "autumn-js/react"; +import { + AlertCircle, + ArrowLeft, + Columns3, + SearchCheck, + Sparkles, +} from "lucide-react"; +import { explorePrompt } from "@/serverFunctions/ai-search"; +import { useSession } from "@/lib/auth-client"; +import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection"; +import { getStandardErrorMessage } from "@/client/lib/error-messages"; +import { PromptExplorerForm } from "@/client/features/ai-search/components/PromptExplorerForm"; +import { PromptExplorerResults } from "@/client/features/ai-search/components/PromptExplorerResults"; +import { PromptExplorerLoadingState } from "@/client/features/ai-search/components/PromptExplorerLoadingState"; +import { PromptExplorerHistorySection } from "@/client/features/ai-search/components/PromptExplorerHistorySection"; +import { AiSearchPaidPlanGate } from "@/client/features/ai-search/components/AiSearchPaidPlanGate"; +import { + usePromptExplorerSearchHistory, + type PromptExplorerSearchHistoryItem, +} from "@/client/hooks/usePromptExplorerSearchHistory"; +import { + PROMPT_EXPLORER_MAX_PROMPT_LENGTH, + PROMPT_EXPLORER_MODELS, + type PromptExplorerModel, + type WebSearchCountryCode, +} from "@/types/schemas/ai-search"; + +type Props = { + projectId: string; +}; + +const PROMPT_EXPLORER_BULLETS = [ + { + icon: Columns3, + title: "Four models side-by-side", + body: "Run one prompt across ChatGPT, Claude, Gemini, and Perplexity and compare answers in a single view.", + }, + { + icon: SearchCheck, + title: "See what the models cite", + body: "Every answer lists the sources it drew from, so you can audit where each model gets its information.", + }, + { + icon: Sparkles, + title: "Check brand mentions", + body: "Highlight a brand to instantly see whether it shows up in the answer text or the cited sources.", + }, +]; + +type FormState = { + prompt: string; + highlightBrand: string; + models: PromptExplorerModel[]; + webSearch: boolean; + webSearchCountryCode: WebSearchCountryCode; +}; + +const INITIAL_FORM_STATE: FormState = { + prompt: "", + highlightBrand: "", + models: [...PROMPT_EXPLORER_MODELS], + webSearch: true, + webSearchCountryCode: "US", +}; + +export function PromptExplorerPage(props: Props) { + return ( + + + + ); +} + +function PromptExplorerPageInner({ projectId }: Props) { + const [form, setForm] = useState(INITIAL_FORM_STATE); + const [validationError, setValidationError] = useState(null); + + const { data: session } = useSession(); + const customerQuery = useCustomer({ + queryOptions: { enabled: Boolean(session?.user?.id) }, + }); + const planKnown = customerQuery.isSuccess || customerQuery.isError; + const isFreePlan = + !!customerQuery.data && + getCustomerPlanStatus(customerQuery.data) === "free"; + + const { + history, + isLoaded: historyLoaded, + addSearch, + removeHistoryItem, + } = usePromptExplorerSearchHistory(projectId); + + const exploreMutation = useMutation({ + mutationFn: (input: FormState) => + explorePrompt({ + data: { + projectId, + prompt: input.prompt, + models: input.models, + highlightBrand: + input.highlightBrand.length > 0 ? input.highlightBrand : undefined, + webSearch: input.webSearch, + webSearchCountryCode: input.webSearchCountryCode, + }, + }), + }); + + const runExplore = (values: FormState) => { + const normalized: FormState = { + ...values, + prompt: values.prompt.trim(), + highlightBrand: values.highlightBrand.trim(), + }; + addSearch({ + prompt: normalized.prompt, + highlightBrand: normalized.highlightBrand, + models: normalized.models, + webSearch: normalized.webSearch, + webSearchCountryCode: normalized.webSearchCountryCode, + }); + exploreMutation.mutate(normalized); + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + const trimmedPrompt = form.prompt.trim(); + if (trimmedPrompt.length === 0) { + setValidationError("Enter a prompt"); + return; + } + if (trimmedPrompt.length > PROMPT_EXPLORER_MAX_PROMPT_LENGTH) { + setValidationError( + `Keep prompts under ${PROMPT_EXPLORER_MAX_PROMPT_LENGTH} characters`, + ); + return; + } + if (form.models.length === 0) { + setValidationError("Select at least one model"); + return; + } + setValidationError(null); + runExplore(form); + }; + + const handleSelectHistoryItem = (item: PromptExplorerSearchHistoryItem) => { + const nextForm: FormState = { + prompt: item.prompt, + highlightBrand: item.highlightBrand, + models: item.models, + webSearch: item.webSearch, + webSearchCountryCode: item.webSearchCountryCode, + }; + setForm(nextForm); + setValidationError(null); + runExplore(nextForm); + }; + + const handleShowRecentSearches = () => { + exploreMutation.reset(); + setForm(INITIAL_FORM_STATE); + setValidationError(null); + }; + + const errorMessage = exploreMutation.isError + ? getStandardErrorMessage(exploreMutation.error) + : null; + + const updateForm = ( + key: K, + value: FormState[K], + ) => { + setForm((prev) => ({ ...prev, [key]: value })); + if (validationError) setValidationError(null); + }; + + if (!planKnown) return null; + + return ( +
+
+
+

Prompt Explorer

+

+ Ask any prompt across ChatGPT, Claude, Gemini, and Perplexity + side-by-side. +

+
+ + {isFreePlan ? ( + + ) : ( + <> + updateForm("prompt", value)} + onHighlightBrandChange={(value) => + updateForm("highlightBrand", value) + } + onModelsChange={(value) => updateForm("models", value)} + onWebSearchChange={(value) => updateForm("webSearch", value)} + onCountryChange={(value) => + updateForm("webSearchCountryCode", value) + } + onSubmit={handleSubmit} + isLoading={exploreMutation.isPending} + validationError={validationError} + /> + + {errorMessage ? ( +
+ + {errorMessage} +
+ ) : null} + + {exploreMutation.isPending ? ( + + ) : exploreMutation.data ? ( + <> +
+ +
+ + + ) : !errorMessage ? ( + + ) : null} + + )} +
+
+ ); +} diff --git a/src/client/features/ai-search/brandLookupFilterTypes.ts b/src/client/features/ai-search/brandLookupFilterTypes.ts new file mode 100644 index 0000000..955d1ab --- /dev/null +++ b/src/client/features/ai-search/brandLookupFilterTypes.ts @@ -0,0 +1,33 @@ +export type CitationTab = "pages" | "queries"; + +export type TopPagesFilterValues = { + include: string; + exclude: string; + platform: string; + minMentions: string; + maxMentions: string; +}; + +export type QueriesFilterValues = { + include: string; + exclude: string; + platform: string; + minVolume: string; + maxVolume: string; +}; + +export const EMPTY_TOP_PAGES_FILTERS: TopPagesFilterValues = { + include: "", + exclude: "", + platform: "", + minMentions: "", + maxMentions: "", +}; + +export const EMPTY_QUERIES_FILTERS: QueriesFilterValues = { + include: "", + exclude: "", + platform: "", + minVolume: "", + maxVolume: "", +}; diff --git a/src/client/features/ai-search/brandLookupFiltering.ts b/src/client/features/ai-search/brandLookupFiltering.ts new file mode 100644 index 0000000..6664e85 --- /dev/null +++ b/src/client/features/ai-search/brandLookupFiltering.ts @@ -0,0 +1,93 @@ +import { parseTerms } from "@/client/features/keywords/utils"; +import type { BrandLookupResult } from "@/types/schemas/ai-search"; +import type { + QueriesFilterValues, + TopPagesFilterValues, +} from "./brandLookupFilterTypes"; + +function passesNumericFilter( + value: number | null | undefined, + min: string, + max: string, +): boolean { + if (value == null) return true; + const minN = Number(min); + if (min && !Number.isNaN(minN) && value < minN) return false; + const maxN = Number(max); + if (max && !Number.isNaN(maxN) && value > maxN) return false; + return true; +} + +function passesTextFilter( + haystack: string, + includeTerms: string[], + excludeTerms: string[], +): boolean { + const lower = haystack.toLowerCase(); + if ( + includeTerms.length > 0 && + !includeTerms.some((term) => lower.includes(term)) + ) { + return false; + } + if (excludeTerms.some((term) => lower.includes(term))) { + return false; + } + return true; +} + +export function filterTopPages( + rows: BrandLookupResult["topPages"], + filters: TopPagesFilterValues, +): BrandLookupResult["topPages"] { + const includeTerms = parseTerms(filters.include); + const excludeTerms = parseTerms(filters.exclude); + + return rows.filter((row) => { + const textFields = [row.url, row.domain] + .filter((v): v is string => Boolean(v)) + .join(" "); + + if (!passesTextFilter(textFields, includeTerms, excludeTerms)) return false; + if (filters.platform && row.platform !== filters.platform) return false; + if ( + !passesNumericFilter( + row.mentions, + filters.minMentions, + filters.maxMentions, + ) + ) { + return false; + } + return true; + }); +} + +export function filterQueries( + rows: BrandLookupResult["topQueries"], + filters: QueriesFilterValues, +): BrandLookupResult["topQueries"] { + const includeTerms = parseTerms(filters.include); + const excludeTerms = parseTerms(filters.exclude); + + return rows.filter((row) => { + const textFields = [row.question, ...row.brandsMentioned].join(" "); + + if (!passesTextFilter(textFields, includeTerms, excludeTerms)) return false; + if (filters.platform && row.platform !== filters.platform) return false; + if ( + !passesNumericFilter( + row.aiSearchVolume, + filters.minVolume, + filters.maxVolume, + ) + ) { + return false; + } + return true; + }); +} + +export function countActiveFilters(values: Record): number { + return Object.values(values).filter((v) => v.trim() !== "").length; +} diff --git a/src/client/features/ai-search/components/AiSearchLoadingState.tsx b/src/client/features/ai-search/components/AiSearchLoadingState.tsx new file mode 100644 index 0000000..89253c4 --- /dev/null +++ b/src/client/features/ai-search/components/AiSearchLoadingState.tsx @@ -0,0 +1,29 @@ +export function AiSearchLoadingState() { + return ( +
+
+ {Array.from({ length: 3 }).map((_, index) => ( +
+
+
+
+
+ ))} +
+ +
+
+
+ {Array.from({ length: 6 }).map((_, index) => ( +
+
+
+
+
+
+ ))} +
+
+
+ ); +} diff --git a/src/client/features/ai-search/components/AiSearchPaidPlanGate.tsx b/src/client/features/ai-search/components/AiSearchPaidPlanGate.tsx new file mode 100644 index 0000000..82e0b5a --- /dev/null +++ b/src/client/features/ai-search/components/AiSearchPaidPlanGate.tsx @@ -0,0 +1,49 @@ +import { Link } from "@tanstack/react-router"; +import { Sparkles, type LucideIcon } from "lucide-react"; +import { SUBSCRIBE_ROUTE } from "@/shared/billing"; + +type Props = { + feature: string; + description: string; + bullets: Array<{ icon: LucideIcon; title: string; body: string }>; +}; + +export function AiSearchPaidPlanGate({ feature, description, bullets }: Props) { + return ( +
+
+
+ + + Paid plan + +

+ Unlock {feature} +

+

{description}

+
+ + Upgrade + +
+ +
+ {bullets.map(({ icon: Icon, title, body }) => ( +
+
+ +
+

{title}

+

+ {body} +

+
+ ))} +
+
+ ); +} diff --git a/src/client/features/ai-search/components/BrandLookupCitationTables.tsx b/src/client/features/ai-search/components/BrandLookupCitationTables.tsx new file mode 100644 index 0000000..a14bd88 --- /dev/null +++ b/src/client/features/ai-search/components/BrandLookupCitationTables.tsx @@ -0,0 +1,210 @@ +import { + createColumnHelper, + flexRender, + type Table, +} from "@tanstack/react-table"; +import { ExternalLink } from "lucide-react"; +import { SortableHeader } from "@/client/components/table/SortableHeader"; +import { numericNullsLast } from "@/client/components/table/nullSafeSort"; +import { + formatCount, + formatPlatformLabel, +} from "@/client/features/ai-search/platformLabels"; +import { formatUrlForDisplay } from "@/client/features/ai-search/urlDisplay"; +import type { BrandLookupResult } from "@/types/schemas/ai-search"; + +type TopPageRow = BrandLookupResult["topPages"][number]; +type TopQueryRow = BrandLookupResult["topQueries"][number]; +type PlatformKey = TopPageRow["platform"]; + +const PLATFORM_BADGE_CLASS: Record = { + chat_gpt: "border-emerald-500/40 bg-emerald-500/10 text-emerald-500", + google: "border-sky-500/40 bg-sky-500/10 text-sky-500", +}; + +function PlatformBadge({ platform }: { platform: PlatformKey }) { + return ( + + {formatPlatformLabel(platform)} + + ); +} + +const pagesHelper = createColumnHelper(); +const queriesHelper = createColumnHelper(); + +export const topPagesColumns = [ + pagesHelper.accessor("url", { + id: "url", + header: () => URL, + enableSorting: false, + cell: ({ row }) => ( + <> + + + {formatUrlForDisplay(row.original.url)} + + + + {row.original.domain ? ( +

{row.original.domain}

+ ) : null} + + ), + }), + pagesHelper.accessor("platform", { + id: "platform", + header: () => Platform, + enableSorting: false, + cell: ({ getValue }) => , + }), + pagesHelper.accessor("mentions", { + id: "mentions", + header: ({ column }) => ( + + ), + cell: ({ getValue }) => ( + {formatCount(getValue())} + ), + sortingFn: numericNullsLast, + sortDescFirst: true, + }), +]; + +export const topQueriesColumns = [ + queriesHelper.accessor("question", { + id: "question", + header: () => Query, + enableSorting: false, + cell: ({ row }) => ( + <> +

{row.original.question}

+ {row.original.brandsMentioned.length > 0 ? ( +

+ Brands: {row.original.brandsMentioned.slice(0, 5).join(", ")} +

+ ) : null} + + ), + }), + queriesHelper.accessor("platform", { + id: "platform", + header: () => Platform, + enableSorting: false, + cell: ({ getValue }) => , + }), + queriesHelper.accessor("aiSearchVolume", { + id: "aiSearchVolume", + header: ({ column }) => ( + + ), + cell: ({ getValue }) => ( + {formatCount(getValue())} + ), + sortingFn: numericNullsLast, + sortDescFirst: true, + }), +]; + +export function TopPagesTable({ table }: { table: Table }) { + if (table.getRowModel().rows.length === 0) { + return ( +

+ No cited pages returned. +

+ ); + } + + return ; +} + +export function TopQueriesTable({ table }: { table: Table }) { + if (table.getRowModel().rows.length === 0) { + return ( +

+ No matching queries found. +

+ ); + } + + return ; +} + +function BrandLookupTable({ + table, + urlLikeColumnId, +}: { + table: Table; + urlLikeColumnId: string; +}) { + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const isNumeric = header.column.getCanSort(); + return ( + + ); + })} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => { + const isNumeric = cell.column.getCanSort(); + return ( + + ); + })} + + ))} + +
+ {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ ); +} + +function cellClassName( + columnId: string, + urlLikeColumnId: string, + isNumeric: boolean, +): string { + if (columnId === urlLikeColumnId) { + return "min-w-80 max-w-2xl align-top"; + } + if (isNumeric) { + return "whitespace-nowrap text-right align-top"; + } + return "whitespace-nowrap align-top"; +} diff --git a/src/client/features/ai-search/components/BrandLookupFilterPanel.tsx b/src/client/features/ai-search/components/BrandLookupFilterPanel.tsx new file mode 100644 index 0000000..57dbfe7 --- /dev/null +++ b/src/client/features/ai-search/components/BrandLookupFilterPanel.tsx @@ -0,0 +1,235 @@ +import { RotateCcw } from "lucide-react"; +import type { CitationTab } from "@/client/features/ai-search/brandLookupFilterTypes"; +import { formatPlatformLabel } from "@/client/features/ai-search/platformLabels"; +import type { BrandLookupFiltersState } from "@/client/features/ai-search/useBrandLookupFilters"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyForm = { Field: React.ComponentType }; + +function FilterTextInput({ + form, + name, + label, + placeholder, +}: { + form: AnyForm; + name: string; + label: string; + placeholder: string; +}) { + return ( + + ); +} + +function FilterRangeInputs({ + form, + title, + minName, + maxName, +}: { + form: AnyForm; + title: string; + minName: string; + maxName: string; +}) { + return ( +
+

+ {title} +

+
+ + +
+
+ ); +} + +function CompactRangeInput({ + form, + name, + placeholder, +}: { + form: AnyForm; + name: string; + placeholder: string; +}) { + return ( + + {(field: { + state: { value: string }; + handleChange: (v: string) => void; + }) => ( + field.handleChange(event.target.value)} + /> + )} + + ); +} + +function PlatformToggle({ form }: { form: AnyForm }) { + return ( +
+

+ Platform +

+ + {(field: { + state: { value: string }; + handleChange: (v: string) => void; + }) => ( +
+ {(["", "chat_gpt", "google"] as const).map((value) => ( + + ))} +
+ )} +
+
+ ); +} + +function TopPagesFilters({ + form, +}: { + form: BrandLookupFiltersState["pages"]["form"]; +}) { + return ( + <> +
+ + +
+ +
+ +
+ +
+
+ + ); +} + +function QueriesFilters({ + form, +}: { + form: BrandLookupFiltersState["queries"]["form"]; +}) { + return ( + <> +
+ + +
+ +
+ +
+ +
+
+ + ); +} + +export function BrandLookupFilterPanel({ + activeTab, + filters, +}: { + activeTab: CitationTab; + filters: BrandLookupFiltersState; +}) { + const current = filters[activeTab]; + + return ( +
+
+
+

Refine results

+ {current.activeFilterCount > 0 ? ( + + {current.activeFilterCount} active + + ) : null} +
+ +
+ + {activeTab === "pages" ? ( + + ) : null} + {activeTab === "queries" ? ( + + ) : null} +
+ ); +} diff --git a/src/client/features/ai-search/components/BrandLookupHistorySection.tsx b/src/client/features/ai-search/components/BrandLookupHistorySection.tsx new file mode 100644 index 0000000..60e47d5 --- /dev/null +++ b/src/client/features/ai-search/components/BrandLookupHistorySection.tsx @@ -0,0 +1,24 @@ +import { Sparkles } from "lucide-react"; +import { SearchHistorySection } from "@/client/features/ai-search/components/SearchHistorySection"; +import type { BrandLookupSearchHistoryItem } from "@/client/hooks/useBrandLookupSearchHistory"; + +type Props = { + history: BrandLookupSearchHistoryItem[]; + historyLoaded: boolean; + onRemoveHistoryItem: (timestamp: number) => void; + onSelectHistoryItem: (item: BrandLookupSearchHistoryItem) => void; +}; + +export function BrandLookupHistorySection(props: Props) { + return ( + ( +

{item.query}

+ )} + /> + ); +} diff --git a/src/client/features/ai-search/components/BrandLookupMentionTrendCard.tsx b/src/client/features/ai-search/components/BrandLookupMentionTrendCard.tsx new file mode 100644 index 0000000..38cd99b --- /dev/null +++ b/src/client/features/ai-search/components/BrandLookupMentionTrendCard.tsx @@ -0,0 +1,95 @@ +import { useMemo } from "react"; +import { + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { formatCount } from "@/client/features/ai-search/platformLabels"; +import type { BrandLookupResult } from "@/types/schemas/ai-search"; + +type Props = { + result: BrandLookupResult; +}; + +export function BrandLookupMentionTrendCard({ result }: Props) { + const chartData = useMemo( + () => + result.monthlyVolume.map((entry) => ({ + label: `${entry.year}-${String(entry.month).padStart(2, "0")}`, + volume: entry.volume ?? 0, + })), + [result.monthlyVolume], + ); + + if (chartData.length === 0) { + return ( +
+ Not enough historical data yet. +
+ ); + } + + return ( +
+ + + + + + } + cursor={{ stroke: "currentColor", strokeOpacity: 0.2 }} + /> + + + +
+ ); +} + +function MentionTooltip({ + active, + payload, + label, +}: { + active?: boolean; + payload?: Array<{ value: number }>; + label?: string; +}) { + if (!active || !payload?.length) return null; + return ( +
+

{label}

+

+ {formatCount(payload[0].value)} mentions +

+
+ ); +} diff --git a/src/client/features/ai-search/components/BrandLookupResults.tsx b/src/client/features/ai-search/components/BrandLookupResults.tsx new file mode 100644 index 0000000..b505b40 --- /dev/null +++ b/src/client/features/ai-search/components/BrandLookupResults.tsx @@ -0,0 +1,420 @@ +import { useMemo, useState } from "react"; +import { + getCoreRowModel, + getSortedRowModel, + useReactTable, + type SortingState, +} from "@tanstack/react-table"; +import { Download, Info, SlidersHorizontal } from "lucide-react"; +import { buildCsv, downloadCsv } from "@/client/lib/csv"; +import { BrandLookupMentionTrendCard } from "@/client/features/ai-search/components/BrandLookupMentionTrendCard"; +import { BrandLookupFilterPanel } from "@/client/features/ai-search/components/BrandLookupFilterPanel"; +import { + TopPagesTable, + TopQueriesTable, + topPagesColumns, + topQueriesColumns, +} from "@/client/features/ai-search/components/BrandLookupCitationTables"; +import { + formatCount, + formatPlatformLabel, +} from "@/client/features/ai-search/platformLabels"; +import { + filterQueries, + filterTopPages, +} from "@/client/features/ai-search/brandLookupFiltering"; +import { useBrandLookupFilters } from "@/client/features/ai-search/useBrandLookupFilters"; +import type { CitationTab } from "@/client/features/ai-search/brandLookupFilterTypes"; +import type { BrandLookupResult } from "@/types/schemas/ai-search"; + +type Props = { + result: BrandLookupResult; +}; + +type PlatformRow = BrandLookupResult["perPlatform"][number]; +type MetricKey = "mentions" | "aiSearchVolume" | "impressions"; + +const PLATFORM_DOT_CLASS: Record = { + chat_gpt: "bg-emerald-500", + google: "bg-sky-500", +}; + +export function BrandLookupResults({ result }: Props) { + const erroredPlatforms = result.perPlatform.filter( + (p) => p.status === "error", + ); + const allPlatformsErrored = + erroredPlatforms.length === result.perPlatform.length && + result.perPlatform.length > 0; + + if (!result.hasData) { + if (allPlatformsErrored) { + return ( +
+ AI mention data is temporarily unavailable for{" "} + {result.resolvedTarget}. Please try again shortly. +
+ ); + } + return ( +
+
+ No AI mentions found for {result.resolvedTarget}. +
+ {erroredPlatforms.length > 0 ? ( +

+ Note: {formatPlatformList(erroredPlatforms.map((p) => p.platform))}{" "} + {erroredPlatforms.length === 1 ? "was" : "were"} unavailable — some + mentions may be missing. +

+ ) : null} +
+ ); + } + + const hasTrendData = result.monthlyVolume.length > 0; + + return ( +
+ +
+ + {hasTrendData ? : null} +
+ +
+ ); +} + +function formatPlatformList(platforms: PlatformRow["platform"][]): string { + return platforms.map(formatPlatformLabel).join(" and "); +} + +function BrandHeader({ result }: { result: BrandLookupResult }) { + return ( +
+
+

+ {result.resolvedTarget} +

+ + {result.detectedTargetType} + +
+

+ Updated {formatRelative(result.fetchedAt)} +

+
+ ); +} + +function KpiTiles({ result }: { result: BrandLookupResult }) { + return ( +
+ + + +
+ ); +} + +function KpiTile({ + label, + tooltip, + total, + perPlatform, + metric, +}: { + label: string; + tooltip: string; + total: number | null; + perPlatform: PlatformRow[]; + metric: MetricKey; +}) { + return ( +
+
+

+ {label} + + + +

+

+ {formatCount(total)} +

+
+
+ {perPlatform.map((row) => ( + + ))} +
+
+ ); +} + +function PlatformStatRow({ + row, + metric, +}: { + row: PlatformRow; + metric: MetricKey; +}) { + const value = row.status === "error" ? null : row[metric]; + + return ( +
+ + + {formatPlatformLabel(row.platform)} + {row.platform === "chat_gpt" ? ( + + + + ) : null} + {row.status === "error" ? ( + unavailable + ) : null} + + + {formatCount(value)} + +
+ ); +} + +function MentionTrendCard({ result }: { result: BrandLookupResult }) { + return ( +
+
+

+ Mention trend (last 12 months) +

+
+
+ +
+
+ ); +} + +const DEFAULT_PAGES_SORT: SortingState = [{ id: "mentions", desc: true }]; +const DEFAULT_QUERIES_SORT: SortingState = [ + { id: "aiSearchVolume", desc: true }, +]; + +function CitationTabsCard({ result }: { result: BrandLookupResult }) { + const [activeTab, setActiveTab] = useState("queries"); + const [pagesSort, setPagesSort] = useState(DEFAULT_PAGES_SORT); + const [queriesSort, setQueriesSort] = + useState(DEFAULT_QUERIES_SORT); + const filters = useBrandLookupFilters(); + + const filteredPages = useMemo( + () => filterTopPages(result.topPages, filters.pages.values), + [result.topPages, filters.pages.values], + ); + const filteredQueries = useMemo( + () => filterQueries(result.topQueries, filters.queries.values), + [result.topQueries, filters.queries.values], + ); + + const pagesTable = useReactTable({ + data: filteredPages, + columns: topPagesColumns, + state: { sorting: pagesSort }, + onSortingChange: setPagesSort, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + const queriesTable = useReactTable({ + data: filteredQueries, + columns: topQueriesColumns, + state: { sorting: queriesSort }, + onSortingChange: setQueriesSort, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + const handleExport = () => { + if (activeTab === "pages") { + const sortedPages = pagesTable + .getSortedRowModel() + .rows.map((row) => row.original); + const csv = buildCsv( + ["URL", "Domain", "Platform", "Mentions"], + sortedPages.map((row) => [ + row.url, + row.domain ?? "", + formatPlatformLabel(row.platform), + row.mentions ?? "", + ]), + ); + downloadCsv( + `ai-brand-lookup-pages-${slugify(result.resolvedTarget)}.csv`, + csv, + ); + return; + } + const sortedQueries = queriesTable + .getSortedRowModel() + .rows.map((row) => row.original); + const csv = buildCsv( + ["Query", "Platform", "AI search volume", "First seen", "Last seen"], + sortedQueries.map((row) => [ + row.question, + formatPlatformLabel(row.platform), + row.aiSearchVolume ?? "", + row.firstSeenAt ?? "", + row.lastSeenAt ?? "", + ]), + ); + downloadCsv( + `ai-brand-lookup-queries-${slugify(result.resolvedTarget)}.csv`, + csv, + ); + }; + + const canExport = + activeTab === "pages" + ? filteredPages.length > 0 + : filteredQueries.length > 0; + + const currentFilterCount = filters[activeTab].activeFilterCount; + + return ( +
+
+
+ + +
+ + +
+ +
+ +
+ +
+ {activeTab === "pages" ? ( + <> + Other pages LLMs cited in the same answers that referenced{" "} + + {result.resolvedTarget} + + . Useful for spotting the sources competing for attention alongside + your domain. + + ) : ( + <> + User prompts where the LLM's answer referenced{" "} + + {result.resolvedTarget} + {" "} + in its text or citations. The prompt itself does not have to mention + your domain. + + )} +
+ + {filters.showFilters ? ( + + ) : null} + + {activeTab === "pages" ? ( + + ) : ( + + )} +
+ ); +} + +function formatRelative(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return "just now"; + + const diffMs = Date.now() - date.getTime(); + const diffMin = Math.floor(diffMs / 60_000); + + if (diffMin < 1) return "just now"; + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + return `${diffDay}d ago`; +} + +function slugify(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60); +} diff --git a/src/client/features/ai-search/components/BrandLookupSearchCard.tsx b/src/client/features/ai-search/components/BrandLookupSearchCard.tsx new file mode 100644 index 0000000..e5f3c37 --- /dev/null +++ b/src/client/features/ai-search/components/BrandLookupSearchCard.tsx @@ -0,0 +1,90 @@ +import type { FormEvent } from "react"; +import { Search } from "lucide-react"; +import { isHostedClientAuthMode } from "@/lib/auth-mode"; +import { applyBillingMarkupUsd } from "@/shared/billing"; +import { BRAND_LOOKUP_MAX_INPUT_LENGTH } from "@/types/schemas/ai-search"; + +type Props = { + query: string; + onQueryChange: (next: string) => void; + onSubmit: (event: FormEvent) => void; + isLoading: boolean; + validationError: string | null; +}; + +/** + * One brand lookup = 6 DataForSEO calls (3 endpoints × 2 platforms). Measured + * live at ~$0.634 raw via `pnpm billing:brand-lookup`; rounded up to leave + * headroom for per-query variance. + */ +const BRAND_LOOKUP_RAW_COST_USD = 0.65; + +// Hosted customers are billed the marked-up USD; self-hosted users pay +// DataForSEO directly at the raw rate. +const BRAND_LOOKUP_DISPLAYED_COST_USD = isHostedClientAuthMode() + ? applyBillingMarkupUsd(BRAND_LOOKUP_RAW_COST_USD) + : BRAND_LOOKUP_RAW_COST_USD; + +export function BrandLookupSearchCard({ + query, + onQueryChange, + onSubmit, + isLoading, + validationError, +}: Props) { + return ( +
+
+
+ + + +
+ + {validationError ? ( +

+ {validationError} +

+ ) : null} + +
+

+ Est.{" "} + + ${BRAND_LOOKUP_DISPLAYED_COST_USD.toFixed(2)} + +

+
+
+
+ ); +} diff --git a/src/client/features/ai-search/components/MarkdownAnswer.tsx b/src/client/features/ai-search/components/MarkdownAnswer.tsx new file mode 100644 index 0000000..6985dc6 --- /dev/null +++ b/src/client/features/ai-search/components/MarkdownAnswer.tsx @@ -0,0 +1,282 @@ +import { + useLayoutEffect, + useRef, + useState, + type ComponentPropsWithoutRef, + type ReactNode, +} from "react"; +import { ChevronDown, ChevronUp } from "lucide-react"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +type Props = { + text: string; +}; + +/** + * Collapsed-state max height in px. Roughly 9 lines of body text — enough + * to convey the shape of an answer without dominating the page when four + * models are stacked. + */ +const COLLAPSED_MAX_PX = 240; + +/** + * Render an LLM's markdown answer with explicit per-element Tailwind classes. + * + * Long answers collapse to ~12 lines with a fade-out gradient and a + * "Read more" toggle so a side-by-side comparison of four models stays + * scannable. We measure the rendered scroll height to decide whether the + * toggle is needed. + * + * Anchor URLs are sanitized to http(s) only — LLMs can be coaxed into + * emitting `javascript:` payloads. + */ +export function MarkdownAnswer({ text }: Props) { + const contentRef = useRef(null); + const [expanded, setExpanded] = useState(false); + const [needsCollapse, setNeedsCollapse] = useState(false); + const { thinking, body } = extractThinkingBlocks(text); + const normalized = normalizeLlmMarkdown(body); + + useLayoutEffect(() => { + const el = contentRef.current; + if (!el) return; + // scrollHeight reflects natural content height even when overflow is + // clipped by max-h, so we can detect overflow without toggling state. + setNeedsCollapse(el.scrollHeight > COLLAPSED_MAX_PX + 8); + }, [normalized]); + + if (normalized.trim().length === 0 && thinking.length === 0) { + return ( +

+ Model returned an empty response. +

+ ); + } + + const isCollapsed = needsCollapse && !expanded; + + return ( +
+ {thinking.map((block, index) => ( + + ))} + + {normalized.trim().length > 0 ? ( +
+
+ + {normalized} + +
+ + {isCollapsed ? ( +
+ ) : null} +
+ ) : null} + + {needsCollapse ? ( + + ) : null} +
+ ); +} + +function ThinkingBlock({ text }: { text: string }) { + return ( +
+ + + Model Thinking + +
+        {text}
+      
+
+ ); +} + +/** + * Reasoning models (e.g. Perplexity sonar-reasoning-pro) wrap their chain of + * thought in `...` tags inline with the answer. Pull those out + * so we can render them in a separate, collapsible block. + * + * Tolerates an unclosed final `` (e.g. from a truncated stream) by + * treating everything after it as a thinking block. + */ +function extractThinkingBlocks(text: string): { + thinking: string[]; + body: string; +} { + const thinking: string[] = []; + let body = text; + + body = body.replace(/([\s\S]*?)<\/think>/gi, (_, inner: string) => { + thinking.push(inner.trim()); + return ""; + }); + + body = body.replace(/([\s\S]*)$/i, (_, inner: string) => { + thinking.push(inner.trim()); + return ""; + }); + + return { thinking, body }; +} + +/** + * Fix a class of malformed markdown we see from LLM responses: a list marker + * (`-`, `*`, `+`, or `1.`) on a line by itself, followed by a blank line, + * followed by the actual item content as a separate paragraph. Default + * markdown correctly renders that as an empty bullet + detached paragraph, + * which looks broken. Collapse the blank line so the marker and content + * form a proper list item. + */ +function normalizeLlmMarkdown(text: string): string { + return text.replace( + /^([ \t]*)([-*+]|\d+\.)[ \t]*\r?\n[ \t]*\r?\n(?=\S)(?![ \t]*(?:[-*+]|\d+\.)[ \t])/gm, + "$1$2 ", + ); +} + +type AnchorProps = ComponentPropsWithoutRef<"a">; + +function SafeAnchor({ href, children, ...rest }: AnchorProps) { + const safeHref = isHttpUrl(href) ? href : undefined; + if (!safeHref) { + return {children}; + } + return ( + + {children} + + ); +} + +function isHttpUrl(value: string | undefined): value is string { + if (!value) return false; + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") return false; + // Mirror server-side `safeHttpUrl` — a `user:pass@host` URL shows one + // hostname in link text while auth hits another. + if (url.username || url.password) return false; + return true; + } catch { + return false; + } +} + +const MARKDOWN_COMPONENTS = { + h1: ({ children }: { children?: ReactNode }) => ( +

{children}

+ ), + h2: ({ children }: { children?: ReactNode }) => ( +

{children}

+ ), + h3: ({ children }: { children?: ReactNode }) => ( +

{children}

+ ), + h4: ({ children }: { children?: ReactNode }) => ( +

{children}

+ ), + p: ({ children }: { children?: ReactNode }) => ( +

{children}

+ ), + ul: ({ children }: { children?: ReactNode }) => ( +
    {children}
+ ), + ol: ({ children }: { children?: ReactNode }) => ( +
    {children}
+ ), + li: ({ children }: { children?: ReactNode }) => ( +
  • {children}
  • + ), + a: SafeAnchor, + strong: ({ children }: { children?: ReactNode }) => ( + {children} + ), + em: ({ children }: { children?: ReactNode }) => ( + {children} + ), + blockquote: ({ children }: { children?: ReactNode }) => ( +
    + {children} +
    + ), + hr: () =>
    , + code: ({ children, className }: ComponentPropsWithoutRef<"code">) => { + // Inline code (no `language-*` className from remark) gets the badge style; + // block code is rendered by `pre` with a different shell. + if (typeof className === "string" && className.startsWith("language-")) { + return {children}; + } + return ( + + {children} + + ); + }, + pre: ({ children }: { children?: ReactNode }) => ( +
    +      {children}
    +    
    + ), + table: ({ children }: { children?: ReactNode }) => ( +
    + + {children} +
    +
    + ), + thead: ({ children }: { children?: ReactNode }) => {children}, + tbody: ({ children }: { children?: ReactNode }) => {children}, + tr: ({ children }: { children?: ReactNode }) => ( + {children} + ), + th: ({ children }: { children?: ReactNode }) => ( + {children} + ), + td: ({ children }: { children?: ReactNode }) => ( + {children} + ), +}; diff --git a/src/client/features/ai-search/components/PromptExplorerForm.tsx b/src/client/features/ai-search/components/PromptExplorerForm.tsx new file mode 100644 index 0000000..a4609f0 --- /dev/null +++ b/src/client/features/ai-search/components/PromptExplorerForm.tsx @@ -0,0 +1,190 @@ +import type { FormEvent } from "react"; +import { + formatCountryLabel, + formatModelLabel, +} from "@/client/features/ai-search/platformLabels"; +import { + PROMPT_EXPLORER_MAX_PROMPT_LENGTH, + PROMPT_EXPLORER_MODELS, + WEB_SEARCH_COUNTRY_CODES, + type PromptExplorerModel, + type WebSearchCountryCode, +} from "@/types/schemas/ai-search"; + +type FormValues = { + prompt: string; + highlightBrand: string; + models: PromptExplorerModel[]; + webSearch: boolean; + webSearchCountryCode: WebSearchCountryCode; +}; + +type Props = { + form: FormValues; + onPromptChange: (value: string) => void; + onHighlightBrandChange: (value: string) => void; + onModelsChange: (value: PromptExplorerModel[]) => void; + onWebSearchChange: (value: boolean) => void; + onCountryChange: (value: WebSearchCountryCode) => void; + onSubmit: (event: FormEvent) => void; + isLoading: boolean; + validationError: string | null; +}; + +function isCountryCode(value: string): value is WebSearchCountryCode { + return (WEB_SEARCH_COUNTRY_CODES as readonly string[]).includes(value); +} + +function parseCountryCode(value: string): WebSearchCountryCode { + return isCountryCode(value) ? value : "US"; +} + +export function PromptExplorerForm({ + form, + onPromptChange, + onHighlightBrandChange, + onModelsChange, + onWebSearchChange, + onCountryChange, + onSubmit, + isLoading, + validationError, +}: Props) { + const toggleModel = (model: PromptExplorerModel) => { + if (form.models.includes(model)) { + onModelsChange(form.models.filter((m) => m !== model)); + } else { + onModelsChange([...form.models, model]); + } + }; + + const promptCharCount = form.prompt.length; + const promptOverLimit = promptCharCount > PROMPT_EXPLORER_MAX_PROMPT_LENGTH; + + return ( +
    +
    +
    + +