import { useCallback, useEffect, useRef, useState, type PointerEvent, } from "react"; import { ChevronDown } from "lucide-react"; import { createRoot } from "react-dom/client"; import { useLocation, useNavigate } from "react-router-dom"; import { LayoutGrid, Search, SlidersHorizontal, Menu, X, } from "lucide-react"; import Swal from "sweetalert2"; import { WhatsappShareButton, WhatsappIcon, TelegramShareButton, TelegramIcon, TwitterShareButton, XIcon, FacebookShareButton, FacebookIcon, EmailShareButton, EmailIcon, } from "react-share"; import { ProductGroupCard, IndividualProductCard } from "./ProductCards"; import { useRoomVisualizer } from "./roomVisualizerHooks"; import { useCatalogProducts } from "./useCatalogProducts"; import { RoomPreviewPanel } from "./RoomPreviewPanel"; import useAppStore from "../../store/useAppStore"; import { useSegmentCanvas } from "../../hooks/useSegmentCanvas"; import { useApplyTexture } from "../../hooks/useApplyTexture"; import { API_BASE } from "../../api/client"; type RoomVisualizerState = { previewImage?: string; filename?: string; maskCount?: number; }; // ── Hook para detectar mobile (< 1024px) ───────────────────────────────────── function useIsMobile() { const [isMobile, setIsMobile] = useState(() => window.innerWidth < 1024); useEffect(() => { const handler = () => setIsMobile(window.innerWidth < 1024); window.addEventListener("resize", handler); return () => window.removeEventListener("resize", handler); }, []); return isMobile; } export default function RoomVisualizer() { const location = useLocation(); const navigate = useNavigate(); const state = location.state as RoomVisualizerState | null; const isMobile = useIsMobile(); const storedPreviewImage = useAppStore((store) => store.previewImage); const segmentFilename = useAppStore((store) => store.segmentFilename); const accumulatedFilename = useAppStore((store) => store.accumulatedFilename); const setAccumulatedFilename = useAppStore((store) => store.setAccumulatedFilename); const setSegmentResult = useAppStore((store) => store.setSegmentResult); const setPreviewImage = useAppStore((store) => store.setPreviewImage); // Restaurar segmentFilename y previewImage cuando se abre una sesión del historial useEffect(() => { if (state?.filename && state.filename !== segmentFilename) { setSegmentResult(state.filename, state.maskCount ?? 0); setAccumulatedFilename(null); // limpiar ediciones de sesión anterior } if (state?.previewImage) { setPreviewImage(state.previewImage); } // Solo al montar — state no cambia tras la navegación // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const [currentPreviewImage, setCurrentPreviewImage] = useState(() => { if (accumulatedFilename) return `${API_BASE}/seg/image/${accumulatedFilename}`; return state?.previewImage ?? storedPreviewImage ?? null; }); const [zoom, setZoom] = useState(1); const [offset, setOffset] = useState({ x: 0, y: 0 }); const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null); const [imageSize, setImageSize] = useState({ width: 0, height: 0 }); const wrapperRef = useRef(null); const { products, categories, loading, error } = useCatalogProducts(); // null = sin interacción (primera categoría abierta por defecto) // Set vacío = usuario cerró todo deliberadamente const [openCategoryIds, setOpenCategoryIds] = useState | null>(null); const isCategoryOpen = useCallback( (id: string) => { if (openCategoryIds === null) { return categories.length > 0 && id === categories[0].id; } return openCategoryIds.has(id); }, [openCategoryIds, categories], ); const toggleCategory = useCallback((id: string) => { setOpenCategoryIds((prev) => { const base = prev === null ? categories.length > 0 ? new Set([categories[0].id]) : new Set() : new Set(prev); if (base.has(id)) base.delete(id); else base.add(id); return base; }); }, [categories]); const { viewMode, showGrid, showList, openProductId, handleSelectProduct, selectedProduct, isSearchOpen, setIsSearchOpen, searchQuery, setSearchQuery, closeSearch, filteredProducts, chunkArray, } = useRoomVisualizer(products); useEffect(() => { showList(); }, [showList]); const { canvasRef, hoveredMask, selectedMasks, segmentMeta, handleCanvasMouseMove, handleCanvasMouseLeave, handleCanvasClick, clearSelection, } = useSegmentCanvas(segmentFilename); const { applyTexture, isApplying, resetResult } = useApplyTexture(); const applyTextureWith = useCallback( async (texturePath: string) => { if (!segmentFilename || selectedMasks.size === 0) return; const baseFilename = accumulatedFilename ?? segmentFilename; try { const data = await applyTexture(baseFilename, [...selectedMasks], texturePath, segmentFilename); if (data?.output_url) { setCurrentPreviewImage(`${API_BASE}${data.output_url}?t=${Date.now()}`); setAccumulatedFilename(data.output_filename); clearSelection(); } } catch { // error ya guardado en el hook } }, [applyTexture, segmentFilename, selectedMasks, clearSelection, accumulatedFilename, setAccumulatedFilename], ); const handleApplyTexture = useCallback(async () => { if (selectedProduct) await applyTextureWith(selectedProduct.ref); }, [applyTextureWith, selectedProduct]); const handleProductSelect = useCallback( async (id: string | number | null) => { handleSelectProduct(id); if (!id || selectedMasks.size === 0 || !segmentFilename) return; const product = products.find((p) => p.id === id); if (product) await applyTextureWith(product.ref); }, [handleSelectProduct, selectedMasks, segmentFilename, products, applyTextureWith], ); const handleReset = useCallback(() => { const original = state?.previewImage ?? storedPreviewImage ?? null; setCurrentPreviewImage(original); setAccumulatedFilename(null); clearSelection(); resetResult(); }, [state, storedPreviewImage, setAccumulatedFilename, clearSelection, resetResult]); const handleDownload = useCallback(async () => { if (!currentPreviewImage) return; const response = await fetch(currentPreviewImage); const blob = await response.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `hyper-reality-${Date.now()}.jpg`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }, [currentPreviewImage]); const handleShare = useCallback(async (): Promise => { let shareUrl = window.location.href; const outputFilename = accumulatedFilename; if (outputFilename) { try { const res = await fetch(`${API_BASE}/api/share`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ output_filename: outputFilename, segment_filename: segmentFilename }), }); if (res.ok) { const data = (await res.json()) as { share_id: string }; shareUrl = `${window.location.origin}/app/share/${data.share_id}`; } } catch { // fallback to current URL } } const title = "My design with Hyper Reality Visualizer"; let reactRoot: ReturnType | null = null; await Swal.fire({ title: "Compartir diseño", html: `

