General enquries feature

This commit is contained in:
2026-05-04 20:32:24 +12:00
parent bf9331bb5b
commit fa1bc1a615
27 changed files with 657 additions and 164 deletions
+2 -1
View File
@@ -70,7 +70,8 @@ mkdir -p /docker/goodwalk-svelte
It is created from [deploy.env.template](deploy.env.template). Current template contents: It is created from [deploy.env.template](deploy.env.template). Current template contents:
```env ```env
APP_VERSION=4.0.1 APP_VERSION=4.0.2
ENABLE_GENERAL_ENQUIRIES=false
TZ=Pacific/Auckland TZ=Pacific/Auckland
POSTGRES_DB=goodwalk POSTGRES_DB=goodwalk
+1 -1
View File
@@ -1,4 +1,4 @@
ARG APP_VERSION=4.0.1 ARG APP_VERSION=4.0.2
FROM node:22-alpine AS builder FROM node:22-alpine AS builder
ARG APP_VERSION ARG APP_VERSION
+2 -1
View File
@@ -1,4 +1,4 @@
APP_VERSION=4.0.1 APP_VERSION=4.0.2
TZ=Pacific/Auckland TZ=Pacific/Auckland
POSTGRES_DB=goodwalk POSTGRES_DB=goodwalk
@@ -10,6 +10,7 @@ RESEND_API_KEY=re_hcDByLp8_HEBW93wDirr7o9g16FgCeYNF
OWNER_EMAIL=mattcohen0@gmail.com OWNER_EMAIL=mattcohen0@gmail.com
FROM_EMAIL=GoodWalk <info@goodwalk.co.nz> FROM_EMAIL=GoodWalk <info@goodwalk.co.nz>
REPLY_TO=info@goodwalk.co.nz REPLY_TO=info@goodwalk.co.nz
ENABLE_GENERAL_ENQUIRIES=false
FORM_MIN_SECONDS=4 FORM_MIN_SECONDS=4
FORM_MAX_SECONDS=7200 FORM_MAX_SECONDS=7200
+6 -4
View File
@@ -3,13 +3,14 @@ services:
build: build:
context: . context: .
args: args:
APP_VERSION: ${APP_VERSION:-4.0.1} APP_VERSION: ${APP_VERSION:-4.0.2}
container_name: goodwalk_svelte_app container_name: goodwalk_svelte_app
environment: 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} DATABASE_URL: postgresql://${POSTGRES_USER:-goodwalk}:${POSTGRES_PASSWORD_URLENCODED:-goodwalk}@db:5432/${POSTGRES_DB:-goodwalk}
NODE_ENV: production NODE_ENV: production
PORT: 3000 PORT: 3000
ENABLE_GENERAL_ENQUIRIES: ${ENABLE_GENERAL_ENQUIRIES:-false}
TZ: ${TZ:-Pacific/Auckland} TZ: ${TZ:-Pacific/Auckland}
depends_on: depends_on:
- db - db
@@ -24,14 +25,15 @@ services:
build: build:
context: ./mail-api context: ./mail-api
args: args:
APP_VERSION: ${APP_VERSION:-4.0.1} APP_VERSION: ${APP_VERSION:-4.0.2}
container_name: goodwalk_svelte_mail_api container_name: goodwalk_svelte_mail_api
environment: environment:
APP_VERSION: ${APP_VERSION:-4.0.1} APP_VERSION: ${APP_VERSION:-4.0.2}
RESEND_API_KEY: ${RESEND_API_KEY} RESEND_API_KEY: ${RESEND_API_KEY}
OWNER_EMAIL: ${OWNER_EMAIL} OWNER_EMAIL: ${OWNER_EMAIL}
FROM_EMAIL: ${FROM_EMAIL:-GoodWalk <bookings@goodwalk.co.nz>} FROM_EMAIL: ${FROM_EMAIL:-GoodWalk <bookings@goodwalk.co.nz>}
REPLY_TO: ${REPLY_TO:-aless@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_MIN_SECONDS: ${FORM_MIN_SECONDS:-4}
FORM_MAX_SECONDS: ${FORM_MAX_SECONDS:-7200} FORM_MAX_SECONDS: ${FORM_MAX_SECONDS:-7200}
RATE_LIMIT_WINDOW_SECONDS: ${RATE_LIMIT_WINDOW_SECONDS:-900} RATE_LIMIT_WINDOW_SECONDS: ${RATE_LIMIT_WINDOW_SECONDS:-900}
+6 -4
View File
@@ -3,12 +3,13 @@ services:
build: build:
context: . context: .
args: args:
APP_VERSION: ${APP_VERSION:-4.0.1} APP_VERSION: ${APP_VERSION:-4.0.2}
environment: 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} DATABASE_URL: postgresql://${POSTGRES_USER:-goodwalk}:${POSTGRES_PASSWORD:-goodwalk}@db:5432/${POSTGRES_DB:-goodwalk}
NODE_ENV: production NODE_ENV: production
PORT: ${APP_PORT:-3000} PORT: ${APP_PORT:-3000}
ENABLE_GENERAL_ENQUIRIES: ${ENABLE_GENERAL_ENQUIRIES:-false}
TZ: ${TZ:-Pacific/Auckland} TZ: ${TZ:-Pacific/Auckland}
depends_on: depends_on:
- db - db
@@ -18,13 +19,14 @@ services:
build: build:
context: ./mail-api context: ./mail-api
args: args:
APP_VERSION: ${APP_VERSION:-4.0.1} APP_VERSION: ${APP_VERSION:-4.0.2}
environment: environment:
APP_VERSION: ${APP_VERSION:-4.0.1} APP_VERSION: ${APP_VERSION:-4.0.2}
RESEND_API_KEY: ${RESEND_API_KEY} RESEND_API_KEY: ${RESEND_API_KEY}
OWNER_EMAIL: ${OWNER_EMAIL} OWNER_EMAIL: ${OWNER_EMAIL}
FROM_EMAIL: ${FROM_EMAIL:-GoodWalk <bookings@goodwalk.co.nz>} FROM_EMAIL: ${FROM_EMAIL:-GoodWalk <bookings@goodwalk.co.nz>}
REPLY_TO: ${REPLY_TO:-aless@goodwalk.co.nz} REPLY_TO: ${REPLY_TO:-aless@goodwalk.co.nz}
ENABLE_GENERAL_ENQUIRIES: ${ENABLE_GENERAL_ENQUIRIES:-false}
TZ: ${TZ:-Pacific/Auckland} TZ: ${TZ:-Pacific/Auckland}
restart: unless-stopped restart: unless-stopped
+1 -1
View File
@@ -1,4 +1,4 @@
ARG APP_VERSION=4.0.1 ARG APP_VERSION=4.0.2
FROM python:3.12-slim FROM python:3.12-slim
ARG APP_VERSION ARG APP_VERSION
Binary file not shown.
+172 -58
View File
@@ -89,6 +89,7 @@ def _load_config() -> dict:
"owner_email": os.environ["OWNER_EMAIL"], "owner_email": os.environ["OWNER_EMAIL"],
"from_email": os.environ.get("FROM_EMAIL", "GoodWalk <bookings@goodwalk.co.nz>"), "from_email": os.environ.get("FROM_EMAIL", "GoodWalk <bookings@goodwalk.co.nz>"),
"reply_to": os.environ.get("REPLY_TO", "aless@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"))), "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_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"))), "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"] OWNER_EMAIL = _config["owner_email"]
FROM_EMAIL = _config["from_email"] FROM_EMAIL = _config["from_email"]
REPLY_TO = _config["reply_to"] REPLY_TO = _config["reply_to"]
ENABLE_GENERAL_ENQUIRIES = _config["enable_general_enquiries"]
MAX_SEND_ATTEMPTS = _config["max_attempts"] MAX_SEND_ATTEMPTS = _config["max_attempts"]
FORM_MIN_SECONDS = _config["form_min_seconds"] FORM_MIN_SECONDS = _config["form_min_seconds"]
FORM_MAX_SECONDS = _config["form_max_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" LOGO_URL = "https://www.goodwalk.co.nz/images/goodwalk-auckland-dog-walking-logo.png"
logger.info( 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, APP_VERSION,
os.environ.get("TZ", "system-default"), os.environ.get("TZ", "system-default"),
FROM_EMAIL, FROM_EMAIL,
REPLY_TO, REPLY_TO,
OWNER_EMAIL, OWNER_EMAIL,
ENABLE_GENERAL_ENQUIRIES,
MAX_SEND_ATTEMPTS, MAX_SEND_ATTEMPTS,
FORM_MIN_SECONDS, FORM_MIN_SECONDS,
FORM_MAX_SECONDS, FORM_MAX_SECONDS,
@@ -165,11 +168,12 @@ async def _request_logging_middleware(request: Request, call_next):
class BookingSubmission(BaseModel): class BookingSubmission(BaseModel):
enquiryType: str = "booking"
fullName: str fullName: str
email: EmailStr email: EmailStr
phone: str phone: str
petName: str petName: str = ""
location: str location: str = ""
message: str = "" message: str = ""
services: list[str] = [] services: list[str] = []
website: str = "" website: str = ""
@@ -300,6 +304,85 @@ def _is_honeypot_triggered(data: BookingSubmission) -> bool:
return bool(_trimmed(data.website)) 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: def _parse_ua(ua: str) -> str:
if not ua: if not ua:
return "Unknown" return "Unknown"
@@ -360,8 +443,46 @@ def _logo_header(badge_html: str = "", subtitle: str = "") -> str:
def client_email(data: BookingSubmission) -> str: def client_email(data: BookingSubmission) -> str:
is_general = _is_general_enquiry(data)
services_text = ", ".join(data.services) if data.services else "Not specified" 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&rsquo;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&rsquo;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&rsquo;ve received your enquiry and we will be in touch shortly to arrange "
"a <strong style=\"color:#213021;\">Meet &amp; 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 &amp; Greet. No commitment required &mdash; just a "
f"chance for {data.petName} to make a new best friend."
)
logo_subtitle = "Professional dog walking services"
return f"""<!DOCTYPE html> return f"""<!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -380,7 +501,7 @@ def client_email(data: BookingSubmission) -> str:
style="max-width:600px;width:100%;border-radius:16px;overflow:hidden; style="max-width:600px;width:100%;border-radius:16px;overflow:hidden;
box-shadow:0 4px 24px rgba(0,0,0,0.08);"> box-shadow:0 4px 24px rgba(0,0,0,0.08);">
{_logo_header(subtitle="Professional dog walking services")} {_logo_header(subtitle=logo_subtitle)}
<!-- Body --> <!-- Body -->
<tr> <tr>
@@ -392,9 +513,7 @@ def client_email(data: BookingSubmission) -> str:
</h1> </h1>
<p style="margin:0 0 32px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; <p style="margin:0 0 32px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:16px;color:#555;line-height:1.65;"> font-size:16px;color:#555;line-height:1.65;">
We&rsquo;ve received your enquiry and we will be in touch shortly to arrange {intro_html}
a <strong style="color:#213021;">Meet &amp; Greet</strong> with you and
{data.petName}.
</p> </p>
<!-- Details card --> <!-- Details card -->
@@ -408,13 +527,7 @@ def client_email(data: BookingSubmission) -> str:
Your enquiry summary Your enquiry summary
</div> </div>
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"> <table width="100%" cellpadding="0" cellspacing="0" role="presentation">
{_detail_row("Your name", data.fullName)} {"".join(enquiry_summary_rows)}
{_detail_row("Email", data.email)}
{_detail_row("Phone", data.phone)}
{_detail_row("Dog&rsquo;s name", data.petName)}
{_detail_row("Location", data.location)}
{_detail_row("Services", services_text)}
{message_row}
</table> </table>
</td> </td>
</tr> </tr>
@@ -431,9 +544,7 @@ def client_email(data: BookingSubmission) -> str:
</div> </div>
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; <div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:14px;color:#666;line-height:1.6;"> font-size:14px;color:#666;line-height:1.6;">
We will review your details and reach out within 1 business days {next_steps_html}
to schedule a free Meet &amp; Greet. No commitment required &mdash; just a
chance for {data.petName} to make a new best friend.
</div> </div>
</td> </td>
</tr> </tr>
@@ -469,17 +580,20 @@ def client_email(data: BookingSubmission) -> str:
def owner_email(data: BookingSubmission, ip: str, browser: str) -> 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 "" services_text = ", ".join(data.services) if data.services else ""
now = datetime.now() now = datetime.now()
submitted_at = now.strftime("%d %b %Y at %I:%M %p").lstrip("0") 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" 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""" message_block = f"""
<tr> <tr>
<td colspan="2" style="padding:16px 0 0;"> <td colspan="2" style="padding:16px 0 0;">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; <div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:11px;font-weight:700;letter-spacing:0.08em;color:#888; 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; <div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:14px;color:#444;line-height:1.6;background:#f0efe9; font-size:14px;color:#444;line-height:1.6;background:#f0efe9;
border-radius:8px;padding:14px 16px;">{data.message}</div> 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;"> padding:10px 28px;">
<span style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; <span style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:18px;font-weight:700;color:#213021;"> font-size:18px;font-weight:700;color:#213021;">
&#x1F4E9;&nbsp; New lead! &#x1F4E9;&nbsp; New enquiry!
</span> </span>
</div> </div>
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; <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") 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 "" page_row = _meta_row("Page", data.page) if data.page else ""
detail_heading = "Enquiry details" if is_general else "Dog &amp; 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> return f"""<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>New GoodWalk Lead</title> <title>{email_title}</title>
</head> </head>
<body style="margin:0;padding:0;background:#f2f2f0;"> <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 --> <!-- Dog & service details -->
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; <div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:11px;font-weight:700;letter-spacing:0.1em;color:#888; font-size:11px;font-weight:700;letter-spacing:0.1em;color:#888;
text-transform:uppercase;margin-bottom:16px;">Dog &amp; services</div> text-transform:uppercase;margin-bottom:16px;">{detail_heading}</div>
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" <table width="100%" cellpadding="0" cellspacing="0" role="presentation"
style="background:#f8f7f4;border-radius:12px;margin-bottom:28px;"> style="background:#f8f7f4;border-radius:12px;margin-bottom:28px;">
<tr><td style="padding:24px 28px;"> <tr><td style="padding:24px 28px;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"> <table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr> {"".join(detail_rows)}
<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} {message_block}
</table> </table>
</td></tr> </td></tr>
@@ -681,7 +788,7 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
border-top:1px solid #e8e8e4;"> border-top:1px solid #e8e8e4;">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; <div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:12px;color:#bbb;"> font-size:12px;color:#bbb;">
Sent automatically by GoodWalk booking form Sent automatically by GoodWalk enquiry form
</div> </div>
</td> </td>
</tr> </tr>
@@ -753,15 +860,6 @@ async def submit_booking(data: BookingSubmission, request: Request):
ip = _get_ip(request) ip = _get_ip(request)
browser = _parse_ua(request.headers.get("user-agent", "")) 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)) await _enforce_submit_rate_limits(request_id, ip, str(data.email))
_enforce_form_timing(request_id, data) _enforce_form_timing(request_id, data)
@@ -779,6 +877,18 @@ async def submit_booking(data: BookingSubmission, request: Request):
"ignored": True, "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] = [] failures: list[dict] = []
try: try:
@@ -787,7 +897,7 @@ async def submit_booking(data: BookingSubmission, request: Request):
"from": FROM_EMAIL, "from": FROM_EMAIL,
"to": [data.email], "to": [data.email],
"reply_to": REPLY_TO, "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), "html": client_email(data),
}, },
label="client_email", label="client_email",
@@ -807,7 +917,11 @@ async def submit_booking(data: BookingSubmission, request: Request):
"from": FROM_EMAIL, "from": FROM_EMAIL,
"to": [OWNER_EMAIL], "to": [OWNER_EMAIL],
"reply_to": data.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), "html": owner_email(data, ip, browser),
}, },
label="owner_email", label="owner_email",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "goodwalk-svelte-port", "name": "goodwalk-svelte-port",
"version": "4.0.1", "version": "4.0.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "goodwalk-svelte-port", "name": "goodwalk-svelte-port",
"version": "4.0.1", "version": "4.0.2",
"dependencies": { "dependencies": {
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"pg": "^8.13.1" "pg": "^8.13.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "goodwalk-svelte-port", "name": "goodwalk-svelte-port",
"version": "4.0.1", "version": "4.0.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+10 -3
View File
@@ -4,6 +4,7 @@
import type { BookingContent } from '$lib/types'; import type { BookingContent } from '$lib/types';
export let booking: BookingContent; export let booking: BookingContent;
export let allowGeneralEnquiry = false;
const email = 'info@goodwalk.co.nz'; const email = 'info@goodwalk.co.nz';
const phone = '(022) 642 1011'; const phone = '(022) 642 1011';
@@ -12,8 +13,14 @@
<main class="booking-page"> <main class="booking-page">
<section class="booking-page-hero"> <section class="booking-page-hero">
<div class="booking-page-inner"> <div class="booking-page-inner">
<h1>Book a Meet &amp; Greet</h1> <h1>Contact Us</h1>
<p class="booking-page-sub">Fill in the form below and we'll be in touch to arrange a free introduction.</p> <p class="booking-page-sub">
{#if allowGeneralEnquiry}
Fill in the form below to book a Meet &amp; 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"> <div class="booking-page-contact">
<a href="mailto:{email}" class="booking-contact-link"> <a href="mailto:{email}" class="booking-contact-link">
<Icon name="fas fa-envelope" /> <Icon name="fas fa-envelope" />
@@ -27,7 +34,7 @@
</div> </div>
</section> </section>
<BookingSection {booking} /> <BookingSection {booking} {allowGeneralEnquiry} />
</main> </main>
<style> <style>
+128 -23
View File
@@ -7,10 +7,13 @@
import type { BookingContent } from '$lib/types'; import type { BookingContent } from '$lib/types';
export let booking: BookingContent; export let booking: BookingContent;
export let allowGeneralEnquiry = false;
type EnquiryType = 'booking' | 'general';
let step = 1; let step = 1;
$: headingParts = splitBookingTitle(booking.title); $: headingParts = splitBookingTitle(booking.title);
let enquiryType: EnquiryType = 'booking';
let fullName = ''; let fullName = '';
let email = ''; let email = '';
let phone = ''; let phone = '';
@@ -59,12 +62,26 @@
const defaultDogIntro = const defaultDogIntro =
'Tell us about your dog and where you are based so we can plan the right Meet & Greet.'; '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; $: dogIntro = booking.dogIntro?.trim() || defaultDogIntro;
$: hasBanner = Boolean(booking.subtitle?.trim()); $: generalIntro = booking.generalIntro?.trim() || defaultGeneralIntro;
$: hasServices = booking.serviceOptions.length > 0; $: 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'; $: ownerStepLabel = booking.ownerStepLabel?.trim() || 'Owner Details';
$: dogStepLabel = booking.dogStepLabel?.trim() || 'Your dog'; $: dogStepLabel = booking.dogStepLabel?.trim() || 'Your dog';
$: firstStepLabel = isGeneralEnquiry ? 'Your enquiry' : dogStepLabel;
$: firstStepIntro = isGeneralEnquiry ? generalIntro : dogIntro;
$: successPetName = petName.trim() || 'your dog';
onMount(() => { onMount(() => {
formStartedAt = Date.now(); formStartedAt = Date.now();
@@ -99,22 +116,47 @@
selectedServices = selectedServices.filter((item) => item !== service); 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> = {}; const next: Record<string, string> = {};
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 (!petName.trim()) next.petName = "Please enter your dog's name";
if (!location.trim()) next.location = 'Please enter your location'; if (!location.trim()) next.location = 'Please enter your location';
}
errors = next; errors = next;
if (next.petName) { petNameInput?.focus(); return false; } if (next.petName) {
if (next.location) { locationInput?.focus(); return false; } petNameInput?.focus();
return false;
}
if (next.location) {
locationInput?.focus();
return false;
}
if (next.message) {
return false;
}
return true; return true;
} }
function goToOwnerStep() { function goToOwnerStep() {
if (!validateDogStep()) return; if (!validateFirstStep()) return;
errors = {}; errors = {};
step = 2; step = 2;
} }
@@ -152,13 +194,19 @@
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
fullName, email, phone, petName, location, message, enquiryType,
services: selectedServices, fullName,
email,
phone,
petName: isGeneralEnquiry ? '' : petName,
location: isGeneralEnquiry ? '' : location,
message,
services: isGeneralEnquiry ? [] : selectedServices,
website, website,
formStartedAt, formStartedAt,
referrer: document.referrer, referrer: document.referrer,
page: window.location.href, page: window.location.href,
}), })
}); });
if (!res.ok) { if (!res.ok) {
@@ -185,8 +233,9 @@
{#if submitted} {#if submitted}
<SuccessModal <SuccessModal
firstName={fullName.split(' ')[0]} firstName={fullName.split(' ')[0]}
{petName} petName={successPetName}
{email} {email}
{enquiryType}
onClose={() => (submitted = false)} onClose={() => (submitted = false)}
/> />
{/if} {/if}
@@ -194,6 +243,7 @@
{#if showErrorModal} {#if showErrorModal}
<ErrorModal <ErrorModal
detail={submitErrorDetail} detail={submitErrorDetail}
{enquiryType}
onClose={() => (showErrorModal = false)} onClose={() => (showErrorModal = false)}
onRetry={() => (showErrorModal = false)} onRetry={() => (showErrorModal = false)}
/> />
@@ -212,7 +262,7 @@
on:click={() => (step = 1)} on:click={() => (step = 1)}
> >
<span class="booking-step-number">1</span> <span class="booking-step-number">1</span>
<span class="booking-step-label">{dogStepLabel}</span> <span class="booking-step-label">{firstStepLabel}</span>
</button> </button>
<span class="booking-step-divider" aria-hidden="true"></span> <span class="booking-step-divider" aria-hidden="true"></span>
<button <button
@@ -247,14 +297,50 @@
{#if step === 1} {#if step === 1}
<div class="booking-panel"> <div class="booking-panel">
{#if dogIntro} {#if firstStepIntro}
<div class="booking-panel-banner">{dogIntro}</div> <div class="booking-panel-banner">{firstStepIntro}</div>
{/if} {/if}
<div class:booking-card-grid-with-banner={Boolean(dogIntro)} class="booking-card-grid booking-card-grid-dog"> <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" />&nbsp;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 &amp; 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>
</div>
{/if}
{#if !isGeneralEnquiry}
<div class="booking-field-card" class:booking-field-card-invalid={errors.petName}> <div class="booking-field-card" class:booking-field-card-invalid={errors.petName}>
<label for="petName"> <label for="petName">
<Icon name="fas fa-dog" />&nbsp;Pet's Name <span class="booking-required">*</span> <Icon name="fas fa-dog" />&nbsp;Dog's Name <span class="booking-required">*</span>
</label> </label>
<input <input
bind:this={petNameInput} bind:this={petNameInput}
@@ -297,20 +383,34 @@
</p> </p>
{/if} {/if}
</div> </div>
{/if}
<div class="booking-field-card booking-field-card-full"> <div class="booking-field-card booking-field-card-full" class:booking-field-card-invalid={errors.message}>
<label for="message"><Icon name="fas fa-comment" />&nbsp;About Your Dog</label> <label for="message">
<Icon name="fas fa-comment" />&nbsp;{isGeneralEnquiry ? 'Your Message' : 'About Your Dog'}
{#if isGeneralEnquiry}<span class="booking-required">*</span>{/if}
</label>
<textarea <textarea
bind:value={message} bind:value={message}
id="message" id="message"
name="message" name="message"
rows="4" 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> ></textarea>
{#if errors.message}
<p class="field-error">
<Icon name="fas fa-circle-exclamation" />
{errors.message}
</p>
{/if}
</div> </div>
</div> </div>
{#if hasServices} {#if hasServices && !isGeneralEnquiry}
<div class="booking-service-row"> <div class="booking-service-row">
<span class="booking-service-label"><Icon name="fas fa-paw" />&nbsp;Services</span> <span class="booking-service-label"><Icon name="fas fa-paw" />&nbsp;Services</span>
<div class="booking-service-options"> <div class="booking-service-options">
@@ -340,19 +440,24 @@
</button> </button>
</div> </div>
{:else} {:else}
<input type="hidden" name="enquiryType" value={enquiryType} />
{#if !isGeneralEnquiry}
<input type="hidden" name="petName" value={petName} /> <input type="hidden" name="petName" value={petName} />
<input type="hidden" name="location" value={location} /> <input type="hidden" name="location" value={location} />
{/if}
<input type="hidden" name="message" value={message} /> <input type="hidden" name="message" value={message} />
{#if !isGeneralEnquiry}
{#each selectedServices as service} {#each selectedServices as service}
<input type="hidden" name="services" value={service} /> <input type="hidden" name="services" value={service} />
{/each} {/each}
<div class="booking-panel">
{#if hasBanner}
<div class="booking-panel-banner">{booking.subtitle}</div>
{/if} {/if}
<div class:booking-card-grid-with-banner={hasBanner} class="booking-card-grid booking-card-grid-owner"> <div class="booking-panel">
{#if ownerSubtitle}
<div class="booking-panel-banner">{ownerSubtitle}</div>
{/if}
<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-card booking-field-card-group booking-field-card-full">
<div class="booking-field-group booking-field-group-owner"> <div class="booking-field-group booking-field-group-owner">
<div class="booking-field-stack" class:booking-field-stack-invalid={errors.fullName}> <div class="booking-field-stack" class:booking-field-stack-invalid={errors.fullName}>
+69 -3
View File
@@ -16,6 +16,8 @@ describe('BookingSection', () => {
booking: homepageContent.booking booking: homepageContent.booking
}); });
expect(screen.queryByLabelText(/General enquiry/i)).not.toBeInTheDocument();
await fireEvent.click(container.querySelector('.booking-next-button')!); await fireEvent.click(container.querySelector('.booking-next-button')!);
expect(screen.getByText("Please enter your dog's name")).toBeInTheDocument(); expect(screen.getByText("Please enter your dog's name")).toBeInTheDocument();
@@ -27,7 +29,7 @@ describe('BookingSection', () => {
booking: homepageContent.booking booking: homepageContent.booking
}); });
await fireEvent.input(screen.getByLabelText(/Pet's Name/i), { await fireEvent.input(screen.getByLabelText(/Dog's Name/i), {
target: { value: 'Maya' } target: { value: 'Maya' }
}); });
await fireEvent.input(screen.getByLabelText(/Location/i), { 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('Pack Walks'));
await fireEvent.click(screen.getByLabelText('Other Services')); 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' } target: { value: 'Maya' }
}); });
await fireEvent.input(screen.getByLabelText(/Location/i), { 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); const payload = JSON.parse(fetchMock.mock.calls[0][1].body as string);
expect(payload).toMatchObject({ expect(payload).toMatchObject({
enquiryType: 'booking',
fullName: 'Alex Walker', fullName: 'Alex Walker',
email: 'alex@example.com', email: 'alex@example.com',
phone: '021 123 4567', 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 () => { it('shows the API error message when submission fails', async () => {
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
@@ -126,7 +192,7 @@ describe('BookingSection', () => {
booking: homepageContent.booking booking: homepageContent.booking
}); });
await fireEvent.input(screen.getByLabelText(/Pet's Name/i), { await fireEvent.input(screen.getByLabelText(/Dog's Name/i), {
target: { value: 'Maya' } target: { value: 'Maya' }
}); });
await fireEvent.input(screen.getByLabelText(/Location/i), { await fireEvent.input(screen.getByLabelText(/Location/i), {
+6 -2
View File
@@ -1,14 +1,18 @@
<script lang="ts"> <script lang="ts">
export let email = 'info@goodwalk.co.nz'; export let email = 'info@goodwalk.co.nz';
export let enquiryType: 'booking' | 'general' = 'booking';
export let onClose: () => void; export let onClose: () => void;
export let onRetry: (() => void) | null = null; export let onRetry: (() => void) | null = null;
export let detail = ''; export let detail = '';
$: isGeneralEnquiry = enquiryType === 'general';
$: mailtoHref = $: mailtoHref =
`mailto:${email}` + `mailto:${email}` +
`?subject=${encodeURIComponent('Booking enquiry')}` + `?subject=${encodeURIComponent(isGeneralEnquiry ? 'General enquiry' : 'Booking enquiry')}` +
`&body=${encodeURIComponent( `&body=${encodeURIComponent(
'Hi Aless,\n\nI tried to submit the booking form but it didnt go through. Here are my details:\n\nName:\nPhone:\nDogs name:\nLocation:\n\nThanks!' isGeneralEnquiry
? 'Hi Aless,\n\nI tried to submit the contact form but it didnt go through. Here are my details:\n\nName:\nPhone:\nMessage:\n\nThanks!'
: 'Hi Aless,\n\nI tried to submit the booking form but it didnt go through. Here are my details:\n\nName:\nPhone:\nDogs name:\nLocation:\n\nThanks!'
)}`; )}`;
function handleKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent) {
+22 -2
View File
@@ -5,8 +5,11 @@
export let firstName: string; export let firstName: string;
export let petName: string; export let petName: string;
export let email: string; export let email: string;
export let enquiryType: 'booking' | 'general' = 'booking';
export let onClose: () => void; export let onClose: () => void;
$: isGeneralEnquiry = enquiryType === 'general';
onMount(() => { onMount(() => {
const duration = 3200; const duration = 3200;
const end = Date.now() + duration; const end = Date.now() + duration;
@@ -44,7 +47,7 @@
class="modal-backdrop" class="modal-backdrop"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="Booking confirmed" aria-label={isGeneralEnquiry ? 'Enquiry confirmed' : 'Booking confirmed'}
on:click|self={onClose} on:click|self={onClose}
on:keydown={(e) => e.key === 'Escape' && onClose()} on:keydown={(e) => e.key === 'Escape' && onClose()}
tabindex="-1" tabindex="-1"
@@ -56,18 +59,35 @@
<div class="modal-paw" aria-hidden="true">🐾</div> <div class="modal-paw" aria-hidden="true">🐾</div>
<h2 class="modal-heading">You&rsquo;re on our radar!</h2> <h2 class="modal-heading">
{#if isGeneralEnquiry}
Your message is with us!
{:else}
You&rsquo;re on our radar!
{/if}
</h2>
{#if isGeneralEnquiry}
<p class="modal-body">
Thanks, <strong>{firstName}</strong>! We&rsquo;ve sent a confirmation to
<strong>{email}</strong> and Aless will be in touch soon about your enquiry.
</p>
{:else}
<p class="modal-body"> <p class="modal-body">
Thanks, <strong>{firstName}</strong>! We&rsquo;ve sent a confirmation to Thanks, <strong>{firstName}</strong>! We&rsquo;ve sent a confirmation to
<strong>{email}</strong> and Aless will be in touch soon to arrange a <strong>{email}</strong> and Aless will be in touch soon to arrange a
Meet &amp; Greet with <strong>{petName}</strong>. Meet &amp; Greet with <strong>{petName}</strong>.
</p> </p>
{/if}
<div class="modal-divider"></div> <div class="modal-divider"></div>
<p class="modal-sub"> <p class="modal-sub">
{#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! In the meantime, feel free to follow along on Instagram for daily walks and happy dogs!
{/if}
</p> </p>
<button class="modal-btn" type="button" on:click={onClose}> <button class="modal-btn" type="button" on:click={onClose}>
+5 -1
View File
@@ -158,10 +158,14 @@ export const homepageContent: HomePageContent = {
title: "Let's meet!", title: "Let's meet!",
subtitle: subtitle:
"Almost there — just your contact details so we can reach out to arrange your free, no-obligation Meet & Greet.", "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', formAction: '/contact-us',
serviceOptions: ['Pack Walks', '1:1 Walks', 'Puppy Visits', 'Other Services'], serviceOptions: ['Pack Walks', '1:1 Walks', 'Puppy Visits', 'Other Services'],
ownerStepLabel: 'Your details', 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: { info: {
title: 'Locations & Hours', title: 'Locations & Hours',
+1 -1
View File
@@ -37,7 +37,7 @@ export const staticPages = {
}, },
'contact-us': { 'contact-us': {
title: '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' canonicalPath: '/contact-us'
}, },
'terms-and-conditions': { 'terms-and-conditions': {
+21
View File
@@ -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);
}
+69
View File
@@ -191,6 +191,13 @@
color: #34363a; color: #34363a;
} }
.booking-help-text {
margin: 14px 0 0;
font-size: 13px;
line-height: 1.5;
color: #666;
}
.booking-required { .booking-required {
color: var(--yellow); color: var(--yellow);
} }
@@ -238,6 +245,68 @@
box-shadow: 0 10px 30px rgba(17, 20, 24, 0.04); 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 { .booking-service-options {
display: flex; display: flex;
+17
View File
@@ -469,6 +469,23 @@
gap: 12px 18px; 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 { .booking-check-option {
font-size: 15px; font-size: 15px;
} }
+2
View File
@@ -77,11 +77,13 @@ export interface TestimonialContent {
export interface BookingContent { export interface BookingContent {
title: string; title: string;
subtitle: string; subtitle: string;
generalSubtitle?: string;
formAction: string; formAction: string;
serviceOptions: string[]; serviceOptions: string[];
ownerStepLabel?: string; ownerStepLabel?: string;
dogStepLabel?: string; dogStepLabel?: string;
dogIntro?: string; dogIntro?: string;
generalIntro?: string;
} }
export interface ServicePricingPlan { export interface ServicePricingPlan {
+13 -2
View File
@@ -1,5 +1,6 @@
import { error, redirect } from '@sveltejs/kit'; import { error, redirect } from '@sveltejs/kit';
import { staticPages, type StaticPageSlug } from '$lib/content/static-pages'; import { staticPages, type StaticPageSlug } from '$lib/content/static-pages';
import { isGeneralEnquiryEnabled } from '$lib/server/feature-flags';
import { getSharedPageContent } from '$lib/server/content'; import { getSharedPageContent } from '$lib/server/content';
export async function load({ params }) { export async function load({ params }) {
@@ -12,14 +13,24 @@ export async function load({ params }) {
} }
const slug = params.slug as StaticPageSlug; 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'); 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 { return {
content: await getSharedPageContent(), content: await getSharedPageContent(),
generalEnquiryEnabled,
page, page,
slug slug
}; };
+1 -1
View File
@@ -193,7 +193,7 @@
{:else if data.slug === 'privacy-policy'} {:else if data.slug === 'privacy-policy'}
<LegalPage pageContent={privacyPolicyContent} /> <LegalPage pageContent={privacyPolicyContent} />
{:else if data.slug === 'contact-us'} {:else if data.slug === 'contact-us'}
<BookingPage booking={data.content.booking} /> <BookingPage booking={data.content.booking} allowGeneralEnquiry={data.generalEnquiryEnabled} />
{:else} {:else}
<main class="static-page"> <main class="static-page">
<section class="static-page-hero"> <section class="static-page-hero">
@@ -15,6 +15,7 @@ import { load } from './+page.server';
describe('static slug page server load', () => { describe('static slug page server load', () => {
beforeEach(() => { beforeEach(() => {
getSharedPageContent.mockReset(); getSharedPageContent.mockReset();
vi.unstubAllEnvs();
}); });
it('redirects the legacy about-us slug to /about', async () => { 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({ await expect(load({ params: { slug: 'pack-walks' } } as never)).resolves.toEqual({
content: sharedPageContent, content: sharedPageContent,
generalEnquiryEnabled: false,
page: staticPages['pack-walks'], page: staticPages['pack-walks'],
slug: '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'
});
});
}); });
+17
View File
@@ -43,4 +43,21 @@ describe('static slug route page', () => {
expect(screen.queryByText('Why people choose us!')).not.toBeInTheDocument(); 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();
});
}); });
+1
View File
@@ -12,6 +12,7 @@ describe('home page route', () => {
expect(screen.getAllByText("Your Dog's Day!").length).toBeGreaterThan(0); expect(screen.getAllByText("Your Dog's Day!").length).toBeGreaterThan(0);
expect(document.body.textContent).toContain('Happy pets,'); expect(document.body.textContent).toContain('Happy pets,');
expect(screen.getByText('Locations & Hours')).toBeInTheDocument(); 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.title).toBe('Home | Auckland Dog Walking | Goodwalk');
expect(document.head.innerHTML).toContain('FAQPage'); expect(document.head.innerHTML).toContain('FAQPage');
expect(document.head.innerHTML).toContain('https://www.goodwalk.co.nz/images/auckland-dog-walking-happy-dog-hero.png'); expect(document.head.innerHTML).toContain('https://www.goodwalk.co.nz/images/auckland-dog-walking-happy-dog-hero.png');
+1
View File
@@ -18,6 +18,7 @@ export function createHomepageRouteData() {
export function createStaticRouteData(slug: StaticPageSlug) { export function createStaticRouteData(slug: StaticPageSlug) {
return { return {
content: sharedPageContent, content: sharedPageContent,
generalEnquiryEnabled: false,
page: staticPages[slug], page: staticPages[slug],
slug slug
}; };