From 9c90c3e72c4c393064497d317001ff67d2f4e76f Mon Sep 17 00:00:00 2001 From: ManuelCLopes Date: Thu, 5 Mar 2026 00:18:08 +0000 Subject: [PATCH 1/2] feat: improve SEO indexing and add llms.txt --- client/index.html | 40 +++++++- client/public/llms.txt | 21 +++++ client/public/robots.txt | 12 +++ client/public/sitemap.xml | 18 ++-- client/src/i18n/locales/en/common.json | 6 +- client/src/i18n/locales/pt/common.json | 6 +- client/src/pages/LandingPage.css | 8 ++ client/src/pages/LandingPage.tsx | 71 +++++++++++--- server/routes/static.ts | 122 +++++++++++++++++++------ server/tests/static-routes.test.ts | 18 +++- vercel.json | 2 +- 11 files changed, 261 insertions(+), 63 deletions(-) create mode 100644 client/public/llms.txt diff --git a/client/index.html b/client/index.html index 41419f4..71c5fa0 100644 --- a/client/index.html +++ b/client/index.html @@ -3,11 +3,41 @@ - Local Competitor Analyzer | Business Intelligence Tool - - - + Competitor Analysis Tool for Local Businesses | Competitor Watcher + + + + + + + + + + + + + + + + + @@ -17,4 +47,4 @@
- \ No newline at end of file + diff --git a/client/public/llms.txt b/client/public/llms.txt new file mode 100644 index 0000000..7880006 --- /dev/null +++ b/client/public/llms.txt @@ -0,0 +1,21 @@ +# Competitor Watcher +> Competitor Watcher is a free competitor analysis platform for local businesses. + +## Summary +- Product: AI-powered competitor analysis and competitive intelligence software. +- Primary users: Local businesses, agencies, and operators who need fast market visibility. +- Core use cases: competitor research, local market analysis, review intelligence, and tactical recommendations. + +## Public pages +- Homepage: https://competitorwatcher.pt/ +- Support: https://competitorwatcher.pt/support +- Privacy policy: https://competitorwatcher.pt/privacy-policy +- Cookie policy: https://competitorwatcher.pt/cookie-policy +- Sitemap: https://competitorwatcher.pt/sitemap.xml + +## Access notes +- Main product workflows require authentication in /dashboard. +- Public API routes live under /api/* and are rate-limited. + +## Keywords +competitor analysis, competitive analysis tool, competitor analysis software, competitive intelligence, local market analysis, competitor tracking diff --git a/client/public/robots.txt b/client/public/robots.txt index 028e51e..0a2ad76 100644 --- a/client/public/robots.txt +++ b/client/public/robots.txt @@ -1,4 +1,16 @@ User-agent: * Allow: / +Allow: /login +Allow: /register +Allow: /support +Allow: /privacy-policy +Allow: /cookie-policy +Allow: /llms.txt +Disallow: /dashboard +Disallow: /settings +Disallow: /admin +Disallow: /admin/ +Disallow: /api/ +Disallow: /r/ Sitemap: https://competitorwatcher.pt/sitemap.xml diff --git a/client/public/sitemap.xml b/client/public/sitemap.xml index 0d12109..e35bb63 100644 --- a/client/public/sitemap.xml +++ b/client/public/sitemap.xml @@ -2,37 +2,37 @@ https://competitorwatcher.pt/ - 2024-03-20 weekly 1.0 + https://competitorwatcher.pt/login - 2024-03-20 monthly - 0.8 + 0.6 https://competitorwatcher.pt/register - 2024-03-20 monthly - 0.8 + 0.6 https://competitorwatcher.pt/support - 2024-03-20 monthly 0.7 https://competitorwatcher.pt/privacy-policy - 2024-03-20 yearly - 0.5 + 0.4 https://competitorwatcher.pt/cookie-policy - 2024-03-20 yearly + 0.4 + + + https://competitorwatcher.pt/llms.txt + monthly 0.5 diff --git a/client/src/i18n/locales/en/common.json b/client/src/i18n/locales/en/common.json index cd9f411..5087f00 100644 --- a/client/src/i18n/locales/en/common.json +++ b/client/src/i18n/locales/en/common.json @@ -415,8 +415,8 @@ "perMonth": "/month" }, "quickSearch": { - "title": "Discover Your Local Competition", - "subtitle": "Analyze competitors in any location instantly", + "title": "Competitor Analysis for Local Businesses", + "subtitle": "Run instant competitor research and competitive analysis in any location", "addressPlaceholder": "Enter business address", "selectType": "Business type", "selectRadius": "Search radius", @@ -900,4 +900,4 @@ "generationStarted": "Report Generation Started", "generationStartedDesc": "We are generating your report in the background. You will be notified when it is ready." } -} \ No newline at end of file +} diff --git a/client/src/i18n/locales/pt/common.json b/client/src/i18n/locales/pt/common.json index 192f690..984254a 100644 --- a/client/src/i18n/locales/pt/common.json +++ b/client/src/i18n/locales/pt/common.json @@ -416,8 +416,8 @@ "perMonth": "/mês" }, "quickSearch": { - "title": "Descubra a Sua Concorrência Local", - "subtitle": "Analise concorrentes em qualquer localização instantaneamente", + "title": "Análise de Concorrência para Negócios Locais", + "subtitle": "Faça análise competitiva em qualquer localização em segundos", "addressPlaceholder": "Introduza a morada do negócio", "selectType": "Tipo de negócio", "selectRadius": "Raio de pesquisa", @@ -899,4 +899,4 @@ "generationStarted": "Geração de Relatório Iniciada", "generationStartedDesc": "Estamos a gerar o seu relatório em segundo plano. Será notificado quando estiver pronto." } -} \ No newline at end of file +} diff --git a/client/src/pages/LandingPage.css b/client/src/pages/LandingPage.css index f45a43d..3d3cdf6 100644 --- a/client/src/pages/LandingPage.css +++ b/client/src/pages/LandingPage.css @@ -202,6 +202,14 @@ line-height: 1.6; } +.hero-intent-text { + max-width: 860px; + margin: 0 auto 2rem; + font-size: 0.975rem; + line-height: 1.6; + color: rgba(255, 255, 255, 0.92); +} + .hero-features { display: flex; flex-direction: column; diff --git a/client/src/pages/LandingPage.tsx b/client/src/pages/LandingPage.tsx index 6adad2b..a80f94b 100644 --- a/client/src/pages/LandingPage.tsx +++ b/client/src/pages/LandingPage.tsx @@ -26,8 +26,24 @@ import { usePricingModal } from "@/context/PricingModalContext"; export default function LandingPage() { const { isAuthenticated, isLoading } = useAuth(); - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { openPricing } = usePricingModal(); + const language = i18n?.language ?? "en"; + const siteUrl = "https://competitorwatcher.pt"; + const canonicalUrl = `${siteUrl}/`; + const seoTitle = "Competitor Analysis Tool for Local Businesses | Competitor Watcher"; + const seoDescription = "Competitor Watcher is a free competitor analysis platform for local businesses. Discover competitors, benchmark your market, and get actionable AI insights."; + const seoKeywords = "competitor analysis, competitive analysis tool, competitor analysis software, competitive intelligence, local market analysis, competitor tracking, market intelligence software"; + const ogLocale = + language === "pt" + ? "pt_PT" + : language === "es" + ? "es_ES" + : language === "fr" + ? "fr_FR" + : language === "de" + ? "de_DE" + : "en_US"; // Quick search state const [isSearching, setIsSearching] = useState(false); @@ -212,23 +228,27 @@ export default function LandingPage() { return (
- Competitor Watcher - Análise de Concorrência Local com IA - - + {seoTitle} + + + + {/* Open Graph / Facebook */} - - - - + + + + + + {/* Twitter */} - - - - + + + + {/* Structured Data */} + @@ -290,6 +329,12 @@ export default function LandingPage() {

{t('quickSearch.subtitle')}

+

+ {t("quickSearch.seoIntent", { + defaultValue: + "Competitor Watcher is a free competitor analysis tool and competitive intelligence platform for local businesses. Compare nearby competitors, track market shifts, and get actionable recommendations.", + })} +

{/* Quick Search Form */}
diff --git a/server/routes/static.ts b/server/routes/static.ts index 6311fb9..f41b9de 100644 --- a/server/routes/static.ts +++ b/server/routes/static.ts @@ -18,6 +18,92 @@ function getBaseUrl(req: Request): string { return `${protocol}://${host}`; } +function buildRobotsTxt(baseUrl: string): string { + return `User-agent: * +Allow: / +Allow: /login +Allow: /register +Allow: /support +Allow: /privacy-policy +Allow: /cookie-policy +Allow: /llms.txt +Disallow: /dashboard +Disallow: /settings +Disallow: /admin +Disallow: /admin/ +Disallow: /api/ +Disallow: /r/ + +Sitemap: ${baseUrl}/sitemap.xml +`; +} + +function buildSitemapXml(baseUrl: string): string { + return ` + + + ${baseUrl}/ + weekly + 1.0 + + + ${baseUrl}/login + monthly + 0.6 + + + ${baseUrl}/register + monthly + 0.6 + + + ${baseUrl}/support + monthly + 0.7 + + + ${baseUrl}/privacy-policy + yearly + 0.4 + + + ${baseUrl}/cookie-policy + yearly + 0.4 + + + ${baseUrl}/llms.txt + monthly + 0.5 + +`; +} + +function buildLlmsTxt(baseUrl: string): string { + return `# Competitor Watcher +> Competitor Watcher is a free competitor analysis platform for local businesses. + +## Summary +- Product: AI-powered competitor analysis and competitive intelligence software. +- Primary users: Local businesses, agencies, and operators who need fast market visibility. +- Core use cases: competitor research, local market analysis, review intelligence, and tactical recommendations. + +## Public pages +- Homepage: ${baseUrl}/ +- Support: ${baseUrl}/support +- Privacy policy: ${baseUrl}/privacy-policy +- Cookie policy: ${baseUrl}/cookie-policy +- Sitemap: ${baseUrl}/sitemap.xml + +## Access notes +- Main product workflows require authentication in /dashboard. +- Public API routes live under /api/* and are rate-limited. + +## Keywords +competitor analysis, competitive analysis tool, competitor analysis software, competitive intelligence, local market analysis, competitor tracking +`; +} + export function registerStaticRoutes(app: Express) { // Proxy for Google Static Maps to avoid exposing API key app.get("/api/static-map", async (req, res) => { @@ -118,41 +204,21 @@ export function registerStaticRoutes(app: Express) { }); // SEO Endpoints - app.get("/robots.txt", (req, res) => { + app.get("/llms.txt", (req, res) => { const baseUrl = getBaseUrl(req); res.type("text/plain"); - res.send(`User-agent: * -Allow: / -Allow: /auth -Allow: /login -Allow: /register -Disallow: /dashboard -Disallow: /api/ + res.send(buildLlmsTxt(baseUrl)); + }); -Sitemap: ${baseUrl}/sitemap.xml - `); + app.get("/robots.txt", (req, res) => { + const baseUrl = getBaseUrl(req); + res.type("text/plain"); + res.send(buildRobotsTxt(baseUrl)); }); app.get("/sitemap.xml", (req, res) => { const baseUrl = getBaseUrl(req); res.type("application/xml"); - res.send(` - - - ${baseUrl}/ - weekly - 1.0 - - - ${baseUrl}/login - monthly - 0.8 - - - ${baseUrl}/register - monthly - 0.8 - -`); + res.send(buildSitemapXml(baseUrl)); }); } diff --git a/server/tests/static-routes.test.ts b/server/tests/static-routes.test.ts index 02525af..5900c6d 100644 --- a/server/tests/static-routes.test.ts +++ b/server/tests/static-routes.test.ts @@ -24,7 +24,9 @@ describe("Static Routes", () => { expect(res.type).toBe("text/plain"); expect(res.text).toContain("User-agent: *"); expect(res.text).toContain("Allow: /"); + expect(res.text).toContain("Allow: /support"); expect(res.text).toContain("Disallow: /dashboard"); + expect(res.text).toContain("Disallow: /admin"); expect(res.text).toContain("Disallow: /api/"); expect(res.text).toContain("Sitemap:"); }); @@ -38,7 +40,21 @@ describe("Static Routes", () => { expect(res.type).toBe("application/xml"); expect(res.text).toContain("urlset"); expect(res.text).toMatch(/https?:\/\/127\.0\.0\.1:\d+\/<\/loc>/); - expect(res.text).toContain(""); + expect(res.text).toContain("/support"); + expect(res.text).toContain("/privacy-policy"); + expect(res.text).toContain("/llms.txt"); + }); + }); + + describe("GET /llms.txt", () => { + it("should return llms index content", async () => { + const res = await request(app).get("/llms.txt"); + + expect(res.status).toBe(200); + expect(res.type).toBe("text/plain"); + expect(res.text).toContain("# Competitor Watcher"); + expect(res.text).toContain("competitor analysis"); + expect(res.text).toContain("Sitemap:"); }); }); diff --git a/vercel.json b/vercel.json index b22809f..6d39d39 100644 --- a/vercel.json +++ b/vercel.json @@ -9,7 +9,7 @@ }, "rewrites": [ { - "source": "/((?!api/).*)", + "source": "/((?!api/|robots\\.txt|sitemap\\.xml|llms\\.txt).*)", "destination": "/index.html" } ], From deea440a10fb51846a8295050b53cfcfd458ed5d Mon Sep 17 00:00:00 2001 From: ManuelCLopes Date: Thu, 5 Mar 2026 00:43:16 +0000 Subject: [PATCH 2/2] test: stabilize and update e2e assertions --- e2e/dashboard-flow.spec.ts | 20 ++++++++++++++++++-- e2e/i18n-thorough.spec.ts | 8 ++++++-- e2e/seo-accessibility.spec.ts | 6 +++++- e2e/settings-support.spec.ts | 5 +++-- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/e2e/dashboard-flow.spec.ts b/e2e/dashboard-flow.spec.ts index f3132de..1ae4900 100644 --- a/e2e/dashboard-flow.spec.ts +++ b/e2e/dashboard-flow.spec.ts @@ -170,7 +170,10 @@ test.describe('Dashboard Business Management Flow', () => { // Now address is "verified" (pending status), select type await page.getByTestId('select-business-type').click(); - await page.getByTestId('option-type-restaurant').click(); + const restaurantOption = page.getByTestId('option-type-restaurant').first(); + await expect(restaurantOption).toBeVisible({ timeout: 10000 }); + await restaurantOption.click({ force: true }); + await expect(page.getByTestId('select-business-type')).toContainText(/Restaurant|Restaurante/i); // Submit // We need to update the GET mock to include the new business for the UI to update optimistically or on refetch? @@ -178,10 +181,23 @@ test.describe('Dashboard Business Management Flow', () => { // Let's update the GET mock dynamically for the next fetch. + const createPromise = page.waitForResponse((response) => + response.url().includes('/api/businesses') && + response.request().method() === 'POST' && + response.status() === 200 + ); + const refetchPromise = page.waitForResponse((response) => + /\/api\/businesses(\?|$)/.test(response.url()) && + response.request().method() === 'GET' && + response.status() === 200 + ); + await page.getByTestId('button-submit-business').click({ force: true }); + await createPromise; + await refetchPromise; // Verify it appears in the list - await expect(page.getByText('New Pizza Place')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'New Pizza Place' })).toBeVisible({ timeout: 10000 }); }); test('can edit an existing business', async ({ page }) => { diff --git a/e2e/i18n-thorough.spec.ts b/e2e/i18n-thorough.spec.ts index 931cff9..c5f0bac 100644 --- a/e2e/i18n-thorough.spec.ts +++ b/e2e/i18n-thorough.spec.ts @@ -123,12 +123,16 @@ test.describe('Internationalization Thoroughness', () => { await langSelector.click(); await page.getByTestId('button-lang-en').click(); - await expect(page.getByRole('heading', { name: /Discover Your Local Competition/i })).toBeVisible(); + await expect( + page.getByRole('heading', { name: /Competitor Analysis for Local Businesses|Discover Your Local Competition/i }) + ).toBeVisible(); // Switch to PT await langSelector.click(); await page.getByTestId('button-lang-pt').click(); - await expect(page.getByRole('heading', { name: /Descubra a sua Concorrência Local/i })).toBeVisible(); + await expect( + page.getByRole('heading', { name: /Análise de Concorrência para Negócios Locais|Descubra a sua Concorrência Local/i }) + ).toBeVisible(); }); }); diff --git a/e2e/seo-accessibility.spec.ts b/e2e/seo-accessibility.spec.ts index f288ff7..442c251 100644 --- a/e2e/seo-accessibility.spec.ts +++ b/e2e/seo-accessibility.spec.ts @@ -10,7 +10,11 @@ test.describe('SEO & Accessibility', () => { // Meta description const description = page.locator('meta[name="description"]').first(); - await expect(description).toHaveAttribute('content', /Analyze|concorrência/i, { timeout: 10000 }); + await expect(description).toHaveAttribute( + 'content', + /competitor analysis|Analyze|concorr[eê]ncia/i, + { timeout: 10000 } + ); // OpenGraph tags const ogTitle = page.locator('meta[property="og:title"]').first(); diff --git a/e2e/settings-support.spec.ts b/e2e/settings-support.spec.ts index 017f0bb..840a08d 100644 --- a/e2e/settings-support.spec.ts +++ b/e2e/settings-support.spec.ts @@ -68,7 +68,9 @@ test.describe('Support & Settings Flows', () => { // Verify toast or updated state // When switching to PT, the toast should be in PT - await expect(page.getByText('Idioma atualizado')).toBeVisible({ timeout: 5000 }); + await expect( + page.getByRole('status').filter({ hasText: /Idioma atualizado|Language updated/i }).first() + ).toBeVisible({ timeout: 5000 }); }); test('authenticated user can update profile', async ({ page }) => { @@ -123,4 +125,3 @@ test.describe('Support & Settings Flows', () => { }); }); -