| 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; |
| }; |
|
|
| |
| 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); |
|
|
| |
| useEffect(() => { |
| if (state?.filename && state.filename !== segmentFilename) { |
| setSegmentResult(state.filename, state.maskCount ?? 0); |
| setAccumulatedFilename(null); |
| } |
| if (state?.previewImage) { |
| setPreviewImage(state.previewImage); |
| } |
| |
| |
| }, []); |
|
|
| 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(); |
|
|
| |
| |
| 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 { |
| |
| } |
| }, |
| [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 { |
| |
| } |
| } |
|
|
| 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), []); |
|
|
| |
| 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, |
| }; |
|
|
| |
| 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> |
| ); |
|
|
| |
| 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> |
| ); |
| } |
|
|