b17b5c831a
- Contatti: rate-limit per IP reale (X-Forwarded-For) invece dell'IP del proxy Traefik, condiviso da tutti; controllo spostato dopo la validazione così bot/honeypot non consumano la quota. Honeypot rinominato da "website" a "hp_field" per non farlo riempire dall'autofill del browser (falsi 400). - Servizi: "Performance Class" -> "Class" (titolo/card/H1/footer); migrazione DB invertita e guardata. - One to One: migrazione titolo prod "Personal training" -> "One to One". - Footer: orario sabato 8-17 (non 8-19), seed + migrazione guardata. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
59 lines
2.5 KiB
Plaintext
59 lines
2.5 KiB
Plaintext
---
|
|
import { t } from '../lib/content';
|
|
import T from './content/T.astro';
|
|
interface Props { variant?: 'full' | 'footer' }
|
|
const { variant = 'full' } = Astro.props;
|
|
---
|
|
<form
|
|
class:list={['cform', `cform--${variant}`]}
|
|
method="post"
|
|
action="/api/contact"
|
|
novalidate
|
|
data-msg-success={t('form.msg.success')}
|
|
data-msg-error-generic={t('form.msg.error-generic')}
|
|
data-msg-error-network={t('form.msg.error-network')}
|
|
>
|
|
<input class="field" name="firstName" placeholder={t('form.field.first-name')} required maxlength="100" />
|
|
<input class="field" name="lastName" placeholder={t('form.field.last-name')} required maxlength="100" />
|
|
<input class="field" type="tel" name="phone" placeholder={t('form.field.phone')} required maxlength="40" />
|
|
<input class="field" type="email" name="email" placeholder={t('form.field.email')} required maxlength="200" />
|
|
<textarea class="field" name="message" placeholder={t('form.field.message')} required maxlength="5000"></textarea>
|
|
<input class="hp" type="text" name="hp_field" tabindex="-1" autocomplete="off" aria-hidden="true" />
|
|
<button class="btn" type="submit"><T tag="form.submit" as="span" /></button>
|
|
<p class="cform__privacy">Inviando il modulo dichiari di aver letto l'<a href="/privacy">informativa sulla privacy</a>.</p>
|
|
<p class="form-msg" hidden></p>
|
|
</form>
|
|
|
|
<style>
|
|
.cform__privacy { font-size: .74rem; opacity: .8; margin: 8px 0 0; line-height: 1.5; }
|
|
.cform__privacy a { text-decoration: underline; }
|
|
</style>
|
|
|
|
<script>
|
|
document.querySelectorAll<HTMLFormElement>('.cform').forEach((form) => {
|
|
form.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const msg = form.querySelector<HTMLParagraphElement>('.form-msg')!;
|
|
const btn = form.querySelector<HTMLButtonElement>('button')!;
|
|
btn.disabled = true;
|
|
msg.hidden = true;
|
|
try {
|
|
const res = await fetch('/api/contact', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(Object.fromEntries(new FormData(form))),
|
|
});
|
|
const data = await res.json();
|
|
msg.textContent = res.ok ? form.dataset.msgSuccess! : (data.error ?? form.dataset.msgErrorGeneric);
|
|
msg.className = `form-msg ${res.ok ? 'form-msg--ok' : 'form-msg--err'}`;
|
|
if (res.ok) form.reset();
|
|
} catch {
|
|
msg.textContent = form.dataset.msgErrorNetwork!;
|
|
msg.className = 'form-msg form-msg--err';
|
|
}
|
|
msg.hidden = false;
|
|
btn.disabled = false;
|
|
});
|
|
});
|
|
</script>
|