4.2.2 - tracking across email, fixes to dark mode.

This commit is contained in:
2026-05-06 15:50:01 +12:00
parent a7ce4c74b5
commit 2f4001b8af
11 changed files with 323 additions and 41 deletions
+1 -1
View File
@@ -73,7 +73,7 @@ mkdir -p /docker/goodwalk-svelte
It is created from [deploy.env.template](deploy.env.template). Current template contents:
```env
APP_VERSION=4.0.2
APP_VERSION=4.2.2
ENABLE_GENERAL_ENQUIRIES=false
TZ=Pacific/Auckland
+1 -1
View File
@@ -1,4 +1,4 @@
ARG APP_VERSION=4.0.2
ARG APP_VERSION=4.2.2
FROM node:22-alpine AS builder
ARG APP_VERSION
+1 -1
View File
@@ -1,4 +1,4 @@
APP_VERSION=4.0.2
APP_VERSION=4.2.2
TZ=Pacific/Auckland
POSTGRES_DB=goodwalk
+4 -4
View File
@@ -3,10 +3,10 @@ services:
build:
context: .
args:
APP_VERSION: ${APP_VERSION:-4.0.2}
APP_VERSION: ${APP_VERSION:-4.2.2}
container_name: goodwalk_svelte_app
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}
NODE_ENV: production
PORT: 3000
@@ -25,10 +25,10 @@ services:
build:
context: ./mail-api
args:
APP_VERSION: ${APP_VERSION:-4.0.2}
APP_VERSION: ${APP_VERSION:-4.2.2}
container_name: goodwalk_svelte_mail_api
environment:
APP_VERSION: ${APP_VERSION:-4.0.2}
APP_VERSION: ${APP_VERSION:-4.2.2}
RESEND_API_KEY: ${RESEND_API_KEY}
OWNER_EMAIL: ${OWNER_EMAIL}
OWNER_BCC: ${OWNER_BCC:-}
+4 -4
View File
@@ -3,9 +3,9 @@ services:
build:
context: .
args:
APP_VERSION: ${APP_VERSION:-4.0.2}
APP_VERSION: ${APP_VERSION:-4.2.2}
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}
NODE_ENV: production
PORT: ${APP_PORT:-3000}
@@ -19,9 +19,9 @@ services:
build:
context: ./mail-api
args:
APP_VERSION: ${APP_VERSION:-4.0.2}
APP_VERSION: ${APP_VERSION:-4.2.2}
environment:
APP_VERSION: ${APP_VERSION:-4.0.2}
APP_VERSION: ${APP_VERSION:-4.2.2}
RESEND_API_KEY: ${RESEND_API_KEY}
OWNER_EMAIL: ${OWNER_EMAIL}
OWNER_BCC: ${OWNER_BCC:-}
+1 -1
View File
@@ -1,4 +1,4 @@
ARG APP_VERSION=4.0.2
ARG APP_VERSION=4.2.2
FROM python:3.12-slim
ARG APP_VERSION
+209 -11
View File
@@ -141,6 +141,7 @@ logger.info(
)
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(
CORSMiddleware,
@@ -184,6 +185,12 @@ class BookingSubmission(BaseModel):
services: list[str] = []
website: str = ""
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 = ""
page: str = ""
@@ -382,6 +389,13 @@ def _normalize_submission(data: BookingSubmission) -> None:
data.referrer = _trimmed(data.referrer)
data.page = _trimmed(data.page)
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):
data.petName = ""
@@ -430,6 +444,33 @@ def _meta_row(label: str, value: str) -> str:
</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 ──────────────────────────────────────────────────────────
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")
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 &amp; services"
detail_rows = [_detail_row("Type", _enquiry_type_label(data))]
@@ -642,29 +689,104 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
<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>{email_title}</title>
</head>
<body style="margin:0;padding:0;background:#f2f2f0;">
<style>
: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;">
<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;
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)}
<!-- Body -->
<tr>
<td style="background:#ffffff;padding:40px 48px 36px;">
<td bgcolor="#ffffff" style="background:#ffffff;padding:40px 48px 36px;color:#213021;">
<!-- 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;">
<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;
font-size:11px;font-weight:700;letter-spacing:0.1em;color:#7aaa7a;
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;">
Email {first_name} directly:
</div>
<div style="font-family:Menlo,Consolas,'SFMono-Regular',monospace;
font-size:20px;font-weight:700;color:#ffffff;line-height:1.4;
word-break:break-all;margin-bottom:12px;">
<div style="margin-bottom:12px;">
<a href="mailto:{data.email}" class="gw-owner-email-chip"
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}
</a>
</div>
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
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">
{_meta_row("IP address", ip)}
{_meta_row("Browser", browser)}
{visit_time_row}
{page_time_row}
{active_time_row}
{form_time_row}
{step_changes_row}
{referrer_row}
{page_row}
{journey_row}
</table>
</td></tr>
</table>
@@ -853,8 +986,73 @@ async def _send_email(payload: dict, label: str, request_id: str) -> dict:
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 ───────────────────────────────────────────────────────────────────
@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")
async def health() -> dict:
return {"status": "ok"}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "goodwalk-svelte-port",
"version": "4.0.2",
"version": "4.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "goodwalk-svelte-port",
"version": "4.0.2",
"version": "4.2.2",
"dependencies": {
"canvas-confetti": "^1.9.4",
"pg": "^8.13.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "goodwalk-svelte-port",
"version": "4.2.1",
"version": "4.2.2",
"private": true,
"type": "module",
"scripts": {
+87 -12
View File
@@ -9,6 +9,9 @@
export let booking: BookingContent;
export let allowGeneralEnquiry = false;
type EnquiryType = 'booking' | 'general';
const visitStartedStorageKey = 'goodwalk_visit_started_at';
const journeyStorageKey = 'goodwalk_journey';
const maxJourneyEntries = 8;
let step = 1;
$: headingParts = splitBookingTitle(booking.title);
@@ -23,6 +26,12 @@
let selectedServices: string[] = [];
let website = '';
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 emailInput: HTMLInputElement;
@@ -84,7 +93,11 @@
$: successPetName = petName.trim() || 'your dog';
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) {
@@ -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) {
noteInteraction();
if (checked) {
selectedServices = [...selectedServices, service];
return;
@@ -117,6 +181,7 @@
}
function setEnquiryType(nextType: EnquiryType) {
noteInteraction();
enquiryType = nextType;
if (nextType === 'general') {
petName = '';
@@ -186,9 +251,9 @@
}
function goToOwnerStep() {
noteInteraction();
if (!validateDetailsStep()) return;
errors = {};
step = 2;
setStep(2, true);
}
async function handleSubmit(event: SubmitEvent) {
@@ -204,6 +269,8 @@
}
errors = {};
noteInteraction();
sendClickedAt = Date.now();
submitting = true;
submitErrorDetail = '';
showErrorModal = false;
@@ -223,6 +290,12 @@
services: isGeneralEnquiry ? [] : selectedServices,
website,
formStartedAt,
visitStartedAt,
pageEnteredAt,
firstInteractionAt,
sendClickedAt,
stepChanges,
journey,
referrer: document.referrer,
page: window.location.href
})
@@ -279,10 +352,7 @@
type="button"
class:active={step === 1}
class="booking-step"
on:click={() => {
step = 1;
errors = {};
}}
on:click={() => setStep(1, step !== 1)}
>
<span class="booking-step-number">1</span>
<span class="booking-step-label">{detailsStepLabel}</span>
@@ -300,7 +370,15 @@
</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">
<label for="website">Website</label>
<input
@@ -569,10 +647,7 @@
<button
type="button"
class="btn btn-outline btn-outline-green"
on:click={() => {
step = 1;
errors = {};
}}
on:click={() => setStep(1, true)}
>
Back
</button>
+11 -2
View File
@@ -36,6 +36,7 @@ async function moveToOwnerStep(container: HTMLElement) {
describe('BookingSection', () => {
beforeEach(() => {
window.sessionStorage.clear();
Object.defineProperty(document, 'referrer', {
configurable: true,
value: 'https://www.google.com/'
@@ -104,9 +105,15 @@ describe('BookingSection', () => {
message: 'Loves small group walks.',
services: ['Pack Walks', 'Other Services'],
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.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('heading', { name: /on our radar/i })).toBeInTheDocument();
@@ -156,7 +163,9 @@ describe('BookingSection', () => {
petName: '',
location: '',
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();