feat(contact): implement Formspree contact form submission
Some checks failed
CI / lint-and-test (push) Has been cancelled
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>
This commit is contained in:
@@ -10,6 +10,7 @@ This folder contains lessons learned from sprints and development work. These le
|
|||||||
|
|
||||||
| Date | Sprint/Phase | Title | Tags |
|
| Date | Sprint/Phase | Title | Tags |
|
||||||
|------|--------------|-------|------|
|
|------|--------------|-------|------|
|
||||||
|
| 2026-02-01 | Sprint 10 | [Formspree Integration with Dash Callbacks](./sprint-10-formspree-dash-integration.md) | formspree, dash, callbacks, forms, spam-protection, honeypot, ajax |
|
||||||
| 2026-01-17 | Sprint 9 | [Gitea Labels API Requires Org Context](./sprint-9-gitea-labels-user-repos.md) | gitea, mcp, api, labels, projman, configuration |
|
| 2026-01-17 | Sprint 9 | [Gitea Labels API Requires Org Context](./sprint-9-gitea-labels-user-repos.md) | gitea, mcp, api, labels, projman, configuration |
|
||||||
| 2026-01-17 | Sprint 9 | [Always Read CLAUDE.md Before Asking Questions](./sprint-9-read-claude-md-first.md) | projman, claude-code, context, documentation, workflow |
|
| 2026-01-17 | Sprint 9 | [Always Read CLAUDE.md Before Asking Questions](./sprint-9-read-claude-md-first.md) | projman, claude-code, context, documentation, workflow |
|
||||||
| 2026-01-17 | Sprint 9-10 | [Graceful Error Handling in Service Layers](./sprint-9-10-graceful-error-handling.md) | python, postgresql, error-handling, dash, graceful-degradation, arm64 |
|
| 2026-01-17 | Sprint 9-10 | [Graceful Error Handling in Service Layers](./sprint-9-10-graceful-error-handling.md) | python, postgresql, error-handling, dash, graceful-degradation, arm64 |
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Sprint 10 - Formspree Integration with Dash Callbacks
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Implementing a contact form on a Dash portfolio site that submits to Formspree, a third-party form handling service.
|
||||||
|
|
||||||
|
## Insights
|
||||||
|
|
||||||
|
### Formspree AJAX Submission
|
||||||
|
Formspree supports AJAX submissions (no page redirect) when you:
|
||||||
|
1. POST with `Content-Type: application/json`
|
||||||
|
2. Include `Accept: application/json` header
|
||||||
|
3. Send form data as JSON body
|
||||||
|
|
||||||
|
This returns a JSON response instead of redirecting to a thank-you page, which is ideal for single-page Dash applications.
|
||||||
|
|
||||||
|
### Dash Multi-Output Callbacks for Forms
|
||||||
|
When handling form submission with validation and feedback, use a multi-output callback pattern:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@callback(
|
||||||
|
Output("feedback-container", "children"), # Success/error alert
|
||||||
|
Output("submit-button", "loading"), # Button loading state
|
||||||
|
Output("field-1", "value"), # Clear on success
|
||||||
|
Output("field-2", "value"), # Clear on success
|
||||||
|
Output("field-1", "error"), # Field-level errors
|
||||||
|
Output("field-2", "error"), # Field-level errors
|
||||||
|
Input("submit-button", "n_clicks"),
|
||||||
|
State("field-1", "value"),
|
||||||
|
State("field-2", "value"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `no_update` for outputs you don't want to change (e.g., keep form values on validation error, only clear on success).
|
||||||
|
|
||||||
|
### Honeypot Spam Protection
|
||||||
|
Simple and effective bot protection without CAPTCHA:
|
||||||
|
1. Add a hidden text input field (CSS: `position: absolute; left: -9999px`)
|
||||||
|
2. Set `tabIndex=-1` and `autoComplete="off"` to prevent accidental filling
|
||||||
|
3. In callback, check if honeypot has value - if yes, it's a bot
|
||||||
|
4. For bots: return fake success (don't reveal detection)
|
||||||
|
5. For humans: proceed with real submission
|
||||||
|
|
||||||
|
Formspree also accepts `_gotcha` as a honeypot field name in the JSON payload.
|
||||||
|
|
||||||
|
## Code Pattern
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Honeypot check - bots fill hidden fields
|
||||||
|
if honeypot_value:
|
||||||
|
# Fake success - don't let bots know they were caught
|
||||||
|
return (_create_success_alert(), False, "", "", None, None)
|
||||||
|
|
||||||
|
# Real submission for humans
|
||||||
|
response = requests.post(
|
||||||
|
FORMSPREE_ENDPOINT,
|
||||||
|
json=form_data,
|
||||||
|
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prevention/Best Practices
|
||||||
|
- Always use `timeout` parameter with `requests.post()` to avoid hanging
|
||||||
|
- Wrap external API calls in try/except for network errors
|
||||||
|
- Return user-friendly error messages, not technical details
|
||||||
|
- Use DMC's `required=True` and `error` props for form validation feedback
|
||||||
|
|
||||||
|
## Tags
|
||||||
|
formspree, dash, callbacks, forms, spam-protection, honeypot, ajax, python, requests, validation
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Application-level callbacks for the portfolio app."""
|
"""Application-level callbacks for the portfolio app."""
|
||||||
|
|
||||||
from . import sidebar, theme
|
from . import contact, sidebar, theme
|
||||||
|
|
||||||
__all__ = ["sidebar", "theme"]
|
__all__ = ["contact", "sidebar", "theme"]
|
||||||
|
|||||||
214
portfolio_app/callbacks/contact.py
Normal file
214
portfolio_app/callbacks/contact.py
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
"""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,
|
||||||
|
)
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import dash
|
import dash
|
||||||
import dash_mantine_components as dmc
|
import dash_mantine_components as dmc
|
||||||
|
from dash import html
|
||||||
from dash_iconify import DashIconify
|
from dash_iconify import DashIconify
|
||||||
|
|
||||||
dash.register_page(__name__, path="/contact", name="Contact")
|
dash.register_page(__name__, path="/contact", name="Contact")
|
||||||
@@ -51,51 +52,57 @@ def create_intro_section() -> dmc.Stack:
|
|||||||
|
|
||||||
|
|
||||||
def create_contact_form() -> dmc.Paper:
|
def create_contact_form() -> dmc.Paper:
|
||||||
"""Create the contact form (disabled in Phase 1)."""
|
"""Create the contact form with Formspree integration."""
|
||||||
return dmc.Paper(
|
return dmc.Paper(
|
||||||
dmc.Stack(
|
dmc.Stack(
|
||||||
[
|
[
|
||||||
dmc.Title("Send a Message", order=2, size="h4"),
|
dmc.Title("Send a Message", order=2, size="h4"),
|
||||||
dmc.Alert(
|
# Feedback container for success/error messages
|
||||||
"Contact form submission is coming soon. Please use the direct contact "
|
html.Div(id="contact-feedback"),
|
||||||
"methods below for now.",
|
|
||||||
title="Form Coming Soon",
|
|
||||||
color="blue",
|
|
||||||
variant="light",
|
|
||||||
),
|
|
||||||
dmc.TextInput(
|
dmc.TextInput(
|
||||||
|
id="contact-name",
|
||||||
label="Name",
|
label="Name",
|
||||||
placeholder="Your name",
|
placeholder="Your name",
|
||||||
leftSection=DashIconify(icon="tabler:user", width=18),
|
leftSection=DashIconify(icon="tabler:user", width=18),
|
||||||
disabled=True,
|
required=True,
|
||||||
),
|
),
|
||||||
dmc.TextInput(
|
dmc.TextInput(
|
||||||
|
id="contact-email",
|
||||||
label="Email",
|
label="Email",
|
||||||
placeholder="your.email@example.com",
|
placeholder="your.email@example.com",
|
||||||
leftSection=DashIconify(icon="tabler:mail", width=18),
|
leftSection=DashIconify(icon="tabler:mail", width=18),
|
||||||
disabled=True,
|
required=True,
|
||||||
),
|
),
|
||||||
dmc.Select(
|
dmc.Select(
|
||||||
|
id="contact-subject",
|
||||||
label="Subject",
|
label="Subject",
|
||||||
placeholder="Select a subject",
|
placeholder="Select a subject",
|
||||||
data=SUBJECT_OPTIONS,
|
data=SUBJECT_OPTIONS,
|
||||||
leftSection=DashIconify(icon="tabler:tag", width=18),
|
leftSection=DashIconify(icon="tabler:tag", width=18),
|
||||||
disabled=True,
|
|
||||||
),
|
),
|
||||||
dmc.Textarea(
|
dmc.Textarea(
|
||||||
|
id="contact-message",
|
||||||
label="Message",
|
label="Message",
|
||||||
placeholder="Your message...",
|
placeholder="Your message...",
|
||||||
minRows=4,
|
minRows=4,
|
||||||
disabled=True,
|
required=True,
|
||||||
|
),
|
||||||
|
# Honeypot field for spam protection (hidden from users)
|
||||||
|
dmc.TextInput(
|
||||||
|
id="contact-gotcha",
|
||||||
|
style={"position": "absolute", "left": "-9999px"},
|
||||||
|
tabIndex=-1,
|
||||||
|
autoComplete="off",
|
||||||
),
|
),
|
||||||
dmc.Button(
|
dmc.Button(
|
||||||
"Send Message",
|
"Send Message",
|
||||||
|
id="contact-submit",
|
||||||
fullWidth=True,
|
fullWidth=True,
|
||||||
leftSection=DashIconify(icon="tabler:send", width=18),
|
leftSection=DashIconify(icon="tabler:send", width=18),
|
||||||
disabled=True,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
gap="md",
|
gap="md",
|
||||||
|
style={"position": "relative"},
|
||||||
),
|
),
|
||||||
p="xl",
|
p="xl",
|
||||||
radius="md",
|
radius="md",
|
||||||
|
|||||||
Reference in New Issue
Block a user