Files
365devnet/src/components/ui/Form.astro
becarta e8833ce52b Add spam review functionality to contact form and update translations
- Introduced a new spamReview property in the Form interface to handle manual review requests for messages flagged as spam.
- Updated the Form component to display a spam warning and a manual review form with dynamic labels and placeholders based on translations.
- Enhanced the Tailwind CSS styles for responsive subtitles across various components.
- Added corresponding translations for the spam review feature in English, Dutch, German, and French, ensuring consistency and clarity in messaging.
- Updated various components to integrate the new spamReview functionality, improving user experience and interaction.
2025-07-12 00:28:44 +02:00

292 lines
11 KiB
Plaintext

---
import type { Form as Props } from '~/types';
import Button from '~/components/ui/Button.astro';
const { inputs, textarea, disclaimer, button = 'Contact us', description = '', spamReview } = Astro.props;
---
<style>
/* Checkbox error styling */
.checkbox-error {
border: 2px solid #ef4444;
border-radius: 0.5rem;
padding: 0.5rem;
background-color: rgba(239, 68, 68, 0.05);
}
</style>
<form id="contact-form" name="contact" method="POST" action="/api/contact" class="needs-validation" novalidate>
<!-- Form status messages -->
<div id="form-success" class="hidden mb-6 p-4 bg-green-100 border border-green-200 text-green-700 rounded-lg">
Your message has been sent successfully. We will get back to you soon!
</div>
<div id="form-error" class="hidden mb-6 p-4 bg-red-100 border border-red-200 text-red-700 rounded-lg">
There was an error sending your message. Please check all fields and try again.
</div>
<!-- CSRF token field (will be filled by JS) -->
<input type="hidden" name="csrf_token" id="csrf_token" value="" />
<!-- Honeypot field to prevent spam -->
<p class="hidden">
<label>Don't fill this out if you're human: <input name="bot-field" /></label>
</p>
{
inputs &&
inputs.map(
({ type = 'text', name, label = '', autocomplete = 'on', placeholder = '', required = true }) =>
name && (
<div class="mb-6">
{label && (
<label for={name} class="block text-sm font-medium">
{label}
{required && <span class="text-red-600">*</span>}
</label>
)}
<input
type={type}
name={name}
id={name}
autocomplete={autocomplete}
placeholder={placeholder}
class="py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900"
required={required}
aria-describedby={`invalid-feedback-${name}`}
/>
<div id={`invalid-feedback-${name}`} class="invalid-feedback hidden text-red-600 text-sm mt-1" />
</div>
)
)
}
{
textarea && (
<div class="mb-6">
<label for="textarea" class="block text-sm font-medium">
{textarea.label}
<span class="text-red-600">*</span>
</label>
<textarea
id="textarea"
name={textarea.name ? textarea.name : 'message'}
rows={textarea.rows ? textarea.rows : 4}
placeholder={textarea.placeholder}
class="py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900"
required
aria-describedby="invalid-feedback-textarea"
/>
<div id="invalid-feedback-textarea" class="invalid-feedback hidden text-red-600 text-sm mt-1" />
</div>
)
}
{
disclaimer && (
<div class="mt-3 flex items-start mb-6">
<div class="flex mt-0.5">
<input
id="disclaimer"
name="disclaimer"
type="checkbox"
class="cursor-pointer mt-1 py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900"
required
aria-describedby="invalid-feedback-disclaimer"
/>
</div>
<div class="ml-3">
<label for="disclaimer" class="cursor-pointer select-none text-sm text-gray-600 dark:text-gray-400">
{disclaimer.label}
<span class="text-red-600">*</span>
</label>
<div id="invalid-feedback-disclaimer" class="invalid-feedback hidden text-red-600 text-sm mt-1" />
</div>
</div>
)
}
{
button && (
<div class="mt-10 grid">
<Button variant="primary" type="submit">
{button}
</Button>
</div>
)
}
{
description && (
<div class="mt-3 text-center">
<p class="text-sm text-gray-600 dark:text-gray-400">{description}</p>
</div>
)
}
</form>
<!-- Manual Review UI -->
<div
id="spam-warning"
style="display:none;"
class="max-w-xl mx-auto rounded-lg backdrop-blur-sm bg-white/15 dark:bg-slate-900 border border-gray-200 dark:border-gray-700 shadow-md p-4 sm:p-6 lg:p-8 w-full mb-6"
>
<p class="mb-4 text-lg font-medium text-gray-800 dark:text-gray-100">
{spamReview?.title}<br />
<span class="text-base font-normal text-gray-600 dark:text-gray-300">{spamReview?.description}</span>
</p>
<form id="manual-review-form" class="space-y-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="manual-email"
>{spamReview?.emailLabel}</label
>
<input
type="email"
id="manual-email"
required
placeholder={spamReview?.emailPlaceholder}
class="py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900"
/>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="manual-justification"
>{spamReview?.justificationLabel} <span class="text-gray-400">{spamReview?.justificationOptional}</span></label
>
<textarea
id="manual-justification"
placeholder={spamReview?.justificationPlaceholder}
class="py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900"
></textarea>
<input type="hidden" id="manual-token" />
<button
type="submit"
class="mt-2 w-full py-3 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold transition-colors"
>{spamReview?.button}</button
>
</form>
<div id="manual-review-result" class="mt-4 text-center text-green-700 dark:text-green-400 font-medium"></div>
</div>
<script>
// TypeScript: declare the property on window
declare global {
interface Window {
__originalEmail?: string;
}
}
async function setCsrfToken() {
try {
const res = await fetch('/api/contact?csrf=true');
if (res.ok) {
const data = await res.json();
const csrfInput = document.getElementById('csrf_token');
if (csrfInput && data.csrfToken) {
(csrfInput as HTMLInputElement).value = data.csrfToken;
}
}
} catch (e) {
console.error('Failed to fetch CSRF token', e);
}
}
document.addEventListener('DOMContentLoaded', setCsrfToken);
const form = document.getElementById('contact-form') as HTMLFormElement | null;
if (form) {
form.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(form);
let response, result;
try {
response = await fetch('/api/contact', {
method: 'POST',
body: formData,
});
result = await response.json();
console.log('Contact API response:', result);
} catch (_err) {
const errorElement = document.getElementById('form-error');
if (errorElement) errorElement.classList.remove('hidden');
const successElement = document.getElementById('form-success');
if (successElement) successElement.classList.add('hidden');
return;
}
// Show manual review UI if spam detected, even if status is 422
if (result.spam && result.token) {
console.log('Spam detected, showing manual review UI.');
form.style.display = 'none';
const spamWarning = document.getElementById('spam-warning');
const manualEmail = document.getElementById('manual-email') as HTMLInputElement | null;
const manualToken = document.getElementById('manual-token') as HTMLInputElement | null;
// Store the original email in a variable (not in the input)
window.__originalEmail = String(formData.get('email'));
if (spamWarning && manualEmail && manualToken) {
spamWarning.style.display = 'block';
manualEmail.value = '';
manualToken.value = result.token;
}
return;
}
if (!response.ok) {
const errorElement = document.getElementById('form-error');
if (errorElement) errorElement.classList.remove('hidden');
const successElement = document.getElementById('form-success');
if (successElement) successElement.classList.add('hidden');
return;
}
// Success
const successElement = document.getElementById('form-success');
if (successElement) successElement.classList.remove('hidden');
const errorElement = document.getElementById('form-error');
if (errorElement) errorElement.classList.add('hidden');
form.reset();
setCsrfToken();
});
}
// Manual Review Form Logic
const manualReviewForm = document.getElementById('manual-review-form');
if (manualReviewForm) {
manualReviewForm.onsubmit = async function (e) {
e.preventDefault();
const manualEmail = document.getElementById('manual-email') as HTMLInputElement | null;
const manualJustification = document.getElementById('manual-justification') as HTMLTextAreaElement | null;
const manualToken = document.getElementById('manual-token') as HTMLInputElement | null;
const resultDiv = document.getElementById('manual-review-result');
const form = document.getElementById('contact-form') as HTMLFormElement | null;
const spamWarning = document.getElementById('spam-warning');
if (!manualEmail || !manualJustification || !manualToken || !resultDiv) return;
const email = manualEmail.value;
const justification = manualJustification.value;
const token = manualToken.value;
// Check if the entered email matches the original
if (typeof window.__originalEmail !== 'undefined' && email !== window.__originalEmail) {
resultDiv.textContent = 'Email addresses do not match.';
return;
}
const res = await fetch('/api/contact/manual-review', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, justification, token }),
});
const data = await res.json();
if (data.success) {
resultDiv.textContent =
spamReview?.resultSuccess || 'Your request for manual review has been submitted. Thank you!';
// Hide spam-warning and show the normal form again after a short delay
setTimeout(() => {
if (spamWarning) spamWarning.style.display = 'none';
if (form) {
form.style.display = '';
form.reset();
}
resultDiv.textContent = '';
}, 2000);
} else {
resultDiv.textContent =
data.error || spamReview?.resultError || 'There was an error submitting your manual review request.';
}
};
}
</script>