# Migración de vistas a la DB (page builder) — qué falta

> Auditoría del 2026-08-24 sobre `pivot/editor-web`, comparando contra las vistas blade de
> `production`. Todo lo de abajo está **verificado** (DB local + `curl` al sitio + `links:audit`),
> no es una lista teórica.
>
> Comandos para re-verificar:
> ```bash
> ./vendor/bin/sail artisan links:audit          # redirects guardados que no sirven nada
> ./vendor/bin/sail artisan links:audit --all    # incluye ok / externos / placeholders
> curl -s -H "Host: mexico.localhost" http://localhost/ | grep -c tiptap-content   # 0 = home vacía
> ```

---

## 0. Resumen: qué está en la DB y qué sigue en blade

| URL | Qué la sirve hoy | Estado |
|---|---|---|
| `/` (división **main**) | `frontend.page` → **page#4 `home-main`** (17 bloques) | ✅ en DB |
| `/` (divisiones 2-8) | `frontend.page` → **pages 11-17**, una por división | ✅ en DB *(2026-08-24)* |
| `/about` | `frontend.page` → **page#2** (16 bloques) | ✅ en DB *(2026-08-24)* |
| `/contact` | `frontend.page` → **page#6** (11 bloques) | ✅ en DB *(2026-08-24)* |
| `/partners` | `frontend.page` → **page#5** (4 bloques) | ✅ en DB *(2026-08-24)* |
| `/products`, `/products/{sector}` | `products.show` (cascarón blade) + `product_sector_blocks` | 🟡 parcial (bloques sí, cascarón no) |
| `/machinery` y `/machinery/{cat}/category` | `machinery.index` | 🔴 blade hardcodeado |
| `/machinery/{slug}/detail` | `machinery.show` (datos del modelo) | 🟡 contenido en DB, cascarón blade |
| `/machinery/new-machinery` | `machinery.new-machinery` | 🔴 blade 100 % hardcodeado (`messages.*`) |
| `/blogs`, `/blogs/{cat}`, `/blogs/{blog}/detail` | `blogs.index` / `blogs.show` | 🟡 contenido en DB, cascarón blade |
| `/used-machines-for-sale` | `sale-used-machine.customer.*` | 🟡 datos en DB, cascarón blade |
| `/{slug}` (catch-all) | `frontend.page` → cualquier page publicada | ✅ |

Contenido en la DB hoy: **4 pages** (2 about · 4 home-main · 5 partners · 6 contact), 48
`content_block_pages`, 48 `product_sector_blocks`, 6 `shared_sections`, 8 `header_blocks`,
8 `footer_blocks`, 47 `nav_menu_items`.

---

## 1. 🔴 Bloqueantes (rompen el sitio HOY)

### 1.1 ~~Las 7 homes de división están en blanco~~ ✅ HECHO (2026-08-24)

**Verificado: las 8 divisiones tenían home propia** en el diseño estático — 8 divisiones en la DB y
8 blades `resources/views/home/{division}-home.blade.php` en `production`, uno a uno. No hay ninguna
división que no tuviera home propia (el `HomeController` de producción hacía
`view('home.'.$division->name.'-home')` y abortaba 404 si el blade no existía).

Solo `main` tenía page con `is_main = true`; para las otras 7 `HomeController` caía al fallback
`home.example`, que lee `home_sections` — **tabla con 0 filas** → la home renderizaba literalmente
*«Aún no hay contenido configurado.»*

**Hoy las 8 divisiones sirven su propia home desde la DB, publicada y en los 6 idiomas.**

| division | slug | bloques | secciones (en el orden del blade viejo) |
|---|---|---|---|
| main | `home-main` | 17 | (ya existía) |
| mexico | `home-mexico` | 12 | hero → about → partners(7) → products → **banner** → divisions(2) → testimonials(2) → blogs |
| argentina | `home-argentina` | 12 | hero → about → partners(9) → testimonials(3) → products → **banner** → divisions(2) → blogs |
| brasil | `home-brasil` | 12 | hero → about → partners(3) → testimonials(6) → products → **banner** → divisions(2) → blogs |
| colombia | `home-colombia` | 11 | hero → about → partners(5) → products → divisions(1) → testimonials(10) → blogs |
| peru | `home-peru` | 15 | hero → about(main) → partners(11) → products → testimonials(9) → popular → machinery → divisions(1) → blogs |
| italia | `home-italia` | 9 | hero → about → products → testimonials(9) → divisions(1) → blogs (**sin partners**) |
| china | `home-china` | 15 | hero → about(main) → partners(11) → products → testimonials(9) → popular → machinery → divisions(7) → blogs |

#### Dos comandos, en este orden

**1. `php artisan pages:clone-division-homes [--dry-run] [--division=x] [--publish]`**
(`app/Console/Commands/CloneDivisionHomes.php`) — crea la page de cada división atada con
`is_main = true` y le clona **las secciones que su blade viejo tenía, en el orden que las tenía**. No
son todas iguales: solo `main` llevaba el carrusel de segunda mano; solo `peru`, `china` y `main` la
tira de productos populares + maquinaria; `italia` no llevaba partners; y `mexico`/`argentina`/`brasil`
llevaban un banner que `main` nunca tuvo (se crea como `image_banner_text` vacío, no hay de dónde
clonarlo). Aborta si la home de `main` ya no es la página de 17 bloques de la que se derivó la receta
(imprime el diff esperado/encontrado; `--force` para ignorarlo), salta la división que ya tenga
`is_main`, y rechaza un slug ya tomado en el catálogo.

**2. `php artisan pages:fill-division-homes [--dry-run] [--division=x]`**
(`app/Console/Commands/FillDivisionHomes.php`) — reemplaza el contenido de `main` por el de cada
división, leído de las dos fuentes donde el sitio estático lo tenía:

- `resources/views/home/{division}-home.blade.php` → qué clave `messages.*` iba en cada hueco y qué
  imagen de `public/images/{division}/`;
- `lang/{locale}/messages.php` y `lang/{locale}/testimonials.php` → el texto, en los 6 idiomas.

Rellena, y **solo donde el blade viejo difería de main**: about (mexico, argentina, brasil, colombia,
italia — `peru` y `china` usaban el de main), la lista de partners filtrada por división, el banner,
las tarjetas de la sección de divisiones y los testimonios. El hero (slider + las 3 cards de
servicio) y los tabs de productos eran **idénticos en los 8 blades**, así que no se tocan.

Detalles que valen la pena:

- **Los 6 idiomas de una vez**, que es justo lo que el sitio estático no podía: `lang/fr/testimonials.php`
  solo tenía el grupo de main, así que México en francés no mostraba **ningún** testimonio; aquí cae a
  inglés. Igual `es` (sin brasil) y `pt` (solo main+brasil).
- **Los partners se filtran de los 11 de main por título** (verificado: cada lista de división es un
  subconjunto exacto), así que se reusan los logos ya subidos y sus 6 traducciones.
- **Las imágenes se copian** de `public/images/**` al disco público con nombre slugificado
  (los originales traen espacios y acentos: `Ofertas especiales.png`, `Mexico-Bajío.jpg`), idempotente.
- **Idempotente de verdad**: compara el JSON **canónico** (claves ordenadas recursivamente), no byte a
  byte — `content` es una columna `json` de MySQL, que reordena las claves al guardar, así que una
  comparación de strings reescribiría los 25 bloques en cada corrida.
- **El SEO clonado de main es lo correcto**, no una deuda: producción servía
  `messages.seo-title-home` en los 8 subdominios, y el SEO de `main` ES esa clave. No hay que
  personalizarlo para igualar a producción (sí conviene, para no duplicar meta descriptions, pero es
  una mejora, no una regresión).

Verificado con render real de las 8: 0 `Unknown block type`, el placeholder desapareció de todas, y
los conteos coinciden con el diseño estático (mexico 7 partners/2 tarjetas/2 testimonios, brasil
3/2/6 en portugués, italia sin partners, china 11/7/9 en chino…). `main` intacta (7 tarjetas, 11
partners, 9 testimonios).

#### Lo que quedó pendiente de estas homes

- **`argentina`**: el banner llevaba además 3 logos de partner (Titan / Molyguard / IRO-ROJ). El perfil
  de texto enriquecido no acepta imágenes: hay que agregar un bloque `image_gallery` justo después, o
  usar una imagen de banner que ya los incluya.
- **`colombia` e `italia`**: la tarjeta de división quedó **sin subtítulo**. Los dos blades decían
  "Puebla" ahí (copiado de México); no se arrastró el error. Poner la ciudad real.
- **Texto con una nota de edición**: `lang/pt/messages.php → div-brasil-home-about-text-description`
  contiene literalmente `(delete "com isso")`. Estaba así en producción; ahora se ve en la home de
  Brasil.
- Al estar publicadas, cada home es accesible en `/` **y** en `/home-{division}` → §1.6.

<details>
<summary>Referencia: secciones de cada blade viejo (de dónde salió cada receta)</summary>
<details>
<summary>Referencia: secciones de cada blade viejo (de dónde salió cada receta)</summary>


| Blade origen | Secciones a reproducir (en orden) | Bloques del builder equivalentes |
|---|---|---|
| `home/mexico-home.blade.php` | slider · 3 cards de servicio · about+contadores · partners (`partners-home`, lista **de México**) · tabs de productos · **banner "Ofertas especiales"** (imagen `images/mexico/home/`) · divisiones · testimonios · blogs | `hero_carousel_main`, `icon_feature_cards`/`list_group_images`, `split_content`, `carousel_icons_description`, `list_group_images`, `carousel_image_title_desc`, `carousel_title_description`, `shared#6 blog_carousel` · banner: `image_banner_text` |
| `home/argentina-home.blade.php` | idem + banner con **3 logos de partner** (Titan/Molyguard/IRO-ROJ) y 4 párrafos | `image_banner_text`; los 3 logos, en el mismo grupo con `merge_with_next` |
| `home/brasil-home.blade.php` | idem + banner "Ofertas especiales" | `image_banner_text` |
| `home/colombia-home.blade.php` | slider · cards · about · partners (Colombia) · tabs · divisiones · testimonios · blogs | todos existen |
| `home/peru-home.blade.php` | + sección `products-section` (populares + maquinaria) que las otras no tienen | `carousel_image_title_button` ×2 + títulos |
| `home/italia-home.blade.php` | **sin** sección de partners; usa `productsHomeItalia` (tabs distintos: *weaving* + *flat-fabric*) | todos existen |
| `home/china-home.blade.php` | como main (incluye `products-section`) + slider con la variante `slider3` | todos existen |

