<!-- Contenedor -->
<div id="door-configurator-root"></div>
<!-- Tailwind (CDN, para las clases que ya usa tu TSX) -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- React 18 + ReactDOM 18 (CDN) -->
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
<!-- Babel standalone para transpilar TSX/TypeScript en el navegador -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- Tu configurador en TSX (sin lucide-react; uso emojis/texto para iconos) -->
<script type="text/babel" data-presets="typescript,react">
const { useState, useEffect } = React;
const DoorConfigurator: React.FC = () => {
const [step, setStep] = useState<number>(1);
const [config, setConfig] = useState<any>({
model: null,
opening: null,
openingSide: null,
openingVariant: null,
width: 82.5,
height: 203,
isCustomSize: false,
fireRating: null,
acoustic: false,
isDouble: false,
galce: 8,
tapeta: 7,
quantity: 1
});
const [configurations, setConfigurations] = useState<any[]>([]);
const [currentPrice, setCurrentPrice] = useState<number>(0);
const models = [
{ id: 'lisa', name: 'Lacada Blanca Lisa', price: 147.48, image: '🚪', description: 'Diseño minimalista y elegante' },
{ id: 'sofia', name: 'Lacada Blanca Sofía', price: 147.48 * 1.15, image: '🚪', description: '4 ranuras decorativas', increment: '+15%' }
];
const openingTypes = [
{ id: 'abatible', name: 'Abatible', price: 0, icon: '🔄', description: 'Apertura tradicional con bisagras' },
{ id: 'oculta', name: 'Oculta/Enrasada', price: 215, icon: '📐', description: 'Integrada en la pared', increment: '+315€' },
{ id: 'corredera', name: 'Corredera', price: 0.6, icon: '↔️', description: 'Deslizante horizontal', increment: '+60%' }
];
const fireRatings = [
{ id: null, name: 'Sin certificación RF', price: 0 },
{ id: 'EI30', name: 'EI30 - 30 min', price: 250 },
{ id: 'EI45', name: 'EI45 - 45 min', price: 320 },
{ id: 'EI60', name: 'EI60 - 60 min', price: 420 },
{ id: 'EI90', name: 'EI90 - 90 min', price: 600 }
];
const calculatePrice = () => {
if (!config.model) return 0;
const baseModel = models.find(m => m.id === config.model)!;
let price = baseModel.price;
// Dimensiones personalizadas
if (config.isCustomSize) {
const heightMultiplier: Record<number, number> = { 211: 1.12, 220: 1.72, 230: 1.82, 240: 1.98, 260: 2.10 };
const widthMultiplier: Record<number, number> = { 92.5: 1.12, 102: 1.20 };
const hm = heightMultiplier[config.height] || 1;
const wm = widthMultiplier[config.width] || 1;
price *= Math.max(hm, wm);
}
// Tipo de apertura
if (config.opening === 'oculta') price += 215;
else if (config.opening === 'corredera') price *= 1.6;
// Certificaciones
if (config.fireRating) {
const firePrice = fireRatings.find(f => f.id === config.fireRating)?.price || 0;
price += firePrice;
} else if (config.acoustic) {
price += 250;
}
// Puerta doble
if (config.isDouble) price *= 2;
// Galce
const galcePrice: Record<number, number> = { 11: 2.6, 12: 5.2, 13: 7.8 };
price += galcePrice[config.galce] || 0;
// Tapeta
const tapetaPrice: Record<number, number> = { 8: 8.57, 9: 12 };
price += tapetaPrice[config.tapeta] || 0;
return price * config.quantity;
};
useEffect(() => { setCurrentPrice(calculatePrice()); }, [config]);
const validateDimensions = () => {
const surface = (config.width / 100) * (config.height / 100);
const hasRFOrAcoustic = config.fireRating || config.acoustic;
if (hasRFOrAcoustic) {
if (surface > 2.03) return false;
if (config.width > 92.5 || config.height > 220) return false;
}
if (config.opening === 'oculta' && config.fireRating && !['EI30', 'EI45'].includes(config.fireRating)) {
return false;
}
return true;
};
const addConfiguration = () => {
if (validateDimensions()) {
const newConfig = { ...config, id: Date.now(), price: currentPrice };
setConfigurations([...configurations, newConfig]);
setConfig({
model: null, opening: null, openingSide: null, openingVariant: null,
width: 82.5, height: 203, isCustomSize: false,
fireRating: null, acoustic: false, isDouble: false,
galce: 8, tapeta: 7, quantity: 1
});
setStep(1);
}
};
const totalPrice = configurations.reduce((sum, conf) => sum + conf.price, 0);
const StepIndicator = () => (
<div className="mb-8">
<div className="flex justify-between items-center">
{[1,2,3,4,5,6].map((s) => (
<div key={s} className={`flex items-center ${s < 6 ? 'flex-1' : ''}`}>
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold
${step >= s ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-600'}`}>
{step > s ? '✓' : s}
</div>
{s < 6 && <div className={`flex-1 h-1 mx-2 ${step > s ? 'bg-blue-600' : 'bg-gray-200'}`} />}
</div>
))}
</div>
<div className="flex justify-between mt-2 text-xs text-gray-600">
<span>Modelo</span><span>Apertura</span><span>Medidas</span><span>Extras</span><span>Install.</span><span>Resumen</span>
</div>
</div>
);
const PricePreview = () => (
<div className="bg-gradient-to-r from-blue-50 to-blue-100 p-4 rounded-lg border-l-4 border-blue-500">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-gray-700">Precio actual:</span>
<span className="text-xl font-bold text-blue-600">{currentPrice.toFixed(2)} €</span>
</div>
{config.quantity > 1 && (
<div className="text-xs text-gray-600 mt-1">
{(currentPrice / config.quantity).toFixed(2)} € × {config.quantity} unidades
</div>
)}
</div>
);
const renderStep = () => {
switch(step) {
case 1:
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold text-gray-800">Elige tu modelo de puerta</h2>
<div className="grid md:grid-cols-2 gap-4">
{models.map(model => (
<div key={model.id}
className={`p-6 border-2 rounded-lg cursor-pointer transition-all hover:shadow-lg
${config.model === model.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'}`}
onClick={() => setConfig({...config, model: model.id})}>
<div className="text-center">
<div className="text-4xl mb-3">{model.image}</div>
<h3 className="font-semibold text-lg mb-2">{model.name}</h3>
<p className="text-gray-600 text-sm mb-3">{model.description}</p>
<div className="flex justify-between items-center">
<span className="text-lg font-bold text-blue-600">{model.price.toFixed(2)} €</span>
{model.increment && <span className="text-xs bg-orange-100 text-orange-600 px-2 py-1 rounded">{model.increment}</span>}
</div>
</div>
{config.model === model.id && <div className="text-blue-600 mx-auto mt-3 text-center text-lg">✓</div>}
</div>
))}
</div>
</div>
);
case 2:
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold text-gray-800">Tipo de apertura</h2>
<div className="grid md:grid-cols-3 gap-4">
{openingTypes.map(type => (
<div key={type.id}
className={`p-6 border-2 rounded-lg cursor-pointer transition-all hover:shadow-lg
${config.opening === type.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'}`}
onClick={() => setConfig({...config, opening: type.id})}>
<div className="text-center">
<div className="text-3xl mb-3">{type.icon}</div>
<h3 className="font-semibold mb-2">{type.name}</h3>
<p className="text-gray-600 text-xs mb-3">{type.description}</p>
{type.increment && <span className="text-xs bg-green-100 text-green-600 px-2 py-1 rounded">{type.increment}</span>}
</div>
{config.opening === type.id && <div className="text-blue-600 mx-auto mt-3 text-center text-base">✓</div>}
</div>
))}
</div>
{config.opening && (
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
<h4 className="font-medium mb-3">Configuración adicional</h4>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">Sentido de apertura</label>
<div className="flex gap-3">
{['Derecha', 'Izquierda'].map(side => (
<button key={side}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors
${config.openingSide === side ? 'bg-blue-600 text-white' : 'bg-white border border-gray-300 hover:bg-gray-50'}`}
onClick={() => setConfig({...config, openingSide: side})}>
{side}
</button>
))}
</div>
</div>
{config.opening === 'oculta' && (
<div>
<label className="block text-sm font-medium mb-2">Variante de instalación</label>
<select className="w-full p-2 border border-gray-300 rounded-md"
value={config.openingVariant || ''}
onChange={(e) => setConfig({...config, openingVariant: e.target.value})}>
<option value="">Seleccionar...</option>
<option value="revoque">Pared de revoque</option>
<option value="panel">Panel</option>
<option value="pladur">Pared de pladur</option>
</select>
</div>
)}
{config.opening === 'corredera' && (
<div>
<label className="block text-sm font-medium mb-2">Posición</label>
<div className="flex gap-3">
{['Interior del tabique', 'Exterior del tabique'].map(pos => (
<button key={pos}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors
${config.openingVariant === pos ? 'bg-blue-600 text-white' : 'bg-white border border-gray-300 hover:bg-gray-50'}`}
onClick={() => setConfig({...config, openingVariant: pos})}>
{pos}
</button>
))}
</div>
</div>
)}
</div>
</div>
)}
</div>
);
case 3:
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold text-gray-800">Dimensiones</h2>
<div className="bg-blue-50 p-4 rounded-lg border-l-4 border-blue-500">
<div className="flex items-center gap-2 mb-2">
<span className="text-blue-600">ℹ️</span>
<span className="font-medium text-blue-800">Medidas estándar</span>
</div>
<p className="text-sm text-blue-700">82,5 × 203 cm incluido en el precio base</p>
</div>
<div className="space-y-4">
<div>
<label className="flex items-center gap-2 mb-4">
<input type="checkbox"
checked={config.isCustomSize}
onChange={(e) => setConfig({...config, isCustomSize: e.target.checked})}
className="w-4 h-4 text-blue-600" />
<span className="font-medium">Medidas personalizadas</span>
</label>
</div>
{config.isCustomSize && (
<div className="grid md:grid-cols-2 gap-6 p-4 bg-gray-50 rounded-lg">
<div>
<label className="block text-sm font-medium mb-2">Ancho (cm)</label>
<select className="w-full p-2 border border-gray-300 rounded-md"
value={config.width}
onChange={(e) => setConfig({...config, width: parseFloat(e.target.value)})}>
<option value={82.5}>82,5 cm (estándar)</option>
<option value={92.5}>92,5 cm (+12%)</option>
<option value={102}>102 cm (+20%)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2">Alto (cm)</label>
<select className="w-full p-2 border border-gray-300 rounded-md"
value={config.height}
onChange={(e) => setConfig({...config, height: parseFloat(e.target.value)})}>
<option value={203}>203 cm (estándar)</option>
<option value={211}>211 cm (+12%)</option>
<option value={220}>220 cm (+72%)</option>
<option value={230}>230 cm (+82%)</option>
<option value={240}>240 cm (+98%)</option>
<option value={260}>260 cm (+110%)</option>
</select>
</div>
</div>
)}
{!validateDimensions() && (
<div className="bg-red-50 p-4 rounded-lg border-l-4 border-red-500">
<p className="text-red-700 text-sm font-medium">⚠️ Restricción técnica</p>
<p className="text-red-600 text-sm mt-1">
Las puertas RF y acústicas solo están disponibles hasta 92,5×220 cm y máximo 2,03 m² de superficie.
{config.opening === 'oculta' && config.fireRating && !['EI30', 'EI45'].includes(config.fireRating) &&
' Las puertas enrasadas con RF solo admiten certificación hasta EI45.'}
</p>
</div>
)}
</div>
</div>
);
case 4:
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold text-gray-800">Especificaciones técnicas</h2>
<div className="space-y-6">
<div>
<h3 className="font-semibold mb-3">Certificación cortafuegos (RF)</h3>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-3">
{fireRatings.map(rating => (
<button key={rating.id ?? 'none'}
className={`p-3 text-left border-2 rounded-lg transition-all
${config.fireRating === rating.id ? 'border-red-500 bg-red-50' : 'border-gray-200 hover:border-gray-300'}`}
onClick={() => setConfig({...config, fireRating: rating.id, acoustic: rating.id ? true : config.acoustic})}>
<div className="font-medium text-sm">{rating.name}</div>
{rating.price > 0 && <div className="text-red-600 text-xs">+{rating.price}€</div>}
{rating.id && <div className="text-xs text-gray-500 mt-1">Incluye aislamiento acústico</div>}
</button>
))}
</div>
</div>
{!config.fireRating && (
<div>
<label className="flex items-center gap-2">
<input type="checkbox"
checked={config.acoustic}
onChange={(e) => setConfig({...config, acoustic: e.target.checked})}
className="w-4 h-4 text-blue-600" />
<span className="font-medium">Aislamiento acústico 32 dB (+250€)</span>
</label>
<p className="text-sm text-gray-600 ml-6">Ideal para oficinas y espacios que requieren privacidad sonora</p>
</div>
)}
<div>
<label className="flex items-center gap-2">
<input type="checkbox"
checked={config.isDouble}
onChange={(e) => setConfig({...config, isDouble: e.target.checked})}
className="w-4 h-4 text-blue-600" />
<span className="font-medium">Puerta doble (×2 precio)</span>
</label>
<p className="text-sm text-gray-600 ml-6">Para pasos amplios o entradas principales</p>
</div>
</div>
</div>
);
case 5:
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold text-gray-800">Instalación y cantidad</h2>
<div className="grid md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium mb-2">Galce según grosor del tabique</label>
<select className="w-full p-2 border border-gray-300 rounded-md"
value={config.galce}
onChange={(e) => setConfig({...config, galce: parseFloat(e.target.value)})}>
<option value={8}>8 cm (incluido)</option>
<option value={9}>9 cm (incluido)</option>
<option value={10}>10 cm (incluido)</option>
<option value={11}>11 cm (+2,6€)</option>
<option value={12}>12 cm (+5,2€)</option>
<option value={13}>13 cm (+7,8€)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2">Tapeta</label>
<select className="w-full p-2 border border-gray-300 rounded-md"
value={config.tapeta}
onChange={(e) => setConfig({...config, tapeta: parseFloat(e.target.value)})}>
<option value={7}>7 cm (incluido)</option>
<option value={8}>8 cm (+8,57€)</option>
<option value={9}>9 cm (+12€)</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">Cantidad</label>
<div className="flex items-center gap-3">
<button className="w-10 h-10 border border-gray-300 rounded-md flex items-center justify-center hover:bg-gray-50"
onClick={() => setConfig({...config, quantity: Math.max(1, config.quantity - 1)})}>−</button>
<input type="number" min="1" max="99"
className="w-20 p-2 text-center border border-gray-300 rounded-md"
value={config.quantity}
onChange={(e) => setConfig({...config, quantity: parseInt(e.target.value) || 1})} />
<button className="w-10 h-10 border border-gray-300 rounded-md flex items-center justify-center hover:bg-gray-50"
onClick={() => setConfig({...config, quantity: config.quantity + 1})}>+</button>
<span className="text-sm text-gray-600 ml-2">unidades</span>
</div>
</div>
</div>
);
case 6:
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold text-gray-800">Resumen del presupuesto</h2>
{configurations.length > 0 && (
<div className="space-y-4">
<h3 className="font-semibold text-lg">Configuraciones añadidas:</h3>
{configurations.map((conf, idx) => (
<div key={conf.id} className="bg-white p-4 border border-gray-200 rounded-lg shadow-sm">
<div className="flex justify-between items-start">
<div className="flex-1">
<h4 className="font-medium text-gray-800">Puerta #{idx + 1}</h4>
<div className="text-sm text-gray-600 mt-2 space-y-1">
<p>• {models.find(m => m.id === conf.model)?.name}</p>
<p>• {openingTypes.find(t => t.id === conf.opening)?.name}</p>
<p>• {conf.width} × {conf.height} cm</p>
{conf.fireRating && <p>• Certificación {conf.fireRating}</p>}
{conf.acoustic && !conf.fireRating && <p>• Aislamiento acústico</p>}
<p>• Cantidad: {conf.quantity}</p>
</div>
</div>
<div className="text-right">
<div className="text-lg font-bold text-blue-600">{conf.price.toFixed(2)} €</div>
<button className="text-xs text-gray-500 hover:text-blue-600 flex items-center gap-1 mt-2" title="Editar (no implementado)">
✎ Editar
</button>
</div>
</div>
</div>
))}
<div className="border-t pt-4">
<div className="flex justify-between items-center text-xl font-bold">
<span>Total estimado:</span>
<span className="text-blue-600">{totalPrice.toFixed(2)} €</span>
</div>
<p className="text-sm text-gray-600 mt-1">*IVA no incluido</p>
</div>
</div>
)}
<div className="bg-yellow-50 p-4 rounded-lg border-l-4 border-yellow-400">
<h4 className="font-medium text-yellow-800 mb-2">Configuración actual</h4>
{config.model ? (
<div className="text-sm text-yellow-700 space-y-1">
<p>• {models.find(m => m.id === config.model)?.name}</p>
{config.opening && <p>• {openingTypes.find(t => t.id === config.opening)?.name}</p>}
<p>• Precio: {currentPrice.toFixed(2)} €</p>
</div>
) : (
<p className="text-sm text-yellow-700">No hay configuración activa</p>
)}
</div>
<div className="flex gap-3">
<button
onClick={addConfiguration}
disabled={!config.model || !validateDimensions()}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-gray-300 disabled:cursor-not-allowed">
+ Añadir configuración
</button>
{configurations.length > 0 && (
<>
<button className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700" title="Exportar PDF (pendiente)">
⬇ Exportar PDF
</button>
<button className="flex items-center gap-2 px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700" title="Enviar email (pendiente)">
✉ Enviar por email
</button>
</>
)}
</div>
</div>
);
default:
return null;
}
};
return (
<div className="max-w-6xl mx-auto p-6 bg-white min-h-screen">
<header className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-800 mb-2">Configurador de Puertas</h1>
<p className="text-gray-600">Diseña tu puerta perfecta y obtén un presupuesto personalizado</p>
</header>
<div className="lg:grid lg:grid-cols-4 lg:gap-8">
<div className="lg:col-span-3">
<StepIndicator />
<div className="bg-white rounded-lg border border-gray-200 p-6 shadow-sm">
{renderStep()}
</div>
<div className="flex justify-between mt-6">
<button
onClick={() => setStep(Math.max(1, step - 1))}
disabled={step === 1}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
◀ Anterior
</button>
<button
onClick={() => setStep(Math.min(6, step + 1))}
disabled={step === 6 || (step === 1 && !config.model) || (step === 2 && !config.opening) || (step === 3 && !validateDimensions())}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed">
Siguiente ▶
</button>
</div>
</div>
<div className="mt-6 lg:mt-0">
<div className="sticky top-6 space-y-4">
<PricePreview />
{configurations.length > 0 && (
<div className="bg-gray-50 p-4 rounded-lg">
<h4 className="font-medium text-gray-800 mb-2">Puertas configuradas</h4>
<div className="text-sm text-gray-600">
<p>{configurations.length} configuración{configurations.length > 1 ? 'es' : ''}</p>
<p className="font-medium text-blue-600">Total: {totalPrice.toFixed(2)} €</p>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
};
const root = ReactDOM.createRoot(document.getElementById('door-configurator-root')!);
root.render(<DoorConfigurator />);
</script>