fix(blocks): contact — la demo al dia con el block que ya coordina

El add del commit anterior capturo la iteracion previa de ContactSite (el
app poseia schema/labels/disabled); el guardado del editor con la version
final — el block coordina y al app le quedan sus palabras, sus canales, el
reto y el significado de enviar — llego justo despues. Esta es esa version.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 2 months ago
parent d241409138
commit 59bd5ffb71

@ -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">

Loading…
Cancel
Save

Powered by TurnKey Linux.