dev 8 months ago
parent 7247c3fa87
commit e4cb3145fa

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

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.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

@ -0,0 +1,25 @@
{
"name": "EBROSA · Quaddro2",
"short_name": "Quaddro2",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"icons": [
{
"src": "/favicon-64.png",
"sizes": "64x64",
"type": "image/png"
},
{
"src": "/favicon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/favicon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

@ -21,6 +21,8 @@
isSelected?: boolean;
}>();
const textWithCost = dataConfig.texts.with_cost;
// Forzar que estos sean reactivos
const optionValue = $derived(appState.getOptionValue(optionRef.id));
const consultation = $derived(appState.requiresConsultation(viewId, groupId, optionRef.id));
@ -46,22 +48,13 @@
}
// Formatear precio
function formatearPrecio(cantidad: number): string {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(cantidad);
}
// Obtener el precio desde optionRef.amount
const hasPrice = $derived(
optionRef.amount !== undefined &&
optionRef.amount !== 'consultar' &&
optionRef.amount === textWithCost || (
typeof optionRef.amount === 'number' &&
optionRef.amount > 0
optionRef.amount > 0)
);
@ -146,7 +139,7 @@
{#if hasPrice}
<div class="text-center mt-1">
<span class="w-full inline-block bg-gray-200 px-2 py-1 text-gray-800 text-xm font-semibold rounded">
+ {formatPrice(optionRef.amount as number)}
+ {formatPrice(optionRef.amount)}
</span>
</div>
{/if}

@ -0,0 +1,165 @@
<script lang="ts">
import { appState } from "../data/AppState.svelte";
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
let {
optionRef,
groupId,
viewId,
isDisabled = false,
isSelected = false
} = $props<{
optionRef: OptionReference;
groupId: string;
viewId: string;
isDisabled?: boolean;
isSelected?: boolean;
}>();
const textWithCost = dataConfig.texts.with_cost;
// Forzar que estos sean reactivos
const optionValue = $derived(appState.getOptionValue(optionRef.id));
const consultation = $derived(appState.requiresConsultation(viewId, groupId, optionRef.id));
const imgOptSrc = $derived(imgPath + optionValue.thumb)
// Añadir log solo cuando isSelected cambia
$effect(() => {
if (isSelected) {
console.log(`✓ ${optionRef.id} SELECCIONADO en ${viewId}/${groupId}`);
}
});
function handleClick() {
if (isSelected) {
return;
}
console.log('🔵 CLICK:', { viewId, groupId, optionId: optionRef.id });
if (consultation.message) {
popupMessage.open(consultation.message);
}
appState.setOption(viewId, groupId, optionRef.id);
}
// Obtener el precio desde optionRef.amount
const hasPrice = $derived(
optionRef.amount !== undefined &&
optionRef.amount === textWithCost || (
typeof optionRef.amount === 'number' &&
optionRef.amount > 0)
);
</script>
<button
class="relative border-2 rounded-lg p-3 text-left transition-all hover:shadow-md flex flex-col items-center"
class:border-green-600={isSelected}
class:ring-offset-1={isSelected}
class:ring-green-600={isSelected}
class:border-gray-200={!isSelected}
class:cursor-not-allowed={isDisabled}
class:cursor-pointer={!isDisabled && !isSelected}
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">
✓
</div>
{/if}
<!-- Imagen -->
{#if optionValue?.thumb}
<img
class="h-20 object-cover mb-2"
src={imgOptSrc}
alt={optionValue.name}
/>
{:else}
<!--
<div class="w-full h-20 bg-gray-100 rounded mb-2 flex items-center justify-center">
<span class="text-gray-400 text-xs">Sin imagen</span>
</div>
-->
{/if}
<!-- Nombre -->
<div class="font-medium text-xs mb-1 text-center">
{optionValue?.name || optionRef.id}
</div>
<!-- ✅ Badge "No disponible" para opciones deshabilitadas -->
{#if isDisabled}
<div class="absolute -top-2 -right-1 w-5 h-5 rounded-full flex items-center justify-center text-white text-s font-bold shadow-md">
🚫
</div>
<!--
<div class="absolute top-1 right-1 bg-red-500 text-white px-2 py-0.5 rounded-full text-xs font-semibold">
🚫
</div>
-->
{/if}
<!-- Texto adicional -->
<!--
{#if optionValue?.text}
<div class="text-xs text-gray-600 mb-1">
{optionValue.text}
</div>
{/if}
-->
<!-- Precio -->
{#if hasPrice}
<div class="text-center mt-1">
<span class="w-full inline-block bg-gray-200 px-2 py-1 text-gray-800 text-xm font-semibold rounded">
+ {formatPrice(optionRef.amount)}
</span>
</div>
{/if}
<!-- Badge consultar -->
<!--
{#if consultation.required}
<span class="inline-block px-2 py-0.5 bg-yellow-100 text-yellow-800 text-xs rounded mt-1">
Consultar
</span>
{/if}
-->
</button>
<!-- Advertencia -->
<!--
{#if consultation.message && isSelected}
<div class="col-span-2 p-3 bg-yellow-50 border-l-4 border-yellow-400 text-xs text-yellow-800 rounded">
⚠️ {consultation.message}
</div>
{/if}
-->

@ -1,23 +1,27 @@
<script lang="ts">
import { appState } from '../data/AppState.svelte';
import { dataConfig } from '../data/DataConfig';
import { fade } from 'svelte/transition';
import HotspotMarker from './HotspotMarker.svelte';
import HotspotModal from './HotspotModal.svelte';
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,
groupName = '',
showHotspots = true
}: Props = $props();
// Estado de carga
let isLoading = $state(true);
let imageUrl = $state('');
let hasError = $state(false);
let transitionKey = $state('');
// Estado para controlar la visibilidad de hotspots
let hotspotsVisible = $state(false);
let selectedHotspot: Hotspot | null = $state(null);
@ -29,13 +33,36 @@
// Hotspots de la vista
const viewHotspots = $derived(view.hotspots || []);
$effect (() => {
console.log('viewHotspots', viewHotspots);
})
// Effect para cargar la imagen con transición
$effect(() => {
const targetImg = currentFileName;
// Si ya estamos mostrando esta imagen, no hacemos nada
if (imageUrl === targetImg) return;
isLoading = true;
hasError = false;
const img = new Image();
img.onload = () => {
imageUrl = targetImg;
transitionKey = targetImg;
isLoading = false;
};
img.onerror = () => {
console.error(`Error cargando: ${targetImg}`);
isLoading = false;
hasError = true;
};
img.src = targetImg;
});
// Handlers
function handleMouseEnter() {
if (showHotspots) {
if (showHotspots && !isLoading) {
hotspotsVisible = true;
}
}
@ -64,13 +91,6 @@
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
@ -80,33 +100,68 @@
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
>
<!-- Imagen de la vista -->
<img
src={currentFileName}
alt={view.name || view.id}
class="w-full h-full object-contain"
onerror={handleImageError}
/>
<!-- Hotspots superpuestos -->
{#if showHotspots && viewHotspots.length > 0}
<div class="absolute inset-0 pointer-events-none">
{#each viewHotspots as hotspot (hotspot.groupId + hotspot.groupType)}
<div class="pointer-events-auto">
<HotspotMarker
hotspot={hotspot}
groupName={getGroupName(hotspot)}
isVisible={hotspotsVisible}
onClick={() => handleHotspotClick(hotspot)}
<!-- Grid para superponer estados -->
<div class="relative w-full h-full grid grid-cols-1 grid-rows-1 items-start justify-items-center">
<!-- Loading spinner -->
{#if isLoading}
<div
class="col-start-1 row-start-1 z-20 flex items-center justify-center p-10 bg-white/50 backdrop-blur-sm rounded-xl"
transition:fade={{ duration: 200 }}
>
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
<p class="ml-4 text-gray-600 font-medium">
{dataConfig.texts.loading_view || 'Cargando...'}
</p>
</div>
{/if}
<!-- Error de carga -->
{#if hasError}
<div
class="col-start-1 row-start-1 z-20 text-center p-10 bg-red-50 rounded-lg"
transition:fade={{ duration: 200 }}
>
<p class="text-red-600 text-lg mb-2">
{dataConfig.texts.error_loading_image || 'Error cargando imagen'}
</p>
</div>
{:else if imageUrl}
<!-- Contenedor de imagen con hotspots -->
{#key transitionKey}
<div
class="col-start-1 row-start-1 relative inline-block"
transition:fade={{ duration: 500 }}
>
<!-- Imagen de la vista -->
<img
src={imageUrl}
alt={view.name || view.id}
class="w-full h-auto max-h-[90vh] object-contain shadow-sm"
/>
<!-- Hotspots superpuestos directamente sobre la imagen -->
{#if showHotspots && viewHotspots.length > 0 && !isLoading && !hasError}
<div class="absolute inset-0 pointer-events-none">
{#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}
</div>
{/each}
</div>
{/if}
{/key}
{/if}
</div>
<!-- Indicador de hotspots disponibles (esquina inferior derecha) -->
{#if showHotspots && viewHotspots.length > 0}
<!-- Indicador de hotspots disponibles -->
{#if showHotspots && viewHotspots.length > 0 && !isLoading && !hasError}
<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}
@ -148,4 +203,4 @@
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
</style>
</style>

@ -1,7 +1,7 @@
import type { DataConfig } from './DataConfigTypes';
const textWithCost = "CONSULTAR";
export const dataConfig : DataConfig = {
name : 'OpenBox',
@ -44,6 +44,7 @@ export const dataConfig : DataConfig = {
text_options_view : 'Seleccione las opciones de la estancia',
loading_view : 'Cargando vista de estancia',
cview_alt_img : 'Vista de la estancia',
with_cost : textWithCost,
},
images : {
@ -194,8 +195,10 @@ export const dataConfig : DataConfig = {
dneo: {
value : 'dneo',
name : 'D-Neo',
text : '',
image : '',
text : 'Elegido por su diseño vanguardista de Bertrand Lejoly que transforma la estética del baño.' +
'Gracias a su tecnología Rimless y fácil limpieza, garantiza la máxima higiene y eficiencia.' +
'Una inversión duradera en confort, estilo sostenible y calidad alemana para el hogar moderno.',
image : 'dneo.jpg',
thumb : 'dneo_thumb.png',
},
sensow: {
@ -212,47 +215,56 @@ export const dataConfig : DataConfig = {
mnaf: {
value : 'mnaf',
name : 'Muebles altos negros y muebles bajos arena fina',
text : '',
image : 'muebles/mnaf.jpg',
text : 'La combinación de muebles altos negros con muebles bajos en tono arena fina crea una cocina sofisticada y equilibrada. El negro aporta profundidad, carácter y un punto arquitectónico que enmarca el espacio, mientras que el arena suaviza el conjunto, aporta luz y evita que el ambiente resulte pesado.\n' +
'\n' +
'Este contraste genera una estética contemporánea y elegante, con una base cálida y natural que hace la cocina más acogedora. El resultado es un espacio con personalidad, donde la fuerza y la calma conviven en perfecta proporción.',
image : 'mnaf.jpg',
thumb : 'mnaf_thumb.png',
},
mvaf: {
value : 'mvaf',
name : 'Muebles altos negros con vitrina y muebles bajos arena fina',
text : '',
image : 'muebles/mvaf.jpg',
text : 'La combinación de muebles altos negros con una vitrina central introduce un punto focal elegante y ligero en la composición. El negro aporta estructura y sofisticación, mientras que la vitrina rompe la continuidad con un gesto más abierto y decorativo, aligerando visualmente el conjunto.\n' +
'\n' +
'Los muebles bajos en tono arena fina equilibran la intensidad superior, aportando calidez y luminosidad. El resultado es una cocina contemporánea, con carácter arquitectónico y un detalle central que suma personalidad y refinamiento.',
image : 'mvaf.jpg',
thumb : 'mvaf_thumb.png',
},
mbmb: {
value : 'mbmb',
name : 'Muebles blancos',
text : '',
image : 'muebles/mbmb.jpg',
text : 'Una cocina con muebles altos y bajos en blanco apuesta por la pureza y la luminosidad como protagonistas. La uniformidad cromática amplía visualmente el espacio y crea una sensación de orden, limpieza y serenidad.\n' +
'\n' +
'El blanco permite que la luz fluya sin obstáculos, potenciando una estética atemporal y versátil. Es una base perfecta para incorporar texturas, encimeras o detalles que aporten carácter sin perder la armonía del conjunto.',
image : 'mbmb.jpg',
thumb : 'mbmb_thumb.png',
},
mbpe: {
value : 'mnaf',
value : 'mbpe',
name : 'Muebles Personalizados',
text : '',
image : undefined,
text : 'Al seleccionar esta opción, debe ponerse en contacto con Studio 56 a través de los datos que EBROSA les ha facilitado, siendo ellos quienes les ofrecerán una amplia variedad de acabados y calidades, así como el coste final de su cocina.',
image : 'mbpe.jpg',
thumb : 'mob_per_thumb.png',
},
// Disposicion cocina
puerta : {
value : 'puerta',
name : 'Cocina según proyecto',
text : '',
},
abierta : {
value : 'abierta',
name : 'Cocina integrada con el salón',
text : '',
image : undefined,
thumb : undefined,
},
cerrada : {
value : 'cerrada',
name : 'Cocina independiente',
text : '',
image : undefined,
thumb : undefined,
},
// Encimeras
@ -273,8 +285,8 @@ export const dataConfig : DataConfig = {
enpe: {
value : 'per',
name : 'Encimera personalizada',
text : '',
image : undefined,
text : 'Al seleccionar esta opción, debe ponerse en contacto con Studio 56 a través de los datos que EBROSA les ha facilitado, siendo ellos quienes les ofrecerán una amplia variedad de acabados y calidades, así como el coste final de su cocina.',
image : 'enpe.jpg',
thumb : 'enc_per_thumb.png',
},
@ -370,7 +382,7 @@ export const dataConfig : DataConfig = {
{ id: 'dneo' },
{
id: 'sensow',
amount: 2660,
amount: 2269.40,
warn: 'Este inodoro es suspendido'
},
],
@ -387,13 +399,13 @@ export const dataConfig : DataConfig = {
{
groupId: 'alicatado',
groupType: 'view',
x: 65,
x: 75,
y: 55,
},
{
groupId: 'inodoro',
groupType: 'view',
x: 34,
x: 20,
y: 85,
},
],
@ -481,7 +493,6 @@ export const dataConfig : DataConfig = {
{ id: 'ducha' },
{
id: 'sinducha',
amount: 'consultar',
warn: 'La opción de eliminar el plato de ducha o la bañera del aseo solo está disponible en determinadas tipologías de vivienda, consulte la suya.',
},
],
@ -528,8 +539,11 @@ export const dataConfig : DataConfig = {
name: 'Disposición',
text: '',
help: '',
default: 'cerrada',
default: 'puerta',
values : [
{
id : 'puerta'
},
{
id: 'cerrada',
warn: 'La opción de cocina independizada del salón solo está disponible en determinadas tipologías de vivienda, consulte la suya.'
@ -548,11 +562,14 @@ export const dataConfig : DataConfig = {
default: 'mnaf',
values : [
{ id: 'mnaf' },
{ id: 'mvaf'},
{
id: 'mvaf',
amount: 827.31
},
{ id: 'mbmb'},
{
id: 'mbpe',
amount: 'CON COSTE',
amount: textWithCost,
warn: 'Coste según tipología y personalización, se contratará con Studio56',
}
],
@ -574,7 +591,7 @@ export const dataConfig : DataConfig = {
},
{
id: 'enpe',
amount: 'CON COSTE',
amount: textWithCost,
warn: 'Coste según tipología y personalización, se contratará con Studio56',
},
],
@ -583,16 +600,22 @@ export const dataConfig : DataConfig = {
hotspots: [
// 🎯 HOTSPOT CONFIGURADO
{
groupId: 'suelo',
groupType: 'general',
x: 55,
y: 85,
groupId: 'suelo',
x: 45,
y: 90,
},
{
groupType: 'view',
groupId: 'encimera',
x: 40,
y: 63,
x: 32,
y: 60
},
{
groupType: 'view',
groupId: 'mobiliario',
x: 30,
y: 30,
},
]
},

Loading…
Cancel
Save

Powered by TurnKey Linux.