</details>

Diferencias por división que hoy el builder **no** modela solo (ver §5): lista de partners,
testimonios y el banner de ofertas.

### 1.2 ~~`/about`, `/contact` y `/partners` siguen sirviendo el blade viejo~~ ✅ HECHO (2026-08-24)

Las rutas estáticas ganaban al catch-all `/{slug}`, así que `AboutUsController`, `ContactController`
y `PartnersController` renderizaban los blades legacy y las pages 2, 5 y 6 eran inalcanzables salvo
por `/sandbox/page/{slug}`.

**Solución aplicada:** las 3 rutas siguen siendo estáticas pero apuntan al builder:

```php
Route::get('/about', [PageController::class, 'show'])->defaults('slug', 'about')->name('about.index');
```

No se borró la ruta nombrada a propósito: ~40 sitios construyen estas URLs por nombre
(`route('contact.index').'#contactform'` en machinery, used-machine y el footer). `->defaults()` le
pasa a `PageController::show()` el slug que normalmente saldría de la URI, y `route('contact.index')`
sigue generando `/contact` (el default no se cuela como query string). Las 3 definiciones viejas
quedaron **comentadas** justo debajo, como puntero a los blades que son la fuente de la migración.

Verificado: `/about` 32 bloques · `/contact` 55 · `/partners` 5, en las 8 divisiones (200), y el
ancla `#contactform` existe en `/contact`.

### 1.3 ~~El navbar de la división main apunta a un 404~~ ✅ HECHO (2026-08-24)
`nav_menu_items#2` → `about-us`, y no existía ninguna page con ese slug (el slug es `about`);
`GET /about-us` → 404, igual que sus 3 sub-items. Reparado por `links:repair` (§2): ahora es
`slug:70` / `slug:70#history` / `slug:70#people` → `/about`, `/about#history`, `/about#people`.
Verificado con los 21 destinos del navbar de las 8 divisiones: todos 200.

### 1.4 ~~El navbar de main perdió Partners, Blog y Contacts~~ ✅ HECHO (2026-08-25)

`layouts/guest/navbar.blade.php` es **idéntico en `production` y en `development`** (verificado con
diff): siete items de primer nivel, los mismos para las 8 divisiones, con los desplegables de
Products y Machinery llenados por un composer desde `ProductSector::all()` y
`MachineCategory::all()`.

```
Home | About ▾ | Products ▾ | Machinery ▾ | Partners | Blog | Contacts
```

A lo que había derivado la DB: `Partners`, `Blog` y `Contacts` **no existían** en main y estaban
enterrados en un desplegable **`More..`** —que producción nunca tuvo— en las otras siete; el typo
`Partnets` era uno de ellos; y `Second-hand machinery` se había metido entre Products y Machinery.

Lo reconstruye **`nav:sync-division-menus [--dry-run] [--division=x]`**, que declara el menú de
producción y lo aplica a las 8 divisiones. **55 cambios** la primera corrida, 0 la segunda.

**Una diferencia deliberada con producción, corrección y no deriva: los desplegables se llenan POR
DIVISIÓN.** Producción listaba todos los sectores y todas las categorías a todo el mundo, así que el
menú de china ofrecía Knitting — un sector que china no tiene y que ahora responde 404, desde que
`product_sector_divisions` se respeta (§5.4). Las categorías se derivan a través de las máquinas, la
regla del sitemap.

**`Second-hand machinery` NO va en el menú** (corregido 2026-08-26, 32 cambios: se borró de las 8
divisiones y Partners/Blog/Contacts subieron de 6·7·8 a 5·6·7). Producción tenía la ruta pero nunca
la enlazó, y el navbar tiene que ser el que sirve producción. El módulo sigue siendo alcanzable en
`/used-machines-for-sale`: lo enlazan el carrusel de la home y el sitemap.

**Regla de etiquetas, que resultó importante:** en el primer nivel y en los sub-items declarados
manda la **clave de traducción** (que es lo que producción imprimía: `__('messages.partners')`), y la
etiqueta guardada queda solo como respaldo por idioma. En los sub-items **generados** (sectores,
categorías) manda la guardada, porque está completa en los 6 idiomas mientras
`ProductSector::title` solo tiene `en` — leer el modelo perdería 5 idiomas.

Eso arregló tres defectos de datos que la cosecha estaba conservando:

- `Partnets` (typo) y sus hermanos `Blog`/`Contacts`, que eran `{"en": "…", "es": null, …}` — solo
  inglés. Ahora: *Representaciones · Blog · Contacto* en español, *合作伙伴 · 博客 · 联系人* en chino.
- El *About us* de italia tenía el texto **en francés** en su slot `it` (`"À propos de nous"`).
  Ahora *Chi Siamo*.
- *Our people* de italia arrastraba una comilla suelta: `Il nostro team"`.

Verificado: los ~21 destinos del navbar responden **200 en las 8 divisiones**, china ya no lista
`/products/knitting`, y `links:audit` sigue en 0 roto (los placeholders bajaron de 17 a 10 al
desaparecer los 7 `#` de `More..`).

⚠️ **Los desplegables son datos, no una consulta**: si se agrega un sector o una categoría de
maquinaria, hay que volver a correr el comando para que aparezca en los menús.

✅ **HECHO (2026-09-18):** en las etiquetas de sector en chino, *Weaving*, *Knitting* y *Braiding*
compartían la misma traducción `编织` — no solo en china, en las **8 divisiones** (`sub_items` de
cada `nav_menu_items`). Corregido por la migración
`2026_09_18_120400_fix_nav_menu_zh_cn_duplicate_labels` copiando los valores que ya usaba
`sectors:translate-missing` para `product_sectors`: *Weaving* `编织`, *Knitting* `针织`, *Braiding*
`编带` (igual que las categorías de maquinaria, que siempre las distinguieron: `织造` vs `编带`).

### 1.5 ~~Las pages del builder salen SIN `<title>` ni meta description~~ ✅ HECHO (2026-08-24)

`PageController::show/preview` y `HomeController` no llamaban a `SeoService::setSeo()`, aunque
`pages` tiene `seo_title` y `seo_description`. `curl / | grep "<title>"` salía **vacío**; lo único
era un `og:title` de plantilla: `content="Over 9000 Thousand!"`.

**Solución aplicada:** `PageController::setSeoFor(Page $page)` (llamado desde `show` y `preview`) y
el mismo par de líneas en `HomeController` para la page `is_main`; el fallback legacy de
`HomeController` conserva `messages.seo-title-home`. Las columnas son translatable, así que
`$page->seo_title` ya llega resuelta al locale; se envuelve en `translate_data()` por si vuelve como
mapa JSON crudo.

✅ **RESUELTO** (verificado 2026-09-17): `page#2` ya tiene `seo_title`/`seo_description` reales y
completos en los 6 idiomas, ya no dice `"about"` a secas.

### 1.6 ~~Las homes son accesibles por dos URLs~~ ✅ HECHO (2026-08-24)

`/` y `/home-{division}` devolvían ambas 200 con el mismo contenido, en las 8 divisiones. Y era peor
que un duplicado simple: el layout emite `<link rel="canonical" href="{{ url()->current() }}">`, así
que **la copia se declaraba canónica de sí misma** y las dos URLs competían.

**Solución:** `PageController::show()` responde **301 a `/`** cuando la page es la `is_main` de la
división que se está sirviendo, y `GenerateSitemap::addPages()` excluye las `is_main` (ya las publica
la entrada estática `/`). `preview()` **no** redirige a propósito: el sandbox es para revisar.

No se tocó `/`: la sigue sirviendo `HomeController` igual que antes. Verificado en las 8 divisiones:
`/` 200 con los mismos bloques, `/home-{division}` → 301 al `/` de **su** subdominio, y `/about`,
`/partners`, `/contact` intactas.

De paso: `addStaticUrls()` listaba a mano `/about`, `/contact` y `/partners`, que ahora también
publica `addPages()` → cada una salía **dos veces** en el sitemap. Se quitaron de la lista estática.
`sitemap:generate` ahora da **0 duplicados** y ninguna `home-{division}`.

### 1.7 ~~El bloque `image_banner_text` no tenía frontend~~ ✅ HECHO (2026-08-24)

Estaba en el enum, en `PageBlocks::map()` y en el selector de bloques del panel, con su formulario
completo (imagen de fondo + `LocaleSwapEditor`), pero **no estaba en `block-renderer.blade.php`**:
cualquiera que lo eligiera publicaba una caja roja *«Unknown block type: image_banner_text»*. Es
justo el bloque que necesitaban los banners de mexico/argentina/brasil, así que se cerró el hueco:
`image-banner-text.blade.php` + el `@case` en el renderer + una regla acotada en `page.css`
(`.image-banner-text`, las 4 propiedades que el blade viejo llevaba en un `@push('styles')` local;
el degradado va inline por banner, igual que en los grupos con fondo `custom`).

### 1.8 ~~Las 3 feature boxes del hero salían al revés~~ ✅ HECHO (2026-08-26)

Las 8 homes de división llevaban las tres cajas de producción — Textile products (`/products`) ·
Textile machinery (`/machinery/new-machinery`) · Second-hand machinery (`/used-machines-for-sale`),
las mismas tres de los `.service-block` del blade viejo — cada una con su icono, su título en los 6
idiomas y su botón coherentes entre sí, pero **el array estaba invertido**: en pantalla la primera
caja era la de segunda mano y la tercera enlazaba a products. `content-section/hero-01.blade.php`
recorre `feature_boxes` con un `@foreach`, así que **el array ES el orden**.

