4.2.2 - tracking across email, fixes to dark mode.
This commit is contained in:
+1
-1
@@ -73,7 +73,7 @@ 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.2
|
APP_VERSION=4.2.2
|
||||||
ENABLE_GENERAL_ENQUIRIES=false
|
ENABLE_GENERAL_ENQUIRIES=false
|
||||||
TZ=Pacific/Auckland
|
TZ=Pacific/Auckland
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
ARG APP_VERSION=4.0.2
|
ARG APP_VERSION=4.2.2
|
||||||
|
|
||||||
FROM node:22-alpine AS builder
|
FROM node:22-alpine AS builder
|
||||||
ARG APP_VERSION
|
ARG APP_VERSION
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
APP_VERSION=4.0.2
|
APP_VERSION=4.2.2
|
||||||
TZ=Pacific/Auckland
|
TZ=Pacific/Auckland
|
||||||
|
|
||||||
POSTGRES_DB=goodwalk
|
POSTGRES_DB=goodwalk
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
args:
|
args:
|
||||||
APP_VERSION: ${APP_VERSION:-4.0.2}
|
APP_VERSION: ${APP_VERSION:-4.2.2}
|
||||||
container_name: goodwalk_svelte_app
|
container_name: goodwalk_svelte_app
|
||||||
environment:
|
environment:
|
||||||
APP_VERSION: ${APP_VERSION:-4.0.2}
|
APP_VERSION: ${APP_VERSION:-4.2.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
|
||||||
@@ -25,10 +25,10 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./mail-api
|
context: ./mail-api
|
||||||
args:
|
args:
|
||||||
APP_VERSION: ${APP_VERSION:-4.0.2}
|
APP_VERSION: ${APP_VERSION:-4.2.2}
|
||||||
container_name: goodwalk_svelte_mail_api
|
container_name: goodwalk_svelte_mail_api
|
||||||
environment:
|
environment:
|
||||||
APP_VERSION: ${APP_VERSION:-4.0.2}
|
APP_VERSION: ${APP_VERSION:-4.2.2}
|
||||||
RESEND_API_KEY: ${RESEND_API_KEY}
|
RESEND_API_KEY: ${RESEND_API_KEY}
|
||||||
OWNER_EMAIL: ${OWNER_EMAIL}
|
OWNER_EMAIL: ${OWNER_EMAIL}
|
||||||
OWNER_BCC: ${OWNER_BCC:-}
|
OWNER_BCC: ${OWNER_BCC:-}
|
||||||
|
|||||||
+4
-4
@@ -3,9 +3,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
args:
|
args:
|
||||||
APP_VERSION: ${APP_VERSION:-4.0.2}
|
APP_VERSION: ${APP_VERSION:-4.2.2}
|
||||||
environment:
|
environment:
|
||||||
APP_VERSION: ${APP_VERSION:-4.0.2}
|
APP_VERSION: ${APP_VERSION:-4.2.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}
|
||||||
@@ -19,9 +19,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./mail-api
|
context: ./mail-api
|
||||||
args:
|
args:
|
||||||
APP_VERSION: ${APP_VERSION:-4.0.2}
|
APP_VERSION: ${APP_VERSION:-4.2.2}
|
||||||
environment:
|
environment:
|
||||||
APP_VERSION: ${APP_VERSION:-4.0.2}
|
APP_VERSION: ${APP_VERSION:-4.2.2}
|
||||||
RESEND_API_KEY: ${RESEND_API_KEY}
|
RESEND_API_KEY: ${RESEND_API_KEY}
|
||||||
OWNER_EMAIL: ${OWNER_EMAIL}
|
OWNER_EMAIL: ${OWNER_EMAIL}
|
||||||
OWNER_BCC: ${OWNER_BCC:-}
|
OWNER_BCC: ${OWNER_BCC:-}
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
ARG APP_VERSION=4.0.2
|
ARG APP_VERSION=4.2.2
|
||||||
|
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
ARG APP_VERSION
|
ARG APP_VERSION
|
||||||
|
|||||||
+209
-11
@@ -141,6 +141,7 @@ logger.info(
|
|||||||
)
|
)
|
||||||
|
|
||||||
app = FastAPI(title="GoodWalk Mail API")
|
app = FastAPI(title="GoodWalk Mail API")
|
||||||
|
STARTUP_TEST_RECIPIENT = OWNER_BCC if OWNER_BCC and OWNER_BCC.lower() != "example@example.com" else ""
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -184,6 +185,12 @@ class BookingSubmission(BaseModel):
|
|||||||
services: list[str] = []
|
services: list[str] = []
|
||||||
website: str = ""
|
website: str = ""
|
||||||
formStartedAt: int | None = None
|
formStartedAt: int | None = None
|
||||||
|
visitStartedAt: int | None = None
|
||||||
|
pageEnteredAt: int | None = None
|
||||||
|
firstInteractionAt: int | None = None
|
||||||
|
sendClickedAt: int | None = None
|
||||||
|
stepChanges: int = 0
|
||||||
|
journey: list[str] = []
|
||||||
referrer: str = ""
|
referrer: str = ""
|
||||||
page: str = ""
|
page: str = ""
|
||||||
|
|
||||||
@@ -382,6 +389,13 @@ def _normalize_submission(data: BookingSubmission) -> None:
|
|||||||
data.referrer = _trimmed(data.referrer)
|
data.referrer = _trimmed(data.referrer)
|
||||||
data.page = _trimmed(data.page)
|
data.page = _trimmed(data.page)
|
||||||
data.services = [_trimmed(service) for service in data.services if _trimmed(service)]
|
data.services = [_trimmed(service) for service in data.services if _trimmed(service)]
|
||||||
|
data.journey = [_trimmed(step) for step in data.journey if _trimmed(step)][:12]
|
||||||
|
data.stepChanges = max(0, data.stepChanges)
|
||||||
|
|
||||||
|
for field_name in ("visitStartedAt", "pageEnteredAt", "firstInteractionAt", "sendClickedAt"):
|
||||||
|
value = getattr(data, field_name)
|
||||||
|
if value is None or value <= 0:
|
||||||
|
setattr(data, field_name, None)
|
||||||
|
|
||||||
if _is_general_enquiry(data):
|
if _is_general_enquiry(data):
|
||||||
data.petName = ""
|
data.petName = ""
|
||||||
@@ -430,6 +444,33 @@ def _meta_row(label: str, value: str) -> str:
|
|||||||
</tr>"""
|
</tr>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _format_duration_ms(duration_ms: int | None) -> str:
|
||||||
|
if duration_ms is None or duration_ms < 0:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
total_seconds = int(round(duration_ms / 1000))
|
||||||
|
minutes, seconds = divmod(total_seconds, 60)
|
||||||
|
hours, minutes = divmod(minutes, 60)
|
||||||
|
|
||||||
|
if hours > 0:
|
||||||
|
return f"{hours}h {minutes}m"
|
||||||
|
if minutes > 0:
|
||||||
|
return f"{minutes}m {seconds}s"
|
||||||
|
return f"{seconds}s"
|
||||||
|
|
||||||
|
|
||||||
|
def _duration_between(start_ms: int | None, end_ms: int | None) -> str:
|
||||||
|
if start_ms is None or end_ms is None or end_ms < start_ms:
|
||||||
|
return ""
|
||||||
|
return _format_duration_ms(end_ms - start_ms)
|
||||||
|
|
||||||
|
|
||||||
|
def _journey_text(journey: list[str]) -> str:
|
||||||
|
if not journey:
|
||||||
|
return ""
|
||||||
|
return " -> ".join(journey)
|
||||||
|
|
||||||
|
|
||||||
# ── Email templates ──────────────────────────────────────────────────────────
|
# ── Email templates ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _logo_header(badge_html: str = "", subtitle: str = "") -> str:
|
def _logo_header(badge_html: str = "", subtitle: str = "") -> str:
|
||||||
@@ -620,6 +661,12 @@ 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 ""
|
||||||
|
visit_time_row = _meta_row("Time on site", _duration_between(data.visitStartedAt, data.sendClickedAt))
|
||||||
|
page_time_row = _meta_row("Time on page", _duration_between(data.pageEnteredAt, data.sendClickedAt))
|
||||||
|
active_time_row = _meta_row("Active form time", _duration_between(data.firstInteractionAt, data.sendClickedAt))
|
||||||
|
form_time_row = _meta_row("Form open time", _duration_between(data.formStartedAt, data.sendClickedAt))
|
||||||
|
step_changes_row = _meta_row("Step changes", str(data.stepChanges)) if data.stepChanges else ""
|
||||||
|
journey_row = _meta_row("Journey", _journey_text(data.journey))
|
||||||
detail_heading = "Enquiry details" if is_general else "Dog & services"
|
detail_heading = "Enquiry details" if is_general else "Dog & services"
|
||||||
detail_rows = [_detail_row("Type", _enquiry_type_label(data))]
|
detail_rows = [_detail_row("Type", _enquiry_type_label(data))]
|
||||||
|
|
||||||
@@ -642,29 +689,104 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
|
|||||||
<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">
|
||||||
|
<meta name="color-scheme" content="light only">
|
||||||
|
<meta name="supported-color-schemes" content="light">
|
||||||
<title>{email_title}</title>
|
<title>{email_title}</title>
|
||||||
</head>
|
<style>
|
||||||
<body style="margin:0;padding:0;background:#f2f2f0;">
|
:root {{
|
||||||
|
color-scheme: light only;
|
||||||
|
supported-color-schemes: light;
|
||||||
|
}}
|
||||||
|
|
||||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
body,
|
||||||
|
table,
|
||||||
|
td,
|
||||||
|
div,
|
||||||
|
p,
|
||||||
|
span,
|
||||||
|
a {{
|
||||||
|
forced-color-adjust: none !important;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.gw-owner-body {{
|
||||||
|
background: #f2f2f0 !important;
|
||||||
|
color: #213021 !important;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.gw-owner-shell {{
|
||||||
|
background: #ffffff !important;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.gw-owner-dark-panel {{
|
||||||
|
background: #213021 !important;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.gw-owner-email-chip {{
|
||||||
|
display: inline-block;
|
||||||
|
background: #ffffff !important;
|
||||||
|
color: #213021 !important;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #d9dfd9;
|
||||||
|
text-decoration: none !important;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.gw-owner-email-chip,
|
||||||
|
.gw-owner-email-chip a,
|
||||||
|
a.gw-owner-email-chip {{
|
||||||
|
color: #213021 !important;
|
||||||
|
}}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {{
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
.gw-owner-body {{
|
||||||
|
background: #f2f2f0 !important;
|
||||||
|
color: #213021 !important;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.gw-owner-shell,
|
||||||
|
.gw-owner-shell td {{
|
||||||
|
background: #ffffff !important;
|
||||||
|
color: #213021 !important;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.gw-owner-dark-panel,
|
||||||
|
.gw-owner-dark-panel td {{
|
||||||
|
background: #213021 !important;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.gw-owner-email-chip,
|
||||||
|
.gw-owner-email-chip a,
|
||||||
|
a.gw-owner-email-chip {{
|
||||||
|
background: #ffffff !important;
|
||||||
|
color: #213021 !important;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="gw-owner-body" style="margin:0;padding:0;background:#f2f2f0;color:#213021;">
|
||||||
|
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" bgcolor="#f2f2f0"
|
||||||
style="background:#f2f2f0;padding:40px 16px;">
|
style="background:#f2f2f0;padding:40px 16px;">
|
||||||
<tr><td align="center">
|
<tr><td align="center">
|
||||||
|
|
||||||
<table width="600" cellpadding="0" cellspacing="0" role="presentation"
|
<table class="gw-owner-shell" width="600" cellpadding="0" cellspacing="0" role="presentation" bgcolor="#ffffff"
|
||||||
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);background:#ffffff;">
|
||||||
|
|
||||||
{_logo_header(badge_html=badge)}
|
{_logo_header(badge_html=badge)}
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<tr>
|
<tr>
|
||||||
<td style="background:#ffffff;padding:40px 48px 36px;">
|
<td bgcolor="#ffffff" style="background:#ffffff;padding:40px 48px 36px;color:#213021;">
|
||||||
|
|
||||||
<!-- Quick contact -->
|
<!-- Quick contact -->
|
||||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
<table class="gw-owner-dark-panel" width="100%" cellpadding="0" cellspacing="0" role="presentation" bgcolor="#213021"
|
||||||
style="background:#213021;border-radius:12px;margin-bottom:28px;">
|
style="background:#213021;border-radius:12px;margin-bottom:28px;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:22px 24px;">
|
<td bgcolor="#213021" style="padding:22px 24px;background:#213021;">
|
||||||
<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:#7aaa7a;
|
font-size:11px;font-weight:700;letter-spacing:0.1em;color:#7aaa7a;
|
||||||
text-transform:uppercase;margin-bottom:10px;">
|
text-transform:uppercase;margin-bottom:10px;">
|
||||||
@@ -674,10 +796,15 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
|
|||||||
font-size:14px;color:#d8e6d8;line-height:1.6;margin-bottom:10px;">
|
font-size:14px;color:#d8e6d8;line-height:1.6;margin-bottom:10px;">
|
||||||
Email {first_name} directly:
|
Email {first_name} directly:
|
||||||
</div>
|
</div>
|
||||||
<div style="font-family:Menlo,Consolas,'SFMono-Regular',monospace;
|
<div style="margin-bottom:12px;">
|
||||||
font-size:20px;font-weight:700;color:#ffffff;line-height:1.4;
|
<a href="mailto:{data.email}" class="gw-owner-email-chip"
|
||||||
word-break:break-all;margin-bottom:12px;">
|
style="display:inline-block;background:#ffffff;color:#213021 !important;
|
||||||
|
font-family:Menlo,Consolas,'SFMono-Regular',monospace;
|
||||||
|
font-size:20px;font-weight:700;line-height:1.4;word-break:break-all;
|
||||||
|
border-radius:10px;padding:12px 14px;border:1px solid #d9dfd9;
|
||||||
|
text-decoration:none;">
|
||||||
{data.email}
|
{data.email}
|
||||||
|
</a>
|
||||||
</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:12px;color:#b7cbb7;line-height:1.6;">
|
font-size:12px;color:#b7cbb7;line-height:1.6;">
|
||||||
@@ -779,8 +906,14 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
|
|||||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
{_meta_row("IP address", ip)}
|
{_meta_row("IP address", ip)}
|
||||||
{_meta_row("Browser", browser)}
|
{_meta_row("Browser", browser)}
|
||||||
|
{visit_time_row}
|
||||||
|
{page_time_row}
|
||||||
|
{active_time_row}
|
||||||
|
{form_time_row}
|
||||||
|
{step_changes_row}
|
||||||
{referrer_row}
|
{referrer_row}
|
||||||
{page_row}
|
{page_row}
|
||||||
|
{journey_row}
|
||||||
</table>
|
</table>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -853,8 +986,73 @@ async def _send_email(payload: dict, label: str, request_id: str) -> dict:
|
|||||||
raise last_exc
|
raise last_exc
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_startup_test_email() -> None:
|
||||||
|
if not STARTUP_TEST_RECIPIENT:
|
||||||
|
logger.info("Startup test email skipped: OWNER_BCC is not set to a real address")
|
||||||
|
return
|
||||||
|
|
||||||
|
request_id = "startup-test"
|
||||||
|
payload = {
|
||||||
|
"from": FROM_EMAIL,
|
||||||
|
"to": [STARTUP_TEST_RECIPIENT],
|
||||||
|
"reply_to": REPLY_TO,
|
||||||
|
"subject": f"GoodWalk Mail API startup check ({APP_VERSION})",
|
||||||
|
"html": f"""<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="light only">
|
||||||
|
<meta name="supported-color-schemes" content="light">
|
||||||
|
<title>GoodWalk Mail API startup check</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:24px;background:#f2f2f0;color:#213021;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" bgcolor="#f2f2f0" style="background:#f2f2f0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table width="560" cellpadding="0" cellspacing="0" role="presentation" bgcolor="#ffffff"
|
||||||
|
style="max-width:560px;width:100%;background:#ffffff;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);">
|
||||||
|
{_logo_header(subtitle="Mail API startup check")}
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#ffffff" style="background:#ffffff;padding:36px 40px;">
|
||||||
|
<h1 style="margin:0 0 12px;font-size:24px;line-height:1.2;color:#213021;">Startup test email</h1>
|
||||||
|
<p style="margin:0 0 18px;font-size:15px;line-height:1.6;color:#4a4f4a;">
|
||||||
|
The GoodWalk mail service started successfully and sent this boot check to the Gmail monitoring address only.
|
||||||
|
</p>
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="background:#f8f7f4;border-radius:12px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px 24px;">
|
||||||
|
<div style="font-size:13px;line-height:1.7;color:#213021;">
|
||||||
|
<strong>Version:</strong> {APP_VERSION}<br>
|
||||||
|
<strong>Started:</strong> {datetime.now().strftime("%d %b %Y %I:%M %p").lstrip("0")}<br>
|
||||||
|
<strong>Recipient:</strong> {STARTUP_TEST_RECIPIENT}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>""",
|
||||||
|
}
|
||||||
|
|
||||||
|
await _send_email(payload, label="startup_test_email", request_id=request_id)
|
||||||
|
|
||||||
|
|
||||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def _startup_mail_check() -> None:
|
||||||
|
try:
|
||||||
|
await _send_startup_test_email()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Startup test email failed")
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health() -> dict:
|
async def health() -> dict:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "goodwalk-svelte-port",
|
"name": "goodwalk-svelte-port",
|
||||||
"version": "4.0.2",
|
"version": "4.2.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "goodwalk-svelte-port",
|
"name": "goodwalk-svelte-port",
|
||||||
"version": "4.0.2",
|
"version": "4.2.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.2.1",
|
"version": "4.2.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
export let booking: BookingContent;
|
export let booking: BookingContent;
|
||||||
export let allowGeneralEnquiry = false;
|
export let allowGeneralEnquiry = false;
|
||||||
type EnquiryType = 'booking' | 'general';
|
type EnquiryType = 'booking' | 'general';
|
||||||
|
const visitStartedStorageKey = 'goodwalk_visit_started_at';
|
||||||
|
const journeyStorageKey = 'goodwalk_journey';
|
||||||
|
const maxJourneyEntries = 8;
|
||||||
|
|
||||||
let step = 1;
|
let step = 1;
|
||||||
$: headingParts = splitBookingTitle(booking.title);
|
$: headingParts = splitBookingTitle(booking.title);
|
||||||
@@ -23,6 +26,12 @@
|
|||||||
let selectedServices: string[] = [];
|
let selectedServices: string[] = [];
|
||||||
let website = '';
|
let website = '';
|
||||||
let formStartedAt = 0;
|
let formStartedAt = 0;
|
||||||
|
let visitStartedAt = 0;
|
||||||
|
let pageEnteredAt = 0;
|
||||||
|
let firstInteractionAt = 0;
|
||||||
|
let sendClickedAt = 0;
|
||||||
|
let stepChanges = 0;
|
||||||
|
let journey: string[] = [];
|
||||||
|
|
||||||
let fullNameInput: HTMLInputElement;
|
let fullNameInput: HTMLInputElement;
|
||||||
let emailInput: HTMLInputElement;
|
let emailInput: HTMLInputElement;
|
||||||
@@ -84,7 +93,11 @@
|
|||||||
$: successPetName = petName.trim() || 'your dog';
|
$: successPetName = petName.trim() || 'your dog';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
formStartedAt = Date.now();
|
const now = Date.now();
|
||||||
|
formStartedAt = now;
|
||||||
|
pageEnteredAt = now;
|
||||||
|
visitStartedAt = readOrCreateVisitStartedAt(now);
|
||||||
|
journey = updateJourneySnapshot(window.location.pathname, window.location.search);
|
||||||
});
|
});
|
||||||
|
|
||||||
function splitBookingTitle(title: string) {
|
function splitBookingTitle(title: string) {
|
||||||
@@ -107,7 +120,58 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readOrCreateVisitStartedAt(fallback: number) {
|
||||||
|
try {
|
||||||
|
const raw = window.sessionStorage.getItem(visitStartedStorageKey);
|
||||||
|
const parsed = raw ? Number(raw) : NaN;
|
||||||
|
|
||||||
|
if (Number.isFinite(parsed) && parsed > 0) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.sessionStorage.setItem(visitStartedStorageKey, String(fallback));
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateJourneySnapshot(pathname: string, search: string) {
|
||||||
|
const nextEntry = `${pathname}${search}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = window.sessionStorage.getItem(journeyStorageKey);
|
||||||
|
const previous = raw ? (JSON.parse(raw) as string[]) : [];
|
||||||
|
const cleaned = previous.filter((value) => typeof value === 'string' && value.trim());
|
||||||
|
const deduped = cleaned[cleaned.length - 1] === nextEntry ? cleaned : [...cleaned, nextEntry];
|
||||||
|
const nextJourney = deduped.slice(-maxJourneyEntries);
|
||||||
|
|
||||||
|
window.sessionStorage.setItem(journeyStorageKey, JSON.stringify(nextJourney));
|
||||||
|
return nextJourney;
|
||||||
|
} catch {
|
||||||
|
return [nextEntry];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function noteInteraction() {
|
||||||
|
if (!firstInteractionAt) {
|
||||||
|
firstInteractionAt = Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStep(nextStep: number, trackTransition = false) {
|
||||||
|
if (step !== nextStep && trackTransition) {
|
||||||
|
stepChanges += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
step = nextStep;
|
||||||
|
errors = {};
|
||||||
|
}
|
||||||
|
|
||||||
function toggleService(service: string, checked: boolean) {
|
function toggleService(service: string, checked: boolean) {
|
||||||
|
noteInteraction();
|
||||||
|
|
||||||
if (checked) {
|
if (checked) {
|
||||||
selectedServices = [...selectedServices, service];
|
selectedServices = [...selectedServices, service];
|
||||||
return;
|
return;
|
||||||
@@ -117,6 +181,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setEnquiryType(nextType: EnquiryType) {
|
function setEnquiryType(nextType: EnquiryType) {
|
||||||
|
noteInteraction();
|
||||||
enquiryType = nextType;
|
enquiryType = nextType;
|
||||||
if (nextType === 'general') {
|
if (nextType === 'general') {
|
||||||
petName = '';
|
petName = '';
|
||||||
@@ -186,9 +251,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goToOwnerStep() {
|
function goToOwnerStep() {
|
||||||
|
noteInteraction();
|
||||||
if (!validateDetailsStep()) return;
|
if (!validateDetailsStep()) return;
|
||||||
errors = {};
|
setStep(2, true);
|
||||||
step = 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(event: SubmitEvent) {
|
async function handleSubmit(event: SubmitEvent) {
|
||||||
@@ -204,6 +269,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
errors = {};
|
errors = {};
|
||||||
|
noteInteraction();
|
||||||
|
sendClickedAt = Date.now();
|
||||||
submitting = true;
|
submitting = true;
|
||||||
submitErrorDetail = '';
|
submitErrorDetail = '';
|
||||||
showErrorModal = false;
|
showErrorModal = false;
|
||||||
@@ -223,6 +290,12 @@
|
|||||||
services: isGeneralEnquiry ? [] : selectedServices,
|
services: isGeneralEnquiry ? [] : selectedServices,
|
||||||
website,
|
website,
|
||||||
formStartedAt,
|
formStartedAt,
|
||||||
|
visitStartedAt,
|
||||||
|
pageEnteredAt,
|
||||||
|
firstInteractionAt,
|
||||||
|
sendClickedAt,
|
||||||
|
stepChanges,
|
||||||
|
journey,
|
||||||
referrer: document.referrer,
|
referrer: document.referrer,
|
||||||
page: window.location.href
|
page: window.location.href
|
||||||
})
|
})
|
||||||
@@ -279,10 +352,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class:active={step === 1}
|
class:active={step === 1}
|
||||||
class="booking-step"
|
class="booking-step"
|
||||||
on:click={() => {
|
on:click={() => setStep(1, step !== 1)}
|
||||||
step = 1;
|
|
||||||
errors = {};
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span class="booking-step-number">1</span>
|
<span class="booking-step-number">1</span>
|
||||||
<span class="booking-step-label">{detailsStepLabel}</span>
|
<span class="booking-step-label">{detailsStepLabel}</span>
|
||||||
@@ -300,7 +370,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="booking-form" id="bookingForm" novalidate on:submit={handleSubmit}>
|
<form
|
||||||
|
class="booking-form"
|
||||||
|
id="bookingForm"
|
||||||
|
novalidate
|
||||||
|
on:submit={handleSubmit}
|
||||||
|
on:focusin={noteInteraction}
|
||||||
|
on:input={noteInteraction}
|
||||||
|
on:change={noteInteraction}
|
||||||
|
>
|
||||||
<div class="booking-honeypot" aria-hidden="true">
|
<div class="booking-honeypot" aria-hidden="true">
|
||||||
<label for="website">Website</label>
|
<label for="website">Website</label>
|
||||||
<input
|
<input
|
||||||
@@ -569,10 +647,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline btn-outline-green"
|
class="btn btn-outline btn-outline-green"
|
||||||
on:click={() => {
|
on:click={() => setStep(1, true)}
|
||||||
step = 1;
|
|
||||||
errors = {};
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ async function moveToOwnerStep(container: HTMLElement) {
|
|||||||
|
|
||||||
describe('BookingSection', () => {
|
describe('BookingSection', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
window.sessionStorage.clear();
|
||||||
Object.defineProperty(document, 'referrer', {
|
Object.defineProperty(document, 'referrer', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: 'https://www.google.com/'
|
value: 'https://www.google.com/'
|
||||||
@@ -104,9 +105,15 @@ describe('BookingSection', () => {
|
|||||||
message: 'Loves small group walks.',
|
message: 'Loves small group walks.',
|
||||||
services: ['Pack Walks', 'Other Services'],
|
services: ['Pack Walks', 'Other Services'],
|
||||||
website: '',
|
website: '',
|
||||||
referrer: 'https://www.google.com/'
|
referrer: 'https://www.google.com/',
|
||||||
|
stepChanges: 1,
|
||||||
|
journey: [window.location.pathname]
|
||||||
});
|
});
|
||||||
expect(payload.formStartedAt).toEqual(expect.any(Number));
|
expect(payload.formStartedAt).toEqual(expect.any(Number));
|
||||||
|
expect(payload.visitStartedAt).toEqual(expect.any(Number));
|
||||||
|
expect(payload.pageEnteredAt).toEqual(expect.any(Number));
|
||||||
|
expect(payload.firstInteractionAt).toEqual(expect.any(Number));
|
||||||
|
expect(payload.sendClickedAt).toEqual(expect.any(Number));
|
||||||
|
|
||||||
expect(screen.getByRole('dialog', { name: /Booking confirmed/i })).toBeInTheDocument();
|
expect(screen.getByRole('dialog', { name: /Booking confirmed/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('heading', { name: /on our radar/i })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: /on our radar/i })).toBeInTheDocument();
|
||||||
@@ -156,7 +163,9 @@ describe('BookingSection', () => {
|
|||||||
petName: '',
|
petName: '',
|
||||||
location: '',
|
location: '',
|
||||||
message: 'I would like to discuss a business partnership.',
|
message: 'I would like to discuss a business partnership.',
|
||||||
services: []
|
services: [],
|
||||||
|
stepChanges: 1,
|
||||||
|
journey: [window.location.pathname]
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByRole('dialog', { name: /Enquiry confirmed/i })).toBeInTheDocument();
|
expect(screen.getByRole('dialog', { name: /Enquiry confirmed/i })).toBeInTheDocument();
|
||||||
|
|||||||
Reference in New Issue
Block a user