Skip to content
SoyGarfield
Research #05

Cómo construí un personalizador en vivo y después lo simplifiqué a un iframe: el caso Ciudad del Láser

¿Cuándo tiene sentido simplificar una funcionalidad propia ya construida en vez de seguir manteniéndola?

Por Pietro Fiorillo ·

Índice del artículo
Diagrama de dos etapas. Construido: personalizador en React y Canvas que mide texto y detecta desborde del área imprimible, código huérfano hoy. Hoy, en producción: iframe a una herramienta externa de PHP legacy.
De un personalizador propio a una herramienta externa embebida. Una simplificación deliberada, documentada en el propio historial de git. Diagrama propio.

El problema: cotizar sin mostrador, para un taller que vive de WhatsApp

Ciudad del Láser no tiene atención de mostrador: toda cotización pasa por WhatsApp o email. El sitio anterior no explicaba cómo preparar un archivo de corte, y el cliente no podía actualizar contenido sin depender de un developer.

Lo que construí primero: un personalizador en vivo con validación real

Para mates y placas personalizadas, construí un personalizador en React que usaba el Canvas del navegador para medir el texto que el cliente escribía, en vez de depender de una librería externa.

let measureCanvas;
function measureTextWidth(text, family, sizePx) {
  if (typeof document === 'undefined') return 0;
  if (!measureCanvas) measureCanvas = document.createElement('canvas');
  const ctx = measureCanvas.getContext('2d');
  ctx.font = `${sizePx}px ${family}`;
  return (text || '').split('\n')
    .reduce((max, line) => Math.max(max, ctx.measureText(line).width), 0);
}
Código real (sigue en el repositorio, aunque ya no se usa) de src/hooks/useTextMeasurement.js.

Con esa medición, el personalizador comparaba el texto contra un área imprimible fija y marcaba cuando el diseño se salía de los márgenes, además de detectar caracteres no seguros para grabado láser.

const { width: textWidth, lineCount } = useTextMeasurement(
  d.text, d.font.family, d.fontSize
);
const textHeight = lineCount * d.fontSize * 1.2;

const overflowsPrintableArea =
  textWidth > PRINTABLE_BOX.width || textHeight > PRINTABLE_BOX.height;

const unsafeChars = findUnsafeCharacters(d.text);
Código real recuperado del historial de git (commit anterior a d1b954f), de la lógica de validación del personalizador.

Lo que está en producción hoy: un iframe a una herramienta que ya existía

En algún momento, ese personalizador propio se reemplazó por un <iframe> que embebe una herramienta PHP externa ya existente (ember.com.ar/virolas). El cambio está documentado en el commit d1b954f, cuyo mensaje dice literalmente que simplifica el Personalizador eliminando los customizers de mate y placa.

No voy a inventar una razón que no está escrita en el commit. Lo que sí puedo decir es que, en términos de ingeniería, es una decisión razonable: una herramienta externa que ya resolvía el problema evita mantener una pieza de software propia equivalente, a cambio de perder control fino sobre la experiencia.

La regla de 5 mm de margen y los colores de línea (corte, marcado, grabado) siguen existiendo, pero como contenido de una guía «Cómo armar tu archivo», no como una validación que corre en el navegador del cliente.

Cómo se mantiene el sitemap al día sin depender de un build

El contenido (servicios, blog) vive en Sanity. En vez de regenerar el sitemap solo en cada build, una Cloud Function lo arma en el momento, consultando Sanity con GROQ cada vez que alguien lo pide.

