dev 8 months ago
parent b99a093b97
commit 13cd8049f5

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

@ -10,43 +10,42 @@
let { hotspot, groupName, isVisible, onClick }: Props = $props();
const radius = hotspot.radius || 20;
const color = hotspot.color || '#10b981'; // green-500
const icon = hotspot.icon || 'ℹ️';
const radius = 14;
const color = '#00aa63'; // green-500
const icon = '';
</script>
<button
class="hotspot-button absolute transform -translate-x-1/2 -translate-y-1/2 transition-all duration-300"
class:opacity-0={!isVisible}
class:opacity-100={isVisible}
class:scale-0={!isVisible}
class:scale-100={isVisible}
style="
class="hotspot-button absolute transform -translate-x-1/2 -translate-y-1/2 transition-all duration-300"
class:opacity-0={!isVisible}
class:opacity-100={isVisible}
class:scale-0={!isVisible}
class:scale-100={isVisible}
style="
left: {hotspot.x}%;
top: {hotspot.y}%;
width: {radius * 2}px;
height: {radius * 2}px;
"
onclick={onClick}
title="Ver detalles de {groupName}"
onclick={onClick}
title="Ver detalles de {groupName}"
>
<!-- Anillo pulsante -->
<div
class="absolute inset-0 rounded-full animate-ping"
style="background-color: {color}; opacity: 0.4;"
<div
class="absolute inset-0 rounded-full animate-ping"
style="background-color: {color}; opacity: 0.4;"
></div>
<!-- Círculo principal -->
<div
class="absolute inset-0 rounded-full flex items-center justify-center shadow-lg hover:scale-110 transition-transform cursor-pointer"
style="background-color: {color};"
<div
class="absolute inset-0 rounded-full flex items-center justify-center shadow-lg hover:scale-110 transition-transform cursor-pointer"
style="background-color: {color};"
>
<span class="text-white font-bold" style="font-size: {radius * 1.2 }px;">
+
<!--
<span class="text-white font-bold" style="font-size: {radius * 0.6}px;">
{icon}
-->
</span>
</div>

