# Deploy a producción del page builder (2026-09-17)

> Detalle del deploy en caliente y de lo que quedó pendiente inmediatamente después. La regla
> permanente que deja este deploy (permisos tras `rsync`/`scp`, flujo de ramas) vive en `CLAUDE.md`;
> aquí queda el resto: cómo se hizo, cómo se verificó, y el detalle de los pendientes menores
> anotados el mismo día.

## 1. Cómo se hizo, y cómo salió

Se siguió `docs/IMPORTAR-CONTENIDO-EN-PROD-EN-CALIENTE.md`: **producción nunca se sustituyó, se le
agregó**. Código con sus 38 migraciones sobre la base real, y después el contenido del editor con
`content:export` → `content:import` en una transacción verificada.

**Verificado a máquina con `site:snapshot` + `site:compare`** (403 URLs antes, 641 después):

| | |
|---|---|
| 🔴 URLs perdidas | **1**, y es una corrección |
| 🟠 Texto desplomado | **0** |
| 🟡 SEO perdido | **0** |
| ⚪ URLs nuevas | **238** |
| `links:audit --strict` | **589 enlaces, 0 rotos** |
| Catálogo de slugs | **84 filas**; `slugs:sync` no tuvo nada que crear ni actualizar |

La única «pérdida», `china /products/knitting/sector` → 404, viene del filtro por división que este
deploy empezó a aplicar: `ProductSectorController` **ignoraba `product_sector_divisions`** y servía
los 6 sectores a las 8 divisiones, daba igual lo que dijera el dato. Corregir eso estuvo bien — es la
misma clase de fallo que un blog escapándose de su división.

⚠️ **Pero lo que salió a la luz es que el DATO estaba mal, no la regla** (confirmado por el usuario,
2026-09-17): **china sí lleva knitting**, solo que su fila faltaba en `product_sector_divisions`. Se
arregla **tildando China en el panel**, en el sector Knitting. **No hace falta tocar código**: se
probó un 301 al listado para esos casos y se revirtió, justo porque no es un problema de código.

👉 **Si leés en algún sitio «china tiene 5 sectores y no lleva knitting», está desactualizado.** Eso
salió de los datos sembrados, nunca de una confirmación. Son 6, como las demás.

Las fotos del `site:snapshot` quedan en `storage/app/snapshots/`.

⚠️ **Lo que costó sangre el día del deploy fueron los PERMISOS, y se repetirá en el próximo.** El
`rsync` de los 397 archivos de storage se corre como el usuario SSH, así que las carpetas que crea
(`divisions/logos/main/…`) quedan con ese dueño y **`www-data` no puede escribir dentro**: la
migración `move_brand_images_to_storage` falló ahí, y `sitemap:generate` falló al escribir en
`public/sitemaps` por lo mismo. La regla que deja esto (correr `chown` siempre tras `rsync`/`scp`)
está en `CLAUDE.md`.

## 2. Contenido a medio traducir — NO es deuda técnica, se edita en el panel

Un barrido de `content_block_pages`, `product_sector_blocks` y `shared_sections` encontró **16
bloques con campos rellenos solo en inglés**. **No está roto** y **no es un pendiente de código**:
es contenido, y se corrige **desde el panel en producción**, que es justo para lo que se desplegó el
editor. `translate_data()` cae a inglés, así que mientras tanto se ve — en inglés en las seis lenguas.

Se documenta aquí solo porque explica **por qué la base está así** y porque tiene una trampa:

- **Bloque 39 (`home-main`), «Check out our offer of second-hand machinery» y su bajada.** Las
  traducciones **ya existen** como claves en `lang/*.json` («Conozca nuestra oferta», «Scopri la
  nostra offerta»…): el sitio estático sí las traducía y al migrar el bloque solo viajó el inglés.
  Copiar y pegar desde ahí.
- **Trece carruseles de repuestos** (`product_sector_blocks` + `Popular products Cards`, shared 4),
  entre 3 y 16 campos cada uno. Lo que falta son **nombres de piezas**: «Blade For E.D.C», «Enter
  Gripper», «Guide Hook», «Oil Filter», «Sprocket Wheel». Aquí **no hay nada que recuperar**: nunca
  se escribieron.

⚠️ **Y puede que no deban traducirse.** El glosario del proyecto dice que el cliente dejó términos
técnicos en inglés **a propósito** (`Crank`, por ejemplo). **Antes de traducir un nombre de pieza,
preguntarle al cliente qué quiere en cada idioma** — ver la sección de i18n de `CLAUDE.md` y el
glosario aprobado. Rellenarlos "arreglando" el inglés puede romper su terminología.