Lo repara **`page-blocks:order-hero-boxes [--dry-run]`**, que identifica cada caja **por el destino
al que resuelve su botón** (nunca por posición, y entendiendo las referencias `slug:NN` del catálogo),
así que es seguro sobre una home ya arreglada a mano y sobrevive a un renombre de slug. Solo escribe
si la secuencia resultante difiere, **no toca `updated_at`**, y **saltea** —reportándolo— el bloque
que no lleve las 3 cajas conocidas, porque ahí no habría forma de distinguir una variante deliberada
de la deriva. Recorre los 3 almacenes de bloques (`content_block_pages`, `product_sector_blocks`,
`shared_sections`).

⚠️ Al comparar el JSON de estos bloques hay que hacerlo **canónicamente** (claves ordenadas):
`content` es una columna `json` de MySQL y MySQL reordena las claves al guardar, así que
`json_encode($nuevo) === $fila->content` es siempre falso y reescribirías todo en cada corrida.

## 1 bis. El sitemap ✅ REVISADO Y CORREGIDO (2026-08-24)

Tres problemas, uno introducido por la migración de §6.1 y dos que ya venían:

1. **Publicaba una URL que redirige.** `addPages()` listaba el *path del catálogo* de cada page, y el
   de `new-machinery` responde 301 a `/machinery/new-machinery` (que ya estaba en la lista estática).
   Ahora se excluyen las pages declaradas en `slugs.canonical_static_paths` — la **misma** config que
   lee `PageController::show`, así que el sitemap y el 301 no pueden discrepar sobre cuál es la URL
   real.
2. **`/locale/{es,en,it,pt,fr}` estaban listadas** (preexistente). No son páginas: son un endpoint que
   guarda el idioma en la sesión y responde **302** a la anterior. Eran 5 redirects enviados a Google
   en cada sitemap. Fuera.
3. 🔴 **Los 8 sitemaps publicaban el dominio PRINCIPAL** (preexistente, y el más grave). El paquete
   pinta cada `<loc>` con `url()`, que desde consola resuelve contra `APP_URL` — así que
   `mexico.sitemap.xml` listaba `https://group-itg.com/about` en lugar de
   `https://mexico.group-itg.com/about`. Consecuencia: las URLs de cada división **nunca se enviaban**
   y las del dominio principal se enviaban **ocho veces**. Ahora cada URL se escribe absoluta con el
   host de su división, derivado de `APP_URL` + el nombre (la misma convención de subdominio que
   `SubdomainUtil::extract()` lee al revés, así que no hay columna nueva que mantener).

Verificado: las **71 URLs** del sitemap de mexico responden **200** (ninguna redirige), **0
duplicados** en los 8, y cada uno con su host:

```
main.sitemap.xml     http://localhost/…              73 urls
mexico.sitemap.xml   http://mexico.localhost/…       71 urls
china.sitemap.xml    http://china.localhost/…        70 urls   ← una menos: le faltaba la fila de knitting
                                                                  en product_sector_divisions (DATO mal, se
                                                                  tilda en el panel; china SÍ lo lleva)
```

Ese "una menos" de china confirma de punta a punta el filtro por división del §5.4: el sitemap ya
respetaba `product_sector_divisions` mientras el controller lo ignoraba, y ahora los dos coinciden.

## 2. ~~Redirects guardados que no sirven nada — 163 enlaces~~ ✅ HECHO (2026-08-24)

`links:audit` daba **163 broken** de 389. Reparto: `nav_menu_items` 133 · `content_block_pages` 19 ·
`footer_blocks` 8 · `shared_sections` 3. Hoy: **0 broken** (239 ok · 193 externos · 17 placeholder).

**Solución aplicada: `php artisan links:repair [--dry-run] [--strict]`** — la contraparte de
`links:audit`, con el mismo recorrido de las 6 tablas (`LinkRewriter` es el espejo de
`LinkStores::walk`, así que un enlace que la auditoría encuentra es un enlace que el comando puede
reparar). Piezas nuevas:

| Archivo | Qué hace |
|---|---|
| `app/Console/Commands/RepairLinks.php` | el comando: recorre las 6 tablas, reporta y escribe |
| `app/Support/Slugs/StaleLinkFixer.php` | decide el destino de un valor roto (reglas + alias + `slug_history`) |
| `app/Support/Slugs/LinkRewriter.php` | reescribe el valor dentro del JSON, a cualquier profundidad |
| `app/Support/Slugs/LinkStores.php` | +`product_sector_blocks.data` (**60 enlaces que la auditoría no veía**; los 60 son externos, no había nada roto ahí, pero era un punto ciego) |

**Garantías** (por construcción, no por revisión a mano):
- Solo reescribe un valor que **hoy no resuelve** (`broken` o `redirected`), y solo a un candidato
  que **`PathChecker` confirma que resuelve**. Un enlace que ya funciona no se toca, y nada se
  escribe por intuición: lo que no se puede resolver se reporta y se deja quieto.
- Escribe **referencia de catálogo `slug:NN`** cuando el catálogo conoce el path — que es el punto:
  el próximo renombre lo siguen solos en vez de volver a romperse. Excepciones: rutas estáticas sin
  fila en el catálogo (`machinery/new-machinery`, `used-machines-for-sale`) quedan literales, y
  **TipTap** guarda el path resuelto porque así es como el editor guarda los enlaces por diseño.
- Conserva el ancla sobre la referencia: `about-us#history` → `slug:70#history`.
- **Idempotente** (2ª corrida: 0 cambios) y **no toca `updated_at`**, igual que
  `page-blocks:repair-wrappers`.
- Decodifica el JSON como objetos, no como arrays asociativos: re-serializar un array convertiría un
  `{}` vacío en `[]` y cambiaría la forma de un blob del que solo queríamos tocar un string.

Resultado en local: **163 reparados en 77 filas, 0 sin resolver.** Verificado además que el navbar de
las 8 divisiones resuelve a 200 en todos sus destinos y que no queda ni un enlace inerte
(`javascript:void(0)`, que es como se pinta una referencia colgada).

### Reglas que aplica (para leer el mapeo de una ojeada)

#### Tabla de corrección aplicada (valor guardado → valor correcto)

