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:
|
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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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’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>
|
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’ve received your enquiry and we will be in touch shortly to arrange
|
{intro_html}
|
||||||
a <strong style="color:#213021;">Meet & 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’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 & Greet. No commitment required — 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;">
|
||||||
📩 New lead!
|
📩 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 & 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 & 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",
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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 & 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 & 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>
|
||||||
|
|||||||
@@ -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 (!petName.trim()) next.petName = "Please enter your dog's name";
|
if (isGeneralEnquiry) {
|
||||||
if (!location.trim()) next.location = 'Please enter your location';
|
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;
|
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,70 +297,120 @@
|
|||||||
|
|
||||||
{#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">
|
||||||
<div class="booking-field-card" class:booking-field-card-invalid={errors.petName}>
|
{#if allowGeneralEnquiry}
|
||||||
<label for="petName">
|
<div class="booking-field-card booking-field-card-full">
|
||||||
<Icon name="fas fa-dog" /> Pet's Name <span class="booking-required">*</span>
|
<label>
|
||||||
</label>
|
<Icon name="fas fa-comments" /> Enquiry type
|
||||||
<input
|
</label>
|
||||||
bind:this={petNameInput}
|
<div class="booking-toggle-group" role="radiogroup" aria-label="Enquiry type">
|
||||||
bind:value={petName}
|
<label class="booking-toggle-option">
|
||||||
type="text"
|
<input
|
||||||
id="petName"
|
type="radio"
|
||||||
name="petName"
|
name="enquiryType"
|
||||||
required
|
value="booking"
|
||||||
placeholder="Your dog's name"
|
checked={enquiryType === 'booking'}
|
||||||
class:input-invalid={errors.petName}
|
on:change={() => setEnquiryType('booking')}
|
||||||
on:input={() => clearError('petName')}
|
/>
|
||||||
/>
|
<span class="booking-toggle-indicator" aria-hidden="true"></span>
|
||||||
{#if errors.petName}
|
<span>Book a Meet & Greet</span>
|
||||||
<p class="field-error">
|
</label>
|
||||||
<Icon name="fas fa-circle-exclamation" />
|
<label class="booking-toggle-option">
|
||||||
{errors.petName}
|
<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>
|
</p>
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
|
|
||||||
<div class="booking-field-card booking-field-card-wide" class:booking-field-card-invalid={errors.location}>
|
{#if !isGeneralEnquiry}
|
||||||
<label for="location">
|
<div class="booking-field-card" class:booking-field-card-invalid={errors.petName}>
|
||||||
<Icon name="fas fa-location-dot" /> Location <span class="booking-required">*</span>
|
<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>
|
</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
|
<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" /> Services</span>
|
<span class="booking-service-label"><Icon name="fas fa-paw" /> 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="petName" value={petName} />
|
<input type="hidden" name="enquiryType" value={enquiryType} />
|
||||||
<input type="hidden" name="location" value={location} />
|
{#if !isGeneralEnquiry}
|
||||||
|
<input type="hidden" name="petName" value={petName} />
|
||||||
|
<input type="hidden" name="location" value={location} />
|
||||||
|
{/if}
|
||||||
<input type="hidden" name="message" value={message} />
|
<input type="hidden" name="message" value={message} />
|
||||||
{#each selectedServices as service}
|
{#if !isGeneralEnquiry}
|
||||||
<input type="hidden" name="services" value={service} />
|
{#each selectedServices as service}
|
||||||
{/each}
|
<input type="hidden" name="services" value={service} />
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="booking-panel">
|
<div class="booking-panel">
|
||||||
{#if hasBanner}
|
{#if ownerSubtitle}
|
||||||
<div class="booking-panel-banner">{booking.subtitle}</div>
|
<div class="booking-panel-banner">{ownerSubtitle}</div>
|
||||||
{/if}
|
{/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-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}>
|
||||||
|
|||||||
@@ -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), {
|
||||||
|
|||||||
@@ -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 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) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
|||||||
@@ -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’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">
|
{#if isGeneralEnquiry}
|
||||||
Thanks, <strong>{firstName}</strong>! We’ve sent a confirmation to
|
<p class="modal-body">
|
||||||
<strong>{email}</strong> and Aless will be in touch soon to arrange a
|
Thanks, <strong>{firstName}</strong>! We’ve sent a confirmation to
|
||||||
Meet & Greet with <strong>{petName}</strong>.
|
<strong>{email}</strong> and Aless will be in touch soon about your enquiry.
|
||||||
</p>
|
</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>
|
<div class="modal-divider"></div>
|
||||||
|
|
||||||
<p class="modal-sub">
|
<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>
|
</p>
|
||||||
|
|
||||||
<button class="modal-btn" type="button" on:click={onClose}>
|
<button class="modal-btn" type="button" on:click={onClose}>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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': {
|
||||||
|
|||||||
@@ -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;
|
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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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'
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user