General enquries feature
This commit is contained in:
+2
-1
@@ -70,7 +70,8 @@ mkdir -p /docker/goodwalk-svelte
|
||||
It is created from [deploy.env.template](deploy.env.template). Current template contents:
|
||||
|
||||
```env
|
||||
APP_VERSION=4.0.1
|
||||
APP_VERSION=4.0.2
|
||||
ENABLE_GENERAL_ENQUIRIES=false
|
||||
TZ=Pacific/Auckland
|
||||
|
||||
POSTGRES_DB=goodwalk
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
ARG APP_VERSION=4.0.1
|
||||
ARG APP_VERSION=4.0.2
|
||||
|
||||
FROM node:22-alpine AS builder
|
||||
ARG APP_VERSION
|
||||
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
APP_VERSION=4.0.1
|
||||
APP_VERSION=4.0.2
|
||||
TZ=Pacific/Auckland
|
||||
|
||||
POSTGRES_DB=goodwalk
|
||||
@@ -10,6 +10,7 @@ RESEND_API_KEY=re_hcDByLp8_HEBW93wDirr7o9g16FgCeYNF
|
||||
OWNER_EMAIL=mattcohen0@gmail.com
|
||||
FROM_EMAIL=GoodWalk <info@goodwalk.co.nz>
|
||||
REPLY_TO=info@goodwalk.co.nz
|
||||
ENABLE_GENERAL_ENQUIRIES=false
|
||||
|
||||
FORM_MIN_SECONDS=4
|
||||
FORM_MAX_SECONDS=7200
|
||||
|
||||
@@ -3,13 +3,14 @@ services:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-4.0.1}
|
||||
APP_VERSION: ${APP_VERSION:-4.0.2}
|
||||
container_name: goodwalk_svelte_app
|
||||
environment:
|
||||
APP_VERSION: ${APP_VERSION:-4.0.1}
|
||||
APP_VERSION: ${APP_VERSION:-4.0.2}
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-goodwalk}:${POSTGRES_PASSWORD_URLENCODED:-goodwalk}@db:5432/${POSTGRES_DB:-goodwalk}
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
ENABLE_GENERAL_ENQUIRIES: ${ENABLE_GENERAL_ENQUIRIES:-false}
|
||||
TZ: ${TZ:-Pacific/Auckland}
|
||||
depends_on:
|
||||
- db
|
||||
@@ -24,14 +25,15 @@ services:
|
||||
build:
|
||||
context: ./mail-api
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-4.0.1}
|
||||
APP_VERSION: ${APP_VERSION:-4.0.2}
|
||||
container_name: goodwalk_svelte_mail_api
|
||||
environment:
|
||||
APP_VERSION: ${APP_VERSION:-4.0.1}
|
||||
APP_VERSION: ${APP_VERSION:-4.0.2}
|
||||
RESEND_API_KEY: ${RESEND_API_KEY}
|
||||
OWNER_EMAIL: ${OWNER_EMAIL}
|
||||
FROM_EMAIL: ${FROM_EMAIL:-GoodWalk <bookings@goodwalk.co.nz>}
|
||||
REPLY_TO: ${REPLY_TO:-aless@goodwalk.co.nz}
|
||||
ENABLE_GENERAL_ENQUIRIES: ${ENABLE_GENERAL_ENQUIRIES:-false}
|
||||
FORM_MIN_SECONDS: ${FORM_MIN_SECONDS:-4}
|
||||
FORM_MAX_SECONDS: ${FORM_MAX_SECONDS:-7200}
|
||||
RATE_LIMIT_WINDOW_SECONDS: ${RATE_LIMIT_WINDOW_SECONDS:-900}
|
||||
|
||||
+6
-4
@@ -3,12 +3,13 @@ services:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-4.0.1}
|
||||
APP_VERSION: ${APP_VERSION:-4.0.2}
|
||||
environment:
|
||||
APP_VERSION: ${APP_VERSION:-4.0.1}
|
||||
APP_VERSION: ${APP_VERSION:-4.0.2}
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-goodwalk}:${POSTGRES_PASSWORD:-goodwalk}@db:5432/${POSTGRES_DB:-goodwalk}
|
||||
NODE_ENV: production
|
||||
PORT: ${APP_PORT:-3000}
|
||||
ENABLE_GENERAL_ENQUIRIES: ${ENABLE_GENERAL_ENQUIRIES:-false}
|
||||
TZ: ${TZ:-Pacific/Auckland}
|
||||
depends_on:
|
||||
- db
|
||||
@@ -18,13 +19,14 @@ services:
|
||||
build:
|
||||
context: ./mail-api
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-4.0.1}
|
||||
APP_VERSION: ${APP_VERSION:-4.0.2}
|
||||
environment:
|
||||
APP_VERSION: ${APP_VERSION:-4.0.1}
|
||||
APP_VERSION: ${APP_VERSION:-4.0.2}
|
||||
RESEND_API_KEY: ${RESEND_API_KEY}
|
||||
OWNER_EMAIL: ${OWNER_EMAIL}
|
||||
FROM_EMAIL: ${FROM_EMAIL:-GoodWalk <bookings@goodwalk.co.nz>}
|
||||
REPLY_TO: ${REPLY_TO:-aless@goodwalk.co.nz}
|
||||
ENABLE_GENERAL_ENQUIRIES: ${ENABLE_GENERAL_ENQUIRIES:-false}
|
||||
TZ: ${TZ:-Pacific/Auckland}
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
ARG APP_VERSION=4.0.1
|
||||
ARG APP_VERSION=4.0.2
|
||||
|
||||
FROM python:3.12-slim
|
||||
ARG APP_VERSION
|
||||
|
||||
Binary file not shown.
+172
-58
@@ -89,6 +89,7 @@ def _load_config() -> dict:
|
||||
"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"),
|
||||
"enable_general_enquiries": os.environ.get("ENABLE_GENERAL_ENQUIRIES", "false").strip().lower() in {"1", "true", "yes", "on", "enabled"},
|
||||
"max_attempts": max(1, int(os.environ.get("MAIL_MAX_ATTEMPTS", "3"))),
|
||||
"form_min_seconds": max(1, int(os.environ.get("FORM_MIN_SECONDS", "4"))),
|
||||
"form_max_seconds": max(60, int(os.environ.get("FORM_MAX_SECONDS", "7200"))),
|
||||
@@ -105,6 +106,7 @@ resend.api_key = _config["resend_api_key"]
|
||||
OWNER_EMAIL = _config["owner_email"]
|
||||
FROM_EMAIL = _config["from_email"]
|
||||
REPLY_TO = _config["reply_to"]
|
||||
ENABLE_GENERAL_ENQUIRIES = _config["enable_general_enquiries"]
|
||||
MAX_SEND_ATTEMPTS = _config["max_attempts"]
|
||||
FORM_MIN_SECONDS = _config["form_min_seconds"]
|
||||
FORM_MAX_SECONDS = _config["form_max_seconds"]
|
||||
@@ -116,12 +118,13 @@ RATE_LIMIT_MIN_INTERVAL_SECONDS = _config["rate_limit_min_interval_seconds"]
|
||||
LOGO_URL = "https://www.goodwalk.co.nz/images/goodwalk-auckland-dog-walking-logo.png"
|
||||
|
||||
logger.info(
|
||||
"Mail API config: version=%r timezone=%r from=%r reply_to=%r owner=%r max_attempts=%d form_min=%ss form_max=%ss rate_window=%ss per_ip=%d per_email=%d min_interval=%ss",
|
||||
"Mail API config: version=%r timezone=%r from=%r reply_to=%r owner=%r general_enquiries=%r max_attempts=%d form_min=%ss form_max=%ss rate_window=%ss per_ip=%d per_email=%d min_interval=%ss",
|
||||
APP_VERSION,
|
||||
os.environ.get("TZ", "system-default"),
|
||||
FROM_EMAIL,
|
||||
REPLY_TO,
|
||||
OWNER_EMAIL,
|
||||
ENABLE_GENERAL_ENQUIRIES,
|
||||
MAX_SEND_ATTEMPTS,
|
||||
FORM_MIN_SECONDS,
|
||||
FORM_MAX_SECONDS,
|
||||
@@ -165,11 +168,12 @@ async def _request_logging_middleware(request: Request, call_next):
|
||||
|
||||
|
||||
class BookingSubmission(BaseModel):
|
||||
enquiryType: str = "booking"
|
||||
fullName: str
|
||||
email: EmailStr
|
||||
phone: str
|
||||
petName: str
|
||||
location: str
|
||||
petName: str = ""
|
||||
location: str = ""
|
||||
message: str = ""
|
||||
services: list[str] = []
|
||||
website: str = ""
|
||||
@@ -300,6 +304,85 @@ def _is_honeypot_triggered(data: BookingSubmission) -> bool:
|
||||
return bool(_trimmed(data.website))
|
||||
|
||||
|
||||
def _is_general_enquiry(data: BookingSubmission) -> bool:
|
||||
return _trimmed(data.enquiryType).lower() == "general"
|
||||
|
||||
|
||||
def _enquiry_type_label(data: BookingSubmission) -> str:
|
||||
return "General enquiry" if _is_general_enquiry(data) else "Booking enquiry"
|
||||
|
||||
|
||||
def _validate_submission(request_id: str, data: BookingSubmission) -> None:
|
||||
enquiry_type = _trimmed(data.enquiryType).lower()
|
||||
|
||||
if enquiry_type not in {"booking", "general"}:
|
||||
logger.warning("[%s] rejected: invalid enquiryType=%r", request_id, data.enquiryType)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Please choose a valid enquiry type and try again.",
|
||||
)
|
||||
|
||||
if not _trimmed(data.fullName):
|
||||
logger.warning("[%s] rejected: missing full name", request_id)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Please enter your full name.",
|
||||
)
|
||||
|
||||
if not _trimmed(data.phone):
|
||||
logger.warning("[%s] rejected: missing phone number", request_id)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Please enter your contact number.",
|
||||
)
|
||||
|
||||
if _is_general_enquiry(data):
|
||||
if not ENABLE_GENERAL_ENQUIRIES:
|
||||
logger.warning("[%s] rejected: general enquiries are disabled", request_id)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="General enquiries are currently unavailable through this form.",
|
||||
)
|
||||
if not _trimmed(data.message):
|
||||
logger.warning("[%s] rejected: missing general enquiry message", request_id)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Please tell us how we can help.",
|
||||
)
|
||||
return
|
||||
|
||||
if not _trimmed(data.petName):
|
||||
logger.warning("[%s] rejected: missing pet name", request_id)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Please enter your dog's name.",
|
||||
)
|
||||
|
||||
if not _trimmed(data.location):
|
||||
logger.warning("[%s] rejected: missing location", request_id)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Please enter your location.",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_submission(data: BookingSubmission) -> None:
|
||||
data.enquiryType = "general" if _is_general_enquiry(data) else "booking"
|
||||
data.fullName = _trimmed(data.fullName)
|
||||
data.phone = _trimmed(data.phone)
|
||||
data.petName = _trimmed(data.petName)
|
||||
data.location = _trimmed(data.location)
|
||||
data.message = _trimmed(data.message)
|
||||
data.referrer = _trimmed(data.referrer)
|
||||
data.page = _trimmed(data.page)
|
||||
data.services = [_trimmed(service) for service in data.services if _trimmed(service)]
|
||||
|
||||
if _is_general_enquiry(data):
|
||||
data.petName = ""
|
||||
data.location = ""
|
||||
data.services = []
|
||||
|
||||
|
||||
def _parse_ua(ua: str) -> str:
|
||||
if not ua:
|
||||
return "Unknown"
|
||||
@@ -360,8 +443,46 @@ def _logo_header(badge_html: str = "", subtitle: str = "") -> str:
|
||||
|
||||
|
||||
def client_email(data: BookingSubmission) -> str:
|
||||
is_general = _is_general_enquiry(data)
|
||||
services_text = ", ".join(data.services) if data.services else "Not specified"
|
||||
message_row = _detail_row("About the dog", data.message) if data.message else ""
|
||||
enquiry_summary_rows = [
|
||||
_detail_row("Your name", data.fullName),
|
||||
_detail_row("Email", str(data.email)),
|
||||
_detail_row("Phone", data.phone),
|
||||
_detail_row("Type", _enquiry_type_label(data)),
|
||||
]
|
||||
|
||||
if is_general:
|
||||
if data.message:
|
||||
enquiry_summary_rows.append(_detail_row("Message", data.message))
|
||||
intro_html = (
|
||||
"We’ve received your message and we will be in touch shortly."
|
||||
)
|
||||
next_steps_html = (
|
||||
"We will review your message and reply within 1 business day."
|
||||
)
|
||||
logo_subtitle = "General enquiries and dog walking support"
|
||||
else:
|
||||
enquiry_summary_rows.extend(
|
||||
[
|
||||
_detail_row("Dog’s name", data.petName),
|
||||
_detail_row("Location", data.location),
|
||||
_detail_row("Services", services_text),
|
||||
]
|
||||
)
|
||||
if data.message:
|
||||
enquiry_summary_rows.append(_detail_row("About the dog", data.message))
|
||||
intro_html = (
|
||||
"We’ve received your enquiry and we will be in touch shortly to arrange "
|
||||
"a <strong style=\"color:#213021;\">Meet & Greet</strong> with you and "
|
||||
f"{data.petName}."
|
||||
)
|
||||
next_steps_html = (
|
||||
"We will review your details and reach out within 1 business day "
|
||||
"to schedule a free Meet & Greet. No commitment required — just a "
|
||||
f"chance for {data.petName} to make a new best friend."
|
||||
)
|
||||
logo_subtitle = "Professional dog walking services"
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -380,7 +501,7 @@ def client_email(data: BookingSubmission) -> str:
|
||||
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="Professional dog walking services")}
|
||||
{_logo_header(subtitle=logo_subtitle)}
|
||||
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
@@ -392,9 +513,7 @@ def client_email(data: BookingSubmission) -> str:
|
||||
</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 we will be in touch shortly to arrange
|
||||
a <strong style="color:#213021;">Meet & Greet</strong> with you and
|
||||
{data.petName}.
|
||||
{intro_html}
|
||||
</p>
|
||||
|
||||
<!-- Details card -->
|
||||
@@ -408,13 +527,7 @@ def client_email(data: BookingSubmission) -> str:
|
||||
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}
|
||||
{"".join(enquiry_summary_rows)}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -431,9 +544,7 @@ def client_email(data: BookingSubmission) -> str:
|
||||
</div>
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:14px;color:#666;line-height:1.6;">
|
||||
We will review your details and reach out within 1 business days
|
||||
to schedule a free Meet & Greet. No commitment required — just a
|
||||
chance for {data.petName} to make a new best friend.
|
||||
{next_steps_html}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -469,17 +580,20 @@ def client_email(data: BookingSubmission) -> str:
|
||||
|
||||
|
||||
def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
|
||||
is_general = _is_general_enquiry(data)
|
||||
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")
|
||||
first_name = data.fullName.split()[0] if data.fullName.strip() else "them"
|
||||
email_title = "New GoodWalk Enquiry" if is_general else "New GoodWalk Lead"
|
||||
|
||||
message_label = "Message" if is_general else "About the dog"
|
||||
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>
|
||||
text-transform:uppercase;margin-bottom:8px;">{message_label}</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>
|
||||
@@ -490,7 +604,7 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
|
||||
padding:10px 28px;">
|
||||
<span style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:18px;font-weight:700;color:#213021;">
|
||||
📩 New lead!
|
||||
📩 New enquiry!
|
||||
</span>
|
||||
</div>
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
@@ -500,13 +614,29 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
|
||||
|
||||
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 ""
|
||||
detail_heading = "Enquiry details" if is_general else "Dog & services"
|
||||
detail_rows = [_detail_row("Type", _enquiry_type_label(data))]
|
||||
|
||||
if is_general:
|
||||
if data.petName:
|
||||
detail_rows.append(_detail_row("Dog", data.petName))
|
||||
if data.location:
|
||||
detail_rows.append(_detail_row("Location", data.location))
|
||||
else:
|
||||
detail_rows.extend(
|
||||
[
|
||||
_detail_row("Dog", data.petName),
|
||||
_detail_row("Location", data.location),
|
||||
_detail_row("Services", services_text),
|
||||
]
|
||||
)
|
||||
|
||||
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>
|
||||
<title>{email_title}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f2f2f0;">
|
||||
|
||||
@@ -597,36 +727,13 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
|
||||
<!-- 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>
|
||||
text-transform:uppercase;margin-bottom:16px;">{detail_heading}</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>
|
||||
{"".join(detail_rows)}
|
||||
{message_block}
|
||||
</table>
|
||||
</td></tr>
|
||||
@@ -681,7 +788,7 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
|
||||
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
|
||||
Sent automatically by GoodWalk enquiry form
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -753,15 +860,6 @@ async def submit_booking(data: BookingSubmission, request: Request):
|
||||
ip = _get_ip(request)
|
||||
browser = _parse_ua(request.headers.get("user-agent", ""))
|
||||
|
||||
name_parts = data.fullName.strip().split()
|
||||
first_name = name_parts[0] if name_parts else "there"
|
||||
|
||||
logger.info(
|
||||
"[%s] /submit: email=%s ip=%s browser=%r dog=%s services=%s page=%r",
|
||||
request_id, data.email, ip, browser, data.petName, data.services, data.page,
|
||||
)
|
||||
logger.debug("[%s] full payload: %s", request_id, data.model_dump())
|
||||
|
||||
await _enforce_submit_rate_limits(request_id, ip, str(data.email))
|
||||
_enforce_form_timing(request_id, data)
|
||||
|
||||
@@ -779,6 +877,18 @@ async def submit_booking(data: BookingSubmission, request: Request):
|
||||
"ignored": True,
|
||||
}
|
||||
|
||||
_validate_submission(request_id, data)
|
||||
_normalize_submission(data)
|
||||
|
||||
name_parts = data.fullName.split()
|
||||
first_name = name_parts[0] if name_parts else "there"
|
||||
|
||||
logger.info(
|
||||
"[%s] /submit: type=%s email=%s ip=%s browser=%r dog=%s services=%s page=%r",
|
||||
request_id, data.enquiryType, data.email, ip, browser, data.petName, data.services, data.page,
|
||||
)
|
||||
logger.debug("[%s] full payload: %s", request_id, data.model_dump())
|
||||
|
||||
failures: list[dict] = []
|
||||
|
||||
try:
|
||||
@@ -787,7 +897,7 @@ async def submit_booking(data: BookingSubmission, request: Request):
|
||||
"from": FROM_EMAIL,
|
||||
"to": [data.email],
|
||||
"reply_to": REPLY_TO,
|
||||
"subject": f"We received your enquiry, {first_name}! 🐾",
|
||||
"subject": f"We received your {'general enquiry' if _is_general_enquiry(data) else 'enquiry'}, {first_name}! 🐾",
|
||||
"html": client_email(data),
|
||||
},
|
||||
label="client_email",
|
||||
@@ -807,7 +917,11 @@ async def submit_booking(data: BookingSubmission, request: Request):
|
||||
"from": FROM_EMAIL,
|
||||
"to": [OWNER_EMAIL],
|
||||
"reply_to": data.email,
|
||||
"subject": f"New GoodWalk lead — {data.fullName} ({data.petName})",
|
||||
"subject": (
|
||||
f"New GoodWalk general enquiry — {data.fullName}"
|
||||
if _is_general_enquiry(data)
|
||||
else f"New GoodWalk lead — {data.fullName} ({data.petName})"
|
||||
),
|
||||
"html": owner_email(data, ip, browser),
|
||||
},
|
||||
label="owner_email",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "goodwalk-svelte-port",
|
||||
"version": "4.0.1",
|
||||
"version": "4.0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "goodwalk-svelte-port",
|
||||
"version": "4.0.1",
|
||||
"version": "4.0.2",
|
||||
"dependencies": {
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"pg": "^8.13.1"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "goodwalk-svelte-port",
|
||||
"version": "4.0.1",
|
||||
"version": "4.0.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import type { BookingContent } from '$lib/types';
|
||||
|
||||
export let booking: BookingContent;
|
||||
export let allowGeneralEnquiry = false;
|
||||
|
||||
const email = 'info@goodwalk.co.nz';
|
||||
const phone = '(022) 642 1011';
|
||||
@@ -12,8 +13,14 @@
|
||||
<main class="booking-page">
|
||||
<section class="booking-page-hero">
|
||||
<div class="booking-page-inner">
|
||||
<h1>Book a Meet & Greet</h1>
|
||||
<p class="booking-page-sub">Fill in the form below and we'll be in touch to arrange a free introduction.</p>
|
||||
<h1>Contact Us</h1>
|
||||
<p class="booking-page-sub">
|
||||
{#if allowGeneralEnquiry}
|
||||
Fill in the form below to book a Meet & Greet or send a general enquiry.
|
||||
{:else}
|
||||
Fill in the form below and we'll be in touch to arrange a free introduction.
|
||||
{/if}
|
||||
</p>
|
||||
<div class="booking-page-contact">
|
||||
<a href="mailto:{email}" class="booking-contact-link">
|
||||
<Icon name="fas fa-envelope" />
|
||||
@@ -27,7 +34,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<BookingSection {booking} />
|
||||
<BookingSection {booking} {allowGeneralEnquiry} />
|
||||
</main>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -7,10 +7,13 @@
|
||||
import type { BookingContent } from '$lib/types';
|
||||
|
||||
export let booking: BookingContent;
|
||||
export let allowGeneralEnquiry = false;
|
||||
type EnquiryType = 'booking' | 'general';
|
||||
|
||||
let step = 1;
|
||||
$: headingParts = splitBookingTitle(booking.title);
|
||||
|
||||
let enquiryType: EnquiryType = 'booking';
|
||||
let fullName = '';
|
||||
let email = '';
|
||||
let phone = '';
|
||||
@@ -59,12 +62,26 @@
|
||||
|
||||
const defaultDogIntro =
|
||||
'Tell us about your dog and where you are based so we can plan the right Meet & Greet.';
|
||||
const defaultGeneralIntro =
|
||||
'Need to send feedback, make a complaint, or ask a business question? Choose general enquiry and tell us what you need.';
|
||||
const defaultGeneralSubtitle =
|
||||
'Almost there — just your contact details so we can reply properly to your message.';
|
||||
|
||||
$: dogIntro = booking.dogIntro?.trim() || defaultDogIntro;
|
||||
$: hasBanner = Boolean(booking.subtitle?.trim());
|
||||
$: generalIntro = booking.generalIntro?.trim() || defaultGeneralIntro;
|
||||
$: hasServices = booking.serviceOptions.length > 0;
|
||||
$: if (!allowGeneralEnquiry && enquiryType === 'general') {
|
||||
enquiryType = 'booking';
|
||||
}
|
||||
$: isGeneralEnquiry = allowGeneralEnquiry && enquiryType === 'general';
|
||||
$: ownerSubtitle = isGeneralEnquiry
|
||||
? booking.generalSubtitle?.trim() || defaultGeneralSubtitle
|
||||
: booking.subtitle;
|
||||
$: ownerStepLabel = booking.ownerStepLabel?.trim() || 'Owner Details';
|
||||
$: dogStepLabel = booking.dogStepLabel?.trim() || 'Your dog';
|
||||
$: firstStepLabel = isGeneralEnquiry ? 'Your enquiry' : dogStepLabel;
|
||||
$: firstStepIntro = isGeneralEnquiry ? generalIntro : dogIntro;
|
||||
$: successPetName = petName.trim() || 'your dog';
|
||||
|
||||
onMount(() => {
|
||||
formStartedAt = Date.now();
|
||||
@@ -99,22 +116,47 @@
|
||||
selectedServices = selectedServices.filter((item) => item !== service);
|
||||
}
|
||||
|
||||
function validateDogStep(): boolean {
|
||||
function setEnquiryType(nextType: EnquiryType) {
|
||||
enquiryType = nextType;
|
||||
if (nextType === 'general') {
|
||||
petName = '';
|
||||
location = '';
|
||||
selectedServices = [];
|
||||
}
|
||||
errors = {};
|
||||
}
|
||||
|
||||
function validateFirstStep(): boolean {
|
||||
const next: Record<string, string> = {};
|
||||
|
||||
if (!petName.trim()) next.petName = "Please enter your dog's name";
|
||||
if (!location.trim()) next.location = 'Please enter your location';
|
||||
if (isGeneralEnquiry) {
|
||||
if (!message.trim()) next.message = 'Please tell us how we can help';
|
||||
} else {
|
||||
if (!petName.trim()) next.petName = "Please enter your dog's name";
|
||||
if (!location.trim()) next.location = 'Please enter your location';
|
||||
}
|
||||
|
||||
errors = next;
|
||||
|
||||
if (next.petName) { petNameInput?.focus(); return false; }
|
||||
if (next.location) { locationInput?.focus(); return false; }
|
||||
if (next.petName) {
|
||||
petNameInput?.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (next.location) {
|
||||
locationInput?.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (next.message) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function goToOwnerStep() {
|
||||
if (!validateDogStep()) return;
|
||||
if (!validateFirstStep()) return;
|
||||
errors = {};
|
||||
step = 2;
|
||||
}
|
||||
@@ -152,13 +194,19 @@
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
fullName, email, phone, petName, location, message,
|
||||
services: selectedServices,
|
||||
enquiryType,
|
||||
fullName,
|
||||
email,
|
||||
phone,
|
||||
petName: isGeneralEnquiry ? '' : petName,
|
||||
location: isGeneralEnquiry ? '' : location,
|
||||
message,
|
||||
services: isGeneralEnquiry ? [] : selectedServices,
|
||||
website,
|
||||
formStartedAt,
|
||||
referrer: document.referrer,
|
||||
page: window.location.href,
|
||||
}),
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -185,8 +233,9 @@
|
||||
{#if submitted}
|
||||
<SuccessModal
|
||||
firstName={fullName.split(' ')[0]}
|
||||
{petName}
|
||||
petName={successPetName}
|
||||
{email}
|
||||
{enquiryType}
|
||||
onClose={() => (submitted = false)}
|
||||
/>
|
||||
{/if}
|
||||
@@ -194,6 +243,7 @@
|
||||
{#if showErrorModal}
|
||||
<ErrorModal
|
||||
detail={submitErrorDetail}
|
||||
{enquiryType}
|
||||
onClose={() => (showErrorModal = false)}
|
||||
onRetry={() => (showErrorModal = false)}
|
||||
/>
|
||||
@@ -212,7 +262,7 @@
|
||||
on:click={() => (step = 1)}
|
||||
>
|
||||
<span class="booking-step-number">1</span>
|
||||
<span class="booking-step-label">{dogStepLabel}</span>
|
||||
<span class="booking-step-label">{firstStepLabel}</span>
|
||||
</button>
|
||||
<span class="booking-step-divider" aria-hidden="true"></span>
|
||||
<button
|
||||
@@ -247,70 +297,120 @@
|
||||
|
||||
{#if step === 1}
|
||||
<div class="booking-panel">
|
||||
{#if dogIntro}
|
||||
<div class="booking-panel-banner">{dogIntro}</div>
|
||||
{#if firstStepIntro}
|
||||
<div class="booking-panel-banner">{firstStepIntro}</div>
|
||||
{/if}
|
||||
|
||||
<div class:booking-card-grid-with-banner={Boolean(dogIntro)} class="booking-card-grid booking-card-grid-dog">
|
||||
<div class="booking-field-card" class:booking-field-card-invalid={errors.petName}>
|
||||
<label for="petName">
|
||||
<Icon name="fas fa-dog" /> Pet's Name <span class="booking-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
bind:this={petNameInput}
|
||||
bind:value={petName}
|
||||
type="text"
|
||||
id="petName"
|
||||
name="petName"
|
||||
required
|
||||
placeholder="Your dog's name"
|
||||
class:input-invalid={errors.petName}
|
||||
on:input={() => clearError('petName')}
|
||||
/>
|
||||
{#if errors.petName}
|
||||
<p class="field-error">
|
||||
<Icon name="fas fa-circle-exclamation" />
|
||||
{errors.petName}
|
||||
<div class:booking-card-grid-with-banner={Boolean(firstStepIntro)} class="booking-card-grid booking-card-grid-dog">
|
||||
{#if allowGeneralEnquiry}
|
||||
<div class="booking-field-card booking-field-card-full">
|
||||
<label>
|
||||
<Icon name="fas fa-comments" /> Enquiry type
|
||||
</label>
|
||||
<div class="booking-toggle-group" role="radiogroup" aria-label="Enquiry type">
|
||||
<label class="booking-toggle-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="enquiryType"
|
||||
value="booking"
|
||||
checked={enquiryType === 'booking'}
|
||||
on:change={() => setEnquiryType('booking')}
|
||||
/>
|
||||
<span class="booking-toggle-indicator" aria-hidden="true"></span>
|
||||
<span>Book a Meet & Greet</span>
|
||||
</label>
|
||||
<label class="booking-toggle-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="enquiryType"
|
||||
value="general"
|
||||
checked={enquiryType === 'general'}
|
||||
on:change={() => setEnquiryType('general')}
|
||||
/>
|
||||
<span class="booking-toggle-indicator" aria-hidden="true"></span>
|
||||
<span>General enquiry</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="booking-help-text">
|
||||
General enquiries cover feedback, complaints, business enquiries, and other non-booking messages.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="booking-field-card booking-field-card-wide" class:booking-field-card-invalid={errors.location}>
|
||||
<label for="location">
|
||||
<Icon name="fas fa-location-dot" /> Location <span class="booking-required">*</span>
|
||||
{#if !isGeneralEnquiry}
|
||||
<div class="booking-field-card" class:booking-field-card-invalid={errors.petName}>
|
||||
<label for="petName">
|
||||
<Icon name="fas fa-dog" /> Dog's Name <span class="booking-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
bind:this={petNameInput}
|
||||
bind:value={petName}
|
||||
type="text"
|
||||
id="petName"
|
||||
name="petName"
|
||||
required
|
||||
placeholder="Your dog's name"
|
||||
class:input-invalid={errors.petName}
|
||||
on:input={() => clearError('petName')}
|
||||
/>
|
||||
{#if errors.petName}
|
||||
<p class="field-error">
|
||||
<Icon name="fas fa-circle-exclamation" />
|
||||
{errors.petName}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="booking-field-card booking-field-card-wide" class:booking-field-card-invalid={errors.location}>
|
||||
<label for="location">
|
||||
<Icon name="fas fa-location-dot" /> Location <span class="booking-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
bind:this={locationInput}
|
||||
bind:value={location}
|
||||
type="text"
|
||||
id="location"
|
||||
name="location"
|
||||
required
|
||||
placeholder="Neighborhood, street..."
|
||||
class:input-invalid={errors.location}
|
||||
on:input={() => clearError('location')}
|
||||
/>
|
||||
{#if errors.location}
|
||||
<p class="field-error">
|
||||
<Icon name="fas fa-circle-exclamation" />
|
||||
{errors.location}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="booking-field-card booking-field-card-full" class:booking-field-card-invalid={errors.message}>
|
||||
<label for="message">
|
||||
<Icon name="fas fa-comment" /> {isGeneralEnquiry ? 'Your Message' : 'About Your Dog'}
|
||||
{#if isGeneralEnquiry}<span class="booking-required">*</span>{/if}
|
||||
</label>
|
||||
<input
|
||||
bind:this={locationInput}
|
||||
bind:value={location}
|
||||
type="text"
|
||||
id="location"
|
||||
name="location"
|
||||
required
|
||||
placeholder="Neighborhood, street..."
|
||||
class:input-invalid={errors.location}
|
||||
on:input={() => clearError('location')}
|
||||
/>
|
||||
{#if errors.location}
|
||||
<p class="field-error">
|
||||
<Icon name="fas fa-circle-exclamation" />
|
||||
{errors.location}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="booking-field-card booking-field-card-full">
|
||||
<label for="message"><Icon name="fas fa-comment" /> About Your Dog</label>
|
||||
<textarea
|
||||
bind:value={message}
|
||||
id="message"
|
||||
name="message"
|
||||
rows="4"
|
||||
placeholder="Describe your pet, any special needs, or anything we should know."
|
||||
placeholder={isGeneralEnquiry
|
||||
? 'Tell us if this is feedback, a complaint, a business enquiry, or anything else we should know.'
|
||||
: 'Describe your pet, any special needs, or anything we should know.'}
|
||||
class:input-invalid={errors.message}
|
||||
on:input={() => clearError('message')}
|
||||
></textarea>
|
||||
{#if errors.message}
|
||||
<p class="field-error">
|
||||
<Icon name="fas fa-circle-exclamation" />
|
||||
{errors.message}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if hasServices}
|
||||
{#if hasServices && !isGeneralEnquiry}
|
||||
<div class="booking-service-row">
|
||||
<span class="booking-service-label"><Icon name="fas fa-paw" /> Services</span>
|
||||
<div class="booking-service-options">
|
||||
@@ -340,19 +440,24 @@
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<input type="hidden" name="petName" value={petName} />
|
||||
<input type="hidden" name="location" value={location} />
|
||||
<input type="hidden" name="enquiryType" value={enquiryType} />
|
||||
{#if !isGeneralEnquiry}
|
||||
<input type="hidden" name="petName" value={petName} />
|
||||
<input type="hidden" name="location" value={location} />
|
||||
{/if}
|
||||
<input type="hidden" name="message" value={message} />
|
||||
{#each selectedServices as service}
|
||||
<input type="hidden" name="services" value={service} />
|
||||
{/each}
|
||||
{#if !isGeneralEnquiry}
|
||||
{#each selectedServices as service}
|
||||
<input type="hidden" name="services" value={service} />
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<div class="booking-panel">
|
||||
{#if hasBanner}
|
||||
<div class="booking-panel-banner">{booking.subtitle}</div>
|
||||
{#if ownerSubtitle}
|
||||
<div class="booking-panel-banner">{ownerSubtitle}</div>
|
||||
{/if}
|
||||
|
||||
<div class:booking-card-grid-with-banner={hasBanner} class="booking-card-grid booking-card-grid-owner">
|
||||
<div class:booking-card-grid-with-banner={Boolean(ownerSubtitle)} class="booking-card-grid booking-card-grid-owner">
|
||||
<div class="booking-field-card booking-field-card-group booking-field-card-full">
|
||||
<div class="booking-field-group booking-field-group-owner">
|
||||
<div class="booking-field-stack" class:booking-field-stack-invalid={errors.fullName}>
|
||||
|
||||
@@ -16,6 +16,8 @@ describe('BookingSection', () => {
|
||||
booking: homepageContent.booking
|
||||
});
|
||||
|
||||
expect(screen.queryByLabelText(/General enquiry/i)).not.toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(container.querySelector('.booking-next-button')!);
|
||||
|
||||
expect(screen.getByText("Please enter your dog's name")).toBeInTheDocument();
|
||||
@@ -27,7 +29,7 @@ describe('BookingSection', () => {
|
||||
booking: homepageContent.booking
|
||||
});
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/Pet's Name/i), {
|
||||
await fireEvent.input(screen.getByLabelText(/Dog's Name/i), {
|
||||
target: { value: 'Maya' }
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/Location/i), {
|
||||
@@ -56,7 +58,7 @@ describe('BookingSection', () => {
|
||||
await fireEvent.click(screen.getByLabelText('Pack Walks'));
|
||||
await fireEvent.click(screen.getByLabelText('Other Services'));
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/Pet's Name/i), {
|
||||
await fireEvent.input(screen.getByLabelText(/Dog's Name/i), {
|
||||
target: { value: 'Maya' }
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/Location/i), {
|
||||
@@ -91,6 +93,7 @@ describe('BookingSection', () => {
|
||||
|
||||
const payload = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
||||
expect(payload).toMatchObject({
|
||||
enquiryType: 'booking',
|
||||
fullName: 'Alex Walker',
|
||||
email: 'alex@example.com',
|
||||
phone: '021 123 4567',
|
||||
@@ -113,6 +116,69 @@ describe('BookingSection', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('allows general enquiries without dog or service details', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({})
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { container } = render(BookingSection, {
|
||||
booking: homepageContent.booking,
|
||||
allowGeneralEnquiry: true
|
||||
});
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/Dog's Name/i), {
|
||||
target: { value: 'Maya' }
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/Location/i), {
|
||||
target: { value: 'Grey Lynn' }
|
||||
});
|
||||
await fireEvent.click(screen.getByLabelText('Pack Walks'));
|
||||
await fireEvent.click(screen.getByLabelText(/General enquiry/i));
|
||||
|
||||
expect(screen.queryByLabelText(/Dog's Name/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Pack Walks')).not.toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(container.querySelector('.booking-next-button')!);
|
||||
expect(screen.getByText('Please tell us how we can help')).toBeInTheDocument();
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/Your Message/i), {
|
||||
target: { value: 'I would like to discuss a business partnership.' }
|
||||
});
|
||||
|
||||
await fireEvent.click(container.querySelector('.booking-next-button')!);
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/Full Name/i), {
|
||||
target: { value: 'Alex Walker' }
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/^Email/i), {
|
||||
target: { value: 'alex@example.com' }
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/Contact #/i), {
|
||||
target: { value: '021 123 4567' }
|
||||
});
|
||||
|
||||
await fireEvent.click(container.querySelector('.booking-submit-button')!);
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
const payload = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
||||
expect(payload).toMatchObject({
|
||||
enquiryType: 'general',
|
||||
fullName: 'Alex Walker',
|
||||
email: 'alex@example.com',
|
||||
phone: '021 123 4567',
|
||||
petName: '',
|
||||
location: '',
|
||||
message: 'I would like to discuss a business partnership.',
|
||||
services: []
|
||||
});
|
||||
|
||||
expect(screen.getByRole('dialog', { name: /Enquiry confirmed/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Your message is with us!/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the API error message when submission fails', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
@@ -126,7 +192,7 @@ describe('BookingSection', () => {
|
||||
booking: homepageContent.booking
|
||||
});
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/Pet's Name/i), {
|
||||
await fireEvent.input(screen.getByLabelText(/Dog's Name/i), {
|
||||
target: { value: 'Maya' }
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/Location/i), {
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<script lang="ts">
|
||||
export let email = 'info@goodwalk.co.nz';
|
||||
export let enquiryType: 'booking' | 'general' = 'booking';
|
||||
export let onClose: () => void;
|
||||
export let onRetry: (() => void) | null = null;
|
||||
export let detail = '';
|
||||
|
||||
$: isGeneralEnquiry = enquiryType === 'general';
|
||||
$: mailtoHref =
|
||||
`mailto:${email}` +
|
||||
`?subject=${encodeURIComponent('Booking enquiry')}` +
|
||||
`?subject=${encodeURIComponent(isGeneralEnquiry ? 'General enquiry' : 'Booking enquiry')}` +
|
||||
`&body=${encodeURIComponent(
|
||||
'Hi Aless,\n\nI tried to submit the booking form but it didn’t go through. Here are my details:\n\nName:\nPhone:\nDog’s name:\nLocation:\n\nThanks!'
|
||||
isGeneralEnquiry
|
||||
? 'Hi Aless,\n\nI tried to submit the contact form but it didn’t go through. Here are my details:\n\nName:\nPhone:\nMessage:\n\nThanks!'
|
||||
: 'Hi Aless,\n\nI tried to submit the booking form but it didn’t go through. Here are my details:\n\nName:\nPhone:\nDog’s name:\nLocation:\n\nThanks!'
|
||||
)}`;
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
export let firstName: string;
|
||||
export let petName: string;
|
||||
export let email: string;
|
||||
export let enquiryType: 'booking' | 'general' = 'booking';
|
||||
export let onClose: () => void;
|
||||
|
||||
$: isGeneralEnquiry = enquiryType === 'general';
|
||||
|
||||
onMount(() => {
|
||||
const duration = 3200;
|
||||
const end = Date.now() + duration;
|
||||
@@ -44,7 +47,7 @@
|
||||
class="modal-backdrop"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Booking confirmed"
|
||||
aria-label={isGeneralEnquiry ? 'Enquiry confirmed' : 'Booking confirmed'}
|
||||
on:click|self={onClose}
|
||||
on:keydown={(e) => e.key === 'Escape' && onClose()}
|
||||
tabindex="-1"
|
||||
@@ -56,18 +59,35 @@
|
||||
|
||||
<div class="modal-paw" aria-hidden="true">🐾</div>
|
||||
|
||||
<h2 class="modal-heading">You’re on our radar!</h2>
|
||||
<h2 class="modal-heading">
|
||||
{#if isGeneralEnquiry}
|
||||
Your message is with us!
|
||||
{:else}
|
||||
You’re on our radar!
|
||||
{/if}
|
||||
</h2>
|
||||
|
||||
<p class="modal-body">
|
||||
Thanks, <strong>{firstName}</strong>! We’ve sent a confirmation to
|
||||
<strong>{email}</strong> and Aless will be in touch soon to arrange a
|
||||
Meet & Greet with <strong>{petName}</strong>.
|
||||
</p>
|
||||
{#if isGeneralEnquiry}
|
||||
<p class="modal-body">
|
||||
Thanks, <strong>{firstName}</strong>! We’ve sent a confirmation to
|
||||
<strong>{email}</strong> and Aless will be in touch soon about your enquiry.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="modal-body">
|
||||
Thanks, <strong>{firstName}</strong>! We’ve sent a confirmation to
|
||||
<strong>{email}</strong> and Aless will be in touch soon to arrange a
|
||||
Meet & Greet with <strong>{petName}</strong>.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="modal-divider"></div>
|
||||
|
||||
<p class="modal-sub">
|
||||
In the meantime, feel free to follow along on Instagram for daily walks and happy dogs!
|
||||
{#if isGeneralEnquiry}
|
||||
We aim to reply within 1 business day.
|
||||
{:else}
|
||||
In the meantime, feel free to follow along on Instagram for daily walks and happy dogs!
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<button class="modal-btn" type="button" on:click={onClose}>
|
||||
|
||||
@@ -158,10 +158,14 @@ export const homepageContent: HomePageContent = {
|
||||
title: "Let's meet!",
|
||||
subtitle:
|
||||
"Almost there — just your contact details so we can reach out to arrange your free, no-obligation Meet & Greet.",
|
||||
generalSubtitle:
|
||||
"Almost there — just your contact details so we can reply properly to your message.",
|
||||
formAction: '/contact-us',
|
||||
serviceOptions: ['Pack Walks', '1:1 Walks', 'Puppy Visits', 'Other Services'],
|
||||
ownerStepLabel: 'Your details',
|
||||
dogStepLabel: 'Your dog'
|
||||
dogStepLabel: 'Your dog',
|
||||
generalIntro:
|
||||
'Got feedback, a complaint, or a business enquiry? Choose general enquiry and send us the details without filling in dog or service information.'
|
||||
},
|
||||
info: {
|
||||
title: 'Locations & Hours',
|
||||
|
||||
@@ -37,7 +37,7 @@ export const staticPages = {
|
||||
},
|
||||
'contact-us': {
|
||||
title: 'Contact Us',
|
||||
description: 'Book a Meet & Greet with Goodwalk Auckland dog walking services.',
|
||||
description: 'Book a Meet & Greet or send a general enquiry to Goodwalk Auckland dog walking services.',
|
||||
canonicalPath: '/contact-us'
|
||||
},
|
||||
'terms-and-conditions': {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
function parseBooleanFlag(value: string | undefined, defaultValue = false) {
|
||||
if (value == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
|
||||
if (['1', 'true', 'yes', 'on', 'enabled'].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (['0', 'false', 'no', 'off', 'disabled'].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
export function isGeneralEnquiryEnabled() {
|
||||
return parseBooleanFlag(process.env.ENABLE_GENERAL_ENQUIRIES, false);
|
||||
}
|
||||
@@ -191,6 +191,13 @@
|
||||
color: #34363a;
|
||||
}
|
||||
|
||||
.booking-help-text {
|
||||
margin: 14px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.booking-required {
|
||||
color: var(--yellow);
|
||||
}
|
||||
@@ -238,6 +245,68 @@
|
||||
box-shadow: 0 10px 30px rgba(17, 20, 24, 0.04);
|
||||
}
|
||||
|
||||
.booking-toggle-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.booking-toggle-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 56px;
|
||||
padding: 14px 18px;
|
||||
border: 3px solid #111;
|
||||
border-radius: 18px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #34363a;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.2s,
|
||||
border-color 0.2s,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
|
||||
.booking-toggle-option:hover {
|
||||
background: #f6f0e5;
|
||||
}
|
||||
|
||||
.booking-toggle-option input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.booking-toggle-indicator {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid #111;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
transition:
|
||||
background 0.2s,
|
||||
border-color 0.2s;
|
||||
}
|
||||
|
||||
.booking-toggle-option input:checked + .booking-toggle-indicator {
|
||||
border-color: #111;
|
||||
background: var(--yellow);
|
||||
}
|
||||
|
||||
.booking-toggle-option input:checked + .booking-toggle-indicator::after {
|
||||
content: '';
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
|
||||
.booking-service-options {
|
||||
display: flex;
|
||||
|
||||
@@ -469,6 +469,23 @@
|
||||
gap: 12px 18px;
|
||||
}
|
||||
|
||||
.booking-toggle-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.booking-toggle-option {
|
||||
width: 100%;
|
||||
border-width: 2px;
|
||||
border-radius: 16px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.booking-toggle-indicator {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.booking-check-option {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
@@ -77,11 +77,13 @@ export interface TestimonialContent {
|
||||
export interface BookingContent {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
generalSubtitle?: string;
|
||||
formAction: string;
|
||||
serviceOptions: string[];
|
||||
ownerStepLabel?: string;
|
||||
dogStepLabel?: string;
|
||||
dogIntro?: string;
|
||||
generalIntro?: string;
|
||||
}
|
||||
|
||||
export interface ServicePricingPlan {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { staticPages, type StaticPageSlug } from '$lib/content/static-pages';
|
||||
import { isGeneralEnquiryEnabled } from '$lib/server/feature-flags';
|
||||
import { getSharedPageContent } from '$lib/server/content';
|
||||
|
||||
export async function load({ params }) {
|
||||
@@ -12,14 +13,24 @@ export async function load({ params }) {
|
||||
}
|
||||
|
||||
const slug = params.slug as StaticPageSlug;
|
||||
const page = staticPages[slug];
|
||||
const generalEnquiryEnabled = isGeneralEnquiryEnabled();
|
||||
const sourcePage = staticPages[slug];
|
||||
|
||||
if (!page) {
|
||||
if (!sourcePage) {
|
||||
throw error(404, 'Page not found');
|
||||
}
|
||||
|
||||
const page =
|
||||
slug === 'contact-us' && !generalEnquiryEnabled
|
||||
? {
|
||||
...sourcePage,
|
||||
description: 'Book a Meet & Greet with Goodwalk Auckland dog walking services.'
|
||||
}
|
||||
: sourcePage;
|
||||
|
||||
return {
|
||||
content: await getSharedPageContent(),
|
||||
generalEnquiryEnabled,
|
||||
page,
|
||||
slug
|
||||
};
|
||||
|
||||
@@ -193,7 +193,7 @@
|
||||
{:else if data.slug === 'privacy-policy'}
|
||||
<LegalPage pageContent={privacyPolicyContent} />
|
||||
{:else if data.slug === 'contact-us'}
|
||||
<BookingPage booking={data.content.booking} />
|
||||
<BookingPage booking={data.content.booking} allowGeneralEnquiry={data.generalEnquiryEnabled} />
|
||||
{:else}
|
||||
<main class="static-page">
|
||||
<section class="static-page-hero">
|
||||
|
||||
@@ -15,6 +15,7 @@ import { load } from './+page.server';
|
||||
describe('static slug page server load', () => {
|
||||
beforeEach(() => {
|
||||
getSharedPageContent.mockReset();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('redirects the legacy about-us slug to /about', async () => {
|
||||
@@ -42,8 +43,35 @@ describe('static slug page server load', () => {
|
||||
|
||||
await expect(load({ params: { slug: 'pack-walks' } } as never)).resolves.toEqual({
|
||||
content: sharedPageContent,
|
||||
generalEnquiryEnabled: false,
|
||||
page: staticPages['pack-walks'],
|
||||
slug: 'pack-walks'
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps general enquiries disabled on contact-us by default', async () => {
|
||||
getSharedPageContent.mockResolvedValue(sharedPageContent);
|
||||
|
||||
await expect(load({ params: { slug: 'contact-us' } } as never)).resolves.toEqual({
|
||||
content: sharedPageContent,
|
||||
generalEnquiryEnabled: false,
|
||||
page: {
|
||||
...staticPages['contact-us'],
|
||||
description: 'Book a Meet & Greet with Goodwalk Auckland dog walking services.'
|
||||
},
|
||||
slug: 'contact-us'
|
||||
});
|
||||
});
|
||||
|
||||
it('enables general enquiries on contact-us when the env flag is turned on', async () => {
|
||||
vi.stubEnv('ENABLE_GENERAL_ENQUIRIES', 'enabled');
|
||||
getSharedPageContent.mockResolvedValue(sharedPageContent);
|
||||
|
||||
await expect(load({ params: { slug: 'contact-us' } } as never)).resolves.toEqual({
|
||||
content: sharedPageContent,
|
||||
generalEnquiryEnabled: true,
|
||||
page: staticPages['contact-us'],
|
||||
slug: 'contact-us'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,4 +43,21 @@ describe('static slug route page', () => {
|
||||
expect(screen.queryByText('Why people choose us!')).not.toBeInTheDocument();
|
||||
}
|
||||
);
|
||||
|
||||
it('shows the general enquiry option on the contact page only', () => {
|
||||
const { rerender } = render(SlugPage, {
|
||||
data: {
|
||||
...createStaticRouteData('contact-us'),
|
||||
generalEnquiryEnabled: true
|
||||
}
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText(/General enquiry/i)).toBeInTheDocument();
|
||||
|
||||
rerender({
|
||||
data: createStaticRouteData('pack-walks')
|
||||
});
|
||||
|
||||
expect(screen.queryByLabelText(/General enquiry/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ describe('home page route', () => {
|
||||
expect(screen.getAllByText("Your Dog's Day!").length).toBeGreaterThan(0);
|
||||
expect(document.body.textContent).toContain('Happy pets,');
|
||||
expect(screen.getByText('Locations & Hours')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/General enquiry/i)).not.toBeInTheDocument();
|
||||
expect(document.title).toBe('Home | Auckland Dog Walking | Goodwalk');
|
||||
expect(document.head.innerHTML).toContain('FAQPage');
|
||||
expect(document.head.innerHTML).toContain('https://www.goodwalk.co.nz/images/auckland-dog-walking-happy-dog-hero.png');
|
||||
|
||||
@@ -18,6 +18,7 @@ export function createHomepageRouteData() {
|
||||
export function createStaticRouteData(slug: StaticPageSlug) {
|
||||
return {
|
||||
content: sharedPageContent,
|
||||
generalEnquiryEnabled: false,
|
||||
page: staticPages[slug],
|
||||
slug
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user