| Valor guardado | Veces | Por qué falla | Valor correcto |
|---|---|---|---|
| `page/about` | 15 | el prefijo `page/` ya no existe (ahora el catch-all es `/{slug}`) | `about` — mejor: referencia `slug:` a page#2 |
| `page/about#organization` · `#history` · `#people` | 7+7+7 | idem + **el ancla no existe** (§4) | `about#…` tras crear las anclas |
| `about-us` · `about-us#history` · `about-us#people` | 3+1+1 | no hay page con ese slug | `about` / `about#…` |
| `products/general` | 8 | el sector se llama `additional-accessories` | `products/additional-accessories` |
| `page/used-machines-for-sale` | 8 | prefijo `page/` | `used-machines-for-sale` |
| `used-machine-for-sale` | 1 | singular | `used-machines-for-sale` |
| `page/new-machinery` | 8 | no existe esa page (§6.3) | `machinery/new-machinery` |
| `machinery/category/{weaving,warping,label-weaving,knitting,braiding,sizing,finishing}` | 8 c/u = **56** | la rama había cambiado la forma de la URL y se **restauró** la de producción | `machinery/{name}/category` — mejor: referencia `slug:` a la MachineCategory |
| `page/contacts` | 8 | prefijo + plural | `contact` |
| `/page/contact` | 6 | prefijo (dentro de TipTap, los 6 idiomas) | `contact` |
| `page/contact#contactform` | 3 | prefijo (el ancla **sí** existe) | `contact#contactform` |
| `page/partnets` | 7 | prefijo + **typo** | `partners` |
| `page/partners` | 1 | prefijo | `partners` |
| `page/main-home` | 7 | prefijo; además cada división debería ir a **su** home | `/` (opción `[Home]` del desplegable) |
| `page/home` | 2 | no existe | `/` |
| `/products/sector/itg-line` | 6 | orden invertido (dentro de TipTap, 6 idiomas) | `products/itg-line` |
| `page/dfd` | 1 | basura de pruebas (breadcrumb de page#6) | `/` |

#### Dónde estaba cada uno (fuera de `nav_menu_items`)

```
content_block_pages#54  page_header_breadcrumb  page 2  → page/home, page/about
content_block_pages#77  page_header_breadcrumb  page 5  → page/home, page/partners
content_block_pages#83  page_header_breadcrumb  page 6  → page/dfd, page/contacts
content_block_pages#57  side_image_rich_text    page 2  → /products/sector/itg-line  (×6 idiomas)
content_block_pages#76  simple_rich_text        page 2  → /page/contact              (×6 idiomas)
content_block_pages#37  hero_carousel_main      page 4  → used-machine-for-sale
footer_blocks#1..#8     button_01                       → about-us (div 1) / page/about (div 2-8)
shared_sections#4       carousel_image_title_button     → page/contact#contactform   (×3 items)
```

### 2.1 ✅ HECHO: botones sin destino (17 placeholders)

`links:repair` **no los tocaba a propósito**: no había de dónde deducir el destino, había que
llenarlos a mano desde el panel. **Completado** (confirmado por el usuario, 2026-09-21) — sin
`redirect = '#'` restantes en `nav_menu_items` ni placeholders sin llenar en `footer_blocks`.

- **2 quedan bien vacíos a propósito**: las tarjetas de email y dirección de `/contact` (bloque
  `icon_info_cards`). En el legacy solo la de WhatsApp era un enlace; las otras dos nunca lo fueron.
- Los 15 restantes (el `#` del desplegable "More.." de cada división y el primer item de contacto de
  los 8 footers) ya se llenaron a mano.

```
nav_menu_items#49,55,61,67,73,79,85  redirect = "#"   → el desplegable "More.." de cada división
footer_blocks#1..#8  content.2.contact_items.0        → "#"  (primer item de contacto del footer)
content_block_pages#85  cards.0 / cards.2  (page 6)   → vacío (2 de las 3 cards de contacto)
```

### 2.2 Regla para que no vuelva a pasar
Los redirects heredados guardan **texto**; el `dehydrateStateUsing` del select los convierte a
referencia (`slug:42`) al guardar el formulario, y `links:repair` hace lo mismo sin pasar por el
panel. Un enlace guardado como referencia sobrevive al renombre; uno guardado como texto, no.
Ver `docs/CLAUDE-SLUGS-CATALOGO.md`.

Después de cualquier cambio de forma de URL en `routes/web.php`: correr `links:audit --strict` (y
`links:repair --dry-run` si reporta algo).

---

## 3. ✅ HECHO (2026-09-18): enlaces "ok" que seguían guardados como texto

`links:repair` solo tocaba lo roto, así que los enlaces que ya funcionaban seguían siendo texto
literal (`machinery/new-machinery`, `products/weaving/sector`, …). Si alguien renombraba ese
contenido, el 301 de `slug_history` los salvaba, pero el enlace quedaba apuntando a la URL vieja —
y dependía de que esa tabla nunca pode la fila que lo salva (`slugs:prune-history`, retención de 12
meses, ver `docs/CLAUDE-SLUGS-CATALOGO.md`).

**Decisión: convertirlos ahora**, no esperar a que se autoconviertan al guardar el formulario.
`links:repair` ganó el flag `--to-references` (usa `SlugReference::fromLiteral()`, misma lógica que
ya usaba `StaleLinkFixer::store()` para lo roto, reusando el recorrido de las 6 tablas), invocado
desde la migración `2026_09_18_165640_upgrade_working_links_to_slug_references` — mismo patrón que
`media:brand-images-to-storage`. Resultado: 48 enlaces en 24 filas, `links:audit --strict` en 0
rotos antes y después, segunda corrida idempotente (0 para convertir).

---

## 4. ✅ HECHO: anclas perdidas (`#organization`, `#history`, `#people`)

Ningún bloque del builder tenía campo de **ancla / id de sección** — `block-renderer.blade.php` no
emitía `id`. El menú About de las 8 divisiones apunta a `#organization`, `#history` y `#people` (los
ids que tenía el blade viejo), así que sin esto no desplazaban a ninguna parte.

**Se implementó en el commit `6b545e8c`** ("builder: per-division content, anchors, and three
blocks that had no frontend"), antes de que se escribiera esta sección — quedó desactualizada hasta
verificarla el 2026-09-18:

- **Panel:** cada uno de los 24 tipos de bloque tiene el campo `Anchor (link target)` en su sección
  "Appearance & Styling" (`app/Filament/Blocks/PageBlocks.php`, dentro de `getAppearanceFields()`,
  compartido por todos los bloques como `divisions`, `background_style`, etc.). Valida formato con
  `regex:/^[A-Za-z][A-Za-z0-9_-]*$/`.
- **Render:** `block-renderer.blade.php` lee `$block['data']['anchor']`, revalida el mismo patrón, y
  pinta `id="{{ $anchor }}"` + la clase `block-anchor` (compensa el header pegajoso) en el div del
  bloque.
- **Verificado el 2026-09-18 contra producción real** (`itg_production_2026`): la página About (hoy
  `page#9` — el id cambió desde que se escribió esta sección, ya no es `page#2`) tiene los 3 anchors
  puestos con este mismo campo genérico: bloque 25 = `organization`, bloque 65 = `history`, bloque
  69 = `people`.

No queda nada por construir — el select de enlaces internos ya sabía guardar `about#history`, y
ahora el otro lado (el `id` en el HTML) también existe.

---

## 5. Contenido que varía por división — mecanismo ✅ RESUELTO (2026-08-24)

### 5.1 El criterio de diseño

La pregunta que decide todo: **¿es un documento distinto por división, o el mismo documento con
diferencias locales?**

| | respuesta | mecanismo |
|---|---|---|
| **homes** | documento distinto: secciones distintas, en orden distinto, copy distinto de punta a punta | **una page por división** (`is_main` en el pivote), §1.1 |
| **about** | el mismo: el blade legacy no tenía **ni un** `@if` por división | una page compartida, sin nada más |
| **partners** | el mismo: de sus 4 bloques varía **1** (la grilla de partners) | una page + variación adentro |
| **contact** | el mismo: de sus 11 bloques varían **2 secciones** y un título | una page + variación adentro |

Por qué NO una page por división en about/partners/contact:

1. **`slugs` tiene `UNIQUE(path)`** (índice `slugs_path_unique`, sobre `path` solo), así que dos pages
   no pueden vivir ambas en `partners`. Habría que darles slug propio (`partners-mexico`) y, para no
   perder la URL `/partners` —que está en el navbar de las 8 divisiones, en el sitemap y llevа años
   indexada— generalizar el pivote (`is_main` → una columna `role`) y que la ruta estática pida "la
   page con rol `partners` de esta división".
2. **El costo de mantenimiento es el argumento fuerte:** partners pasaría de 4 bloques a **32**, y
   contact de 11 a **88**, para expresar 1 y 3 diferencias. Cada cambio en el banner, el título o el
   "How it works" habría que repetirlo 8 veces (o aceptar que se desincronicen).
3. Cada page quedaría accesible también en `/partners-mexico` → el duplicado del §1.6, ×8.

### 5.2 Lo implementado: dos granularidades de la misma idea

**«¿Quién ve esto?», con `divisions` vacío = todas** (así que nada de lo ya guardado cambia).

**a) Por BLOQUE — el mecanismo general.** `Select` múltiple *Show only in these divisions* en
`PageBlocks::getAppearanceFields()`, que es el grupo de campos que **incluyen los 24 tipos de bloque**
(verificado: 24 en `map()`, 24 usos de `commonConfigurationFields()`) → sirve para los que existen y
para cualquiera que se agregue después, sin tocarlos. Se filtra **una sola vez**, en
`HasGroupedSections::getGroupedSections()`, que es el embudo por el que pasan pages, homes y sectores
de producto. Se guarda dentro del `content` del bloque, así que funciona igual en
`content_block_pages`, `product_sector_blocks` y `shared_sections`, sin migración.

> *«¿Y si el cliente quiere una sección nueva debajo de partners solo para México?»* → se agrega el
> bloque y se elige México. Sin código, sin esquema, sin depender de las secciones que ya están.

El filtro va **antes** del agrupamiento, así que un bloque oculto no puede dejar su grupo
`merge_with_next` a medias: los visibles se agrupan entre ellos. Y la columna **Divisions** en la
tabla de bloques del panel muestra la restricción, para que nadie tenga que abrir bloque por bloque
para entender por qué falta una sección en un subdominio.

⚠️ Un bloque de tipo `shared_section` no puede restringirse por página: su `data` sale de la fila
compartida, y su formulario no tiene los campos de apariencia. Si una sección compartida debe
limitarse, se etiqueta la `shared_sections` (afecta a todos los lugares donde se use).

**b) Por TARJETA — solo donde lo que varía son los items, no la sección.** La grilla de
`/partners` es el caso: los 4 bloques son iguales en las 8 divisiones, lo único que cambia es
**cuáles** de los 11 partners se muestran. Con visibilidad por bloque harían falta 6 grillas casi
idénticas y cambiar el logo de Molyguard serían 6 ediciones. Con la etiqueta en la tarjeta hay **una
grilla y un solo lugar por partner**, y además modela el hecho real: *«a este partner lo representan
estas divisiones»*. Campo `divisions` en el repeater de `gallery_icon_cards` + filtro en
`gallery-icon-cards.blade.php`.

Backfill: **`pages:backfill-legacy-pages`** deriva las 6 listas del propio `GeneralUtil` y las
invierte (no hay matriz hardcodeada: si se corrige uno de esos arrays, el comando lo sigue). Un
partner que resulta visible en las 8 se deja **sin** el campo, que es el default "en todas".

Verificado con render real, las 8 divisiones, contra el `switch` legacy:
`main/peru/china 11 · argentina 9 · mexico 7 · colombia 5 · brasil 3 · italia 2` — las 8 coinciden.

### 5.3 Secciones compartidas: el campo va en la REFERENCIA

Un bloque de tipo `shared_section` no tiene esquema propio, así que nunca recibió los campos de
apariencia — y por lo tanto no había forma de decir *«esta página muestra el carrusel de blogs solo
en México»*. El `Select` se agregó al formulario del bloque compartido (`PageBlockForm`), y se guarda
en el `content` **de la fila que referencia**, no en la fila compartida: la compartida la usan varias
páginas, y restringirla ahí la esconde en todas. `HasGroupedSections` lee primero la referencia y usa
lo que declare la fila compartida como respaldo.

Verificado: con `divisions = [mexico]` en la referencia, el carrusel de blogs desaparece de la home
de main; con el campo vacío vuelve.

### 5.4 Listados dinámicos: todo `where` por división

Un carrusel o un listado que consulta la DB tiene que consultar **lo de esta división**, no todo.
Estado real, revisado uno por uno:

| Listado | Dónde | Estado |
|---|---|---|
| Carrusel de blogs (`blog_carousel_marker`) | `blog-carousel.blade.php` | ✅ ya filtraba (`whereHas('divisions')`) |
| Índice de blogs y sus contadores por categoría | `BlogController::index` | ✅ ya filtraba |
| Listado de maquinaria | `MachineryController::index` | ✅ las máquinas ya filtraban |
| **Filtros de categoría de maquinaria** | `MachineryController::index` | ✅ **corregido**: mostraba las 7 categorías siempre; ahora solo las que tienen máquinas de esta división, derivado a través de las máquinas (`machine_categories` no tiene división propia) — la **misma** regla que ya publicaba `GenerateSitemap::addMachineCategories()`, así que el filtro y el sitemap no pueden discrepar |
| **Sectores de producto** | `ProductSectorController` | ✅ **corregido**: ignoraba `product_sector_divisions` (47 filas, poblado y significativo: china NO tiene `knitting`). Ahora `/products` sirve el primer sector *de la división*, el sidebar lista solo los suyos y un sector ajeno da 404. Verificado: `china.localhost/products/knitting` → 404, `/products/weaving` → 200. El sandbox queda a propósito sin filtrar, para poder revisar cualquier sector desde cualquier subdominio |
| Carrusel de segunda mano (`second_hand_machinery_marker`) | `second-hand-machinery-carousel.blade.php` | ✅ **global A PROPÓSITO** (decidido 2026-08-24): el catálogo de segunda mano es del grupo, no de una división, así que se muestra igual en todas. No hace falta columna de división en `sale_used_machines` — que hoy no la tiene — ni filtrar la consulta |