⚠️ **Y no se hace con un seeder.** Sería reintroducir el patrón que se acaba de eliminar (los 9
backfills dados de baja el 2026-09-08): código que corre una vez, hay que acordarse de borrar, y
cuya salida ya vive en la base. Solo si el cliente entrega una lista de 80 y pico entradas tendría
sentido un comando de una sola vez — y entonces se borra después, como se hizo con los backfills.

## 3. 🟡 Mejoras menores anotadas el 2026-09-17

**1. ✅ Permisos de las carpetas que no vienen de git — RESUELTO (2026-09-17).**

Al medirlo apareció **un problema mayor que el anotado**, y el comando que se había apuntado como
solución **no arreglaba nada**.

**Lo que había, medido en local:**

| | storage | bootstrap/cache | vendor | node_modules | total |
|---|---|---|---|---|---|
| 🔴 Directorios **777** (escribibles por CUALQUIER usuario) | 225 | 1 | 2674 | 0 | **2900** |
| 🟡 Archivos de datos con **+x** | 308 | 3 | 254 | 97 | **662** |

Lo de los 777 no estaba anotado y es **el riesgo de verdad**: cualquier usuario del servidor puede
dejar un archivo dentro. El `+x` en un JSON del debugbar no rompe nada, pero es superficie de ataque
gratis y ensucia auditorías.

⚠️ **`chmod -R u=rwX,g=rwX,o=rX` NO corrige lo ya roto**, aunque estaba apuntado como la solución.
La `X` mayúscula significa «si es directorio **o ya tiene +x para alguien**», así que en un archivo
que ya está mal **lo respeta**. Comprobado:

```
$ chmod 775 datos.json && chmod -R u=rwX,g=rwX,o=rX .
-rwxrwxr-x  datos.json      ← intacto
```

Sirve para **no crear** el problema; para **corregirlo** hay que ir por `find -type f`.

**Lo que se hizo: `deploy/fix-permissions.sh`**, que arregla **solo esas dos cosas** y no normaliza
el resto a propósito — cuanto menos toque, menos puede romper:

```bash
bash deploy/fix-permissions.sh --dry-run                 # solo informa
bash deploy/fix-permissions.sh storage bootstrap/cache vendor node_modules
```

- ⚠️ **Nunca le quita el `+x` a lo que de verdad se ejecuta:** se salta lo que está bajo `bin/` o
  `.bin/`, lo que empieza por `#!` y los binarios ELF. De los 290 con `+x` en `vendor`, **36 son
  ejecutables reales y 254 datos**; en `storage` y `bootstrap/cache` **no hay ni uno real**.
- ⚠️ **No toca los directorios restrictivos (700)** de `storage/app/backups` y compañía: los crea
  así spatie/laravel-backup, y ser *más* cerrado nunca es el problema. Aflojarlos sería una
  decisión, no una corrección.
- Idempotente y tarda **menos de un segundo** sobre los cuatro árboles.

**Ya corrido en local:** 2899 directorios y 662 archivos. Verificado después: `vendor/bin/*` sigue
ejecutándose, `sail` y `artisan` responden (Laravel 11.48.0) y las 6 URLs probadas dan 200. El único
que no se pudo cambiar es `storage/fonts`, que es de `www-data` — en el servidor no pasa.

**Y lo engancha el deploy** (`deploy.sh`, bloque de blindaje de permisos), con `sudo -u www-data` y
sin cortar nunca el despliegue. Va **después** de `composer install` y de `config/route/view:cache`
a propósito — ver el porqué justo aquí abajo.

### ¿Por qué estaban mal, y vuelven a estarlo? (investigado el 2026-09-17)

**Casi todo fue un evento puntual, con nombre y fecha.** Los `.gitignore` de `storage/**` nacieron
correctos (`100644`) en el commit inicial de 2024-04-05. Los cambió el commit **`7a0deceb`
(2025-05-30, «test version save weaving and knitting sell form»)**, que tocó 27 archivos y en **10
de ellos lo único que cambió fue el modo, de 644 a 755**: la firma de un `chmod -R` corrido a mano
para resolver un «permission denied» y commiteado sin querer.

