You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
688 lines
23 KiB
688 lines
23 KiB
<script lang="ts">
|
|
type ContactPayload = {
|
|
email: string;
|
|
text: string;
|
|
};
|
|
|
|
type Status =
|
|
| 'editing'
|
|
| 'folding'
|
|
| 'ready'
|
|
| 'dragging'
|
|
| 'delivering'
|
|
| 'flying'
|
|
| 'sent';
|
|
|
|
type Props = {
|
|
endpoint?: string;
|
|
onSent?: (payload: ContactPayload) => void | Promise<void>;
|
|
};
|
|
|
|
let { endpoint = '/api/contact', onSent }: Props = $props();
|
|
|
|
let form = $state<ContactPayload>({
|
|
email: '',
|
|
text: ''
|
|
});
|
|
|
|
let status = $state<Status>('editing');
|
|
let error = $state('');
|
|
|
|
let stageEl: HTMLElement;
|
|
let formCardEl: HTMLElement;
|
|
let letterEl: HTMLElement;
|
|
let pigeonBeakEl: SVGElement;
|
|
let pigeonDropZoneEl: SVGRectElement;
|
|
|
|
const letterSize = { width: 140, height: 90 };
|
|
|
|
let letter = $state({
|
|
x: 0,
|
|
y: 0,
|
|
homeX: 0,
|
|
homeY: 0,
|
|
dragStartX: 0,
|
|
dragStartY: 0,
|
|
pointerStartX: 0,
|
|
pointerStartY: 0,
|
|
isCaptured: false
|
|
});
|
|
|
|
let canSubmit = $derived(
|
|
status === 'editing' &&
|
|
form.email.trim().length > 0 &&
|
|
form.text.trim().length > 0
|
|
);
|
|
|
|
let showForm = $derived(status === 'editing' || status === 'folding');
|
|
let showLetter = $derived(
|
|
status === 'ready' ||
|
|
status === 'dragging' ||
|
|
status === 'delivering' ||
|
|
status === 'flying'
|
|
);
|
|
|
|
let isNearPigeon = $state(false);
|
|
|
|
let instruction = $derived.by(() => {
|
|
if (status === 'editing') return 'Rellena el formulario.';
|
|
if (status === 'folding') return 'Preparando el mensaje...';
|
|
if (status === 'ready') return 'Arrastra la carta hacia el pico de la paloma.';
|
|
if (status === 'dragging') return isNearPigeon ? '¡Suéltala ahí! La paloma la recogerá.' : 'Acerca la carta a la paloma.';
|
|
if (status === 'delivering') return '¡La tiene!';
|
|
if (status === 'flying') return 'La paloma vuela hacia el horizonte...';
|
|
return 'Mensaje enviado correctamente.';
|
|
});
|
|
|
|
function sleep(ms: number) {
|
|
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
|
}
|
|
|
|
function startChallenge(event: SubmitEvent) {
|
|
event.preventDefault();
|
|
if (!canSubmit) {
|
|
error = 'Introduce un email y un mensaje antes de enviar.';
|
|
return;
|
|
}
|
|
error = '';
|
|
status = 'folding';
|
|
window.setTimeout(() => {
|
|
placeLetterOverForm();
|
|
status = 'ready';
|
|
}, 800);
|
|
}
|
|
|
|
function placeLetterOverForm() {
|
|
const stageRect = stageEl.getBoundingClientRect();
|
|
const formRect = formCardEl.getBoundingClientRect();
|
|
letter.x = formRect.left - stageRect.left + formRect.width / 2 - letterSize.width / 2;
|
|
letter.y = formRect.top - stageRect.top + formRect.height / 2 - letterSize.height / 2;
|
|
letter.homeX = letter.x;
|
|
letter.homeY = letter.y;
|
|
}
|
|
|
|
function grabLetter(event: PointerEvent) {
|
|
if (status !== 'ready') return;
|
|
const target = event.currentTarget as HTMLElement;
|
|
target.setPointerCapture(event.pointerId);
|
|
letter.isCaptured = true;
|
|
letter.dragStartX = letter.x;
|
|
letter.dragStartY = letter.y;
|
|
letter.pointerStartX = event.clientX;
|
|
letter.pointerStartY = event.clientY;
|
|
error = '';
|
|
status = 'dragging';
|
|
}
|
|
|
|
function moveLetter(event: PointerEvent) {
|
|
if (status !== 'dragging' || !letter.isCaptured) return;
|
|
letter.x = letter.dragStartX + event.clientX - letter.pointerStartX;
|
|
letter.y = letter.dragStartY + event.clientY - letter.pointerStartY;
|
|
isNearPigeon = checkProximity();
|
|
}
|
|
|
|
function checkProximity() {
|
|
if (!pigeonDropZoneEl || !letterEl) return false;
|
|
const dropRect = pigeonDropZoneEl.getBoundingClientRect();
|
|
const letterRect = letterEl.getBoundingClientRect();
|
|
const letterCenterX = letterRect.left + letterRect.width / 2;
|
|
const letterCenterY = letterRect.top + letterRect.height / 2;
|
|
return (
|
|
letterCenterX >= dropRect.left - 40 &&
|
|
letterCenterX <= dropRect.right + 40 &&
|
|
letterCenterY >= dropRect.top - 40 &&
|
|
letterCenterY <= dropRect.bottom + 40
|
|
);
|
|
}
|
|
|
|
async function releaseLetter(event: PointerEvent) {
|
|
if (status !== 'dragging') return;
|
|
const target = event.currentTarget as HTMLElement;
|
|
if (target.hasPointerCapture(event.pointerId)) {
|
|
target.releasePointerCapture(event.pointerId);
|
|
}
|
|
letter.isCaptured = false;
|
|
if (!checkProximity()) {
|
|
error = 'Lleva la carta hasta el pico de la paloma.';
|
|
status = 'ready';
|
|
isNearPigeon = false;
|
|
letter.x = letter.homeX;
|
|
letter.y = letter.homeY;
|
|
return;
|
|
}
|
|
await deliverToPigeon();
|
|
}
|
|
|
|
async function deliverToPigeon() {
|
|
error = '';
|
|
status = 'delivering';
|
|
const stageRect = stageEl.getBoundingClientRect();
|
|
const beakRect = pigeonBeakEl.getBoundingClientRect();
|
|
letter.x = beakRect.left - stageRect.left - 20;
|
|
letter.y = beakRect.top - stageRect.top - 20;
|
|
await sleep(800);
|
|
status = 'flying';
|
|
sendForm();
|
|
}
|
|
|
|
async function sendForm() {
|
|
const payload: ContactPayload = {
|
|
email: form.email.trim(),
|
|
text: form.text.trim()
|
|
};
|
|
try {
|
|
if (onSent) {
|
|
await onSent(payload);
|
|
} else {
|
|
const response = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
if (!response.ok) throw new Error();
|
|
}
|
|
await sleep(1500);
|
|
status = 'sent';
|
|
} catch {
|
|
status = 'ready';
|
|
isNearPigeon = false;
|
|
letter.x = letter.homeX;
|
|
letter.y = letter.homeY;
|
|
error = 'La paloma se perdió en la tormenta. Inténtalo de nuevo.';
|
|
}
|
|
}
|
|
|
|
function resetForm() {
|
|
form.email = '';
|
|
form.text = '';
|
|
error = '';
|
|
status = 'editing';
|
|
isNearPigeon = false;
|
|
}
|
|
</script>
|
|
|
|
<section bind:this={stageEl} class="contact-stage">
|
|
<div class="floating-hint">
|
|
<p>{instruction}</p>
|
|
{#if error} <span role="alert" class="err-text">{error}</span> {/if}
|
|
</div>
|
|
|
|
<div class="left-panel">
|
|
{#if showForm}
|
|
<div bind:this={formCardEl} class={`form-card ${status === 'folding' ? 'is-folding' : ''}`}>
|
|
<form onsubmit={startChallenge}>
|
|
<div class="eyebrow">Mensajería Alada</div>
|
|
<h2>Enviar Correo</h2>
|
|
|
|
<label>
|
|
<span>Tu Email</span>
|
|
<input type="email" bind:value={form.email} disabled={status !== 'editing'} required placeholder="tu@email.com" />
|
|
</label>
|
|
|
|
<label>
|
|
<span>Mensaje</span>
|
|
<textarea bind:value={form.text} disabled={status !== 'editing'} required placeholder="Escribe algo bonito..." rows="4"></textarea>
|
|
</label>
|
|
|
|
<button type="submit" disabled={!canSubmit}>Sellar carta</button>
|
|
</form>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if status === 'sent'}
|
|
<div class="success-card">
|
|
<div class="success-icon">🕊️</div>
|
|
<h3>¡Buen viaje!</h3>
|
|
<p>La paloma se ha llevado tu mensaje más allá del horizonte.</p>
|
|
<button type="button" onclick={resetForm}>Enviar otra paloma</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="right-panel">
|
|
<div class={`pigeon-container state-${status} ${isNearPigeon ? 'is-hungry' : ''}`}>
|
|
<svg class="pigeon-svg" viewBox="0 0 400 400" width="100%" height="100%">
|
|
<defs>
|
|
<radialGradient id="skyGrad" cx="0.5" cy="0.5" r="0.5">
|
|
<stop offset="0%" stop-color="#e0efff" stop-opacity="0.8" />
|
|
<stop offset="100%" stop-color="#ffffff" stop-opacity="0" />
|
|
</radialGradient>
|
|
<linearGradient id="pigeonBody" x1="0.3" x2="0.7" y1="0" y2="1">
|
|
<stop offset="0%" stop-color="#e5e7eb" />
|
|
<stop offset="55%" stop-color="#9ca3af" />
|
|
<stop offset="100%" stop-color="#4b5563" />
|
|
</linearGradient>
|
|
<linearGradient id="pigeonBelly" x1="0" x2="0" y1="0" y2="1">
|
|
<stop offset="0%" stop-color="#f3f4f6" />
|
|
<stop offset="100%" stop-color="#d1d5db" />
|
|
</linearGradient>
|
|
<linearGradient id="pigeonWing" x1="0" x2="0.4" y1="0" y2="1">
|
|
<stop offset="0%" stop-color="#9ca3af" />
|
|
<stop offset="55%" stop-color="#4b5563" />
|
|
<stop offset="100%" stop-color="#1f2937" />
|
|
</linearGradient>
|
|
<linearGradient id="neckShimmer" x1="0" x2="1" y1="0" y2="1">
|
|
<stop offset="0%" stop-color="#14b8a6" />
|
|
<stop offset="50%" stop-color="#8b5cf6" />
|
|
<stop offset="100%" stop-color="#ec4899" />
|
|
</linearGradient>
|
|
<linearGradient id="capGrad" x1="0" x2="0" y1="0" y2="1">
|
|
<stop offset="0%" stop-color="#ef4444" />
|
|
<stop offset="100%" stop-color="#991b1b" />
|
|
</linearGradient>
|
|
<linearGradient id="bagGrad" x1="0" x2="0" y1="0" y2="1">
|
|
<stop offset="0%" stop-color="#b45309" />
|
|
<stop offset="100%" stop-color="#78350f" />
|
|
</linearGradient>
|
|
<linearGradient id="bagFlapGrad" x1="0" x2="0" y1="0" y2="1">
|
|
<stop offset="0%" stop-color="#a16207" />
|
|
<stop offset="100%" stop-color="#713f12" />
|
|
</linearGradient>
|
|
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%">
|
|
<feDropShadow dx="0" dy="6" stdDeviation="6" flood-color="#1e293b" flood-opacity="0.18" />
|
|
</filter>
|
|
</defs>
|
|
|
|
<!-- Aura del cielo -->
|
|
<circle cx="200" cy="200" r="170" fill="url(#skyGrad)" />
|
|
|
|
<!-- Nubes decorativas -->
|
|
<g opacity="0.5">
|
|
<ellipse cx="60" cy="90" rx="22" ry="7" fill="#ffffff" />
|
|
<ellipse cx="75" cy="85" rx="16" ry="6" fill="#ffffff" />
|
|
<ellipse cx="340" cy="70" rx="20" ry="6" fill="#ffffff" />
|
|
<ellipse cx="355" cy="65" rx="14" ry="5" fill="#ffffff" />
|
|
</g>
|
|
|
|
<!-- Rama (estática, no vuela) -->
|
|
<g class="branch" filter="url(#softShadow)">
|
|
<path d="M 30 322 C 130 308, 280 326, 370 296"
|
|
fill="none" stroke="#5c4033" stroke-width="14" stroke-linecap="round" />
|
|
<path d="M 30 322 C 130 308, 280 326, 370 296"
|
|
fill="none" stroke="#7c5535" stroke-width="3" stroke-linecap="round" opacity="0.6" />
|
|
<path d="M 295 312 C 315 295, 330 275, 340 248"
|
|
fill="none" stroke="#5c4033" stroke-width="6" stroke-linecap="round" />
|
|
<!-- Hojas -->
|
|
<g>
|
|
<path d="M 338 250 Q 354 238, 358 250 Q 348 266, 338 250 Z" fill="#22c55e" />
|
|
<path d="M 348 248 L 348 252" stroke="#15803d" stroke-width="0.8" />
|
|
<path d="M 330 240 Q 320 232, 324 226 Q 336 234, 330 240 Z" fill="#16a34a" />
|
|
<path d="M 100 314 Q 90 300, 96 294 Q 110 304, 100 314 Z" fill="#22c55e" />
|
|
<path d="M 220 322 Q 212 308, 218 302 Q 230 314, 220 322 Z" fill="#4ade80" />
|
|
</g>
|
|
</g>
|
|
|
|
<!-- ACTOR PALOMA: todo lo que despega cuando vuela -->
|
|
<g class="pigeon-actor" filter="url(#softShadow)">
|
|
|
|
<!-- COLA: plumas detrás del cuerpo -->
|
|
<g class="pigeon-tail">
|
|
<path d="M 110 230 L 72 245 L 80 258 L 118 248 Z" fill="#4b5563" />
|
|
<path d="M 108 242 L 65 268 L 82 280 L 118 260 Z" fill="#6b7280" />
|
|
<path d="M 112 254 L 75 288 L 100 295 L 122 272 Z" fill="#9ca3af" />
|
|
<!-- línea central de cola -->
|
|
<line x1="90" y1="252" x2="115" y2="252" stroke="#374151" stroke-width="0.6" opacity="0.5" />
|
|
<line x1="88" y1="270" x2="115" y2="265" stroke="#374151" stroke-width="0.6" opacity="0.5" />
|
|
</g>
|
|
|
|
<!-- ALA TRASERA: solo visible al volar -->
|
|
<path class="pigeon-wing-back"
|
|
d="M 175 200 C 145 130, 100 130, 125 200 C 145 225, 175 220, 175 200 Z"
|
|
fill="#374151" opacity="0.75" />
|
|
|
|
<!-- PATAS sobre la rama -->
|
|
<g class="pigeon-legs" stroke="#fb7185" stroke-width="4" stroke-linecap="round" fill="none">
|
|
<!-- pata izquierda -->
|
|
<path d="M 175 292 L 172 317" />
|
|
<path d="M 172 317 L 164 320 M 172 317 L 180 320 M 172 317 L 172 323" stroke-width="2.5" />
|
|
<!-- pata derecha -->
|
|
<path d="M 200 292 L 204 317" />
|
|
<path d="M 204 317 L 196 320 M 204 317 L 212 320 M 204 317 L 204 323" stroke-width="2.5" />
|
|
</g>
|
|
|
|
<!-- CUERPO principal (regordete) -->
|
|
<path d="M 250 195
|
|
C 285 205, 290 252, 270 285
|
|
C 250 305, 175 308, 138 290
|
|
C 108 270, 105 222, 130 198
|
|
C 160 176, 225 178, 250 195 Z"
|
|
fill="url(#pigeonBody)" />
|
|
|
|
<!-- Vientre (luz inferior) -->
|
|
<ellipse cx="195" cy="268" rx="58" ry="28" fill="url(#pigeonBelly)" opacity="0.85" />
|
|
|
|
<!-- Brillo iridiscente del cuello (típico de paloma) -->
|
|
<path d="M 222 175 C 218 200, 240 218, 265 207 C 270 192, 263 175, 245 168 Z"
|
|
fill="url(#neckShimmer)" opacity="0.55" />
|
|
<path d="M 235 178 C 230 195, 248 208, 262 200" fill="none" stroke="#ffffff" stroke-width="1" opacity="0.4" />
|
|
|
|
<!-- CORREA de la cartera (diagonal cruzando el pecho) -->
|
|
<path d="M 250 180 Q 222 230, 168 268"
|
|
fill="none" stroke="#78350f" stroke-width="5" stroke-linecap="round" />
|
|
<path d="M 250 180 Q 222 230, 168 268"
|
|
fill="none" stroke="#a16207" stroke-width="1.5" stroke-linecap="round" opacity="0.6" />
|
|
|
|
<!-- CARTERA (bolsa de mensajero) -->
|
|
<g class="messenger-bag">
|
|
<!-- cuerpo de la cartera -->
|
|
<path d="M 138 252
|
|
L 198 252
|
|
L 196 295
|
|
Q 193 304, 184 304
|
|
L 152 304
|
|
Q 141 304, 139 295 Z"
|
|
fill="url(#bagGrad)" stroke="#451a03" stroke-width="0.8" />
|
|
<!-- solapa de la cartera -->
|
|
<path d="M 135 248
|
|
L 201 248
|
|
L 199 274
|
|
Q 168 282, 137 273 Z"
|
|
fill="url(#bagFlapGrad)" stroke="#451a03" stroke-width="0.8" />
|
|
<!-- costuras de la solapa -->
|
|
<path d="M 138 252 Q 168 258, 198 252" fill="none" stroke="#451a03" stroke-width="0.5" stroke-dasharray="2 2" opacity="0.6" />
|
|
<!-- hebilla -->
|
|
<rect x="163" y="268" width="11" height="7" rx="1.5" fill="#fbbf24" stroke="#92400e" stroke-width="0.6" />
|
|
<rect x="165" y="270" width="7" height="3" rx="0.5" fill="#92400e" />
|
|
<!-- emblema postal en la cartera -->
|
|
<circle cx="168" cy="290" r="8" fill="#fef3c7" stroke="#92400e" stroke-width="1" />
|
|
<path d="M 163 288 L 168 292 L 173 288 M 163 290 L 173 290" fill="none" stroke="#92400e" stroke-width="0.9" stroke-linecap="round" />
|
|
<!-- carta asomando -->
|
|
<g transform="rotate(-10 188 245)">
|
|
<rect x="178" y="238" width="22" height="14" rx="1" fill="#fefcf8" stroke="#d6cfc0" stroke-width="0.8" />
|
|
<line x1="181" y1="244" x2="195" y2="244" stroke="#cbd5e1" stroke-width="0.6" />
|
|
<line x1="181" y1="248" x2="192" y2="248" stroke="#cbd5e1" stroke-width="0.6" />
|
|
</g>
|
|
</g>
|
|
|
|
<!-- CABEZA -->
|
|
<circle cx="252" cy="148" r="36" fill="url(#pigeonBody)" />
|
|
<!-- Brillo de la cabeza -->
|
|
<ellipse cx="246" cy="134" rx="20" ry="12" fill="#f3f4f6" opacity="0.45" />
|
|
|
|
<!-- OJO expresivo -->
|
|
<circle cx="272" cy="143" r="5.5" fill="#1f2937" />
|
|
<circle cx="270.5" cy="141" r="2" fill="#ffffff" />
|
|
<circle cx="274" cy="145.5" r="0.8" fill="#ffffff" opacity="0.7" />
|
|
|
|
<!-- PICO con cere (la parte blanca característica de paloma) -->
|
|
<g class="pigeon-beak" bind:this={pigeonBeakEl}>
|
|
<!-- cere (base blanca del pico) -->
|
|
<ellipse cx="287" cy="148" rx="7" ry="4.5" fill="#f9fafb" stroke="#a8a29e" stroke-width="0.8" />
|
|
<circle cx="284" cy="147" r="0.8" fill="#78716c" />
|
|
<circle cx="290" cy="147" r="0.8" fill="#78716c" />
|
|
<!-- pico superior -->
|
|
<path class="beak-top"
|
|
d="M 286 146 L 311 150 L 307 155 L 286 153 Z"
|
|
fill="#fbbf24" stroke="#b45309" stroke-width="1" stroke-linejoin="round" />
|
|
<!-- pico inferior -->
|
|
<path class="beak-bottom"
|
|
d="M 286 154 L 307 156 L 304 159 L 286 159 Z"
|
|
fill="#f59e0b" stroke="#b45309" stroke-width="1" stroke-linejoin="round" />
|
|
</g>
|
|
|
|
<!-- GORRA de mensajero -->
|
|
<g class="messenger-cap">
|
|
<!-- sombra de la gorra sobre la cabeza -->
|
|
<ellipse cx="252" cy="120" rx="32" ry="3.5" fill="#1f2937" opacity="0.18" />
|
|
<!-- domo principal -->
|
|
<path d="M 222 119
|
|
Q 220 92, 252 89
|
|
Q 284 92, 282 119
|
|
Q 282 122, 278 122
|
|
L 226 122
|
|
Q 222 122, 222 119 Z"
|
|
fill="url(#capGrad)" stroke="#7f1d1d" stroke-width="1" stroke-linejoin="round" />
|
|
<!-- banda inferior de la gorra -->
|
|
<path d="M 222 118 Q 252 124, 282 118 L 282 122 Q 252 126, 222 122 Z"
|
|
fill="#7f1d1d" />
|
|
<!-- pequeño brillo en el domo -->
|
|
<path d="M 232 100 Q 240 94, 252 94" fill="none" stroke="#fca5a5" stroke-width="2" stroke-linecap="round" opacity="0.55" />
|
|
<!-- botón superior -->
|
|
<circle cx="252" cy="91" r="3" fill="#fbbf24" stroke="#92400e" stroke-width="0.6" />
|
|
<!-- visera (hacia el pico) -->
|
|
<path d="M 278 121
|
|
Q 297 121, 301 130
|
|
Q 298 134, 282 132
|
|
Q 278 130, 278 121 Z"
|
|
fill="#7f1d1d" stroke="#450a0a" stroke-width="0.8" stroke-linejoin="round" />
|
|
<!-- emblema postal en el frente -->
|
|
<circle cx="266" cy="111" r="5" fill="#fef3c7" stroke="#7f1d1d" stroke-width="1" />
|
|
<path d="M 263 110 L 266 113 L 269 110 M 263 112 L 269 112"
|
|
fill="none" stroke="#7f1d1d" stroke-width="0.9" stroke-linecap="round" />
|
|
</g>
|
|
|
|
<!-- ALA FRONTAL (con animación de aleteo) -->
|
|
<g class="pigeon-wing">
|
|
<path d="M 175 200
|
|
C 145 195, 128 245, 162 273
|
|
C 188 282, 218 270, 222 244
|
|
C 224 218, 200 204, 175 200 Z"
|
|
fill="url(#pigeonWing)" stroke="#1f2937" stroke-width="0.5" />
|
|
<!-- líneas de plumas -->
|
|
<path d="M 168 218 Q 176 240, 178 265" fill="none" stroke="#0f172a" stroke-width="1" opacity="0.45" stroke-linecap="round" />
|
|
<path d="M 184 215 Q 192 240, 192 268" fill="none" stroke="#0f172a" stroke-width="1" opacity="0.45" stroke-linecap="round" />
|
|
<path d="M 200 213 Q 207 238, 205 265" fill="none" stroke="#0f172a" stroke-width="1" opacity="0.45" stroke-linecap="round" />
|
|
<!-- borde superior del ala (covertas) -->
|
|
<path d="M 175 200 C 188 198, 205 205, 218 222" fill="none" stroke="#374151" stroke-width="1.5" opacity="0.6" stroke-linecap="round" />
|
|
</g>
|
|
|
|
<!-- Zona de drop invisible sobre el pico -->
|
|
<rect
|
|
bind:this={pigeonDropZoneEl}
|
|
x="262" y="118" width="62" height="58"
|
|
fill="transparent"
|
|
pointer-events="none"
|
|
/>
|
|
</g>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- CARTA arrastrable -->
|
|
{#if showLetter}
|
|
<div
|
|
bind:this={letterEl}
|
|
class={`letter-envelope status-${status}`}
|
|
style={`--x: ${letter.x}px; --y: ${letter.y}px;`}
|
|
onpointerdown={grabLetter}
|
|
onpointermove={moveLetter}
|
|
onpointerup={releaseLetter}
|
|
>
|
|
<svg width="140" height="90" viewBox="0 0 140 90" filter="url(#softShadow)">
|
|
<rect width="140" height="90" rx="6" fill="#fefcf8" stroke="#e6dfd1" stroke-width="1.5" />
|
|
<rect x="110" y="8" width="18" height="22" fill="#3b82f6" rx="1" />
|
|
<circle cx="119" cy="19" r="4" fill="#ffffff" opacity="0.3" />
|
|
<line x1="16" y1="50" x2="90" y2="50" stroke="#94a3b8" stroke-width="2" stroke-linecap="round" />
|
|
<line x1="16" y1="62" x2="80" y2="62" stroke="#94a3b8" stroke-width="2" stroke-linecap="round" />
|
|
<line x1="16" y1="74" x2="96" y2="74" stroke="#94a3b8" stroke-width="2" stroke-linecap="round" />
|
|
<path d="M 2 2 L 70 42 L 138 2" fill="none" stroke="#e2d8c5" stroke-width="1.5" />
|
|
<!-- Sello de cera rojo -->
|
|
<circle cx="70" cy="42" r="7" fill="#dc2626" stroke="#7f1d1d" stroke-width="0.8" />
|
|
<text x="70" y="46" text-anchor="middle" font-size="9" font-weight="bold" fill="#fef3c7" font-family="serif">M</text>
|
|
</svg>
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
<style>
|
|
:global(*) { box-sizing: border-box; }
|
|
|
|
.contact-stage {
|
|
position: relative;
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 2rem;
|
|
min-height: 550px;
|
|
padding: 2rem;
|
|
background: linear-gradient(135deg, #f0f7ff 0%, #e2eeff 100%);
|
|
border-radius: 24px;
|
|
overflow: hidden;
|
|
align-items: center;
|
|
}
|
|
|
|
.floating-hint {
|
|
position: absolute;
|
|
top: 1.5rem;
|
|
left: 1.5rem;
|
|
z-index: 10;
|
|
background: rgba(255, 255, 255, 0.85);
|
|
padding: 0.75rem 1.2rem;
|
|
border-radius: 16px;
|
|
backdrop-filter: blur(8px);
|
|
box-shadow: 0 8px 24px rgba(0,0,0,0.05);
|
|
max-width: 340px;
|
|
}
|
|
.floating-hint p { margin: 0; font-weight: 700; color: #1e3a8a; }
|
|
.err-text { display: block; margin-top: 0.25rem; color: #ef4444; font-size: 0.88rem; }
|
|
|
|
.left-panel, .right-panel { position: relative; width: 100%; }
|
|
|
|
.form-card, .success-card {
|
|
background: #ffffff;
|
|
padding: 2rem;
|
|
border-radius: 20px;
|
|
box-shadow: 0 20px 40px rgba(0, 50, 150, 0.06);
|
|
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.4s ease;
|
|
}
|
|
|
|
.form-card.is-folding {
|
|
transform: scale(0.3) rotateX(45deg) translateY(100px);
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.eyebrow { font-size: 0.8rem; font-weight: 700; color: #3b82f6; text-transform: uppercase; margin-bottom: 0.5rem; }
|
|
h2 { margin: 0 0 1.5rem 0; color: #1e293b; font-size: 1.8rem; }
|
|
label { display: grid; gap: 0.4rem; margin-bottom: 1rem; font-weight: 600; color: #475569; }
|
|
|
|
input, textarea {
|
|
padding: 0.75rem 1rem;
|
|
border: 1.5px solid #cbd5e1;
|
|
border-radius: 12px;
|
|
outline: none;
|
|
font: inherit;
|
|
}
|
|
input:focus, textarea:focus { border-color: #3b82f6; box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.15); }
|
|
|
|
button {
|
|
width: 100%;
|
|
padding: 0.85rem;
|
|
background: #3b82f6;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 12px;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
box-shadow: 0 10px 20px rgba(59, 130, 246, 0.2);
|
|
transition: transform 0.2s, background 0.2s;
|
|
}
|
|
button:hover:not(:disabled) { background: #2563eb; transform: translateY(-2px); }
|
|
button:disabled { opacity: 0.5; cursor: not-allowed; box-shadow: none; }
|
|
|
|
/* --- PALOMA --- */
|
|
.pigeon-container {
|
|
width: 100%;
|
|
height: 400px;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
|
|
.pigeon-actor {
|
|
transform-origin: 200px 220px;
|
|
transition: transform 0.3s ease;
|
|
}
|
|
|
|
/* Pico: rotación al abrir/cerrar */
|
|
.beak-top, .beak-bottom {
|
|
transform-origin: 286px 150px;
|
|
transition: transform 0.2s ease;
|
|
}
|
|
|
|
/* "Hambriento" -> pico abierto */
|
|
.is-hungry .beak-top { transform: rotate(-15deg); }
|
|
.is-hungry .beak-bottom { transform: rotate(15deg); }
|
|
|
|
/* Carta atrapada -> pico ligeramente apretado (BUG FIX: era descendant combinator) */
|
|
.state-delivering .beak-top,
|
|
.state-flying .beak-top { transform: rotate(-2deg); }
|
|
.state-delivering .beak-bottom,
|
|
.state-flying .beak-bottom { transform: rotate(2deg); }
|
|
|
|
/* Vuelo */
|
|
.state-flying .pigeon-actor {
|
|
animation: pigeonFlyAway 1.8s cubic-bezier(0.55, 0.055, 0.675, 0.19) forwards;
|
|
}
|
|
|
|
.state-flying .pigeon-wing {
|
|
animation: wingFlap 0.15s ease-in-out infinite alternate;
|
|
transform-origin: 200px 210px;
|
|
}
|
|
.state-flying .pigeon-wing-back {
|
|
animation: wingFlapBack 0.15s ease-in-out infinite alternate;
|
|
transform-origin: 175px 200px;
|
|
}
|
|
.state-flying .pigeon-legs {
|
|
transform: scaleY(0.2) translateY(-30px);
|
|
opacity: 0.5;
|
|
transition: transform 0.4s ease;
|
|
}
|
|
|
|
@keyframes pigeonFlyAway {
|
|
0% { transform: translate(0, 0) scale(1); }
|
|
20% { transform: translate(-10px, 15px) scale(0.98); }
|
|
100% { transform: translate(320px, -450px) scale(0.25); }
|
|
}
|
|
|
|
@keyframes wingFlap {
|
|
0% { transform: rotate(0deg) scaleY(1); }
|
|
100% { transform: rotate(-65deg) scaleY(0.4); }
|
|
}
|
|
@keyframes wingFlapBack {
|
|
0% { transform: rotate(0deg) scaleY(0.4); }
|
|
100% { transform: rotate(50deg) scaleY(1.2); }
|
|
}
|
|
|
|
/* --- CARTA --- */
|
|
.letter-envelope {
|
|
position: absolute;
|
|
z-index: 50;
|
|
left: 0;
|
|
top: 0;
|
|
width: 140px;
|
|
height: 90px;
|
|
transform: translate3d(var(--x), var(--y), 0);
|
|
touch-action: none;
|
|
cursor: grab;
|
|
will-change: transform;
|
|
}
|
|
.letter-envelope:active { cursor: grabbing; }
|
|
|
|
.status-delivering {
|
|
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.status-flying {
|
|
pointer-events: none;
|
|
animation: letterFlyAway 1.8s cubic-bezier(0.55, 0.055, 0.675, 0.19) forwards;
|
|
transform-origin: left center;
|
|
}
|
|
|
|
@keyframes letterFlyAway {
|
|
0% { transform: translate3d(var(--x), var(--y), 0) scale(1) rotate(0deg); }
|
|
20% { transform: translate3d(calc(var(--x) - 10px), calc(var(--y) + 15px), 0) scale(0.98); }
|
|
100% {
|
|
transform: translate3d(calc(var(--x) + 320px), calc(var(--y) - -30px - 450px), 0) scale(0.25) rotate(-15deg);
|
|
opacity: 0;
|
|
}
|
|
}
|
|
|
|
.success-card { text-align: center; }
|
|
.success-icon { font-size: 3rem; margin-bottom: 1rem; }
|
|
|
|
@media (max-width: 768px) {
|
|
.contact-stage { grid-template-columns: 1fr; }
|
|
.pigeon-container { height: 300px; }
|
|
}
|
|
</style> |