# SEO phase 1: critical fixes and quick wins

## Objective
Apply the low-cost, high-impact fixes from the 2026-10-30 SEO + GEO audit of https://taxes.com.ar/ (health score 32/100), without the SSR/prerender work (phase 2).

## Problem
- About 30 marketing URLs (`/solutions/*`, `/prices/*`, `/cursos`, `/login`, ...) share the title "Taxes Software" and the default description (fallback in `resources/views/landingempresas/view/layouts/head.blade.php:28-29`). No page has a canonical tag.
- `/sitemap.xml` returns 404, while GSC still has it registered since 2020.
- `/login` (43.5k impressions) and other utility screens are indexable.
- Host duplicates: `routes/subdomain/contable.php:6-14` serves `/sueldos` and `/preguntas` on every host (Google picked `contable.taxes.com.ar/sueldos` as canonical); `www` answers 200 on inner paths; `/index.php` duplicates `/`.
- `/solutions/proveedores` returns HTTP 500; missing blog posts return 200 (soft 404); `/solutions/faq` duplicates `/prices/faq`.

## Why
96% of GSC clicks are brand queries; module pages get thousands of impressions at 0.03-0.14% CTR. These fixes are cheap prerequisites for phases 2-3.

## Scope (authorized by the user on 2026-10-30: "dale" to phase 1)
In: action-plan items 1.1, 1.2, 1.4, 1.5, 1.6, 1.7 (app level), 1.8, 1.9, 1.11, 1.12, 1.13.
Out: SSR/prerender (phase 2), new content pages (phase 3), nginx/server config, GSC actions (sitemap resubmission is done by the user in Search Console), deploy, compiling assets.

## Constraints
- Do not compile assets (`npm run ...`) unless the user asks. Vue edits ship with the next deploy build.
- No production access, no DB writes, no new Composer dependencies.
- Defensive changes over behavior changes: canonical tags rather than cross-subdomain 301s; www/index.php redirects only for GET/HEAD on the marketing hosts.
- Canonical host for `/sueldos` and `/preguntas` = apex `https://taxes.com.ar` (default chosen by the parent; D6 not answered yet, reversible).

## Tasks
- [x] T1 — Fix `/solutions/proveedores` HTTP 500 (301 to the matching existing page) and 301 `/solutions/faq` → `/prices/faq` (items 1.1, 1.13). Route: delegated (writer A).
  - Done: `EmpresasController` now exposes `SOLUTIONS`, `SOLUTIONS_REDIRECTS` and `PRICES` constants. `proveedores` → 301 `/solutions/providers` (same "proveedores" page, the only solutions view about providers); `faq` → 301 `/prices/faq` (`diff -q` of both views: identical). Both slugs left the solutions whitelist.
  - Evidence: production before the fix `/solutions/proveedores` 500 (6,665 B), `/solutions/providers` 200. Local: `php -l` OK; `curl -sI :8001/solutions/proveedores` → 301 `Location: .../solutions/providers`; `/solutions/faq` → 301 `Location: .../prices/faq`; both targets 200.
  - `php artisan route:list` dies locally before filtering (`PspSucursalController::__construct` queries the `psp` connection: account `forge` locked, no `psp` DB locally). Routes were listed with a scratch script that reads the router without instantiating controllers: `GET|HEAD solutions/{solution} => EmpresasController@solutions`.
  - Follow-up (outside writer A surfaces): `Header.vue:156` and `Footer.vue:15` (landingempresas components) still link to `/solutions/faq`; point them to `/prices/faq` so internal links skip the 301.
  - Commit: `641a2e4f`.
