feat: validate email via Emailable, remove auto-download, fix keyboard nav in inputs
All checks were successful
Deploy Production / Deploy to Production (push) Successful in 32s

- Plug Emailable API into checkEmailDeliverability (fail open if no key)
- Show inline red error + shake animation on email field when address is undeliverable
- Remove window.open auto-download after form submission; success now checks result.success
- Disable arrow-key slide navigation when an input or textarea is focused

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-06-08 14:57:03 +02:00
parent 4ea4cd91f5
commit d087012ea8
4 changed files with 117 additions and 13 deletions

View file

@ -49,10 +49,43 @@ function parseContact(array $body): array
return compact('firstName', 'lastName', 'email', 'company', 'role'); return compact('firstName', 'lastName', 'email', 'company', 'role');
} }
function checkEmailDeliverability(string $email): bool function checkEmailDeliverability(string $email, string $apiKey): bool
{ {
// Plug in an external email validation API here (e.g. Hunter, ZeroBounce, AbstractAPI) if ($apiKey === '') {
return true; error_log('[WP] Emailable: no api_key configured, skipping email check');
return true;
}
$url = 'https://api.emailable.com/v1/verify?' . http_build_query([
'email' => $email,
'api_key' => $apiKey,
]);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPGET => true,
CURLOPT_TIMEOUT => 8,
CURLOPT_SSL_VERIFYPEER => true,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($response === false || $httpCode !== 200) {
error_log('[WP] Emailable check failed (HTTP ' . $httpCode . '): ' . $curlError);
return true; // fail open
}
$data = json_decode($response, true);
$state = $data['state'] ?? 'unknown';
error_log('[WP] Emailable response for ' . $email . ': state=' . $state . ' | raw=' . $response);
return $state !== 'undeliverable';
} }
function upsertKirbyContact(\Kirby\Cms\Page $page, array $contact): void function upsertKirbyContact(\Kirby\Cms\Page $page, array $contact): void
@ -179,7 +212,8 @@ return [
$contact = parseContact($body); $contact = parseContact($body);
if (!checkEmailDeliverability($contact['email'])) { $emailableApiKey = kirby()->option('emailable_whitepaper', [])['api_key'] ?? '';
if (!checkEmailDeliverability($contact['email'], $emailableApiKey)) {
wpReject(422, 'Undeliverable email'); wpReject(422, 'Undeliverable email');
} }

View file

@ -196,6 +196,8 @@
const handleKeydown = (e) => { const handleKeydown = (e) => {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return
const tag = document.activeElement?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || document.activeElement?.isContentEditable) return
// Si on est sur une sous-page (ex: /livres-blancs/slug), ne pas changer de slide // Si on est sur une sous-page (ex: /livres-blancs/slug), ne pas changer de slide
const activePath = slides.active?.path ?? '' const activePath = slides.active?.path ?? ''

View file

@ -14,8 +14,9 @@
let email = $state('') let email = $state('')
let consent = $state(false) let consent = $state(false)
let honeypot = $state('') let honeypot = $state('')
let submitting = $state(false) let submitting = $state(false)
let status = $state(null) // null | 'success' | 'error' let status = $state(null) // null | 'success' | 'error'
let emailInvalid = $state(false)
let isEmailValid = $derived.by(() => { let isEmailValid = $derived.by(() => {
const re = /^[\w\-\.]+@([\w-]+\.)+[\w-]{2,}$/gm const re = /^[\w\-\.]+@([\w-]+\.)+[\w-]{2,}$/gm
@ -66,6 +67,7 @@
if (!consent || !uri) return if (!consent || !uri) return
submitting = true submitting = true
status = null status = null
emailInvalid = false
try { try {
const prefix = locale.current === 'en' ? '/en' : '' const prefix = locale.current === 'en' ? '/en' : ''
const res = await fetch(`${prefix}/${uri}/download`, { const res = await fetch(`${prefix}/${uri}/download`, {
@ -74,8 +76,11 @@
body: JSON.stringify({ firstName, lastName, company, role, email, _hp: honeypot }) body: JSON.stringify({ firstName, lastName, company, role, email, _hp: honeypot })
}) })
const result = await res.json() const result = await res.json()
if (result.fileUrl) { if (res.status === 422 && result.error === 'Undeliverable email') {
window.open(result.fileUrl, '_blank') emailInvalid = true
return
}
if (result.success) {
status = 'success' status = 'success'
} else { } else {
status = 'error' status = 'error'
@ -188,13 +193,19 @@
<input <input
id="wp-email" id="wp-email"
class="wp-dialog__input" class="wp-dialog__input"
class:wp-dialog__input--invalid={emailInvalid}
type="email" type="email"
placeholder={t('wp_email')} placeholder={t('wp_email')}
autocomplete="email" autocomplete="email"
required required
aria-required="true" aria-required="true"
aria-describedby={emailInvalid ? 'wp-email-error' : undefined}
bind:value={email} bind:value={email}
oninput={() => emailInvalid = false}
/> />
{#if emailInvalid}
<p class="wp-dialog__email-error" id="wp-email-error" role="alert">Adresse email invalide</p>
{/if}
</div> </div>
<!-- Honeypot anti-spam --> <!-- Honeypot anti-spam -->
@ -344,6 +355,26 @@
color: rgba(255, 255, 255, 0.4); color: rgba(255, 255, 255, 0.4);
} }
.wp-dialog__input--invalid {
border-color: #ff6b6b;
animation: shake 0.4s ease;
}
.wp-dialog__email-error {
font-family: "Danzza", sans-serif;
font-size: var(--font-size-paragraph-small);
color: #ff6b6b;
margin-top: -0.25rem;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-6px); }
40% { transform: translateX(6px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(3px); }
}
.wp-dialog__hp { .wp-dialog__hp {
position: absolute; position: absolute;
left: -9999px; left: -9999px;

View file

@ -10,8 +10,9 @@
let role = $state('') let role = $state('')
let email = $state('') let email = $state('')
let consent = $state(false) let consent = $state(false)
let submitting = $state(false) let submitting = $state(false)
let status = $state(null) // null | 'success' | 'error' let status = $state(null) // null | 'success' | 'error'
let emailInvalid = $state(false)
let showForm = $state(false) let showForm = $state(false)
let honeypot = $state('') let honeypot = $state('')
@ -29,6 +30,7 @@
if (!consent) return if (!consent) return
submitting = true submitting = true
status = null status = null
emailInvalid = false
try { try {
const prefix = locale.current === 'en' ? '/en' : '' const prefix = locale.current === 'en' ? '/en' : ''
const res = await fetch(`${prefix}/${data.uri}/download`, { const res = await fetch(`${prefix}/${data.uri}/download`, {
@ -37,8 +39,11 @@
body: JSON.stringify({ firstName, lastName, company, role, email, _hp: honeypot }) body: JSON.stringify({ firstName, lastName, company, role, email, _hp: honeypot })
}) })
const result = await res.json() const result = await res.json()
if (result.fileUrl) { if (res.status === 422 && result.error === 'Undeliverable email') {
window.open(result.fileUrl, '_blank') emailInvalid = true
return
}
if (result.success) {
status = 'success' status = 'success'
} else { } else {
status = 'error' status = 'error'
@ -96,7 +101,19 @@
</div> </div>
<input class="input" type="text" placeholder={t('wp_company')} bind:value={company} required /> <input class="input" type="text" placeholder={t('wp_company')} bind:value={company} required />
<input class="input" type="text" placeholder={t('wp_role')} bind:value={role} required /> <input class="input" type="text" placeholder={t('wp_role')} bind:value={role} required />
<input class="input" type="email" placeholder={t('wp_email')} bind:value={email} required /> <input
class="input"
class:input--invalid={emailInvalid}
type="email"
placeholder={t('wp_email')}
bind:value={email}
required
aria-describedby={emailInvalid ? 'wp-email-error' : undefined}
oninput={() => emailInvalid = false}
/>
{#if emailInvalid}
<p class="email-error" id="wp-email-error" role="alert">Adresse email invalide</p>
{/if}
<div class="hp" aria-hidden="true"> <div class="hp" aria-hidden="true">
<label for="website">Website</label> <label for="website">Website</label>
@ -246,6 +263,26 @@
color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.5);
} }
.input--invalid {
border-color: #ff6b6b;
animation: shake 0.4s ease;
}
.email-error {
font-family: "Danzza", sans-serif;
font-size: var(--font-size-paragraph-small);
color: #ff6b6b;
margin-top: -0.25rem;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-6px); }
40% { transform: translateX(6px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(3px); }
}
.hp { .hp {
position: absolute; position: absolute;
left: -9999px; left: -9999px;