Initial commit
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY main.py .
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
Binary file not shown.
@@ -0,0 +1,459 @@
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import resend
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
resend.api_key = os.environ["RESEND_API_KEY"]
|
||||
|
||||
OWNER_EMAIL = os.environ["OWNER_EMAIL"]
|
||||
FROM_EMAIL = os.environ.get("FROM_EMAIL", "GoodWalk <bookings@goodwalk.co.nz>")
|
||||
REPLY_TO = os.environ.get("REPLY_TO", "aless@goodwalk.co.nz")
|
||||
|
||||
LOGO_URL = "https://www.goodwalk.co.nz/wp-content/uploads/2022/06/logo-v6.png"
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["POST"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
class BookingSubmission(BaseModel):
|
||||
fullName: str
|
||||
email: EmailStr
|
||||
phone: str
|
||||
petName: str
|
||||
location: str
|
||||
message: str = ""
|
||||
services: list[str] = []
|
||||
referrer: str = ""
|
||||
page: str = ""
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_ip(request: Request) -> str:
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def _parse_ua(ua: str) -> str:
|
||||
if not ua:
|
||||
return "Unknown"
|
||||
browsers = [("Edg/", "Edge"), ("OPR/", "Opera"), ("Chrome/", "Chrome"),
|
||||
("Firefox/", "Firefox"), ("Safari/", "Safari")]
|
||||
systems = [("Windows NT 10", "Windows 10/11"), ("Windows NT 6", "Windows 8"),
|
||||
("Mac OS X", "macOS"), ("iPhone", "iPhone"), ("iPad", "iPad"),
|
||||
("Android", "Android"), ("Linux", "Linux")]
|
||||
browser = next((n for p, n in browsers if p in ua), "Unknown browser")
|
||||
system = next((n for p, n in systems if p in ua), "Unknown OS")
|
||||
return f"{browser} on {system}"
|
||||
|
||||
|
||||
def _detail_row(label: str, value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
return f"""
|
||||
<tr>
|
||||
<td style="padding:8px 0;color:#888;font-size:13px;white-space:nowrap;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;width:130px;">{label}</td>
|
||||
<td style="padding:8px 0 8px 16px;color:#213021;font-size:14px;font-weight:500;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;">{value}</td>
|
||||
</tr>"""
|
||||
|
||||
|
||||
def _meta_row(label: str, value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
return f"""
|
||||
<tr>
|
||||
<td style="padding:5px 0;color:#aaa;font-size:12px;white-space:nowrap;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;width:100px;">{label}</td>
|
||||
<td style="padding:5px 0 5px 16px;color:#666;font-size:12px;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;word-break:break-all;">{value}</td>
|
||||
</tr>"""
|
||||
|
||||
|
||||
# ── Email templates ──────────────────────────────────────────────────────────
|
||||
|
||||
def _logo_header(badge_html: str = "", subtitle: str = "") -> str:
|
||||
badge = f'<div style="margin-top:20px;">{badge_html}</div>' if badge_html else ""
|
||||
sub = f"""<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:13px;color:#7aaa7a;letter-spacing:0.04em;margin-top:8px;">
|
||||
{subtitle}</div>""" if subtitle else ""
|
||||
return f"""
|
||||
<tr>
|
||||
<td style="background:#213021;padding:36px 48px 32px;text-align:center;">
|
||||
<img src="{LOGO_URL}" width="161" height="32" alt="GoodWalk"
|
||||
style="display:inline-block;max-width:161px;height:auto;border:0;">
|
||||
{sub}
|
||||
{badge}
|
||||
</td>
|
||||
</tr>"""
|
||||
|
||||
|
||||
def client_email(data: BookingSubmission) -> str:
|
||||
services_text = ", ".join(data.services) if data.services else "Not specified"
|
||||
message_row = _detail_row("About the dog", data.message) if data.message else ""
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>We received your enquiry</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f2f2f0;">
|
||||
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="background:#f2f2f0;padding:40px 16px;">
|
||||
<tr><td align="center">
|
||||
|
||||
<table width="600" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="max-width:600px;width:100%;border-radius:16px;overflow:hidden;
|
||||
box-shadow:0 4px 24px rgba(0,0,0,0.08);">
|
||||
|
||||
{_logo_header(subtitle="Auckland’s favourite dog walking service")}
|
||||
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="background:#ffffff;padding:48px 48px 40px;">
|
||||
|
||||
<h1 style="margin:0 0 8px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:26px;font-weight:700;color:#213021;line-height:1.2;">
|
||||
Thanks, {data.fullName.split()[0]}! 🐾
|
||||
</h1>
|
||||
<p style="margin:0 0 32px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:16px;color:#555;line-height:1.65;">
|
||||
We’ve received your enquiry and Aless will be in touch shortly to arrange
|
||||
a <strong style="color:#213021;">Meet & Greet</strong> with you and
|
||||
{data.petName}.
|
||||
</p>
|
||||
|
||||
<!-- Details card -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="background:#f8f7f4;border-radius:12px;margin-bottom:36px;">
|
||||
<tr>
|
||||
<td style="padding:28px 32px;">
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:11px;font-weight:700;letter-spacing:0.1em;
|
||||
color:#888;text-transform:uppercase;margin-bottom:20px;">
|
||||
Your enquiry summary
|
||||
</div>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
{_detail_row("Your name", data.fullName)}
|
||||
{_detail_row("Email", data.email)}
|
||||
{_detail_row("Phone", data.phone)}
|
||||
{_detail_row("Dog’s name", data.petName)}
|
||||
{_detail_row("Location", data.location)}
|
||||
{_detail_row("Services", services_text)}
|
||||
{message_row}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- What's next -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="border-left:3px solid #FFD100;margin-bottom:36px;">
|
||||
<tr>
|
||||
<td style="padding:4px 0 4px 20px;">
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:13px;font-weight:700;color:#213021;margin-bottom:6px;">
|
||||
What happens next?
|
||||
</div>
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:14px;color:#666;line-height:1.6;">
|
||||
Aless will review your details and reach out within 1–2 business days
|
||||
to schedule a free Meet & Greet. No commitment required — just a
|
||||
chance for {data.petName} to make a new best friend.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:14px;color:#888;line-height:1.6;">
|
||||
Questions? Just reply to this email or reach us at
|
||||
<a href="mailto:{REPLY_TO}" style="color:#213021;font-weight:600;
|
||||
text-decoration:none;">{REPLY_TO}</a>.
|
||||
</p>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background:#213021;padding:24px 48px;text-align:center;">
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:12px;color:#5a8a5a;line-height:1.6;">
|
||||
GoodWalk · Auckland, New Zealand<br>
|
||||
<a href="https://www.goodwalk.co.nz" style="color:#7aaa7a;text-decoration:none;">
|
||||
goodwalk.co.nz
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
|
||||
services_text = ", ".join(data.services) if data.services else "—"
|
||||
now = datetime.now()
|
||||
submitted_at = now.strftime("%d %b %Y at %I:%M %p").lstrip("0")
|
||||
|
||||
message_block = f"""
|
||||
<tr>
|
||||
<td colspan="2" style="padding:16px 0 0;">
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:11px;font-weight:700;letter-spacing:0.08em;color:#888;
|
||||
text-transform:uppercase;margin-bottom:8px;">About the dog</div>
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:14px;color:#444;line-height:1.6;background:#f0efe9;
|
||||
border-radius:8px;padding:14px 16px;">{data.message}</div>
|
||||
</td>
|
||||
</tr>""" if data.message else ""
|
||||
|
||||
badge = """<div style="display:inline-block;background:#FFD100;border-radius:100px;
|
||||
padding:10px 28px;">
|
||||
<span style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:18px;font-weight:700;color:#213021;">
|
||||
📩 New lead!
|
||||
</span>
|
||||
</div>
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:12px;color:#5a8a5a;margin-top:12px;">
|
||||
Submitted {submitted_at}
|
||||
</div>""".format(submitted_at=submitted_at)
|
||||
|
||||
referrer_row = _meta_row("Came from", data.referrer) if data.referrer else _meta_row("Came from", "Direct / bookmark")
|
||||
page_row = _meta_row("Page", data.page) if data.page else ""
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>New GoodWalk Lead</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f2f2f0;">
|
||||
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="background:#f2f2f0;padding:40px 16px;">
|
||||
<tr><td align="center">
|
||||
|
||||
<table width="600" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="max-width:600px;width:100%;border-radius:16px;overflow:hidden;
|
||||
box-shadow:0 4px 24px rgba(0,0,0,0.08);">
|
||||
|
||||
{_logo_header(badge_html=badge)}
|
||||
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="background:#ffffff;padding:40px 48px 36px;">
|
||||
|
||||
<!-- Owner details -->
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:11px;font-weight:700;letter-spacing:0.1em;color:#888;
|
||||
text-transform:uppercase;margin-bottom:16px;">Owner details</div>
|
||||
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="background:#f8f7f4;border-radius:12px;margin-bottom:28px;">
|
||||
<tr><td style="padding:24px 28px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td style="padding:6px 0;font-size:13px;color:#888;width:80px;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;">Name</td>
|
||||
<td style="padding:6px 0 6px 16px;font-size:15px;font-weight:600;
|
||||
color:#213021;font-family:-apple-system,BlinkMacSystemFont,
|
||||
'Segoe UI',sans-serif;vertical-align:top;">{data.fullName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px 0;font-size:13px;color:#888;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;">Email</td>
|
||||
<td style="padding:6px 0 6px 16px;font-size:14px;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;">
|
||||
<a href="mailto:{data.email}" style="color:#213021;font-weight:500;
|
||||
text-decoration:none;">{data.email}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px 0;font-size:13px;color:#888;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;">Phone</td>
|
||||
<td style="padding:6px 0 6px 16px;font-size:14px;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;">
|
||||
<a href="tel:{data.phone}" style="color:#213021;font-weight:500;
|
||||
text-decoration:none;">{data.phone}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
<!-- Dog & service details -->
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:11px;font-weight:700;letter-spacing:0.1em;color:#888;
|
||||
text-transform:uppercase;margin-bottom:16px;">Dog & services</div>
|
||||
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="background:#f8f7f4;border-radius:12px;margin-bottom:28px;">
|
||||
<tr><td style="padding:24px 28px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td style="padding:6px 0;font-size:13px;color:#888;width:80px;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;">Dog</td>
|
||||
<td style="padding:6px 0 6px 16px;font-size:15px;font-weight:600;
|
||||
color:#213021;font-family:-apple-system,BlinkMacSystemFont,
|
||||
'Segoe UI',sans-serif;vertical-align:top;">{data.petName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px 0;font-size:13px;color:#888;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;">Location</td>
|
||||
<td style="padding:6px 0 6px 16px;font-size:14px;font-weight:500;color:#213021;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;">{data.location}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px 0;font-size:13px;color:#888;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;">Services</td>
|
||||
<td style="padding:6px 0 6px 16px;font-size:14px;color:#444;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;">{services_text}</td>
|
||||
</tr>
|
||||
{message_block}
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
<!-- CTA buttons -->
|
||||
<table cellpadding="0" cellspacing="0" role="presentation" style="margin-bottom:32px;">
|
||||
<tr>
|
||||
<td style="padding-right:12px;">
|
||||
<a href="mailto:{data.email}"
|
||||
style="display:inline-block;background:#213021;color:#FFD100;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:14px;font-weight:600;text-decoration:none;
|
||||
border-radius:8px;padding:12px 24px;">
|
||||
Reply to {data.fullName.split()[0]}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="tel:{data.phone}"
|
||||
style="display:inline-block;background:#f8f7f4;color:#213021;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:14px;font-weight:600;text-decoration:none;
|
||||
border-radius:8px;padding:12px 24px;border:1px solid #e0e0d8;">
|
||||
Call {data.phone}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Session info -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="border-top:1px solid #eeeee8;padding-top:20px;">
|
||||
<tr><td>
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:11px;font-weight:700;letter-spacing:0.08em;color:#ccc;
|
||||
text-transform:uppercase;margin-bottom:12px;">Session info</div>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
{_meta_row("IP address", ip)}
|
||||
{_meta_row("Browser", browser)}
|
||||
{referrer_row}
|
||||
{page_row}
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background:#f8f7f4;padding:18px 48px;text-align:center;
|
||||
border-top:1px solid #e8e8e4;">
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:12px;color:#bbb;">
|
||||
Sent automatically by GoodWalk booking form
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
# ── Route ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.post("/submit")
|
||||
async def submit_booking(data: BookingSubmission, request: Request):
|
||||
ip = _get_ip(request)
|
||||
browser = _parse_ua(request.headers.get("user-agent", ""))
|
||||
logger.info("Booking from %s (%s, %s) for dog %s", data.email, ip, browser, data.petName)
|
||||
|
||||
errors = []
|
||||
|
||||
try:
|
||||
resend.Emails.send({
|
||||
"from": FROM_EMAIL,
|
||||
"to": [data.email],
|
||||
"reply_to": REPLY_TO,
|
||||
"subject": f"We received your enquiry, {data.fullName.split()[0]}! 🐾",
|
||||
"html": client_email(data),
|
||||
})
|
||||
logger.info("Client confirmation sent to %s", data.email)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send client email: %s", exc)
|
||||
errors.append("client_email")
|
||||
|
||||
try:
|
||||
resend.Emails.send({
|
||||
"from": FROM_EMAIL,
|
||||
"to": [OWNER_EMAIL],
|
||||
"reply_to": data.email,
|
||||
"subject": f"New GoodWalk lead — {data.fullName} ({data.petName})",
|
||||
"html": owner_email(data, ip, browser),
|
||||
})
|
||||
logger.info("Owner notification sent to %s", OWNER_EMAIL)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send owner email: %s", exc)
|
||||
errors.append("owner_email")
|
||||
|
||||
if "client_email" in errors and "owner_email" in errors:
|
||||
raise HTTPException(status_code=500, detail="Failed to send emails. Please try again.")
|
||||
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.32
|
||||
resend>=2.0
|
||||
pydantic[email]>=2.10
|
||||
Reference in New Issue
Block a user