**La operación normal NO los rompe, y está probado.** Se hizo que la app creara archivos de cero
(`view:cache`, un log, una entrada de caché, una subida a `storage/app/public`) y **todos nacen
`644`**, los directorios `775`/`755`, ninguno con `+x`. Tiene razón técnica: cuando PHP crea un
archivo lo hace con modo `0666` filtrado por el `umask` (aquí `0022`) → **`644`**. **PHP nunca pone
el bit de ejecución en un archivo que crea**; solo un `chmod` explícito puede hacerlo. Lo confirma
además que tras un `composer install` limpio `vendor` queda en **0 directorios 777 y 0 archivos con
`+x`**: no fue composer.

⚠️ **PERO HAY UNA EXCEPCIÓN REAL Y RECURRENTE, y es del framework:**
**`bootstrap/cache/packages.php` y `services.php` se regeneran SIEMPRE con `755`.**
Los escribe `PackageManifest::write()` a través de `Illuminate\Filesystem\Filesystem::replace()`,
que en la línea 228 hace:

```php
chmod($tempPath, 0777 - umask());   // con umask 0022  →  0755
```

Es una resta aritmética sobre literales octales, y en todo Laravel se comporta así. O sea que esos
dos archivos **vuelven a salir ejecutables en cada `composer install`, cada `package:discover` y
cada deploy**. No es un error de este proyecto ni algo que se pueda "arreglar" de origen sin tocar
el framework.

👉 **Por eso el hook del deploy no es una muleta: es la única forma de que esos dos no se acumulen.**
Y por eso corre **al final**, después de `composer install` (línea 171) y de
`config:cache`/`route:cache`/`view:cache` (254-256): antes no serviría de nada, porque son justo
esos pasos los que los reescriben.

**Resumen de si vuelve:**

| | ¿vuelve solo? | por qué |
|---|---|---|
| `storage`, `vendor`, `node_modules` | **No** | PHP crea a 644; composer instala limpio. Fue el `chmod -R` de 2025-05-30 |
| `bootstrap/cache` (2 archivos) | **Sí, siempre** | `Filesystem::replace()` → `chmod(0777 - umask())` = 755 |
| Un `chmod -R 777` nuevo a mano | **Sí** | Por eso el script se puede correr suelto, sin deploy |

**Chequeo suelto, sin desplegar nada** — si dice 0 y 0, no hay nada que hacer:

```bash
bash deploy/fix-permissions.sh --dry-run storage bootstrap/cache vendor node_modules
```

### Verificación de que no se rompió nada al quitar los bits

Se comprobó por el lado correcto: no *qué se quitó*, sino que **todo lo que debe ser ejecutable lo
sigue siendo**.

- **14 de 14 binarios declarados** por los paquetes en su `composer.json` (`"bin"`) conservan el
  `+x`; **0 rotos**. Los 14 enlaces de `vendor/bin` y sus destinos, también.
- **23 de 23 binarios** declarados en los `package.json` de `node_modules`; **0 rotos**.
- **Ejecutados de verdad** dentro del contenedor: `pint` (1.27.0), `phpunit` (11.5.50), `psysh`
  (0.12.20), `yaml-lint`, `pest`, `artisan` (Laravel 11.48.0) y `composer` (2.9.7).
- Las 9 URLs públicas y `/admin`, todas en **200**.

⚠️ **Siete archivos con shebang dentro de `vendor` NO tienen `+x`, y NO los tocó el script**: son los
6 `laravel/sail/runtimes/*/start-container` y `mailchimp/marketing/git_push.sh`. Vienen así del zip
de composer. No importan: el `Dockerfile` de Sail hace `RUN chmod +x /usr/local/bin/start-container`
después de copiarlo, y a `git_push.sh` no lo invoca nadie. El script no pudo tocarlos por partida
doble: solo mira archivos que **ya** tienen `+x`, y además salta cualquier cosa que empiece por `#!`.

**2. ✅ El paginador del carrusel de blogs se pintaba sobre las tarjetas — RESUELTO (2026-09-17).**

**La causa: el bloque contenedor no era el que se creía.** `resources/css/page.css` colocaba
`.news-section .owl-nav` y `.owl-dots` con `position:absolute; bottom:35px`, contando con caer en el
`padding-bottom:100px` que el theme le da a `.news-section` (`style.css:4090`). Pero un `bottom` se
mide desde el **ancestro posicionado más cercano**, y ese no es la sección: **`owl.css` declara
`.owl-carousel{position:relative}`**, y el carrusel está entre medias. Así que los 35px se medían
desde el borde inferior del CARRUSEL — o sea, justo por encima de donde terminan las tarjetas.

