master
parent
5e40546c1a
commit
b99a093b97
@ -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,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>
|
||||
@ -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,
|
||||
}
|
||||
Loading…
Reference in new issue