export const sitemap = onRequest({ cors: false }, async (req, res) => {
  const [services, posts] = await Promise.all([
    sanityClient.fetch(`*[_type == "service"]{ "slug": slug.current }`),
    sanityClient.fetch(`*[_type == "blogPost"]{ "slug": slug.current }`),
  ]);
  const entries = [
    ...STATIC_URLS.map((s) => urlEntry(s.loc, s.priority)),
    ...services.map((s) => urlEntry(`/${s.slug}`, '0.9')),
    ...posts.map((p) => urlEntry(`/blog/${p.slug}`, '0.5')),
  ];
  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${entries.join('\n')}
</urlset>`;
  res.set('Content-Type', 'application/xml');
  res.set('Cache-Control', 'public, max-age=300, s-maxage=3600');
  res.status(200).send(xml);
});
Código real de functions/index.js.

Esto significa que si se publica un servicio nuevo en Sanity, aparece en el sitemap sin esperar el próximo build del sitio, aunque las páginas HTML sí necesitan ese build para existir.

Cómo arma el formulario el mensaje de WhatsApp

function handleSubmit(e) {
  e.preventDefault();
  const message = [
    `Hola! Quiero cotizar: ${serviceName}.`,
    `Nombre: ${form.nombre}`,
    form.telefono && `Teléfono: ${form.telefono}`,
    form.detalle && `Detalle: ${form.detalle}`,
  ].filter(Boolean).join('\n');
  window.open(buildWhatsappLink(message), '_blank', 'noreferrer');
}
Código real de src/components/QuoteForm.jsx.

Cómo validé esta arquitectura

Corrí el proceso de build real del repositorio (vite build más el paso de SSR y el script de prerender) sin variables de entorno secretas, ya que Sanity se consume en modo público (useCdn: true).

Métricas reales medidas el 18 de octubre de 2026.
MétricaResultadoEntorno / evidencia
Lighthouse Performance (móvil, lab)79 / 100Lighthouse 13.5.0, throttling simulado
LCP (lab)5,1 sMisma medición Lighthouse, no es un dato de campo (CrUX)
Tiempo de build local8,16 svite build + SSR + script de prerender
Tamaño del export10 MB en 171 archivosCarpeta /dist generada por ese build
Rutas prerenderizadas22 de 22Log del script de prerender (todas las landings y el blog)
Commits de desarrollo26, de julio a septiembre de 2026git log del repositorio

Simplificar código propio que ya funciona es una decisión tan válida como construirlo, siempre que quede documentada.

Pietro Fiorillo

Mi participación en este proyecto

Mi participación en Ciudad del Láser incluyó el desarrollo frontend, la integración de Sanity como CMS, la Cloud Function del sitemap y el SEO técnico, como parte de mi trabajo en MANYA Digital.

Lo que haría distinto: código huérfano sin una decisión explícita

El personalizador original no se borró, se dejó de usar. Hoy convive en el repositorio con el resto del código, sin ninguna nota que diga si es una referencia a futuro o simplemente algo que nadie llegó a limpiar. Esa ambigüedad es el verdadero problema, no el código en sí.

Qué probaría después

Decidiría explícitamente el destino del código huérfano del personalizador: documentarlo como referencia para una futura versión propia, o eliminarlo directamente. También evaluaría si vale la pena volver a traer el personalizador en vivo ahora que ya existe la lógica de validación probada, en vez de depender de una herramienta externa fuera de mi control.

Fuentes

Preguntas frecuentes

¿Por qué sacaron el personalizador propio si ya estaba construido?

No lo sé con certeza porque no participé de esa decisión puntual documentada en el commit; lo que muestra el historial de git es que se simplificó a un iframe externo ya existente. Es una decisión de costo de mantenimiento razonable: una herramienta que ya funcionaba, aunque fuera de otro proveedor, resolvía el mismo problema con menos código propio que sostener.

¿El código del personalizador en React y Canvas sigue existiendo?

Sí, varios componentes (DesignPreview, TextControls, LogoControls, PreviewToolbar) siguen en el repositorio, pero ningún archivo los importa. Es código huérfano: no rompe nada, pero tampoco hace nada.

¿La regla de 5 mm de margen sigue aplicándose hoy?

Sigue existiendo, pero como guía escrita para que el cliente prepare su archivo, no como validación automática en un personalizador en vivo. El cambio de un control por software a un control por instrucciones es real y vale la pena ser preciso al respecto.

¿Cómo genera el sitio el sitemap si el contenido vive en Sanity?

Una Cloud Function consulta Sanity en tiempo real (servicios y posts del blog) cada vez que se pide el sitemap, y lo arma combinando esas rutas con las fijas del sitio. No depende de que alguien corra un build para que el sitemap esté al día.

¿Por qué React y Vite en vez de Next.js, como en otros proyectos?

Porque este sitio no necesitaba generación híbrida ni rutas de servidor: un SPA con un paso de prerender propio (un script que genera HTML por ruta después del build) cubre el caso de uso con una base más liviana.

¿Cuánto cuesta mantener un sitio así?

El costo de hosting es bajo porque el sitio final es estático. El costo real está en mantener el contenido en Sanity (servicios, blog, testimonios) y en que la herramienta de personalización externa siga funcionando, ya que no está bajo el control directo del proyecto.