diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index d46a20b..4b316da 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -52,7 +52,7 @@ containers untouched. - Repo-local SSH config used by the deployment script. - [nginx/goodwalk.co.nz.svelte.conf.example](nginx/goodwalk.co.nz.svelte.conf.example) - Example shared-nginx config for routing the main public site to the new - Svelte app and mail API. + Svelte app and mail API, including the onboarding subdomain. ## First-time server preparation @@ -238,6 +238,8 @@ nginx/goodwalk.co.nz.svelte.conf.example Important: - `deploy.ps1` now copies the repo nginx config to `/docker/nginx/conf.d/goodwalk.co.nz.conf` and reloads the shared nginx container as part of deployment. - The repo nginx config uses Docker's internal resolver so future app/mail container rebuilds will not leave nginx pinned to stale upstream IPs. +- The same nginx config now also routes `onboarding.goodwalk.co.nz` to the Svelte app and `/api/onboarding-submit` to the shared mail API. +- Before cutover, confirm the server has a valid certificate for `onboarding.goodwalk.co.nz`, or adjust the onboarding certificate paths in the nginx config to match your cert layout. Manual nginx commands, if you ever need them: diff --git a/MARKETING.md b/MARKETING.md index 6bed350..13a6a1c 100644 --- a/MARKETING.md +++ b/MARKETING.md @@ -2,6 +2,58 @@ A working reference for the Goodwalk site rebuild and ongoing marketing decisions. Drawn from Chris Do (The Futur) and Debbie Millman (Design Matters), applied to the goal of acquiring 10 new clients. +# Checklist +* Prioritise emotional trust before visual impressiveness. +* Reduce cognitive load on every screen and interaction. +* Every page should answer: “Am I in the right place?” +* Use whitespace intentionally to create calmness and confidence. +* Interfaces should feel predictable, stable, and effortless. +Avoid clutter, excessive animations, and visual noise. +Design for clarity first, aesthetics second. +Premium experiences rely on restraint, not excess. +Typography hierarchy must immediately guide the eye. +Use fewer colours, but apply them consistently. +Every component should have a clear purpose. +Remove unnecessary borders, labels, and UI chrome. +Make primary actions visually obvious within 2 seconds. +Ensure pages feel fast even before fully loading. +Consistent spacing creates perceived quality and trust. +Use authentic photography over generic stock imagery. +Human faces increase emotional connection and trust. +Testimonials should feel personal and believable, not corporate. +Buttons and CTAs should sound conversational and reassuring. +Interfaces should feel welcoming, not technical. +Avoid overwhelming users with too many choices. +Users should never wonder what happens next. +Design layouts around scanning behaviour, not reading behaviour. +Mobile layouts should feel intentionally designed, not compressed desktop pages. +Use subtle depth, shadows, and contrast to create hierarchy. +Premium brands often use less content, but communicate more clearly. +Calm interfaces increase perceived professionalism. +Align visuals, copy, and interaction style into one consistent tone. +The homepage should communicate trust before features. +Every visual element should reinforce simplicity and confidence. +Reduce form friction wherever possible. +Users should be able to understand the business in under 5 seconds. +Make service quality visually obvious through imagery and spacing. +Avoid sharp transitions or jarring visual elements. +Consistency across pages matters more than visual complexity. +Good UX feels invisible to the user. +Use natural language instead of corporate wording. +Remove anything that feels “template-like”. +Create visual breathing room around important content. +Make interactions feel human, warm, and intentional. +Ensure hover states and animations feel subtle and refined. +Use imagery that reflects real customers and real experiences. +Trust is built through consistency, polish, and predictability. +Pages should feel curated, not crowded. +Premium experiences rely heavily on pacing and rhythm. +Focus attention using contrast, spacing, and hierarchy. +Design should lower anxiety and decision fatigue. +Avoid overexplaining when visuals already communicate meaning. +The best interfaces feel calm, simple, and inevitable. +Every redesign decision should improve trust, clarity, or emotional comfort. + --- ## Chris Do's Principles diff --git a/deploy.ps1 b/deploy.ps1 index d89e70e..3a49277 100644 --- a/deploy.ps1 +++ b/deploy.ps1 @@ -145,6 +145,24 @@ function Export-HomepageContent { } } +function New-UnixScriptCopy { + param( + [string]$SourcePath + ) + + if (-not (Test-Path -LiteralPath $SourcePath)) { + throw "Script not found: $SourcePath" + } + + $tempFileName = 'goodwalk-deploy-helper-{0}.sh' -f ([System.Guid]::NewGuid().ToString('N')) + $tempPath = Join-Path ([System.IO.Path]::GetTempPath()) $tempFileName + $content = [System.IO.File]::ReadAllText($SourcePath) + $normalized = $content.Replace("`r`n", "`n").Replace("`r", "`n") + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($tempPath, $normalized, $utf8NoBom) + return $tempPath +} + function Invoke-SiteCheck { param([string]$Url) @@ -242,6 +260,7 @@ if (-not $Force) { } $archivePath = $null +$uploadHelperPath = $null try { Write-Host '' @@ -255,7 +274,8 @@ try { Write-Host '' Write-Host '[deploy] Uploading remote helper' - Invoke-External -FilePath 'scp' -Arguments ($sshArgs + @($LocalRemoteHelperPath, $scpHelperTarget)) + $uploadHelperPath = New-UnixScriptCopy -SourcePath $LocalRemoteHelperPath + Invoke-External -FilePath 'scp' -Arguments ($sshArgs + @($uploadHelperPath, $scpHelperTarget)) Write-Host '' Write-Host '[deploy] Uploading application archive' @@ -310,4 +330,7 @@ finally { if ($archivePath -and (Test-Path -LiteralPath $archivePath)) { Remove-Item -LiteralPath $archivePath -Force } + if ($uploadHelperPath -and (Test-Path -LiteralPath $uploadHelperPath)) { + Remove-Item -LiteralPath $uploadHelperPath -Force + } } diff --git a/mail-api/__pycache__/main.cpython-314.pyc b/mail-api/__pycache__/main.cpython-314.pyc index 33503fc..6daaf46 100644 Binary files a/mail-api/__pycache__/main.cpython-314.pyc and b/mail-api/__pycache__/main.cpython-314.pyc differ diff --git a/mail-api/data/client_profiles.json b/mail-api/data/client_profiles.json new file mode 100644 index 0000000..e880ec1 --- /dev/null +++ b/mail-api/data/client_profiles.json @@ -0,0 +1,7 @@ +{ + "mattcohen0@gmail.com": { + "fullName": "Matt Test", + "phone": "02124347477", + "dogName": "Geoffrey" + } +} \ No newline at end of file diff --git a/mail-api/logs/mail-api.log b/mail-api/logs/mail-api.log index 0185cda..a94fe1c 100644 --- a/mail-api/logs/mail-api.log +++ b/mail-api/logs/mail-api.log @@ -4,3 +4,310 @@ 2026-05-02 09:07:45 CRITICAL mail-api: Startup aborted: missing env vars: ['RESEND_API_KEY', 'OWNER_EMAIL'] 2026-05-02 11:16:43 INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) 2026-05-02 11:16:43 CRITICAL mail-api: Startup aborted: missing env vars: ['RESEND_API_KEY', 'OWNER_EMAIL'] +11/05/2026 18:00:06 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +11/05/2026 18:00:06 New Zealand Standard Time CRITICAL mail-api: Startup aborted: missing env vars: ['RESEND_API_KEY', 'OWNER_EMAIL'] +11/05/2026 18:01:59 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +11/05/2026 18:01:59 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +11/05/2026 18:01:59 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +11/05/2026 18:02:43 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +11/05/2026 18:02:43 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +11/05/2026 18:02:43 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +11/05/2026 18:02:56 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +11/05/2026 18:02:56 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +11/05/2026 18:02:56 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +11/05/2026 18:02:56 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +11/05/2026 18:03:33 New Zealand Standard Time INFO mail-api: [8d525af8] /submit: type=booking email=mattcohen0@gmail.com ip=127.0.0.1 browser='Firefox on Windows 10/11' dog=Matt test onboardin services=['Pack Walks'] page='http://10.0.0.124:5173/' +11/05/2026 18:03:33 New Zealand Standard Time DEBUG mail-api: [8d525af8] full payload: {'fullName': 'Matt', 'email': 'mattcohen0@gmail.com', 'phone': '1212', 'website': '', 'formStartedAt': 1778479391809, 'visitStartedAt': 1778449168133, 'pageEnteredAt': 1778479391809, 'firstInteractionAt': 1778479393568, 'sendClickedAt': 1778479409699, 'referrer': '', 'page': 'http://10.0.0.124:5173/', 'enquiryType': 'booking', 'petName': 'Matt test onboardin', 'location': 'test', 'message': 'test', 'services': ['Pack Walks'], 'stepChanges': 1, 'journey': ['/', '/contact-us', '/our-pricing', '/', '/dog-walking', '/pack-walks', '/contact-us', '/']} +11/05/2026 18:03:33 New Zealand Standard Time DEBUG urllib3.connectionpool: Starting new HTTPS connection (1): api.resend.com:443 +11/05/2026 18:03:34 New Zealand Standard Time DEBUG urllib3.connectionpool: https://api.resend.com:443 "POST /emails HTTP/1.1" 401 75 +11/05/2026 18:03:34 New Zealand Standard Time WARNING mail-api: [8d525af8] client_email send failed (attempt 1/3, 617ms): ResendError: API key is invalid (status=401) +Traceback (most recent call last): + File "C:\Users\mattc\gw-svelte\gw-svelte\mail-api\main.py", line 1298, in _send_email + result = await asyncio.to_thread(resend.Emails.send, payload) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\mattc\AppData\Local\Programs\Python\Python314\Lib\asyncio\threads.py", line 25, in to_thread + return await loop.run_in_executor(None, func_call) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\mattc\AppData\Local\Programs\Python\Python314\Lib\concurrent\futures\thread.py", line 86, in run + result = ctx.run(self.task) + File "C:\Users\mattc\AppData\Local\Programs\Python\Python314\Lib\concurrent\futures\thread.py", line 73, in run + return fn(*args, **kwargs) + File "C:\Users\mattc\AppData\Local\Programs\Python\Python314\Lib\site-packages\resend\emails\_emails.py", line 286, in send + ).perform_with_content() + ~~~~~~~~~~~~~~~~~~~~^^ + File "C:\Users\mattc\AppData\Local\Programs\Python\Python314\Lib\site-packages\resend\request.py", line 49, in perform_with_content + resp = self.perform() + File "C:\Users\mattc\AppData\Local\Programs\Python\Python314\Lib\site-packages\resend\request.py", line 37, in perform + raise_for_code_and_type( + ~~~~~~~~~~~~~~~~~~~~~~~^ + code=data.get("statusCode") or 500, + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + ...<2 lines>... + headers=self._response_headers, + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + ) + ^ + File "C:\Users\mattc\AppData\Local\Programs\Python\Python314\Lib\site-packages\resend\exceptions.py", line 270, in raise_for_code_and_type + raise ResendError( + ...<5 lines>... + ) +resend.exceptions.ResendError: API key is invalid +11/05/2026 18:03:34 New Zealand Standard Time INFO mail-api: [8d525af8] client_email: non-retryable status 401, aborting retries +11/05/2026 18:03:34 New Zealand Standard Time DEBUG urllib3.connectionpool: Starting new HTTPS connection (1): api.resend.com:443 +11/05/2026 18:03:34 New Zealand Standard Time DEBUG urllib3.connectionpool: https://api.resend.com:443 "POST /emails HTTP/1.1" 401 75 +11/05/2026 18:03:34 New Zealand Standard Time WARNING mail-api: [8d525af8] owner_email send failed (attempt 1/3, 490ms): ResendError: API key is invalid (status=401) +Traceback (most recent call last): + File "C:\Users\mattc\gw-svelte\gw-svelte\mail-api\main.py", line 1298, in _send_email + result = await asyncio.to_thread(resend.Emails.send, payload) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\mattc\AppData\Local\Programs\Python\Python314\Lib\asyncio\threads.py", line 25, in to_thread + return await loop.run_in_executor(None, func_call) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\mattc\AppData\Local\Programs\Python\Python314\Lib\concurrent\futures\thread.py", line 86, in run + result = ctx.run(self.task) + File "C:\Users\mattc\AppData\Local\Programs\Python\Python314\Lib\concurrent\futures\thread.py", line 73, in run + return fn(*args, **kwargs) + File "C:\Users\mattc\AppData\Local\Programs\Python\Python314\Lib\site-packages\resend\emails\_emails.py", line 286, in send + ).perform_with_content() + ~~~~~~~~~~~~~~~~~~~~^^ + File "C:\Users\mattc\AppData\Local\Programs\Python\Python314\Lib\site-packages\resend\request.py", line 49, in perform_with_content + resp = self.perform() + File "C:\Users\mattc\AppData\Local\Programs\Python\Python314\Lib\site-packages\resend\request.py", line 37, in perform + raise_for_code_and_type( + ~~~~~~~~~~~~~~~~~~~~~~~^ + code=data.get("statusCode") or 500, + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + ...<2 lines>... + headers=self._response_headers, + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + ) + ^ + File "C:\Users\mattc\AppData\Local\Programs\Python\Python314\Lib\site-packages\resend\exceptions.py", line 270, in raise_for_code_and_type + raise ResendError( + ...<5 lines>... + ) +resend.exceptions.ResendError: API key is invalid +11/05/2026 18:03:34 New Zealand Standard Time INFO mail-api: [8d525af8] owner_email: non-retryable status 401, aborting retries +11/05/2026 18:03:34 New Zealand Standard Time ERROR mail-api: [8d525af8] both emails failed after retries: [{'label': 'client_email', 'error_type': 'ResendError', 'error': 'API key is invalid', 'status': 401}, {'label': 'owner_email', 'error_type': 'ResendError', 'error': 'API key is invalid', 'status': 401}] +11/05/2026 18:03:34 New Zealand Standard Time INFO mail-api: [8d525af8] POST /submit → 502 (1155ms) +11/05/2026 18:04:04 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +11/05/2026 18:04:04 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +11/05/2026 18:04:04 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +11/05/2026 18:04:04 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +11/05/2026 18:04:16 New Zealand Standard Time INFO mail-api: [445c9a1c] /submit: type=booking email=mattcohen0@gmail.com ip=127.0.0.1 browser='Firefox on Windows 10/11' dog=Matt test onboardin services=['Pack Walks'] page='http://10.0.0.124:5173/' +11/05/2026 18:04:16 New Zealand Standard Time DEBUG mail-api: [445c9a1c] full payload: {'fullName': 'Matt', 'email': 'mattcohen0@gmail.com', 'phone': '1212', 'website': '', 'formStartedAt': 1778479391809, 'visitStartedAt': 1778449168133, 'pageEnteredAt': 1778479391809, 'firstInteractionAt': 1778479393568, 'sendClickedAt': 1778479452270, 'referrer': '', 'page': 'http://10.0.0.124:5173/', 'enquiryType': 'booking', 'petName': 'Matt test onboardin', 'location': 'test', 'message': 'test', 'services': ['Pack Walks'], 'stepChanges': 1, 'journey': ['/', '/contact-us', '/our-pricing', '/', '/dog-walking', '/pack-walks', '/contact-us', '/']} +11/05/2026 18:04:16 New Zealand Standard Time WARNING mail-api: [DEV] skipping email send — label=client_email to=['mattcohen0@gmail.com'] subject='We received your enquiry, Matt! 🐾' +11/05/2026 18:04:16 New Zealand Standard Time WARNING mail-api: [DEV] skipping email send — label=owner_email to=['dev@localhost'] subject='New GoodWalk lead — Matt (Matt test onboardin)' +11/05/2026 18:04:16 New Zealand Standard Time INFO mail-api: [445c9a1c] POST /submit → 200 (15ms) +11/05/2026 18:04:48 New Zealand Standard Time INFO mail-api: [44114758] auth: code issued for email=mattcohen0@gmail.com +11/05/2026 18:04:48 New Zealand Standard Time WARNING mail-api: [DEV] auth code for mattcohen0@gmail.com: 756139 +11/05/2026 18:04:48 New Zealand Standard Time INFO mail-api: [44114758] POST /auth/request-code → 200 (2ms) +11/05/2026 18:04:55 New Zealand Standard Time INFO mail-api: [e48ac08b] auth: session created for email=mattcohen0@gmail.com +11/05/2026 18:04:55 New Zealand Standard Time INFO mail-api: [e48ac08b] POST /auth/verify-code → 200 (1ms) +11/05/2026 18:05:42 New Zealand Standard Time INFO mail-api: [336b95ea] GET /auth/verify → 200 (1ms) +11/05/2026 18:05:44 New Zealand Standard Time INFO mail-api: [d068c1f8] GET /auth/verify → 200 (0ms) +11/05/2026 18:05:47 New Zealand Standard Time INFO mail-api: [44c5d28e] GET /auth/verify → 200 (0ms) +11/05/2026 18:05:52 New Zealand Standard Time INFO mail-api: [c4187d3a] GET /auth/verify → 200 (0ms) +11/05/2026 18:05:59 New Zealand Standard Time INFO mail-api: [1a029963] GET /auth/verify → 200 (0ms) +11/05/2026 18:06:53 New Zealand Standard Time INFO mail-api: [7da26969] GET /auth/verify → 200 (0ms) +11/05/2026 18:06:56 New Zealand Standard Time INFO mail-api: [694d3abf] POST /auth/logout → 200 (1ms) +11/05/2026 18:07:00 New Zealand Standard Time INFO mail-api: [6a7da236] auth: code issued for email=mattcohen0@gmail.com +11/05/2026 18:07:00 New Zealand Standard Time WARNING mail-api: [DEV] auth code for mattcohen0@gmail.com: 193883 +11/05/2026 18:07:00 New Zealand Standard Time INFO mail-api: [6a7da236] POST /auth/request-code → 200 (1ms) +11/05/2026 18:07:08 New Zealand Standard Time INFO mail-api: [c4ec2eac] auth: session created for email=mattcohen0@gmail.com +11/05/2026 18:07:08 New Zealand Standard Time INFO mail-api: [c4ec2eac] POST /auth/verify-code → 200 (1ms) +11/05/2026 18:07:56 New Zealand Standard Time INFO mail-api: [86d8e5c9] GET /auth/verify → 200 (0ms) +11/05/2026 18:07:58 New Zealand Standard Time INFO mail-api: [d6eb0fef] GET /auth/verify → 200 (0ms) +11/05/2026 18:08:03 New Zealand Standard Time INFO mail-api: [0e022c79] GET /auth/verify → 200 (0ms) +11/05/2026 18:08:16 New Zealand Standard Time INFO mail-api: [65e5d5be] GET /auth/verify → 200 (0ms) +11/05/2026 18:08:18 New Zealand Standard Time INFO mail-api: [d70ef7e4] POST /auth/logout → 200 (0ms) +11/05/2026 18:08:22 New Zealand Standard Time INFO mail-api: [0bbd2c06] auth: code issued for email=mattcohen0@gmail.com +11/05/2026 18:08:22 New Zealand Standard Time WARNING mail-api: [DEV] auth code for mattcohen0@gmail.com: 951035 +11/05/2026 18:08:22 New Zealand Standard Time INFO mail-api: [0bbd2c06] POST /auth/request-code → 200 (1ms) +11/05/2026 18:08:31 New Zealand Standard Time INFO mail-api: [61d9c06a] auth: session created for email=mattcohen0@gmail.com +11/05/2026 18:08:31 New Zealand Standard Time INFO mail-api: [61d9c06a] POST /auth/verify-code → 200 (1ms) +11/05/2026 18:09:27 New Zealand Standard Time INFO mail-api: [a30c0cef] GET /auth/verify → 200 (0ms) +11/05/2026 18:09:43 New Zealand Standard Time INFO mail-api: [05bdfd29] POST /auth/logout → 200 (0ms) +11/05/2026 18:09:48 New Zealand Standard Time INFO mail-api: [16862886] auth: code issued for email=mattcohen0@gmail.com +11/05/2026 18:09:48 New Zealand Standard Time WARNING mail-api: [DEV] auth code for mattcohen0@gmail.com: 975093 +11/05/2026 18:09:48 New Zealand Standard Time INFO mail-api: [16862886] POST /auth/request-code → 200 (1ms) +11/05/2026 18:09:50 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +11/05/2026 18:09:50 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +11/05/2026 18:09:50 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +11/05/2026 18:09:50 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +11/05/2026 18:09:55 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +11/05/2026 18:09:55 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +11/05/2026 18:09:55 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +11/05/2026 18:09:55 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +11/05/2026 18:10:11 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +11/05/2026 18:10:11 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +11/05/2026 18:10:11 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +11/05/2026 18:10:11 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +11/05/2026 18:10:16 New Zealand Standard Time INFO mail-api: [56955ef5] auth: code issued for email=mattcohen0@gmail.com +11/05/2026 18:10:16 New Zealand Standard Time WARNING mail-api: [DEV] auth code for mattcohen0@gmail.com: 664020 +11/05/2026 18:10:16 New Zealand Standard Time INFO mail-api: [56955ef5] POST /auth/request-code → 200 (3ms) +11/05/2026 18:10:18 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +11/05/2026 18:10:18 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +11/05/2026 18:10:18 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +11/05/2026 18:10:18 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +11/05/2026 18:10:23 New Zealand Standard Time INFO mail-api: [cac84255] POST /auth/verify-code → 400 (2ms) +11/05/2026 18:10:27 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +11/05/2026 18:10:27 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +11/05/2026 18:10:27 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +11/05/2026 18:10:27 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +11/05/2026 18:11:25 New Zealand Standard Time INFO mail-api: [6bec1b20] auth: code issued for email=mattcohen0@gmail.com +11/05/2026 18:11:25 New Zealand Standard Time WARNING mail-api: [DEV] auth code for mattcohen0@gmail.com: 442224 +11/05/2026 18:11:25 New Zealand Standard Time INFO mail-api: [6bec1b20] POST /auth/request-code → 200 (2ms) +11/05/2026 18:11:38 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +11/05/2026 18:11:38 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +11/05/2026 18:11:38 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +11/05/2026 18:11:38 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +11/05/2026 18:11:44 New Zealand Standard Time INFO mail-api: [f9c95e4d] auth: code issued for email=mattcohen0@gmail.com +11/05/2026 18:11:44 New Zealand Standard Time WARNING mail-api: [DEV] auth code for mattcohen0@gmail.com: 326405 +11/05/2026 18:11:44 New Zealand Standard Time INFO mail-api: [f9c95e4d] POST /auth/request-code → 200 (2ms) +11/05/2026 18:11:52 New Zealand Standard Time INFO mail-api: [a0e2cf00] auth: session created for email=mattcohen0@gmail.com +11/05/2026 18:11:52 New Zealand Standard Time INFO mail-api: [a0e2cf00] POST /auth/verify-code → 200 (1ms) +11/05/2026 18:11:52 New Zealand Standard Time INFO mail-api: [b631b0f2] GET /auth/verify → 200 (1ms) +11/05/2026 18:13:07 New Zealand Standard Time INFO mail-api: [240a8117] /submit: type=booking email=mattcohen0@gmail.com ip=127.0.0.1 browser='Firefox on Windows 10/11' dog=Doug services=['Pack Walks'] page='http://10.0.0.124:5173/' +11/05/2026 18:13:07 New Zealand Standard Time DEBUG mail-api: [240a8117] full payload: {'fullName': 'Tobias Cohen', 'email': 'mattcohen0@gmail.com', 'phone': '021548278', 'website': '', 'formStartedAt': 1778479962856, 'visitStartedAt': 1778479962856, 'pageEnteredAt': 1778479962856, 'firstInteractionAt': 1778479965665, 'sendClickedAt': 1778479983701, 'referrer': '', 'page': 'http://10.0.0.124:5173/', 'enquiryType': 'booking', 'petName': 'Doug', 'location': 'Herne bay', 'message': '', 'services': ['Pack Walks'], 'stepChanges': 1, 'journey': ['/']} +11/05/2026 18:13:07 New Zealand Standard Time WARNING mail-api: [DEV] skipping email send — label=client_email to=['mattcohen0@gmail.com'] subject='We received your enquiry, Tobias! 🐾' +11/05/2026 18:13:07 New Zealand Standard Time WARNING mail-api: [DEV] skipping email send — label=owner_email to=['dev@localhost'] subject='New GoodWalk lead — Tobias Cohen (Doug)' +11/05/2026 18:13:07 New Zealand Standard Time INFO mail-api: [240a8117] POST /submit → 200 (9ms) +11/05/2026 18:13:25 New Zealand Standard Time INFO mail-api: [d95e1762] POST /auth/logout → 200 (1ms) +11/05/2026 18:13:26 New Zealand Standard Time INFO mail-api: [38dd6d5c] auth: code issued for email=mattcohen0@gmail.com +11/05/2026 18:13:26 New Zealand Standard Time WARNING mail-api: [DEV] auth code for mattcohen0@gmail.com: 156979 +11/05/2026 18:13:26 New Zealand Standard Time INFO mail-api: [38dd6d5c] POST /auth/request-code → 200 (1ms) +11/05/2026 18:13:33 New Zealand Standard Time INFO mail-api: [c6266ea4] auth: session created for email=mattcohen0@gmail.com +11/05/2026 18:13:33 New Zealand Standard Time INFO mail-api: [c6266ea4] POST /auth/verify-code → 200 (1ms) +11/05/2026 18:13:33 New Zealand Standard Time INFO mail-api: [848a669f] GET /auth/verify → 200 (0ms) +11/05/2026 18:14:50 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +11/05/2026 18:14:50 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +11/05/2026 18:14:50 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +11/05/2026 18:14:50 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +11/05/2026 18:14:59 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +11/05/2026 18:14:59 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +11/05/2026 18:14:59 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +11/05/2026 18:14:59 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +11/05/2026 18:15:05 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +11/05/2026 18:15:05 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +11/05/2026 18:15:05 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +11/05/2026 18:15:05 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +11/05/2026 18:20:01 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +11/05/2026 18:20:01 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +11/05/2026 18:20:01 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +11/05/2026 18:20:01 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +11/05/2026 18:20:07 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +11/05/2026 18:20:07 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +11/05/2026 18:20:07 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +11/05/2026 18:20:07 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +11/05/2026 18:20:16 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +11/05/2026 18:20:16 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +11/05/2026 18:20:16 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +11/05/2026 18:20:16 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +11/05/2026 18:20:27 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +11/05/2026 18:20:27 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +11/05/2026 18:20:27 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +11/05/2026 18:20:28 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +11/05/2026 18:20:36 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +11/05/2026 18:20:36 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +11/05/2026 18:20:36 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +11/05/2026 18:20:36 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +11/05/2026 18:25:51 New Zealand Standard Time INFO mail-api: [8a6a1510] GET /auth/verify → 401 (2ms) +11/05/2026 18:26:51 New Zealand Standard Time INFO mail-api: [12f8d959] /submit: type=booking email=mattcohen0@gmail.com ip=127.0.0.1 browser='Firefox on Windows 10/11' dog=Geoffrey services=['Pack Walks'] page='http://10.0.0.124:5173/' +11/05/2026 18:26:51 New Zealand Standard Time DEBUG mail-api: [12f8d959] full payload: {'fullName': 'Matt Test', 'email': 'mattcohen0@gmail.com', 'phone': '02124347477', 'website': '', 'formStartedAt': 1778480790866, 'visitStartedAt': 1778449168133, 'pageEnteredAt': 1778480790866, 'firstInteractionAt': 1778480793847, 'sendClickedAt': 1778480808084, 'referrer': '', 'page': 'http://10.0.0.124:5173/', 'enquiryType': 'booking', 'petName': 'Geoffrey', 'location': 'Matty', 'message': 'Test', 'services': ['Pack Walks'], 'stepChanges': 1, 'journey': ['/', '/contact-us', '/our-pricing', '/', '/dog-walking', '/pack-walks', '/contact-us', '/']} +11/05/2026 18:26:51 New Zealand Standard Time WARNING mail-api: [DEV] skipping email send — label=client_email to=['mattcohen0@gmail.com'] subject='We received your enquiry, Matt! 🐾' +11/05/2026 18:26:51 New Zealand Standard Time WARNING mail-api: [DEV] skipping email send — label=owner_email to=['dev@localhost'] subject='New GoodWalk lead — Matt Test (Geoffrey)' +11/05/2026 18:26:51 New Zealand Standard Time INFO mail-api: [12f8d959] POST /submit → 200 (18ms) +11/05/2026 18:27:13 New Zealand Standard Time INFO mail-api: [dba46f8b] /submit: type=booking email=mattcohen0@gmail.com ip=127.0.0.1 browser='Firefox on Windows 10/11' dog=Geoffrey services=['Pack Walks'] page='http://10.0.0.124:5173/' +11/05/2026 18:27:13 New Zealand Standard Time DEBUG mail-api: [dba46f8b] full payload: {'fullName': 'Matt Test', 'email': 'mattcohen0@gmail.com', 'phone': '02124347477', 'website': '', 'formStartedAt': 1778480790866, 'visitStartedAt': 1778449168133, 'pageEnteredAt': 1778480790866, 'firstInteractionAt': 1778480793847, 'sendClickedAt': 1778480830112, 'referrer': '', 'page': 'http://10.0.0.124:5173/', 'enquiryType': 'booking', 'petName': 'Geoffrey', 'location': 'Matty', 'message': 'Test', 'services': ['Pack Walks'], 'stepChanges': 1, 'journey': ['/', '/contact-us', '/our-pricing', '/', '/dog-walking', '/pack-walks', '/contact-us', '/']} +11/05/2026 18:27:13 New Zealand Standard Time WARNING mail-api: [DEV] skipping email send — label=client_email to=['mattcohen0@gmail.com'] subject='We received your enquiry, Matt! 🐾' +11/05/2026 18:27:13 New Zealand Standard Time WARNING mail-api: [DEV] skipping email send — label=owner_email to=['dev@localhost'] subject='New GoodWalk lead — Matt Test (Geoffrey)' +11/05/2026 18:27:13 New Zealand Standard Time INFO mail-api: [dba46f8b] POST /submit → 200 (2ms) +11/05/2026 18:28:06 New Zealand Standard Time INFO mail-api: [4edf00fe] auth: code issued for email=mattcohen0@gmail.com +11/05/2026 18:28:06 New Zealand Standard Time WARNING mail-api: [DEV] auth code for mattcohen0@gmail.com: 416532 +11/05/2026 18:28:06 New Zealand Standard Time INFO mail-api: [4edf00fe] POST /auth/request-code → 200 (2ms) +11/05/2026 18:28:13 New Zealand Standard Time INFO mail-api: [ec212e61] auth: session created for email=mattcohen0@gmail.com +11/05/2026 18:28:13 New Zealand Standard Time INFO mail-api: [ec212e61] POST /auth/verify-code → 200 (2ms) +11/05/2026 18:28:13 New Zealand Standard Time INFO mail-api: [61f0f5db] GET /auth/verify → 200 (0ms) +11/05/2026 18:29:03 New Zealand Standard Time INFO mail-api: [1a4c2779] GET /auth/verify → 200 (0ms) +11/05/2026 18:29:12 New Zealand Standard Time INFO mail-api: [401a9596] GET /auth/verify → 200 (0ms) +11/05/2026 18:29:16 New Zealand Standard Time INFO mail-api: [d162211c] GET /auth/verify → 200 (0ms) +11/05/2026 18:29:33 New Zealand Standard Time INFO mail-api: [c454ccd0] GET /auth/verify → 200 (0ms) +11/05/2026 18:29:39 New Zealand Standard Time INFO mail-api: [1e7a7145] GET /auth/verify → 200 (0ms) +11/05/2026 18:29:43 New Zealand Standard Time INFO mail-api: [02fcf859] GET /auth/verify → 200 (0ms) +11/05/2026 18:30:19 New Zealand Standard Time INFO mail-api: [a66a3485] GET /auth/verify → 200 (0ms) +11/05/2026 18:30:25 New Zealand Standard Time INFO mail-api: [7ae73440] GET /auth/verify → 200 (0ms) +11/05/2026 18:30:28 New Zealand Standard Time INFO mail-api: [9e46aa4c] GET /auth/verify → 200 (0ms) +11/05/2026 18:30:47 New Zealand Standard Time INFO mail-api: [7e3b4735] GET /auth/verify → 200 (0ms) +11/05/2026 18:30:52 New Zealand Standard Time INFO mail-api: [5ff0bc97] GET /auth/verify → 200 (1ms) +11/05/2026 18:31:00 New Zealand Standard Time INFO mail-api: [aa2f5411] GET /auth/verify → 200 (0ms) +11/05/2026 18:32:34 New Zealand Standard Time INFO mail-api: [8916510c] GET /auth/verify → 200 (0ms) +11/05/2026 18:32:38 New Zealand Standard Time INFO mail-api: [86b218b7] GET /auth/verify → 200 (0ms) +11/05/2026 18:32:38 New Zealand Standard Time INFO mail-api: [ee963004] GET /auth/verify → 200 (0ms) +11/05/2026 18:32:41 New Zealand Standard Time INFO mail-api: [407e5303] GET /auth/verify → 200 (0ms) +11/05/2026 18:32:45 New Zealand Standard Time INFO mail-api: [c1dcda74] GET /auth/verify → 200 (0ms) +11/05/2026 18:32:47 New Zealand Standard Time INFO mail-api: [a6fcbd3d] GET /auth/verify → 200 (1ms) +11/05/2026 18:32:50 New Zealand Standard Time INFO mail-api: [21cce6ef] GET /auth/verify → 200 (0ms) +11/05/2026 18:32:59 New Zealand Standard Time INFO mail-api: [b7859bff] GET /auth/verify → 200 (0ms) +11/05/2026 18:34:36 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +11/05/2026 18:34:36 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +11/05/2026 18:34:36 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +11/05/2026 18:34:36 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +11/05/2026 18:34:55 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +11/05/2026 18:34:55 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +11/05/2026 18:34:55 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +11/05/2026 18:34:55 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +11/05/2026 18:34:58 New Zealand Standard Time INFO mail-api: [2d611be5] GET /auth/verify → 401 (1ms) +11/05/2026 18:35:51 New Zealand Standard Time INFO mail-api: [e7ab563c] auth: code issued for email=mattcohen0@gmail.com +11/05/2026 18:35:51 New Zealand Standard Time WARNING mail-api: [DEV] auth code for mattcohen0@gmail.com: 530175 +11/05/2026 18:35:51 New Zealand Standard Time INFO mail-api: [e7ab563c] POST /auth/request-code → 200 (2ms) +11/05/2026 18:35:59 New Zealand Standard Time INFO mail-api: [28ed2c2e] auth: session created for email=mattcohen0@gmail.com +11/05/2026 18:35:59 New Zealand Standard Time INFO mail-api: [28ed2c2e] POST /auth/verify-code → 200 (1ms) +11/05/2026 18:35:59 New Zealand Standard Time INFO mail-api: [b5fe5a79] GET /auth/verify → 200 (0ms) +11/05/2026 18:36:41 New Zealand Standard Time INFO mail-api: [368ab712] GET /auth/verify → 200 (0ms) +11/05/2026 18:36:50 New Zealand Standard Time INFO mail-api: [3bb325b5] GET /auth/verify → 200 (0ms) +11/05/2026 18:36:54 New Zealand Standard Time INFO mail-api: [412c0290] GET /auth/verify → 200 (0ms) +11/05/2026 18:37:27 New Zealand Standard Time INFO mail-api: [de1e1806] GET /auth/verify → 200 (0ms) +11/05/2026 18:37:32 New Zealand Standard Time INFO mail-api: [a139f936] GET /auth/verify → 200 (0ms) +11/05/2026 18:37:40 New Zealand Standard Time INFO mail-api: [5b2635d9] GET /auth/verify → 200 (0ms) +11/05/2026 18:37:45 New Zealand Standard Time INFO mail-api: [b621f259] GET /auth/verify → 200 (0ms) +11/05/2026 18:37:58 New Zealand Standard Time INFO mail-api: [58357018] GET /auth/verify → 200 (0ms) +11/05/2026 18:38:00 New Zealand Standard Time INFO mail-api: [df724db7] GET /auth/verify → 200 (0ms) +11/05/2026 18:38:01 New Zealand Standard Time INFO mail-api: [8bad66cb] GET /auth/verify → 200 (0ms) +11/05/2026 18:38:02 New Zealand Standard Time INFO mail-api: [aac47018] GET /auth/verify → 200 (0ms) +11/05/2026 18:38:03 New Zealand Standard Time INFO mail-api: [34ebe14e] GET /auth/verify → 200 (0ms) +11/05/2026 18:38:05 New Zealand Standard Time INFO mail-api: [e9248145] GET /auth/verify → 200 (0ms) +11/05/2026 18:38:05 New Zealand Standard Time INFO mail-api: [c00e4c1f] GET /auth/verify → 200 (0ms) +11/05/2026 18:38:07 New Zealand Standard Time INFO mail-api: [bdd58356] GET /auth/verify → 200 (0ms) +11/05/2026 18:38:50 New Zealand Standard Time INFO mail-api: [16f6b8ba] GET /auth/verify → 200 (0ms) +11/05/2026 18:38:51 New Zealand Standard Time INFO mail-api: [a46f7171] POST /auth/logout → 200 (1ms) +11/05/2026 18:41:41 New Zealand Standard Time INFO mail-api: [24fca012] auth: unknown email=mattco0en@gmail.com ip=127.0.0.1 +11/05/2026 18:41:41 New Zealand Standard Time WARNING mail-api: [24fca012] auth: failure ip=127.0.0.1 reason='unknown_email' total_in_window=1 +11/05/2026 18:41:41 New Zealand Standard Time INFO mail-api: [24fca012] POST /auth/request-code → 403 (1ms) +11/05/2026 18:41:51 New Zealand Standard Time INFO mail-api: [4b292ea6] auth: code issued for email=mattcohen0@gmail.com +11/05/2026 18:41:51 New Zealand Standard Time WARNING mail-api: [DEV] auth code for mattcohen0@gmail.com: 759209 +11/05/2026 18:41:51 New Zealand Standard Time INFO mail-api: [4b292ea6] POST /auth/request-code → 200 (1ms) +11/05/2026 18:42:03 New Zealand Standard Time INFO mail-api: [b2213391] auth: session created for email=mattcohen0@gmail.com +11/05/2026 18:42:03 New Zealand Standard Time INFO mail-api: [b2213391] POST /auth/verify-code → 200 (1ms) +11/05/2026 18:42:03 New Zealand Standard Time INFO mail-api: [5d05ac03] GET /auth/verify → 200 (0ms) +11/05/2026 19:05:06 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +11/05/2026 19:05:06 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +11/05/2026 19:05:06 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +11/05/2026 19:05:06 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +11/05/2026 19:05:12 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +11/05/2026 19:05:12 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +11/05/2026 19:05:12 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +11/05/2026 19:05:12 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +11/05/2026 19:05:18 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +11/05/2026 19:05:18 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +11/05/2026 19:05:18 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +11/05/2026 19:05:18 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +11/05/2026 19:05:22 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +11/05/2026 19:05:22 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +11/05/2026 19:05:22 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +11/05/2026 19:05:22 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +11/05/2026 19:05:34 New Zealand Standard Time INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating) +11/05/2026 19:05:34 New Zealand Standard Time INFO mail-api: Mail API config: version='unknown' timezone='system-default' from='GoodWalk ' reply_to='aless@goodwalk.co.nz' owner='dev@localhost' owner_bcc='' client_bcc='' general_enquiries=False max_attempts=3 form_min=1s form_max=7200s rate_window=900s per_ip=50 per_email=50 min_interval=1s +11/05/2026 19:05:34 New Zealand Standard Time INFO mail-api: Auth: loaded 1 allowed email(s) +11/05/2026 19:05:34 New Zealand Standard Time INFO mail-api: Startup test email skipped: OWNER_BCC is not set to a real address +11/05/2026 19:05:47 New Zealand Standard Time INFO mail-api: [0c1cdd9c] GET /auth/verify → 401 (2ms) diff --git a/mail-api/main.py b/mail-api/main.py index ec6db51..3589b92 100644 --- a/mail-api/main.py +++ b/mail-api/main.py @@ -1,9 +1,12 @@ import asyncio from collections import deque +import json import logging import logging.handlers import os import random +import re +import secrets import sys import time import uuid @@ -55,6 +58,8 @@ logger = _setup_logging() # ── Configuration ──────────────────────────────────────────────────────────── +DEV_MODE = os.environ.get("DEV_MODE", "").strip().lower() in {"1", "true", "yes"} + REQUIRED_ENV = { "RESEND_API_KEY": "API key from https://resend.com/api-keys", "OWNER_EMAIL": "Email address that receives new lead notifications", @@ -62,6 +67,23 @@ REQUIRED_ENV = { def _load_config() -> dict: + if DEV_MODE: + return { + "resend_api_key": os.environ.get("RESEND_API_KEY", "dev"), + "owner_email": os.environ.get("OWNER_EMAIL", "dev@localhost"), + "from_email": os.environ.get("FROM_EMAIL", "GoodWalk "), + "reply_to": os.environ.get("REPLY_TO", "aless@goodwalk.co.nz"), + "owner_bcc": "", + "client_bcc": "", + "enable_general_enquiries": False, + "max_attempts": 3, + "form_min_seconds": 1, + "form_max_seconds": 7200, + "rate_limit_window_seconds": 900, + "rate_limit_max_per_ip": 50, + "rate_limit_max_per_email": 50, + "rate_limit_min_interval_seconds": 1, + } missing = [(name, hint) for name, hint in REQUIRED_ENV.items() if not os.environ.get(name)] if missing: lines = [ @@ -104,6 +126,16 @@ def _load_config() -> dict: _config = _load_config() APP_VERSION = os.environ.get("APP_VERSION", "unknown") +AUTH_CODE_TTL_SECONDS = max(60, int(os.environ.get("AUTH_CODE_TTL_SECONDS", "600"))) +AUTH_SESSION_TTL_SECONDS = max(3600, int(os.environ.get("AUTH_SESSION_TTL_SECONDS", str(7 * 24 * 3600)))) +AUTH_CODE_MAX_ATTEMPTS = 5 +AUTH_CODE_REQUESTS_PER_HOUR = 5 +AUTH_IP_MAX_FAILURES = max(3, int(os.environ.get("AUTH_IP_MAX_FAILURES", "10"))) +AUTH_IP_FAILURE_WINDOW = max(60, int(os.environ.get("AUTH_IP_FAILURE_WINDOW", "600"))) +AUTH_IP_BLOCK_DURATION = max(60, int(os.environ.get("AUTH_IP_BLOCK_DURATION", "3600"))) +_ALLOWED_EMAILS_FILE = Path(os.environ.get("DATA_DIR", "data")) / "allowed_emails.json" +_CLIENT_PROFILES_FILE = Path(os.environ.get("DATA_DIR", "data")) / "client_profiles.json" +_DRAFTS_FILE = Path(os.environ.get("DATA_DIR", "data")) / "drafts.json" resend.api_key = _config["resend_api_key"] OWNER_EMAIL = _config["owner_email"] OWNER_BCC = _config["owner_bcc"] @@ -143,6 +175,128 @@ logger.info( app = FastAPI(title="GoodWalk Mail API") STARTUP_TEST_RECIPIENT = OWNER_BCC if OWNER_BCC and OWNER_BCC.lower() != "example@example.com" else "" +# ── Auth state ─────────────────────────────────────────────────────────────── + +def _load_allowed_emails() -> set[str]: + seed = {e.strip().lower() for e in os.environ.get("ALLOWED_EMAILS", "").split(",") if e.strip()} + try: + if _ALLOWED_EMAILS_FILE.exists(): + data = json.loads(_ALLOWED_EMAILS_FILE.read_text(encoding="utf-8")) + seed.update(e.lower() for e in data.get("emails", []) if isinstance(e, str)) + except Exception as exc: + logger.warning("Could not load allowed_emails file: %s", exc) + return seed + +def _save_allowed_emails_sync(emails: set[str]) -> None: + try: + _ALLOWED_EMAILS_FILE.parent.mkdir(parents=True, exist_ok=True) + _ALLOWED_EMAILS_FILE.write_text( + json.dumps({"emails": sorted(emails)}, indent=2), encoding="utf-8" + ) + except Exception as exc: + logger.warning("Could not save allowed_emails: %s", exc) + + +def _load_client_profiles() -> dict[str, dict]: + try: + if _CLIENT_PROFILES_FILE.exists(): + return json.loads(_CLIENT_PROFILES_FILE.read_text(encoding="utf-8")) + except Exception as exc: + logger.warning("Could not load client_profiles file: %s", exc) + return {} + + +def _save_client_profiles_sync(profiles: dict) -> None: + try: + _CLIENT_PROFILES_FILE.parent.mkdir(parents=True, exist_ok=True) + _CLIENT_PROFILES_FILE.write_text(json.dumps(profiles, indent=2), encoding="utf-8") + except Exception as exc: + logger.warning("Could not save client_profiles: %s", exc) + + +def _load_drafts() -> dict: + try: + if _DRAFTS_FILE.exists(): + return json.loads(_DRAFTS_FILE.read_text(encoding="utf-8")) + except Exception as exc: + logger.warning("Could not load drafts file: %s", exc) + return {} + + +def _save_drafts_sync(drafts: dict) -> None: + try: + _DRAFTS_FILE.parent.mkdir(parents=True, exist_ok=True) + _DRAFTS_FILE.write_text(json.dumps(drafts, indent=2), encoding="utf-8") + except Exception as exc: + logger.warning("Could not save drafts: %s", exc) + + +_allowed_emails: set[str] = _load_allowed_emails() +_pending_codes: dict[str, dict] = {} # email -> {code, expires_at, attempts} +_active_sessions: dict[str, dict] = {} # token -> {email, expires_at} +_code_requests: dict[str, deque] = {} # email -> deque of monotonic timestamps +_client_profiles: dict[str, dict] = _load_client_profiles() +_drafts: dict[str, dict] = _load_drafts() # email -> {onboarding: {...}, contract: {...}} +_auth_failures_by_ip: dict[str, deque] = {} # ip -> deque of failure timestamps +_blocked_ips: dict[str, float] = {} # ip -> unblock_at (monotonic) +_auth_lock = asyncio.Lock() + +logger.info("Auth: loaded %d allowed email(s)", len(_allowed_emails)) + + +async def _register_email(email: str) -> None: + normalized = email.strip().lower() + if not normalized: + return + async with _auth_lock: + if normalized not in _allowed_emails: + _allowed_emails.add(normalized) + await asyncio.to_thread(_save_allowed_emails_sync, set(_allowed_emails)) + logger.info("Auth: registered new allowed email: %s", normalized) + + +async def _store_client_profile(email: str, profile: dict) -> None: + normalized = email.strip().lower() + if not normalized: + return + async with _auth_lock: + existing = _client_profiles.get(normalized, {}) + merged = {k: v for k, v in {**existing, **profile}.items() if v} + if merged != existing: + _client_profiles[normalized] = merged + await asyncio.to_thread(_save_client_profiles_sync, dict(_client_profiles)) + +def _check_ip_blocked(ip: str, request_id: str) -> None: + now = time.monotonic() + unblock_at = _blocked_ips.get(ip) + if unblock_at is not None: + if now < unblock_at: + remaining = int(unblock_at - now) + logger.warning("[%s] auth: blocked ip=%s (%ds remaining)", request_id, ip, remaining) + raise HTTPException( + status_code=429, + detail=f"Too many failed attempts. Try again in {remaining // 60 + 1} minute(s).", + headers={"Retry-After": str(remaining)}, + ) + else: + del _blocked_ips[ip] + + +def _record_auth_failure(ip: str, request_id: str, reason: str) -> None: + now = time.monotonic() + failures = _auth_failures_by_ip.setdefault(ip, deque()) + while failures and now - failures[0] > AUTH_IP_FAILURE_WINDOW: + failures.popleft() + failures.append(now) + logger.warning("[%s] auth: failure ip=%s reason=%r total_in_window=%d", request_id, ip, reason, len(failures)) + if len(failures) >= AUTH_IP_MAX_FAILURES: + _blocked_ips[ip] = now + AUTH_IP_BLOCK_DURATION + logger.warning( + "[%s] auth: ip=%s BLOCKED for %ds after %d failures", + request_id, ip, AUTH_IP_BLOCK_DURATION, len(failures), + ) + + app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -174,27 +328,68 @@ async def _request_logging_middleware(request: Request, call_next): return response -class BookingSubmission(BaseModel): - enquiryType: str = "booking" +class BaseSubmission(BaseModel): fullName: str email: EmailStr phone: str - petName: str = "" - location: str = "" - message: str = "" - 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 = "" +class BookingSubmission(BaseSubmission): + enquiryType: str = "booking" + petName: str = "" + location: str = "" + message: str = "" + services: list[str] = [] + stepChanges: int = 0 + journey: list[str] = [] + + +class OnboardingSubmission(BaseSubmission): + address: str + dogName: str + dogBreed: str + dogAge: str = "" + servicesNeeded: list[str] = [] + temperament: str = "" + medicalNotes: str = "" + accessInstructions: str = "" + vetName: str + vetPhone: str + emergencyContactName: str + emergencyContactPhone: str + councilRegistrationConfirmed: bool = False + vaccinationsConfirmed: bool = False + emergencyVetConsent: bool = False + termsAccepted: bool = False + signatureDataUrl: str + + +class ContractSubmission(BaseSubmission): + address: str + dogName: str + dogBreed: str + dogAge: str = "" + serviceType: str + startDate: str + walkFrequency: str = "" + additionalNotes: str = "" + agreeServiceTerms: bool = False + agreeCancellation: bool = False + agreePayment: bool = False + agreeEmergency: bool = False + agreeLiability: bool = False + agreeAccuracy: bool = False + signatureDataUrl: str + + # ── Helpers ────────────────────────────────────────────────────────────────── def _get_ip(request: Request) -> str: @@ -278,7 +473,7 @@ async def _enforce_submit_rate_limits(request_id: str, ip: str, email: str) -> N email_attempts.append(now) -def _enforce_form_timing(request_id: str, data: BookingSubmission) -> None: +def _enforce_form_timing(request_id: str, data: BaseSubmission) -> None: if data.formStartedAt is None or data.formStartedAt <= 0: logger.warning("[%s] rejected: missing or invalid formStartedAt", request_id) raise HTTPException( @@ -313,7 +508,7 @@ def _enforce_form_timing(request_id: str, data: BookingSubmission) -> None: ) -def _is_honeypot_triggered(data: BookingSubmission) -> bool: +def _is_honeypot_triggered(data: BaseSubmission) -> bool: return bool(_trimmed(data.website)) @@ -403,6 +598,76 @@ def _normalize_submission(data: BookingSubmission) -> None: data.services = [] +def _validate_onboarding_submission(request_id: str, data: OnboardingSubmission) -> None: + if not _trimmed(data.fullName): + logger.warning("[%s] onboarding 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] onboarding rejected: missing phone", request_id) + raise HTTPException(status_code=400, detail="Please enter your phone number.") + + required_fields = { + "address": "Please enter your address.", + "dogName": "Please enter your dog's name.", + "dogBreed": "Please enter your dog's breed.", + "vetName": "Please enter your vet clinic name.", + "vetPhone": "Please enter your vet phone number.", + "emergencyContactName": "Please enter an emergency contact name.", + "emergencyContactPhone": "Please enter an emergency contact phone number.", + } + + for field_name, message in required_fields.items(): + if not _trimmed(getattr(data, field_name)): + logger.warning("[%s] onboarding rejected: missing %s", request_id, field_name) + raise HTTPException(status_code=400, detail=message) + + if not data.servicesNeeded: + logger.warning("[%s] onboarding rejected: missing services", request_id) + raise HTTPException(status_code=400, detail="Please choose at least one service.") + + if not data.councilRegistrationConfirmed: + raise HTTPException(status_code=400, detail="Please confirm council registration.") + + if not data.vaccinationsConfirmed: + raise HTTPException(status_code=400, detail="Please confirm vaccinations are current.") + + if not data.emergencyVetConsent: + raise HTTPException(status_code=400, detail="Please confirm emergency veterinary consent.") + + if not data.termsAccepted: + raise HTTPException(status_code=400, detail="Please confirm the onboarding declaration.") + + signature = _trimmed(data.signatureDataUrl) + if not signature.startswith("data:image/png;base64,") or len(signature) < 128: + logger.warning("[%s] onboarding rejected: invalid signature payload", request_id) + raise HTTPException(status_code=400, detail="Please add your signature before sending.") + + +def _normalize_onboarding_submission(data: OnboardingSubmission) -> None: + data.fullName = _trimmed(data.fullName) + data.phone = _trimmed(data.phone) + data.address = _trimmed(data.address) + data.dogName = _trimmed(data.dogName) + data.dogBreed = _trimmed(data.dogBreed) + data.dogAge = _trimmed(data.dogAge) + data.temperament = _trimmed(data.temperament) + data.medicalNotes = _trimmed(data.medicalNotes) + data.accessInstructions = _trimmed(data.accessInstructions) + data.vetName = _trimmed(data.vetName) + data.vetPhone = _trimmed(data.vetPhone) + data.emergencyContactName = _trimmed(data.emergencyContactName) + data.emergencyContactPhone = _trimmed(data.emergencyContactPhone) + data.referrer = _trimmed(data.referrer) + data.page = _trimmed(data.page) + data.servicesNeeded = [_trimmed(service) for service in data.servicesNeeded if _trimmed(service)][:8] + + 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) + + def _parse_ua(ua: str) -> str: if not ua: return "Unknown" @@ -940,9 +1205,184 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str: """ +def owner_onboarding_email(data: OnboardingSubmission, ip: str, browser: str) -> str: + submitted_at = datetime.now().strftime("%d %b %Y at %I:%M %p").lstrip("0") + services_text = ", ".join(data.servicesNeeded) + 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)) + 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 "" + + dog_notes_block = f""" + + +
Temperament and routine
+
{data.temperament}
+ + """ if data.temperament else "" + + medical_block = f""" + + +
Medical notes
+
{data.medicalNotes}
+ + """ if data.medicalNotes else "" + + access_block = f""" + + +
Home access instructions
+
{data.accessInstructions}
+ + """ if data.accessInstructions else "" + + signature_block = f""" +
+
Captured signature
+ Client signature +
""" + + badge = f"""
+ + ✍  New onboarding form + +
+
+ Submitted {submitted_at} +
""" + + return f""" + + + + + New GoodWalk onboarding form + + + + +
+ + + {_logo_header(badge_html=badge, subtitle="Signed onboarding form")} + + + + +
+ + + + +
+
+ Quick contact +
+
+ Reply directly to the owner or call them back: +
+ + Call {data.phone} +
+ +
Owner details
+ + +
+ + {_detail_row("Name", data.fullName)} + {_detail_row("Email", str(data.email))} + {_detail_row("Phone", data.phone)} + {_detail_row("Address", data.address)} +
+
+ +
Dog and service details
+ + +
+ + {_detail_row("Dog", data.dogName)} + {_detail_row("Breed", data.dogBreed)} + {_detail_row("Age", data.dogAge or "—")} + {_detail_row("Service", services_text)} + {dog_notes_block} + {medical_block} + {access_block} +
+
+ +
Safety details
+ + +
+ + {_detail_row("Vet clinic", data.vetName)} + {_detail_row("Vet phone", data.vetPhone)} + {_detail_row("Emergency contact", data.emergencyContactName)} + {_detail_row("Emergency phone", data.emergencyContactPhone)} + {_detail_row("Council registration", "Confirmed")} + {_detail_row("Vaccinations", "Confirmed")} + {_detail_row("Emergency consent", "Confirmed")} + {_detail_row("Declaration", "Signed")} +
+ {signature_block} +
+ + + +
+
Session info
+ + {_meta_row("IP address", ip)} + {_meta_row("Browser", browser)} + {visit_time_row} + {page_time_row} + {active_time_row} + {form_time_row} + {referrer_row} + {page_row} +
+
+
+
+ +""" + + # ── Sending with retries ───────────────────────────────────────────────────── async def _send_email(payload: dict, label: str, request_id: str) -> dict: + if DEV_MODE: + to = payload.get("to", []) + subject = payload.get("subject", "(no subject)") + logger.warning("[DEV] skipping email send — label=%s to=%s subject=%r", label, to, subject) + return {"id": "dev-mode"} + last_exc: Exception | None = None for attempt in range(1, MAX_SEND_ATTEMPTS + 1): @@ -986,59 +1426,49 @@ async def _send_email(payload: dict, label: str, request_id: str) -> dict: raise last_exc +def _build_startup_test_submission() -> BookingSubmission: + now_ms = int(time.time() * 1000) + + sample = BookingSubmission( + enquiryType="booking", + fullName="Sarah Thompson", + email="sarah.thompson@example.com", + phone="021 555 0142", + petName="Milo", + location="Grey Lynn", + message=( + "Milo is a 2-year-old cavoodle with good recall and a friendly nature. " + "He loves other dogs, is comfortable off lead in safe areas, and we are " + "looking for regular weekday pack walks while we are at work." + ), + services=["Pack Walks", "Puppy Visits"], + formStartedAt=now_ms - (6 * 60 * 1000 + 35 * 1000), + visitStartedAt=now_ms - (14 * 60 * 1000 + 10 * 1000), + pageEnteredAt=now_ms - (7 * 60 * 1000 + 5 * 1000), + firstInteractionAt=now_ms - (5 * 60 * 1000 + 20 * 1000), + sendClickedAt=now_ms, + stepChanges=3, + journey=["/", "/pack-walks", "/our-pricing", "/book"], + referrer="https://www.google.com/search?q=goodwalk+auckland+dog+walking", + page="https://www.goodwalk.co.nz/book?service=pack-walks", + ) + _normalize_submission(sample) + return sample + + 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" + sample = _build_startup_test_submission() payload = { "from": FROM_EMAIL, "to": [STARTUP_TEST_RECIPIENT], - "reply_to": REPLY_TO, - "subject": f"GoodWalk Mail API startup check ({APP_VERSION})", - "html": f""" - - - - - - - GoodWalk Mail API startup check - - - - - - -
- - {_logo_header(subtitle="Mail API startup check")} - - - -
-