@ -1,36 +1,102 @@
<script lang="ts">
import { appState } from '../data/AppState.svelte';
import { dataConfig } from '../data/DataConfig';
import type { OptionGroup } from '../data/DataConfigTypes';
import type { OptionGroup, Hotspot } from '../data/DataConfigTypes';
import {formatPrice} from "../utils/formats";
interface Props {
group: OptionGroup;
hotspot: Hotspot;
viewId: string;
isOpen: boolean;
onClose: () => void;
}
let { group, viewId, isOpen, onClose }: Props = $props();
let { hotspot, viewId, isOpen, onClose }: Props = $props();
// Obtener el grupo según el tipo
const group = $derived(() => {
try {
if (hotspot.groupType === 'general') {
return dataConfig.options.general[hotspot.groupId] || null;
} else {
const view = dataConfig.options.views[viewId];
return view?.options.find(g => g.id === hotspot.groupId) || null;
}
} catch (e) {
console.error('Error obteniendo grupo:', e);
return null;
}
});
// Obtener la opción actual seleccionada
const currentOptionId = $derived(appState.getCurrentOption(viewId, group.id));
const currentOption = $derived(currentOptionId ? appState.getOptionValue(currentOptionId) : null);
const currentOptionId = $derived(() => {
try {
const grp = group();
if (!grp) return null;
if (hotspot.groupType === 'general') {
return appState.getCurrentOption('general', hotspot.groupId);
} else {
return appState.getCurrentOption(viewId, hotspot.groupId);
}
} catch (e) {
console.error('Error obteniendo currentOptionId:', e);
return null;
}
});
const currentOption = $derived(() => {
try {
const optId = currentOptionId();
return optId ? appState.getOptionValue(optId) : null;
} catch (e) {
console.error('Error obteniendo currentOption:', e);
return null;
}
});
// Obtener información de la opción desde el grupo
const optionRef = $derived(
group.values.find(v => v.id === currentOptionId)
);
const optionRef = $derived(() => {
try {
const grp = group();
const optId = currentOptionId();
if (!grp || !optId) return null;
return grp.values.find(v => v.id === optId) || null;
} catch (e) {
console.error('Error obteniendo optionRef:', e);
return null;
}
});
const hasPrice = $derived(
optionRef?.amount !== undefined &&
optionRef.amount !== 'consultar' &&
typeof optionRef.amount === 'number' &&
optionRef.amount > 0
);
const hasPrice = $derived(() => {
try {
const ref = optionRef();
return ref?.amount !== undefined &&
ref.amount !== 'consultar' &&
typeof ref.amount === 'number' &&
ref.amount > 0;
} catch (e) {
return false;
}
});
// Obtener el warn del grupo o de la opción
const warnMessage = $derived(() => {
try {
const grp = group();
const ref = optionRef();
// Prioridad: warn de la opción > warn del grupo
if (ref?.warn) return ref.warn;
if (grp?.warn) return grp.warn;
return null;
} catch (e) {
return null;
}
});
// Manejar click en el overlay (cerrar)
function handleOverlayClick(e: MouseEvent) {
function handleOverlayClick(e: MouseEvent | KeyboardEvent) {
if (e.target === e.currentTarget) {
onClose();
}
@ -42,25 +108,39 @@
onClose();
}
}
function handleImageError(e: Event) {
const target = e.currentTarget as HTMLImageElement;
target.onerror = null; // evita bucle
target.src = dataConfig.sources.images + 'placeholder.jpg';
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isOpen}
{#if isOpen && group()}
<!-- Overlay con opacidad muy baja -->
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4 animate-fade-in"
style="background-color: rgba(0, 0, 0, 0.2); backdrop-filter: blur(2px);"
onclick={handleOverlayClick}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
onkeydown={(e) => e.key === 'Enter' && handleOverlayClick(e)}
role="button"
tabindex="0"
aria-label="Cerrar modal"
>
<!-- Modal -->
<div class="bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden animate-scale-in">
<div
class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden animate-scale-in"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<!-- Header -->
<div class="bg-gradient-to-r from-green-800 to-green-700 text-white p-3 relative">
<div class="bg-gradient-to-r from-green-600 to-green-700 text-white p-3 relative">
<button
class="absolute top-4 right-4 text-white hover:text-gray-200 transition-colors"
onclick={onClose}
@ -72,12 +152,18 @@
</button>
<h2 id="modal-title" class="text-2xl font-bold mb-1">
{group.name || group.id}
{group()?.name || group()?.id || 'Opción'}
</h2>
{#if group.text}
<p class="text-green-100 text-sm">
{group.text}
{#if hotspot.groupType === 'general'}
<span class="inline-block px-2 py-1 bg-green-500 bg-opacity-30 rounded text-xs font-semibold mt-2">
Opción general para toda la vivienda
</span>
{/if}
{#if group()?.text}
<p class="text-green-100 text-sm mt-2">
{group()?.text || ''}
</p>
{/if}
</div>
@ -85,20 +171,15 @@
<!-- Content -->
<div class="p-6 overflow-y-auto max-h-[calc(90vh-200px)]">
{#if currentOption}
{#if currentOption()}
<!-- Imagen del material -->
{#if currentOption.image}
{#if currentOption()?.image}
<div class="mb-6 rounded-lg overflow-hidden shadow-lg">
<img
src={dataConfig.sources.imgOptionsHi + currentOption.image}
alt={currentOption.name}
src={dataConfig.sources.imgOptionsHi + (currentOption()?.image || '')}
alt={currentOption()?.name || 'Material'}
class="w-full h-64 object-cover"
onerror={(e) => {
console.log('error', e);
console.log(dataConfig.sources.imgOptionsHi + currentOption.image)
const target = e.currentTarget as HTMLImageElement;
target.src = dataConfig.sources.images + 'placeholder.jpg';
}}
onerror={handleImageError}
/>
</div>
{/if}
@ -108,27 +189,25 @@
<!-- Nombre del material -->
<div class="border-l-4 border-green-500 pl-4">
<h3 class="text-lg font-semibold text-gray-800 mb-1">
Material seleccionado
Opción seleccionada
</h3>
<p class="text-xl font-bold text-green-700">
{currentOption.name}
{currentOption()?.name || 'Sin nombre'}
</p>
</div>
<!-- Descripción del material -->
{#if currentOption.text}
<div class="bg-gray-50 rounded-lg p-4">
<h4 class="text-sm font-semibold text-gray-600 mb-2 uppercase tracking-wide">
Descripción
</h4>
<p class="text-gray-700 leading-relaxed">
{currentOption.text}
{#if currentOption()?.text}
<div class="bg-white rounded-lg p-2">
<p class="text-gray-700 leading-relaxed text-sm">
{currentOption()?.text || ''}
</p>
</div>
{/if}
<!-- Texto adicional del grupo -->
{#if group.help}
{#if group()?.help}
<div class="bg-blue-50 rounded-lg p-4 border border-blue-200">
<h4 class="text-sm font-semibold text-blue-800 mb-2 flex items-center gap-2">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
@ -137,62 +216,62 @@
Información adicional
</h4>
<p class="text-blue-900 text-sm leading-relaxed">
{group.help}
{group()?.help || ''}
</p>
</div>
{/if}
<!-- WARN - Advertencia específica -->
{#if warnMessage()}
<div class="bg-yellow-50 rounded-lg p-1 border-2 border-yellow-300">
<div class="flex items-start gap-3">
<span class="text-2xl flex-shrink-0">⚠️</span>
<div class="flex-1">
<h4 class="font-semibold text-yellow-900 m-1">
Advertencia : {warnMessage() || ''}
</h4>
<!--
<p class="text-sm text-yellow-800 leading-relaxed">
{warnMessage() || ''}
</p>
-->
</div>
</div>
</div>
{/if}
<!-- Precio -->
{#if hasPrice}
{#if hasPrice()}
<div class="bg-green-50 rounded-lg p-4 border-2 border-green-200">
<div class="flex items-center justify-between">
<span class="text-sm font-semibold text-gray-700">
Coste adicional:
</span>
<span class="text-2xl font-bold text-green-700">
+ {formatPrice(optionRef?.amount as number)}
<span class="text-2xl font-bold text-black-700">
+{formatPrice(optionRef()?.amount)}
</span>
</div>
</div>
{/if}
<!-- Badge Consultar -->
{#if optionRef?.amount === 'consultar'}
<div class="bg-yellow-50 rounded-lg p-4 border-2 border-yellow-200">
{#if optionRef()?.amount === 'consultar'}
<div class="bg-orange-50 rounded-lg p-4 border-2 border-orange-200">
<div class="flex items-start gap-3">
<span class="text-2xl">⚠️</span>
<span class="text-2xl flex-shrink-0">💬</span>
<div class="flex-1">
<h4 class="font-semibold text-yellow-800 mb-1">
<h4 class="font-semibold text-orange-800 mb-1">
Requiere consulta
</h4>
{#if optionRef.warn}
<p class="text-sm text-yellow-700">
{optionRef.warn}
</p>
{/if}
<p class="text-sm text-orange-700">
Esta opción debe ser consultada con nuestro equipo comercial.
</p>
</div>
</div>
</div>
{/if}
<!-- Miniatura adicional -->
<!--
{#if currentOption.thumb && currentOption.thumb !== currentOption.image}
<div class="flex items-center gap-3 p-3 bg-gray-100 rounded-lg">
<img
src={dataConfig.sources.imgValues + currentOption.thumb}
alt="Muestra de {currentOption.name}"
class="w-20 h-20 object-cover rounded shadow"
onerror={(e) => {
const target = e.currentTarget as HTMLImageElement;
target.style.display = 'none';
}}
/>
<span class="text-sm text-gray-600">Muestra del material</span>
</div>
{/if}
-->
</div>
{:else}
<!-- Sin opción seleccionada -->
@ -246,4 +325,4 @@
.animate-scale-in {
animation: scale-in 0.3s ease-out;
}
</style>
</style>

@ -1,32 +1,37 @@
<script lang="ts">
import { appState } from '../data/AppState.svelte';
import { dataConfig } from '../data/DataConfig';
import HotspotMarker from './HotspotMarker.svelte';
import HotspotModal from './HotspotModal.svelte';
import type { View, OptionGroup } from '../data/DataConfigTypes';
import type { View, Hotspot } from '../data/DataConfigTypes';
import HotspotMarker from "./HotspotMarker.svelte";
import HotspotModal from "./HotspotModal.svelte";
interface Props {
view?: View;
groupName?: string;
showHotspots?: boolean;
}
let {
view = appState.currentView,
showHotspots = true
showHotspots = true,
groupName = '',
}: Props = $props();
// Estado para controlar la visibilidad de hotspots
let hotspotsVisible = $state(false);
let selectedGroup: OptionGroup | null = $state(null);
let selectedHotspot: Hotspot | null = $state(null);
let isModalOpen = $state(false);
// Obtener la URL de la imagen actual
const currentFileName = $derived(appState.currentFileName);
// Grupos con hotspots
const groupsWithHotspots = $derived(
view.options.filter(group => group.hotspot !== undefined)
);
// Hotspots de la vista
const viewHotspots = $derived(view.hotspots || []);
$effect (() => {
console.log('viewHotspots', viewHotspots);
})
// Handlers
function handleMouseEnter() {
@ -39,19 +44,39 @@
hotspotsVisible = false;
}
function handleHotspotClick(group: OptionGroup) {
selectedGroup = group;
function handleHotspotClick(hotspot: Hotspot) {
selectedHotspot = hotspot;
isModalOpen = true;
}
function handleCloseModal() {
isModalOpen = false;
selectedGroup = null;
selectedHotspot = null;
}
// Obtener el nombre del grupo para el tooltip
function getGroupName(hotspot: Hotspot): string {
if (hotspot.groupType === 'general') {
const group = dataConfig.options.general[hotspot.groupId];
return group?.name || hotspot.groupId;
} else {
const group = view.options.find(g => g.id === hotspot.groupId);
return group?.name || hotspot.groupId;
}
}
function handleImageError(e: Event) {
const target = e.currentTarget as HTMLImageElement;
target.onerror = null; // evita bucle
target.src = dataConfig.sources.images + 'placeholder.jpg';
}
</script>
<div
class="relative w-full h-full overflow-hidden"
role="img"
aria-label={view.name || view.id}
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
>
@ -60,32 +85,28 @@
src={currentFileName}
alt={view.name || view.id}
class="w-full h-full object-contain"
onerror={(e) => {
const target = e.currentTarget as HTMLImageElement;
target.src = dataConfig.sources.images + 'placeholder.jpg';
}}
onerror={handleImageError}
/>
<!-- Hotspots superpuestos -->
{#if showHotspots}
{#if showHotspots && viewHotspots.length > 0}
<div class="absolute inset-0 pointer-events-none">
{#each groupsWithHotspots as group}
{#if group.hotspot}
<div class="pointer-events-auto">
<HotspotMarker
hotspot={group.hotspot}
groupName={group.name || group.id}
isVisible={hotspotsVisible}
onClick={() => handleHotspotClick(group)}
/>
</div>
{/if}
{#each viewHotspots as hotspot (hotspot.groupId + hotspot.groupType)}
<div class="pointer-events-auto">
<HotspotMarker
hotspot={hotspot}
groupName={getGroupName(hotspot)}
isVisible={hotspotsVisible}
onClick={() => handleHotspotClick(hotspot)}
/>
</div>
{/each}
</div>
{/if}
<!-- Indicador de hotspots disponibles (esquina inferior derecha) -->
{#if showHotspots && groupsWithHotspots.length > 0}
{#if showHotspots && viewHotspots.length > 0}
<div
class="absolute bottom-4 right-4 bg-white bg-opacity-95 backdrop-blur-sm rounded-full px-4 py-2 shadow-lg transition-all duration-300"
class:opacity-100={!hotspotsVisible}
@ -97,7 +118,7 @@
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
<span class="text-gray-700 font-medium whitespace-nowrap">
Pasa el ratón para ver detalles
Pasa el ratón para ver detalles ({viewHotspots.length})
</span>
</div>
</div>
@ -105,9 +126,9 @@
</div>
<!-- Modal -->
{#if selectedGroup}
{#if selectedHotspot}
<HotspotModal
group={selectedGroup}
hotspot={selectedHotspot}
viewId={view.id}
isOpen={isModalOpen}
onClose={handleCloseModal}
@ -127,4 +148,4 @@
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
</style>
</style>

@ -0,0 +1,249 @@
<script lang="ts">
import { appState } from '../data/AppState.svelte';
import { dataConfig } from '../data/DataConfig';
import type { OptionGroup } from '../data/DataConfigTypes';
import {formatPrice} from "../utils/formats";
interface Props {
group: OptionGroup;
viewId: string;
isOpen: boolean;
onClose: () => void;
}
let { group, viewId, isOpen, onClose }: Props = $props();
// Obtener la opción actual seleccionada
const currentOptionId = $derived(appState.getCurrentOption(viewId, group.id));
const currentOption = $derived(currentOptionId ? appState.getOptionValue(currentOptionId) : null);
// Obtener información de la opción desde el grupo
const optionRef = $derived(
group.values.find(v => v.id === currentOptionId)
);
const hasPrice = $derived(
optionRef?.amount !== undefined &&
optionRef.amount !== 'consultar' &&
typeof optionRef.amount === 'number' &&
optionRef.amount > 0
);
// Manejar click en el overlay (cerrar)
function handleOverlayClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
// Cerrar con tecla ESC
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && isOpen) {
onClose();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isOpen}
<!-- Overlay con opacidad muy baja -->
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4 animate-fade-in"
style="background-color: rgba(0, 0, 0, 0.2); backdrop-filter: blur(2px);"
onclick={handleOverlayClick}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<!-- Modal -->
<div class="bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden animate-scale-in">
<!-- Header -->
<div class="bg-gradient-to-r from-green-800 to-green-700 text-white p-3 relative">
<button
class="absolute top-4 right-4 text-white hover:text-gray-200 transition-colors"
onclick={onClose}
aria-label="Cerrar"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
<h2 id="modal-title" class="text-2xl font-bold mb-1">
{group.name || group.id}
</h2>
{#if group.text}
<p class="text-green-100 text-sm">
{group.text}
</p>
{/if}
</div>
<!-- Content -->
<div class="p-6 overflow-y-auto max-h-[calc(90vh-200px)]">
{#if currentOption}
<!-- Imagen del material -->
{#if currentOption.image}
<div class="mb-6 rounded-lg overflow-hidden shadow-lg">
<img
src={dataConfig.sources.imgOptionsHi + currentOption.image}
alt={currentOption.name}
class="w-full h-64 object-cover"
onerror={(e) => {
console.log('error', e);
console.log(dataConfig.sources.imgOptionsHi + currentOption.image)
const target = e.currentTarget as HTMLImageElement;
target.src = dataConfig.sources.images + 'placeholder.jpg';
}}
/>
</div>
{/if}
<!-- Información del material -->
<div class="space-y-4">
<!-- Nombre del material -->
<div class="border-l-4 border-green-500 pl-4">
<h3 class="text-lg font-semibold text-gray-800 mb-1">
Material seleccionado
</h3>
<p class="text-xl font-bold text-green-700">
{currentOption.name}
</p>
</div>
<!-- Descripción del material -->
{#if currentOption.text}
<div class="bg-gray-50 rounded-lg p-4">
<h4 class="text-sm font-semibold text-gray-600 mb-2 uppercase tracking-wide">
Descripción
</h4>
<p class="text-gray-700 leading-relaxed">
{currentOption.text}
</p>
</div>
{/if}
<!-- Texto adicional del grupo -->
{#if group.help}
<div class="bg-blue-50 rounded-lg p-4 border border-blue-200">
<h4 class="text-sm font-semibold text-blue-800 mb-2 flex items-center gap-2">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
Información adicional
</h4>
<p class="text-blue-900 text-sm leading-relaxed">
{group.help}
</p>
</div>
{/if}
<!-- Precio -->
{#if hasPrice}
<div class="bg-green-50 rounded-lg p-4 border-2 border-green-200">
<div class="flex items-center justify-between">
<span class="text-sm font-semibold text-gray-700">
Coste adicional:
</span>
<span class="text-2xl font-bold text-green-700">
+ {formatPrice(optionRef?.amount as number)}
</span>
</div>
</div>
{/if}
<!-- Badge Consultar -->
{#if optionRef?.amount === 'consultar'}
<div class="bg-yellow-50 rounded-lg p-4 border-2 border-yellow-200">
<div class="flex items-start gap-3">
<span class="text-2xl">⚠️</span>
<div class="flex-1">
<h4 class="font-semibold text-yellow-800 mb-1">
Requiere consulta
</h4>
{#if optionRef.warn}
<p class="text-sm text-yellow-700">
{optionRef.warn}
</p>
{/if}
</div>
</div>
</div>
{/if}
<!-- Miniatura adicional -->
<!--
{#if currentOption.thumb && currentOption.thumb !== currentOption.image}
<div class="flex items-center gap-3 p-3 bg-gray-100 rounded-lg">
<img
src={dataConfig.sources.imgValues + currentOption.thumb}
alt="Muestra de {currentOption.name}"
class="w-20 h-20 object-cover rounded shadow"
onerror={(e) => {
const target = e.currentTarget as HTMLImageElement;
target.style.display = 'none';
}}
/>
<span class="text-sm text-gray-600">Muestra del material</span>
</div>
{/if}
-->
</div>
{:else}
<!-- Sin opción seleccionada -->
<div class="text-center py-8">
<div class="text-4xl mb-4">🔍</div>
<p class="text-gray-500">No hay ninguna opción seleccionada para este elemento</p>
</div>
{/if}
</div>
<!-- Footer -->
<!--
<div class="bg-gray-50 px-6 py-4 flex justify-end gap-3 border-t">
<button
class="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium shadow-sm"
onclick={onClose}
>
Cerrar
</button>
</div>
-->
</div>
</div>
{/if}
<style>
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-fade-in {
animation: fade-in 0.2s ease-out;
}
.animate-scale-in {
animation: scale-in 0.3s ease-out;
}
</style>

@ -83,22 +83,26 @@ export const dataConfig : DataConfig = {
trentino: {
value : 'trentino',
name : 'Trentino Tiles Sand',
text : '',
image : 'suelo_trentino.jpg',
text : 'Un material sereno y elegante que transforma el espacio desde la primera mirada.\n' +
'SAND nace de diminutos granos de roca moldeados por el agua y el sol, dando lugar a una superficie suave, delicada y sorprendente al tacto. Una serie porcelánica que aporta calma, equilibrio y sofisticación a cualquier ambiente.\n' +
'Piezas 60x120cm',
image : 'trentino.jpg',
thumb : 'trentino_tiles_sand_thumb.png',
},
roble: {
value : 'roble',
name : 'Albar Roble',
text : '',
image : 'suelos/roble.jpg',
text : 'Tau Cerámica Albar Roble.\n'+
'Piezas 25x150cm',
image : 'roble.jpg',
thumb : 'albar_roble_thumb.png',
},
alpino: {
value : 'alpino',
name : 'Alpino Honey',
text : '',
image : 'suelos/roble.jpg',
text : 'Tau Cerámica Alpino Honey.\n'+
'Piezas 25x150cm',
image : 'alpino.jpg',
thumb : 'alpino_honey_thumb.png',
},
@ -106,39 +110,43 @@ export const dataConfig : DataConfig = {
onyks: {
value : 'onyks',
name : 'Mirage Onyks',
text : '',
image : 'alicatados/onyks.jpg',
text : 'Mirage Onyks Jewels 2.0. Piezas 60x120cm',
image : 'onyks.jpg',
thumb : 'onyks_thumb.png',
},
light: {
value : 'light',
name : 'Ground Light',
text : '',
image : 'alicatados/light.jpg',
text : 'Living Ceramics Ground Light Brush + Twist. Piezas 30x90cm',
image : 'light.jpg',
thumb : 'light_brush_thumb.png',
},
grey: {
value : 'grey',
name : 'Ground Grey',
text : '',
image : 'alicatados/grey.jpg',
text : 'Living Ceramics Ground Grey Brush + Twist. Piezas 30x90cm',
image : 'grey.jpg',
thumb : 'grey_brush_thumb.png',
},
salvia: {
value : 'salvia',
name : 'Marea Salvia',
text : '',
image : 'alicatados/light.jpg',
text : 'Marea Salvia Matt introduce un tono verde suave y natural que conecta el espacio con la calma del entorno.\n' +
'Su acabado mate y formato alargado aportan equilibrio, luz y continuidad visual.\n' +
'Un alicatado sereno y contemporáneo. Piezas 7,5x30cm',
image : 'salvia.jpg',
thumb : 'marea_salvia_thumb.png',
},
zucchero: {
value : 'zucchero',
name : 'Carta da Zucchero',
text : '',
image : 'alicatados/zucchero.jpg',
text : 'Quintessenza Carta da Zucchero Matt aporta una superficie lisa y mate de inspiración artesanal, suave y envolvente.\n' +
'Su tono azul empolvado introduce calma y profundidad sin dominar el espacio.\n' +
'Un revestimiento elegante y atemporal. Piezas 7,5x30cm',
image : 'zucchero.jpg',
thumb : 'carta_zucchero_thumb.png',
},
@ -193,7 +201,7 @@ export const dataConfig : DataConfig = {
sensow: {
value : 'sensow',
name : 'D-Neo SensoWash',
text : 'SensoWash® D-Neo aporta tecnología de lavado en un formato compacto. Su diseño minimalista de perfil bajo se integra con armonía en todas las series de diseño de Duravit.Una solución funcional que suma bienestar, higiene avanzada y estética moderna a presupuestos moderados.',
text : 'SensoWash® D-Neo aporta tecnología de lavado en un formato compacto. Su diseño minimalista de perfil bajo se integra con armonía en todas las series de diseño de Duravit.Una solución funcional que suma bienestar, higiene avanzada y estética moderna.',
image : 'sensow.jpg',
thumb : 'sensow_thumb.jpg',
},
@ -284,7 +292,7 @@ export const dataConfig : DataConfig = {
// Grupo de opciones
suelo: {
id: 'suelo',
name: '',
name: 'Pavimento general de la vivienda',
text: '',
help: '',
default: 'trentino',
@ -305,6 +313,15 @@ export const dataConfig : DataConfig = {
text: '',
help: '',
options: [],
hotspots: [
// 🎯 HOTSPOT CONFIGURADO
{
groupId: 'suelo',
groupType: 'general',
x: 50,
y: 92,
},
]
},
bano1: {
@ -342,14 +359,6 @@ export const dataConfig : DataConfig = {
rules: { general: { suelo: { allow:['trentino','roble'] } } }
},
],
// 🎯 HOTSPOT CONFIGURADO
hotspot: {
x: 65,
y: 55,
radius: 14,
color: '#00a63e', // Rosa
icon: '✨'
}
},
{
id: 'inodoro',
@ -365,16 +374,30 @@ export const dataConfig : DataConfig = {
warn: 'Este inodoro es suspendido'
},
],
// 🎯 HOTSPOT CONFIGURADO
hotspot: {
x: 34,
y: 85,
radius: 14,
color: '#00a63e', // Rosa
icon: '✨'
}
},
],
hotspots: [
// 🎯 HOTSPOT CONFIGURADO
{
groupId: 'suelo',
groupType: 'general',
x: 55,
y: 85,
},
{
groupId: 'alicatado',
groupType: 'view',
x: 65,
y: 55,
},
{
groupId: 'inodoro',
groupType: 'view',
x: 34,
y: 85,
},
],
},
@ -424,6 +447,21 @@ export const dataConfig : DataConfig = {
],
},
],
hotspots: [
// 🎯 HOTSPOT CONFIGURADO
{
groupId: 'suelo',
groupType: 'general',
x: 55,
y: 93,
},
{
groupId: 'alicatado',
groupType: 'view',
x: 72,
y: 55,
},
],
},
aseo: {
@ -461,6 +499,21 @@ export const dataConfig : DataConfig = {
],
},
],
hotspots: [
// 🎯 HOTSPOT CONFIGURADO
{
groupId: 'suelo',
groupType: 'general',
x: 55,
y: 90,
},
{
groupId: 'alicatado',
groupType: 'view',
x: 60,
y: 55,
},
],
},
cocina: {
@ -525,16 +578,23 @@ export const dataConfig : DataConfig = {
warn: 'Coste según tipología y personalización, se contratará con Studio56',
},
],
// 🎯 HOTSPOT CONFIGURADO
hotspot: {
x: 40,
y: 63,
radius: 14,
color: '#00a63e', // Rosa
icon: '✨'
}
},
],
hotspots: [
// 🎯 HOTSPOT CONFIGURADO
{
groupId: 'suelo',
groupType: 'general',
x: 55,
y: 85,
},
{
groupType: 'view',
groupId: 'encimera',
x: 40,
y: 63,
},
]
},
},

@ -22,9 +22,12 @@ export interface Hotspot {
color?: string;
/** Icono del hotspot (opcional, default: 'ℹ️') */
icon?: string;
/** ID del grupo que representa este hotspot */
groupId: string;
/** Tipo de grupo: 'general' o 'view' */
groupType: 'general' | 'view';
}
// 2. Definición de la referencia dentro de un grupo (ej: { id: 'trentino' })
export interface OptionReference {
id: string;
@ -43,8 +46,6 @@ export interface OptionGroup {
help: string;
default: string;
warn?: string;
/** Hotspot para visualizar esta opción en la vista */
hotspot?: Hotspot;
values: OptionReference[];
}
@ -55,6 +56,8 @@ export interface View {
text: string;
help: string;
options: OptionGroup[];
/** Array de hotspots para esta vista */
hotspots?: Hotspot[];
}

@ -4,8 +4,23 @@
export const formatPrice = (precio?: number): string => {
export const formatPrice = (precio?: number | string): string => {
if (precio === undefined || precio === null) return '';
if (typeof precio === 'string') {
// opcional: limpiar formato "1.234,56" → "1234.56"
const normalizado = precio
.replace(/\./g, '')
.replace(',', '.');
const numero = Number(normalizado);
if (Number.isNaN(numero)) return precio; // o ''
precio = numero;
}
if (precio === undefined) return '';
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR',

@ -6,7 +6,7 @@
import PnGroup from "../cmps/PnGroup.svelte";
import BtViewAccordion from "../cmps/BtViewAccordion.svelte";
import {navigationState} from "../data/NavState.svelte";
import ViewWithHotspots from "../cmps/ViewWithHotspots.svelte";
import Hotspots from "../cmps/Hotspots.svelte";
let isLoading = $state(true);
@ -89,7 +89,7 @@
<!-- IMAGEN CENTRAL CON HOTSPOTS -->
<div class="sticky top-1 z-10 flex-1 p-3 h-full bg-white shadow-lg lg:static flex lg:items-start justify-center overflow-hidden">
<div class="relative w-full h-full flex items-center justify-center">
<ViewWithHotspots
<Hotspots
view={appState.currentView}
showHotspots={true}
/>

Loading…
Cancel
Save

Powered by TurnKey Linux.