eduardo4547's picture
Upload 150 files
cb5d9d0 verified
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<string | null>(() => {
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<HTMLDivElement>(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<Set<string> | 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<string>()
: 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<void> => {
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<typeof createRoot> | null = null;
await Swal.fire({
title: "Compartir diseño",
html: `
<div style="text-align:left">
<p style="font-size:13px;color:#666;margin-bottom:8px">Enlace de tu diseño:</p>
<div style="display:flex;gap:8px;align-items:center;margin-bottom:20px">
<input id="swal-share-url" readonly value="${shareUrl}"
style="flex:1;padding:8px 12px;border:1px solid #ddd;border-radius:8px;font-size:12px;background:#f9f9f9;outline:none;color:#333;" />
<button id="swal-copy-btn"
style="padding:8px 16px;background:#0047AB;color:white;border:none;border-radius:8px;cursor:pointer;font-size:13px;white-space:nowrap;font-weight:600;">
Copiar
</button>
</div>
<p style="font-size:13px;color:#666;margin-bottom:12px">Compartir en:</p>
<div id="swal-share-buttons" style="display:flex;gap:12px;flex-wrap:wrap;"></div>
</div>
`,
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(
<>
<WhatsappShareButton url={shareUrl} title={title}><WhatsappIcon size={48} round /></WhatsappShareButton>
<TelegramShareButton url={shareUrl} title={title}><TelegramIcon size={48} round /></TelegramShareButton>
<TwitterShareButton url={shareUrl} title={title}><XIcon size={48} round /></TwitterShareButton>
<FacebookShareButton url={shareUrl}><FacebookIcon size={48} round /></FacebookShareButton>
<EmailShareButton url={shareUrl} subject={title} body="Mira mi diseño de habitación:"><EmailIcon size={48} round /></EmailShareButton>
</>,
);
}
},
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<HTMLDivElement>) => {
setDragStart({ x: event.clientX, y: event.clientY });
}, []);
const handlePointerMove = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
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 = (
<div style={{ background: "#fff", borderTop: "1px solid #e5e7eb", flexShrink: 0 }}>
{isSearchOpen && (
<div style={{ padding: "8px 12px 4px" }}>
<div style={{ position: "relative" }}>
<Search style={{ position: "absolute", left: 10, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "#9ca3af" }} />
<input
autoFocus
type="text"
placeholder="Buscar productos..."
value={searchQuery}
onChange={(e) => 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",
}}
/>
<button
onClick={closeSearch}
style={{ position: "absolute", right: 8, top: "50%", transform: "translateY(-50%)", background: "none", border: "none", cursor: "pointer", padding: 4 }}
>
<X style={{ width: 14, height: 14, color: "#9ca3af" }} />
</button>
</div>
</div>
)}
{/* Thumbnails con scroll horizontal */}
<div style={{ display: "flex", overflowX: "auto", gap: 8, padding: "8px 12px", scrollbarWidth: "none" }}>
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "100%", height: 64, fontSize: 12, color: "#9ca3af" }}>
Cargando...
</div>
) : (
filteredProducts.map((product) => (
<button
key={product.id}
onClick={() => handleProductSelect(product.id)}
style={{
flexShrink: 0,
width: 64,
height: 64,
borderRadius: 12,
overflow: "hidden",
border: openProductId === product.id ? "2.5px solid #0047AB" : "2px solid #e5e7eb",
boxShadow: openProductId === product.id ? "0 0 0 2px #dbe7ff" : "none",
cursor: "pointer",
padding: 0,
background: "none",
transition: "border-color 0.15s",
}}
>
<img
src={product.image}
alt={product.name}
style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
/>
</button>
))
)}
</div>
{/* Info del producto + íconos */}
<div style={{ display: "flex", alignItems: "center", padding: "0 12px 10px", gap: 8, minHeight: 36 }}>
{selectedProduct ? (
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 10, color: "#707070", textTransform: "uppercase", letterSpacing: "0.05em", margin: 0, lineHeight: 1 }}>
{selectedProduct.brand}
</p>
<p style={{ fontSize: 12, fontWeight: 600, color: "#333", margin: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{selectedProduct.name}
</p>
</div>
) : (
<div style={{ flex: 1 }} />
)}
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
<button
onClick={() => setIsSearchOpen(!isSearchOpen)}
style={{ padding: 6, borderRadius: 8, border: "none", background: "none", cursor: "pointer" }}
>
<Search style={{ width: 16, height: 16, color: "#0047AB" }} />
</button>
<button
style={{ padding: 6, borderRadius: 8, border: "none", background: "none", cursor: "pointer" }}
>
<SlidersHorizontal style={{ width: 16, height: 16, color: "#0047AB" }} />
</button>
</div>
</div>
</div>
);
// ── Desktop sidebar ───────────────────────────────────────────────────────
const DesktopSidebar = (
<div style={{ width: "25%", height: "100%", display: "flex", flexDirection: "column", borderRight: "1px solid rgba(0,71,171,0.1)", background: "#fff", flexShrink: 0 }}>
<div style={{ padding: "24px 24px 0" }}>
<div style={{ height: 1, background: "#e5e7eb", width: "100%" }} />
{/* Barra de herramientas */}
<div style={{ display: "flex", alignItems: "center", gap: 8, height: 46 }}>
{!isSearchOpen && (
<button
onClick={() => setIsSearchOpen(true)}
className="p-3 rounded-lg border border-[#0047AB] bg-[#0047AB] text-white hover:bg-[#003a94] transition-all duration-300 flex items-center justify-center shadow-sm"
>
<Search className="w-5 h-5" />
</button>
)}
<button className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg border border-[#0047AB] bg-white text-[#0047AB] hover:bg-[#eaf1ff] transition-all duration-300 shadow-sm">
<SlidersHorizontal className="w-4 h-4 text-[#0047AB]" />
<span className="font-medium text-sm">Filtros</span>
</button>
<div className="flex border border-gray-300 rounded-lg overflow-hidden shadow-sm">
<button
onClick={showList}
className={`p-2.5 flex items-center justify-center transition-colors ${viewMode === "list" ? "bg-[#0047AB] text-white" : "bg-white text-[#0047AB] hover:bg-[#eaf1ff] border-r border-[#dbe7ff]"}`}
>
<Menu className="w-5 h-5" />
</button>
<button
onClick={showGrid}
className={`p-2.5 flex items-center justify-center transition-colors ${viewMode === "grid" ? "bg-[#0047AB] text-white" : "bg-white text-[#0047AB] hover:bg-[#eaf1ff]"}`}
>
<LayoutGrid className="w-5 h-5" />
</button>
</div>
</div>
{isSearchOpen && (
<div className="animate-in slide-in-from-top-2 fade-in duration-300">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
autoFocus
type="text"
placeholder="¿Qué estás buscando?..."
value={searchQuery}
onChange={(e) => 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]"
/>
<button
onClick={closeSearch}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 bg-white rounded-full shadow-sm"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
{/* Lista de productos con acordeón por categoría */}
<div className="flex-1 overflow-y-auto py-2">
{loading ? (
<div className="flex items-center justify-center h-32 text-sm text-gray-400">Cargando productos...</div>
) : error ? (
<div className="flex items-center justify-center h-32 text-sm text-red-400">{error}</div>
) : searchQuery ? (
/* Búsqueda activa → lista plana de resultados */
<div className={viewMode === "grid" ? "px-4" : "px-2"}>
{filteredProducts.length === 0 ? (
<div className="flex items-center justify-center h-24 text-sm text-gray-400">Sin resultados</div>
) : viewMode === "grid" ? (
<div className="flex flex-col gap-4">
{chunkArray(filteredProducts, 3).map((group, i) => (
<ProductGroupCard key={i} group={group} openProductId={openProductId} onSelectProduct={handleProductSelect} />
))}
</div>
) : (
<div className="grid grid-cols-1 gap-3">
{filteredProducts.map((product) => (
<IndividualProductCard key={product.id} product={product} isSelected={openProductId === product.id} onToggle={() => handleProductSelect(product.id)} />
))}
</div>
)}
</div>
) : (
/* Sin búsqueda → acordeón por categoría */
<div className="flex flex-col">
{categories.map((cat) => {
const isOpen = isCategoryOpen(cat.id);
return (
<div key={cat.id} className="border-b border-gray-100 last:border-0">
<button
onClick={() => toggleCategory(cat.id)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-[#f4f8ff] transition-colors text-left"
>
<div className="flex items-center gap-2 min-w-0">
<span className="font-semibold text-sm text-[#333] truncate">{cat.nombre}</span>
<span className="text-xs text-[#0047AB] bg-[#eaf1ff] px-1.5 py-0.5 rounded-full flex-shrink-0">
{cat.products.length}
</span>
</div>
<ChevronDown
className={`w-4 h-4 text-[#0047AB] flex-shrink-0 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
/>
</button>
{isOpen && (
<div className={viewMode === "grid" ? "px-4 pb-4" : "px-2 pb-2"}>
{viewMode === "grid" ? (
<div className="flex flex-col gap-4">
{chunkArray(cat.products, 3).map((group, i) => (
<ProductGroupCard key={i} group={group} openProductId={openProductId} onSelectProduct={handleProductSelect} />
))}
</div>
) : (
<div className="grid grid-cols-1 gap-3">
{cat.products.map((product) => (
<IndividualProductCard key={product.id} product={product} isSelected={openProductId === product.id} onToggle={() => handleProductSelect(product.id)} />
))}
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
return (
<div style={{ height: "100svh", width: "100%", background: "#fff", fontFamily: "sans-serif", color: "#000", display: "flex", flexDirection: "column" }}>
{isMobile ? (
// ── Layout Mobile: imagen arriba, strip de thumbnails abajo ──────────
<div style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
<RoomPreviewPanel {...previewPanelProps} />
</div>
{MobileProductStrip}
</div>
) : (
// ── Layout Desktop: sidebar izquierda + imagen derecha ───────────────
<div style={{ flex: 1, display: "flex", flexDirection: "row", minHeight: 0 }}>
{DesktopSidebar}
<div style={{ flex: 1, minWidth: 0, overflow: "hidden" }}>
<RoomPreviewPanel {...previewPanelProps} />
</div>
</div>
)}
</div>
);
}