From e9637f77ffe18b56be683bec299f3592a8f94998 Mon Sep 17 00:00:00 2001
From: Carlos Narro
Date: Mon, 1 Jun 2026 22:36:55 +0200
Subject: [PATCH] =?UTF-8?q?Integrar=20recorte=20y=20optimizaci=C3=B3n=20a?=
=?UTF-8?q?=20WebP=20en=20las=20subidas=20de=20imagen=20del=20panel?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Los tres uploaders del panel (logo, "quiénes somos" y galería) ahora abren
un recorte interactivo (zoom + encuadre) y reescalan en el navegador antes
de subir: logo y "quiénes somos" a máx. 500 px, galería a máx. 1200 px,
reencodando a WebP (calidad 0.82). El SVG del logo se sube tal cual para no
rasterizar el vector. Esto reduce el peso de los data URIs base64 que se
guardan en Postgres y se inlinean en el funnel.
Nueva dependencia react-easy-crop: librería de recorte ligera, sin estado
global, compatible con React 19; el reescalado y reencodado se hacen con
canvas nativo (lib/image/crop.ts), sin dependencias extra.
---
mvp/b2c/package-lock.json | 21 ++
mvp/b2c/package.json | 1 +
.../components/panel/AboutFotoUploader.tsx | 15 +-
.../src/components/panel/GaleriaUploader.tsx | 13 +-
.../components/panel/ImageCropperField.tsx | 211 ++++++++++++++++++
mvp/b2c/src/components/panel/LogoUploader.tsx | 17 +-
mvp/b2c/src/lib/image/crop.ts | 38 ++++
7 files changed, 302 insertions(+), 14 deletions(-)
create mode 100644 mvp/b2c/src/components/panel/ImageCropperField.tsx
create mode 100644 mvp/b2c/src/lib/image/crop.ts
diff --git a/mvp/b2c/package-lock.json b/mvp/b2c/package-lock.json
index b885150..568ee9b 100644
--- a/mvp/b2c/package-lock.json
+++ b/mvp/b2c/package-lock.json
@@ -17,6 +17,7 @@
"postgres": "^3.4.9",
"react": "19.2.4",
"react-dom": "19.2.4",
+ "react-easy-crop": "^5.5.7",
"tailwindcss": "^4.3.0",
"zod": "^4.4.3"
},
@@ -7209,6 +7210,12 @@
"svg-arc-to-cubic-bezier": "^3.0.0"
}
},
+ "node_modules/normalize-wheel": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
+ "integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -7635,6 +7642,20 @@
"react": "^19.2.4"
}
},
+ "node_modules/react-easy-crop": {
+ "version": "5.5.7",
+ "resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.5.7.tgz",
+ "integrity": "sha512-kYo4NtMeXFQB7h1U+h5yhUkE46WQbQdq7if54uDlbMdZHdRgNehfvaFrXnFw5NR1PNoUOJIfTwLnWmEx/MaZnA==",
+ "license": "MIT",
+ "dependencies": {
+ "normalize-wheel": "^1.0.1",
+ "tslib": "^2.0.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.4.0",
+ "react-dom": ">=16.4.0"
+ }
+ },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
diff --git a/mvp/b2c/package.json b/mvp/b2c/package.json
index f4fba54..633a7a3 100644
--- a/mvp/b2c/package.json
+++ b/mvp/b2c/package.json
@@ -26,6 +26,7 @@
"postgres": "^3.4.9",
"react": "19.2.4",
"react-dom": "19.2.4",
+ "react-easy-crop": "^5.5.7",
"tailwindcss": "^4.3.0",
"zod": "^4.4.3"
},
diff --git a/mvp/b2c/src/components/panel/AboutFotoUploader.tsx b/mvp/b2c/src/components/panel/AboutFotoUploader.tsx
index 631597d..8f51e5e 100644
--- a/mvp/b2c/src/components/panel/AboutFotoUploader.tsx
+++ b/mvp/b2c/src/components/panel/AboutFotoUploader.tsx
@@ -6,6 +6,7 @@ import {
quitarAboutFoto,
type LogoResult,
} from '@/app/panel/empresa/actions';
+import ImageCropperField from '@/components/panel/ImageCropperField';
export default function AboutFotoUploader({ fotoUrl }: { fotoUrl: string | null }) {
const [state, formAction, pending] = useActionState(
@@ -34,16 +35,18 @@ export default function AboutFotoUploader({ fotoUrl }: { fotoUrl: string | null
}
{state?.ok && Foto añadida ✓
}
- PNG, JPG o WEBP · máx. 2 MB.
+
+ PNG, JPG o WEBP. Se recorta y optimiza a WebP (máx. 1200 px).
+
);
}
diff --git a/mvp/b2c/src/components/panel/ImageCropperField.tsx b/mvp/b2c/src/components/panel/ImageCropperField.tsx
new file mode 100644
index 0000000..e950801
--- /dev/null
+++ b/mvp/b2c/src/components/panel/ImageCropperField.tsx
@@ -0,0 +1,211 @@
+'use client';
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+import Cropper from 'react-easy-crop';
+import { getCroppedWebp, type CropArea } from '@/lib/image/crop';
+
+type Props = {
+ name: string;
+ maxDimension: number;
+ accept: string;
+ buttonLabel?: string;
+ previewClassName?: string;
+ previewImgClassName?: string;
+ allowSvgPassthrough?: boolean;
+ quality?: number;
+ disabled?: boolean;
+};
+
+// Campo de imagen con recorte (zoom + pan) y reescalado a WebP en el navegador.
+// El archivo optimizado se inyecta en un oculto con `name`,
+// de modo que el formulario padre lo envía como si fuera la selección original.
+export default function ImageCropperField({
+ name,
+ maxDimension,
+ accept,
+ buttonLabel = 'Elegir imagen',
+ previewClassName = 'h-24 w-24 rounded-lg',
+ previewImgClassName = 'h-full w-full object-cover',
+ allowSvgPassthrough = false,
+ quality = 0.82,
+ disabled = false,
+}: Props) {
+ const pickerRef = useRef(null);
+ const outputRef = useRef(null);
+ const objectUrls = useRef>(new Set());
+
+ const [modalSrc, setModalSrc] = useState(null);
+ const [aspect, setAspect] = useState(1);
+ const [crop, setCrop] = useState({ x: 0, y: 0 });
+ const [zoom, setZoom] = useState(1);
+ const [area, setArea] = useState(null);
+ const [previewUrl, setPreviewUrl] = useState(null);
+ const [working, setWorking] = useState(false);
+ const [error, setError] = useState(null);
+
+ const track = useCallback((url: string) => {
+ objectUrls.current.add(url);
+ return url;
+ }, []);
+
+ const revoke = useCallback((url: string | null) => {
+ if (url && objectUrls.current.has(url)) {
+ URL.revokeObjectURL(url);
+ objectUrls.current.delete(url);
+ }
+ }, []);
+
+ useEffect(() => {
+ const urls = objectUrls.current;
+ return () => {
+ for (const url of urls) URL.revokeObjectURL(url);
+ urls.clear();
+ };
+ }, []);
+
+ function setOutputFile(file: File) {
+ const dt = new DataTransfer();
+ dt.items.add(file);
+ if (outputRef.current) outputRef.current.files = dt.files;
+ }
+
+ async function onPick(e: React.ChangeEvent) {
+ setError(null);
+ const file = e.target.files?.[0];
+ e.target.value = ''; // permite re-elegir el mismo archivo
+ if (!file) return;
+
+ if (allowSvgPassthrough && file.type === 'image/svg+xml') {
+ setOutputFile(file);
+ revoke(previewUrl);
+ setPreviewUrl(track(URL.createObjectURL(file)));
+ return;
+ }
+
+ const src = track(URL.createObjectURL(file));
+ try {
+ const dims = await loadDims(src);
+ setAspect(dims.width / dims.height || 1);
+ setCrop({ x: 0, y: 0 });
+ setZoom(1);
+ setArea(null);
+ setModalSrc(src);
+ } catch {
+ revoke(src);
+ setError('No se pudo abrir la imagen.');
+ }
+ }
+
+ function cerrarModal() {
+ revoke(modalSrc);
+ setModalSrc(null);
+ }
+
+ async function aplicar() {
+ if (!modalSrc || !area) return;
+ setWorking(true);
+ setError(null);
+ try {
+ const blob = await getCroppedWebp(modalSrc, area, maxDimension, quality);
+ setOutputFile(new File([blob], `${name}.webp`, { type: 'image/webp' }));
+ revoke(previewUrl);
+ setPreviewUrl(track(URL.createObjectURL(blob)));
+ cerrarModal();
+ } catch {
+ setError('No se pudo procesar la imagen. Prueba con otra.');
+ } finally {
+ setWorking(false);
+ }
+ }
+
+ return (
+
+
+
+
+
+ {previewUrl && (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+ )}
+
+
+
+ {error &&
{error}
}
+
+ {modalSrc && (
+
+
+
Ajusta el encuadre
+
+ setArea(areaPixels)}
+ />
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+function loadDims(src: string): Promise<{ width: number; height: number }> {
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+ img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
+ img.onerror = () => reject(new Error('No se pudo cargar la imagen.'));
+ img.src = src;
+ });
+}
diff --git a/mvp/b2c/src/components/panel/LogoUploader.tsx b/mvp/b2c/src/components/panel/LogoUploader.tsx
index cba0f8f..1c9b3bc 100644
--- a/mvp/b2c/src/components/panel/LogoUploader.tsx
+++ b/mvp/b2c/src/components/panel/LogoUploader.tsx
@@ -2,6 +2,7 @@
import { useActionState } from 'react';
import { subirLogo, quitarLogo, type LogoResult } from '@/app/panel/empresa/actions';
+import ImageCropperField from '@/components/panel/ImageCropperField';
export default function LogoUploader({ logoUrl }: { logoUrl: string | null }) {
const [state, formAction, pending] = useActionState(subirLogo, null);
@@ -27,16 +28,20 @@ export default function LogoUploader({ logoUrl }: { logoUrl: string | null }) {