From a5d6866d63ea27aa48261d4faf8372495acb42fc Mon Sep 17 00:00:00 2001 From: l3ocho Date: Sun, 1 Feb 2026 14:26:02 -0500 Subject: [PATCH] feat(contact): implement Formspree contact form submission - 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 --- docs/project-lessons-learned/INDEX.md | 1 + .../sprint-10-formspree-dash-integration.md | 70 ++++++ portfolio_app/callbacks/__init__.py | 4 +- portfolio_app/callbacks/contact.py | 214 ++++++++++++++++++ portfolio_app/pages/contact.py | 33 +-- 5 files changed, 307 insertions(+), 15 deletions(-) create mode 100644 docs/project-lessons-learned/sprint-10-formspree-dash-integration.md create mode 100644 portfolio_app/callbacks/contact.py diff --git a/docs/project-lessons-learned/INDEX.md b/docs/project-lessons-learned/INDEX.md index 4045308..f45f8b2 100644 --- a/docs/project-lessons-learned/INDEX.md +++ b/docs/project-lessons-learned/INDEX.md @@ -10,6 +10,7 @@ This folder contains lessons learned from sprints and development work. These le | 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 | [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 | diff --git a/docs/project-lessons-learned/sprint-10-formspree-dash-integration.md b/docs/project-lessons-learned/sprint-10-formspree-dash-integration.md new file mode 100644 index 0000000..64240cd --- /dev/null +++ b/docs/project-lessons-learned/sprint-10-formspree-dash-integration.md @@ -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 diff --git a/portfolio_app/callbacks/__init__.py b/portfolio_app/callbacks/__init__.py index b8d030e..a592365 100644 --- a/portfolio_app/callbacks/__init__.py +++ b/portfolio_app/callbacks/__init__.py @@ -1,5 +1,5 @@ """Application-level callbacks for the portfolio app.""" -from . import sidebar, theme +from . import contact, sidebar, theme -__all__ = ["sidebar", "theme"] +__all__ = ["contact", "sidebar", "theme"] diff --git a/portfolio_app/callbacks/contact.py b/portfolio_app/callbacks/contact.py new file mode 100644 index 0000000..1ccdccd --- /dev/null +++ b/portfolio_app/callbacks/contact.py @@ -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, + ) diff --git a/portfolio_app/pages/contact.py b/portfolio_app/pages/contact.py index 6e9f75e..25e60d2 100644 --- a/portfolio_app/pages/contact.py +++ b/portfolio_app/pages/contact.py @@ -2,6 +2,7 @@ import dash import dash_mantine_components as dmc +from dash import html from dash_iconify import DashIconify dash.register_page(__name__, path="/contact", name="Contact") @@ -51,51 +52,57 @@ def create_intro_section() -> dmc.Stack: def create_contact_form() -> dmc.Paper: - """Create the contact form (disabled in Phase 1).""" + """Create the contact form with Formspree integration.""" return dmc.Paper( dmc.Stack( [ dmc.Title("Send a Message", order=2, size="h4"), - dmc.Alert( - "Contact form submission is coming soon. Please use the direct contact " - "methods below for now.", - title="Form Coming Soon", - color="blue", - variant="light", - ), + # Feedback container for success/error messages + html.Div(id="contact-feedback"), dmc.TextInput( + id="contact-name", label="Name", placeholder="Your name", leftSection=DashIconify(icon="tabler:user", width=18), - disabled=True, + required=True, ), dmc.TextInput( + id="contact-email", label="Email", placeholder="your.email@example.com", leftSection=DashIconify(icon="tabler:mail", width=18), - disabled=True, + required=True, ), dmc.Select( + id="contact-subject", label="Subject", placeholder="Select a subject", data=SUBJECT_OPTIONS, leftSection=DashIconify(icon="tabler:tag", width=18), - disabled=True, ), dmc.Textarea( + id="contact-message", label="Message", placeholder="Your message...", 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( "Send Message", + id="contact-submit", fullWidth=True, leftSection=DashIconify(icon="tabler:send", width=18), - disabled=True, ), ], gap="md", + style={"position": "relative"}, ), p="xl", radius="md",