⚠️ El fix de las categorías de maquinaria **no se nota en la DB local**, donde las 8 divisiones
tienen las 26 máquinas y las 7 categorías. En prod, donde el pivote sí discrimina, es donde importa
— y es exactamente el caso que CLAUDE.md advierte de no juzgar por los conteos locales.

### 5.3 Lo que el legacy diferenciaba, y dónde quedó

| Legacy | Qué diferenciaba | Estado |
|---|---|---|
| `partners.blade.php:37` `switch($division->name)` | 6 listas de partners en `/partners` | ✅ etiqueta por tarjeta |
| `components/partners-home.blade.php` | 6 listas en la home | ✅ una page por división (§1.1) |
| `components/testimonials.blade.php` | testimonios filtrados por división | ✅ una page por división (§1.1) |
| `contact.blade.php:19` `@if ($division->id == 1)` | sección "oficina cerca de ti" solo en main | ✅ bloque etiquetado `main` |
| `contact.blade.php:192` `@if ($division->id == 1)` | sección "How it works" solo en main | ✅ bloque etiquetado `main` |
| `contact.blade.php:83-87` `@if/@else` | título distinto en main vs divisiones | ✅ los 2 bloques ya existían: uno etiquetado `main`, el otro con las 7 |
| `products/knitting.blade.php:46` | un producto extra en `main` y `brasil` | ❓ verificar en los bloques del sector Knitting |
| `products/braiding.blade.php:41` | un producto extra en `mexico` | ❓ verificar en los bloques del sector Braiding |
| `products/{general,weaving}.blade.php` `@if(!empty($repre))` | representante oficial por división | ✅ bloque `official_representative` |

## 5 bis. `/contact` — reordenada y etiquetada ✅ HECHO (2026-08-24)

Es el primer uso real de la visibilidad por bloque, y tenía **tres** problemas, no uno:

1. **Los dos títulos se veían a la vez.** Quien migró guardó las dos ramas del `@if/@else` como
   bloques separados, y sin nada que las distinguiera las 8 divisiones mostraban el título de main
   *seguido* del de las divisiones: «Or choose the desired Division or Commercial Agent» y debajo
   «Contacts / Choose the desired division or specific agent».
2. **El orden no era el del blade.** El formulario había quedado en medio (posición 6-7) y la grilla
   de divisiones y los agentes al final, así que los dos títulos estaban **cuatro posiciones** por
   encima de la grilla que titulan.
3. **Faltaba un titular.** «Itg Worldwide divisions», que el legacy pintaba sobre la grilla, no
   estaba en ningún bloque.

Lo resuelve `pages:backfill-legacy-pages` con la constante `CONTACT_LAYOUT`, que declara la página
sección por sección en el orden del blade y con su restricción por división:

| # | bloque | `divisions` |
|---|---|---|
| 1 | `page_header_breadcrumb` | todas |
| 2 | título "Contact us directly" | **main** |
| 3 | `icon_info_cards` (email / WhatsApp / dirección) | **main** |
| 4 | título "Or choose the desired Division…" | **main** |
| 5 | título "Contacts / Choose the desired division…" | **las otras 7** |
| 6 | **titular NUEVO** "Itg Worldwide divisions" (6 idiomas) | todas |
| 7 | `cards_with_modal` (grilla de divisiones) | todas |
| 8 | título "Commercial Agents" | todas |
| 9 | `accordion_modal_list` (agentes por continente) | todas |
| 10 | `numbered_steps` ("How it works") | **main** |
| 11 | título "Send us your inquiry" | todas |
| 12 | `shared_section` (formulario, `#contactform`) | todas |

**Cómo identifica cada bloque:** por TIPO, y por el texto de su clave legacy cuando el tipo aparece
más de una vez en la página — nunca por id, así que funciona en cualquier entorno. Ojo con un detalle
real: `messages.commercial-agents` aparece en **dos** bloques (el titular de la sección y una frase
dentro de un paso de "How it works"), por eso la búsqueda por texto lleva también filtro de tipo.
Si algún bloque no se resuelve sin ambigüedad, o queda alguno fuera del layout, **no toca nada y lo
reporta**: media reordenación es peor que ninguna.

Verificado con render real de las 8 divisiones:

```
main       tarjetas=3  pasos=3   grilla=16  agentes=3  form=sí  titular=sí
las otras  tarjetas=0  pasos=0   grilla=16  agentes=3  form=sí  titular=sí   (los 6 idiomas)
```

Y el orden que ve un visitante de main: *Contacts → Contact us directly → Or choose the desired
Division… → Itg Worldwide divisions → Commercial Agents → «Do you want to be part of our Commercial
Agents network?» → Send us your inquiry + formulario.* En México desaparecen las tarjetas, el
"How it works" y el título de main, y aparece el suyo.

## 6. 🟡 Vistas que siguen 100 % hardcodeadas (candidatas a migrar)

### 6.1 ~~`machinery/new-machinery.blade.php`~~ ✅ MIGRADA (2026-08-24)