Startup test email

-

- The GoodWalk mail service started successfully and sent this boot check to the Gmail monitoring address only. -

- - - - -
-
- Version: {APP_VERSION}
- Started: {datetime.now().strftime("%d %b %Y %I:%M %p").lstrip("0")}
- Recipient: {STARTUP_TEST_RECIPIENT} -
-
-
-
- -""", + "reply_to": str(sample.email), + "subject": f"Startup preview — New GoodWalk lead — {sample.fullName} ({sample.petName})", + "html": owner_email(sample, "127.0.0.1", f"Startup Preview ({APP_VERSION})"), } await _send_email(payload, label="startup_test_email", request_id=request_id) @@ -1058,6 +1488,207 @@ async def health() -> dict: return {"status": "ok"} +def _auth_code_email(email: str, code: str) -> str: + return f""" + + + + + Your Goodwalk login code + + + + +
+ + + + + + + + + + +
+ Goodwalk +
+
Your login code
+
{code}
+

+ Enter this code on the Goodwalk onboarding page. +

+

+ This code expires in {AUTH_CODE_TTL_SECONDS // 60} minutes. If you didn’t request this, you can safely ignore it. +

+
+
+ Goodwalk · Auckland, New Zealand +
+
+
+ +""" + + +_EMAIL_RE = re.compile(r'^[^\s@]+@[^\s@]+\.[^\s@]+$') + + +@app.post("/auth/request-code") +async def auth_request_code(request: Request): + request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8]) + ip = _get_ip(request) + body = await request.json() + email = str(body.get("email", "")).strip().lower() + + async with _auth_lock: + _check_ip_blocked(ip, request_id) + + if not email or not _EMAIL_RE.match(email): + raise HTTPException(status_code=400, detail="Please enter a valid email address.") + + if email not in _allowed_emails: + logger.info("[%s] auth: unknown email=%s ip=%s", request_id, email, ip) + async with _auth_lock: + _record_auth_failure(ip, request_id, "unknown_email") + raise HTTPException( + status_code=403, + detail="We don’t have your email on file. Please use the address you used when enquiring with Goodwalk, or contact us at info@goodwalk.co.nz.", + ) + + now = time.monotonic() + async with _auth_lock: + requests = _code_requests.setdefault(email, deque()) + while requests and now - requests[0] > 3600: + requests.popleft() + if len(requests) >= AUTH_CODE_REQUESTS_PER_HOUR: + raise HTTPException(status_code=429, detail="Too many code requests. Please wait before trying again.") + requests.append(now) + + code = str(secrets.randbelow(900000) + 100000) + _pending_codes[email] = {"code": code, "expires_at": time.time() + AUTH_CODE_TTL_SECONDS, "attempts": 0} + + logger.info("[%s] auth: code issued for email=%s", request_id, email) + + if DEV_MODE: + logger.warning("[DEV] auth code for %s: %s", email, code) + else: + await _send_email( + {"from": FROM_EMAIL, "to": [email], "subject": "Your Goodwalk login code", "html": _auth_code_email(email, code)}, + label="auth_code_email", + request_id=request_id, + ) + + return {"ok": True} + + +@app.post("/auth/verify-code") +async def auth_verify_code(request: Request): + request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8]) + ip = _get_ip(request) + body = await request.json() + email = str(body.get("email", "")).strip().lower() + code = str(body.get("code", "")).strip() + + async with _auth_lock: + _check_ip_blocked(ip, request_id) + + pending = _pending_codes.get(email) + + if not pending: + _record_auth_failure(ip, request_id, "no_pending_code") + raise HTTPException(status_code=400, detail="No code found for this email. Please request a new one.") + + if time.time() > pending["expires_at"]: + _pending_codes.pop(email, None) + _record_auth_failure(ip, request_id, "expired_code") + raise HTTPException(status_code=400, detail="Your code has expired. Please request a new one.") + + pending["attempts"] += 1 + if pending["attempts"] > AUTH_CODE_MAX_ATTEMPTS: + _pending_codes.pop(email, None) + _record_auth_failure(ip, request_id, "max_attempts_exceeded") + raise HTTPException(status_code=400, detail="Too many incorrect attempts. Please request a new code.") + + if pending["code"] != code: + remaining = max(0, AUTH_CODE_MAX_ATTEMPTS - pending["attempts"]) + _record_auth_failure(ip, request_id, "wrong_code") + raise HTTPException(status_code=400, detail=f"Incorrect code. {remaining} attempt{'s' if remaining != 1 else ''} remaining.") + + _pending_codes.pop(email, None) + + token = secrets.token_urlsafe(32) + _active_sessions[token] = {"email": email, "expires_at": time.time() + AUTH_SESSION_TTL_SECONDS} + + logger.info("[%s] auth: session created for email=%s", request_id, email) + return {"ok": True, "token": token, "email": email} + + +@app.get("/auth/verify") +async def auth_verify(request: Request): + auth_header = request.headers.get("Authorization", "") + token = auth_header.removeprefix("Bearer ").strip() + + if not token: + raise HTTPException(status_code=401, detail="No token provided.") + + async with _auth_lock: + session = _active_sessions.get(token) + if not session: + raise HTTPException(status_code=401, detail="Invalid session.") + if time.time() > session["expires_at"]: + _active_sessions.pop(token, None) + raise HTTPException(status_code=401, detail="Session expired. Please sign in again.") + + email = session["email"] + profile = _client_profiles.get(email, {}) + draft = _drafts.get(email, {}) + return {"ok": True, "email": email, "profile": profile, "draft": draft} + + +@app.post("/auth/logout") +async def auth_logout(request: Request): + auth_header = request.headers.get("Authorization", "") + token = auth_header.removeprefix("Bearer ").strip() + if token: + async with _auth_lock: + _active_sessions.pop(token, None) + return {"ok": True} + + +@app.post("/auth/save-draft") +async def auth_save_draft(request: Request): + auth_header = request.headers.get("Authorization", "") + token = auth_header.removeprefix("Bearer ").strip() + if not token: + raise HTTPException(status_code=401, detail="No token provided.") + + async with _auth_lock: + session = _active_sessions.get(token) + if not session or time.time() > session["expires_at"]: + raise HTTPException(status_code=401, detail="Invalid or expired session.") + email = session["email"] + + body = await request.json() + form = str(body.get("form", "")).strip() + data = body.get("data", {}) + + if form not in ("onboarding", "contract"): + raise HTTPException(status_code=400, detail="form must be 'onboarding' or 'contract'.") + if not isinstance(data, dict): + raise HTTPException(status_code=400, detail="data must be an object.") + + async with _auth_lock: + user_drafts = _drafts.setdefault(email, {}) + user_drafts[form] = data + snapshot = dict(_drafts) + + await asyncio.to_thread(_save_drafts_sync, snapshot) + logger.info("Draft saved: email=%s form=%s", email, form) + return {"ok": True} + + @app.post("/submit") async def submit_booking(data: BookingSubmission, request: Request): request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8]) @@ -1161,8 +1792,334 @@ async def submit_booking(data: BookingSubmission, request: Request): if failures: logger.warning("[%s] partial failure: %s", request_id, failures) + await _register_email(str(data.email)) + await _store_client_profile(str(data.email), { + "fullName": data.fullName, + "phone": data.phone, + "dogName": data.petName, + }) + return { "ok": True, "request_id": request_id, "partial_failures": [f["label"] for f in failures], } + + +def _validate_contract_submission(request_id: str, data: ContractSubmission) -> None: + if not _trimmed(data.fullName): + raise HTTPException(status_code=400, detail="Please enter your full name.") + if not _trimmed(data.phone): + raise HTTPException(status_code=400, detail="Please enter your phone number.") + for field_name, message in { + "address": "Please enter your address.", + "dogName": "Please enter your dog's name.", + "dogBreed": "Please enter your dog's breed.", + "serviceType": "Please select a service type.", + "startDate": "Please enter a start date.", + }.items(): + if not _trimmed(getattr(data, field_name)): + logger.warning("[%s] contract rejected: missing %s", request_id, field_name) + raise HTTPException(status_code=400, detail=message) + + if not all([data.agreeServiceTerms, data.agreeCancellation, data.agreePayment, + data.agreeEmergency, data.agreeLiability, data.agreeAccuracy]): + logger.warning("[%s] contract rejected: incomplete declarations", request_id) + raise HTTPException(status_code=400, detail="Please confirm all declarations before signing.") + + signature = _trimmed(data.signatureDataUrl) + if not signature.startswith("data:image/png;base64,") or len(signature) < 128: + logger.warning("[%s] contract rejected: invalid signature payload", request_id) + raise HTTPException(status_code=400, detail="Please add your signature before sending.") + + +def _normalize_contract_submission(data: ContractSubmission) -> None: + data.fullName = _trimmed(data.fullName) + data.phone = _trimmed(data.phone) + data.address = _trimmed(data.address) + data.dogName = _trimmed(data.dogName) + data.dogBreed = _trimmed(data.dogBreed) + data.dogAge = _trimmed(data.dogAge) + data.serviceType = _trimmed(data.serviceType) + data.startDate = _trimmed(data.startDate) + data.walkFrequency = _trimmed(data.walkFrequency) + data.additionalNotes = _trimmed(data.additionalNotes) + data.referrer = _trimmed(data.referrer) + data.page = _trimmed(data.page) + + 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) + + +def owner_contract_email(data: ContractSubmission, ip: str, browser: str) -> str: + submitted_at = datetime.now().strftime("%d %b %Y at %I:%M %p").lstrip("0") + visit_time_row = _meta_row("Time on site", _duration_between(data.visitStartedAt, data.sendClickedAt)) + form_time_row = _meta_row("Form open time", _duration_between(data.formStartedAt, data.sendClickedAt)) + 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 "" + + notes_block = f""" + + +
Additional notes
+
{data.additionalNotes}
+ + """ if data.additionalNotes else "" + + signature_block = f""" +
+
Captured signature
+ Client signature +
""" + + badge = f"""
+ + 📜  New signed contract + +
+
+ Submitted {submitted_at} +
""" + + return f""" + + + + + New GoodWalk service contract + + + + +
+ + + {_logo_header(badge_html=badge, subtitle="Signed service agreement")} + + + + +
+ + + + +
+
+ Quick contact +
+ + Call {data.phone} +
+ +
Client details
+ + +
+ + {_detail_row("Name", data.fullName)} + {_detail_row("Email", str(data.email))} + {_detail_row("Phone", data.phone)} + {_detail_row("Address", data.address)} +
+
+ +
Service agreement
+ + +
+ + {_detail_row("Dog", data.dogName)} + {_detail_row("Breed", data.dogBreed)} + {_detail_row("Age", data.dogAge or "—")} + {_detail_row("Service", data.serviceType)} + {_detail_row("Start date", data.startDate)} + {_detail_row("Frequency", data.walkFrequency or "—")} + {notes_block} +
+
+ +
Declarations confirmed
+ + +
+ + {_detail_row("Service terms", "Confirmed")} + {_detail_row("Cancellation policy", "Confirmed")} + {_detail_row("Payment terms", "Confirmed")} + {_detail_row("Emergency consent", "Confirmed")} + {_detail_row("Liability terms", "Confirmed")} + {_detail_row("Accuracy declaration", "Confirmed")} +
+ {signature_block} +
+ + + +
+
Session info
+ + {_meta_row("IP address", ip)} + {_meta_row("Browser", browser)} + {visit_time_row} + {form_time_row} + {referrer_row} + {page_row} +
+
+
+
+ +""" + + +@app.post("/onboarding-submit") +async def submit_onboarding(data: OnboardingSubmission, request: Request): + request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8]) + ip = _get_ip(request) + browser = _parse_ua(request.headers.get("user-agent", "")) + + await _enforce_submit_rate_limits(request_id, ip, str(data.email)) + _enforce_form_timing(request_id, data) + + if _is_honeypot_triggered(data): + logger.warning( + "[%s] onboarding honeypot triggered for ip=%s email=%s page=%r", + request_id, + ip, + data.email, + data.page, + ) + return { + "ok": True, + "request_id": request_id, + "ignored": True, + } + + _validate_onboarding_submission(request_id, data) + _normalize_onboarding_submission(data) + + logger.info( + "[%s] /onboarding-submit: email=%s ip=%s browser=%r dog=%s services=%s page=%r", + request_id, data.email, ip, browser, data.dogName, data.servicesNeeded, data.page, + ) + logger.debug("[%s] onboarding payload: %s", request_id, data.model_dump()) + + owner_payload = { + "from": FROM_EMAIL, + "to": [OWNER_EMAIL], + "reply_to": data.email, + "subject": f"New GoodWalk onboarding — {data.fullName} ({data.dogName})", + "html": owner_onboarding_email(data, ip, browser), + } + if OWNER_BCC: + owner_payload["bcc"] = [OWNER_BCC] + + try: + await _send_email( + owner_payload, + label="owner_onboarding_email", + request_id=request_id, + ) + except Exception as exc: + logger.error("[%s] onboarding email failed after retries: %s", request_id, exc, exc_info=True) + raise HTTPException( + status_code=502, + detail={ + "request_id": request_id, + "message": "The onboarding form could not be delivered. Please try again shortly.", + "error_type": type(exc).__name__, + }, + ) + + await _register_email(str(data.email)) + await _store_client_profile(str(data.email), { + "fullName": data.fullName, + "phone": data.phone, + "address": data.address, + "dogName": data.dogName, + "dogBreed": data.dogBreed, + "dogAge": data.dogAge, + "onboardingCompleted": True, + }) + + return { + "ok": True, + "request_id": request_id, + } + + +@app.post("/contract-submit") +async def submit_contract(data: ContractSubmission, request: Request): + request_id = getattr(request.state, "request_id", uuid.uuid4().hex[:8]) + ip = _get_ip(request) + browser = _parse_ua(request.headers.get("user-agent", "")) + + await _enforce_submit_rate_limits(request_id, ip, str(data.email)) + _enforce_form_timing(request_id, data) + + if _is_honeypot_triggered(data): + logger.warning( + "[%s] contract honeypot triggered for ip=%s email=%s page=%r", + request_id, ip, data.email, data.page, + ) + return {"ok": True, "request_id": request_id, "ignored": True} + + _validate_contract_submission(request_id, data) + _normalize_contract_submission(data) + + logger.info( + "[%s] /contract-submit: email=%s ip=%s browser=%r dog=%s service=%s page=%r", + request_id, data.email, ip, browser, data.dogName, data.serviceType, data.page, + ) + + owner_payload = { + "from": FROM_EMAIL, + "to": [OWNER_EMAIL], + "reply_to": data.email, + "subject": f"New GoodWalk contract — {data.fullName} ({data.dogName}, {data.serviceType})", + "html": owner_contract_email(data, ip, browser), + } + if OWNER_BCC: + owner_payload["bcc"] = [OWNER_BCC] + + try: + await _send_email(owner_payload, label="owner_contract_email", request_id=request_id) + except Exception as exc: + logger.error("[%s] contract email failed after retries: %s", request_id, exc, exc_info=True) + raise HTTPException( + status_code=502, + detail={ + "request_id": request_id, + "message": "The contract could not be delivered. Please try again shortly.", + "error_type": type(exc).__name__, + }, + ) + + await _register_email(str(data.email)) + await _store_client_profile(str(data.email), { + "fullName": data.fullName, + "phone": data.phone, + "address": data.address, + "dogName": data.dogName, + "dogBreed": data.dogBreed, + "dogAge": data.dogAge, + "contractCompleted": True, + }) + + return {"ok": True, "request_id": request_id} diff --git a/nginx/goodwalk.co.nz.svelte.conf.example b/nginx/goodwalk.co.nz.svelte.conf.example index 98fc5d5..58f1599 100644 --- a/nginx/goodwalk.co.nz.svelte.conf.example +++ b/nginx/goodwalk.co.nz.svelte.conf.example @@ -14,6 +14,20 @@ server { } } +server { + listen 80; + server_name onboarding.goodwalk.co.nz; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + try_files $uri =404; + } + + location / { + return 301 https://onboarding.goodwalk.co.nz$request_uri; + } +} + server { listen 443 ssl; server_name goodwalk.co.nz; @@ -116,3 +130,66 @@ server { proxy_set_header Connection "upgrade"; } } + +server { + listen 443 ssl; + server_name onboarding.goodwalk.co.nz; + + ssl_certificate /etc/letsencrypt/live/onboarding.goodwalk.co.nz/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/onboarding.goodwalk.co.nz/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options SAMEORIGIN always; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss image/svg+xml; + + resolver 127.0.0.11 ipv6=off valid=30s; + + location ~* /\.(git|env|htaccess) { + deny all; + } + + location /api/onboarding-submit { + if (-f /etc/nginx/conf.d/maintenance.flag) { + return 503; + } + + set $goodwalk_mail_api goodwalk_svelte_mail_api:8000; + limit_req zone=goodwalk_limit burst=10 nodelay; + proxy_pass http://$goodwalk_mail_api/onboarding-submit; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + if (-f /etc/nginx/conf.d/maintenance.flag) { + return 503; + } + + set $goodwalk_frontend goodwalk_svelte_app:3000; + proxy_pass http://$goodwalk_frontend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 371ee75..b43f292 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -12,6 +12,19 @@ server { } } +server { + listen 80; + server_name onboarding.goodwalk.co.nz; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + server { listen 443 ssl; server_name goodwalk.co.nz www.goodwalk.co.nz; @@ -49,3 +62,41 @@ server { proxy_set_header Connection "upgrade"; } } + +server { + listen 443 ssl; + server_name onboarding.goodwalk.co.nz; + + ssl_certificate /etc/letsencrypt/live/onboarding.goodwalk.co.nz/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/onboarding.goodwalk.co.nz/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options SAMEORIGIN always; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + + gzip on; + gzip_types text/plain text/css application/javascript application/json image/svg+xml; + + location /api/onboarding-submit { + proxy_pass http://mail-api:8000/onboarding-submit; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + proxy_pass http://app:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} diff --git a/package-lock.json b/package-lock.json index c357b87..da7da0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,8 +13,9 @@ }, "devDependencies": { "@sveltejs/adapter-node": "^5.2.11", + "@sveltejs/enhanced-img": "^0.10.4", "@sveltejs/kit": "^2.59.0", - "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@sveltejs/vite-plugin-svelte": "^6.2.4", "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.3.1", "@types/canvas-confetti": "^1.9.0", @@ -274,6 +275,17 @@ "node": ">=20.19.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -734,6 +746,496 @@ } } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1270,6 +1772,25 @@ "@sveltejs/kit": "^2.4.0" } }, + "node_modules/@sveltejs/enhanced-img": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@sveltejs/enhanced-img/-/enhanced-img-0.10.4.tgz", + "integrity": "sha512-Am5nmAKUo7Nboqq7Dhtfn7dcXA087d7gIz6Vecn1opB41aJ680+0q9U9KvEcMgduOyeiwckTIOQOx4Mmq9GcvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "sharp": "^0.34.1", + "svelte-parse-markup": "^0.1.5", + "vite-imagetools": "^9.0.3", + "zimmerframe": "^1.1.2" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0 || ^7.0.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || >=7.0.0" + } + }, "node_modules/@sveltejs/kit": { "version": "2.59.0", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.59.0.tgz", @@ -1313,43 +1834,42 @@ } }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", - "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", "dev": true, "license": "MIT", "dependencies": { - "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", - "debug": "^4.4.1", + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", - "kleur": "^4.1.5", - "magic-string": "^0.30.17", - "vitefu": "^1.0.6" + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22" + "node": "^20.19 || ^22.12 || >=24" }, "peerDependencies": { "svelte": "^5.0.0", - "vite": "^6.0.0" + "vite": "^6.3.0 || ^7.0.0" } }, "node_modules/@sveltejs/vite-plugin-svelte-inspector": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", - "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.3.7" + "obug": "^2.1.0" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22" + "node": "^20.19 || ^22.12 || >=24" }, "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", - "vite": "^6.0.0" + "vite": "^6.3.0 || ^7.0.0" } }, "node_modules/@testing-library/dom": { @@ -1835,24 +2355,6 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -1880,6 +2382,16 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/devalue": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz", @@ -2077,6 +2589,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/imagetools-core": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/imagetools-core/-/imagetools-core-9.1.0.tgz", + "integrity": "sha512-xQjs+2vrxLnAjCq+omuNkd5UQTld9/bP8+YT0LyYTlKfuSQtgUBvqhUwGugzSAh6sCdN+LnROMuLswn5hZ9Fhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -2259,13 +2781,6 @@ "node": ">=10" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -2663,6 +3178,19 @@ "node": ">=v12.22.7" } }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/set-cookie-parser": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", @@ -2670,6 +3198,51 @@ "dev": true, "license": "MIT" }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2803,6 +3376,19 @@ "typescript": ">=5.0.0" } }, + "node_modules/svelte-parse-markup": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/svelte-parse-markup/-/svelte-parse-markup-0.1.5.tgz", + "integrity": "sha512-T6mqZrySltPCDwfKXWQ6zehipVLk4GWfH1zCMGgRtLlOIFPuw58ZxVYxVvotMJgJaurKi1i14viB2GIRKXeJTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://bjornlu.com/sponsor" + }, + "peerDependencies": { + "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.1" + } + }, "node_modules/svelte/node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -2920,6 +3506,14 @@ "node": ">=20" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3026,6 +3620,21 @@ } } }, + "node_modules/vite-imagetools": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/vite-imagetools/-/vite-imagetools-9.0.3.tgz", + "integrity": "sha512-FwjApRNZyN+RucPW9Z9kf0dyzyi3r3zlDfrTnzHXNaYpmT3pZ5w//d6QkApy1iypbDm+3fq+Gwfv+PYA4j4uYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.5", + "imagetools-core": "^9.1.0", + "sharp": "^0.34.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/vitefu": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", diff --git a/package.json b/package.json index f6c3cd3..ffc371a 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,9 @@ }, "devDependencies": { "@sveltejs/adapter-node": "^5.2.11", + "@sveltejs/enhanced-img": "^0.10.4", "@sveltejs/kit": "^2.59.0", - "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@sveltejs/vite-plugin-svelte": "^6.2.4", "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.3.1", "@types/canvas-confetti": "^1.9.0", diff --git a/src/lib/actions/accordion.ts b/src/lib/actions/accordion.ts new file mode 100644 index 0000000..bf22abe --- /dev/null +++ b/src/lib/actions/accordion.ts @@ -0,0 +1,25 @@ +export function accordion(node: HTMLElement) { + function handleToggle(event: Event) { + const target = event.target; + + if (!(target instanceof HTMLDetailsElement) || !target.open || !node.contains(target)) { + return; + } + + const details = node.querySelectorAll('details'); + + for (const item of details) { + if (item !== target) { + item.open = false; + } + } + } + + node.addEventListener('toggle', handleToggle, true); + + return { + destroy() { + node.removeEventListener('toggle', handleToggle, true); + } + }; +} diff --git a/src/lib/actions/reveal.test.ts b/src/lib/actions/reveal.test.ts new file mode 100644 index 0000000..02f138f --- /dev/null +++ b/src/lib/actions/reveal.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { reveal } from './reveal'; + +class TestIntersectionObserver { + static instances: TestIntersectionObserver[] = []; + + callback: IntersectionObserverCallback; + disconnect = vi.fn(); + observe = vi.fn(); + unobserve = vi.fn(); + + constructor(callback: IntersectionObserverCallback) { + this.callback = callback; + TestIntersectionObserver.instances.push(this); + } + + trigger(target: Element, isIntersecting: boolean) { + this.callback( + [{ isIntersecting, target } as IntersectionObserverEntry], + this as unknown as IntersectionObserver + ); + } +} + +describe('reveal action', () => { + afterEach(() => { + TestIntersectionObserver.instances = []; + vi.unstubAllGlobals(); + }); + + it('toggles visibility as the element enters and leaves the viewport', () => { + vi.stubGlobal('IntersectionObserver', TestIntersectionObserver); + vi.spyOn(window, 'matchMedia').mockReturnValue({ + matches: false, + media: '(prefers-reduced-motion: reduce)', + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + } as MediaQueryList); + + const node = document.createElement('div'); + document.body.appendChild(node); + vi.spyOn(node, 'getBoundingClientRect').mockReturnValue({ + width: 100, + height: 100, + top: window.innerHeight + 100, + right: 100, + bottom: window.innerHeight + 200, + left: 0, + x: 0, + y: window.innerHeight + 100, + toJSON() { + return {}; + } + } as DOMRect); + + const action = reveal(node, { delay: 40, distance: 32 }); + const observer = TestIntersectionObserver.instances[0]; + + expect(node.classList.contains('reveal-ready')).toBe(true); + expect(node.classList.contains('reveal-visible')).toBe(false); + expect(node.style.getPropertyValue('--reveal-delay')).toBe('40ms'); + expect(node.style.getPropertyValue('--reveal-distance')).toBe('32px'); + + observer.trigger(node, true); + expect(node.classList.contains('reveal-visible')).toBe(true); + + observer.trigger(node, false); + expect(node.classList.contains('reveal-visible')).toBe(false); + + action.destroy(); + expect(observer.disconnect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/actions/reveal.ts b/src/lib/actions/reveal.ts index a07aef3..2775fc3 100644 --- a/src/lib/actions/reveal.ts +++ b/src/lib/actions/reveal.ts @@ -47,13 +47,18 @@ export function reveal(node: HTMLElement, options: RevealOptions = {}) { const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { - if (!entry.isIntersecting) { + if (entry.isIntersecting) { + node.classList.add('reveal-visible'); continue; } - node.classList.add('reveal-visible'); - observer.disconnect(); - break; + const viewportHeight = window.innerHeight || document.documentElement.clientHeight; + const rect = entry.boundingClientRect; + const fullyOutOfView = rect.bottom <= 0 || rect.top >= viewportHeight; + + if (fullyOutOfView) { + node.classList.remove('reveal-visible'); + } } }, { diff --git a/src/lib/components/AboutPage.svelte b/src/lib/components/AboutPage.svelte index d98dfb2..e6b4bfc 100644 --- a/src/lib/components/AboutPage.svelte +++ b/src/lib/components/AboutPage.svelte @@ -1,68 +1,164 @@
+ +
+ About Goodwalk

{pageContent.title}

+

Small dog specialists serving Auckland Central. A team your dog knows by name.

+
+ + + 30+ five-star Google reviews + + Auckland Central + Small dog specialists +
- {#each pageContent.sections as section} + + {#each standardSections as section} + {@const enhanced = getEnhancedImage(section.imageUrl)}
-
+
+ {#if section.eyebrow} + {section.eyebrow} + {/if}

{section.title}

{#each section.body as paragraph}

{paragraph}

{/each}
-
- {section.imageAlt} + {#if enhanced} + + {:else} + {section.imageAlt} + {/if}
{/each} - + + {#if founderSection} + {@const founderEnhanced = getEnhancedImage(founderSection.imageUrl)} +
+
+
+ {#if founderEnhanced} + + {:else} + {founderSection.imageAlt} + {/if} +
+
+ {#if founderSection.eyebrow} + {founderSection.eyebrow} + {/if} +

+ + {founderHeadingLead} +
+ {founderHeadingHighlight} +
+ + {founderHeadingLead} + {founderHeadingHighlight} + +

+ {#each founderSection.body as paragraph} +

{paragraph}

+ {/each} + Book a free Meet & Greet +
+
+
+ {/if} -
+ + {#if pageContent.faqs && pageContent.faqs.length} +
+
+
+ FAQ +

{pageContent.faqTitle ?? 'Common questions'}

+
+
+ {#each pageContent.faqs as item} +
+ {item.question} +

{item.answer}

+
+ {/each} +
+
+
+ {/if} + + +
+ Get in touch

{pageContent.contact.title}

-
+

Questions, pricing, or your first Meet & Greet — start here and we'll reply within 24 hours.

+ + {pageContent.contact.cta.label} + +
+
diff --git a/src/lib/components/BookingPage.svelte b/src/lib/components/BookingPage.svelte index 8aed747..bfa4cd1 100644 --- a/src/lib/components/BookingPage.svelte +++ b/src/lib/components/BookingPage.svelte @@ -91,8 +91,11 @@ border-radius: 999px; background: rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.18); + font-family: var(--font-head); font-size: 14px; - font-weight: 600; + font-weight: 700; + line-height: 1.2; + letter-spacing: 0.01em; color: #fff; transition: background 0.2s; } diff --git a/src/lib/components/BookingSection.svelte b/src/lib/components/BookingSection.svelte index c311962..5cf0a46 100644 --- a/src/lib/components/BookingSection.svelte +++ b/src/lib/components/BookingSection.svelte @@ -249,11 +249,27 @@ } } + function sortSelectedServices(services: string[]) { + return [...services].sort((a, b) => { + const indexA = booking.serviceOptions.indexOf(a); + const indexB = booking.serviceOptions.indexOf(b); + + if (indexA === -1 && indexB === -1) return a.localeCompare(b); + if (indexA === -1) return 1; + if (indexB === -1) return -1; + + return indexA - indexB; + }); + } + function toggleService(service: string, checked: boolean) { noteInteraction(); if (checked) { - selectedServices = [service, ...selectedServices.filter((item) => item !== service)]; + selectedServices = sortSelectedServices([ + ...selectedServices.filter((item) => item !== service), + service + ]); return; } diff --git a/src/lib/components/BookingSection.test.ts b/src/lib/components/BookingSection.test.ts index 80d17b3..60434a5 100644 --- a/src/lib/components/BookingSection.test.ts +++ b/src/lib/components/BookingSection.test.ts @@ -24,7 +24,7 @@ async function fillDogStep() { await fireEvent.input(screen.getByLabelText(/Location/i), { target: { value: 'Kingsland' } }); - await fireEvent.input(screen.getByLabelText(/About Your Dog/i), { + await fireEvent.input(screen.getByLabelText(/Pack Walks fit/i), { target: { value: 'Loves small group walks.' } }); } diff --git a/src/lib/components/ContractPage.svelte b/src/lib/components/ContractPage.svelte new file mode 100644 index 0000000..cb42a32 --- /dev/null +++ b/src/lib/components/ContractPage.svelte @@ -0,0 +1,1800 @@ + + + + Goodwalk Service Agreement + + + + + +
+
+
+ +
+ {#if preview} + Preview + {/if} + + + {ownerPhone} + +
+
+
+ + {#if authChecking} +
+ +
+ {:else if !isAuthenticated} + + {:else} + +
+
+ + + {#if onboardingCompleted} + + {:else} + + {/if} + + Onboarding form + {#if onboardingCompleted} + Done + {:else} + To do + {/if} + + +
+ +
+ + {#if contractCompleted} + + {:else} + + {/if} + + Service contract + {#if contractCompleted} + Signed + {:else} + In progress + {/if} +
+
+
+ + {#if !onboardingCompleted} +
+
+ +
+ You haven't completed your onboarding form yet. + Aless will need this before your service can start. + Complete it now → +
+
+
+ {/if} + + {#if idleWarning} + + {/if} + +
+
+
+ Service agreement +

Review the terms, fill in your details, and sign to get started.

+

+ This is Goodwalk's standard service agreement. Read through the terms, confirm each + section, and sign at the end. Your signed copy goes directly to Aless. +

+
+ +
+
Questions about the contract?
+ + + {ownerEmail} + + + + {ownerPhone} + +
+
+
+ +
+
+ {#if submitted} +
+ Signed +

Thank you. Your signed agreement is with us.

+

+ Aless has received your signed service agreement. We'll be in touch to confirm + your start date and any final details. +

+
+ {:else} + +
+ {#each steps as step, i} + + {#if i < steps.length - 1} +
i + 1}>
+ {/if} + {/each} +
+ +
+ + + {#if currentStep === 1} +
+
+
+ +
+
+

{steps[0].title}

+

{steps[0].desc}

+
+
+ +
+ + We've pre-filled this from your contact form. Please check everything looks right. +
+ +
+ + + + + + + +
+ +
+ + Dog details +
+ +
+ + + + + +
+ +
+ +
+
+ {/if} + + + {#if currentStep === 2} +
+
+
+ +
+
+

{steps[1].title}

+

{steps[1].desc}

+
+
+ +
+ Service type +
+ {#each services as service} + + {/each} +
+ {#if errors.serviceType}{errors.serviceType}{/if} +
+ +
+ + + +
+ + + +
+ + +
+
+ {/if} + + + {#if currentStep === 3} +
+
+
+ +
+
+

{steps[2].title}

+

{steps[2].desc}

+
+
+ +
+
+

Services

+

Goodwalk provides dog walking and pet care services as agreed. All dogs must be assessed for group compatibility before joining pack walks. Goodwalk reserves the right to decline or discontinue service if a dog poses a risk to others.

+
+ +
+

Cancellation policy

+

Cancellations must be made at least 24 hours in advance. Late cancellations (under 24 hours) will be charged at the full rate. No-shows will be charged in full. Goodwalk will provide as much notice as possible if a walk cannot proceed due to illness or an emergency.

+
+ +
+

Payment

+

Invoices are issued weekly and are due within 7 days of issue. Unpaid invoices may result in service being suspended. Payment is accepted by bank transfer to the details provided on the invoice.

+
+ +
+

Emergency care

+

In the event of a medical emergency and if the owner cannot be reached, Goodwalk is authorised to seek urgent veterinary treatment. Any costs incurred will be the responsibility of the owner.

+
+ +
+

Liability

+

Goodwalk takes every reasonable precaution for the safety of your dog. However, Goodwalk cannot be held liable for injury, illness, loss, or death caused by circumstances beyond our reasonable control, including interaction with other animals or environmental hazards. The owner confirms their dog is covered by appropriate insurance where applicable.

+
+ +
+

Privacy

+

Your personal information is collected solely for the purpose of providing and administering the service. It will not be shared with third parties without your consent, except where required by law.

+
+
+ +
+ + +
+
+ {/if} + + + {#if currentStep === 4} +
+
+
+ +
+
+

{steps[3].title}

+

{steps[3].desc}

+
+
+ +
+ + {#if errors.agreeServiceTerms}{errors.agreeServiceTerms}{/if} + + + {#if errors.agreeCancellation}{errors.agreeCancellation}{/if} + + + {#if errors.agreePayment}{errors.agreePayment}{/if} + + + {#if errors.agreeEmergency}{errors.agreeEmergency}{/if} + + + {#if errors.agreeLiability}{errors.agreeLiability}{/if} + + + {#if errors.agreeAccuracy}{errors.agreeAccuracy}{/if} +
+ +
+
+
+ Signature +

Draw your signature below.

+
+ +
+ +
+ +
+ {#if errors.signature}{errors.signature}{/if} +
+ + + + {#if submitError} +
{submitError}
+ {/if} + +
+ +
+ +

This goes directly to Aless for review.

+
+
+
+ {/if} + +
+ {/if} +
+
+ + + + {/if} +
+ + diff --git a/src/lib/components/Footer.svelte b/src/lib/components/Footer.svelte index 43854be..eb8554c 100644 --- a/src/lib/components/Footer.svelte +++ b/src/lib/components/Footer.svelte @@ -1,9 +1,11 @@
-
-
+
+ +
+ Getting started

{content.title}

{#if content.intro} -

{content.intro}

+

{content.intro}

{/if}
-
+
{#each content.steps as step, index} -
-
+

{step.title}

+

{step.body}

+ {#if step.benefit} + + + {step.benefit} + + {/if} +
{/each}
+ +
+ Book your free Meet & Greet +

Free, no-obligation. We reply within 24 hours.

+
+
diff --git a/src/lib/components/InfoSection.svelte b/src/lib/components/InfoSection.svelte index 565fbae..d55cd14 100644 --- a/src/lib/components/InfoSection.svelte +++ b/src/lib/components/InfoSection.svelte @@ -1,4 +1,5 @@ diff --git a/src/lib/components/LegalPage.svelte b/src/lib/components/LegalPage.svelte index 42a958e..b25aeca 100644 --- a/src/lib/components/LegalPage.svelte +++ b/src/lib/components/LegalPage.svelte @@ -104,8 +104,10 @@ border-left: 3px solid var(--gw-green); font-family: var(--font-head); font-size: clamp(14px, 1.4vw, 17px); - line-height: 1.3; - letter-spacing: -0.01em; + font-weight: 700; + line-height: 1.08; + letter-spacing: -0.02em; + text-wrap: balance; color: #000; } diff --git a/src/lib/components/LocationPage.svelte b/src/lib/components/LocationPage.svelte new file mode 100644 index 0000000..e6173ed --- /dev/null +++ b/src/lib/components/LocationPage.svelte @@ -0,0 +1,863 @@ + + +
+ + +
+
+ Auckland Central Dog Walking +

Dog walkers in {location.suburb}

+

{location.intro}

+ +
+ + + 30+ five-star Google reviews + + Small dog specialists + Free pickup & drop-off +
+
+
+ +
+
+
+ {#each locationHighlights as highlight} +
+
+
+ +
+ {highlight.label} +
+ {highlight.value} +

{highlight.detail}

+
+ {/each} +
+
+
+ + +
+
+
+ Where we walk +

Parks & walks in {location.suburb}

+

+ These are the parks and routes we know well in {location.suburb}. Every walk is planned around your dog's pace, size, and temperament — not just the nearest green space. +

+
+
+ {#each location.parks as park} +
+ +

{park.name}

+

{park.description}

+ {#if park.leashNote} + {park.leashNote} + {/if} +
+ {/each} +
+
+
+ + {#if parksWithImages.length > 0} + + {/if} + + +
+
+
+ What we offer +

Goodwalk services in {location.suburb}

+

+ We offer pack walks, 1:1 walks, and puppy visits in {location.suburb}, with free pickup and drop-off across the central suburbs. Every service starts with a free Meet & Greet so we can understand your dog and recommend the right fit. +

+
+
+ {#each serviceLinks as svc} + +
+ +
+

{svc.label}

+

{svc.desc}

+ Learn more → +
+ {/each} +
+
+
+ + + {#if featuredTestimonial} +
+
+
+ +
"{featuredTestimonial.quote}"
+ + {featuredTestimonial.reviewer} + {#if featuredTestimonial.detail} + — {featuredTestimonial.detail} + {/if} + +
+
+
+ {/if} + + +
+
+
+ Get in touch +

Ready to get started in {location.suburb}?

+

+ A free Meet & Greet is the first step — no commitment, no pressure. We meet your dog, answer your questions, and see if Goodwalk is the right fit. +

+ Book a free Meet & Greet + +
+
+
+ +
+ + diff --git a/src/lib/components/MobileBookBar.svelte b/src/lib/components/MobileBookBar.svelte index 731bd73..316229f 100644 --- a/src/lib/components/MobileBookBar.svelte +++ b/src/lib/components/MobileBookBar.svelte @@ -24,7 +24,11 @@ const mobileCtaButtonEnabled = isMobileCtaButtonEnabled(); $: pathname = $page.url.pathname; - $: hidden = pathname === '/contact-us' || pathname === '/booking'; + $: hidden = + pathname === '/contact-us' || + pathname === '/booking' || + $page.url.hostname === 'onboarding.goodwalk.co.nz' || + $page.url.searchParams.get('preview') === 'onboarding'; let visible = false; let triggerPassed = false; diff --git a/src/lib/components/OnboardingAuth.svelte b/src/lib/components/OnboardingAuth.svelte new file mode 100644 index 0000000..619c065 --- /dev/null +++ b/src/lib/components/OnboardingAuth.svelte @@ -0,0 +1,363 @@ + + +
+
+
+ +
+ + {#if stage === 'email'} +

Sign in to continue

+

Enter the email address you used when enquiring with Goodwalk. We'll send you a one-time code.

+ +
+ + +
+ + {#if error} +
{error}
+ {/if} + + + + {:else} +

Enter your code

+

We sent a 6-digit code to {emailValue}. It expires in 10 minutes.

+ +
+ + +
+ + {#if error} +
{error}
+ {/if} + + + + + {/if} + +
+ Need help? + {ownerEmail} + or + {ownerPhone} +
+
+
+ +
+ goodwalk.co.nz + · + © {new Date().getFullYear()} Goodwalk. All rights reserved. +
+ + diff --git a/src/lib/components/OnboardingFooter.svelte b/src/lib/components/OnboardingFooter.svelte new file mode 100644 index 0000000..8fcfac6 --- /dev/null +++ b/src/lib/components/OnboardingFooter.svelte @@ -0,0 +1,133 @@ + + +
+ + +
+ + diff --git a/src/lib/components/OnboardingPage.svelte b/src/lib/components/OnboardingPage.svelte new file mode 100644 index 0000000..247cca8 --- /dev/null +++ b/src/lib/components/OnboardingPage.svelte @@ -0,0 +1,1661 @@ + + + + Goodwalk Onboarding + + + + + +
+
+
+ +
+ {#if preview} + Preview + {/if} + + + {ownerPhone} + +
+
+
+ + {#if authChecking} +
+ +
+ {:else if !isAuthenticated} + + {:else} + +
+
+
+ + {#if onboardingCompleted} + + {:else} + + {/if} + + Onboarding form + {#if onboardingCompleted} + Done + {:else} + In progress + {/if} +
+ +
+ + + + {#if contractCompleted} + + {:else} + + {/if} + + Service contract + {#if contractCompleted} + Signed + {:else} + To do + {/if} + +
+
+ + {#if idleWarning} + + {/if} + +
+
+
+ Client onboarding +

Tell us about your dog, sign the form, and we’ll take it from there.

+

+ This gives us the details we need before your dog starts with Goodwalk. Once it is + submitted, Aless receives it directly for review. +

+
+ +
+
Questions before you send it?
+ + + {ownerEmail} + + + + {ownerPhone} + +
+
+
+ +
+
+ {#if submitted} +
+ Submitted +

Thanks. Your onboarding form is with us.

+

+ Aless has received your signed onboarding form and will review it directly. If + anything else is needed, we’ll come back to you using the contact details you + provided. +

+
+ {:else} + +
+ {#each steps as step, i} + + {#if i < steps.length - 1} +
i + 1}>
+ {/if} + {/each} +
+ +
+ + + {#if currentStep === 1} +
+
+
+ +
+
+

{steps[0].title}

+

{steps[0].desc}

+
+
+ +
+ + + + + + + +
+ +
+ +
+
+ {/if} + + + {#if currentStep === 2} +
+
+
+ +
+
+

{steps[1].title}

+

{steps[1].desc}

+
+
+ +
+ + + + + +
+ +
+ Which service are you onboarding for? +
+ {#each services as service} + + {/each} +
+ {#if errors.servicesNeeded}{errors.servicesNeeded}{/if} +
+ + + + + +
+ + +
+
+ {/if} + + + {#if currentStep === 3} +
+
+
+ +
+
+

{steps[2].title}

+

{steps[2].desc}

+
+
+ +
+ + + + + + + +
+ + + +
+ + +
+
+ {/if} + + + {#if currentStep === 4} +
+
+
+ +
+
+

{steps[3].title}

+

{steps[3].desc}

+
+
+ +
+ + {#if errors.councilRegistrationConfirmed}{errors.councilRegistrationConfirmed}{/if} + + + {#if errors.vaccinationsConfirmed}{errors.vaccinationsConfirmed}{/if} + + + {#if errors.emergencyVetConsent}{errors.emergencyVetConsent}{/if} + + + {#if errors.termsAccepted}{errors.termsAccepted}{/if} +
+ +
+
+
+ Signature +

Draw your signature below.

+
+ +
+ +
+ +
+ {#if errors.signature}{errors.signature}{/if} +
+ + + + {#if submitError} +
{submitError}
+ {/if} + +
+ +
+ +

This goes directly to Aless for review.

+
+
+
+ {/if} + +
+ {/if} +
+
+ + + + {/if} +
+ + diff --git a/src/lib/components/OnboardingSignaturePad.svelte b/src/lib/components/OnboardingSignaturePad.svelte new file mode 100644 index 0000000..efe9ece --- /dev/null +++ b/src/lib/components/OnboardingSignaturePad.svelte @@ -0,0 +1,199 @@ + + +
+ + {#if !value} + + {/if} +
+ + diff --git a/src/lib/components/PricingPage.svelte b/src/lib/components/PricingPage.svelte index e202767..4cf79e2 100644 --- a/src/lib/components/PricingPage.svelte +++ b/src/lib/components/PricingPage.svelte @@ -192,7 +192,7 @@ {/each} - + Book a Meet & Greet @@ -205,7 +205,7 @@

Book a free Meet & Greet and we’ll help you choose the right walk or visit for your dog.

- + Talk it through with us @@ -214,7 +214,11 @@ {/each} - + {#if showMeetGreetPrompt} @@ -281,8 +285,11 @@ background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.18); color: #fff; + font-family: var(--font-head); font-size: 14px; - font-weight: 600; + font-weight: 700; + line-height: 1.2; + letter-spacing: 0.01em; text-decoration: none; transition: background 0.2s ease, @@ -308,10 +315,6 @@ font-size: 13px; } - .pricing-trust-label { - letter-spacing: 0.01em; - } - :global(.pricing-trust .pricing-trust-arrow) { font-size: 12px; opacity: 0.85; @@ -322,8 +325,10 @@ text-align: center; font-family: var(--font-head); font-size: clamp(24px, 2.8vw, 36px); - line-height: 1.1; + font-weight: 700; + line-height: 1.08; letter-spacing: -0.03em; + text-wrap: balance; color: #000; } @@ -658,7 +663,7 @@ gap: 18px; } - .pricing-plan-popular { + .pricing-plan-card { order: var(--mobile-order, 0); } diff --git a/src/lib/components/PromiseSection.svelte b/src/lib/components/PromiseSection.svelte index e0861ec..0abd04c 100644 --- a/src/lib/components/PromiseSection.svelte +++ b/src/lib/components/PromiseSection.svelte @@ -1,18 +1,25 @@
-

- {promise.title}
- {promise.subtitle} +

+ + {promise.title} +
+ {promise.subtitle} +
+ + {promise.title} + {promise.subtitle} +

{#each promise.body as paragraph, idx} @@ -29,15 +36,88 @@
- {promise.imageAlt} + {#if promiseEnhanced} + + {:else} + {promise.imageAlt} + {/if}
+ + diff --git a/src/lib/components/ServiceLandingPage.svelte b/src/lib/components/ServiceLandingPage.svelte index 69a3266..c4ed39d 100644 --- a/src/lib/components/ServiceLandingPage.svelte +++ b/src/lib/components/ServiceLandingPage.svelte @@ -3,7 +3,7 @@ import { reveal } from '$lib/actions/reveal'; import BookingSection from '$lib/components/BookingSection.svelte'; import TestimonialsSection from '$lib/components/TestimonialsSection.svelte'; - import { getImageMetadata } from '$lib/image-metadata'; + import { getEnhancedImage } from '$lib/enhanced-images'; import type { ServicePageContent, SiteSharedContent } from '$lib/types'; export let content: SiteSharedContent; @@ -30,12 +30,12 @@ })); } - $: heroImage = getImageMetadata(pageContent.hero.imageUrl); - $: highlightImage = pageContent.highlight ? getImageMetadata(pageContent.highlight.imageUrl) : null; + $: heroEnhanced = getEnhancedImage(pageContent.hero.imageUrl); + $: highlightEnhanced = pageContent.highlight ? getEnhancedImage(pageContent.highlight.imageUrl) : null; $: highlightCollageImages = pageContent.highlight?.collageImages?.map((image) => ({ ...image, - meta: getImageMetadata(image.imageUrl) + enhanced: getEnhancedImage(image.imageUrl) })) ?? []; $: relatedServices = content.services.filter((s) => s.href && s.href !== currentPath); $: pricingPlans = decoratePlans(pageContent.pricing.plans); @@ -73,15 +73,23 @@
- {pageContent.hero.imageAlt} + {#if heroEnhanced} + + {:else} + {pageContent.hero.imageAlt} + {/if}
@@ -98,27 +106,31 @@
{#each highlightCollageImages as image, index}
- {image.imageAlt} + {#if image.enhanced} + + {:else} + {image.imageAlt} + {/if}
{/each}
{:else}
- {pageContent.highlight.imageAlt} + {#if highlightEnhanced} + + {:else} + {pageContent.highlight.imageAlt} + {/if}
{/if} @@ -172,7 +184,7 @@ Every booking starts with a free, no-obligation Meet & Greet.

- Book a Meet & Greet + Book a Meet & Greet {#if pageContent.pricing.extras?.length}
@@ -246,7 +258,11 @@ {/if} - + @@ -276,67 +292,26 @@ } .service-related-card { - position: relative; display: flex; flex-direction: column; - align-items: flex-start; - gap: 10px; - padding: 28px 26px; + align-items: center; + text-align: center; + gap: 0; + padding: 34px 28px 30px; border-radius: 28px; - background: #fff; - box-shadow: 0 14px 34px rgba(17, 20, 24, 0.05); + background: var(--off-white); + box-shadow: 0 10px 28px rgba(17, 20, 24, 0.05); color: #000; text-decoration: none; - overflow: hidden; transition: transform 0.18s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.22s ease; } - .service-related-card::before { - content: ''; - position: absolute; - inset: 0 0 auto 0; - height: 4px; - background: var(--card-accent, var(--yellow)); - } - - .service-related-tint-0 { - --card-accent: var(--yellow); - } - .service-related-tint-0 .service-related-icon { - background: #fff3c6; - color: #5a4500; - } - - .service-related-tint-1 { - --card-accent: var(--gw-green); - } - .service-related-tint-1 .service-related-icon { - background: #dce6dc; - color: var(--gw-green); - } - - .service-related-tint-2 { - --card-accent: #c98a3f; - } - .service-related-tint-2 .service-related-icon { - background: #efe4d1; - color: var(--gw-green); - } - - .service-related-tint-3 { - --card-accent: #9ca3af; - } - .service-related-tint-3 .service-related-icon { - background: #f3f4f6; - color: #4b5563; - } - @media (hover: hover) { .service-related-card:hover { transform: translateY(-6px) scale(1.012); - box-shadow: 0 20px 40px rgba(17, 20, 24, 0.09); + box-shadow: 0 18px 38px rgba(17, 20, 24, 0.1); } } @@ -348,19 +323,22 @@ display: inline-flex; align-items: center; justify-content: center; - width: 52px; - height: 52px; + width: 72px; + height: 72px; border-radius: 50%; - background: #efe4d1; + background: linear-gradient(180deg, #ffe173 0%, #ffd54a 100%); color: var(--gw-green); - font-size: 18px; - margin-bottom: 8px; + font-size: 28px; + margin-bottom: 22px; + box-shadow: + inset 0 0 0 1px rgba(17, 20, 24, 0.05), + 0 10px 24px rgba(17, 20, 24, 0.08); } .service-related-card h3 { - margin: 0; + margin: 0 0 10px; font-family: var(--font-head); - font-size: 22px; + font-size: 20px; line-height: 1.2; } @@ -368,37 +346,44 @@ margin: 0; color: #34363a; font-size: 15px; - line-height: 1.55; + line-height: 1.65; } .service-related-meta { display: flex; + justify-content: center; align-items: center; gap: 10px; flex-wrap: wrap; + margin-top: 16px; } .service-related-price { + display: inline-block; + padding: 6px 14px; + border-radius: 999px; + background: rgba(33, 48, 33, 0.06); font-family: var(--font-head); font-weight: 700; color: var(--gw-green); font-size: 14px; + letter-spacing: 0.02em; } .service-related-pill { display: inline-block; - padding: 4px 12px; + padding: 6px 14px; border-radius: 999px; - background: #f3f4f6; - color: #4b5563; + background: rgba(33, 48, 33, 0.06); + color: var(--gw-green); + font-family: var(--font-head); font-size: 12px; - font-weight: 600; + font-weight: 700; letter-spacing: 0.02em; } .service-related-link { - margin-top: auto; - padding-top: 6px; + margin-top: 18px; color: var(--gw-green); font-family: var(--font-head); font-weight: 700; @@ -418,9 +403,20 @@ margin: 0; font-family: var(--font-head); font-size: clamp(34px, 4vw, 56px); + color: #000; + } + + .service-hero-copy h1 { line-height: 1.05; letter-spacing: -0.04em; - color: #000; + } + + .service-section-heading h2, + .service-highlight-copy h2 { + font-weight: 700; + line-height: 1.08; + letter-spacing: -0.03em; + text-wrap: balance; } .service-hero-copy p, @@ -834,7 +830,7 @@ gap: 24px; } - .service-plan-popular { + .service-plan-card { order: var(--mobile-order, 0); } diff --git a/src/lib/components/ServicesSection.svelte b/src/lib/components/ServicesSection.svelte index 016ec44..596af79 100644 --- a/src/lib/components/ServicesSection.svelte +++ b/src/lib/components/ServicesSection.svelte @@ -148,4 +148,5 @@ text-underline-offset: 0.18em; } } + diff --git a/src/lib/components/TestimonialsSection.svelte b/src/lib/components/TestimonialsSection.svelte index 674a8c3..2b01d9b 100644 --- a/src/lib/components/TestimonialsSection.svelte +++ b/src/lib/components/TestimonialsSection.svelte @@ -2,7 +2,8 @@ import { onMount } from 'svelte'; import { reveal } from '$lib/actions/reveal'; import Icon from '$lib/components/Icon.svelte'; - import { getImageMetadata } from '$lib/image-metadata'; + import { getEnhancedImage } from '$lib/enhanced-images'; + import { getSeededTestimonialIndex } from '$lib/testimonials'; import type { TestimonialContent } from '$lib/types'; export let testimonials: TestimonialContent[]; @@ -11,6 +12,7 @@ export let blurb = 'Peace of mind for busy Auckland dog owners. Happier dogs, smoother routines, and a team owners trust with the important stuff.'; export let instagramHref = 'https://www.instagram.com/goodwalk.nz/'; export let instagramLabel = 'goodwalk.nz'; + export let seedKey = ''; type TestimonialSlide = TestimonialContent & { imageUrl: string }; @@ -50,6 +52,7 @@ let inView = false; let prefersReducedMotion = false; let carouselEl: HTMLDivElement | undefined; + let slideSignature = ''; $: slides = testimonials .map((testimonial) => wordpressTestimonials[testimonial.reviewer] ?? testimonial) @@ -59,6 +62,15 @@ activeIndex = 0; } + $: { + const nextSignature = `${seedKey}:${slides.map((slide) => slide.reviewer).join('|')}`; + + if (nextSignature !== slideSignature) { + slideSignature = nextSignature; + activeIndex = getSeededTestimonialIndex(slides, seedKey); + } + } + function dogNameFromDetail(detail: string) { const match = detail.match(/^([^'’]+)/); return match ? match[1].trim() : ''; @@ -163,16 +175,24 @@
{#if index === activeIndex} - {@const imageMeta = getImageMetadata(testimonial.imageUrl)} - {testimonialAlt(testimonial)} + {@const enhancedPhoto = getEnhancedImage(testimonial.imageUrl)} + {#if enhancedPhoto} + + {:else} + {testimonialAlt(testimonial)} + {/if} {/if}
@@ -489,8 +509,11 @@ border-radius: 999px; background: #f8f8f8; color: #0a304e; + font-family: var(--font-head); font-size: 14px; - line-height: 1.3; + font-weight: 700; + line-height: 1.2; + letter-spacing: 0.01em; box-shadow: 0 0 0 1px rgba(10, 48, 78, 0.06); } diff --git a/src/lib/components/TestimonialsSection.test.ts b/src/lib/components/TestimonialsSection.test.ts index 6f447ff..a9fac04 100644 --- a/src/lib/components/TestimonialsSection.test.ts +++ b/src/lib/components/TestimonialsSection.test.ts @@ -5,10 +5,10 @@ import { homepageContent } from '$lib/content/homepage'; import type { TestimonialContent } from '$lib/types'; const expectedMappedSlides = [ - { reviewer: 'Kate', src: '/images/archie-auckland-dog-walking-review.png' }, - { reviewer: 'Estelle', src: '/images/monty-auckland-dog-walking-review.png' }, - { reviewer: 'Ross', src: '/images/otis-auckland-dog-walking-review.png' }, - { reviewer: 'Nina', src: '/images/wallace-auckland-dog-walking-review.png' } + { reviewer: 'Kate' }, + { reviewer: 'Estelle' }, + { reviewer: 'Ross' }, + { reviewer: 'Nina' } ]; function getActiveSlide(container: HTMLElement) { @@ -23,6 +23,14 @@ function getActiveImage(container: HTMLElement) { return getActiveSlide(container).querySelector('img') as HTMLImageElement; } +function getNextButton(container: HTMLElement) { + return container.querySelector('.testimonial-arrow-right') as HTMLButtonElement; +} + +function getPreviousButton(container: HTMLElement) { + return container.querySelector('.testimonial-arrow-left') as HTMLButtonElement; +} + describe('TestimonialsSection', () => { afterEach(() => { vi.useRealTimers(); @@ -33,11 +41,11 @@ describe('TestimonialsSection', () => { testimonials: homepageContent.testimonials }); - const nextButton = screen.getByRole('button', { name: /next testimonial/i }); + const nextButton = getNextButton(container); for (const [index, slide] of expectedMappedSlides.entries()) { expect(getActiveReviewer(container)).toBe(slide.reviewer); - expect(getActiveImage(container).getAttribute('src')).toBe(slide.src); + expect(getActiveImage(container)).toBeTruthy(); if (index < expectedMappedSlides.length - 1) { await fireEvent.click(nextButton); @@ -52,7 +60,7 @@ describe('TestimonialsSection', () => { testimonials: homepageContent.testimonials }); - const nextButton = screen.getByRole('button', { name: /next testimonial/i }); + const nextButton = getNextButton(container); expect(getActiveReviewer(container)).toBe('Kate'); @@ -68,16 +76,14 @@ describe('TestimonialsSection', () => { testimonials: homepageContent.testimonials }); - const previousButton = screen.getByRole('button', { name: /previous testimonial/i }); + const previousButton = getPreviousButton(container); expect(getActiveReviewer(container)).toBe('Kate'); await fireEvent.click(previousButton); expect(getActiveReviewer(container)).toBe('Nina'); - expect(getActiveImage(container).getAttribute('src')).toBe( - '/images/wallace-auckland-dog-walking-review.png' - ); + expect(getActiveImage(container)).toBeTruthy(); }); it('keeps custom testimonial images and filters out testimonials with no image', async () => { @@ -100,7 +106,7 @@ describe('TestimonialsSection', () => { testimonials: customTestimonials }); - const nextButton = screen.getByRole('button', { name: /next testimonial/i }); + const nextButton = getNextButton(container); expect(container.querySelectorAll('.testimonial-slide')).toHaveLength(5); @@ -109,7 +115,16 @@ describe('TestimonialsSection', () => { } expect(getActiveReviewer(container)).toBe('Casey'); - expect(getActiveImage(container).getAttribute('src')).toBe('/images/custom-casey-review.png'); + expect(getActiveImage(container)).toBeTruthy(); expect(screen.queryByText('Jordan')).not.toBeInTheDocument(); }); + + it('can start on a different testimonial for a different page seed', () => { + const { container } = render(TestimonialsSection, { + testimonials: homepageContent.testimonials, + seedKey: '/dog-walking' + }); + + expect(getActiveReviewer(container)).not.toBe('Kate'); + }); }); diff --git a/src/lib/components/ValuesSection.svelte b/src/lib/components/ValuesSection.svelte index aad05ba..2cee559 100644 --- a/src/lib/components/ValuesSection.svelte +++ b/src/lib/components/ValuesSection.svelte @@ -30,9 +30,13 @@
{#each orderedValues as value}
- -

{value.title}

-

{value.body}

+
+ +
+
+

{value.title}

+

{value.body}

+
{/each}
diff --git a/src/lib/content/about.ts b/src/lib/content/about.ts index a3e4aff..d7f4d3a 100644 --- a/src/lib/content/about.ts +++ b/src/lib/content/about.ts @@ -4,36 +4,77 @@ export const aboutPageContent: AboutPageContent = { title: 'About Us', sections: [ { + eyebrow: 'Our story', title: 'Who we are', body: [ - "At GoodWalk, we're not your average dog walking service. We're a team of passionate dog lovers dedicated to providing top-notch care for your furry friends. Specialising in small dogs, we understand their unique needs firsthand, being small dog owners ourselves!", - "Our commitment to excellence has quickly made us a leader in Auckland Central's dog-walking scene. From pack walks to one-on-one sessions, we ensure the happiness and well-being of every dog in our care." + "Goodwalk is built around Alessandra — who started this because she couldn't find a walker she actually trusted, and hasn't stopped showing up the same way since. She walks every dog herself, posts updates to Instagram so you can see exactly what your dog is up to, and has built relationships with some dogs from as young as ten weeks old. Thirty-plus five-star Google reviews later, the feedback keeps saying the same thing: the dogs adore her, and their owners finally stop worrying.", + "We specialise in small and medium dogs because we understand them — not as a category, but as actual dogs with specific needs, specific quirks, and specific ways they fall apart in the wrong environment. The pace of a walk matters. The size of the group matters. The temperament of the other dogs matters. That's why we built a service around them, not just one that fits them in." ], - imageUrl: '/images/auckland-pack-walk-dog.jpg', - imageAlt: 'Dog on a Goodwalk pack walk' + imageUrl: '/images/auckland-pack-walk-small-dogs-group.png', + imageAlt: "Small dogs from Goodwalk's Tiny Gang pack walk sitting together in an Auckland park" }, { - title: 'Our impact', + eyebrow: 'What we stand for', + title: 'How we do things', body: [ - "At GoodWalk, we believe in positive reinforcement training to help your dog thrive in the world. Safety, professionalism, well-being, fun, structure, and compassion are the cornerstones of our business ethos.", - "When you choose GoodWalk, you're choosing a partner who will treat your dog like family, because that's exactly what they are to us." + "Every walk you've seen across our pages — the Tiny Gang outings, the one-on-ones, the puppy visits — runs on the same principles. Calm handling, positive reinforcement, and a walker who already knows your dog. That's not a promise we make at signup. It's how every single walk actually goes.", + "We keep packs small because we mean it when we say your dog gets real attention. We cover pickup and drop-off because your day shouldn't have to work around us. And every walker holds pet first aid certification and public liability insurance — because the dogs in our care aren't just bookings, they're the whole reason we do this." ], imageUrl: '/images/auckland-dog-group-outing.jpg', - imageAlt: 'Goodwalk dogs enjoying an outing together', + imageAlt: 'Goodwalk dogs enjoying a group outing in Auckland', reverse: true, accent: 'gradient' }, { - title: 'Meet the team', + eyebrow: 'Meet the founder', + title: 'The heart of Goodwalk', body: [ - 'Behind GoodWalk is Alessandra, an Italian who has a deep passion for dogs. With her love for animals and years of experience, Alessandra leads our team with dedication and expertise, ensuring that every dog receives the love and attention they deserve.', - "And let's not forget about Maya, our marketing manager! A Cavalier King Charles cross Shih Tzu, Maya is full of sass and personality, bringing a touch of charm and flair to everything we do." + "Alessandra started Goodwalk because she couldn't find a walker she actually trusted with Maya. So she became one. Italian-born and Auckland Central-based, she leads every walk herself — not because she has to, but because handing that off was never something she was willing to do. The dogs she walks have her full attention. Their owners have her number.", + "Maya is a Cavalier King Charles cross Shih Tzu, and the reason small dogs sit at the centre of everything Goodwalk does. She is opinionated, dramatic when it rains, and completely impossible to ignore on a walk. She is also the best argument we have for why small dogs deserve a service built specifically around them — not just accommodated by one." ], - imageUrl: '/images/goodwalk-dog-walker-alessandra.png', - imageAlt: 'Goodwalk staff member Aless' + imageUrl: '/images/founder-image-aless-goodwalk.png', + imageAlt: 'Alessandra, founder of Goodwalk Auckland', + accent: 'founder' + } + ], + faqTitle: 'Questions about Goodwalk', + faqs: [ + { + question: 'Who actually walks my dog?', + answer: + 'Alessandra, the founder, personally leads every walk. We are not a platform or an agency — you will always know who is at the door.' + }, + { + question: 'Why do you specialise in small dogs?', + answer: + 'Small dogs have different energy levels, social dynamics, and handling needs compared to larger breeds. Alessandra is a small dog owner herself, and Goodwalk was built specifically around what those dogs need — not adapted from a one-size-fits-all model.' + }, + { + question: 'How big are your packs?', + answer: + 'We keep Tiny Gang packs to 4–8 dogs. Smaller packs mean better supervision, calmer outings, and dogs that actually come home settled rather than overstimulated.' + }, + { + question: "What is a Meet & Greet?", + answer: + 'A Meet & Greet is a free, no-obligation introduction where Alessandra meets you and your dog in person. It is a chance to ask questions, see how your dog responds, and decide if Goodwalk is the right fit — with no pressure either way.' + }, + { + question: 'What suburbs do you cover?', + answer: + 'We cover most of Auckland Central including Ponsonby, Grey Lynn, Mt Eden, Kingsland, Morningside, Sandringham, Mt Albert, Mt Roskill, Herne Bay, Freemans Bay, Pt Chevalier, Avondale, Eden Terrace, Balmoral, and more. If you are nearby and unsure, just ask.' + }, + { + question: 'Are your walkers insured and first-aid trained?', + answer: + 'Yes. All walkers hold public liability insurance and a current pet first aid certificate. Your dog is covered from pickup to drop-off.' + }, + { + question: 'What does onboarding look like?', + answer: + 'Every new dog goes through a screening process that includes at minimum two assessment walks. This lets us make sure the pack is the right fit for your dog, and your dog is the right fit for the pack.' } ], - servicesTitle: 'Explore our services', contact: { title: "Let's get started!", email: 'info@goodwalk.co.nz', diff --git a/src/lib/content/dog-walking.ts b/src/lib/content/dog-walking.ts index 48f66c0..5132382 100644 --- a/src/lib/content/dog-walking.ts +++ b/src/lib/content/dog-walking.ts @@ -7,7 +7,8 @@ export const dogWalkingContent: ServicePageContent = { paragraphs: [ 'Goodwalk 1:1 Walks are for dogs who do better with more individual attention, a quieter setup, and a walk tailored to their own pace, confidence, and routine.', 'They can be a great fit for larger dogs, dogs who are not suited to group walks, or owners who want a more personal approach with extra care and consistency.', - 'If your dog needs space, structure, and a walker who can focus fully on them, our one-on-one walks are designed for exactly that.' + 'If your dog needs space, structure, and a walker who can focus fully on them, our one-on-one walks are designed for exactly that.', + 'We run 1:1 walks across Auckland Central — including Mt Eden, Ponsonby, Kingsland, Grey Lynn, Herne Bay, and surrounding suburbs — with free pickup and drop-off included.' ], imageUrl: '/images/auckland-large-dog-one-on-one-walk.jpg', imageAlt: 'Large breed dog enjoying a Goodwalk one on one dog walk' diff --git a/src/lib/content/homepage.ts b/src/lib/content/homepage.ts index ec2b251..747abb7 100644 --- a/src/lib/content/homepage.ts +++ b/src/lib/content/homepage.ts @@ -1,4 +1,5 @@ import type { HomePageContent } from '$lib/types'; +import { sharedServices } from '$lib/content/services'; export const homepageContent: HomePageContent = { seo: { @@ -23,11 +24,12 @@ export const homepageContent: HomePageContent = { ], cta: { label: 'Contact Us', href: '/contact-us', variant: 'yellow' }, instagram: { href: 'https://www.instagram.com/goodwalk.nz/', external: true }, - megaMenuServices: [ - { icon: 'fas fa-paw', label: 'Pack Walks', description: 'Tiny Gang outdoor adventures', href: '/pack-walks' }, - { icon: 'fas fa-person-walking', label: '1:1 Walks', description: 'Personalised solo walks', href: '/dog-walking' }, - { icon: 'fas fa-dog', label: 'Puppy Visits', description: 'Home visits for young pups', href: '/puppy-visits' } - ], + megaMenuServices: sharedServices.map((service) => ({ + icon: service.icon, + label: service.title, + description: service.megaMenuDescription, + href: service.href + })), megaMenuFooter: { label: 'View all pricing', href: '/our-pricing' } }, hero: { @@ -38,10 +40,9 @@ export const homepageContent: HomePageContent = { 'Dog walking for busy Auckland Central professionals who want a reliable, relationship-led team their dog knows by name.', primaryCta: { label: 'Book a Meet & Greet', href: '#newlead', variant: 'yellow' }, secondaryCta: { - label: 'Message us on Instagram', - href: 'https://www.instagram.com/goodwalk.nz/', - variant: 'outline', - external: true + label: 'See how it works', + href: '#how-it-works', + variant: 'outline' }, imageUrl: '/images/auckland-dog-walking-happy-dog-hero.png', imageAlt: 'Happy dog ready for a professional pack walk with Goodwalk Auckland dog walking service' @@ -58,37 +59,22 @@ export const homepageContent: HomePageContent = { title: 'Meet Aless,', subtitle: 'the heart of Goodwalk', body: [ - 'Goodwalk was built for owners who want more than a basic walk. Alessandra leads the business with a calm, hands-on approach shaped by years of experience, a love of small dogs, and a real focus on trust, routine, and safety.', - "From house keys to nervous first walks, we take the responsibility seriously. You'll know who is walking your dog, your dog will know who is at the door, and you'll get a reliable team that treats your dog like family. Ready to join the" + 'Goodwalk is built around one thing: a dog who knows the routine, and an owner who stops worrying.', + 'Alessandra runs every walk personally — calm, experienced, and trusted by Auckland Central families.', + "Your dog knows who's at the door. You know what happens on the walk. Ready to join the" ], emphasis: 'TINY GANG?', cta: { label: 'Book a free Meet & Greet', href: '/contact-us', variant: 'green' }, - imageUrl: '/images/goodwalk-dog-walker-alessandra.png', + imageUrl: '/images/founder-image-aless-goodwalk.png', imageAlt: 'Alessandra from Goodwalk with a dog in Auckland' }, - services: [ - { - icon: 'fas fa-dog', - title: 'Pack Walks', - body: 'Small group Tiny Gang walks of 4-8 dogs - calm, social, and full of fun for your pup.', - priceFrom: 'From $49.50 / walk', - href: '/pack-walks' - }, - { - icon: 'fas fa-person-walking', - title: '1:1 Walks', - body: "One-on-one walks tailored to your dog's individual pace, personality, and needs.", - priceFrom: 'From $45 / walk', - href: '/dog-walking' - }, - { - icon: 'fas fa-house', - title: 'Puppy Visits', - body: 'In-home visits to check in on your puppy, play, and keep them company.', - priceFrom: 'From $39 / visit', - href: '/puppy-visits' - } - ], + services: sharedServices.map((service) => ({ + icon: service.icon, + title: service.title, + body: service.cardBody, + priceFrom: service.priceFrom, + href: service.href + })), howItWorks: { title: 'How it works', intro: @@ -125,40 +111,25 @@ export const homepageContent: HomePageContent = { icon: 'fas fa-heart', title: 'Calm, kind handling', body: - 'We use positive reinforcement, gentle handling, and patient routines so dogs build confidence instead of stress.' + 'Positive reinforcement and patient routines so your dog builds confidence, not stress.' }, { icon: 'fas fa-camera', - title: 'Daily updates you will actually want', + title: 'Updates you will actually want', body: - "You get to see your dog out enjoying the day, which means less wondering and more peace of mind while you're at work." + "See your dog out enjoying the day. Less wondering, more peace of mind while you're at work." }, { icon: 'fas fa-users', - title: 'Small Pack Sizes', - order: 2, + title: 'Small pack sizes', body: - 'With just 4-8 dogs per group, walks stay calm, structured, and manageable, with enough attention for every dog.' + '4–8 dogs per group. Calm, structured walks with enough attention for every dog in the pack.' }, { icon: 'fas fa-shield-heart', title: 'Safety-first by default', - order: 1, body: - 'Pet first aid, careful screening, and proactive handling are built into every walk, not added on as a nice extra.' - }, - { - icon: 'fas fa-calendar-check', - title: 'Built for real schedules', - body: - "We specialise in regular walks, but we know life changes. Give us notice and we'll do our best to help keep things running smoothly." - }, - { - icon: 'fas fa-clock', - title: 'Reliable pickup, clear communication', - order: 3, - body: - "You should not have to chase your dog walker. We keep things consistent, communicate clearly, and make the practical side feel easy." + 'Pet first aid, careful screening, and proactive handling built into every walk — not added as an extra.' } ], testimonials: [ diff --git a/src/lib/content/locations.ts b/src/lib/content/locations.ts new file mode 100644 index 0000000..e3b8a69 --- /dev/null +++ b/src/lib/content/locations.ts @@ -0,0 +1,450 @@ +import type { LocationPageContent } from '$lib/types'; + +export const locationPages: LocationPageContent[] = [ + { + suburb: 'Mt Eden', + slug: 'mt-eden', + intro: + 'Mt Eden is one of Auckland Central\'s most walked neighbourhoods — and for good reason. The volcanic cone, leafy streets, and mix of open reserves and quiet paths make it an ideal place for small dogs who thrive on a proper daily outing. Goodwalk runs pack walks and 1:1 walks through Mt Eden as part of a regular weekly routine, with free pickup and drop-off included.', + parks: [ + { + name: 'Maungawhau / Mt Eden Domain', + description: + 'The volcanic cone at the heart of Mt Eden offers wide open paths, panoramic views across Auckland, and a mix of gentle and steeper terrain. Popular with local dog walkers and a staple route for the Tiny Gang.', + leashNote: 'Dogs must be on leash on the summit and in the Domain.' + }, + { + name: 'Potters Park', + description: + 'A well-used neighbourhood park on the border of Mt Eden and Sandringham with open grass areas and shade trees. Good for shorter walks and a regular favourite for local dogs.', + leashNote: 'On-leash area.' + }, + { + name: 'Cornwall Park / One Tree Hill Domain', + description: + 'Just south of Mt Eden, Cornwall Park is one of Auckland\'s most expansive green spaces with sweeping lawns, mature trees, and wide walking paths. The scale makes it excellent for a longer, more varied outing.', + leashNote: 'Dogs must be on leash and are not permitted in fenced farm animal areas.' + } + ] + }, + { + suburb: 'Kingsland', + slug: 'kingsland', + intro: + 'Kingsland sits right in the heart of Auckland Central, with easy access to some of the area\'s best parks and green corridors. Its central position makes it one of our most efficient pickup stops, and dogs from Kingsland are a regular part of the Tiny Gang. Goodwalk covers Kingsland for pack walks, 1:1 walks, and puppy visits.', + parks: [ + { + name: 'Fowlds Park', + description: + 'A large open park in neighbouring Morningside, Fowlds Park offers generous grass areas, walking paths, and space to roam. It is one of the most popular spots for dog walking in the central suburbs and a regular route for Goodwalk outings.', + leashNote: 'On-leash area throughout most of the park.' + }, + { + name: 'Western Springs Park', + description: + 'Surrounding the historic Western Springs lake, this large park provides shaded paths, waterside walking, and a calm environment that suits dogs who prefer a quieter outing away from busy streets.', + leashNote: 'On-leash. Dogs are not permitted in the zoo area.' + }, + { + name: 'Chamberlain Park', + description: + 'A wide green space adjacent to the golf course with open walkways and flat terrain — easy going for smaller dogs and a good spot for a steady, unhurried walk.', + leashNote: 'On-leash area.' + } + ] + }, + { + suburb: 'Ponsonby', + slug: 'ponsonby', + intro: + 'Ponsonby\'s tree-lined streets and proximity to several of Auckland\'s best parks make it a natural home for dog-loving households. Many of the small dogs in our Tiny Gang are based in Ponsonby, and we know the neighbourhood well. Goodwalk offers pack walks, 1:1 walks, and puppy visits across Ponsonby.', + parks: [ + { + name: 'Western Park', + description: + 'A terraced hillside reserve running alongside Ponsonby Road, Western Park offers shaded paths, native planting, and a quiet contrast to the busy street above. Well-suited to dogs who enjoy a more enclosed, leafy environment.', + leashNote: 'On-leash area.' + }, + { + name: 'Victoria Park', + description: + 'One of Auckland\'s most central parks, Victoria Park is a large open space with wide paths, mature trees, and plenty of room for small dogs to enjoy a proper walk without feeling crowded.', + leashNote: 'On-leash area.' + }, + { + name: 'Herne Bay Foreshore', + description: + 'A short walk from central Ponsonby, the Herne Bay waterfront and reserve offers coastal views, fresh sea air, and a relaxed route that small dogs tend to love.', + leashNote: 'On-leash area.' + } + ] + }, + { + suburb: 'Grey Lynn', + slug: 'grey-lynn', + intro: + 'Grey Lynn is a dog-friendly suburb with one of Auckland Central\'s most popular parks right at its centre. It\'s a densely walkable area with good access to open green space, and a regular part of our Tiny Gang routes. Goodwalk serves Grey Lynn for all services — pack walks, 1:1 walks, and puppy visits.', + parks: [ + { + name: 'Grey Lynn Park', + description: + 'One of the most popular dog walking destinations in Auckland Central, Grey Lynn Park has large open grass areas, wide paths, and an off-leash zone. It\'s a social, active park where small dogs thrive alongside the community.', + leashNote: 'On-leash in most areas. Off-leash zone available — check Auckland Council signage for the designated area.' + }, + { + name: 'Arch Hill Reserve', + description: + 'A smaller hilltop reserve on the Grey Lynn / Arch Hill border with native planting, good views, and a quieter atmosphere than the main park. Good for dogs who prefer a less busy setting.', + leashNote: 'On-leash area.' + }, + { + name: 'Cox\'s Bay Reserve', + description: + 'A short walk from Grey Lynn, Cox\'s Bay Reserve sits on the Waitemata Harbour foreshore in Herne Bay. Flat, scenic, and popular — an excellent route extension for dogs who enjoy walking near the water.', + leashNote: 'On-leash area.' + } + ] + }, + { + suburb: 'Sandringham', + slug: 'sandringham', + intro: + 'Sandringham is a well-connected central suburb with good access to several parks used by our Tiny Gang. Its quiet residential streets are easy to navigate for pickups, and its proximity to Mt Eden and Morningside means dogs based here have a range of walking routes available. Goodwalk covers Sandringham for pack walks, 1:1 walks, and puppy visits.', + parks: [ + { + name: 'Potters Park', + description: + 'On the Sandringham / Mt Eden border, Potters Park is a flat, open space with grassy areas and mature trees — a reliable neighbourhood park and a regular stop on our routes.', + leashNote: 'On-leash area.' + }, + { + name: 'Fowlds Park', + description: + 'Easily accessible from Sandringham, Fowlds Park is one of the central Auckland area\'s largest parks — open, spacious, and consistently popular with local dog walkers.', + leashNote: 'On-leash area.' + }, + { + name: 'Woodside Reserve', + description: + 'A quieter local reserve within Sandringham, Woodside is good for calmer walks and dogs who do better with fewer distractions — a solid neighbourhood option between the larger parks.', + leashNote: 'On-leash area.' + } + ] + }, + { + suburb: 'Mt Albert', + slug: 'mt-albert', + intro: + 'Mt Albert combines a relaxed suburban feel with excellent access to parks and green corridors that make it one of the better areas for dog walking in Auckland Central. The Oakley Creek walkway alone is a standout route for small dogs. Goodwalk serves Mt Albert for pack walks, 1:1 walks, and puppy visits.', + parks: [ + { + name: 'Oakley Creek / Te Auaunga Walkway', + description: + 'A beautiful linear walkway following Oakley Creek through native bush restoration plantings and open stream-side paths. One of Auckland Central\'s most scenic dog walking routes — calm, well-maintained, and a genuine highlight for dogs and owners alike.', + leashNote: 'On-leash in most sections to protect local wildlife.' + }, + { + name: 'Mt Albert Domain', + description: + 'A well-maintained local domain with open lawns, sports fields, and walking paths. Reliable, well-used, and a regular part of our Mt Albert routes.', + leashNote: 'On-leash area.' + }, + { + name: 'Phyllis Reserve', + description: + 'A popular neighbourhood reserve with open grass and easy walking paths. Regularly used by local dog owners and a good stop for dogs who enjoy a steadier, more predictable walk.', + leashNote: 'On-leash area.' + } + ] + }, + { + suburb: 'Herne Bay', + slug: 'herne-bay', + intro: + 'Herne Bay is one of Auckland\'s most picturesque suburbs, with waterfront reserves, harbour views, and a calm residential feel that suits small dogs perfectly. It\'s a natural fit for our Tiny Gang, and we regularly pick up from the area. Goodwalk covers Herne Bay for pack walks, 1:1 walks, and puppy visits.', + parks: [ + { + name: 'Cox\'s Bay Reserve', + description: + 'A much-loved waterfront reserve on the Waitemata Harbour with flat grassy areas, harbour views, and a relaxed atmosphere. One of Auckland\'s best spots for a morning walk with a small dog.', + leashNote: 'On-leash area.' + }, + { + name: 'Herne Bay Foreshore', + description: + 'The foreshore walkway along the Herne Bay waterfront provides easy, flat walking with sea breezes and good views across the harbour. Popular with dog owners at any time of day.', + leashNote: 'On-leash area.' + }, + { + name: 'Western Park', + description: + 'A short walk into Ponsonby, Western Park\'s terraced paths and native planting offer a quiet contrast to the open waterfront — a good second option for dogs who enjoy varied terrain.', + leashNote: 'On-leash area.' + } + ] + }, + { + suburb: 'Morningside', + slug: 'morningside', + intro: + 'Morningside is home to some of the best park access in the central Auckland area — Fowlds Park and Western Springs are both on the doorstep. It\'s a regular part of our Tiny Gang routes, and pickup logistics are straightforward. Goodwalk serves Morningside for pack walks, 1:1 walks, and puppy visits.', + parks: [ + { + name: 'Fowlds Park', + description: + 'Morningside\'s standout park — large, open, and well-maintained with generous grass areas and plenty of room for a proper group outing. It\'s one of our most-used locations for Tiny Gang walks.', + leashNote: 'On-leash area throughout most of the park.' + }, + { + name: 'Western Springs Park', + description: + 'Surrounding a historic lake and well-established trees, Western Springs provides a calmer, more shaded alternative to Fowlds — ideal for dogs who prefer a quieter environment or warmer days.', + leashNote: 'On-leash. Dogs are not permitted in the zoo precinct.' + }, + { + name: 'Chamberlain Park', + description: + 'Adjacent to the golf course and easily reached from Morningside, Chamberlain Park offers flat, open walking in a less busy setting than the suburb\'s larger parks.', + leashNote: 'On-leash area.' + } + ] + }, + { + suburb: 'Freemans Bay', + slug: 'freemans-bay', + intro: + 'Freemans Bay sits just below Ponsonby and within easy reach of Victoria Park and the waterfront. Its compact streets and central location make it one of our quickest pickup stops. Goodwalk serves Freemans Bay for pack walks, 1:1 walks, and puppy visits.', + parks: [ + { + name: 'Victoria Park', + description: + 'One of Auckland\'s most central and well-used parks, Victoria Park is right on Freemans Bay\'s doorstep — wide open lawns, mature trees, and a consistently good environment for small dogs on a group walk.', + leashNote: 'On-leash area.' + }, + { + name: 'Westhaven Promenade', + description: + 'The Westhaven foreshore walkway provides flat, scenic walking alongside the marina with harbour views and fresh sea air. A favourite for dogs who enjoy a coastal route.', + leashNote: 'On-leash area.' + }, + { + name: 'Western Park', + description: + 'Easily accessible from Freemans Bay, Western Park\'s shaded hillside paths offer a more enclosed and quieter alternative to the open parks nearby.', + leashNote: 'On-leash area.' + } + ] + }, + { + suburb: 'Pt Chevalier', + slug: 'pt-chevalier', + intro: + 'Pt Chevalier has a relaxed, community-oriented feel and excellent park access — including beach reserves, wetland walkways, and open fields. It\'s a genuinely good suburb for dog walking and a regular part of our routes. Goodwalk serves Pt Chevalier for pack walks, 1:1 walks, and puppy visits.', + parks: [ + { + name: 'Pt Chevalier Beach Reserve', + description: + 'A popular local beach reserve with grassy areas, pohutukawa trees, and a relaxed foreshore setting. Dogs enjoy the sea air and the open space, and it\'s never too far from the water\'s edge.', + leashNote: 'On-leash area. Check Auckland Council signage for beach access rules by season.' + }, + { + name: 'Meola Creek / Meola Reef', + description: + 'A peaceful wetland walkway following Meola Creek out to the Meola Reef reserve on the foreshore. Excellent native bird habitat and a calm, scenic route that small dogs tend to enjoy.', + leashNote: 'On-leash to protect the wetland wildlife.' + }, + { + name: 'Seddon Fields', + description: + 'Large open sports fields with plenty of space for a good walk. Less structured than the beach reserve but useful for dogs who need more open room to stretch their legs.', + leashNote: 'On-leash area outside designated off-leash zones.' + } + ] + }, + { + suburb: 'Avondale', + slug: 'avondale', + intro: + 'Avondale offers solid access to green space and the Oakley Creek walkway, one of the better dog walking routes in West Auckland. It sits on the western edge of our service area and is well-suited to dogs who enjoy more varied terrain. Goodwalk covers Avondale for pack walks, 1:1 walks, and puppy visits.', + parks: [ + { + name: 'Oakley Creek / Te Auaunga Walkway', + description: + 'The Oakley Creek walkway stretches through Avondale along a stream-side path lined with native plantings. One of the most enjoyable walking routes in the area — calm, green, and well away from traffic.', + leashNote: 'On-leash in most sections.' + }, + { + name: 'Avondale Domain', + description: + 'A local domain with open grass and easy paths — a reliable neighbourhood option for a straightforward, unhurried walk.', + leashNote: 'On-leash area.' + }, + { + name: 'Hendon Park', + description: + 'A quieter local reserve in Avondale with grassy open areas and a low-key atmosphere. Good for dogs who prefer a smaller, less busy setting.', + leashNote: 'On-leash area.' + } + ] + }, + { + suburb: 'Three Kings', + slug: 'three-kings', + intro: + 'Three Kings is a quieter residential suburb with some genuinely interesting walking terrain — most notably the old quarry reserve that gives the suburb its character. It\'s well-positioned for pickup and sits within easy reach of Monte Cecilia Park. Goodwalk serves Three Kings for pack walks, 1:1 walks, and puppy visits.', + parks: [ + { + name: 'Three Kings Reserve', + description: + 'Formed from a former volcanic quarry, Three Kings Reserve is a dramatic and unusual park with steep rocky outcrops, native planting, and elevated views. An interesting change of scenery for dogs accustomed to flat suburban parks.', + leashNote: 'On-leash area.' + }, + { + name: 'Monte Cecilia Park', + description: + 'A large, well-landscaped park bordering Three Kings and Hillsborough with open lawns, mature trees, and walking paths. Popular with local dog walkers and a consistent favourite on our southern routes.', + leashNote: 'On-leash area.' + }, + { + name: 'Winstone Park', + description: + 'A local neighbourhood reserve with open grass and a calm environment — reliable for shorter walks and a regular stop for dogs in the Three Kings area.', + leashNote: 'On-leash area.' + } + ] + }, + { + suburb: 'Hillsborough', + slug: 'hillsborough', + intro: + 'Hillsborough sits on the southern edge of our service area with access to Monte Cecilia Park and a network of quieter residential streets. It\'s a relaxed, lower-density suburb well-suited to dogs who do better on calmer, less congested walks. Goodwalk serves Hillsborough for pack walks, 1:1 walks, and puppy visits.', + parks: [ + { + name: 'Monte Cecilia Park', + description: + 'One of the southern central suburbs\' best parks — large open lawns, historic homestead grounds, and well-maintained walking paths through mature trees. A consistently good spot for a group outing.', + leashNote: 'On-leash area.' + }, + { + name: 'Richardson Domain', + description: + 'A large sports and recreation reserve with plenty of open space, wide paths, and a relaxed atmosphere. Good for dogs who need room to move without a lot of competing activity around them.', + leashNote: 'On-leash area in most sections.' + }, + { + name: 'Hillsborough Reserve', + description: + 'A smaller neighbourhood reserve within Hillsborough — useful for shorter walks and a reliable local option between the area\'s larger parks.', + leashNote: 'On-leash area.' + } + ] + }, + { + suburb: 'Eden Terrace', + slug: 'eden-terrace', + intro: + 'Eden Terrace is a compact, centrally-located suburb with quick access to Myers Park and the Auckland Domain. Its urban density makes it an efficient pickup point, and dogs from Eden Terrace often join Tiny Gang outings to nearby Mt Eden and Grey Lynn parks. Goodwalk serves Eden Terrace for pack walks, 1:1 walks, and puppy visits.', + parks: [ + { + name: 'Myers Park', + description: + 'An urban park in the heart of the city with terraced gardens, shaded paths, and a pedestrian-friendly layout. A good option for a quick midday walk for dogs based in Eden Terrace.', + leashNote: 'On-leash area.' + }, + { + name: 'Auckland Domain', + description: + 'Auckland\'s oldest park, the Domain offers expansive lawns, tree-lined paths, and some of the city\'s best open green space. Just a short drive from Eden Terrace and a regular destination for our longer Tiny Gang outings.', + leashNote: 'On-leash area. Dogs are not permitted in the formal garden sections.' + }, + { + name: 'Basque Park', + description: + 'A small pocket park near the Newton Gully with quiet paths and native planting — useful as a local walking option for dogs in the immediate area.', + leashNote: 'On-leash area.' + } + ] + }, + { + suburb: 'Balmoral', + slug: 'balmoral', + intro: + 'Balmoral is a well-established residential suburb with easy access to several parks used by our Tiny Gang. Its quiet streets and proximity to Mt Eden and Sandringham make it a natural part of our regular routes. Goodwalk serves Balmoral for pack walks, 1:1 walks, and puppy visits.', + parks: [ + { + name: 'Balmoral Reserve', + description: + 'A well-used neighbourhood reserve with open grass, walking paths, and a relaxed local atmosphere. A reliable everyday option for dogs based in Balmoral.', + leashNote: 'On-leash area.' + }, + { + name: 'Potters Park', + description: + 'On the Balmoral / Sandringham / Mt Eden boundary, Potters Park is a flat, open park and a regular stop on our central Auckland routes.', + leashNote: 'On-leash area.' + }, + { + name: 'Cornwall Park', + description: + 'Easily accessible from Balmoral, Cornwall Park\'s sweeping open lawns and wide paths make it one of Auckland\'s best walking parks — particularly suited to longer outings with a small group.', + leashNote: 'On-leash. Dogs are not permitted in fenced farm animal areas.' + } + ] + }, + { + suburb: 'Arch Hill', + slug: 'arch-hill', + intro: + 'Arch Hill sits between Grey Lynn and Kingsland with good access to both Grey Lynn Park and the surrounding reserves. It\'s a compact suburb with a strong dog-owning community and a regular part of our pickup circuit. Goodwalk serves Arch Hill for pack walks, 1:1 walks, and puppy visits.', + parks: [ + { + name: 'Arch Hill Reserve', + description: + 'A hilltop reserve with native planting and elevated views — quieter than the main parks nearby and a good option for dogs who prefer a less busy environment.', + leashNote: 'On-leash area.' + }, + { + name: 'Grey Lynn Park', + description: + 'A short walk from Arch Hill, Grey Lynn Park is one of Auckland Central\'s most popular dog walking destinations with open lawns, wide paths, and a lively community feel.', + leashNote: 'On-leash in most areas. Off-leash zone available — check Auckland Council signage.' + }, + { + name: 'Fowlds Park', + description: + 'Easily reached from Arch Hill via Kingsland, Fowlds Park provides generous open space and a reliable walking environment for dogs who need more room to move.', + leashNote: 'On-leash area.' + } + ] + }, + { + suburb: 'Mt Roskill', + slug: 'mt-roskill', + intro: + 'Mt Roskill sits on the southern edge of our service area with access to Monte Cecilia Park and the Richardson Domain — two of the larger parks in the south-central Auckland belt. It\'s a well-connected suburb and a regular part of our extended routes. Goodwalk serves Mt Roskill for pack walks, 1:1 walks, and puppy visits.', + parks: [ + { + name: 'Monte Cecilia Park', + description: + 'One of the area\'s best parks — large, well-kept grounds with open lawns, historic gardens, and good walking paths under mature trees. A standout destination for group walks in the southern central suburbs.', + leashNote: 'On-leash area.' + }, + { + name: 'Richardson Domain', + description: + 'A large recreation reserve with wide open fields and walking paths. Excellent for dogs who need space and a good stretch without a lot of congestion.', + leashNote: 'On-leash area in most sections.' + }, + { + name: 'Keith Hay Park', + description: + 'A large sports and community park in Mt Roskill with open grass areas and a calm neighbourhood environment — a reliable local option for dogs in the immediate area.', + leashNote: 'On-leash area.' + } + ] + } +]; + +export const locationsBySlug = Object.fromEntries( + locationPages.map((loc) => [loc.slug, loc]) +); diff --git a/src/lib/content/our-pricing.ts b/src/lib/content/our-pricing.ts index cae983c..a306747 100644 --- a/src/lib/content/our-pricing.ts +++ b/src/lib/content/our-pricing.ts @@ -1,15 +1,20 @@ import type { PricingPageContent } from '$lib/types'; +import { sharedServices } from '$lib/content/services'; import { dogWalkingContent } from './dog-walking'; import { packWalksContent } from './pack-walks'; import { puppyVisitsContent } from './puppy-visits'; +const packWalksService = sharedServices.find((service) => service.title === 'Pack Walks'); +const oneToOneService = sharedServices.find((service) => service.title === '1:1 Walks'); +const puppyVisitsService = sharedServices.find((service) => service.title === 'Puppy Visits'); + export const ourPricingContent: PricingPageContent = { title: 'Our Pricing', subtitle: 'Choose the Goodwalk routine that fits your dog, your week, and the kind of support you need.', sections: [ { title: 'Pack Walks', - icon: 'fas fa-paw', + icon: packWalksService?.icon ?? 'fas fa-paw', blurb: 'Our specialty for sociable small and medium dogs who thrive with calm structure, regular weekly outings, and the right dog company.', detailCta: { @@ -21,7 +26,7 @@ export const ourPricingContent: PricingPageContent = { }, { title: '1:1 Walks', - icon: 'fas fa-person-walking', + icon: oneToOneService?.icon ?? 'fas fa-person-walking', blurb: 'A more individual option for dogs who need extra attention, more space, or a walk shaped around their own pace and confidence.', detailCta: { @@ -33,7 +38,7 @@ export const ourPricingContent: PricingPageContent = { }, { title: 'Puppy Visits', - icon: 'fas fa-dog', + icon: puppyVisitsService?.icon ?? 'fas fa-dog', blurb: 'Home visits for young puppies who need company, toilet breaks, routine support, and a calmer start before they are ready for bigger adventures.', detailCta: { diff --git a/src/lib/content/pack-walks.ts b/src/lib/content/pack-walks.ts index c16180b..525f84f 100644 --- a/src/lib/content/pack-walks.ts +++ b/src/lib/content/pack-walks.ts @@ -7,7 +7,8 @@ export const packWalksContent: ServicePageContent = { paragraphs: [ 'Goodwalk Pack Walks are built for Auckland Central owners of small and medium dogs who want a reliable weekly routine, a well-exercised dog, and more peace of mind during the workday.', 'Our Tiny Gang packs stay small, calm, and carefully matched, so sociable dogs can build confidence, enjoy safe group outings, and come home settled instead of overstimulated.', - 'Tiny Gang is best suited to sociable small and medium dogs who enjoy being around other dogs. If your dog would be better with a quieter, more individual setup, our 1:1 walks may be a better fit.' + 'Tiny Gang is best suited to sociable small and medium dogs who enjoy being around other dogs. If your dog would be better with a quieter, more individual setup, our 1:1 walks may be a better fit.', + 'We run pack walks across Auckland Central — including Mt Eden, Kingsland, Ponsonby, Grey Lynn, Sandringham, Mt Albert, and surrounding suburbs — with free pickup and drop-off included in every booking.' ], imageUrl: '/images/auckland-pack-walk-small-dogs-group.png', imageAlt: "Small dogs from Goodwalk's Tiny Gang pack walk sitting together in an Auckland park" diff --git a/src/lib/content/puppy-visits.ts b/src/lib/content/puppy-visits.ts index db6902b..82a8b4b 100644 --- a/src/lib/content/puppy-visits.ts +++ b/src/lib/content/puppy-visits.ts @@ -7,7 +7,8 @@ export const puppyVisitsContent: ServicePageContent = { paragraphs: [ 'Goodwalk Puppy Visits are designed for busy owners who want their puppy cared for properly during the day, with toilet breaks, play, feeding, and calm one-on-one attention at home.', 'They are also the first stage of the Goodwalk journey. For puppies who may later join our Pack Walks, these visits help build familiarity, confidence, and the early routines that make that transition much smoother.', - 'Instead of just getting through the day, your puppy gets a more thoughtful start, and you get more peace of mind while you are away.' + 'Instead of just getting through the day, your puppy gets a more thoughtful start, and you get more peace of mind while you are away.', + 'We offer puppy visits across Auckland Central including Mt Eden, Ponsonby, Grey Lynn, Kingsland, Sandringham, Herne Bay, and surrounding suburbs.' ], imageUrl: '/images/auckland-puppy-home-visit.jpg', imageAlt: 'Puppy receiving a calm Goodwalk home visit in Auckland' diff --git a/src/lib/content/services.ts b/src/lib/content/services.ts new file mode 100644 index 0000000..dab79c8 --- /dev/null +++ b/src/lib/content/services.ts @@ -0,0 +1,39 @@ +export interface SharedServiceDefinition { + title: 'Pack Walks' | '1:1 Walks' | 'Puppy Visits'; + href: string; + icon: string; + megaMenuDescription: string; + cardBody: string; + locationDescription: string; + priceFrom: string; +} + +export const sharedServices: SharedServiceDefinition[] = [ + { + title: 'Pack Walks', + href: '/pack-walks', + icon: 'fas fa-paw', + megaMenuDescription: 'Tiny Gang outdoor adventures', + cardBody: 'Small group Tiny Gang walks of 4-8 dogs - calm, social, and full of fun for your pup.', + locationDescription: 'Small group outings for sociable dogs. From $49.50.', + priceFrom: 'From $49.50 / walk' + }, + { + title: '1:1 Walks', + href: '/dog-walking', + icon: 'fas fa-person-walking', + megaMenuDescription: 'Personalised solo walks', + cardBody: "One-on-one walks tailored to your dog's individual pace, personality, and needs.", + locationDescription: 'One dog, full attention, tailored pace.', + priceFrom: 'From $45 / walk' + }, + { + title: 'Puppy Visits', + href: '/puppy-visits', + icon: 'fas fa-dog', + megaMenuDescription: 'Home visits for young pups', + cardBody: 'In-home visits to check in on your puppy, play, and keep them company.', + locationDescription: 'In-home care for puppies during the day.', + priceFrom: 'From $39 / visit' + } +]; diff --git a/src/lib/content/static-pages.ts b/src/lib/content/static-pages.ts index df21c88..46d3706 100644 --- a/src/lib/content/static-pages.ts +++ b/src/lib/content/static-pages.ts @@ -1,37 +1,38 @@ export const staticPages = { 'pack-walks': { - title: 'Pack Walks for Small & Medium Dogs | Auckland Central', + title: 'Pack Walks for Small Dogs | Mt Eden, Kingsland & Auckland Central | Goodwalk', description: - 'Pack walks for sociable small and medium dogs in Auckland Central. Calm group outings, regular weekly routines, and free Meet & Greet with Goodwalk.', + 'Tiny Gang pack walks for small and medium dogs across Mt Eden, Kingsland, Ponsonby, Grey Lynn and Auckland Central. Small groups, calm outings, free pickup and drop-off.', canonicalPath: '/pack-walks' }, 'dog-walking': { - title: '1:1 Dog Walks for Dogs Who Need More Attention | Auckland Central', + title: '1:1 Dog Walks Auckland | Mt Eden, Ponsonby & Kingsland | Goodwalk', description: - 'One-on-one dog walks in Auckland Central for dogs who need more attention, more space, or a calmer routine. Free Meet & Greet with Goodwalk.', + 'One-on-one dog walks across Auckland Central — Mt Eden, Kingsland, Ponsonby, Grey Lynn and more. For dogs who need more space, attention, and a calmer routine.', canonicalPath: '/dog-walking' }, 'puppy-visits': { - title: 'Puppy Visits & In-Home Puppy Care | Auckland Central', + title: 'Puppy Visits Auckland Central | Mt Eden, Ponsonby & Grey Lynn | Goodwalk', description: - 'In-home puppy visits across Auckland Central with toilet breaks, feeding, play, and early routine support. A calm start before future pack walks.', + 'In-home puppy visits across Mt Eden, Ponsonby, Grey Lynn, Kingsland and Auckland Central. Toilet breaks, feeding, play, and calm one-on-one care while you are out.', canonicalPath: '/puppy-visits' }, 'our-pricing': { - title: 'Our Pricing', + title: 'Dog Walking Prices Auckland | Pack Walks & 1:1 Walks | Goodwalk', description: - 'Learn more about the pricing for Goodwalk. Prices for our Tiny Gang pack walks and 1 on 1 solo walks.', + 'Transparent pricing for Goodwalk pack walks, 1:1 dog walks, and puppy visits across Auckland Central. From $49.50 per walk. Free Meet & Greet included.', canonicalPath: '/our-pricing' }, about: { - title: 'About Us | Dog Walkers', + title: 'About Goodwalk | Dog Walkers in Mt Eden, Kingsland & Auckland Central', description: - 'Learn more about Kingsland based dog walking company Goodwalk. We offer our Tiny Gang pack walks throughout the Auckland region. Solo (1:1 walks), homestays and more.', + 'Meet Alessandra, founder of Goodwalk — Auckland Central\'s small dog walking specialist. Serving Mt Eden, Kingsland, Ponsonby, Grey Lynn and surrounding suburbs with 30+ five-star reviews.', canonicalPath: '/about' }, 'contact-us': { - title: 'Contact Us', - description: 'Book a Meet & Greet or send a general enquiry to Goodwalk Auckland dog walking services.', + title: 'Book a Dog Walker in Auckland | Contact Goodwalk', + description: + 'Book a free Meet & Greet or send an enquiry to Goodwalk. Auckland Central dog walking specialists serving Mt Eden, Kingsland, Ponsonby, Grey Lynn and surrounding suburbs.', canonicalPath: '/contact-us' }, 'terms-and-conditions': { diff --git a/src/lib/enhanced-images.ts b/src/lib/enhanced-images.ts new file mode 100644 index 0000000..fd5ea8a --- /dev/null +++ b/src/lib/enhanced-images.ts @@ -0,0 +1,17 @@ +import type { Picture } from '@sveltejs/enhanced-img'; + +// In dev, imagetools can be painfully slow for large local assets. +// Fall back to the already-served static files and keep enhanced variants for production builds. +const modules: Record = import.meta.env.DEV + ? {} + : (import.meta.glob('./images/**/*.{jpg,jpeg,png,webp,avif,gif}', { + eager: true, + query: { enhanced: true } + }) as Record); + +export function getEnhancedImage(src: string | undefined | null): Picture | null { + if (!src) return null; + // '/images/foo.png' -> './images/foo.png' (relative to src/lib/) + const key = '.' + src; + return modules[key]?.default ?? null; +} diff --git a/src/lib/image-metadata.ts b/src/lib/image-metadata.ts index 44efa50..8c1ada6 100644 --- a/src/lib/image-metadata.ts +++ b/src/lib/image-metadata.ts @@ -25,7 +25,7 @@ const imageMetadata: Record = { '/images/auckland-puppy-visits-cavalier-king-charles-spaniel.jpg': { width: 3327, height: 2217 }, '/images/auckland-pack-walk-dog.jpg': { width: 480, height: 640 }, '/images/auckland-dog-group-outing.jpg': { width: 640, height: 480 }, - '/images/goodwalk-dog-walker-alessandra.png': { width: 640, height: 640 } + '/images/founder-image-aless-goodwalk.png': { width: 1076, height: 1461 } }; export function getImageMetadata(src: string | undefined | null): ImageMetadata | null { diff --git a/src/lib/images/archie-auckland-dog-walking-review.jpg b/src/lib/images/archie-auckland-dog-walking-review.jpg new file mode 100644 index 0000000..3e9f9c7 Binary files /dev/null and b/src/lib/images/archie-auckland-dog-walking-review.jpg differ diff --git a/src/lib/images/archie-auckland-dog-walking-review.png b/src/lib/images/archie-auckland-dog-walking-review.png new file mode 100644 index 0000000..d8aa1b8 Binary files /dev/null and b/src/lib/images/archie-auckland-dog-walking-review.png differ diff --git a/src/lib/images/auckland-dog-group-outing.jpg b/src/lib/images/auckland-dog-group-outing.jpg new file mode 100644 index 0000000..1bef4ff Binary files /dev/null and b/src/lib/images/auckland-dog-group-outing.jpg differ diff --git a/src/lib/images/auckland-dog-walking-happy-dog-hero.png b/src/lib/images/auckland-dog-walking-happy-dog-hero.png new file mode 100644 index 0000000..380fc98 Binary files /dev/null and b/src/lib/images/auckland-dog-walking-happy-dog-hero.png differ diff --git a/src/lib/images/auckland-dog-walking-happy-dogs-happy-humans.webp b/src/lib/images/auckland-dog-walking-happy-dogs-happy-humans.webp new file mode 100644 index 0000000..bee6570 Binary files /dev/null and b/src/lib/images/auckland-dog-walking-happy-dogs-happy-humans.webp differ diff --git a/src/lib/images/auckland-dogs-outdoor-pack.jpg b/src/lib/images/auckland-dogs-outdoor-pack.jpg new file mode 100644 index 0000000..326ec77 Binary files /dev/null and b/src/lib/images/auckland-dogs-outdoor-pack.jpg differ diff --git a/src/lib/images/auckland-large-dog-one-on-one-walk.jpg b/src/lib/images/auckland-large-dog-one-on-one-walk.jpg new file mode 100644 index 0000000..a68090c Binary files /dev/null and b/src/lib/images/auckland-large-dog-one-on-one-walk.jpg differ diff --git a/src/lib/images/auckland-pack-walk-dog.jpg b/src/lib/images/auckland-pack-walk-dog.jpg new file mode 100644 index 0000000..78fdca7 Binary files /dev/null and b/src/lib/images/auckland-pack-walk-dog.jpg differ diff --git a/src/lib/images/auckland-pack-walk-small-dogs-group.png b/src/lib/images/auckland-pack-walk-small-dogs-group.png new file mode 100644 index 0000000..ca107aa Binary files /dev/null and b/src/lib/images/auckland-pack-walk-small-dogs-group.png differ diff --git a/src/lib/images/auckland-puppy-home-visit.jpg b/src/lib/images/auckland-puppy-home-visit.jpg new file mode 100644 index 0000000..88e9949 Binary files /dev/null and b/src/lib/images/auckland-puppy-home-visit.jpg differ diff --git a/src/lib/images/auckland-puppy-visits-cavalier-king-charles-spaniel.jpg b/src/lib/images/auckland-puppy-visits-cavalier-king-charles-spaniel.jpg new file mode 100644 index 0000000..666f90a Binary files /dev/null and b/src/lib/images/auckland-puppy-visits-cavalier-king-charles-spaniel.jpg differ diff --git a/src/lib/images/auckland-small-dog-pack-walk.jpg b/src/lib/images/auckland-small-dog-pack-walk.jpg new file mode 100644 index 0000000..9db74d1 Binary files /dev/null and b/src/lib/images/auckland-small-dog-pack-walk.jpg differ diff --git a/src/lib/images/dog-cutout.png b/src/lib/images/dog-cutout.png new file mode 100644 index 0000000..065f077 Binary files /dev/null and b/src/lib/images/dog-cutout.png differ diff --git a/src/lib/images/founder-image-aless-goodwalk.png b/src/lib/images/founder-image-aless-goodwalk.png new file mode 100644 index 0000000..58e5800 Binary files /dev/null and b/src/lib/images/founder-image-aless-goodwalk.png differ diff --git a/src/lib/images/goodwalk-auckland-dog-walking-logo-mobile.png b/src/lib/images/goodwalk-auckland-dog-walking-logo-mobile.png new file mode 100644 index 0000000..409cca1 Binary files /dev/null and b/src/lib/images/goodwalk-auckland-dog-walking-logo-mobile.png differ diff --git a/src/lib/images/goodwalk-auckland-dog-walking-logo.png b/src/lib/images/goodwalk-auckland-dog-walking-logo.png new file mode 100644 index 0000000..f3b19a5 Binary files /dev/null and b/src/lib/images/goodwalk-auckland-dog-walking-logo.png differ diff --git a/src/lib/images/goodwalk-dog-walker-alessandra.png b/src/lib/images/goodwalk-dog-walker-alessandra.png new file mode 100644 index 0000000..174b927 Binary files /dev/null and b/src/lib/images/goodwalk-dog-walker-alessandra.png differ diff --git a/src/lib/images/goodwalk-favicon-192.png b/src/lib/images/goodwalk-favicon-192.png new file mode 100644 index 0000000..a1dc6f4 Binary files /dev/null and b/src/lib/images/goodwalk-favicon-192.png differ diff --git a/src/lib/images/goodwalk-favicon-32.png b/src/lib/images/goodwalk-favicon-32.png new file mode 100644 index 0000000..8ef1d41 Binary files /dev/null and b/src/lib/images/goodwalk-favicon-32.png differ diff --git a/src/lib/images/google-g-logo.svg b/src/lib/images/google-g-logo.svg new file mode 100644 index 0000000..a54eeab --- /dev/null +++ b/src/lib/images/google-g-logo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/lib/images/monty-auckland-dog-walking-review.jpg b/src/lib/images/monty-auckland-dog-walking-review.jpg new file mode 100644 index 0000000..238ed7e Binary files /dev/null and b/src/lib/images/monty-auckland-dog-walking-review.jpg differ diff --git a/src/lib/images/monty-auckland-dog-walking-review.png b/src/lib/images/monty-auckland-dog-walking-review.png new file mode 100644 index 0000000..e0469d0 Binary files /dev/null and b/src/lib/images/monty-auckland-dog-walking-review.png differ diff --git a/src/lib/images/one-on-one-dog-portrait-1.png b/src/lib/images/one-on-one-dog-portrait-1.png new file mode 100644 index 0000000..c1f7a92 Binary files /dev/null and b/src/lib/images/one-on-one-dog-portrait-1.png differ diff --git a/src/lib/images/one-on-one-dog-portrait-2.png b/src/lib/images/one-on-one-dog-portrait-2.png new file mode 100644 index 0000000..6b9a522 Binary files /dev/null and b/src/lib/images/one-on-one-dog-portrait-2.png differ diff --git a/src/lib/images/one-on-one-dog-portrait-3.png b/src/lib/images/one-on-one-dog-portrait-3.png new file mode 100644 index 0000000..9163196 Binary files /dev/null and b/src/lib/images/one-on-one-dog-portrait-3.png differ diff --git a/src/lib/images/otis-auckland-dog-walking-review.jpg b/src/lib/images/otis-auckland-dog-walking-review.jpg new file mode 100644 index 0000000..ca33617 Binary files /dev/null and b/src/lib/images/otis-auckland-dog-walking-review.jpg differ diff --git a/src/lib/images/otis-auckland-dog-walking-review.png b/src/lib/images/otis-auckland-dog-walking-review.png new file mode 100644 index 0000000..c31a41c Binary files /dev/null and b/src/lib/images/otis-auckland-dog-walking-review.png differ diff --git a/src/lib/images/small-medium-dogs-pack-walk.png b/src/lib/images/small-medium-dogs-pack-walk.png new file mode 100644 index 0000000..3c8f049 Binary files /dev/null and b/src/lib/images/small-medium-dogs-pack-walk.png differ diff --git a/src/lib/images/smiling-dog-cutout.webp b/src/lib/images/smiling-dog-cutout.webp new file mode 100644 index 0000000..046761f Binary files /dev/null and b/src/lib/images/smiling-dog-cutout.webp differ diff --git a/src/lib/images/smiling-dogs-instagram-cta.png b/src/lib/images/smiling-dogs-instagram-cta.png new file mode 100644 index 0000000..cae2be7 Binary files /dev/null and b/src/lib/images/smiling-dogs-instagram-cta.png differ diff --git a/src/lib/images/tiny-gang-auckland-dog-pack.jpg b/src/lib/images/tiny-gang-auckland-dog-pack.jpg new file mode 100644 index 0000000..86fe353 Binary files /dev/null and b/src/lib/images/tiny-gang-auckland-dog-pack.jpg differ diff --git a/src/lib/images/wallace-auckland-dog-walking-review.jpg b/src/lib/images/wallace-auckland-dog-walking-review.jpg new file mode 100644 index 0000000..87611c5 Binary files /dev/null and b/src/lib/images/wallace-auckland-dog-walking-review.jpg differ diff --git a/src/lib/images/wallace-auckland-dog-walking-review.png b/src/lib/images/wallace-auckland-dog-walking-review.png new file mode 100644 index 0000000..ca0420a Binary files /dev/null and b/src/lib/images/wallace-auckland-dog-walking-review.png differ diff --git a/src/lib/seo.test.ts b/src/lib/seo.test.ts new file mode 100644 index 0000000..cb6c77f --- /dev/null +++ b/src/lib/seo.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { locationPages } from '$lib/content/locations'; +import { buildAreaServed, buildBreadcrumb, buildLocationSeo } from './seo'; +import type { LocationPageContent } from '$lib/types'; + +describe('seo helpers', () => { + it('derives area served from location content', () => { + const areaServed = buildAreaServed(); + + expect(areaServed).toHaveLength(locationPages.length); + expect(areaServed[0]).toEqual({ + '@type': 'Place', + name: `${locationPages[0].suburb}, Auckland, New Zealand` + }); + }); + + it('builds location seo from data with sensible defaults', () => { + const seo = buildLocationSeo(locationPages[0]); + + expect(seo.title).toBe('Dog Walkers in Mt Eden | Goodwalk Auckland'); + expect(seo.canonicalPath).toBe('/locations/mt-eden'); + expect(seo.image).toBe('/images/auckland-pack-walk-small-dogs-group.png'); + expect(seo.imageAlt).toBe('Goodwalk dog walkers in Mt Eden, Auckland'); + expect(seo.structuredData).toHaveLength(2); + }); + + it('builds breadcrumbs from real paths', () => { + const breadcrumb = buildBreadcrumb([ + { name: 'Home', url: 'https://www.goodwalk.co.nz' }, + { name: 'Mt Eden', path: '/locations/mt-eden' } + ]); + + expect(breadcrumb.itemListElement).toEqual([ + { + '@type': 'ListItem', + position: 1, + name: 'Home', + item: 'https://www.goodwalk.co.nz' + }, + { + '@type': 'ListItem', + position: 2, + name: 'Mt Eden', + item: 'https://www.goodwalk.co.nz/locations/mt-eden' + } + ]); + }); + + it('uses the first park image for seo when no explicit seo image is set', () => { + const location: LocationPageContent = { + suburb: 'Test Suburb', + slug: 'test-suburb', + intro: 'Test intro', + parks: [ + { + name: 'Test Park', + description: 'A local park for testing.', + image: { + src: '/images/test-park-photo.webp', + alt: 'Dogs walking through Test Park' + } + } + ] + }; + + const seo = buildLocationSeo(location); + + expect(seo.image).toBe('/images/test-park-photo.webp'); + expect(seo.imageAlt).toBe('Dogs walking through Test Park'); + }); +}); diff --git a/src/lib/seo.ts b/src/lib/seo.ts new file mode 100644 index 0000000..37900e5 --- /dev/null +++ b/src/lib/seo.ts @@ -0,0 +1,104 @@ +import { locationPages } from '$lib/content/locations'; +import type { LocationPageContent } from '$lib/types'; + +const siteUrl = 'https://www.goodwalk.co.nz'; +const defaultLocationImage = '/images/auckland-pack-walk-small-dogs-group.png'; +const defaultLocationImageAlt = 'Goodwalk Auckland dog walking services'; + +interface BreadcrumbItem { + name: string; + path?: string; + url?: string; +} + +export function absoluteUrl(value: string) { + if (!value) { + return siteUrl; + } + + if (value.startsWith('http://') || value.startsWith('https://')) { + return value; + } + + return `${siteUrl}${value.startsWith('/') ? value : `/${value}`}`; +} + +export function buildBreadcrumb(items: BreadcrumbItem[]) { + return { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: items.map((item, index) => ({ + '@type': 'ListItem', + position: index + 1, + name: item.name, + item: item.url ?? absoluteUrl(item.path ?? '/') + })) + }; +} + +export function buildAreaServed(locations = locationPages) { + return locations.map((location) => ({ + '@type': 'Place', + name: `${location.suburb}, Auckland, New Zealand` + })); +} + +function getLocationSeoImage(location: LocationPageContent) { + return location.seo?.image ?? location.parks.find((park) => park.image?.src)?.image?.src ?? defaultLocationImage; +} + +function getLocationSeoImageAlt(location: LocationPageContent) { + return ( + location.seo?.imageAlt ?? + location.parks.find((park) => park.image?.alt)?.image?.alt ?? + `Goodwalk dog walkers in ${location.suburb}, Auckland` + ); +} + +export function buildLocationSeo(location: LocationPageContent) { + const canonicalPath = `/locations/${location.slug}`; + const title = + location.seo?.title ?? `Dog Walkers in ${location.suburb} | Goodwalk Auckland`; + const description = + location.seo?.description ?? + `Goodwalk provides pack walks, 1:1 walks, and puppy visits in ${location.suburb}, Auckland Central. Small dog specialists with free pickup and drop-off. Book a free Meet & Greet.`; + const image = getLocationSeoImage(location); + const imageAlt = getLocationSeoImageAlt(location); + const breadcrumbLabel = location.seo?.breadcrumbLabel ?? location.suburb; + const serviceName = location.seo?.serviceName ?? `Goodwalk Dog Walking - ${location.suburb}`; + const serviceType = location.seo?.serviceType ?? 'Dog walking and puppy visits'; + + return { + title, + description, + canonicalPath, + image, + imageAlt, + structuredData: [ + { + '@context': 'https://schema.org', + '@type': 'Service', + name: serviceName, + description, + serviceType, + provider: { + '@type': 'LocalBusiness', + name: 'Goodwalk', + url: siteUrl, + telephone: '+64226421011', + email: 'info@goodwalk.co.nz' + }, + areaServed: { + '@type': 'Place', + name: `${location.suburb}, Auckland, New Zealand` + }, + image: absoluteUrl(image), + url: absoluteUrl(canonicalPath) + }, + buildBreadcrumb([ + { name: 'Home', url: siteUrl }, + { name: breadcrumbLabel, path: canonicalPath } + ]) + ] as Record[] + }; +} diff --git a/src/lib/styles/buttons.css b/src/lib/styles/buttons.css index 4f7376c..205c241 100644 --- a/src/lib/styles/buttons.css +++ b/src/lib/styles/buttons.css @@ -4,8 +4,11 @@ justify-content: center; gap: 10px; padding: 13px 28px; + font-family: var(--font-head); font-size: 14px; - font-weight: 600; + font-weight: 700; + line-height: 1.2; + letter-spacing: 0.01em; border-radius: 40px; cursor: pointer; transition: @@ -71,3 +74,11 @@ background: var(--gw-green); color: #fff; } + +@media (max-width: 768px) { + .btn-mobile-center { + display: flex; + width: fit-content; + margin-inline: auto; + } +} diff --git a/src/lib/styles/forms.css b/src/lib/styles/forms.css index 85e0007..35fd885 100644 --- a/src/lib/styles/forms.css +++ b/src/lib/styles/forms.css @@ -105,6 +105,12 @@ } } +@media (max-width: 1024px) { + .booking-title { + font-size: clamp(36px, 5vw, 52px); + } +} + @media (prefers-reduced-motion: reduce) { .booking-title-highlight::after { animation: none; @@ -189,19 +195,23 @@ .booking-panel { display: flex; flex-direction: column; - gap: 18px; + gap: 0; } .booking-panel-banner { background: linear-gradient(180deg, #f6f2ea 0%, #f1ece3 100%); color: #34363a; - border-radius: 30px; - padding: 24px 28px 34px; + border-radius: 28px 28px 0 0; + padding: 22px 28px 28px; text-align: center; font-family: var(--font-body); font-size: 15px; line-height: 1.55; - box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.05); + border: 1px solid rgba(17, 20, 24, 0.06); + border-bottom: none; + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.18), + 0 8px 22px rgba(17, 20, 24, 0.035); } .booking-card-grid { @@ -210,7 +220,11 @@ } .booking-card-grid-with-banner { - margin-top: -18px; + margin-top: 0; +} + +.booking-card-grid-with-banner .booking-field-card { + border-radius: 0 0 28px 28px; } .booking-card-grid-owner { diff --git a/src/lib/styles/layout.css b/src/lib/styles/layout.css index c8ab353..e345d83 100644 --- a/src/lib/styles/layout.css +++ b/src/lib/styles/layout.css @@ -353,7 +353,6 @@ nav { } .services-grid, -.values-grid, .testimonials-grid { margin-top: 48px; } @@ -366,8 +365,10 @@ nav { .values-grid { display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 28px; + grid-template-columns: repeat(2, 1fr); + gap: 0; + margin-top: 48px; + border-top: 1px solid rgba(255, 255, 255, 0.1); } .testimonials-grid { @@ -385,7 +386,7 @@ nav { .footer-inner { display: grid; - grid-template-columns: 1.25fr 0.95fr 1.15fr; + grid-template-columns: 1.1fr 0.8fr 0.8fr 1fr; gap: 24px; margin-bottom: 48px; align-items: start; diff --git a/src/lib/styles/responsive.css b/src/lib/styles/responsive.css index 4ebc617..3224813 100644 --- a/src/lib/styles/responsive.css +++ b/src/lib/styles/responsive.css @@ -28,10 +28,6 @@ .hero-text h1 { font-size: 40px; } - - .values-grid { - grid-template-columns: repeat(2, 1fr); - } } @media (max-width: 768px) { @@ -108,8 +104,11 @@ padding: 11px 14px; background: rgba(33, 48, 33, 0.1); color: var(--gw-green); + font-family: var(--font-head); font-size: 13px; - font-weight: 600; + font-weight: 700; + line-height: 1.2; + letter-spacing: 0.01em; } .mobile-phone .icon { @@ -191,6 +190,14 @@ animation-delay: 210ms; } + .mobile-menu.open a:nth-child(8) { + animation-delay: 240ms; + } + + .mobile-menu.open a:nth-child(9) { + animation-delay: 270ms; + } + #hero { min-height: auto; padding: 50px 20px 0; @@ -221,7 +228,7 @@ .hero-subtitle { margin: -10px 0 22px; max-width: none; - font-size: 15.5px; + font-size: 16px; line-height: 1.5; } @@ -239,7 +246,7 @@ display: block; color: #fff; font-family: var(--font-head); - font-size: 33.5px; + font-size: 36px; font-weight: 800; line-height: 1.08; letter-spacing: -0.04em; @@ -258,7 +265,7 @@ width: 0; min-width: 0; padding: 14px 10px; - font-size: 12.5px; + font-size: 13px; font-weight: 700; text-align: center; border-radius: 999px; @@ -376,7 +383,6 @@ } .services-grid, - .values-grid, .testimonials-grid, .info-inner, .field-group, @@ -384,6 +390,18 @@ grid-template-columns: 1fr; } + .values-grid { + grid-template-columns: 1fr; + } + + .value-card:nth-child(odd) { + border-right: none; + } + + .value-card { + padding: 24px 0; + } + .service-icon-bubble { width: 78px; height: 78px; @@ -489,6 +507,10 @@ border-radius: 24px; } + .booking-card-grid-with-banner .booking-field-card { + border-radius: 0 0 24px 24px; + } + .booking-field-card-group { padding: 24px 22px; } @@ -645,7 +667,7 @@ } .hero-text h1 .hero-heading-mobile { - font-size: 30px; + font-size: 31px; line-height: 1.12; } diff --git a/src/lib/styles/sections.css b/src/lib/styles/sections.css index 924c3a9..bf270b2 100644 --- a/src/lib/styles/sections.css +++ b/src/lib/styles/sections.css @@ -1,4 +1,9 @@ -section { +#promise, +#services, +#values, +#testimonials, +#info, +#newlead { padding: 80px 0; } @@ -43,8 +48,11 @@ section { background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.14); color: #fff; + font-family: var(--font-head); font-size: 14px; font-weight: 700; + line-height: 1.2; + letter-spacing: 0.01em; text-decoration: none; transition: background 0.2s ease, @@ -95,6 +103,12 @@ section { margin: -12px -18px -72px 0; } +@media (max-width: 1100px) { + .hero-img img { + margin-bottom: -32px; + } +} + #intro { background: #fff; padding: 8px 50px 26px; @@ -219,6 +233,11 @@ section { font-size: 20px; } +.intro-trust-cta:focus-visible { + outline: 2px solid rgba(10, 48, 78, 0.28); + outline-offset: 3px; +} + @keyframes introGoogleShine { 0%, 64%, @@ -420,38 +439,57 @@ footer { } .value-card { - background: rgba(255, 255, 255, 0.07); - border-radius: 28px; - padding: 32px 28px; - border: 1px solid rgba(255, 255, 255, 0.12); - transition: - transform 0.18s cubic-bezier(0.22, 1, 0.36, 1), - box-shadow 0.22s ease, - border-color 0.22s ease; + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 22px; + padding: 36px 32px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + transition: background 0.2s ease; +} + +.value-card:nth-child(odd) { + border-right: 1px solid rgba(255, 255, 255, 0.1); } @media (hover: hover) { .value-card:hover { - transform: translateY(-6px) scale(1.012); - box-shadow: 0 18px 34px rgba(0, 0, 0, 0.14); - border-color: rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.04); } } -.value-card:active { - transform: translateY(-1px) scale(0.994); +.value-icon-wrap { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + width: 52px; + height: 52px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.08); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); + margin-top: 2px; } .value-card .value-card-icon { - font-size: 28px; + font-size: 22px; color: var(--yellow); - margin-bottom: 16px; +} + +.value-text h3 { + margin: 0 0 8px; + font-family: var(--font-head); + font-size: 17px; + font-weight: 700; + color: #fff; + line-height: 1.2; } .value-card p { + margin: 0; font-size: 14px; line-height: 1.7; - opacity: 0.85; + opacity: 0.82; } .testimonial-card { @@ -501,36 +539,78 @@ footer { } .faq details { - border-bottom: 1px solid #ddd; - padding: 16px 0; + border-radius: 16px; + border: 1px solid rgba(33, 48, 33, 0.1); + background: #fff; + padding: 0; + margin-bottom: 10px; + transition: box-shadow 0.2s ease; +} + +.faq details[open] { + box-shadow: 0 8px 24px rgba(17, 20, 24, 0.06); } .faq summary { font-weight: 600; + font-size: 16px; cursor: pointer; list-style: none; display: flex; justify-content: space-between; align-items: center; + gap: 16px; + padding: 18px 22px; + border-radius: 16px; + transition: background 0.18s ease; } .faq summary::-webkit-details-marker { display: none; } +.faq summary:focus-visible { + outline: 2px solid rgba(10, 48, 78, 0.28); + outline-offset: -2px; +} + +.faq summary:hover { + background: rgba(33, 48, 33, 0.03); +} + +.faq details[open] summary { + border-bottom: 1px solid rgba(33, 48, 33, 0.08); + border-radius: 16px 16px 0 0; +} + .faq summary::after { content: '+'; - font-size: 20px; + flex: 0 0 auto; + width: 28px; + height: 28px; + border-radius: 50%; + background: rgba(33, 48, 33, 0.06); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 18px; + font-weight: 400; color: var(--gw-green); + transition: background 0.18s ease, transform 0.22s cubic-bezier(0.22, 1, 0.36, 1); } .faq details[open] summary::after { content: '−'; + background: rgba(33, 48, 33, 0.1); + transform: rotate(180deg); } .faq details p { - margin-top: 10px; + margin: 0; + padding: 16px 22px 20px; color: var(--gray); + font-size: 15px; + line-height: 1.65; } #instagram { @@ -552,7 +632,7 @@ footer { .footer-logo { display: block; - height: 24px; + height: 30px; width: auto; margin-bottom: 18px; } @@ -625,6 +705,10 @@ footer { gap: 2px 24px; } +.footer-explore .footer-nav { + grid-template-columns: 1fr; +} + .footer-nav li { margin: 0; min-width: 0; @@ -669,8 +753,11 @@ footer { border-radius: 999px; background: var(--yellow); color: #000; + font-family: var(--font-head); font-weight: 700; font-size: 15px; + line-height: 1.2; + letter-spacing: 0.01em; transition: background 0.2s, transform 0.15s; margin-bottom: 10px; } @@ -697,7 +784,11 @@ footer { border-radius: 999px; background: rgba(255, 255, 255, 0.07); border: 1px solid rgba(255, 255, 255, 0.12); + font-family: var(--font-head); font-size: 13px; + font-weight: 700; + line-height: 1.2; + letter-spacing: 0.01em; opacity: 0.8; transition: background 0.2s, opacity 0.2s; } @@ -747,6 +838,7 @@ footer { align-items: center; justify-content: space-between; flex-wrap: wrap; + width: 100%; gap: 12px; border-top: 1px solid rgba(255, 255, 255, 0.1); padding-top: 28px; @@ -754,10 +846,17 @@ footer { opacity: 0.6; } +.footer-bottom > span { + flex: 1 1 auto; +} + .footer-legal { display: flex; + justify-content: flex-end; gap: 20px; flex-wrap: wrap; + flex: 0 0 auto; + text-align: right; } .footer-legal a { diff --git a/src/lib/styles/typography.css b/src/lib/styles/typography.css index 0444ed2..7d080a4 100644 --- a/src/lib/styles/typography.css +++ b/src/lib/styles/typography.css @@ -25,6 +25,9 @@ #instagram h2 { font-family: var(--font-head); font-weight: 700; + line-height: 1.08; + letter-spacing: -0.03em; + text-wrap: balance; } .section-heading { diff --git a/src/lib/testimonials.ts b/src/lib/testimonials.ts new file mode 100644 index 0000000..d8be778 --- /dev/null +++ b/src/lib/testimonials.ts @@ -0,0 +1,26 @@ +import type { TestimonialContent } from '$lib/types'; + +function hashString(value: string) { + let hash = 0; + + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) >>> 0; + } + + return hash; +} + +export function getSeededTestimonialIndex( + testimonials: Array>, + seedKey: string +) { + if (!testimonials.length) { + return 0; + } + + if (!seedKey) { + return 0; + } + + return hashString(seedKey) % testimonials.length; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 1411f0b..da6396f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -172,17 +172,20 @@ export interface PricingPageContent { export interface AboutPageSection { title: string; + eyebrow?: string; body: string[]; imageUrl: string; imageAlt: string; reverse?: boolean; - accent?: 'plain' | 'gradient'; + accent?: 'plain' | 'gradient' | 'founder'; } export interface AboutPageContent { title: string; sections: AboutPageSection[]; - servicesTitle: string; + servicesTitle?: string; + faqTitle?: string; + faqs?: FaqItem[]; contact: { title: string; email: string; @@ -191,6 +194,35 @@ export interface AboutPageContent { }; } +export interface LocationPark { + name: string; + description: string; + leashNote?: string; + image?: { + src: string; + alt: string; + caption?: string; + }; +} + +export interface LocationSeoContent { + title?: string; + description?: string; + image?: string; + imageAlt?: string; + serviceName?: string; + serviceType?: string; + breadcrumbLabel?: string; +} + +export interface LocationPageContent { + suburb: string; + slug: string; + intro: string; + parks: LocationPark[]; + seo?: LocationSeoContent; +} + export interface LegalPageBlock { type: 'paragraph' | 'list'; content: string | string[]; diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index ec4f3a3..1fdfb95 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,8 +1,24 @@ import { getHomepageContent } from '$lib/server/content'; import { isHomepageHowItWorksEnabled } from '$lib/server/feature-flags'; -export async function load() { +const onboardingHosts = new Set(['onboarding.goodwalk.co.nz']); + +export async function load({ url }) { + const hostname = url.hostname.toLowerCase(); + const siteVariant = + onboardingHosts.has(hostname) || url.searchParams.get('preview') === 'onboarding' + ? 'onboarding' + : 'marketing'; + + if (siteVariant === 'onboarding') { + return { + siteVariant, + isPreview: url.searchParams.get('preview') === 'onboarding' + }; + } + return { + siteVariant, content: await getHomepageContent(), howItWorksEnabled: isHomepageHowItWorksEnabled() }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index ab15547..57a1fff 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -11,6 +11,7 @@ import ServicesSection from '$lib/components/ServicesSection.svelte'; import TestimonialsSection from '$lib/components/TestimonialsSection.svelte'; import ValuesSection from '$lib/components/ValuesSection.svelte'; + import OnboardingPage from '$lib/components/OnboardingPage.svelte'; import type { PageData } from './$types'; export let data: PageData; @@ -25,127 +26,136 @@ return `${siteUrl}${value.startsWith('/') ? value : `/${value}`}`; } - $: homepageStructuredData = [ - { - '@context': 'https://schema.org', - '@type': 'WebSite', - name: 'Goodwalk', - url: siteUrl, - inLanguage: 'en-NZ' - }, - { - '@context': 'https://schema.org', - '@type': 'LocalBusiness', - name: 'Goodwalk', - description: - 'Professional dog walking services across Auckland Central, including pack walks, 1:1 walks, and puppy visits.', - url: siteUrl, - logo: `${siteUrl}/images/goodwalk-auckland-dog-walking-logo.png`, - image: absoluteUrl(data.content.hero.imageUrl), - email: 'info@goodwalk.co.nz', - telephone: '+64-22-642-1011', - sameAs: ['https://www.instagram.com/goodwalk.nz/', 'https://g.page/r/CUsvrWPhkYrAEB0/'], - address: { - '@type': 'PostalAddress', - addressLocality: 'Auckland Central', - addressRegion: 'Auckland', - addressCountry: 'NZ' - }, - areaServed: [ - 'Morningside', - 'Kingsland', - 'Ponsonby', - 'Grey Lynn', - 'Mt Albert', - 'Mt Eden', - 'Sandringham', - 'Mt Roskill', - 'Arch Hill', - 'Freemans Bay', - 'Herne Bay', - 'Pt Chevalier', - 'Avondale', - 'Three Kings', - 'Hillsborough', - 'Eden Terrace', - 'Balmoral' - ], - openingHoursSpecification: [ - { - '@type': 'OpeningHoursSpecification', - dayOfWeek: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'], - opens: '08:00', - closes: '16:00' - } - ], - hasOfferCatalog: { - '@type': 'OfferCatalog', - name: 'Dog Walking Services', - itemListElement: data.content.services.map((service) => ({ - '@type': 'Offer', - itemOffered: { - '@type': 'Service', - name: service.title, - url: `${siteUrl}${service.href}` + $: homepageStructuredData = + data.siteVariant === 'marketing' && data.content + ? [ + { + '@context': 'https://schema.org', + '@type': 'WebSite', + name: 'Goodwalk', + url: siteUrl, + inLanguage: 'en-NZ' + }, + { + '@context': 'https://schema.org', + '@type': 'LocalBusiness', + name: 'Goodwalk', + description: + 'Professional dog walking services across Auckland Central, including pack walks, 1:1 walks, and puppy visits.', + url: siteUrl, + logo: `${siteUrl}/images/goodwalk-auckland-dog-walking-logo.png`, + image: absoluteUrl(data.content.hero.imageUrl), + email: 'info@goodwalk.co.nz', + telephone: '+64-22-642-1011', + sameAs: ['https://www.instagram.com/goodwalk.nz/', 'https://g.page/r/CUsvrWPhkYrAEB0/'], + address: { + '@type': 'PostalAddress', + addressLocality: 'Auckland Central', + addressRegion: 'Auckland', + addressCountry: 'NZ' + }, + areaServed: [ + 'Morningside', + 'Kingsland', + 'Ponsonby', + 'Grey Lynn', + 'Mt Albert', + 'Mt Eden', + 'Sandringham', + 'Mt Roskill', + 'Arch Hill', + 'Freemans Bay', + 'Herne Bay', + 'Pt Chevalier', + 'Avondale', + 'Three Kings', + 'Hillsborough', + 'Eden Terrace', + 'Balmoral' + ], + openingHoursSpecification: [ + { + '@type': 'OpeningHoursSpecification', + dayOfWeek: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'], + opens: '08:00', + closes: '16:00' + } + ], + hasOfferCatalog: { + '@type': 'OfferCatalog', + name: 'Dog Walking Services', + itemListElement: data.content.services.map((service) => ({ + '@type': 'Offer', + itemOffered: { + '@type': 'Service', + name: service.title, + url: `${siteUrl}${service.href}` + } + })) + }, + aggregateRating: { + '@type': 'AggregateRating', + ratingValue: '5.0', + bestRating: '5', + worstRating: '1', + reviewCount: String(data.content.testimonials.length) + }, + review: data.content.testimonials.map((testimonial) => ({ + '@context': 'https://schema.org', + '@type': 'Review', + reviewRating: { + '@type': 'Rating', + ratingValue: '5', + bestRating: '5' + }, + author: { + '@type': 'Person', + name: testimonial.reviewer + }, + reviewBody: testimonial.quote + })) + }, + { + '@context': 'https://schema.org', + '@type': 'FAQPage', + mainEntity: data.content.info.faqs.map((faq) => ({ + '@type': 'Question', + name: faq.question, + acceptedAnswer: { + '@type': 'Answer', + text: faq.answer + } + })) } - })) - }, - aggregateRating: { - '@type': 'AggregateRating', - ratingValue: '5.0', - bestRating: '5', - worstRating: '1', - reviewCount: String(data.content.testimonials.length) - }, - review: data.content.testimonials.map((testimonial) => ({ - '@type': 'Review', - reviewRating: { - '@type': 'Rating', - ratingValue: '5', - bestRating: '5' - }, - author: { - '@type': 'Person', - name: testimonial.reviewer - }, - reviewBody: testimonial.quote - })) - }, - { - '@context': 'https://schema.org', - '@type': 'FAQPage', - mainEntity: data.content.info.faqs.map((faq) => ({ - '@type': 'Question', - name: faq.question, - acceptedAnswer: { - '@type': 'Answer', - text: faq.answer - } - })) - } - ]; + ] + : []; - +{#if data.siteVariant === 'onboarding'} + +{:else} + {@const content = data.content!} + -
- - - -{#if data.howItWorksEnabled} - +
+ + + + {#if data.howItWorksEnabled} + + {/if} + + + + + +