- [x] T2 — Blog: missing/unpublished posts return HTTP 404; 301 the PEM post slug variants to the canonical slug (item 1.2). Route: delegated (writer A).
  - Done: `BlogController@redirigir` renders the same `landing2.view.blog.post.error` view with status 404. New `BlogController::PATH_REDIRECTS` maps the misspelled path to the real one (301).
  - PEM decision, from production GETs on 2026-10-30: `/blog/Convertir-archivo-PEM-a-Excel-F8011-Controlador-Nueva-Tecnologia` → 200, 9,498 B (same size as `/blog/zzz-no-existe`, the soft 404); `...-Controladores-Nueva-Tecnologia` → 200, 53,208 B (real post; local `blogs.id = 26` has this path). The trailing-slash variants return the same sizes because Laravel trims the slash before matching `{path}`. Only the `Controlador` (singular) variant is redirected; one entry covers both with and without trailing slash. The real post's trailing-slash duplicate is left to the canonical tag (T5).
  - Evidence (local): `php -l` OK; routes `GET|HEAD blog/{path} => BlogController@redirigir`; `curl -sI :8001/blog/zzz-no-existe` → 404 (9,239 B body, same error view); `.../Controlador-Nueva-Tecnologia` and `.../Controlador-Nueva-Tecnologia/` → 301 `Location: .../Controladores-Nueva-Tecnologia`; the real post → 200 (52,949 B).
  - Commit: `39fb44a3`.