274 líneas con todo el texto en claves `messages.*`, y era **la última página escrita a mano a la que
apuntaba la navegación** (el item Machinery de las 8 divisiones). Migrada con
**`pages:migrate-new-machinery`** a la page `new-machinery` (page#18), publicada y atada a las 8
—el blade no tenía ni un `@if` por división— con el texto de `lang/*/messages.php` en los 6 idiomas
(las 22 claves existen en los 6) y las 5 imágenes copiadas del árbol del theme al disco público.

| # | bloque | sección legacy |
|---|---|---|
| 1 | `page_header_breadcrumb` | banner `Header.png` + breadcrumb + título |
| 2 | `simple_rich_text` | "ITG Machinery" |
| 3 | `side_image_rich_text` | imagen `1.png` + "our commitment" |
| 4 | `simple_rich_text` | "New Machinery" + intro |
| 5 | `icon_info_cards` | las 3 cards: **01/02/03** + título + descripción |
| 6 | `simple_rich_text` | botón "Check our machinery offer" → `/machinery` |
| 7 | `simple_rich_text` en grupo **dark** | banner de contacto |
| 8 | `numbered_steps` | "Second-hand": 3 pasos + 3 imágenes + botón |
| 9 | `shared_section` | testimonios |

Decisiones de mapeo que vale la pena conocer:

- **Las 3 cards son una secuencia** (consultar la oferta → enviar la consulta → te contacta un
  representante), así que el 01/02/03 significa algo. `icon_info_cards` va 3 en fila y su icono es
  **opcional**, así que el número va en el `title` y el nombre del paso en el `subtitle` — que es
  exactamente cómo los apilaba el blade (número grande, título, texto).
- **El banner de contacto no necesitó bloque nuevo**: `.banner` era un color plano `#1c1c1b` sin
  imagen, que el estilo `dark` del grupo ya reproduce.
- **Los botones van como enlace-botón de TipTap** (`as_button`), que ya tiene CSS en
  `guest-titap-editor.css`; los dos apuntan a `/machinery` y `/contact#contactform`.
- **Pieza nueva: `testimonials_marker`** (`SystemSectionTypeEnum` + `shared_sections`#8 +
  `testimonials-carousel.blade.php`). El blade terminaba con `@include('components.testimonials')`,
  que leía `$division` del scope del padre — algo que una page del builder no tiene. El marcador
  resuelve la división en el render, igual que el de blogs, y cae a inglés si el
  `lang/{locale}/testimonials.php` no tiene grupo para esa división. Reutilizable en cualquier página.

**URL:** conserva su dirección indexada `/machinery/new-machinery` mediante ruta estática
(`->defaults('slug', 'new-machinery')`), y su path propio `/new-machinery` **301** hacia allí — el
mapa `CANONICAL_STATIC_PATH` de `PageController`, que es la generalización de lo que el `is_main` del
pivote hace con las homes. Así no reaparece el duplicado que cerró §1.6.

Verificado en las 8 divisiones: 13 bloques, 0 `Unknown block type`, 3 cards, 3 pasos con sus
imágenes, y los testimonios filtrados por división (main 9, mexico 2 — los grupos de
`testimonials.php`). Cada idioma con su texto.

⚠️ **Código muerto que deja atrás** (para la limpieza final, §6.4): `MachineryController::newMachinery()`
y `resources/views/machinery/new-machinery.blade.php` ya no los usa ninguna ruta.

### 6.2 `machinery/index.blade.php` (130 líneas)
Cabecera + breadcrumb + filtros new/used + grid de máquinas (datos de DB). Migrable a
`page_header_breadcrumb` + `simple_rich_text` + un marcador nuevo tipo `machinery_grid_marker`
(como `second_hand_machinery_marker`).

### 6.3 ~~Cascarones legacy con contenido ya en DB~~ ✅ HECHO (2026-08-24)

El contenido de esas vistas ya salía de la DB, pero **el banner, el breadcrumb y el titular estaban
escritos en el blade**: cambiar la imagen de cabecera de `/blogs` era tocar código.

**Qué es contenido y qué es UI** (la línea que se trazó): banner, breadcrumb y titular son
**contenido** → a la DB. Las etiquetas de campo ("Sectors", "Details", "Model", "New", "Used") son
**UI** → se quedan en `__()`, que es la convención i18n del proyecto.

**Solución: 3 `shared_sections` de tipo `page_header_breadcrumb`** — una por BANNER, no por vista,
porque el listado y el detalle de cada módulo compartían la imagen en el diseño estático:

| shared section | vistas que la usan | banner |
|---|---|---|
| **Blog header** | `blogs/index`, `blogs/show` | `blog-header.png` |
| **Machinery header** | `machinery/index`, `machinery/show` | `machinery-header.png` |
| **Second-hand machinery header** | `sale-used-machine/customer/index` y `/show` | `second-hand-machinery-header.png` |

Las crea **`sections:seed-view-headers`** (idempotente, `--dry-run`), copiando el banner del árbol del
theme al disco público y con el breadcrumb y el titular en los 6 idiomas.

**`<x-page-header>`** (`resources/views/components/page-header.blade.php`) es lo que reemplaza al
`<section class="page-title">` de cada blade. Sus props:

| prop | para qué |
|---|---|
| `section` | el `shared_sections.name` de donde leer |
| `image`, `crumbs`, `fallback-title` | **el respaldo**: lo que el blade tenía escrito |
| `title` | pisa el titular guardado — el detalle muestra el nombre del registro |
| `extra` | crumbs añadidos después de los guardados (la categoría de una máquina) |
| `h1` | el `<h1 class="d-none">` de SEO, que varias vistas armaban con el registro |

**Por qué hay respaldo:** las filas **no** son `is_system`, porque el panel no deja editar ni borrar
las de sistema — justo lo que esto viene a habilitar. Al ser editables también se pueden renombrar o
borrar, así que si la fila falta el componente pinta lo que el blade tenía en vez de un banner roto.
Probado: renombrando la fila, `/blogs` vuelve a `images/blogs/Header-Blog.png` con su titular; al
restaurarla, vuelve a `/storage/pages/page_header_breadcrumb/images/blog-header.png`.

Dos añadidos chicos en `page-header-breadcrumb.blade.php` para que el bloque sirva a los dos mundos:
`background_image_url` (una URL ya resuelta, que es como el respaldo apunta a `public/images`) y
`hidden_h1`.

Verificado: las 6 vistas 200 en las 8 divisiones, el banner sale del disco público, el breadcrumb y
el titular traducidos (`Inicio / Blog`, `首页 / 博客`), el detalle de maquinaria arma
`Home / Machinery / Weaving` con su categoría, y el `<h1>` oculto se conserva en las 6.

**No era cascarón:** `products/show` ya leía su banner de la DB (`$selectedSector->banner`), o sea que
ya era editable por sector; lo único hardcodeado ahí eran etiquetas de UI.

### 6.4 Código muerto que conviene borrar tras migrar ✅ HECHO (borrado en `2a88f411`)

`ProductController`, las blades de `products/*`, `home/main-home.blade.php` y los 7 blades de home de
división ya no existen — se borraron junto con el resto del sitio estático (§16).

---

## 7. Traducciones del contenido migrado — ✅ REVISADO (2026-08-24), no es deuda de migración

Las pages están completas: `about` 34/34 campos en los 6 idiomas, `home main` 132/132,
`partners` 52/52, `contact` 120/120, y las 7 homes de división se llenaron en los 6 idiomas de una
vez (§1.1). `nav_menu_items` 40/40.

**Los sectores de producto no son un hueco de migración.** El contenido de `/products` son los
`product_sector_blocks` — la tabla `products` **no existe** (los modelos `Product`,
`ProductCategory`, `ProductImage` son código muerto, solo `ContactProductsPost` los menciona). Están
completos en inglés; los 75 campos sin traducir son **nombres de pieza**:

| sector | campos con inglés y sin traducir |
|---|---|
| Weaving | 34 — *Blade For E.D.C, Enter Gripper, Guide Hook, Lateral Bar, Oil Filter, Sprocket Wheel…* |
| Additional Accessories | 19 |
| Knitting | 14 — *Yarn Feeders, Needles and Sinkers, Yarn sensor, Bus cable…* |
| Braiding | 8 — *Carriers, Dial Gear, Flange heand…* |
| ITG Line · Yarn | 0 |

Traducir un nombre de repuesto es decisión de negocio, no de la migración.

- **5 typos en la fuente** (carousel de "Additional Accessories"): `dalo tex`, `Cetury tex`,
  `Cunting lens`, `urex Yarn`, `Cilinders and Tensloners`. Confirmados el 2026-09-18 contra un dump
  real de producción — `product_sector_blocks` id 19 (sector 3), 31 (sector 4), 36 (sector 6).
  **Decisión del usuario: no corregir** — es contenido de catálogo que nace sin traducir/revisar en
  todos los sectores por igual, no es deuda de la migración.
- ~~1 campo con el idioma cruzado~~ ✅ **HECHO (2026-09-18)** — el bloque de "representante
  oficial" tenía **2** casos, no 1: `product_sector_blocks#40` (Braiding), `mexico.description.en`
  guardaba texto en español (`es` vacío); y `#41` (Knitting), `brasil.title.en` guardaba texto en
  portugués (`pt` sí tenía el correcto). De paso se completaron los 6 idiomas de los 3 registros con
  contenido (`mexico` en #40, `main` y `brasil` en #41) — antes solo tenían 1 o 2 idiomas de los 6.
  Corregido por `2026_09_18_122006_fix_product_sector_representative_blocks`.
- ~~Los 6 títulos de sector solo tienen `en`~~ ✅ **HECHO** — `sectors:translate-missing` los
  tradujo a los 5 idiomas, confirmado en producción el 2026-09-18 (ver §18).

Huecos menores en piezas compartidas, por si alguien los quiere cerrar: `header_blocks` 32/80 (el
resto son marcas y nombres de redes, que no se traducen), `footer_blocks` 32/40, `shared_sections`
13/19.

## 8. Checklist de ejecución sugerido

Orden pensado para que nada quede roto a mitad de camino.

- [x] **1.** Llamar a `SeoService::setSeo()` en `PageController::show/preview` + `HomeController` (§1.5).
      ↳ ✅ resuelto: el SEO real de page#2 ya está escrito en los 6 idiomas.
- [x] **2.** Campo `anchor` en los bloques, pintado en `block-renderer` + `scroll-margin` acotado (§4).
- [x] **3.** Mecanismo de variación por división (§5): visibilidad **por bloque** (los 24 tipos) +
      **por tarjeta** en la grilla de partners, y la referencia en los bloques compartidos.
- [x] **4.** Liberar `/about`, `/contact`, `/partners` del controller legacy y revisar las llamadas a
      `route('contact.index')` (§1.2).
- [x] **5.** Reparar los 163 redirects (§2) → `links:repair`, 0 broken, guardados como `slug:`.
      Los 17 placeholders ✅ se llenaron a mano (§2.1). Los 76 enlaces que ya funcionaban como texto
      literal ✅ se convirtieron a referencia (§3, commit `5741d8de`).
- [x] **6.** Anclas `#organization`, `#history`, `#people` puestas en los bloques de page#2 y el
      sub-item de main reapuntado a `#organization` → `pages:backfill-legacy-pages`.
      (El id de la página About cambió desde entonces — hoy es `page#9`, no `page#2`. El mecanismo
      genérico que las sostiene está en §4, verificado el 2026-09-18.)
- [x] **7.** Navbar de las 8 divisiones reconstruido a la estructura de producción (§1.4) →
      `nav:sync-division-menus`.
- [x] **8.** Migrar `machinery/new-machinery` a una page del builder (§6.1) →
      `pages:migrate-new-machinery`; conserva la URL `/machinery/new-machinery`, así que el menú ya
      apunta bien.
- [x] **9.** Crear las **7 homes de división** (§1.1) → `pages:clone-division-homes` +
      `pages:fill-division-homes`, publicadas y en los 6 idiomas. Los 3 logos que faltaban (banner
      de Argentina + ciudad de Colombia/Italia) ✅ subidos (confirmado por el usuario, 2026-09-21).
- [x] **10.** Revisar las traducciones (§7): las pages están completas; el campo con idioma cruzado
      ✅ se corrigió (commit `798e08e7`) y los 6 títulos de sector ✅ se tradujeron
      (`sectors:translate-missing`, ver §7). Quedan los 5 typos del carousel, decisión de no
      corregirlos (ver `docs/PENDIENTES.md`).
- [x] **11.** Arreglar la URL duplicada `/` vs `/home-{division}` y el sitemap (§1.6).
- [x] **12.** Migrar los cascarones de blogs / machinery / used-machines (§6.3) →
      `sections:seed-view-headers` + `<x-page-header>`. Código muerto (§6.4) ✅ borrado en `2a88f411`.
- [x] **13.** `links:audit --strict` en 0 broken y `sitemap:generate` revisado (§1 bis): sin URLs que
      redirijan, sin duplicados y cada división con su propio host.
- [x] **14.** Orden de las 3 feature boxes del hero restaurado al de producción en las 8 homes (§1.8)
      → `page-blocks:order-hero-boxes`.

## 9. Títulos del panel: qué registro estás editando (2026-08-28)

Las URLs del panel van **por id** (`admin/pages/12/edit`), nunca por slug, así que un H1 que decía
solo `Edit Page` no permitía saber cuál de las 40 páginas estabas editando (ni el breadcrumb, ni el
modal de borrar, ni la pestaña del navegador). Dos concerns, y **cualquier resource nuevo debería
usarlos**:

- **`App\Filament\Concerns\NamesRecordsForHumans`** (en los 9 resources) — da el nombre del registro
  para el **H1, el breadcrumb y los modales de las acciones** (Filament lo propaga solo: `ListRecords`
  y `InteractsWithRecord` le pasan `getRecordTitle()` a la tabla). Lo resuelve **en PHP** porque el
  nombre nunca es una columna plana: `slugLabel()` en los 6 modelos enrutables (el mismo texto que ya
  muestra el desplegable de slugs) y `name`/`title` en el resto; sin nombre cae a `Tipo #id`.
  ⚠️ **`$recordTitleAttribute` sigue en `null` a propósito**: es lo que Filament le pasa a la búsqueda
  global, y un `where` sobre un atributo que no es columna es un error de SQL. Lo que enciende la
  función es `hasRecordTitle()`, abierto a mano.
- **`App\Filament\Concerns\ShowsRecordInPageTitle`** (en las 8 páginas Edit + la View de contact
  request) — **H1** = el que arma Filament (`Edit :label`, traducido por Filament) + `: <registro>`;
  **pestaña del navegador** = el registro primero (la pestaña corta por el final) + `·` + la
  descripción de la pantalla, que cada página declara en `screenDescription()` (son las mismas claves
  i18n que ya existían en los 6 JSON). Las páginas Create no cambian: no hay registro que nombrar.
- **Bloques:** las tablas de bloques (page y product sector) usan `->recordTitle(...)` →
  `[3] Nombre del bloque (tipo)`, así que los modales de **editar y borrar un bloque** dicen de qué
  bloque hablan. Antes declaraban `recordTitleAttribute('Page Blocks')`, que **no es una columna**:
  resolvía a `null` y caía al model label, o sea el mismo texto para todas las filas.
- El **tipo** del H1 es el model label de Filament y **no está traducido** (en español sale
  "Editar Page", igual que antes). Si molesta, es un `getModelLabel()` por resource + 6 JSON.

## 10. Homes por división: fallback a `main` y limpieza del legacy (2026-09-08)

**Sin home propia, una división cae a la home de `main`** (antes caía a la blade `home.example`
leyendo `home_sections`, una tabla vacía desde la migración al builder, así que servía un slider en
blanco: parecía rota en vez de parecer el grupo). El filtro `divisions` por bloque (§5) sigue
corriendo sobre la page de main, así que un bloque que main se reserve no se pinta; el resto es
contenido del grupo, que es justo lo que corresponde mostrar. Si `main` tampoco tiene home, **404**
— honesto — con un `Log::warning` diciendo que el arreglo es contenido (tildar `is_main`), no código.
La vista `home.example`, el modelo `HomeSection` y su único lector ya no existen. ✅ La tabla se
dropeó en `2026_09_18_085735_drop_home_sections_table` (commit `ea88dd05`).

⚠️ **Las 3 feature boxes del `hero_carousel_main` van en el orden de producción** — Textile products
(`/products`) · Textile machinery (`/machinery/new-machinery`) · Second-hand machinery
(`/used-machines-for-sale`) — que es el de los tres `.service-block` del blade viejo (§1.8 lo repara).
⚠️ **Comparar JSON de estos bloques SIEMPRE canónicamente** (claves ordenadas), nunca byte a byte:
`content_block_pages.content` es una columna `json` de MySQL y **MySQL reordena las claves al
guardar**, así que `json_encode($nuevo) === $fila->content` es siempre falso y reescribirías todo en
cada corrida.
La home de cada división vive en `/`; su propio path (`home-mexico`) responde **301 a `/`** desde
`PageController::show()`, y el sitemap excluye las `is_main` — antes las dos URLs servían lo mismo y,
como el layout emite `canonical = url()->current()`, la copia se declaraba canónica de sí misma.

## 11. Testimonios: shared sections por división, una sola fuente editable (2026-08-28)

Los mismos testimonios se pintaban de **dos maneras** y en **nueve lugares**: cada home llevaba su
propio `carousel_title_description` (copiado por `pages:fill-division-homes` desde
`lang/{locale}/testimonials.php`) y `/machinery/new-machinery` apuntaba al **marker de sistema**
`testimonials_marker`, que lee el archivo de idioma en vivo y **no se puede editar en el panel**.
Ahora la fuente es una sola: **`sections:share-testimonials [--dry-run] [--exact]`** creó
**una shared section por conjunto distinto** (`Testimonials — Main, Peru, Italia & China`, `— Mexico`,
`— Argentina`, `— Brasil`, `— Colombia`, los 5 grupos que declara el archivo de idioma) más
**`Section title: testimonials`** (el encabezado era byte a byte idéntico en las 8 homes), todas
`is_system = 0` → **editables**. Las 8 homes y new machinery apuntan a esas filas.

- **En una página compartida por las 8 divisiones** (new machinery) van **las 5 referencias**, cada
  una con `divisions` en la fila que **referencia** (el campo *Show only in these divisions*, §5.3):
  solo sobrevive la del visitante. Es el patrón "una page + variación adentro" aplicado a un shared.
- **La apariencia de una shared section vive dentro de `content[<tipo>]`** (ahí la guarda el form:
  cada `Section` de `PageBlocks` tiene `->statePath($tipo)` y los campos de apariencia van adentro).
  ⚠️ `Popular products title` (ss 3) los tenía en la **raíz**, donde `HasGroupedSections` no los lee
  — corregido por `sectors:add-popular-products` (§12). Al crear filas por comando, escribir siempre
  el formato de hoy.
- El comando es **idempotente y re-ejecutable**: no pisa una shared section que ya existe (respeta lo
  editado en el panel), saltea el bloque ya convertido y **reconstruye el mapa desde las homes**, así
  que un marker agregado a otra página después también se convierte. No toca `updated_at`.
- **El marker `testimonials_marker` se eliminó por completo**: la fila (ss 8), el case del enum
  `SystemSectionTypeEnum`, el case del `block-renderer` y la blade `testimonials-carousel.blade.php`.
  Quedan **3 markers de sistema** — contact form, blog carousel y segunda mano — los que declara
  `SystemSharedSectionsSeeder`.

⚠️ **Un marker de sistema pinta su PROPIA `<section>` con su `.auto-container` adentro**, y como la
fila que lo referencia **no tiene campos de apariencia** (`content` vacío), el envoltorio le queda
`auto-container` y la franja de fondo se corta a 1200 px. Esa es la razón real de por qué el carrusel
de testimonios no salía full width en new machinery — no era el bloque, era que estaba solo.
**La forma de darle ancho a un marker es ponerle delante un bloque con `w-100` y `merge_with_next`**
(el título oscuro), que es lo que hacen las 8 homes con el carrusel de segunda mano: el grupo lo abre
el título y el marker lo hereda. Se probó un default `w-100` para los markers en `HasGroupedSections`
y **se descartó**: el `auto-container` es el que se quiere.

## 12. Popular products en los 6 sectores, no solo en ITG Line (2026-08-28)

En producción las seis páginas de sector son **UNA sola blade**: `ProductController::index()` siempre
renderiza `products.products` y solo hace `@include` del fragmento del sector. Todo lo que rodea ese
fragmento —el acordeón de FAQ y el carrusel full width **Popular products**— se imprimía en **los 6**.
En el builder la franja se había migrado solo a ITG Line.

- **`sectors:add-popular-products [--dry-run]`** agrega la franja donde falta: un `separator` (solo si
  el sector no tiene ninguno) + las dos referencias a las shared sections de la franja, al final.
  Idempotente y re-ejecutable, no toca `updated_at`. Localiza la franja **por nombre** y, si alguien
  renombró las shared sections, la deduce del sector que ya la tiene.
- ⚠️ **Sin `separator` la franja se pintaría DENTRO de la columna angosta del sidebar**: en
  `/products/{sector}` el separador es lo que `BlockGroup::splitBySeparator()` usa para decidir qué
  sale del sidebar y se pinta full width abajo.
- **No salía oscura por la DB, no por el CSS:** `Popular products title` (ss 3) tenía sus campos de
  apariencia en la **raíz** de `content` en vez de `content[<tipo>]` (§11). El comando mueve esas
  claves adonde se leen, en **cualquier** shared section con esa forma legacy.
- Una de las 3 tarjetas tenía el botón con `type: contact_link` y valor `slug:73#contactform`:
  `contact_link` significa email/teléfono y `<x-redirect-link>` lo imprime **literal** (enlace muerto
  en las 6 páginas). El comando lo retipa a `internal`. ⚠️ **`links:audit` no lo detecta**: da los
  `contact_link` por externos y los saltea.
- ✅ **RESUELTO (2026-09-17)** por `sectors:translate-missing` — ver `docs/PENDIENTES.md`.

## 13. Cabeceras de las vistas servidas por controller

`/blogs`, `/machinery` y `/used-machines-for-sale` listan contenido que ya está en la DB, pero su
banner, breadcrumb y titular estaban en el blade. Ahora salen de **3 `shared_sections` de tipo
`page_header_breadcrumb`** (una por banner: el listado y el detalle de cada módulo comparten imagen),
que crea `sections:seed-view-headers`, y las pinta **`<x-page-header>`**.
La regla de corte: **banner, breadcrumb y titular = contenido** (a la DB); **etiquetas de campo = UI**
(se quedan en `__()`).
⚠️ Esas filas **no** son `is_system` a propósito (el panel no deja editar las de sistema, que es justo
lo que se busca), así que se pueden renombrar o borrar: por eso `<x-page-header>` **cae al valor que
el blade tenía** (`image`, `crumbs`, `fallback-title`) en vez de pintar un banner roto. Si agregás una
cabecera nueva, mantené ese respaldo.

## 14. Logos y banderas: los últimos DATOS que vivían en `public/images` (2026-08-28)

⚠️ **`$blog->image` puede ser `null`** (blog sin imagen, o solo en un idioma que no resuelve): toda
blade que la pinte debe guardar con `@if ($blog->image?->image_large)`. Usar `Storage::url()`, nunca
`asset('images/blogs/…')` — las imágenes de blog las sube el panel y quedan en storage.

Todo lo que sube el panel va a **storage**. Las dos excepciones eran **`logos.url_logo*`**
(8 divisiones × 5) y **`languages.flag_image`** (6): se sembraron con las rutas del sitio estático
y **son datos, no markup**, así que el día que se borre `public/images` el logo y las banderas dan
404 en el header, el footer, el navbar de compra/venta y la preview del panel.

Los movió **`media:brand-images-to-storage [--dry-run]`**, corrida por la migración
`2026_08_28_000001` (el deploy, con `public/images` todavía en el checkout). Solo cambia el prefijo
(`/images/main/logo/x.png` → `divisions/logos/main/logo/x.png`); reversible (`down()` lo deshace),
idempotente, no toca `updated_at`, no borra nada de `public/`. Trabaja sobre lo que dice la DB, nunca
sobre una lista de archivos hardcodeada.

- **`App\Support\Media\StoredImage`** resuelve las **dos formas** (`images/…` → `asset()`, cualquier
  otra → `Storage::url()`), y lo llaman los accessors de `Language::flag_image` y las 5 columnas de
  `Logo`. Por eso ninguna blade que pinta bandera/logo tuvo que cambiar.
- **Una fila cuyo archivo no está en `public/` se reporta y NO se reescribe** (evita convertir una
  ruta rota en otra ruta rota). Era el caso de los 14 `logo_short` de las 7 divisiones que no son
  `main` (nombraban archivos que nunca se commitearon). ✅ **Resuelto el 2026-09-18**: verificado
  contra un dump real de producción que seguían rotos ahí también, y la migración
  `2026_09_18_101219_null_orphan_logo_short_paths` les copió el archivo real de `main` (el único que
  existe) en vez de dejarlos vacíos — nadie los lee hoy (ver punto siguiente), pero al menos ya no
  apuntan a un archivo fantasma.
- **`App\Support\Media\MainDivisionLogo`** (renombrada el 2026-09-18, antes `BrandLogo`) es lo que
  hace que el logo se USE: doce vistas internas (login, navbar de compra/venta, kanban, ficha PDF,
  botones de imprimir, correos) traían el logo con `asset('images/main/logo/…')`. Ahora salen de
  `logos` (división `main`). El nombre viejo (`BrandLogo`) sugería que podía variar por división;
  **siempre resuelve la fila de `main`, a propósito** — son pantallas internas del grupo, no de una
  división visitada (confirmado el 2026-09-18: ninguno de sus 9 consumidores tiene hoy un concepto
  de "división actual" — ni los tickets, ni las máquinas, ni el login lo tienen).
  ⚠️ **Tres formas, y confundirlas es el bug:** `url()` relativa para un `src` del sitio;
  **`absoluteUrl()` para CORREOS y ventana de imprimir**, que se renderizan fuera del documento y no
  pueden resolver una relativa; y **`path()`, ruta de FICHERO, para Dompdf**, que lee del disco
  (devuelve `null` si el fichero no está: mejor ficha sin logo que recuadro roto). `SeoService`
  (`og:image` por defecto) también necesita absoluta.
- `LanguageSeeder` y `LogoSeeder` ya siembran rutas de storage. Como `storage/app/public` está en
  `.gitignore`, esos archivos no viajan en el repo: en un entorno nuevo hay que copiarlos.
  `LogoSeeder` ya siembra un `logo_short` distinto por división (no solo `main`), pero esos archivos
  tampoco existen — mismo hueco, nunca se subieron.
- **`media:brand-images-to-storage` (el comando) se queda, no se borra**: lo invoca directo
  `Artisan::call()` la migración `2026_08_28_000001_move_brand_images_to_storage`, así que si el
  comando no existe, esa migración rompe un `migrate:fresh` desde cero. Que ya haya terminado su
  trabajo en producción (verificado el 2026-09-18: los 8 `logos` y las 6 `languages` ya están en
  formato storage) no lo vuelve código muerto — sigue siendo parte necesaria del camino de
  migraciones.

## 15. `media:audit-public-images`: vaciar `public/images` con seguridad (2026-08-28)

⚠️ **Hay un backfill que copia de `public/images` y es fácil de pasar por alto, porque no está en
`app/`:** `database/seeders/Pages/Contacts/CommercialAgentsAccordionContactsSeeder.php` lleva
`images/resource/contacts/{europa,asia,africa}.jpg` al disco público para el acordeón de agentes
comerciales. Está en `BACKFILL_SOURCES` del comando de auditoría.

**Hoy esas fuentes YA NO EXISTEN** (`public/images/resource/contacts/` se fue en la limpieza). El
seeder va guardado con `File::exists()`, así que **no revienta: escribe el bloque sin imagen, en
silencio** — que es justo lo que lo hace fácil de no notar. No cambia nada en la práctica porque el
bloque real ya apunta a storage, pero **si alguna vez hay que re-sembrarlo, primero hay que
restaurar `public/images` desde git** (`git checkout -- public/images`).


`public/images` mezclaba dos cosas idénticas desde fuera — los muebles del theme (patrones, shapes,
preloader) y contenido ya migrado a storage. Borrar lo segundo es limpieza; borrar lo primero se
lleva el diseño. Curarla a mano es peligroso: una imagen se puede reclamar desde **cuatro** sitios
(CSS, blade, PHP, columna de DB), y a veces con una ruta que se **construye**
(`asset('images/products/' . $sector->image)`), así que buscar por nombre no la encuentra.

`media:audit-public-images` lee las cuatro fuentes y **calcula** la alcanzabilidad (no la declara):
arranca en las vistas que renderizan los controllers **ruteados** y sigue `@extends`, `@include`,
`<x-…>` y `#[Layout('…')]`. Salen **KEEP** / **LEGACY** (solo la piden vistas muertas) / **ORPHAN**
(nadie la pide). `--prune` borra ORPHAN, `--prune --include-legacy` también LEGACY,
`--why=<archivo>` / `--views` / `--list` explican cada veredicto.

⚠️ Tres trampas ya resueltas en el comando, no reintroducirlas: (1) anclar las rutas construidas en
`asset(`/`url(` — si no, `StoredImage::toLegacyPath()` reclama el árbol entero; (2) quitar los
comentarios `{{-- --}}` antes de escanear; (3) no confundir "clase ruteada" con "método ruteado"
(closures en `routes/auth.php`, `Pdf::loadView()`, componentes Livewire también renderizan). Sigue
siendo **conservador a propósito** con los 3 componentes elegidos por variable
(`UsedMachineForm::viewFor()`): son falsos positivos conocidos, 213 de 216 vistas alcanzables.

**Estado: `public/images` quedó en 38 archivos, 16 MB** (de 458 y 90 MB), en dos pasadas
(2026-08-28 y 2026-09-08 tras borrar las blades legacy, §16). **Regla: no borrar a mano. Correr
`media:audit-public-images` y borrar lo que diga.** Todo `public/images` está en git →
`git checkout -- public/images` lo devuelve.

## 16. Se borraron las 46 blades del sitio estático (2026-09-08)

El editor sirve `about`, `contact`, `partners`, las 8 homes, los 6 sectores y `machinery/new-machinery`
desde el **builder** desde el 2026-08, así que sus blades estáticas quedaron sin ruta. Se conservaban
como referencia de la migración; ahora esa referencia es **git**: **262 → 217 blades**.

Se fueron `about/*`, `contact/*`, `partners/*`, **`home/` entera** (con el modelo `HomeSection`, su
único lector), los `blogs/blog*` legacy, `machinery/{machinery,machinery-show,machinery-video,
braiding-complementary,new-machinery}`, los 7 `products/*` legacy (⚠️ `products/show` SE QUEDA),
`layouts/guest/{footer,navbar}` (las vivas son `frontend/layouts/*`) y 15 componentes sin uso. Con
ellas murió el PHP que solo las servía: `AboutUsController`, `ContactController`,
`PartnersController`, `ProductController`, `Utils/ProductsUtil`,
`MachineryController::newMachinery()`. `Utils/GeneralUtil` **se quedó** porque lo lee
`pages:backfill-legacy-pages` (hasta que ese comando también se dio de baja, §17).

⚠️ **Cómo se hizo, porque el método importa más que la lista.** Base: `media:audit-public-images
--views`, pero cada vista se vetó una por una buscando su nombre en todo el código **con `grep -F`**
(con `grep` normal el `.` del nombre punteado matchea cualquier carácter: `about.history` "aparece"
dentro de `about#history`, una URL). Se borró **en RONDAS**, re-corriendo el análisis tras cada una,
porque borrar destapa cascadas. **Verificación:** `view:cache` compila las 216, cero
`@include`/`@extends`/`<x-…>` colgando, 10 páginas públicas en 200.

## 17. Se dieron de baja los comandos de backfill ya gastados (2026-09-08)

**A producción el contenido del builder llega copiando la BASE DE DATOS**, no re-corriendo los
comandos que lo construyeron (decisión del usuario, 2026-09-08). Con eso, los backfills que
convirtieron el sitio estático en pages del builder dejaron de tener función y **se borraron**:
`pages:backfill-legacy-pages` · `pages:clone-division-homes` · `pages:fill-division-homes` ·
`sections:seed-view-headers` · `sections:share-testimonials` · `pages:migrate-new-machinery` ·
`pages:set-titles` · `blocks:name-missing` · `page-blocks:order-hero-boxes`, más
`app/Utils/GeneralUtil` y `app/Utils/KeywordsUtil`.

⚠️ **Las secciones §9-§16 de este documento quedan a propósito**: explican POR QUÉ los datos son
como son, y eso sigue siendo cierto y sigue haciendo falta para entender la DB. Lo que ya no existe
es el ejecutable — si hay que reconstruir ese contenido desde cero, el código está en git.

**La regla para distinguir qué comando se queda** (un comando se queda si sigue teniendo trabajo que
hacer) está en `CLAUDE.md` § "Gotchas" / sección de comandos — se mantiene: lo invoca el deploy o una
migración, está agendado, es diagnóstico/reparación, o son datos que hay que regenerar al cambiar el
catálogo (`nav:sync-division-menus`).

## 18. ✅ HECHO: `sectors:translate-missing` corrido en producción y borrado (2026-09-18)

Los 6 sectores del editor web quedaron con `title`/`seo_title`/`seo_description` solo en `en`, y la
carousel compartida "Popular products" (grippers/Heald/Tapes + botón ORDER NOW — misma fila que usan
las 6 páginas de sector y home, ver §12) igual. `app/Console/Commands/TranslateProductSectorsMissingLocales.php`
(`sectors:translate-missing {--dry-run}`) rellena los 5 idiomas que faltan (`es/fr/it/pt/zh_CN`), sin
pisar nada que ya esté traducido (idempotente).

Los valores no se inventaron donde había una fuente: `title` es el mismo que ya estaba hardcodeado
para el navbar en `database/seeders/NavMenuItemSeeder.php` (commit `80e09359`); las 3 tarjetas de la
carousel y su botón salen de `lang/*/messages.php` del sitio estático viejo (`products-grippers`,
`products-heald`, `products-tapes`, `order-now`), borrado en `2a88f411`. `seo_title`/`seo_description`
nunca tuvieron traducción histórica en ningún lado — esas 5 se redactaron a mano a partir del inglés
actual.

✅ Verificado el 2026-09-18 contra un dump real de producción (`itg_production_2026`): los 6
sectores y las 2 `shared_sections` de "Popular products" ya tienen las 5 traducciones. Mismo
criterio de §17 — el comando se borró (`app/Console/Commands/TranslateProductSectorsMissingLocales.php`).