Enlace de tu diseño:

Compartir en:

`, showConfirmButton: false, showCloseButton: true, width: 500, didOpen: () => { const copyBtn = document.getElementById("swal-copy-btn"); copyBtn?.addEventListener("click", async () => { await navigator.clipboard.writeText(shareUrl).catch(() => {}); if (copyBtn) { copyBtn.textContent = "¡Copiado!"; copyBtn.style.background = "#16a34a"; } setTimeout(() => { if (copyBtn) { copyBtn.textContent = "Copiar"; copyBtn.style.background = "#0047AB"; } }, 2000); }); const container = document.getElementById("swal-share-buttons"); if (container) { reactRoot = createRoot(container); reactRoot.render( <> , ); } }, willClose: () => { reactRoot?.unmount(); }, }); }, [segmentFilename, accumulatedFilename]); const clampOffset = useCallback( (x: number, y: number, zoomValue: number) => { const wrapper = wrapperRef.current; if (!wrapper || imageSize.width === 0 || imageSize.height === 0) return { x, y }; const containerRect = wrapper.getBoundingClientRect(); const scaledWidth = imageSize.width * zoomValue; const scaledHeight = imageSize.height * zoomValue; const maxX = Math.max(0, (scaledWidth - containerRect.width) / 2); const maxY = Math.max(0, (scaledHeight - containerRect.height) / 2); return { x: Math.max(-maxX, Math.min(maxX, x)), y: Math.max(-maxY, Math.min(maxY, y)), }; }, [imageSize], ); const updateImageSize = useCallback( (img: HTMLImageElement) => { const wrapper = wrapperRef.current; if (!wrapper) return; const containerRect = wrapper.getBoundingClientRect(); const naturalRatio = img.naturalWidth / img.naturalHeight; const containerRatio = containerRect.width / containerRect.height; const width = naturalRatio > containerRatio ? containerRect.width : containerRect.height * naturalRatio; const height = naturalRatio > containerRatio ? containerRect.width / naturalRatio : containerRect.height; setImageSize({ width, height }); setOffset((current) => clampOffset(current.x, current.y, zoom)); }, [clampOffset, zoom], ); const handleWheel = useCallback( (event: WheelEvent) => { event.preventDefault(); setZoom((currentZoom) => { const next = Math.min(3, Math.max(1, currentZoom - event.deltaY * 0.0015)); setOffset((current) => clampOffset(current.x, current.y, next)); return next; }); }, [clampOffset], ); useEffect(() => { const el = wrapperRef.current; if (!el) return; el.addEventListener("wheel", handleWheel, { passive: false }); return () => el.removeEventListener("wheel", handleWheel); }, [handleWheel]); const handlePointerDown = useCallback((event: PointerEvent) => { setDragStart({ x: event.clientX, y: event.clientY }); }, []); const handlePointerMove = useCallback( (event: PointerEvent) => { if (!dragStart || zoom <= 1) return; const dx = event.clientX - dragStart.x; const dy = event.clientY - dragStart.y; setOffset((current) => clampOffset(current.x + dx, current.y + dy, zoom)); setDragStart({ x: event.clientX, y: event.clientY }); }, [clampOffset, dragStart, zoom], ); const handlePointerUp = useCallback(() => setDragStart(null), []); // Props compartidos entre mobile y desktop para RoomPreviewPanel const previewPanelProps = { previewImage: currentPreviewImage, offset, zoom, imageSize, wrapperRef, canvasRef, selectedProduct, selectedMasks, hoveredMask, segmentMeta, isApplying, onBack: () => navigate("/app"), onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, updateImageSize, onCanvasMouseMove: handleCanvasMouseMove, onCanvasMouseLeave: handleCanvasMouseLeave, onCanvasClick: handleCanvasClick, onApplyTexture: handleApplyTexture, onReset: handleReset, onDownload: handleDownload, onShare: handleShare, }; // ── Mobile strip: thumbnails + búsqueda ────────────────────────────────── const MobileProductStrip = (
{isSearchOpen && (
setSearchQuery(e.target.value)} style={{ width: "100%", paddingLeft: 34, paddingRight: 32, paddingTop: 8, paddingBottom: 8, borderRadius: 8, border: "2px solid #0047AB", outline: "none", fontSize: 14, color: "#333", boxSizing: "border-box", }} />
)} {/* Thumbnails con scroll horizontal */}
{loading ? (
Cargando...
) : ( filteredProducts.map((product) => ( )) )}
{/* Info del producto + íconos */}
{selectedProduct ? (

{selectedProduct.brand}

{selectedProduct.name}

) : (
)}
); // ── Desktop sidebar ─────────────────────────────────────────────────────── const DesktopSidebar = (
{/* Barra de herramientas */}
{!isSearchOpen && ( )}
{isSearchOpen && (
setSearchQuery(e.target.value)} className="w-full pl-11 pr-10 py-3 rounded-lg border-2 border-[#0047AB] bg-white focus:outline-none transition-all text-sm text-[#333333]" />
)}
{/* Lista de productos con acordeón por categoría */}
{loading ? (
Cargando productos...
) : error ? (
{error}
) : searchQuery ? ( /* Búsqueda activa → lista plana de resultados */
{filteredProducts.length === 0 ? (
Sin resultados
) : viewMode === "grid" ? (
{chunkArray(filteredProducts, 3).map((group, i) => ( ))}
) : (
{filteredProducts.map((product) => ( handleProductSelect(product.id)} /> ))}
)}
) : ( /* Sin búsqueda → acordeón por categoría */
{categories.map((cat) => { const isOpen = isCategoryOpen(cat.id); return (
{isOpen && (
{viewMode === "grid" ? (
{chunkArray(cat.products, 3).map((group, i) => ( ))}
) : (
{cat.products.map((product) => ( handleProductSelect(product.id)} /> ))}
)}
)}
); })}
)}
); return (
{isMobile ? ( // ── Layout Mobile: imagen arriba, strip de thumbnails abajo ──────────
{MobileProductStrip}
) : ( // ── Layout Desktop: sidebar izquierda + imagen derecha ───────────────
{DesktopSidebar}
)}
); }