Some checks failed
CI / lint-and-test (push) Has been cancelled
- Enable contact form fields with component IDs - Add callback for Formspree POST with JSON/AJAX - Include honeypot spam protection (_gotcha field) - Handle validation, loading, success/error states - Clear form on successful submission - Add lessons learned documentation Closes #92, #93, #94 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
215 lines
5.9 KiB
Python
215 lines
5.9 KiB
Python
"""Contact form submission callback with Formspree integration."""
|
|
|
|
import re
|
|
from typing import Any
|
|
|
|
import dash_mantine_components as dmc
|
|
import requests
|
|
from dash import Input, Output, State, callback, no_update
|
|
from dash_iconify import DashIconify
|
|
|
|
FORMSPREE_ENDPOINT = "https://formspree.io/f/mqelqzpd"
|
|
EMAIL_REGEX = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
|
|
|
|
|
|
def _validate_form(
|
|
name: str | None, email: str | None, message: str | None
|
|
) -> str | None:
|
|
"""Validate form fields and return error message if invalid."""
|
|
if not name or not name.strip():
|
|
return "Please enter your name."
|
|
if not email or not email.strip():
|
|
return "Please enter your email address."
|
|
if not EMAIL_REGEX.match(email.strip()):
|
|
return "Please enter a valid email address."
|
|
if not message or not message.strip():
|
|
return "Please enter a message."
|
|
return None
|
|
|
|
|
|
def _create_success_alert() -> dmc.Alert:
|
|
"""Create success feedback alert."""
|
|
return dmc.Alert(
|
|
"Thank you for your message! I'll get back to you soon.",
|
|
title="Message Sent",
|
|
color="green",
|
|
variant="light",
|
|
icon=DashIconify(icon="tabler:check", width=20),
|
|
withCloseButton=True,
|
|
)
|
|
|
|
|
|
def _create_error_alert(message: str) -> dmc.Alert:
|
|
"""Create error feedback alert."""
|
|
return dmc.Alert(
|
|
message,
|
|
title="Error",
|
|
color="red",
|
|
variant="light",
|
|
icon=DashIconify(icon="tabler:alert-circle", width=20),
|
|
withCloseButton=True,
|
|
)
|
|
|
|
|
|
@callback( # type: ignore[misc]
|
|
Output("contact-feedback", "children"),
|
|
Output("contact-submit", "loading"),
|
|
Output("contact-name", "value"),
|
|
Output("contact-email", "value"),
|
|
Output("contact-subject", "value"),
|
|
Output("contact-message", "value"),
|
|
Output("contact-name", "error"),
|
|
Output("contact-email", "error"),
|
|
Output("contact-message", "error"),
|
|
Input("contact-submit", "n_clicks"),
|
|
State("contact-name", "value"),
|
|
State("contact-email", "value"),
|
|
State("contact-subject", "value"),
|
|
State("contact-message", "value"),
|
|
State("contact-gotcha", "value"),
|
|
prevent_initial_call=True,
|
|
)
|
|
def submit_contact_form(
|
|
n_clicks: int | None,
|
|
name: str | None,
|
|
email: str | None,
|
|
subject: str | None,
|
|
message: str | None,
|
|
gotcha: str | None,
|
|
) -> tuple[Any, ...]:
|
|
"""Submit contact form to Formspree.
|
|
|
|
Args:
|
|
n_clicks: Button click count.
|
|
name: User's name.
|
|
email: User's email address.
|
|
subject: Message subject (optional).
|
|
message: Message content.
|
|
gotcha: Honeypot field value (should be empty for real users).
|
|
|
|
Returns:
|
|
Tuple of (feedback, loading, name, email, subject, message,
|
|
name_error, email_error, message_error).
|
|
"""
|
|
if not n_clicks:
|
|
return (no_update,) * 9
|
|
|
|
# Check honeypot - if filled, silently "succeed" (it's a bot)
|
|
if gotcha:
|
|
return (
|
|
_create_success_alert(),
|
|
False,
|
|
"",
|
|
"",
|
|
None,
|
|
"",
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
|
|
# Validate form
|
|
validation_error = _validate_form(name, email, message)
|
|
if validation_error:
|
|
# Determine which field has the error
|
|
name_error = "Required" if not name or not name.strip() else None
|
|
email_error = None
|
|
message_error = "Required" if not message or not message.strip() else None
|
|
|
|
if not email or not email.strip():
|
|
email_error = "Required"
|
|
elif not EMAIL_REGEX.match(email.strip()):
|
|
email_error = "Invalid email format"
|
|
|
|
return (
|
|
_create_error_alert(validation_error),
|
|
False,
|
|
no_update,
|
|
no_update,
|
|
no_update,
|
|
no_update,
|
|
name_error,
|
|
email_error,
|
|
message_error,
|
|
)
|
|
|
|
# Prepare form data (validation passed, so name/email/message are not None)
|
|
assert name is not None
|
|
assert email is not None
|
|
assert message is not None
|
|
form_data = {
|
|
"name": name.strip(),
|
|
"email": email.strip(),
|
|
"subject": subject or "General Inquiry",
|
|
"message": message.strip(),
|
|
"_gotcha": "", # Formspree honeypot
|
|
}
|
|
|
|
# Submit to Formspree
|
|
try:
|
|
response = requests.post(
|
|
FORMSPREE_ENDPOINT,
|
|
json=form_data,
|
|
headers={
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json",
|
|
},
|
|
timeout=10,
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
# Success - clear form
|
|
return (
|
|
_create_success_alert(),
|
|
False,
|
|
"",
|
|
"",
|
|
None,
|
|
"",
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
else:
|
|
# Formspree returned an error
|
|
return (
|
|
_create_error_alert(
|
|
"Failed to send message. Please try again or use direct contact."
|
|
),
|
|
False,
|
|
no_update,
|
|
no_update,
|
|
no_update,
|
|
no_update,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
|
|
except requests.exceptions.Timeout:
|
|
return (
|
|
_create_error_alert("Request timed out. Please try again."),
|
|
False,
|
|
no_update,
|
|
no_update,
|
|
no_update,
|
|
no_update,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
except requests.exceptions.RequestException:
|
|
return (
|
|
_create_error_alert(
|
|
"Network error. Please check your connection and try again."
|
|
),
|
|
False,
|
|
no_update,
|
|
no_update,
|
|
no_update,
|
|
no_update,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|