dev 8 months ago
parent 5e40546c1a
commit b99a093b97

@ -1,18 +1,20 @@
<script lang="ts">
import './app.css';
import NavMain from "./lib/cmps/NvMain.svelte";
import {navegationState} from "./lib/data/NavState.svelte";
import {navigationState} from "./lib/data/NavState.svelte";
import VwHome from "./lib/views/VwHome.svelte";
import VwConfig from "./lib/views/VwConfig.svelte";
import VwSelection from "./lib/views/VwSelection.svelte";
import { fade } from 'svelte/transition';
import VwGallery from "./lib/views/VwGallery.svelte";
import {appState} from "./lib/data/AppState.svelte";
import PoMessage from "./lib/cmps/PoMessage.svelte";
import PoImage from "./lib/cmps/PoImage.svelte";
function handleViewClick(viewId: string) {
appState.setView(viewId);
navegationState.activePage = 'cfg'; // Navegar al configurador
navigationState.activePage = 'cfg'; // Navegar al configurador
}
</script>
@ -22,17 +24,17 @@
<NavMain />
<div class="flex-1 overflow-y-auto relative">
{#key navegationState.activePage}
{#key navigationState.activePage}
<div
class="absolute inset-0"
in:fade={{ duration: 250 }}
out:fade={{ duration: 250 }}
>
{#if navegationState.activePage === 'cfg'}
{#if navigationState.activePage === 'cfg'}
<VwConfig/>
{:else if navegationState.activePage === 'sel'}
{:else if navigationState.activePage === 'sel'}
<VwSelection onViewClick={handleViewClick}/>
{:else if navegationState.activePage === 'gal'}
{:else if navigationState.activePage === 'gal'}
<VwGallery />
{:else}
<VwHome />
@ -43,6 +45,9 @@
<PoMessage />
<PoImage />
</main>

@ -3,6 +3,7 @@
import type { OptionReference } from "../data/DataConfigTypes";
import {dataConfig} from "../data/DataConfig";
import {formatPrice} from "../utils/formats";
import {popupMessage} from "../data/PopsState.svelte";
const imgPath : string = dataConfig.sources.imgValues
@ -35,6 +36,9 @@
function handleClick() {
console.log('🔵 CLICK:', { viewId, groupId, optionId: optionRef.id });
if (consultation.message) {
popupMessage.open(consultation.message);
}
appState.setOption(viewId, groupId, optionRef.id);
}
@ -71,16 +75,19 @@
class:border-gray-200={!isSelected}
class:cursor-not-allowed={isDisabled}
class:cursor-pointer={!isDisabled}
onclick={handleClick}
>
<!-- Warning indicator (simple) -->
{#if consultation.message}
<div class="absolute -top-3 -left-1 w-5 h-5 rounded-full flex items-center justify-center text-white text-s font-bold shadow-md p-3">
⚠
</div>
{/if}
<!-- Selected indicator (simple) -->
{#if isSelected}
<div class="absolute -top-1 {consultation.message ? '-right-1' : '-right-1'} w-5 h-5 bg-green-600 rounded-full flex items-center justify-center text-white text-xs font-bold shadow-md">
@ -105,7 +112,7 @@
{/if}
<!-- Nombre -->
<div class="font-medium text-xm mb-1 text-center">
<div class="font-medium text-xs mb-1 text-center">
{optionValue?.name || optionRef.id}
</div>
@ -123,11 +130,13 @@
{/if}
<!-- Texto adicional -->
<!--
{#if optionValue?.text}
<div class="text-xs text-gray-600 mb-1">
{optionValue.text}
</div>
{/if}
-->
<!-- Precio -->

@ -1,21 +1,24 @@
<script lang="ts">
import { type Snippet } from 'svelte';
let { title, desc, children } = $props<{
let { title, desc, children, isOpen, openHandler } = $props<{
title: string;
desc: string;
openHandler: Function;
isOpen: boolean;
children: Snippet
}>();
let isOpen = $state(true);
</script>
<div class="border border-gray-200 rounded-xl mb-6 bg-white shadow-sm overflow-hidden">
<button
type="button"
onclick={() => isOpen = !isOpen}
onclick={() => openHandler()}
class="w-full p-5 text-left hover:bg-gray-50 transition-colors flex justify-between items-center group"
>
<div>
<h2 class="text-lg font-bold text-gray-900 leading-tight">{title}</h2>
<p class="text-sm text-gray-500 mt-1">{desc}</p>

@ -0,0 +1,75 @@
<script lang="ts">
import type { Hotspot } from '../data/DataConfigTypes';
interface Props {
hotspot: Hotspot;
groupName: string;
isVisible: boolean;
onClick: () => void;
}
let { hotspot, groupName, isVisible, onClick }: Props = $props();
const radius = hotspot.radius || 20;
const color = hotspot.color || '#10b981'; // green-500
const icon = hotspot.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="
left: {hotspot.x}%;
top: {hotspot.y}%;
width: {radius * 2}px;
height: {radius * 2}px;
"
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>
<!-- 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};"
>
<span class="text-white font-bold" style="font-size: {radius * 1.2 }px;">
+
<!--
{icon}
-->
</span>
</div>
</button>
<style>
.hotspot-button {
z-index: 10;
pointer-events: auto;
}
.hotspot-button:hover {
filter: brightness(1.1);
}
@keyframes ping {
75%, 100% {
transform: scale(2);
opacity: 0;
}
}
.animate-ping {
animation: ping 2s cubic-bezier(0, 0, 0.2, 1) infinite;
}
</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>

@ -0,0 +1,372 @@
<script lang="ts">
/**
* HotspotPositioner.svelte
*
* Herramienta de desarrollo para posicionar hotspots fácilmente.
* Muestra la imagen y permite hacer click para obtener coordenadas.
*
* USO:
* 1. Importa este componente
* 2. Pásale la URL de la imagen
* 3. Haz click en la imagen donde quieras el hotspot
* 4. Copia las coordenadas que aparecen en consola
*/
interface Props {
imageUrl: string;
viewName?: string;
}
let { imageUrl, viewName = 'Vista' }: Props = $props();
interface HotspotPosition {
x: number;
y: number;
timestamp: number;
}
let positions = $state<HotspotPosition[]>([]);
let showGrid = $state(true);
let radius = $state(20);
let selectedColor = $state('#10b981');
const colors = [
{ name: 'Verde', value: '#10b981' },
{ name: 'Azul', value: '#3b82f6' },
{ name: 'Púrpura', value: '#8b5cf6' },
{ name: 'Naranja', value: '#f59e0b' },
{ name: 'Rosa', value: '#ec4899' },
{ name: 'Rojo', value: '#ef4444' },
];
function handleImageClick(e: MouseEvent) {
const img = e.currentTarget as HTMLImageElement;
const rect = img.getBoundingClientRect();
const x = Math.round(((e.clientX - rect.left) / rect.width) * 100 * 10) / 10;
const y = Math.round(((e.clientY - rect.top) / rect.height) * 100 * 10) / 10;
const position: HotspotPosition = {
x,
y,
timestamp: Date.now()
};
positions.push(position);
console.log(`%c🎯 Nuevo Hotspot`, 'color: #10b981; font-weight: bold; font-size: 14px;');
console.log(`hotspot: {
x: ${x},
y: ${y},
radius: ${radius},
color: '${selectedColor}',
icon: '🎨' // ← Cambia el emoji
}`);
// Copiar al portapapeles
const code = `hotspot: { x: ${x}, y: ${y}, radius: ${radius}, color: '${selectedColor}', icon: '🎨' }`;
navigator.clipboard.writeText(code).then(() => {
console.log('✅ Código copiado al portapapeles');
});
}
function removePosition(index: number) {
positions.splice(index, 1);
}
function clearAll() {
positions = [];
console.clear();
}
function exportCode() {
const code = positions.map((pos, i) => `
// Hotspot ${i + 1}
hotspot: {
x: ${pos.x},
y: ${pos.y},
radius: ${radius},
color: '${selectedColor}',
icon: 'ℹ️'
}`).join(',\n');
console.log('%c📋 Código para DataConfig.ts:', 'color: #3b82f6; font-weight: bold; font-size: 16px;');
console.log(code);
navigator.clipboard.writeText(code).then(() => {
alert('Código copiado al portapapeles ✅');
});
}
</script>
<div class="hotspot-positioner p-6 bg-gray-50 rounded-lg">
<div class="mb-6">
<h2 class="text-2xl font-bold text-gray-800 mb-2">
🎯 Posicionador de Hotspots
</h2>
<p class="text-gray-600">
Haz click en la imagen para añadir hotspots. Las coordenadas se copiarán automáticamente.
</p>
</div>
<!-- Controles -->
<div class="bg-white rounded-lg p-4 mb-6 shadow-sm">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Radio -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Radio: {radius}px
</label>
<input
type="range"
min="12"
max="40"
bind:value={radius}
class="w-full"
/>
</div>
<!-- Color -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Color
</label>
<div class="flex gap-2">
{#each colors as color}
<button
class="w-8 h-8 rounded-full border-2 transition-all"
class:ring-2={selectedColor === color.value}
class:ring-offset-2={selectedColor === color.value}
style="background-color: {color.value}; border-color: {selectedColor === color.value ? color.value : 'transparent'}"
onclick={() => selectedColor = color.value}
title={color.name}
></button>
{/each}
</div>
</div>
<!-- Grid -->
<div class="flex items-end">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
bind:checked={showGrid}
class="w-4 h-4"
/>
<span class="text-sm font-medium text-gray-700">
Mostrar cuadrícula
</span>
</label>
</div>
</div>
<!-- Botones de acción -->
<div class="flex gap-2 mt-4 pt-4 border-t">
<button
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
onclick={exportCode}
disabled={positions.length === 0}
>
📋 Exportar Código
</button>
<button
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
onclick={clearAll}
disabled={positions.length === 0}
>
🗑️ Limpiar Todo
</button>
<div class="flex-1"></div>
<span class="text-sm text-gray-600 flex items-center">
{positions.length} hotspot{positions.length !== 1 ? 's' : ''}
</span>
</div>
</div>
<!-- Imagen con hotspots -->
<div class="relative bg-white rounded-lg shadow-lg overflow-hidden">
<div class="relative">
<img
src={imageUrl}
alt={viewName}
class="w-full h-auto cursor-crosshair"
onclick={handleImageClick}
onerror={(e) => {
const target = e.currentTarget as HTMLImageElement;
target.src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="800" height="600"%3E%3Crect fill="%23ddd" width="800" height="600"/%3E%3Ctext x="50%25" y="50%25" text-anchor="middle" fill="%23999" font-size="24"%3EImagen no encontrada%3C/text%3E%3C/svg%3E';
}}
/>
<!-- Cuadrícula -->
{#if showGrid}
<svg class="absolute inset-0 w-full h-full pointer-events-none" style="opacity: 0.3;">
{#each Array(10) as _, i}
<!-- Líneas verticales -->
<line
x1="{(i + 1) * 10}%"
y1="0%"
x2="{(i + 1) * 10}%"
y2="100%"
stroke="#3b82f6"
stroke-width="1"
stroke-dasharray="5,5"
/>
<!-- Líneas horizontales -->
<line
x1="0%"
y1="{(i + 1) * 10}%"
x2="100%"
y2="{(i + 1) * 10}%"
stroke="#3b82f6"
stroke-width="1"
stroke-dasharray="5,5"
/>
<!-- Etiquetas -->
<text x="{(i + 1) * 10}%" y="2%" fill="#3b82f6" font-size="12" text-anchor="middle">
{(i + 1) * 10}
</text>
<text x="1%" y="{(i + 1) * 10 + 1}%" fill="#3b82f6" font-size="12">
{(i + 1) * 10}
</text>
{/each}
</svg>
{/if}
<!-- Hotspots posicionados -->
{#each positions as position, index}
<div
class="absolute transform -translate-x-1/2 -translate-y-1/2 group"
style="left: {position.x}%; top: {position.y}%;"
>
<!-- Anillo pulsante -->
<div
class="absolute inset-0 rounded-full animate-ping"
style="
width: {radius * 2}px;
height: {radius * 2}px;
margin-left: -{radius}px;
margin-top: -{radius}px;
background-color: {selectedColor};
opacity: 0.4;
"
></div>
<!-- Círculo principal -->
<div
class="absolute rounded-full flex items-center justify-center shadow-lg cursor-pointer hover:scale-110 transition-transform"
style="
width: {radius * 2}px;
height: {radius * 2}px;
margin-left: -{radius}px;
margin-top: -{radius}px;
background-color: {selectedColor};
"
>
<span class="text-white font-bold" style="font-size: {radius * 0.6}px;">
{index + 1}
</span>
</div>
<!-- Botón eliminar (aparece al hover) -->
<button
class="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity shadow-lg hover:bg-red-600 flex items-center justify-center text-xs font-bold"
onclick={() => removePosition(index)}
title="Eliminar"
>
×
</button>
<!-- Tooltip con coordenadas -->
<div class="absolute -top-12 left-1/2 transform -translate-x-1/2 bg-gray-900 text-white px-3 py-1 rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
x: {position.x}, y: {position.y}
<div class="absolute bottom-0 left-1/2 transform -translate-x-1/2 translate-y-full border-4 border-transparent border-t-gray-900"></div>
</div>
</div>
{/each}
</div>
</div>
<!-- Lista de hotspots -->
{#if positions.length > 0}
<div class="mt-6 bg-white rounded-lg shadow-sm p-4">
<h3 class="text-lg font-semibold text-gray-800 mb-3">
Hotspots Creados
</h3>
<div class="space-y-2">
{#each positions as position, index}
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div class="flex items-center gap-3">
<span
class="w-8 h-8 rounded-full flex items-center justify-center text-white font-bold text-sm"
style="background-color: {selectedColor}"
>
{index + 1}
</span>
<code class="text-sm bg-gray-200 px-3 py-1 rounded font-mono">
x: {position.x}, y: {position.y}
</code>
</div>
<button
class="px-3 py-1 bg-red-100 text-red-700 rounded hover:bg-red-200 transition-colors text-sm font-medium"
onclick={() => removePosition(index)}
>
Eliminar
</button>
</div>
{/each}
</div>
</div>
{/if}
<!-- Instrucciones -->
<div class="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 class="font-semibold text-blue-900 mb-2">💡 Instrucciones</h4>
<ul class="text-sm text-blue-800 space-y-1 list-disc list-inside">
<li>Haz click en la imagen donde quieras posicionar un hotspot</li>
<li>Las coordenadas se copiarán automáticamente al portapapeles</li>
<li>Ajusta el radio y color antes de hacer click</li>
<li>Activa la cuadrícula para precisión</li>
<li>Usa "Exportar Código" para obtener todos los hotspots de una vez</li>
<li>Revisa la consola del navegador para ver el código generado</li>
</ul>
</div>
</div>
<style>
@keyframes ping {
75%, 100% {
transform: scale(2);
opacity: 0;
}
}
.animate-ping {
animation: ping 2s cubic-bezier(0, 0, 0.2, 1) infinite;
}
input[type="range"] {
height: 6px;
border-radius: 3px;
background: #e5e7eb;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: #10b981;
cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: #10b981;
cursor: pointer;
border: none;
}
</style>

@ -1,5 +1,5 @@
<script lang="ts">
import {navegationState} from "../data/NavState.svelte";
import {navigationState} from "../data/NavState.svelte";
import type { PageId } from "../data/NavState.svelte";
import {cubicOut } from "svelte/easing";
import { fade, slide } from 'svelte/transition';
@ -23,7 +23,7 @@
}
function handleOnclick (sectionId: PageId) {
navegationState.activePage = sectionId;
navigationState.activePage = sectionId;
mobileMenuOpen = false; // Cierra menú móvil al seleccionar
}
@ -84,13 +84,13 @@
<!-- Menú desktop -->
<div class="hidden md:flex items-center space-x-1 lg:space-x-2">
{#each navegationState.sectionsInfo as section}
{#each navigationState.sectionsInfo as section}
{#if section.menu }
<button
type="button"
class="px-3 py-2 lg:px-4 lg:py-2 rounded-md text-sm lg:text-base font-medium
transition-all duration-200 font-sans hover:shadow-sm
{navegationState.activePage === section.id
{navigationState.activePage === section.id
? 'bg-green-100 text-green-700 border border-green-200 shadow-sm'
: 'text-gray-600 hover:text-gray-900 hover:bg-green-50 cursor-pointer'
}"
@ -150,20 +150,20 @@
>
<div class="px-2 pt-2 pb-4 space-y-1 sm:px-3">
{#each navegationState.sectionsInfo as section}
{#each navigationState.sectionsInfo as section}
{#if section.menu }
<button
type="button"
class="w-full text-left px-4 py-3 rounded-md text-base font-medium
transition-all duration-200 border-l-4
{navegationState.activePage === section.id
{navigationState.activePage === section.id
? 'bg-green-50 text-green-700 border-green-500'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50 border-transparent'
}"
onclick={() => handleOnclick(section.id)}>
<div class="flex items-center justify-between">
<span>{section.mome ?? section.label}</span>
{#if navegationState.activePage === section.id}
{#if navigationState.activePage === section.id}
<svg class="w-5 h-5 text-green-500" stroke="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>

@ -46,11 +46,13 @@
{/each}
<!-- ✅ Mensaje de consulta si la opción seleccionada lo requiere -->
<!--
{#if selectedConsultation?.required}
<div class="col-span-2 p-3 bg-yellow-50 border-l-4 border-yellow-400 text-xs text-yellow-800 rounded">
⚠️ {selectedConsultation.message}
</div>
{/if}
-->
</div>

@ -0,0 +1,69 @@
<script lang="ts">
import { popupImage } from '../data/PopsState.svelte';
let isLoading = $state(true);
$effect(() => {
if (popupImage.imagen) {
isLoading = true;
}
});
function handleImageLoad() { isLoading = false; }
function handleOverlayClick() { popupImage.close(); }
function handleKeydown(e: KeyboardEvent) { if (e.key === 'Escape') popupImage.close(); }
</script>
{#if popupImage.isVisible }
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- Overlay principal -->
<div
class="fixed inset-0 bg-black/80 flex items-center justify-center z-50 transition-opacity duration-300"
role="button"
tabindex="0"
aria-label="Cerrar modal"
onclick={handleOverlayClick}
onkeydown={handleKeydown}
>
<!-- Contenedor del contenido -->
<div
class="bg-white p-6 rounded-xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-auto transform transition-transform duration-300 scale-100"
onclick={(e) => e.stopPropagation()}
role="document"
>
<h2 class="text-2xl font-bold text-center mb-4 text-gray-800">
Opción de {popupImage.texto}
</h2>
{#if isLoading}
<div class="flex justify-center items-center h-[70vh]">
<div class="animate-spin rounded-full h-16 w-16 border-4 border-blue-500 border-t-transparent"></div>
</div>
{/if}
<img
src={popupImage.imagen}
alt="Imagen ampliada"
class="w-full max-h-[70vh] object-contain rounded-lg mb-4 transition-opacity duration-500"
onload={handleImageLoad}
style:opacity={isLoading ? 0 : 1}
/>
<button
type="button"
class="w-full px-4 py-2 bg-azul-s text-white rounded-lg hover:bg-blue-900 focus:ring-4 focus:ring-blue-300 font-medium transition-all duration-200"
onclick={() => popupImage.close()}
>
Cerrar
</button>
</div>
</div>
{/if}

@ -0,0 +1,79 @@
<script lang="ts">
import type { PopupMessageState } from "../data/PopsState.svelte";
import { popupMessage } from "../data/PopsState.svelte";
const handleClose = () => {
popupMessage.close();
};
const handleKeyClose = (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
popupMessage.close();
}
};
</script>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
{#if popupMessage.isVisible }
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm transition-all duration-300"
style="background-color: rgba(0, 0, 0, 0.4);"
class:opacity-0={!popupMessage.isVisible}
class:pointer-events-none={!popupMessage.isVisible}
onclick={handleClose}
onkeydown={handleKeyClose}
role="presentation"
>
<div
class="relative max-w-lg w-full bg-gradient-to-br from-amber-50 to-yellow-50 rounded-2xl shadow-2xl transform transition-all duration-300 overflow-hidden"
class:scale-95={!popupMessage.isVisible}
class:scale-100={popupMessage.isVisible}
role="alert"
onclick={(e) => { e.stopPropagation() }}
>
<!-- Borde decorativo animado -->
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-yellow-400 via-amber-500 to-yellow-400"></div>
<div class="p-6">
<div class="flex items-start gap-4">
<!-- Icono con efecto de resplandor -->
<div class="flex-shrink-0 relative">
<div class="absolute inset-0 bg-yellow-400 rounded-full blur-lg opacity-30 animate-pulse"></div>
<div class="relative bg-gradient-to-br from-yellow-400 to-amber-500 rounded-full p-3">
<svg class="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.3 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
</div>
<div class="flex-1 pt-1">
<h3 class="text-xl font-bold text-gray-900 mb-2">
Advertencia Importante
</h3>
<p class="text-sm text-gray-700 leading-relaxed">
{popupMessage.mensaje}
</p>
</div>
<!-- Botón de cerrar mejorado -->
<button
type="button"
class="flex-shrink-0 w-8 h-8 rounded-full hover:bg-yellow-200/50 flex items-center justify-center transition-all duration-200 hover:scale-110 active:scale-95 text-gray-600 hover:text-gray-900"
aria-label="Cerrar advertencia"
onclick={handleClose}
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- Línea inferior decorativa -->
<div class="h-1 bg-gradient-to-r from-transparent via-yellow-300 to-transparent opacity-50"></div>
</div>
</div>
{/if}

@ -0,0 +1,130 @@
<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';
interface Props {
view?: View;
showHotspots?: boolean;
}
let {
view = appState.currentView,
showHotspots = true
}: Props = $props();
// Estado para controlar la visibilidad de hotspots
let hotspotsVisible = $state(false);
let selectedGroup: OptionGroup | 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)
);
// Handlers
function handleMouseEnter() {
if (showHotspots) {
hotspotsVisible = true;
}
}
function handleMouseLeave() {
hotspotsVisible = false;
}
function handleHotspotClick(group: OptionGroup) {
selectedGroup = group;
isModalOpen = true;
}
function handleCloseModal() {
isModalOpen = false;
selectedGroup = null;
}
</script>
<div
class="relative w-full h-full overflow-hidden"
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
>
<!-- Imagen de la vista -->
<img
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';
}}
/>
<!-- Hotspots superpuestos -->
{#if showHotspots}
<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}
</div>
{/if}
<!-- Indicador de hotspots disponibles (esquina inferior derecha) -->
{#if showHotspots && groupsWithHotspots.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}
class:opacity-0={hotspotsVisible}
class:pointer-events-none={hotspotsVisible}
>
<div class="flex items-center gap-2 text-sm">
<svg class="w-5 h-5 text-green-600 animate-pulse" 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>
<span class="text-gray-700 font-medium whitespace-nowrap">
Pasa el ratón para ver detalles
</span>
</div>
</div>
{/if}
</div>
<!-- Modal -->
{#if selectedGroup}
<HotspotModal
group={selectedGroup}
viewId={view.id}
isOpen={isModalOpen}
onClose={handleCloseModal}
/>
{/if}
<style>
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: .5;
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
</style>

@ -23,10 +23,10 @@ export const dataConfig : DataConfig = {
imgViews64 : '',
// imgOptionsHi: Directorio donde residen las imágenes de las opciones
// en alta resolución
imgOptionsHi : '',
imgOptionsHi : './img/mt/',
// imgOptionsLow: Directorio donde residen las imágenes de las opciones
// en baja resolución
imgOptionsLow : '',
imgOptionsLow : './img/mt/',
// imgGallery: Directorio donde residen las galerías de imágenes
imgGallery : './img/gl/',
imgGalleryLow : './img/gl/lw/',
@ -193,8 +193,8 @@ export const dataConfig : DataConfig = {
sensow: {
value : 'sensow',
name : 'D-Neo SensoWash',
text : '',
image : '',
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.',
image : 'sensow.jpg',
thumb : 'sensow_thumb.jpg',
},
@ -251,15 +251,15 @@ export const dataConfig : DataConfig = {
keyla: {
value : 'keyla',
name : 'Dekton Keyla',
text : '',
image : 'encimeras/keyla.jpg',
text : 'Con un toque de particularidad, este tono se inspira en los mármoles oscuros con vetas claras para los ambientes más especiales.',
image : 'keyla.jpg',
thumb : 'keyla_thumb.png',
},
kairos: {
value : 'kairos',
name : 'Dekton Kairos',
text : '',
image : 'encimeras/kairos.jpg',
text : 'Con sus características únicas, Kairos se asimila a uno de los mármoles más blancos y fríos del mercado, el mármol estatuario. Entre su luminosidad resaltan las finas vetas en gris pálido.',
image : 'kairos.jpg',
thumb : 'kairos_thumb.png',
},
enpe: {
@ -342,6 +342,14 @@ 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',
@ -357,8 +365,17 @@ export const dataConfig : DataConfig = {
warn: 'Este inodoro es suspendido'
},
],
// 🎯 HOTSPOT CONFIGURADO
hotspot: {
x: 34,
y: 85,
radius: 14,
color: '#00a63e', // Rosa
icon: '✨'
}
},
],
},
bano2: {
@ -506,8 +523,16 @@ export const dataConfig : DataConfig = {
id: 'enpe',
amount: 'CON COSTE',
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: '✨'
}
},
],
},

@ -9,6 +9,22 @@ export interface OptionValue {
thumb?: string;
}
// 2. Definición de un Hotspot
export interface Hotspot {
/** Posición X en porcentaje (0-100) */
x: number;
/** Posición Y en porcentaje (0-100) */
y: number;
/** Radio del hotspot en píxeles (opcional, default: 20) */
radius?: number;
/** Color del hotspot (opcional, default: verde) */
color?: string;
/** Icono del hotspot (opcional, default: 'ℹ️') */
icon?: string;
}
// 2. Definición de la referencia dentro de un grupo (ej: { id: 'trentino' })
export interface OptionReference {
id: string;
@ -27,6 +43,8 @@ export interface OptionGroup {
help: string;
default: string;
warn?: string;
/** Hotspot para visualizar esta opción en la vista */
hotspot?: Hotspot;
values: OptionReference[];
}

@ -9,14 +9,24 @@ interface PageInfo {
}
console.log('NAV STORE CARGADO');
// Rune global para la sección activa
let activeSection = $state<PageId>(
window.location.hash?.replace('#', '') as PageId || 'ini'
);
let isOpenGeneral = $state(true);
export const navigationState = {
get isOpenGeneral(): boolean {
return isOpenGeneral;
},
turnOpenGeneral() {
return isOpenGeneral = !isOpenGeneral;
},
export const navegationState = {
get activePage (): PageId {
return activeSection
},

@ -0,0 +1,32 @@
export type PopupMessageState = {
mensaje : string;
} | null ;
let popupMessageState = $state<PopupMessageState>(null);
export const popupMessage = {
get isVisible () : boolean { return popupMessageState !== null; },
get mensaje () : string { return popupMessageState?.mensaje ?? ''},
open : (mensaje : string) => { popupMessageState = { mensaje }; },
close : () => popupMessageState = null,
}
export type PopupImageState = {
texto: string;
imagen: string;
} | null;
let popupImageState = $state<PopupImageState>(null);
export const popupImage = {
get isVisible () : boolean { return popupImageState !== null; },
get texto () : string { return popupImageState?.texto ?? '' },
get imagen() : string { return popupImageState?.imagen ?? ''; },
open : (imagen: string, texto: string) => { popupImageState = { imagen, texto }},
close : () => popupImageState = null,
}

@ -5,6 +5,8 @@
import BtViewSelector from "../cmps/BtViewSelector.svelte";
import PnGroup from "../cmps/PnGroup.svelte";
import BtViewAccordion from "../cmps/BtViewAccordion.svelte";
import {navigationState} from "../data/NavState.svelte";
import ViewWithHotspots from "../cmps/ViewWithHotspots.svelte";
let isLoading = $state(true);
@ -49,6 +51,7 @@
<!-- IMAGEN CENTRAL -->
<!--
<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 grid grid-cols-1 grid-rows-1 items-center justify-items-center">
@ -81,6 +84,19 @@
</div>
</div>
-->
<!-- 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
view={appState.currentView}
showHotspots={true}
/>
</div>
</div>
<!-- ASIDE DERECHA CON BARRA SCROOL -->
@ -94,6 +110,8 @@
<BtViewAccordion
isOpen={navigationState.isOpenGeneral}
openHandler={navigationState.turnOpenGeneral}
title={dataConfig.texts.title_options_general || 'PAVIMENTO GENERAL DE LA VIVIENDA'}
desc={dataConfig.texts.text_options_general || 'El pavimento de la vivienda es el mismo en todas las estancias, por lo que la opción elegida será la que se instale también en el resto de la vivienda.'}
>

Loading…
Cancel
Save

Powered by TurnKey Linux.