dev 8 months ago
parent 1d3a19cebf
commit a856ed7de1

@ -7,9 +7,13 @@
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";
function handleViewClick(viewId: string) {
appState.setView(viewId);
navegationState.activePage = 'cfg'; // Navegar al configurador
}
</script>
@ -27,7 +31,7 @@
{#if navegationState.activePage === 'cfg'}
<VwConfig/>
{:else if navegationState.activePage === 'sel'}
<VwSelection />
<VwSelection onViewClick={handleViewClick}/>
{:else if navegationState.activePage === 'gal'}
<VwGallery />
{:else}

@ -1,14 +1,56 @@
<script lang="ts">
import {dataConfig} from "../data/DataConfig.js";
import type {DownloadState} from "../data/DownloadTypes";
import PoDownload from "../cmps/PoDownload.svelte";
async function generatePdf () {}
const stepTimeout = 1000;
const logoProject = dataConfig.sources.logos + dataConfig.logos.project;
const logoCompany = dataConfig.sources.logos + dataConfig.logos.company;
console.log(logoProject, logoCompany);
let downloadState : DownloadState = 'idle';
let isVisible = false;
let message = '';
// Función auxiliar para esperar
const wait = (ms : number ) => new Promise(resolve => setTimeout(resolve, ms));
async function generatePdf () {
isVisible = true;
downloadState = 'creating';
try {
await wait(3000);
} catch (err : any) {
downloadState = 'error';
message = err?.message;
console.log(err);
} finally {
await wait(1000);
isVisible = false;
downloadState = 'idle';
}
}
</script>
<button type="button"
class="px-3 lg:px-4 py-2 rounded-md text-base font-medium transition-colors duration-200 text-white bg-green-900 hover:bg-green-700 cursor-pointer transition"
class="px-3 lg:px-4 py-2 rounded-md text-base font-medium transition-colors duration-200 text-white bg-green-900 hover:bg-green-700 cursor-pointer"
onclick={() => generatePdf()}>
Descargar Selección
</button>
<PoDownload
{isVisible}
{message}
{downloadState}
logoCompany={logoCompany}
logoProject={logoProject}
text={'testo'}
/>

@ -1,9 +1,10 @@
<script lang="ts">
import {navegationState} from "../data/NavState.svelte";
import type { PageId } from "../data/NavState.svelte";
import {cubicOut } from "svelte/easing";
import { fade, slide } from 'svelte/transition';
import BtDownload from "./BtDownload.svelte";
import {navegationState} from "../data/NavState.svelte";
import type { PageId } from "../data/NavState.svelte";
import {cubicOut } from "svelte/easing";
import { fade, slide } from 'svelte/transition';
import { onMount } from 'svelte';
import BtDownload from "./BtDownload.svelte";
const info = {
@ -26,6 +27,22 @@ import BtDownload from "./BtDownload.svelte";
mobileMenuOpen = false; // Cierra menú móvil al seleccionar
}
// Cerrar menú móvil automáticamente en desktop
onMount(() => {
const handleResize = () => {
if (window.innerWidth >= 768) { // md breakpoint (768px)
mobileMenuOpen = false;
}
};
window.addEventListener('resize', handleResize);
// Cleanup
return () => {
window.removeEventListener('resize', handleResize);
};
});
</script>
@ -86,36 +103,35 @@ import BtDownload from "./BtDownload.svelte";
</div>
<!-- Botón hamburguesa - Mejoras visuales -->
<div class="md:hidden flex items-center"></div>
<button
type="button"
class="inline-flex items-center justify-center p-2 rounded-md text-gray-400
hover:text-gray-600 hover:bg-gray-100 focus:outline-none focus:ring-1 z-400
<!-- Botón hamburguesa - Solo visible en móvil -->
<div class="md:hidden flex items-center">
<button
type="button"
class="inline-flex items-center justify-center p-2 rounded-md text-gray-400
hover:text-gray-600 hover:bg-gray-100 focus:outline-none focus:ring-1
focus:ring-inset focus:ring-green-500 transition-colors duration-200
{mobileMenuOpen ? 'bg-gray-100 text-gray-600' : ''}"
onclick={toggleMobileMenu}
aria-expanded={mobileMenuOpen}
aria-label={mobileMenuOpen ? "Cerrar menú" : "Abrir menú"}>
{#if !mobileMenuOpen}
<!-- Icono hamburguesa -->
<svg class="h-6 w-6" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
{:else}
<!-- Icono X -->
<svg class="h-6 w-6" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
{/if}
</button>
onclick={toggleMobileMenu}
aria-expanded={mobileMenuOpen}
aria-label={mobileMenuOpen ? "Cerrar menú" : "Abrir menú"}>
{#if !mobileMenuOpen}
<!-- Icono hamburguesa -->
<svg class="h-6 w-6" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
{:else}
<!-- Icono X -->
<svg class="h-6 w-6" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
{/if}
</button>
</div>
</div>
</div>
<!-- Menú móvil mejorado -->
<!-- Menú móvil con slide funcionando -->
{#if mobileMenuOpen}
<div
class="fixed inset-0 z-2 md:hidden bg-white bg-opacity-1"

@ -0,0 +1,89 @@
<script lang="ts">
import { fade, scale } from 'svelte/transition';
import type {DownloadState} from "../data/DownloadTypes";
import {cubicOut} from "svelte/easing";
let {
downloadState,
isVisible ,
message,
logoProject,
logoCompany,
text,
} = $props< {
downloadState: DownloadState;
isVisible : boolean;
message : string ;
logoProject : string ;
logoCompany? : string ;
text : string
}> ();
let stateText = $derived (
downloadState === 'creating' ? 'Creando documento PDF' :
downloadState === 'downloading' ? 'Descargando documento' :
downloadState === 'success' ? '✅ PDF Generado correctamente' :
''
);
let showSpin = $derived (
downloadState === 'creating' || downloadState === 'downloading'
);
</script>
{#if isVisible}
<!-- Fondo oscuro con fundido -->
<div
transition:fade={{ duration: 200, easing: cubicOut }}
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
>
<!-- Contenedor del popup con entrada tipo zoom -->
<div
transition:scale={{ duration: 250, easing: cubicOut }}
class="bg-white rounded-2xl px-10 py-8 w-[420px] text-center shadow-2xl flex flex-col items-center space-y-6 "
>
<!-- Bloque superior: logos y título -->
<div class="flex flex-col leading-none">
{#if logoCompany}
<img src={logoCompany} alt="logo empresa" class="w-40 block leading-none" />
{/if}
<img src={logoProject} alt="logo proyecto" class="w-92 block" />
<h2 class="text-2xl font-semibold text-gray-600 tracking-wide leading-tight mt-1">
{text}
</h2>
</div>
<!-- Contenido del estado -->
{#if downloadState !== 'error' && downloadState !== 'idle' }
<div class="flex items-center space-x-3 mt-4 w-full">
<!-- Contenedor para reservar espacio del spinner -->
<div class="w-10 h-10 flex-shrink-0">
{#if showSpin}
<div class="w-10 h-10 border-4 border-gray-200 border-t-primary-700 border-t-opacity-75 rounded-full animate-spin"
></div>
{/if}
</div>
<!-- Texto -->
<div class="text-primary-700 font-medium text-lg">
{stateText}
</div>
</div>
{:else}
<div class="text-red-600 text-lg font-semibold mt-4">
❌ Error al generar PDF
</div>
<p class="text-gray-600 text-sm mt-2 max-w-xs">{message}</p>
{/if}
</div>
</div>
{/if}

@ -0,0 +1,170 @@
<script lang="ts">
import { appState } from '../data/AppState.svelte';
import { dataConfig } from '../data/DataConfig';
import type { View } from '../data/DataConfigTypes';
// Props
interface Props {
view: View;
onClick?: (viewId: string) => void;
}
let { view, onClick }: Props = $props();
// Función para obtener la URL de la imagen de la vista
function getViewImageUrl(viewId: string): string {
const parts: string[] = [viewId];
// Agregar opciones generales
Object.keys(dataConfig.options.general).forEach(genKey => {
const value = appState.selection.general[genKey];
if (value) parts.push(value);
});
// Agregar opciones específicas de la vista
const viewConfig = dataConfig.options.views[viewId];
if (viewConfig?.options) {
viewConfig.options.forEach((group) => {
const value = appState.selection.views[viewId]?.[group.id];
if (value) parts.push(value);
});
}
return appState.imgSrc + `${parts.join('_')}.jpg`;
}
// Función para calcular el total de la vista
function getViewTotal(): number {
let total = 0;
for (const group of view.options) {
const currentOptionId = appState.getCurrentOption(view.id, group.id);
if (currentOptionId) {
const option = group.values.find(v => v.id === currentOptionId);
if (option?.amount && typeof option.amount === 'number') {
total += option.amount;
}
}
}
return total;
}
// Función para formatear precio
function formatearPrecio(cantidad: number): string {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(cantidad);
}
// Verificar si hay incidencias en esta vista
function hasIncidencias(): boolean {
for (const group of view.options) {
const currentOptionId = appState.getCurrentOption(view.id, group.id);
if (currentOptionId) {
const consultaInfo = appState.requiresConsultation(view.id, group.id, currentOptionId);
if (consultaInfo.required) return true;
}
}
return false;
}
// Handler de click
function handleClick() {
if (onClick) {
onClick(view.id);
} else {
appState.setView(view.id);
}
}
// Valores derivados reactivos
let imageUrl = $derived(getViewImageUrl(view.id));
let total = $derived(getViewTotal());
let hasWarnings = $derived(hasIncidencias());
</script>
<div
class="bg-white rounded-xl border border-gray-300 shadow-sm overflow-hidden hover:shadow-lg transition-all duration-200 cursor-pointer group"
class:border-red-300={hasWarnings}
role="button"
tabindex="0"
onclick={handleClick}
onkeydown={(e) => e.key === 'Enter' && handleClick()}
>
<!-- Imagen de la vista -->
<div class="relative h-48 bg-gray-100 overflow-hidden">
<img
src={imageUrl}
alt={view.name || view.id}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
<!-- Indicador de incidencias -->
{#if hasWarnings}
<div class="absolute top-2 right-2 bg-red-500 text-white px-2 py-1 rounded-full text-xs font-semibold shadow-lg">
⚠️ Revisar
</div>
{/if}
</div>
<!-- Título de la vista -->
<div class="bg-green-800 text-white p-3">
<h3 class="text-lg font-semibold">
{view.name || view.id}
</h3>
</div>
<!-- Lista de selecciones -->
<div class="p-4 space-y-2 text-left min-h-[180px]">
{#if view.options.length > 0}
{#each view.options as group}
{@const currentOptionId = appState.getCurrentOption(view.id, group.id)}
{@const optionValue = currentOptionId ? appState.getOptionValue(currentOptionId) : null}
{@const requiresConsult = currentOptionId ? appState.requiresConsultation(view.id, group.id, currentOptionId) : { required: false }}
<div class="flex flex-col">
<span class="text-xs text-gray-500 uppercase tracking-wide">
{group.name || group.id}
</span>
<div class="flex items-center gap-1">
<span class="text-sm font-medium text-gray-800">
{optionValue?.name || currentOptionId || 'No seleccionado'}
</span>
{#if requiresConsult.required}
<span class="text-xs text-red-600" title={requiresConsult.message}>⚠️</span>
{/if}
</div>
</div>
{/each}
{:else}
<div class="flex items-center justify-center h-full">
<p class="text-sm text-gray-500 italic">Sin opciones específicas</p>
</div>
{/if}
</div>
<!-- Total de la vista (si hay precios) -->
{#if total > 0}
<div class="px-4 pb-4">
<div class="pt-3 border-t border-gray-200">
<div class="flex justify-between items-center">
<span class="text-sm font-semibold text-gray-700">Extras:</span>
<span class="text-base font-bold text-green-700">
{formatearPrecio(total)}
</span>
</div>
</div>
</div>
{/if}
</div>
<style>
/* Estilos adicionales si son necesarios */
</style>

@ -9,7 +9,7 @@ export const dataConfig : DataConfig = {
// sources: directorios de los diferentes elementos: imágenes, textos, etc
sources: {
// logos: Directorio donde se ubican los logos del proyecto
logos : '',
logos : './img/lg/',
// images
images : './img/',
// imgViewsHi: Directorio donde residen las imágenes de las vistas de
@ -50,6 +50,12 @@ export const dataConfig : DataConfig = {
home : 'edificio_openbox.jpeg',
},
logos : {
company : 'logo_ebrosa_living.png',
project : 'logo_openbox.png',
},
gallery: [
{ img: '1.jpg', thumb:'1.jpg' },
{ img: '2.jpg', thumb:'2.jpg' },
@ -200,7 +206,7 @@ export const dataConfig : DataConfig = {
name : 'Muebles altos negros y muebles bajos arena fina',
text : '',
image : 'muebles/mnaf.jpg',
thumb : './img/mt/mnaf_thumb.png',
thumb : 'mnaf_thumb.png',
},
mvaf: {
value : 'mvaf',

@ -87,6 +87,7 @@ export interface DataConfig {
texts: Record<string, string>;
images: Record<string,string>;
logos : Record<string, string>;
gallery: ImageGallery[];
options: {

@ -0,0 +1,3 @@
// DownloadTypes.ts
export type DownloadState = 'idle' | 'creating' | 'downloading' | 'success' | 'error'

@ -1,7 +1,270 @@
<script lang="ts">
import { appState } from '../data/AppState.svelte';
import { dataConfig } from '../data/DataConfig';
import PnView from '../cmps/PnView.svelte';
import type { View } from '../data/DataConfigTypes';
// Props opcionales
interface Props {
onViewClick?: (viewId: string) => void;
showGeneralOptions?: boolean;
}
let {
onViewClick,
showGeneralOptions = true
}: Props = $props();
// Tipos para incidencias
interface Incidencia {
viewId: string;
viewName: string;
groupId: string;
groupName: string;
optionId: string;
optionName: string;
mensaje: string;
}
<div>
Soon ...
// Función para obtener las incidencias
function getIncidencias(): Record<string, Incidencia[]> {
const incidenciasPorView: Record<string, Incidencia[]> = {};
for (const viewId of appState.getAllViewIds()) {
const view = dataConfig.options.views[viewId];
for (const group of view.options) {
const currentOptionId = appState.getCurrentOption(viewId, group.id);
if (currentOptionId) {
const consultaInfo = appState.requiresConsultation(viewId, group.id, currentOptionId);
if (consultaInfo.required) {
if (!incidenciasPorView[viewId]) {
incidenciasPorView[viewId] = [];
}
const optionValue = appState.getOptionValue(currentOptionId);
incidenciasPorView[viewId].push({
viewId,
viewName: view.name || view.id,
groupId: group.id,
groupName: group.name || group.id,
optionId: currentOptionId,
optionName: optionValue?.name || currentOptionId,
mensaje: consultaInfo.message || 'Esta opción requiere consulta'
});
}
}
}
}
return incidenciasPorView;
}
// Función para calcular el total de una vista
function getTotalView(viewId: string): number {
let total = 0;
const view = dataConfig.options.views[viewId];
for (const group of view.options) {
const currentOptionId = appState.getCurrentOption(viewId, group.id);
if (currentOptionId) {
const option = group.values.find(v => v.id === currentOptionId);
if (option?.amount && typeof option.amount === 'number') {
total += option.amount;
}
}
}
return total;
}
// Función para calcular el total general
function getTotalGeneral(): number {
let total = 0;
for (const viewId of appState.getAllViewIds()) {
total += getTotalView(viewId);
}
return total;
}
// Formatear precio
function formatearPrecio(cantidad: number): string {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(cantidad);
}
// Handler para click en vista
function handleViewClick(viewId: string) {
if (onViewClick) {
onViewClick(viewId);
} else {
appState.setView(viewId);
}
}
// Valores derivados reactivos
let incidencias = $derived(getIncidencias());
let totalGeneral = $derived(getTotalGeneral());
let hasIncidencias = $derived(Object.keys(incidencias).length > 0);
</script>
<div class="mx-auto text-center bg-white bg-cover bg-center h-full overflow-y-auto">
<div class="max-w-7xl w-full mx-auto pt-4 pb-8">
<!-- TÍTULO PRINCIPAL -->
<div class="p-4 sm:p-6">
<h1 class="text-3xl font-bold text-gray-800 mb-2">Resumen de Configuración</h1>
<p class="text-gray-600">Revisa y modifica tu selección de acabados</p>
</div>
<!-- GRID DE VISTAS Y TOTALES -->
<div class="p-4 sm:p-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- FICHAS DE VISTAS -->
{#each appState.getAllViews() as view}
<PnView {view} onClick={handleViewClick} />
{/each}
<!-- INCIDENCIAS -->
{#if hasIncidencias}
<div class="col-span-1 md:col-span-2 lg:col-span-3">
<div class="bg-white rounded-xl border border-red-200 shadow-sm overflow-hidden">
<div class="p-6 text-left">
<h2 class="text-lg font-bold text-red-700 mb-4 flex items-center gap-2">
<span class="text-2xl">⚠️</span>
<span>Opciones que requieren consulta</span>
</h2>
{#each Object.entries(incidencias) as [viewId, listaInc]}
<div
class="mb-4 last:mb-0 border border-red-200 rounded-lg p-4 cursor-pointer hover:shadow-md hover:bg-red-50 transition-all duration-200"
role="button"
tabindex="0"
title="Volver a configurar esta estancia"
onclick={() => handleViewClick(viewId)}
onkeydown={(e) => e.key === 'Enter' && handleViewClick(viewId)}
>
<h3 class="font-semibold text-red-800 mb-3 flex items-center justify-between">
<span>{listaInc[0].viewName}</span>
<span class="text-xs bg-red-100 px-2 py-1 rounded-full">
{listaInc.length} {listaInc.length === 1 ? 'opción' : 'opciones'}
</span>
</h3>
<div class="space-y-2">
{#each listaInc as inc}
<div class="bg-white p-3 rounded border border-red-100">
<div class="font-medium text-sm text-gray-800 mb-1">
{inc.groupName}: <span class="text-red-700">{inc.optionName}</span>
</div>
<div class="text-xs text-gray-600">{inc.mensaje}</div>
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>
{/if}
<!-- TOTALES -->
{#if totalGeneral > 0}
<div class="col-span-1 {hasIncidencias ? '' : 'lg:col-start-4'}">
<div class="bg-white rounded-xl border border-gray-300 shadow-sm overflow-hidden flex flex-col sticky top-4">
<div class="bg-green-800 text-white p-3">
<h3 class="text-sm font-semibold tracking-wider uppercase">
Total Extras
</h3>
</div>
<div class="p-4 space-y-3 flex-grow">
{#each appState.getAllViews() as view}
{@const total = getTotalView(view.id)}
{#if total > 0}
<div class="flex justify-between items-center text-gray-700">
<span class="text-sm font-medium">{view.name || view.id}</span>
<span class="text-sm font-semibold">{formatearPrecio(total)}</span>
</div>
{/if}
{/each}
</div>
<div class="p-4 border-t border-gray-200 bg-amber-50">
<div class="flex justify-between items-center">
<span class="text-sm font-bold text-gray-800 uppercase tracking-wide">TOTAL</span>
<span class="text-lg font-bold text-green-700">{formatearPrecio(totalGeneral)}</span>
</div>
</div>
</div>
</div>
{/if}
</div>
</div>
<!-- OPCIONES GENERALES -->
{#if showGeneralOptions}
<div class="p-4 sm:p-6">
<div class="bg-white rounded-xl border border-gray-300 shadow-sm p-6">
<h2 class="text-2xl font-bold text-gray-800 mb-6">
{dataConfig.texts.title_options_general || 'Opciones Generales'}
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{#each Object.entries(dataConfig.options.general) as [key, group]}
{@const currentOptionId = appState.getCurrentOption('general', key)}
{@const optionValue = currentOptionId ? appState.getOptionValue(currentOptionId) : null}
<div class="flex flex-col p-4 border-2 border-gray-200 rounded-lg hover:border-green-500 transition-colors">
<span class="text-xs text-gray-500 uppercase tracking-wider mb-2 font-semibold">
{group.name || group.id}
</span>
<span class="text-base font-semibold text-gray-800 mb-3">
{optionValue?.name || currentOptionId || 'No seleccionado'}
</span>
{#if optionValue?.thumb}
<img
src={dataConfig.sources.imgValues + optionValue.thumb}
alt={optionValue.name}
class="w-full h-32 object-cover rounded-md shadow-sm"
onerror={(e) => {
(e.target as HTMLImageElement).style.display = 'none'; }}
/>
{/if}
</div>
{/each}
</div>
</div>
</div>
{/if}
<!-- MENSAJE LEGAL O AVISO -->
<div class="p-4 sm:p-6">
<div class="bg-blue-50 border border-blue-200 rounded-xl p-6 text-center">
<p class="text-sm text-gray-700 leading-relaxed">
ℹ️ Las imágenes son orientativas y pueden no corresponder exactamente con el resultado final.
Los acabados están sujetos a disponibilidad.
</p>
</div>
</div>
</div>
</div>
<style>
/* Scroll suave */
:global(html) {
scroll-behavior: smooth;
}
</style>
Loading…
Cancel
Save

Powered by TurnKey Linux.