parent
e7dd9b4599
commit
b390dcf6c0
@ -0,0 +1,253 @@
|
||||
<script lang="ts">
|
||||
/** El mismo formulario sirve en la página directa y en el diálogo global. */
|
||||
import { applyAction, enhance } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
import { untrack } from 'svelte';
|
||||
import { MOTIVOS, type ErroresContacto, type ValoresContacto } from '$lib/contacto';
|
||||
|
||||
interface RespuestaContacto {
|
||||
errores?: ErroresContacto;
|
||||
valores?: ValoresContacto;
|
||||
exito?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
resultadoInicial?: RespuestaContacto | null;
|
||||
/** Evita identificadores repetidos si el diálogo se abre sobre /contacto. */
|
||||
prefijoId?: string;
|
||||
compacto?: boolean;
|
||||
}
|
||||
|
||||
let { resultadoInicial = null, prefijoId = '', compacto = false }: Props = $props();
|
||||
|
||||
let enviando = $state(false);
|
||||
let errorConexion = $state('');
|
||||
let respuesta = $state<RespuestaContacto | null>(untrack(() => resultadoInicial));
|
||||
let campos = $state(
|
||||
untrack(() => ({
|
||||
nombre: resultadoInicial?.valores?.nombre ?? '',
|
||||
email: resultadoInicial?.valores?.email ?? '',
|
||||
motivo: resultadoInicial?.valores?.motivo ?? '',
|
||||
mensaje: resultadoInicial?.valores?.mensaje ?? ''
|
||||
}))
|
||||
);
|
||||
|
||||
const errores = $derived(respuesta?.errores);
|
||||
const exito = $derived(Boolean(respuesta?.exito));
|
||||
const id = (campo: string) => (prefijoId ? `${prefijoId}-${campo}` : campo);
|
||||
</script>
|
||||
|
||||
<div class="contacto" class:compacto>
|
||||
{#if exito}
|
||||
<div class="aviso exito" role="status">
|
||||
<h3>Mensaje enviado</h3>
|
||||
<p>Gracias por escribir. Te responderemos al correo que has indicado.</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#if errorConexion || errores?.general}
|
||||
<p class="aviso error" role="alert">{errorConexion || errores?.general}</p>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action={resolve('/contacto')}
|
||||
class="formulario"
|
||||
use:enhance={() => {
|
||||
enviando = true;
|
||||
errorConexion = '';
|
||||
respuesta = null;
|
||||
return async ({ result }) => {
|
||||
try {
|
||||
if (result.type === 'failure' || result.type === 'success') {
|
||||
respuesta = (result.data as RespuestaContacto | undefined) ?? null;
|
||||
if (result.type === 'success' && respuesta?.exito) {
|
||||
campos = { nombre: '', email: '', motivo: '', mensaje: '' };
|
||||
}
|
||||
} else if (result.type === 'redirect') {
|
||||
await applyAction(result);
|
||||
} else {
|
||||
errorConexion =
|
||||
'No se ha podido confirmar el envío. Conservamos tu mensaje; inténtalo de nuevo.';
|
||||
}
|
||||
} finally {
|
||||
enviando = false;
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<!-- Campo trampa para envíos automáticos. -->
|
||||
<div class="trampa" aria-hidden="true">
|
||||
<label for={id('companiaWeb')}>No rellenes este campo</label>
|
||||
<input
|
||||
id={id('companiaWeb')}
|
||||
name="companiaWeb"
|
||||
type="text"
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="campo">
|
||||
<label for={id('nombre')}>Nombre</label>
|
||||
<input
|
||||
id={id('nombre')}
|
||||
name="nombre"
|
||||
type="text"
|
||||
required
|
||||
autocomplete="name"
|
||||
bind:value={campos.nombre}
|
||||
aria-invalid={errores?.nombre ? 'true' : undefined}
|
||||
aria-describedby={errores?.nombre ? id('error-nombre') : undefined}
|
||||
/>
|
||||
{#if errores?.nombre}
|
||||
<p class="error" id={id('error-nombre')}>{errores.nombre}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="campo">
|
||||
<label for={id('email')}>Correo electrónico</label>
|
||||
<input
|
||||
id={id('email')}
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
bind:value={campos.email}
|
||||
aria-invalid={errores?.email ? 'true' : undefined}
|
||||
aria-describedby={errores?.email ? id('error-email') : undefined}
|
||||
/>
|
||||
{#if errores?.email}
|
||||
<p class="error" id={id('error-email')}>{errores.email}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="campo">
|
||||
<label for={id('motivo')}>Motivo</label>
|
||||
<select
|
||||
id={id('motivo')}
|
||||
name="motivo"
|
||||
bind:value={campos.motivo}
|
||||
required
|
||||
aria-invalid={errores?.motivo ? 'true' : undefined}
|
||||
aria-describedby={errores?.motivo ? id('error-motivo') : undefined}
|
||||
>
|
||||
<option value="" disabled>Elige un motivo</option>
|
||||
{#each MOTIVOS as opcion (opcion.valor)}
|
||||
<option value={opcion.valor}>{opcion.etiqueta}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if errores?.motivo}
|
||||
<p class="error" id={id('error-motivo')}>{errores.motivo}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="campo">
|
||||
<label for={id('mensaje')}>Mensaje</label>
|
||||
<textarea
|
||||
id={id('mensaje')}
|
||||
name="mensaje"
|
||||
bind:value={campos.mensaje}
|
||||
rows={compacto ? 5 : 8}
|
||||
required
|
||||
aria-invalid={errores?.mensaje ? 'true' : undefined}
|
||||
aria-describedby={errores?.mensaje ? id('error-mensaje') : undefined}></textarea>
|
||||
{#if errores?.mensaje}
|
||||
<p class="error" id={id('error-mensaje')}>{errores.mensaje}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button class="btn btn--primary" type="submit" disabled={enviando}>
|
||||
{enviando ? 'Enviando...' : 'Enviar mensaje'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.formulario {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
max-width: 34rem;
|
||||
margin-bottom: var(--sp-8);
|
||||
}
|
||||
|
||||
.compacto .formulario {
|
||||
gap: var(--sp-3);
|
||||
max-width: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.campo {
|
||||
display: grid;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-boton);
|
||||
background: var(--c-bg);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
[aria-invalid='true'] {
|
||||
border-color: var(--c-danger);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
color: var(--c-danger);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.aviso {
|
||||
max-width: 34rem;
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-tarjeta);
|
||||
margin-bottom: var(--sp-6);
|
||||
}
|
||||
|
||||
.compacto .aviso {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.aviso.error {
|
||||
border-color: var(--c-danger);
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
.aviso.exito h3 {
|
||||
margin-top: 0;
|
||||
font-size: var(--fs-lg);
|
||||
}
|
||||
|
||||
.aviso.exito p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.trampa {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button[type='submit'] {
|
||||
justify-self: start;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,231 @@
|
||||
<script lang="ts">
|
||||
/** Contacto global con el mismo lenguaje visual y modal que el acceso. */
|
||||
import { contactoModal } from '$lib/contacto-modal.svelte';
|
||||
import { heroe, site } from '$lib/site';
|
||||
import FormularioContacto from './FormularioContacto.svelte';
|
||||
import Icono from './Icono.svelte';
|
||||
|
||||
let dialogo = $state<HTMLDialogElement | null>(null);
|
||||
const imagen = heroe.fondo.oscuro;
|
||||
|
||||
$effect(() => {
|
||||
if (!dialogo) return;
|
||||
if (contactoModal.abierto && !dialogo.open) {
|
||||
dialogo.showModal();
|
||||
dialogo.querySelector<HTMLInputElement>('input[name="nombre"]')?.focus();
|
||||
} else if (!contactoModal.abierto && dialogo.open) {
|
||||
dialogo.close();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<dialog
|
||||
bind:this={dialogo}
|
||||
class="contacto-modal"
|
||||
aria-labelledby="titulo-contacto-modal"
|
||||
aria-describedby="descripcion-contacto-modal"
|
||||
onclose={() => contactoModal.cerrar()}
|
||||
onclick={(evento) => {
|
||||
if (evento.target === dialogo) contactoModal.cerrar();
|
||||
}}
|
||||
>
|
||||
<div class="marco">
|
||||
<div class="ilustracion" aria-hidden="true">
|
||||
<img
|
||||
src={imagen.src}
|
||||
srcset={imagen.srcset}
|
||||
sizes="20rem"
|
||||
width={imagen.ancho}
|
||||
height={imagen.alto}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
<div class="sobreimpreso">
|
||||
<p class="marca">Senza Paura</p>
|
||||
<p class="lema">Cuéntame qué necesitas y te responderé personalmente.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2 id="titulo-contacto-modal">Contacto</h2>
|
||||
<p id="descripcion-contacto-modal" class="descripcion">
|
||||
Para colaboraciones, licencias, contratación o prensa. También puedes escribir a
|
||||
<a href="mailto:{site.email}">{site.email}</a>.
|
||||
</p>
|
||||
|
||||
{#if contactoModal.abierto}
|
||||
<FormularioContacto prefijoId="modal-contacto" compacto />
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="cerrar"
|
||||
onclick={() => contactoModal.cerrar()}
|
||||
aria-label="Cerrar contacto"
|
||||
>
|
||||
<Icono nombre="cerrar" tamano={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.contacto-modal {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
margin: 0;
|
||||
padding: var(--sp-4);
|
||||
border: none;
|
||||
background: transparent;
|
||||
overflow: auto;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.contacto-modal[open] {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.contacto-modal::backdrop {
|
||||
background: rgb(3 8 20 / 72%);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.marco {
|
||||
display: grid;
|
||||
grid-template-columns: 20rem minmax(0, 30rem);
|
||||
width: 100%;
|
||||
max-width: 50rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-tarjeta);
|
||||
background: var(--c-bg-raised);
|
||||
box-shadow: 0 24px 64px rgb(0 0 0 / 45%);
|
||||
}
|
||||
|
||||
.ilustracion {
|
||||
position: relative;
|
||||
background: var(--c-bg-sunken);
|
||||
}
|
||||
|
||||
.ilustracion img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: 12% center;
|
||||
}
|
||||
|
||||
.sobreimpreso {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-5);
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
color-mix(in oklab, #030814 88%, transparent) 0%,
|
||||
color-mix(in oklab, #030814 40%, transparent) 45%,
|
||||
transparent 75%
|
||||
);
|
||||
}
|
||||
|
||||
.marca {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--fs-xl);
|
||||
background: var(--oro);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.lema {
|
||||
margin: 0;
|
||||
color: rgb(255 255 255 / 78%);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: relative;
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 var(--sp-2);
|
||||
font-size: var(--fs-xl);
|
||||
}
|
||||
|
||||
.descripcion {
|
||||
margin: 0 0 var(--sp-4);
|
||||
padding-right: var(--sp-5);
|
||||
color: var(--c-text-muted);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cerrar {
|
||||
position: absolute;
|
||||
top: var(--sp-3);
|
||||
right: var(--sp-3);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-boton);
|
||||
background: transparent;
|
||||
color: var(--c-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cerrar:hover,
|
||||
.cerrar:focus-visible {
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
.marco {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
max-width: 30rem;
|
||||
}
|
||||
|
||||
.ilustracion {
|
||||
height: 8rem;
|
||||
}
|
||||
|
||||
.sobreimpreso {
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
|
||||
.lema {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
.contacto-modal {
|
||||
padding: var(--sp-2);
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: var(--sp-5) var(--sp-4) var(--sp-4);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 42rem) {
|
||||
.marco {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
max-width: 30rem;
|
||||
}
|
||||
|
||||
.ilustracion {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Estado del diálogo global de contacto.
|
||||
*
|
||||
* Igual que el acceso, no pertenece al pie que lo abre: el diálogo se monta
|
||||
* una sola vez en el layout para seguir disponible desde cualquier página.
|
||||
*/
|
||||
class ControlDeContacto {
|
||||
abierto = $state(false);
|
||||
#origen: HTMLElement | null = null;
|
||||
|
||||
abrir(): void {
|
||||
this.#origen = typeof document === 'undefined' ? null : (document.activeElement as HTMLElement);
|
||||
this.abierto = true;
|
||||
}
|
||||
|
||||
cerrar(): void {
|
||||
this.abierto = false;
|
||||
this.#origen?.focus?.();
|
||||
this.#origen = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const contactoModal = new ControlDeContacto();
|
||||
|
||||
/**
|
||||
* Abre el diálogo en una pulsación normal. El enlace conserva su `href` para
|
||||
* funcionar sin JavaScript y para poder abrirse en otra pestaña.
|
||||
*/
|
||||
export function pedirContacto(evento: MouseEvent): boolean {
|
||||
if (
|
||||
evento.defaultPrevented ||
|
||||
evento.metaKey ||
|
||||
evento.ctrlKey ||
|
||||
evento.shiftKey ||
|
||||
evento.altKey ||
|
||||
evento.button !== 0
|
||||
)
|
||||
return false;
|
||||
|
||||
evento.preventDefault();
|
||||
contactoModal.abrir();
|
||||
return true;
|
||||
}
|
||||
Loading…
Reference in new issue