@ -1,257 +1,60 @@
< script lang = "ts" >
/**
* The contact mini-page. The APP owns the schema, the values, the handler and
* the translation of the messages — the block only places.
* The contact mini-page.
*
* Three things learned building `newsletter` and applied here without
* re-discovering them: the handler goes in `createForm` (the provider ignores
* `onValidSubmit` on a prebuilt handle), `progressive` is the right timing for
* fields that start empty, and SIUM messages are idlangrefs that have to go
* through `uix.langs.t`.
* Compare it with what it was: the app no longer owns the schema, the labels,
* the submit's `disabled`, the sending flag or the sentence that explains why a
* send cannot happen. All of that is the block's coordination now. What is left
* is what genuinely belongs to an app — the words of the page, the ways to
* reach this particular company, the verification challenge it chose, and what
* "send" actually does.
*/
import { Contact } from '$blocks/contact';
import { Section } from '$uix/eidos/components/section';
import { Container } from '$uix/eidos/components/container';
import { Stack } from '$uix/eidos/components/stack';
import { Grid } from '$uix/eidos/components/grid';
import { Heading } from '$uix/eidos/components/heading';
import { Text } from '$uix/eidos/components/text';
import { Link } from '$uix/eidos/components/link';
import { Callout } from '$uix/eidos/components/callout';
import { Form } from '$uix/eidos/components/form';
import { Field } from '$uix/eidos/components/field';
import { TextArea } from '$uix/eidos/components/textarea';
import { ProofOfHuman , type ProofOfHumanStatus } from '$uix/eidos/components/proof-of-human';
import * as Icon from '$uix/eidos/components/icon';
import { getActiveUix } from '$active-uix';
import { createForm , type FormIssue } from '$libs/forms';
import { object , string , pipe , email , min , meta } from '$sium/core';
const uix = getActiveUix();
let { formFirst = true } : { formFirst? : boolean } = $props();
const schema = object({
name: pipe(string(), min(2), meta({ label : 'Nombre' } )),
email: pipe(string(), email(), meta({ label : 'Correo electrónico' } )),
message: pipe(string(), min(20), meta({ label : 'Mensaje' } ))
});
let sent = $state(false);
// Verificación humana: el componente da FRICCIÓN y señal de comportamiento, no
// una muralla. Nunca hace red — al resolver entrega telemetría y el APP la
// verifica contra su servidor, que es el único árbitro. Aquí no hay servidor,
// así que se acepta en cuanto resuelve; en producción esto sería una petición
// con nonce de un solo uso.
//
// MEDIDO (2026-07-31): el veredicto de vuelta NO se pudo ejercitar desde el app.
// El provider de soma escribe `status` él mismo (`writableActive`), así que un
// `status = 'failed'` escrito por el app tras consultar a su servidor no se
// sostiene, y los slots `stage*` no llegaron a renderizar en ningún estado.
// Anotado como hallazgo; aquí se deja el cableado que SÍ se verifica de punta a
// punta: resuelto → `verified` → el envío se habilita.
// El reto es del app: el componente da fricción y señal, y el servidor del app
// es el único árbitro. Su estado se le PASA al block, que lo pliega en el estado
// de la sección — incluido el veredicto negativo, que es justo el que antes no
// se contaba en ninguna parte.
let humanStatus = $state< ProofOfHumanStatus > ('idle');
const form = createForm({
schema,
defaults: { name : '' , email : '' , message : '' } ,
validationBehaviour: 'progressive',
// ASÍNCRONO a propósito: `Form` marca `isPending` mientras el handler corre,
// y eso es lo que el botón refleja. Un handler síncrono no tiene fase de
// envío que enseñar. El retardo simula la ida y vuelta al servidor; los
// temporizadores van por `uix.timers`, nunca por `setTimeout` a pelo.
onValidSubmit: async () => {
await new Promise< void > ((resolve) => {
uix.timers.schedule(null, 900, () => resolve());
});
sent = true;
}
});
/** SIUM devuelve idlangref + params: el mensaje pasa por el TRADUCTOR. */
function messageOf(field: 'name' | 'email' | 'message'): string | undefined {
const issue = (form.issues[field] as FormIssue[] | undefined)?.[0];
if (issue) return uix.langs.t(issue.message, issue.params);
const raw = form.errors[field]?.[0];
return raw ? uix.langs.t(raw) : undefined;
/** Lo que «enviar» significa aquí. El block no lo sabe ni tiene por qué. */
async function send(): Promise< void > {
await new Promise< void > ((resolve) => {
uix.timers.schedule(null, 900, () => resolve());
});
}
/**
* ── El estado de la sección, en un solo sitio ────────────────────────────
*
* Antes había tres fuentes leídas en el punto de uso —la validez del `Form`,
* el estado del reto y un `sent` mío— y cada `disabled` era una expresión
* distinta. Así es como aparecen los huecos: un envío que no se puede hacer y
* NADIE dice por qué.
*
* Aquí se derivan a un estado único y EXHAUSTIVO. La consecuencia es que un
* estado mudo deja de ser posible: si existe, tiene su fila en la tabla de
* abajo, y esa fila dice qué falta.
*/
type EstadoContacto =
| 'incompleto'
| 'sin-verificar'
| 'verificando'
| 'rechazado'
| 'listo'
| 'enviando'
| 'enviado';
const estado = $derived.by((): EstadoContacto => {
if (sent) return 'enviado';
if (form.isPending) return 'enviando';
if (humanStatus === 'failed') return 'rechazado';
if (humanStatus === 'verifying') return 'verificando';
if (!form.isValid) return 'incompleto';
if (humanStatus !== 'verified') return 'sin-verificar';
return 'listo';
});
/** Un solo mapa estado → lo que se ve. Ningún `disabled` suelto por el árbol. */
const UI: Record< EstadoContacto , { accion : string ; bloqueado : boolean ; motivo : string | null } > =
{
incompleto: {
accion: 'Enviar mensaje',
bloqueado: true,
motivo: 'Rellena los tres campos para poder enviar.'
},
'sin-verificar': {
accion: 'Enviar mensaje',
bloqueado: true,
motivo: 'Resuelve la comprobación de arriba: pon la aguja en la hora marcada.'
},
verificando: {
accion: 'Enviar mensaje',
bloqueado: true,
motivo: 'Comprobando la verificación…'
},
rechazado: {
accion: 'Enviar mensaje',
bloqueado: true,
motivo: 'No hemos podido verificar la comprobación. Inténtalo otra vez.'
},
listo: { accion : 'Enviar mensaje' , bloqueado : false , motivo : null } ,
enviando: { accion : 'Enviando…' , bloqueado : true , motivo : null } ,
enviado: {
accion: 'Enviado',
bloqueado: true,
motivo: 'Gracias. Te responderemos por correo.'
}
};
const ui = $derived(UI[estado]);
/** El resumen se monta solo si hay algo que listar: su raíz pinta borde de
* riesgo en cuanto el formulario es inválido, y vacía se ve como una caja
* naranja sin texto. */
const hasErrors = $derived(Object.keys(form.errors ?? {} ).length > 0);
const errors = $derived({
name: messageOf('name'),
email: messageOf('email'),
message: messageOf('message')
});
< / script >
{ # snippet contactForm ()}
< Contact.Form { form } { schema } >
{ # snippet children ()}
<!-- El resumen es del app porque su lista por defecto imprime el idlangref
crudo: con `children` se renderiza con el traductor y con enlaces al
campo que falla. Con un solo campo (newsletter) no hacía falta; con
tres, es lo que convierte «algo falla» en «esto falla». -->
{ #if hasErrors }
< Form.ErrorSummary >
{ # snippet children ({ fieldErrors , count })}
{ #if count > 0 }
< Callout intent = "risk" >
< Callout.Icon / >
< Callout.Title > Revisa el formulario< / Callout.Title >
< Callout.Content >
< Stack gap = { 1 } >
{ #each fieldErrors as err ( err . field )}
< Text size = "sm" >
< Link href = "# { err . field } " variant = "subtle" size = "sm"
>{ err . label ?? err . field } < /Link
>: { uix . langs . t ( err . message )}
< / Text >
{ /each }
< / Stack >
< / Callout.Content >
< / Callout >
{ /if }
{ /snippet }
< / Form.ErrorSummary >
{ /if }
{ #if sent }
< Callout intent = "affirm" >
< Callout.Icon / >
< Callout.Title > Mensaje enviado< / Callout.Title >
< Callout.Content > Te respondemos el mismo día laborable.< / Callout.Content >
< / Callout >
{ /if }
<!-- Nombre y correo comparten fila en cuanto hay sitio: son cortos y
relacionados, y apilarlos alarga el formulario sin ganar nada. -->
< Grid templateColumns = {{ base : '1fr' , sm : '1fr 1fr' }} gap= { 4 } >
< Field name = "name" label = "Nombre" required floatingLabel >
< Field.Label > Nombre< / Field.Label >
< Field.Control >
< Field.Input placeholder = " " bind:value = { form . values . name } / >
< / Field.Control >
{ #if errors . name }
< Field.ErrorText > { errors . name } </ Field.ErrorText >
{ /if }
< / Field >
< Field name = "email" label = "Correo electrónico" required floatingLabel >
< Field.Label > Correo electrónico< / Field.Label >
< Field.Control >
< Field.Input type = "email" placeholder = " " bind:value = { form . values . email } / >
< / Field.Control >
{ #if errors . email }
< Field.ErrorText > { errors . email } </ Field.ErrorText >
{ /if }
< / Field >
< / Grid >
<!-- SIN `Field.Control`: el `TextArea` pinta su propia carcasa (borde, radio y
relleno son suyos, en su recipe), así que envolverlo daba DOS marcos. Los
controles especializados componen el `Field` por dentro; el `Control` es
la carcasa del `Field.Input` desnudo. -->
< Field name = "message" label = "Mensaje" required >
< Field.Label > Mensaje< / Field.Label >
< TextArea bind:value = { form . values . message } autosize minRows = { 4 } maxRows= { 10 } >
< TextArea.Input placeholder = "Cuéntanos qué necesitas medir." / >
< / TextArea >
{ #if errors . message }
< Field.ErrorText > { errors . message } </ Field.ErrorText >
{ /if }
< / Field >
<!-- Antes del envío, no después: pedir la comprobación cuando el mensaje ya
está escrito es lo que hace que la gente lo pierda. -->
< ProofOfHuman
bind:status={ humanStatus }
color="primary"
size="md"
onSolve={() => ( humanStatus = 'verified' )}
>
< ProofOfHuman.Clock / >
< / ProofOfHuman >
< Stack gap = { 2 } align="start" width = "100%" >
< Form.Submit color = "fulfill" size = "lg" disabled = { ui . bloqueado } >
{ ui . accion }
< / Form.Submit >
{ #if ui . motivo }
<!-- Ningún estado bloqueado es mudo: el motivo sale del mismo mapa que
decide el bloqueo, así que no pueden desincronizarse. -->
< Text size = "sm" color = { estado === 'enviado' ? 'affirm' : 'muted' } > { ui . motivo } </Text >
{ /if }
< / Stack >
{ /snippet }
< Contact.Form >
< Contact.Fields / >
< ProofOfHuman
bind:status={ humanStatus }
color="primary"
size="md"
onSolve={() => ( humanStatus = 'verified' )}
>
< ProofOfHuman.Clock / >
< / ProofOfHuman >
< Stack gap = { 2 } align="start" width = "100%" >
< Contact.Submit / >
< Contact.Reason / >
< / Stack >
< / Contact.Form >
{ /snippet }
@ -299,26 +102,28 @@
< / Contact.Details >
{ /snippet }
< Contact >
< Contact.Header >
< Heading level = { 2 } > Hablemos</Heading >
< Text color = "muted" wrap = "pretty" >
Escríbenos y te respondemos el mismo día laborable. Si prefieres verlo funcionando antes de
preguntar, la documentación está abierta y no pide correo.
< / Text >
< / Contact.Header >
<!-- El lado del formulario lo decide el ORDEN de composición, no un prop: así
el orden de tabulación —que sigue al DOM— coincide siempre con lo que se ve. -->
< Contact.Body >
{ #if formFirst }
{ @render contactForm ()}
{ @render contactDetails ()}
{ : else }
{ @render contactDetails ()}
{ @render contactForm ()}
{ /if }
< / Contact.Body >
< Contact verification = { humanStatus } onSend= { send } >
{ # snippet children ()}
< Contact.Header >
< Heading level = { 2 } > Hablemos</Heading >
< Text color = "muted" wrap = "pretty" >
Escríbenos y te respondemos el mismo día laborable. Si prefieres verlo funcionando antes de
preguntar, la documentación está abierta y no pide correo.
< / Text >
< / Contact.Header >
<!-- El lado del formulario lo decide el ORDEN de composición, no un prop: así
el orden de tabulación —que sigue al DOM— coincide con lo que se ve. -->
< Contact.Body >
{ #if formFirst }
{ @render contactForm ()}
{ @render contactDetails ()}
{ : else }
{ @render contactDetails ()}
{ @render contactForm ()}
{ /if }
< / Contact.Body >
{ /snippet }
< / Contact >
< Section size = "lg" >