Medido en la home con el inspector: sección 90→593, carrusel 102→493, tarjetas 102→**463**,
paginador en **436–458** (dentro de las tarjetas) y los 100px de la sección (493→593) **vacíos**.

**El arreglo:** `top:100%` —ancla el borde superior al borde inferior del carrusel— más
`margin-top:28px`, que lo empuja a ese hueco. No depende de la altura del carrusel ni de cuántas
tarjetas haya, que es lo que haría frágil un `bottom` negativo. Verificado con el bundle compilado:
pasa a **521–544**, sin solapar y dentro de la sección.

⚠️ **Por qué la investigación anterior no lo encontró, y la lección.** Lo descartado entonces era
correcto —la blade sí emite `news-section`, `.news-section` sí es `position:relative`, la regla sí
llega al bundle— pero **`position:relative` en un ancestro no basta: gana el más cercano**. Y la
sospecha anotada (el `section-wrapper` del builder) era la equivocada: el culpable estaba **dentro**
del carrusel, no fuera.

⚠️ **Trampa del entorno local que costó la primera media hora:** con `public/hot` presente, el
layout pide `page.css` al **servidor de dev de Vite**, que aquí devolvía **32 bytes vacíos** — o sea
que en local `page.css` **no se aplicaba en absoluto** y lo que se medía era solo el theme
(`top:-95px`). **Para depurar CSS de `page.css` hay que apartar `public/hot` y usar el bundle**
(`npm run build`), o no se está mirando lo que ve producción.

⚠️ **Nav y dots comparten posición A PROPÓSITO** (ambos en 521–544): el nav es una caja de 200px con
las flechas en los extremos (`justify-content:space-between`) y los dots son el contador «01/02»
centrado encima. Si alguien los "separa" porque parece un solapamiento, rompe el diseño.

**Observación no verificada, anotada por si aparece:** en `@media (max-width:1023px)`,
`responsive.css` oculta el nav (`display:none`) y pone los dots en `position:relative`. Como
`page.css` carga después y tiene la misma especificidad, **lo pisa**, así que en móvil el paginador
se comporta como en escritorio. No se tocó —no es el bug reportado— y no pude comprobarlo: el
navegador de pruebas no dejó cambiar el viewport.

**3. reCAPTCHA se carga en todas las páginas guest.**
`@include('layouts.partials.captcha')` está en **TRES** layouts —`layouts/guest/general.blade.php`,
`layouts/guest/preview-admin.blade.php` y `layouts/purchase-and-sale/general-form-tickets.blade.php:97`—
así que el script de Google y su insignia salen en **todo** el sitio público. Pero solo lo usan
**8 componentes Livewire**, todos vía el trait `App\Livewire\Traits\Captcha\WithRecaptcha`: los
4 formularios de contacto, los **3** pasos de máquina usada (`UsedMachineIntakeStepForm`,
`MediaStepForm` —que dispara el evento **dos veces**, `addAnother` y `finish`— y el de venta) y el
paso de registro de ticket.

⚠️ **Conteo corregido el 2026-09-17: eran 3 layouts y 8 componentes, no 2 y 7.** Y el tercer layout
**sí** contiene formularios que usan el captcha, así que no se puede quitar sin más.

Cargar un script de terceros en páginas que no lo usan cuesta una petición y una insignia flotante a
cambio de nada.

👉 **Livewire 3 tiene la herramienta exacta: la directiva `@assets`**, que carga el script **una sola
vez por página y solo si el componente está presente** — y, a diferencia de `@push`, también cuando
el componente se monta por AJAX dentro de un modal o un wizard. El `@once` actual no sirve: se evalúa
en el layout, que se renderiza siempre.

⚠️ **Y NO basta con poner `@assets` en las 8 blades.** Livewire deduplica por **ruta del archivo que
compila la directiva** (`SupportScriptsAndAssets::getUniqueBladeCompileTimeKey()`, que hace
`crc32(blade.compiler->getPath())`), así que 8 blades distintas son 8 claves distintas → el script se
cargaría **8 veces** en una página con varios formularios. La forma correcta es dejar el `@assets`
**en un único partial** e incluirlo desde las 8: una sola ruta compilada, una sola clave.
⚠️ Al moverlo hay que comprobar los 8 formularios, no solo uno: el listener `get-recaptcha-token`
tiene que seguir existiendo cuando el componente se monta dentro de un modal o de un wizard.

## 4. Pendientes menores

Movidos a `docs/PENDIENTES.md` (único lugar donde se listan los pendientes sueltos del proyecto).