- [x] T3 — Host and index hygiene middleware: 301 `www.taxes.com.ar/*` → `https://taxes.com.ar/*`, 301 `/index.php` → `/`; `X-Robots-Tag: noindex, follow` on `/login`, `/registrarse`, `/proveedores`, `/mantenimiento*`, `/probar*` and the campaign `probar` aliases, `password/*`, plus every response on the turecibo and panel hosts (items 1.7 app level, 1.9). Route: delegated (writer A).
  - Done: `app/Http/Middleware/SeoHostHygiene.php`, registered in `Kernel::$middleware` (global, right after maintenance mode, before the `web` group's session/CSRF). Hosts and paths live in `config/seo.php` (`canonical_host`, `redirect_hosts`, `noindex_hosts`, `noindex_paths`, `robots_header`). No DB, no session; redirects are built as a plain `RedirectResponse`.
  - Behavior: GET/HEAD on `www.taxes.com.ar` → 301 `https://taxes.com.ar` + raw path + query. GET/HEAD `/index.php` on the canonical host → 301 `/`; `/index.php/<path>` → 301 `/<path>` too (same duplicate, and www + `/index.php` collapses into one hop). The raw request URI is used because Symfony reports `/index.php` as base URL with path info `/`. POST and other methods pass through. contable.* is not redirected.
  - Evidence (local `php artisan serve --port=8001`, Host header emulation): `php -l` OK on the 3 files. `www` `/preguntas?utm=x` → 301 `https://taxes.com.ar/preguntas?utm=x`, no Set-Cookie; POST `www` `/newsletter` → 419 (not redirected); `/index.php` with Host `taxes.com.ar` → 301 `https://taxes.com.ar/`; `/index.php/preguntas` → 301 `/preguntas`; `www` `/index.php` → 301 `https://taxes.com.ar/` (one hop); `/index.php` on 127.0.0.1 → 200 (not the canonical host, by design). `X-Robots-Tag: noindex, follow` present on `/login`, `/registrarse`, `/proveedores`, `/mantenimiento`, `/mantenimiento/hola`, `/probar`, `/probar_curso`, `/password/reset`, the 3 campaign aliases, `solutions/recaudacion/registrarse|login`, and `/` on Host `turecibo.taxes.com.ar` and `panel.taxes.com.ar`; absent on `/`, `/preguntas`, `/solutions/billing`, `/solutions/recaudacion`, `/blog`, and on Host `contable.taxes.com.ar` `/sueldos` (200, no redirect).
  - Note: `public/robots.txt` and other static files are served by nginx without reaching Laravel, so they never get these headers or redirects.
  - Commit: `81458a0c`.
- [x] T4 — Dynamic `/sitemap.xml` (apex URLs only: `/`, `/preguntas`, `/sueldos`, `/cursos`, `/programa-partners`, privacy pages, `/blog` + published posts, valid `/solutions/*` and `/prices/*` slugs) and `Sitemap:` line in `public/robots.txt` (item 1.8). Route: delegated (writer A).
  - Done: `SitemapController@index` + `resources/views/sitemap/index.blade.php`, route `GET /sitemap.xml` in `routes/web.php` (no domain constraint, `SitioPanelNoPasa`, name `sitemap`). URLs are absolute on `https://` + `config('seo.canonical_host')`: 9 static paths (`/`, `/preguntas`, `/sueldos`, `/cursos`, `/programa-partners`, `/privacidad-taxes`, `/privacidad-watax`, `/privacidad-shop`, `/blog`), `EmpresasController::SOLUTIONS` (22, without `proveedores`/`faq`, which now redirect) and `::PRICES` (10), and published posts (`Blog::where('publicar', 1)`, the blog index filter, unique by `path`) with `<lastmod>` = `updated_at` (W3C datetime). No priority/changefreq. Post paths are percent-encoded like the browser encodes the `/blog/${path}` links (non-ASCII, quotes), keeping RFC 3986 path characters. XML cached 1 h (`Cache::remember('seo.sitemap.xml', config('seo.sitemap_cache_ttl'))`); no cache invalidation on post edits, so new posts appear within the hour.
  - `public/robots.txt`: existing `User-agent: *` / `Disallow:` kept, `Sitemap: https://taxes.com.ar/sitemap.xml` appended. The same file is served on every host (contable, turecibo, panel, www); acceptable for now since the Sitemap line is absolute to the apex.
  - Shadowing check: subdomain groups are registered before `web.php`, but the only wildcard host group (`{subdomain}.APP_DOMAIN`) never matches the apex and has no catch-all path; no root-level `{param}` GET route exists besides the fallback. Production `/sitemap.xml` and `/zzz-no-existe.xml` both return the app's `errores.404` view (559 B, sets `laravel_session`), so `.xml` requests reach Laravel; no nginx change needed.
  - Evidence (local): `php -l` OK (controller, `routes/web.php`, `config/seo.php`); routes `GET|HEAD sitemap.xml => SitemapController@index`; `curl -sI :8001/sitemap.xml` → 200 `Content-Type: application/xml; charset=UTF-8`, no X-Robots-Tag; 73 `<url>` (9 + 22 + 10 + 32 local posts), 32 `<lastmod>`; `xmllint --noout` valid; every `<loc>` starts with `https://taxes.com.ar/`; none of login/registr/password/probar/mantenimiento/recaudacion/`proveedores`/`solutions/faq`/campaign aliases present; all 73 URLs requested against the local server → 200; second request served from cache (`Cache::has` true); `www` `/sitemap.xml` → 301 `https://taxes.com.ar/sitemap.xml`.
  - Correction to "Pending decisions": the 559 B "Página No Encontrada" page is the app's `resources/views/errores/404.blade.php`. `SubDomainController@index` (`{subdomain}` wildcard, `app/Http/Controllers/SubDomain/SubDomain/SubDomainController.php:47,94`) returns it with `view()` (status 200), which likely explains the `panel.taxes.com.ar/` soft 404; not verified against the panel deployment.
  - Commit: `3611bd51`.
- [x] T5 — Unique title, meta description, og/twitter title per `/solutions/*` and `/prices/*` slug and per blog post; meta description in the legacy head; self-referencing canonical forced to the apex host in both head layouts (items 1.4, 1.6). Route: delegated (writer B).
  - Done: `EmpresasController::SOLUTIONS_META` (22), `::PRICES_META` (10) and `::PARTNERS_META`; `SiteController::META` (home, `/preguntas`, `/sueldos`); `BlogController::META_INDEX`, `::POST_META` (hand-written PEM post meta from the audit proposal) and, for every other post, the post title (+ " | Taxes" when it fits in 60 chars) and a 155-char plain-text excerpt; `/blog/categorias/{id}` uses the category name. Copy: Spanish, nominal/impersonal, titles ≤60 chars, descriptions 140-160 chars, "ARCA (ex AFIP)" on billing/tax pages, built from each page's Vue copy; no prices in the copy.
  - Heads: both layouts compute `$seoTitle`/`$seoDescription` once and reuse them for og:title/og:description/twitter:*. The landingempresas fallback description now says "ARCA (ex AFIP)" without tú/vos forms. The legacy head (landing2) keeps its default title, emits a description only when the controller passes one (no generic duplicate), and gained og/twitter title+description.
  - Canonical: new partial `landingempresas/view/layouts/_canonical.blade.php`, included by both heads, emits `<link rel="canonical">` and `og:url` with the same URL. `config('seo.shared_paths')` (`/`, `solutions/*`, `prices/*`, `sueldos`, `preguntas`, `blog`, `blog/*`, `cursos*`, `programa-partners`) → `https://taxes.com.ar` + path; any other page → its own scheme + host + path. Query string and trailing slash dropped; root = `/`.
  - Contable exception: the contable.* home is `ContableController@index` → `landing2.view.landing.index` → legacy head. The exception is generalized: `/` only points to the apex on the apex itself; the root of every other host (contable.*, but also turecibo.*, panel.*, proveedores.*, simuladorganancias.*, which all render one of these heads at `/` with their own page) keeps a self-canonical. Deviation from the brief, reason: the literal rule would have pointed those hosts' own homes to the apex home.
  - Evidence (local `php artisan serve --port=8001`; local `APP_DOMAIN=localhost`, so the contable route group only matches Host `contable.localhost`): `php -l` OK on the 4 PHP files. Host `taxes.com.ar` `/` and `/?utm_source=x` → canonical `https://taxes.com.ar/`; `/solutions/administration`, `/solutions/billing`, `/prices/faq`, `/prices/todos`, `/sueldos`, `/sueldos?utm=x`, `/preguntas`, `/blog`, `/programa-partners`, `/blog/categorias/2` → canonical `https://taxes.com.ar{path}`; PEM post with trailing slash → canonical without it. og:url = canonical and og/twitter title+description = title/description on every page checked. Host `contable.localhost` `/` → title "Taxes Software - Sistema para Contadores", no description, canonical `http://contable.localhost/` (self); `contable.localhost/sueldos` and `/preguntas` → apex canonical. Host `contable.taxes.com.ar` `/` locally falls through to the apex home route (no contable route match) but still self-canonicalizes. `/login` → self canonical. All 32 `/solutions/*` + `/prices/*` sitemap URLs → 200 with 32 distinct titles.
  - Follow-ups outside the allowed surfaces: the contable.* home has no meta description (`ContableController@index` / `landing2.view.landing.index`); `/cursos` index keeps the "Taxes Software" fallback title (`CoursesController@index`). Blog rows 18/19 and 22/23 are duplicated in the DB (same title and path), so those posts cannot get distinct titles without a manual DB cleanup.
  - Commit: `d56c3343`.
- [x] T6 — One H1 per module/price page, aria-labels on footer social icons, blog thumbnails and the unlabeled select/input, copy fixes ("/control/general" on impuestos, "Si tendrías" on the PEM post) (items 1.5, 1.11, 1.12). Route: delegated (writer B).
  - Done: the existing hero heading of the 22 served `/solutions/*` components went from `<h3 class="solutions-title">` to `<h1>` with the same class and text (all were already keyword-first module names, no slogans, so no copy change). `PaymentFlow.vue` is not registered in `app_landing_empresas.js` and was left as is. The 8 price components render the hero through `<component :is="headingTag">` (prop default `h1`); `/prices/todos` promotes its own `<h2>` title to the page `<h1>`, passes `heading-tag="h2"` to the 8 stacked price components and its "Planes para Contadores" `<h3>` became `<h2>` (inline styles, same look), so that page keeps one h1. `Faq.vue` (`/prices/faq`): `<h3 class="faq-title">` → `<h1>`. Home (`Index.vue`) keeps its single h1.
  - Look preserved: `index.css` styles every `h1` as the home hero (uppercase, `width: 15ch`, Montserrat, blue). `landingempresas/view/layouts/head.blade.php` adds a small `<style>` scoped to `h1.solutions-title`/`h1.faq-title` that restores the h3 look (no transform, auto width, bootstrap heading font stack, `#151515`, blue inside `.section-description`, also for the `h2` variant on `/prices/todos`).
  - Accessibility: `Footer.vue` social links got `aria-label="Taxes en Facebook|Instagram|YouTube"` (icons `alt=""`); `Index.vue` social icons' wrong `alt="Taxes software hero image"` → "Taxes en Facebook|Instagram|YouTube"; `Register.vue` (embedded on the home and `/registrarse`): `aria-label` on the country-code `<b-form-select>` and on the 5 text inputs (the audit counted 1 unlabeled input but did not name it, so all got one); blog list thumbnail links (`landing2/view/blog/posts-components.vue`) got `:aria-label="blog.titulo"`.
  - Copy: `Impuestos.vue` "en `/control/general`" → "desde el Control General"; `Header.vue:156` and `Footer.vue:15` now link to `/prices/faq`.
  - Manual content edit (DB, not done here): the PEM post typo "Si tendrías un usuario podrías" lives in `dbtaxes.blogs` row `id = 26` (`contenido`); change it to "Si tuvieras un usuario podrías" from the blog admin in production.
  - Evidence: `rg -c '<h1'` → 1 in each of the 22 solution components, `Faq.vue` and `Index.vue`; the 8 price components have 1 `<component :is="headingTag"` each (default h1) and no literal `<h1`; local `/prices/todos` raw HTML → exactly 1 `<h1` ("Precios de todos los servicios"), 8 `heading-tag="h2"`, the Contadores title as `<h2>`; the new `h1.solutions-title` CSS is present in the served head. Rendered h1 counts of the Vue pages can't be checked locally until the next asset build (no compiling in this phase).
  - Commit: `b0575971`.
- [x] T7 — Unify the free-trial length across home, `Faq.vue`, `/prices/faq` and the `/get_preguntas` data (item 1.10). Unblocked: D1 = 30 días (user decision). Route: delegated (writer B).
  - Done (42 strings in 32 landingempresas components, free-trial mentions only): "Pruébalo Gratis Por 1 Mes" / "Pruébalo gratis por 1 mes" → "Probalo gratis 30 días" (24: `Index.vue`, `Register.vue` ×2, 20 solution components); "¡LO QUIERO! - 1° MES GRATIS" → "¡LO QUIERO! - 30 DÍAS GRATIS" (16 in the 8 price components); `Header.vue` CTA "Empieza Tu 1° Mes Gratis" → "Empezá tus 30 días gratis"; `Faq.vue` "¿Cómo funciona el periódo de Prueba de Un Mes?" → "¿Cómo funciona el período de Prueba de 30 días?" (its answers already said 30 días). Billing-period mentions ("mes abonado", "/ mes", "Plan base mensual") untouched. The T5 home description already says "Prueba gratis de 30 días"; no title mentions the trial length.
  - Manual DB edit for production (not done here, no DB writes): `/preguntas` reads `Faq::all()` (`SiteController@get_preguntas`, connection `seguridad`) → table `dbtaxes.faq`, row `id = 1`, `pregunta` "¿La prueba gratis por cuanto tiempo es?", current `respuesta` (local copy, `updated_at` NULL): `Por 3 meses con acceso a todas las modalidades sin restricciones , luego conservas el usuario con el plan gratuito. Los planes podes visualizarlos aca <a href="/#planes" >PLANES</a>`. Replace "Por 3 meses" with "Por 30 días" after checking the production row has the same text. No other `faq` row mentions a length (read-only `SELECT ... LIKE '%mes%' / '%prueba%'` → only id 1).
  - Out of the allowed surfaces, still saying 1 month (follow-up, needs a decision on whether each is the same free trial): `landing2/view/landing/whatsapp.blade.php:269,303,400` (contable.* home, WhatsApp section: "Primer mes gratis", "1° mes GRATIS", "Probar 1 mes GRATIS"); `subdomain/turnos/landing/view/landing/index.vue:24,146,403,552`; `landingempresas/proveedores/layouts/components/{solutions,prices}/*.vue` (16 strings); `taxes_admin/notificaciones/popups/PopupWhatsApp.vue:108` (internal popup).
  - Evidence: `rg -c 'Probalo gratis 30 días'` → 24, `rg -c '30 DÍAS GRATIS'` → 16; `rg -i '1 mes|1° mes|un mes|primer mes|3 meses'` over `landingempresas/view/layouts/components` → no matches.
  - Commit: `99b5f5b4`.
- [x] T8 — Unknown subdomain soft 404: `SubDomainController` renders `errores.404` with HTTP 200 (what `panel.taxes.com.ar/` serves); return it with status 404 in every branch that renders it, no other behavior change. Route: delegated (writer B).
  - Done: both branches that render `errores.404` (`index()` when no `TaxesSubdomain` matches, `estoy_en_panel()` when SITIO=panel and the subdomain is not type 8) now return `response()->view('errores.404', [], 404)`. Nothing else changed.
  - Evidence (local, `APP_DOMAIN=localhost`, so the `{subdomain}` wildcard only matches `*.localhost`): `php -l` OK. Before: Host `panel.localhost` `/` → 200 "Página No Encontrada". After: `panel.localhost` `/` → 404 (same title), `noexiste.localhost` `/` → 404; existing landing subdomain `tms.localhost` `/` → 200 (unchanged). Host `panel.taxes.com.ar` `/` locally → 200 because it does not match the local wildcard and falls through to the apex home; it can only be confirmed after deploy. The `estoy_en_panel()` branch needs SITIO=panel and was not exercised locally.
  - Commit: `79824bf8`.

Route evidence: every task touches 2+ non-trivial files (controllers, routes, middleware, views) → writer trigger fires; one writer at a time, A then B.

## Acceptance criteria
- `/solutions/proveedores` and `/solutions/faq` answer 301; a missing blog slug answers 404.
- `www` inner paths and `/index.php` answer 301 to the apex; utility routes carry `X-Robots-Tag: noindex, follow`.
- `/sitemap.xml` answers 200 `application/xml` with only apex, indexable, 200 URLs; robots.txt declares it.
- Every `/solutions/*`, `/prices/*` and blog post page has a distinct `<title>` and description and a canonical `https://taxes.com.ar{path}`.

## Checks
- No meaningful automated suite exists (`tests/Feature` only has `ExampleTest.php`), so test-first does not apply; checks are `php -l` on changed PHP files, `php artisan route:list` for new routes, and HTTP checks against a local `php artisan serve --port=8001` when it boots (Host header for www/turecibo cases).

## Progress
- 2026-10-30: branch `feat/seo-fase-1` created from `559df2e1`. Engram mirror `odd/seo-fase-1/tasks`: PENDING (mem_save rejects writes: "host session already ended").
- 2026-10-30: writer A closed T1-T4 (one commit each). Known local environment failure: `php artisan route:list` aborts on `PspSucursalController::__construct` (`psp` connection: account locked / DB missing locally), so routes were listed with a scratch script reading the router directly.
- 2026-10-30: writer B closed T5-T8 (one commit each: `d56c3343`, `b0575971`, `99b5f5b4`, `79824bf8`). Engram mirror still PENDING (writer B has no Engram access; the parent resynchronizes). Vue changes (T6, T7) need the next asset build to show up; nothing was compiled.
- 2026-10-30: parent spot check `php -l SubDomainController.php`: no syntax errors; tree clean after `6afc6bfe`. RDD: `review assess --base-ref 559df2e1 --committed-only` → risk medium, review_due (slice_budget_reached, 53 files / 1048 lines); consent relayed, user chose **declined** for this candidate (`declined_this_candidate`). Off path, medium tier: writer self-verification plus parent spot check; no extra verifier.

## Pending decisions
- D1 trial length: DECIDED by the user on 2026-10-30, the free trial lasts **30 días** (T7). D6 canonical host for the marketing pages duplicated on contable.*: apex `https://taxes.com.ar` (parent default, applied in T5).
- Superseded (T4 correction, fixed in T8): the `panel.taxes.com.ar/` soft 404 came from `SubDomainController`, not from server config; it now returns 404.
- Manual production content edits (DB, by hand): `dbtaxes.faq` id 1 "Por 3 meses" → "Por 30 días" (T7); `dbtaxes.blogs` id 26 "Si tendrías" → "Si tuvieras" (T6).
- Out-of-surface follow-ups: contable.* home meta description, `/cursos` index title (T5); the remaining "1 mes" trial mentions listed under T7 (needs a decision per file).

## Next step
Parent: review T5-T8, resync the Engram mirror, decide the T7 out-of-surface trial mentions and the contable.*/`/cursos` meta follow-ups. After deploy: confirm `panel.taxes.com.ar/` → 404 and the rendered h1 on `/solutions/*` and `/prices/*`.
