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.
svelte-kit-vice/web/routes/demos/mail.svelte

1137 lines
21 KiB

<script lang="ts">
type ContactPayload = {
email: string;
text: string;
};
type Status =
| 'editing'
| 'folding'
| 'ready'
| 'dragging'
| 'delivering'
| 'sending'
| '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 mailboxSlotEl: SVGRectElement;
let mailboxDropZoneEl: SVGRectElement;
const letterSize = {
width: 180,
height: 116
};
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 === 'sending'
);
let showFloatingHint = $derived(
status === 'ready' ||
status === 'dragging' ||
status === 'delivering' ||
status === 'sending'
);
let mailboxReceiving = $derived(status === 'delivering' || status === 'sending');
let flagLowered = $derived(
status === 'delivering' ||
status === 'sending' ||
status === 'sent'
);
let instruction = $derived.by(() => {
if (status === 'editing') return 'Rellena el formulario.';
if (status === 'folding') return 'Convirtiendo el formulario en una carta...';
if (status === 'ready') return 'Arrastra la carta hasta el buzón.';
if (status === 'dragging') return 'Suelta la carta sobre el buzón.';
if (status === 'delivering') return 'Metiendo la carta en el buzón...';
if (status === 'sending') return 'Enviando mensaje...';
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';
}, 820);
}
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 returnLetterHome() {
letter.x = letter.homeX;
letter.y = letter.homeY;
}
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;
}
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 (!isLetterOverMailbox()) {
error = 'Tienes que soltar la carta encima del buzón.';
status = 'ready';
returnLetterHome();
return;
}
await deliverLetterIntoMailbox();
await sendForm();
}
function cancelDrag(event: PointerEvent) {
const target = event.currentTarget as HTMLElement;
if (target.hasPointerCapture(event.pointerId)) {
target.releasePointerCapture(event.pointerId);
}
letter.isCaptured = false;
if (status === 'dragging') {
status = 'ready';
returnLetterHome();
}
}
function isLetterOverMailbox() {
const dropRect = mailboxDropZoneEl.getBoundingClientRect();
const letterRect = letterEl.getBoundingClientRect();
const letterCenterX = letterRect.left + letterRect.width / 2;
const letterCenterY = letterRect.top + letterRect.height / 2;
return (
letterCenterX >= dropRect.left &&
letterCenterX <= dropRect.right &&
letterCenterY >= dropRect.top &&
letterCenterY <= dropRect.bottom
);
}
function snapLetterToSlot() {
const stageRect = stageEl.getBoundingClientRect();
const slotRect = mailboxSlotEl.getBoundingClientRect();
letter.x =
slotRect.left -
stageRect.left +
slotRect.width / 2 -
letterSize.width / 2;
letter.y =
slotRect.top -
stageRect.top +
slotRect.height / 2 -
letterSize.height / 2;
}
async function deliverLetterIntoMailbox() {
error = '';
snapLetterToSlot();
status = 'delivering';
await sleep(760);
status = 'sending';
await sleep(280);
}
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(`Error ${response.status}`);
}
}
status = 'sent';
} catch {
returnLetterHome();
status = 'ready';
error = 'No se pudo enviar el mensaje. Inténtalo otra vez.';
}
}
function resetForm() {
form.email = '';
form.text = '';
error = '';
status = 'editing';
letter.x = 0;
letter.y = 0;
letter.homeX = 0;
letter.homeY = 0;
letter.dragStartX = 0;
letter.dragStartY = 0;
letter.pointerStartX = 0;
letter.pointerStartY = 0;
letter.isCaptured = false;
}
</script>
<section
bind:this={stageEl}
class={`contact-mailbox ${status === 'folding' ? 'is-folding' : ''}`}
>
{#if showFloatingHint}
<div class="floating-hint">
<p>{instruction}</p>
{#if error}
<span role="alert">{error}</span>
{/if}
</div>
{/if}
<div class="left-panel">
{#if showForm}
<div bind:this={formCardEl} class="form-card">
<form onsubmit={startChallenge}>
<div class="eyebrow">Contacto</div>
<h2>Envíanos un mensaje</h2>
<label>
<span>Email</span>
<input
type="email"
name="email"
autocomplete="email"
placeholder="tu@email.com"
bind:value={form.email}
disabled={status !== 'editing'}
required
/>
</label>
<label>
<span>Texto</span>
<textarea
name="text"
rows="6"
placeholder="Escribe tu mensaje..."
bind:value={form.text}
disabled={status !== 'editing'}
required
></textarea>
</label>
<button type="submit" disabled={!canSubmit}>
Preparar carta
</button>
<p class="hint">{instruction}</p>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
</form>
</div>
{/if}
{#if status === 'sent'}
<div class="success-card">
<div class="success-icon">✓</div>
<h3>Mensaje enviado</h3>
<p>La carta ha llegado al buzón.</p>
<button type="button" onclick={resetForm}>
Enviar otro mensaje
</button>
</div>
{/if}
</div>
<div class="right-panel" aria-hidden="true">
<svg
class={`mailbox ${mailboxReceiving ? 'is-receiving' : ''} ${flagLowered ? 'is-flag-down' : ''}`}
viewBox="0 0 420 420"
role="img"
>
<defs>
<linearGradient id="mailboxBody" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="#4f8cff" />
<stop offset="100%" stop-color="#2457d6" />
</linearGradient>
<linearGradient id="mailboxFlag" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="#ff7468" />
<stop offset="100%" stop-color="#ef3f48" />
</linearGradient>
</defs>
<ellipse
class="mailbox-shadow"
cx="210"
cy="354"
rx="132"
ry="24"
fill="#dce7ff"
/>
<g class="mailbox-visual">
<path
class="mailbox-shell"
d="M110 194c0-68 55-123 123-123h4c68 0 123 55 123 123v127H110Z"
fill="url(#mailboxBody)"
/>
<path
class="mailbox-roof"
d="M110 194c0-68 55-123 123-123h4c68 0 123 55 123 123H110Z"
fill="#78a9ff"
opacity="0.85"
/>
<rect
class="mailbox-front"
x="110"
y="188"
width="250"
height="133"
rx="18"
fill="#2f6de8"
/>
<rect
bind:this={mailboxSlotEl}
class="mailbox-slot"
x="142"
y="220"
width="186"
height="34"
rx="17"
fill="#10255f"
/>
<rect
class="mailbox-slot-shine"
x="156"
y="232"
width="158"
height="8"
rx="4"
fill="#020b2e"
opacity="0.5"
/>
<rect
class="mailbox-slot-lip"
x="148"
y="214"
width="174"
height="9"
rx="5"
fill="#91b7ff"
opacity="0.65"
/>
<rect
bind:this={mailboxDropZoneEl}
x="118"
y="176"
width="248"
height="132"
rx="28"
fill="transparent"
pointer-events="none"
/>
<rect x="184" y="320" width="34" height="72" rx="10" fill="#29447a" />
<rect x="246" y="320" width="34" height="72" rx="10" fill="#29447a" />
<circle cx="186" cy="238" r="6" fill="#91b7ff" />
<circle cx="302" cy="238" r="6" fill="#91b7ff" />
</g>
<g class="mailbox-flag-pole">
<rect x="274" y="126" width="16" height="154" rx="8" fill="#133a9c" />
<circle cx="282" cy="158" r="13" fill="#10255f" />
<circle cx="282" cy="158" r="7" fill="#91b7ff" />
</g>
<g class="mailbox-flag-arm">
<rect
x="282"
y="132"
width="92"
height="52"
rx="14"
fill="url(#mailboxFlag)"
/>
<rect
x="282"
y="132"
width="18"
height="52"
rx="9"
fill="#c82f3a"
opacity="0.38"
/>
</g>
<g
class="mailbox-sparks"
fill="none"
stroke="#ffd166"
stroke-width="7"
stroke-linecap="round"
>
<path d="M92 196l-26-13" />
<path d="M94 240H62" />
<path d="M103 282l-24 18" />
<path d="M368 196l26-13" />
<path d="M370 240h32" />
<path d="M360 282l24 18" />
</g>
</svg>
</div>
{#if showLetter}
<div
bind:this={letterEl}
class={`letter ${status === 'dragging' ? 'is-dragging' : ''} ${status === 'delivering' ? 'is-delivering' : ''} ${status === 'sending' ? 'is-sending' : ''}`}
style={`--x: ${letter.x}px; --y: ${letter.y}px;`}
aria-label="Carta del formulario. Arrástrala hasta el buzón."
onpointerdown={grabLetter}
onpointermove={moveLetter}
onpointerup={releaseLetter}
onpointercancel={cancelDrag}
>
<div class="letter-front">
<div class="letter-flap"></div>
<div class="letter-lines">
<span></span>
<span></span>
<span></span>
</div>
<div class="stamp"></div>
</div>
</div>
{/if}
</section>
<style>
:global(*) {
box-sizing: border-box;
}
.contact-mailbox {
position: relative;
display: grid;
grid-template-columns: minmax(280px, 1fr) minmax(280px, 0.9fr);
gap: clamp(1.5rem, 4vw, 4rem);
align-items: center;
min-height: 560px;
padding: clamp(1rem, 3vw, 2.5rem);
overflow: hidden;
border-radius: 32px;
background:
radial-gradient(circle at 82% 18%, rgba(79, 140, 255, 0.22), transparent 34%),
linear-gradient(135deg, #f7faff 0%, #eef4ff 100%);
}
.left-panel,
.right-panel {
position: relative;
z-index: 1;
}
.left-panel {
min-height: 460px;
display: grid;
place-items: center;
}
.form-card,
.success-card {
width: min(100%, 440px);
padding: 2rem;
border: 1px solid rgba(31, 58, 113, 0.1);
border-radius: 28px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 24px 70px rgba(36, 87, 214, 0.14);
backdrop-filter: blur(14px);
}
.form-card {
transform-origin: center;
transition:
transform 0.8s cubic-bezier(0.65, 0, 0.2, 1),
opacity 0.45s ease,
filter 0.45s ease;
}
.is-folding .form-card {
pointer-events: none;
opacity: 0;
filter: blur(4px);
transform:
perspective(900px)
rotateX(72deg)
scale(0.42)
translateY(30px);
}
.floating-hint {
position: absolute;
left: clamp(1rem, 3vw, 2.5rem);
top: clamp(1rem, 3vw, 2.5rem);
z-index: 5;
max-width: 360px;
padding: 0.85rem 1rem;
border: 1px solid rgba(31, 58, 113, 0.1);
border-radius: 18px;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 14px 40px rgba(16, 37, 95, 0.12);
backdrop-filter: blur(12px);
}
.floating-hint p {
margin: 0;
color: #10255f;
font-weight: 800;
}
.floating-hint span {
display: block;
margin-top: 0.35rem;
color: #c1272d;
font-size: 0.92rem;
font-weight: 700;
}
.eyebrow {
width: fit-content;
margin-bottom: 0.75rem;
padding: 0.35rem 0.7rem;
border-radius: 999px;
color: #2457d6;
background: #e7efff;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
h2,
h3,
p {
margin: 0;
}
h2 {
margin-bottom: 1.4rem;
color: #10255f;
font-size: clamp(1.7rem, 3vw, 2.35rem);
line-height: 1.05;
}
label {
display: grid;
gap: 0.45rem;
margin-bottom: 1rem;
color: #22345f;
font-weight: 700;
}
input,
textarea {
width: 100%;
border: 1px solid #d7e2ff;
border-radius: 16px;
padding: 0.9rem 1rem;
color: #10255f;
background: #fbfdff;
font: inherit;
outline: none;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
background 0.2s ease;
}
textarea {
resize: vertical;
min-height: 140px;
}
input:focus,
textarea:focus {
border-color: #4f8cff;
box-shadow: 0 0 0 4px rgba(79, 140, 255, 0.16);
background: #ffffff;
}
input:disabled,
textarea:disabled {
opacity: 0.6;
}
button {
border: 0;
border-radius: 999px;
padding: 0.9rem 1.3rem;
color: #ffffff;
background: #2457d6;
font: inherit;
font-weight: 800;
cursor: pointer;
box-shadow: 0 14px 30px rgba(36, 87, 214, 0.24);
transition:
transform 0.2s ease,
opacity 0.2s ease,
box-shadow 0.2s ease;
}
button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 18px 38px rgba(36, 87, 214, 0.3);
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
box-shadow: none;
}
.hint {
margin-top: 1rem;
color: #607198;
font-size: 0.94rem;
}
.error {
margin-top: 0.75rem;
color: #c1272d;
font-weight: 700;
}
.right-panel {
display: grid;
place-items: center;
min-height: 420px;
}
.mailbox {
width: min(100%, 420px);
height: auto;
overflow: visible;
filter: drop-shadow(0 28px 40px rgba(36, 87, 214, 0.22));
}
.mailbox-visual,
.mailbox-shadow,
.mailbox-slot,
.mailbox-slot-shine,
.mailbox-slot-lip,
.mailbox-flag-arm,
.mailbox-sparks {
transform-box: fill-box;
}
.mailbox-visual {
transform-origin: center bottom;
}
.mailbox-shadow {
transform-origin: center;
}
.mailbox-slot,
.mailbox-slot-shine,
.mailbox-slot-lip {
transform-origin: center;
}
.mailbox-flag-arm {
transform-box: view-box;
transform-origin: 282px 158px;
transition: transform 0.72s cubic-bezier(0.2, 0.75, 0.2, 1.25);
}
.mailbox.is-receiving .mailbox-flag-arm {
transition-delay: 0.18s;
}
.mailbox.is-flag-down .mailbox-flag-arm {
transform: rotate(92deg);
}
.mailbox-sparks {
opacity: 0;
transform-origin: center;
}
.mailbox.is-receiving .mailbox-visual {
animation: mailbox-receive 0.76s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.mailbox.is-receiving .mailbox-shadow {
animation: mailbox-shadow 0.76s ease both;
}
.mailbox.is-receiving .mailbox-slot {
animation: mailbox-slot-swallow 0.76s ease both;
}
.mailbox.is-receiving .mailbox-slot-shine {
animation: mailbox-slot-shine 0.76s ease both;
}
.mailbox.is-receiving .mailbox-slot-lip {
animation: mailbox-slot-lip 0.76s ease both;
}
.mailbox.is-receiving .mailbox-sparks {
animation: mailbox-sparks 0.76s ease both;
}
.letter {
position: absolute;
z-index: 4;
left: 0;
top: 0;
width: 180px;
height: 116px;
transform: translate3d(var(--x), var(--y), 0) scale(1);
touch-action: none;
cursor: grab;
transition:
transform 0.35s cubic-bezier(0.2, 0.8, 0.2, 1),
opacity 0.35s ease;
will-change: transform, opacity;
}
.letter.is-dragging {
cursor: grabbing;
transform: translate3d(var(--x), var(--y), 0) scale(1.04);
transition: none;
filter: drop-shadow(0 22px 30px rgba(16, 37, 95, 0.24));
}
.letter.is-delivering {
pointer-events: none;
cursor: default;
transform: translate3d(var(--x), var(--y), 0) scale(0.62);
transition:
transform 0.52s cubic-bezier(0.65, 0, 0.2, 1),
opacity 0.35s ease;
}
.letter.is-sending {
pointer-events: none;
cursor: default;
opacity: 0;
transform: translate3d(var(--x), var(--y), 0) scale(0.16);
transition:
transform 0.28s ease,
opacity 0.28s ease;
}
.letter-front {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
border: 2px solid #d7e2ff;
border-radius: 16px;
background: #fffdf8;
box-shadow: 0 18px 50px rgba(16, 37, 95, 0.18);
}
.letter-front::before,
.letter-front::after {
content: '';
position: absolute;
bottom: -2px;
width: 102px;
height: 102px;
border-top: 2px solid #d7e2ff;
background: #fff6e7;
}
.letter-front::before {
left: -16px;
transform: rotate(38deg);
}
.letter-front::after {
right: -16px;
transform: rotate(-38deg);
}
.letter-flap {
position: absolute;
left: 50%;
top: -62px;
width: 126px;
height: 126px;
border-right: 2px solid #d7e2ff;
border-bottom: 2px solid #d7e2ff;
background: #fff8ec;
transform: translateX(-50%) rotate(45deg);
}
.letter-lines {
position: absolute;
left: 22px;
top: 42px;
display: grid;
gap: 8px;
width: 82px;
}
.letter-lines span {
display: block;
height: 7px;
border-radius: 999px;
background: #dce7ff;
}
.letter-lines span:nth-child(2) {
width: 72%;
}
.letter-lines span:nth-child(3) {
width: 88%;
}
.stamp {
position: absolute;
right: 22px;
top: 24px;
width: 34px;
height: 34px;
border-radius: 10px;
background:
linear-gradient(
45deg,
transparent 45%,
rgba(255, 255, 255, 0.7) 45% 55%,
transparent 55%
),
#ff7468;
box-shadow: inset 0 0 0 3px rgba(255, 255, 255, 0.55);
}
.success-card {
text-align: center;
}
.success-icon {
display: grid;
place-items: center;
width: 64px;
height: 64px;
margin: 0 auto 1rem;
border-radius: 999px;
color: #ffffff;
background: #20b26b;
font-size: 2rem;
font-weight: 900;
}
.success-card h3 {
margin-bottom: 0.5rem;
color: #10255f;
font-size: 1.6rem;
}
.success-card p {
margin-bottom: 1.2rem;
color: #607198;
}
@keyframes mailbox-receive {
0% {
transform: translateY(0) scale(1) rotate(0);
}
18% {
transform: translateY(-8px) scale(1.018, 0.982) rotate(-0.8deg);
}
42% {
transform: translateY(5px) scale(0.985, 1.018) rotate(0.8deg);
}
64% {
transform: translateY(-3px) scale(1.006, 0.994) rotate(-0.35deg);
}
100% {
transform: translateY(0) scale(1) rotate(0);
}
}
@keyframes mailbox-shadow {
0% {
transform: scale(1);
opacity: 1;
}
20% {
transform: scale(0.86);
opacity: 0.72;
}
44% {
transform: scale(1.08);
opacity: 0.9;
}
100% {
transform: scale(1);
opacity: 1;
}
}
@keyframes mailbox-slot-swallow {
0% {
transform: scaleX(1) scaleY(1);
filter: none;
}
26% {
transform: scaleX(1.16) scaleY(1.34);
filter: drop-shadow(0 0 14px rgba(255, 255, 255, 0.8));
}
52% {
transform: scaleX(0.92) scaleY(0.78);
filter: drop-shadow(0 0 8px rgba(255, 255, 255, 0.45));
}
100% {
transform: scaleX(1) scaleY(1);
filter: none;
}
}
@keyframes mailbox-slot-shine {
0% {
opacity: 0.5;
transform: scaleX(1);
}
28% {
opacity: 0.9;
transform: scaleX(1.08);
}
58% {
opacity: 0.25;
transform: scaleX(0.82);
}
100% {
opacity: 0.5;
transform: scaleX(1);
}
}
@keyframes mailbox-slot-lip {
0% {
transform: translateY(0);
opacity: 0.65;
}
30% {
transform: translateY(-8px);
opacity: 0.95;
}
58% {
transform: translateY(2px);
opacity: 0.5;
}
100% {
transform: translateY(0);
opacity: 0.65;
}
}
@keyframes mailbox-sparks {
0% {
opacity: 0;
transform: scale(0.72);
}
22% {
opacity: 1;
transform: scale(1);
}
58% {
opacity: 0.8;
transform: scale(1.05);
}
100% {
opacity: 0;
transform: scale(1.12);
}
}
@media (max-width: 820px) {
.contact-mailbox {
grid-template-columns: 1fr;
}
.right-panel {
min-height: 320px;
}
.mailbox {
max-width: 340px;
}
.floating-hint {
position: relative;
left: auto;
top: auto;
grid-column: 1;
margin-bottom: 1rem;
}
}
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
animation: none !important;
}
}
</style>

Powered by TurnKey Linux.