Onboarding / Deployment Scripts / Marketing updates
@@ -52,7 +52,7 @@ containers untouched.
|
|||||||
- Repo-local SSH config used by the deployment script.
|
- Repo-local SSH config used by the deployment script.
|
||||||
- [nginx/goodwalk.co.nz.svelte.conf.example](nginx/goodwalk.co.nz.svelte.conf.example)
|
- [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
|
- 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
|
## First-time server preparation
|
||||||
|
|
||||||
@@ -238,6 +238,8 @@ nginx/goodwalk.co.nz.svelte.conf.example
|
|||||||
Important:
|
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.
|
- `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 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:
|
Manual nginx commands, if you ever need them:
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
## Chris Do's Principles
|
||||||
|
|||||||
@@ -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 {
|
function Invoke-SiteCheck {
|
||||||
param([string]$Url)
|
param([string]$Url)
|
||||||
|
|
||||||
@@ -242,6 +260,7 @@ if (-not $Force) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$archivePath = $null
|
$archivePath = $null
|
||||||
|
$uploadHelperPath = $null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
@@ -255,7 +274,8 @@ try {
|
|||||||
|
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Write-Host '[deploy] Uploading remote helper'
|
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 ''
|
||||||
Write-Host '[deploy] Uploading application archive'
|
Write-Host '[deploy] Uploading application archive'
|
||||||
@@ -310,4 +330,7 @@ finally {
|
|||||||
if ($archivePath -and (Test-Path -LiteralPath $archivePath)) {
|
if ($archivePath -and (Test-Path -LiteralPath $archivePath)) {
|
||||||
Remove-Item -LiteralPath $archivePath -Force
|
Remove-Item -LiteralPath $archivePath -Force
|
||||||
}
|
}
|
||||||
|
if ($uploadHelperPath -and (Test-Path -LiteralPath $uploadHelperPath)) {
|
||||||
|
Remove-Item -LiteralPath $uploadHelperPath -Force
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"mattcohen0@gmail.com": {
|
||||||
|
"fullName": "Matt Test",
|
||||||
|
"phone": "02124347477",
|
||||||
|
"dogName": "Geoffrey"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 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 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']
|
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 <bookings@goodwalk.co.nz>' 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 <bookings@goodwalk.co.nz>' 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 <bookings@goodwalk.co.nz>' 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 <bookings@goodwalk.co.nz>' 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 <bookings@goodwalk.co.nz>' 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 <bookings@goodwalk.co.nz>' 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 <bookings@goodwalk.co.nz>' 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 <bookings@goodwalk.co.nz>' 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 <bookings@goodwalk.co.nz>' 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 <bookings@goodwalk.co.nz>' 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 <bookings@goodwalk.co.nz>' 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 <bookings@goodwalk.co.nz>' 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 <bookings@goodwalk.co.nz>' 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 <bookings@goodwalk.co.nz>' 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 <bookings@goodwalk.co.nz>' 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 <bookings@goodwalk.co.nz>' 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 <bookings@goodwalk.co.nz>' 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 <bookings@goodwalk.co.nz>' 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 <bookings@goodwalk.co.nz>' 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 <bookings@goodwalk.co.nz>' 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 <bookings@goodwalk.co.nz>' 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 <bookings@goodwalk.co.nz>' 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 <bookings@goodwalk.co.nz>' 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 <bookings@goodwalk.co.nz>' 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 <bookings@goodwalk.co.nz>' 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)
|
||||||
|
|||||||
@@ -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 {
|
server {
|
||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
server_name goodwalk.co.nz;
|
server_name goodwalk.co.nz;
|
||||||
@@ -116,3 +130,66 @@ server {
|
|||||||
proxy_set_header Connection "upgrade";
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
server {
|
||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
server_name goodwalk.co.nz www.goodwalk.co.nz;
|
server_name goodwalk.co.nz www.goodwalk.co.nz;
|
||||||
@@ -49,3 +62,41 @@ server {
|
|||||||
proxy_set_header Connection "upgrade";
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,8 +13,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-node": "^5.2.11",
|
"@sveltejs/adapter-node": "^5.2.11",
|
||||||
|
"@sveltejs/enhanced-img": "^0.10.4",
|
||||||
"@sveltejs/kit": "^2.59.0",
|
"@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/jest-dom": "^6.9.1",
|
||||||
"@testing-library/svelte": "^5.3.1",
|
"@testing-library/svelte": "^5.3.1",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
@@ -274,6 +275,17 @@
|
|||||||
"node": ">=20.19.0"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
"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": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
@@ -1270,6 +1772,25 @@
|
|||||||
"@sveltejs/kit": "^2.4.0"
|
"@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": {
|
"node_modules/@sveltejs/kit": {
|
||||||
"version": "2.59.0",
|
"version": "2.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.59.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.59.0.tgz",
|
||||||
@@ -1313,43 +1834,42 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sveltejs/vite-plugin-svelte": {
|
"node_modules/@sveltejs/vite-plugin-svelte": {
|
||||||
"version": "5.1.1",
|
"version": "6.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz",
|
||||||
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
|
"integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
|
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
|
||||||
"debug": "^4.4.1",
|
|
||||||
"deepmerge": "^4.3.1",
|
"deepmerge": "^4.3.1",
|
||||||
"kleur": "^4.1.5",
|
"magic-string": "^0.30.21",
|
||||||
"magic-string": "^0.30.17",
|
"obug": "^2.1.0",
|
||||||
"vitefu": "^1.0.6"
|
"vitefu": "^1.1.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.0.0 || ^20.0.0 || >=22"
|
"node": "^20.19 || ^22.12 || >=24"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"vite": "^6.0.0"
|
"vite": "^6.3.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@sveltejs/vite-plugin-svelte-inspector": {
|
"node_modules/@sveltejs/vite-plugin-svelte-inspector": {
|
||||||
"version": "4.0.1",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz",
|
||||||
"integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==",
|
"integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.3.7"
|
"obug": "^2.1.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.0.0 || ^20.0.0 || >=22"
|
"node": "^20.19 || ^22.12 || >=24"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^6.0.0-next.0",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"vite": "^6.0.0"
|
"vite": "^6.3.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@testing-library/dom": {
|
"node_modules/@testing-library/dom": {
|
||||||
@@ -1835,24 +2355,6 @@
|
|||||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
"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": {
|
"node_modules/decimal.js": {
|
||||||
"version": "10.6.0",
|
"version": "10.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||||
@@ -1880,6 +2382,16 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/devalue": {
|
||||||
"version": "5.7.1",
|
"version": "5.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz",
|
"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": "^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": {
|
"node_modules/indent-string": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
|
||||||
@@ -2259,13 +2781,6 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
@@ -2663,6 +3178,19 @@
|
|||||||
"node": ">=v12.22.7"
|
"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": {
|
"node_modules/set-cookie-parser": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
|
||||||
@@ -2670,6 +3198,51 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/siginfo": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
@@ -2803,6 +3376,19 @@
|
|||||||
"typescript": ">=5.0.0"
|
"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": {
|
"node_modules/svelte/node_modules/is-reference": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||||
@@ -2920,6 +3506,14 @@
|
|||||||
"node": ">=20"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"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": {
|
"node_modules/vitefu": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
|
||||||
|
|||||||
@@ -17,8 +17,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-node": "^5.2.11",
|
"@sveltejs/adapter-node": "^5.2.11",
|
||||||
|
"@sveltejs/enhanced-img": "^0.10.4",
|
||||||
"@sveltejs/kit": "^2.59.0",
|
"@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/jest-dom": "^6.9.1",
|
||||||
"@testing-library/svelte": "^5.3.1",
|
"@testing-library/svelte": "^5.3.1",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,13 +47,18 @@ export function reveal(node: HTMLElement, options: RevealOptions = {}) {
|
|||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
|
node.classList.add('reveal-visible');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
node.classList.add('reveal-visible');
|
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||||
observer.disconnect();
|
const rect = entry.boundingClientRect;
|
||||||
break;
|
const fullyOutOfView = rect.bottom <= 0 || rect.top >= viewportHeight;
|
||||||
|
|
||||||
|
if (fullyOutOfView) {
|
||||||
|
node.classList.remove('reveal-visible');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,68 +1,164 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { accordion } from '$lib/actions/accordion';
|
||||||
import { reveal } from '$lib/actions/reveal';
|
import { reveal } from '$lib/actions/reveal';
|
||||||
import ServicesSection from '$lib/components/ServicesSection.svelte';
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import { getImageMetadata } from '$lib/image-metadata';
|
import { getEnhancedImage } from '$lib/enhanced-images';
|
||||||
import type { AboutPageContent, SiteSharedContent } from '$lib/types';
|
import type { AboutPageContent } from '$lib/types';
|
||||||
|
|
||||||
export let content: SiteSharedContent;
|
|
||||||
export let pageContent: AboutPageContent;
|
export let pageContent: AboutPageContent;
|
||||||
|
|
||||||
|
$: standardSections = pageContent.sections.filter((s) => s.accent !== 'founder');
|
||||||
|
$: founderSection = pageContent.sections.find((s) => s.accent === 'founder') ?? null;
|
||||||
|
const founderHeadingLead = 'Meet Aless,';
|
||||||
|
const founderHeadingHighlight = 'the heart of Goodwalk';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="about-page">
|
<main class="about-page">
|
||||||
|
|
||||||
|
<!-- ── Hero ── -->
|
||||||
<section class="about-hero">
|
<section class="about-hero">
|
||||||
<div class="about-inner">
|
<div class="about-inner">
|
||||||
|
<span class="about-hero-eyebrow">About Goodwalk</span>
|
||||||
<h1>{pageContent.title}</h1>
|
<h1>{pageContent.title}</h1>
|
||||||
|
<p class="about-hero-desc">Small dog specialists serving Auckland Central. A team your dog knows by name.</p>
|
||||||
|
<div class="about-hero-chips">
|
||||||
|
<a
|
||||||
|
href="https://g.page/r/CUsvrWPhkYrAEB0/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="about-hero-chip about-hero-chip-link"
|
||||||
|
>
|
||||||
|
<span class="about-chip-stars" aria-hidden="true">★★★★★</span>
|
||||||
|
30+ five-star Google reviews
|
||||||
|
</a>
|
||||||
|
<span class="about-hero-chip">Auckland Central</span>
|
||||||
|
<span class="about-hero-chip">Small dog specialists</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#each pageContent.sections as section}
|
<!-- ── Standard sections (Who we are, Our impact) ── -->
|
||||||
|
{#each standardSections as section}
|
||||||
|
{@const enhanced = getEnhancedImage(section.imageUrl)}
|
||||||
<section
|
<section
|
||||||
use:reveal
|
use:reveal
|
||||||
class:about-section-gradient={section.accent === 'gradient'}
|
|
||||||
class="about-section reveal-block"
|
class="about-section reveal-block"
|
||||||
|
class:about-section-gradient={section.accent === 'gradient'}
|
||||||
>
|
>
|
||||||
<div class:about-section-reverse={section.reverse} class="about-inner about-section-grid">
|
<div class="about-inner about-section-grid" class:about-section-reverse={section.reverse}>
|
||||||
<div class="about-copy">
|
<div class="about-copy">
|
||||||
|
{#if section.eyebrow}
|
||||||
|
<span class="about-eyebrow">{section.eyebrow}</span>
|
||||||
|
{/if}
|
||||||
<h2>{section.title}</h2>
|
<h2>{section.title}</h2>
|
||||||
{#each section.body as paragraph}
|
{#each section.body as paragraph}
|
||||||
<p>{paragraph}</p>
|
<p>{paragraph}</p>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="about-media">
|
<div class="about-media">
|
||||||
<img
|
{#if enhanced}
|
||||||
src={section.imageUrl}
|
<enhanced:img src={enhanced} alt={section.imageAlt} loading="lazy" decoding="async" />
|
||||||
alt={section.imageAlt}
|
{:else}
|
||||||
width={getImageMetadata(section.imageUrl)?.width}
|
<img src={section.imageUrl} alt={section.imageAlt} loading="lazy" decoding="async" />
|
||||||
height={getImageMetadata(section.imageUrl)?.height}
|
{/if}
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<ServicesSection services={content.services} heading={pageContent.servicesTitle} />
|
<!-- ── Founder section ── -->
|
||||||
|
{#if founderSection}
|
||||||
|
{@const founderEnhanced = getEnhancedImage(founderSection.imageUrl)}
|
||||||
|
<section use:reveal={{ delay: 50 }} class="about-founder reveal-block">
|
||||||
|
<div class="about-inner about-founder-grid">
|
||||||
|
<div class="about-founder-media">
|
||||||
|
{#if founderEnhanced}
|
||||||
|
<enhanced:img
|
||||||
|
src={founderEnhanced}
|
||||||
|
alt={founderSection.imageAlt}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<img
|
||||||
|
src={founderSection.imageUrl}
|
||||||
|
alt={founderSection.imageAlt}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="about-founder-copy">
|
||||||
|
{#if founderSection.eyebrow}
|
||||||
|
<span class="about-eyebrow">{founderSection.eyebrow}</span>
|
||||||
|
{/if}
|
||||||
|
<h2 class="about-founder-heading">
|
||||||
|
<span class="about-founder-heading-desktop">
|
||||||
|
<span class="about-founder-title-main">{founderHeadingLead}</span>
|
||||||
|
<br />
|
||||||
|
<span class="about-founder-title-highlight">{founderHeadingHighlight}</span>
|
||||||
|
</span>
|
||||||
|
<span class="about-founder-heading-mobile">
|
||||||
|
<span class="about-founder-title-main">{founderHeadingLead}</span>
|
||||||
|
<span class="about-founder-title-highlight">{founderHeadingHighlight}</span>
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
{#each founderSection.body as paragraph}
|
||||||
|
<p>{paragraph}</p>
|
||||||
|
{/each}
|
||||||
|
<a href="/contact-us" class="btn btn-green btn-mobile-center">Book a free Meet & Greet</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<section use:reveal={{ delay: 70 }} class="about-contact reveal-block">
|
<!-- ── FAQs ── -->
|
||||||
|
{#if pageContent.faqs && pageContent.faqs.length}
|
||||||
|
<section use:reveal={{ delay: 30 }} class="about-faq reveal-block">
|
||||||
|
<div class="about-inner">
|
||||||
|
<div class="about-faq-header">
|
||||||
|
<span class="about-eyebrow">FAQ</span>
|
||||||
|
<h2>{pageContent.faqTitle ?? 'Common questions'}</h2>
|
||||||
|
</div>
|
||||||
|
<div use:accordion class="faq about-faq-list">
|
||||||
|
{#each pageContent.faqs as item}
|
||||||
|
<details>
|
||||||
|
<summary>{item.question}</summary>
|
||||||
|
<p>{item.answer}</p>
|
||||||
|
</details>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- ── Contact CTA ── -->
|
||||||
|
<section use:reveal={{ delay: 50 }} class="about-contact reveal-block">
|
||||||
<div class="about-inner">
|
<div class="about-inner">
|
||||||
<div class="about-contact-card">
|
<div class="about-contact-card">
|
||||||
|
<span class="about-contact-eyebrow">Get in touch</span>
|
||||||
<h2>{pageContent.contact.title}</h2>
|
<h2>{pageContent.contact.title}</h2>
|
||||||
<div class="about-contact-grid">
|
<p class="about-contact-desc">Questions, pricing, or your first Meet & Greet — start here and we'll reply within 24 hours.</p>
|
||||||
|
<a class="btn btn-yellow btn-mobile-center about-contact-btn" href={pageContent.contact.cta.href}>
|
||||||
|
{pageContent.contact.cta.label}
|
||||||
|
</a>
|
||||||
|
<div class="about-contact-links">
|
||||||
<a class="about-contact-link" href={`mailto:${pageContent.contact.email}`}>
|
<a class="about-contact-link" href={`mailto:${pageContent.contact.email}`}>
|
||||||
|
<Icon name="fas fa-envelope" />
|
||||||
{pageContent.contact.email}
|
{pageContent.contact.email}
|
||||||
</a>
|
</a>
|
||||||
<a class="btn btn-yellow" href={pageContent.contact.cta.href}>
|
<a
|
||||||
{pageContent.contact.cta.label}
|
class="about-contact-link"
|
||||||
</a>
|
href={`tel:${pageContent.contact.phone.replace(/[^0-9+]/g, '')}`}
|
||||||
<a class="about-contact-link" href={`tel:${pageContent.contact.phone.replace(/[^0-9+]/g, '')}`}>
|
>
|
||||||
|
<Icon name="fas fa-phone" />
|
||||||
{pageContent.contact.phone}
|
{pageContent.contact.phone}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -76,47 +172,112 @@
|
|||||||
padding: 0 50px;
|
padding: 0 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Shared eyebrow ── */
|
||||||
|
.about-eyebrow,
|
||||||
|
.about-hero-eyebrow,
|
||||||
|
.about-contact-eyebrow {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-eyebrow {
|
||||||
|
background: rgba(33, 48, 33, 0.08);
|
||||||
|
color: var(--gw-green);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-hero-eyebrow,
|
||||||
|
.about-contact-eyebrow {
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hero ── */
|
||||||
.about-hero {
|
.about-hero {
|
||||||
padding: 72px 0 40px;
|
background: var(--gw-green);
|
||||||
}
|
color: #fff;
|
||||||
|
padding: 80px 0 72px;
|
||||||
.about-hero h1,
|
|
||||||
.about-copy h2,
|
|
||||||
.about-contact-card h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--font-head);
|
|
||||||
font-size: clamp(34px, 4vw, 56px);
|
|
||||||
line-height: 1.05;
|
|
||||||
letter-spacing: -0.04em;
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.about-hero h1 {
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.about-hero h1 {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: clamp(40px, 5vw, 68px);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.02;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-hero-desc {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto 28px;
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-hero-chips {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-hero-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-hero-chip-link {
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-hero-chip-link:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-chip-stars {
|
||||||
|
color: var(--yellow);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Standard sections ── */
|
||||||
.about-section {
|
.about-section {
|
||||||
padding: 0 0 88px;
|
padding: 88px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-section-gradient {
|
.about-section-gradient {
|
||||||
margin: 0 24px 88px;
|
|
||||||
padding: 40px 0;
|
|
||||||
border-radius: 28px;
|
|
||||||
background: linear-gradient(180deg, #f5efe6 0%, #f9f6ef 100%);
|
background: linear-gradient(180deg, #f5efe6 0%, #f9f6ef 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-section-grid {
|
.about-section-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
gap: 44px;
|
gap: 60px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-section-reverse {
|
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.about-section-reverse .about-copy {
|
.about-section-reverse .about-copy {
|
||||||
order: 2;
|
order: 2;
|
||||||
}
|
}
|
||||||
@@ -126,29 +287,227 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.about-copy h2 {
|
.about-copy h2 {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-family: var(--font-head);
|
||||||
font-size: clamp(28px, 3vw, 40px);
|
font-size: clamp(28px, 3vw, 40px);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.08;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: #0d1a0d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-copy p {
|
.about-copy p {
|
||||||
margin: 18px 0 0;
|
margin: 12px 0 0;
|
||||||
color: #34363a;
|
color: #34363a;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.about-media {
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 28px;
|
||||||
|
box-shadow: 0 16px 48px rgba(17, 20, 24, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.about-media img {
|
.about-media img {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 460px;
|
height: 100%;
|
||||||
aspect-ratio: 4 / 3;
|
|
||||||
height: auto;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
border-radius: 28px;
|
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
box-shadow: 0 16px 40px rgba(17, 20, 24, 0.08);
|
object-position: center top;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Founder section ── */
|
||||||
|
.about-founder {
|
||||||
|
padding: 88px 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-founder-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
||||||
|
gap: 64px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-founder-media img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 28px;
|
||||||
|
object-fit: cover;
|
||||||
|
box-shadow: 0 24px 56px rgba(17, 20, 24, 0.12);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-founder-copy h2 {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: clamp(30px, 3.5vw, 44px);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.06;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
text-wrap: balance;
|
||||||
|
color: #0d1a0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-founder-heading-desktop {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-founder-heading-mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-founder-heading-mobile .about-founder-title-main,
|
||||||
|
.about-founder-heading-mobile .about-founder-title-highlight {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-founder-title-main {
|
||||||
|
color: #0d1a0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-founder-title-highlight {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
color: #0d1a0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-founder-title-highlight::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: -6px;
|
||||||
|
bottom: -16px;
|
||||||
|
height: 24px;
|
||||||
|
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 34' fill='none'%3E%3Cpath d='M4 24C67 10 131 4 198 5c43 1 82 6 118 18' stroke='%23192419' stroke-width='8' stroke-linecap='round'/%3E%3C/svg%3E")
|
||||||
|
center/contain no-repeat;
|
||||||
|
transform-origin: left center;
|
||||||
|
animation: about-founder-underline-draw 0.9s cubic-bezier(0.22, 1, 0.36, 1) 0.2s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes about-founder-underline-draw {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scaleX(0.2) translateY(6px) rotate(-1.5deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
65% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scaleX(1.04) translateY(0) rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scaleX(1) translateY(0) rotate(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-founder-copy p {
|
||||||
|
margin: 14px 0 0;
|
||||||
|
color: #34363a;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-founder-copy .btn {
|
||||||
|
display: flex;
|
||||||
|
width: fit-content;
|
||||||
|
margin: 28px auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── FAQs ── */
|
||||||
|
.about-faq {
|
||||||
|
padding: 80px 0;
|
||||||
|
background: var(--off-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-faq-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-faq-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: clamp(28px, 3vw, 40px);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.08;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: #0d1a0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-faq-list {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Contact CTA ── */
|
||||||
|
.about-contact {
|
||||||
|
padding: 0 0 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-contact-card {
|
||||||
|
background: var(--gw-green);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 28px;
|
||||||
|
padding: 56px 48px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 20px 48px rgba(33, 48, 33, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-contact-card h2 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: clamp(28px, 3vw, 42px);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.08;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-contact-desc {
|
||||||
|
max-width: 440px;
|
||||||
|
margin: 0 auto 28px;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-contact-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-contact-links {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 32px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-contact-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-contact-link:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reveal ── */
|
||||||
:global(.reveal-ready.reveal-block) {
|
:global(.reveal-ready.reveal-block) {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translate3d(0, var(--reveal-distance, 24px), 0);
|
transform: translate3d(0, var(--reveal-distance, 24px), 0);
|
||||||
@@ -163,41 +522,12 @@
|
|||||||
transform: translate3d(0, 0, 0);
|
transform: translate3d(0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-contact {
|
/* ── Tablet ── */
|
||||||
padding: 0 0 88px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.about-contact-card {
|
|
||||||
border-radius: 28px;
|
|
||||||
background: #fff;
|
|
||||||
padding: 42px 48px;
|
|
||||||
box-shadow: 0 14px 34px rgba(17, 20, 24, 0.05);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.about-contact-card h2 {
|
|
||||||
font-size: clamp(28px, 3vw, 42px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.about-contact-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.about-contact-link {
|
|
||||||
color: #34363a;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.about-section-grid,
|
.about-section-grid,
|
||||||
.about-section-reverse {
|
.about-founder-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
gap: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-section-reverse .about-copy,
|
.about-section-reverse .about-copy,
|
||||||
@@ -205,38 +535,44 @@
|
|||||||
order: initial;
|
order: initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-contact-grid {
|
.about-founder-media img {
|
||||||
grid-template-columns: 1fr;
|
max-width: 420px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Mobile ── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.about-inner {
|
.about-inner {
|
||||||
padding: 0 24px;
|
padding: 0 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-hero {
|
.about-hero {
|
||||||
padding: 56px 0 24px;
|
padding: 56px 0 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-section,
|
.about-hero h1 {
|
||||||
.about-contact {
|
font-size: 38px;
|
||||||
padding-bottom: 64px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-section-gradient {
|
.about-hero-desc {
|
||||||
margin: 0 12px 64px;
|
font-size: 15px;
|
||||||
padding: 28px 0;
|
}
|
||||||
border-radius: 28px;
|
|
||||||
|
.about-hero-chip {
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-section {
|
||||||
|
padding: 60px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-section-grid {
|
.about-section-grid {
|
||||||
gap: 24px;
|
gap: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-copy h2,
|
.about-copy h2 {
|
||||||
.about-contact-card h2 {
|
font-size: 28px;
|
||||||
font-size: 30px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-copy p {
|
.about-copy p {
|
||||||
@@ -244,16 +580,55 @@
|
|||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.about-founder {
|
||||||
|
padding: 60px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-founder-grid {
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-founder-copy h2 {
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1.02;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-founder-heading-desktop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-founder-heading-mobile {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-founder-copy p {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-faq {
|
||||||
|
padding: 60px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-contact {
|
||||||
|
padding-bottom: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
.about-contact-card {
|
.about-contact-card {
|
||||||
padding: 30px 24px;
|
padding: 36px 24px;
|
||||||
|
border-radius: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-contact-grid {
|
.about-contact-links {
|
||||||
margin-top: 22px;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.about-contact-link {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
font-size: 18px;
|
.about-founder-title-highlight::after {
|
||||||
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -91,8 +91,11 @@
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(255, 255, 255, 0.12);
|
background: rgba(255, 255, 255, 0.12);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
font-family: var(--font-head);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
function toggleService(service: string, checked: boolean) {
|
||||||
noteInteraction();
|
noteInteraction();
|
||||||
|
|
||||||
if (checked) {
|
if (checked) {
|
||||||
selectedServices = [service, ...selectedServices.filter((item) => item !== service)];
|
selectedServices = sortSelectedServices([
|
||||||
|
...selectedServices.filter((item) => item !== service),
|
||||||
|
service
|
||||||
|
]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ async function fillDogStep() {
|
|||||||
await fireEvent.input(screen.getByLabelText(/Location/i), {
|
await fireEvent.input(screen.getByLabelText(/Location/i), {
|
||||||
target: { value: 'Kingsland' }
|
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.' }
|
target: { value: 'Loves small group walks.' }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import type { FooterContent, LinkItem } from '$lib/types';
|
import type { FooterContent, LinkItem } from '$lib/types';
|
||||||
|
import { locationPages } from '$lib/content/locations';
|
||||||
|
|
||||||
export let footer: FooterContent;
|
export let footer: FooterContent;
|
||||||
|
|
||||||
|
|
||||||
const socialLinks: LinkItem[] = [
|
const socialLinks: LinkItem[] = [
|
||||||
{ label: 'Instagram', href: 'https://www.instagram.com/goodwalk.nz/', external: true },
|
{ label: 'Instagram', href: 'https://www.instagram.com/goodwalk.nz/', external: true },
|
||||||
{ label: 'Facebook', href: 'https://facebook.com/goodwalk.nz', external: true },
|
{ label: 'Facebook', href: 'https://facebook.com/goodwalk.nz', external: true },
|
||||||
@@ -32,12 +34,10 @@
|
|||||||
<footer>
|
<footer>
|
||||||
<div class="footer-inner">
|
<div class="footer-inner">
|
||||||
<div class="footer-brand">
|
<div class="footer-brand">
|
||||||
<img
|
<enhanced:img
|
||||||
src="/images/goodwalk-auckland-dog-walking-logo.png"
|
src="$lib/images/goodwalk-auckland-dog-walking-logo.png"
|
||||||
alt="Goodwalk – Auckland dog walking service logo"
|
alt="Goodwalk – Auckland dog walking service logo"
|
||||||
class="footer-logo"
|
class="footer-logo"
|
||||||
width="241"
|
|
||||||
height="48"
|
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
@@ -72,6 +72,15 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-locations">
|
||||||
|
<p class="footer-col-label">Areas we serve</p>
|
||||||
|
<ul class="footer-nav">
|
||||||
|
{#each locationPages as loc}
|
||||||
|
<li><a href="/locations/{loc.slug}">{loc.suburb}</a></li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="footer-action footer-panel footer-panel-accent">
|
<div class="footer-action footer-panel footer-panel-accent">
|
||||||
<p class="footer-col-label">Get Started</p>
|
<p class="footer-col-label">Get Started</p>
|
||||||
<h3 class="footer-action-title">Ready when you are</h3>
|
<h3 class="footer-action-title">Ready when you are</h3>
|
||||||
|
|||||||
@@ -3,6 +3,12 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import type { NavigationContent } from '$lib/types';
|
import type { NavigationContent } from '$lib/types';
|
||||||
|
import type { Picture } from '@sveltejs/enhanced-img';
|
||||||
|
import logoDesktop from '$lib/images/goodwalk-auckland-dog-walking-logo.png?enhanced';
|
||||||
|
import logoMobile from '$lib/images/goodwalk-auckland-dog-walking-logo-mobile.png?enhanced';
|
||||||
|
|
||||||
|
const desktop = logoDesktop as Picture;
|
||||||
|
const mobile = logoMobile as Picture;
|
||||||
|
|
||||||
export let navigation: NavigationContent;
|
export let navigation: NavigationContent;
|
||||||
|
|
||||||
@@ -141,15 +147,18 @@
|
|||||||
|
|
||||||
<a href="/" class="logo" aria-label="Goodwalk – Auckland Dog Walking, home">
|
<a href="/" class="logo" aria-label="Goodwalk – Auckland Dog Walking, home">
|
||||||
<picture>
|
<picture>
|
||||||
<source
|
{#if mobile.sources?.webp}
|
||||||
media="(max-width: 768px)"
|
<source media="(max-width: 768px)" type="image/webp" srcset={mobile.sources.webp} />
|
||||||
srcset="/images/goodwalk-auckland-dog-walking-logo-mobile.png"
|
{/if}
|
||||||
/>
|
<source media="(max-width: 768px)" srcset={mobile.img.src} />
|
||||||
|
{#if desktop.sources?.webp}
|
||||||
|
<source type="image/webp" srcset={desktop.sources.webp} />
|
||||||
|
{/if}
|
||||||
<img
|
<img
|
||||||
src="/images/goodwalk-auckland-dog-walking-logo.png"
|
src={desktop.img.src}
|
||||||
alt="Goodwalk – Auckland dog walking service logo"
|
alt="Goodwalk – Auckland dog walking service logo"
|
||||||
width="241"
|
width={desktop.img.w}
|
||||||
height="48"
|
height={desktop.img.h}
|
||||||
decoding="async"
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
</picture>
|
</picture>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import { getImageMetadata } from '$lib/image-metadata';
|
import { getEnhancedImage } from '$lib/enhanced-images';
|
||||||
import type { CallToAction, HeroContent } from '$lib/types';
|
import type { CallToAction, HeroContent } from '$lib/types';
|
||||||
|
|
||||||
export let hero: HeroContent;
|
export let hero: HeroContent;
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
$: titleParts = splitTitle(hero.title);
|
$: titleParts = splitTitle(hero.title);
|
||||||
$: mobileTitle = hero.mobileTitle?.trim() || `${hero.title} ${hero.highlight}`.trim();
|
$: mobileTitle = hero.mobileTitle?.trim() || `${hero.title} ${hero.highlight}`.trim();
|
||||||
$: heroImage = getImageMetadata(hero.imageUrl);
|
$: heroEnhanced = getEnhancedImage(hero.imageUrl);
|
||||||
|
|
||||||
function splitTitle(title: string) {
|
function splitTitle(title: string) {
|
||||||
const trimmed = title.trim();
|
const trimmed = title.trim();
|
||||||
@@ -99,15 +99,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hero-img">
|
<div class="hero-img">
|
||||||
<img
|
{#if heroEnhanced}
|
||||||
src={hero.imageUrl}
|
<enhanced:img
|
||||||
alt={hero.imageAlt}
|
src={heroEnhanced}
|
||||||
width={heroImage?.width}
|
alt={hero.imageAlt}
|
||||||
height={heroImage?.height}
|
loading="eager"
|
||||||
loading="eager"
|
fetchpriority="high"
|
||||||
fetchpriority="high"
|
decoding="async"
|
||||||
decoding="async"
|
/>
|
||||||
/>
|
{:else}
|
||||||
|
<img
|
||||||
|
src={hero.imageUrl}
|
||||||
|
alt={hero.imageAlt}
|
||||||
|
loading="eager"
|
||||||
|
fetchpriority="high"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,43 +1,297 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { reveal } from '$lib/actions/reveal';
|
import { reveal } from '$lib/actions/reveal';
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import type { HowItWorksContent } from '$lib/types';
|
import type { HowItWorksContent } from '$lib/types';
|
||||||
|
|
||||||
export let content: HowItWorksContent;
|
export let content: HowItWorksContent;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section id="how-it-works" use:reveal={{ delay: 30 }} class="reveal-block">
|
<section id="how-it-works" use:reveal={{ delay: 30 }} class="reveal-block">
|
||||||
<div class="how-it-works-inner">
|
<div class="hiw-inner">
|
||||||
<div class="how-it-works-header">
|
|
||||||
|
<div class="hiw-header">
|
||||||
|
<span class="hiw-eyebrow">Getting started</span>
|
||||||
<h2 class="section-heading">{content.title}</h2>
|
<h2 class="section-heading">{content.title}</h2>
|
||||||
{#if content.intro}
|
{#if content.intro}
|
||||||
<p class="how-it-works-intro">{content.intro}</p>
|
<p class="hiw-intro">{content.intro}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="how-it-works-flow" aria-label="How it works">
|
<div class="hiw-steps">
|
||||||
{#each content.steps as step, index}
|
{#each content.steps as step, index}
|
||||||
<article class:how-it-works-step-payoff={index === content.steps.length - 1} class="how-it-works-step">
|
<div class="hiw-step">
|
||||||
<div class="how-it-works-rail-node" aria-hidden="true">
|
<div class="hiw-step-meta">
|
||||||
<span class="how-it-works-rail-dot"></span>
|
<span class="hiw-phase">{step.phase}</span>
|
||||||
|
<span class="hiw-num">0{index + 1}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="how-it-works-step-top">
|
<div class="hiw-icon-wrap">
|
||||||
<span class="how-it-works-count">{`0${index + 1}`}</span>
|
<Icon name={step.icon} className="hiw-step-icon" />
|
||||||
<span class="how-it-works-phase">{step.phase || `Step ${index + 1}`}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="how-it-works-copy">
|
<h3 class="hiw-title">{step.title}</h3>
|
||||||
<h3>{step.title}</h3>
|
<p class="hiw-body">{step.body}</p>
|
||||||
{#if step.benefit}
|
{#if step.benefit}
|
||||||
<p class="how-it-works-benefit">{step.benefit}</p>
|
<span class="hiw-benefit">
|
||||||
{/if}
|
<Icon name="fas fa-check" className="hiw-check-icon" />
|
||||||
</div>
|
{step.benefit}
|
||||||
<p class="how-it-works-body">{step.body}</p>
|
</span>
|
||||||
</article>
|
{/if}
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="hiw-cta">
|
||||||
|
<a href="#newlead" class="btn btn-green btn-mobile-center">Book your free Meet & Greet</a>
|
||||||
|
<p class="hiw-cta-note">Free, no-obligation. We reply within 24 hours.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
#how-it-works {
|
||||||
|
background: var(--off-white);
|
||||||
|
padding: 80px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiw-inner {
|
||||||
|
max-width: var(--max-w);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
.hiw-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiw-eyebrow {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(33, 48, 33, 0.08);
|
||||||
|
color: var(--gw-green);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiw-intro {
|
||||||
|
max-width: 580px;
|
||||||
|
margin: 16px auto 0;
|
||||||
|
color: #4c5056;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Steps grid ── */
|
||||||
|
.hiw-steps {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiw-step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 40px 36px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(17, 20, 24, 0.06);
|
||||||
|
box-shadow: 0 4px 16px rgba(17, 20, 24, 0.04);
|
||||||
|
transition: box-shadow 0.22s ease, transform 0.18s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiw-step:first-child {
|
||||||
|
border-radius: 28px 0 0 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiw-step:last-child {
|
||||||
|
border-radius: 0 28px 28px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiw-step + .hiw-step {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.hiw-step:hover {
|
||||||
|
box-shadow: 0 16px 40px rgba(17, 20, 24, 0.09);
|
||||||
|
transform: translateY(-4px);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Step meta (phase + number) ── */
|
||||||
|
.hiw-step-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiw-phase {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 5px 13px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--yellow);
|
||||||
|
color: #000;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiw-num {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(33, 48, 33, 0.28);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Icon ── */
|
||||||
|
.hiw-icon-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: var(--gw-green);
|
||||||
|
box-shadow: 0 10px 28px rgba(33, 48, 33, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiw-icon-wrap :global(.hiw-step-icon) {
|
||||||
|
font-size: 26px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Content ── */
|
||||||
|
.hiw-title {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: #0d1a0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiw-body {
|
||||||
|
margin: 0 0 20px;
|
||||||
|
color: #4c5056;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.65;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiw-benefit {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(33, 48, 33, 0.07);
|
||||||
|
color: var(--gw-green);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiw-benefit :global(.hiw-check-icon) {
|
||||||
|
font-size: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CTA ── */
|
||||||
|
.hiw-cta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiw-cta-note {
|
||||||
|
margin: 0;
|
||||||
|
color: #888;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#how-it-works {
|
||||||
|
padding: 64px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiw-inner {
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiw-header {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiw-intro {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiw-steps {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiw-step {
|
||||||
|
align-items: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
padding: 28px 24px;
|
||||||
|
border-radius: 24px !important;
|
||||||
|
border: 1px solid rgba(17, 20, 24, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiw-step + .hiw-step {
|
||||||
|
border-left: 1px solid rgba(17, 20, 24, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiw-step-meta {
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiw-icon-wrap {
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
|
border-radius: 16px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiw-icon-wrap :global(.hiw-step-icon) {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiw-title {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiw-body {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiw-cta {
|
||||||
|
margin-top: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reveal ── */
|
||||||
:global(.reveal-ready.reveal-block) {
|
:global(.reveal-ready.reveal-block) {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translate3d(0, var(--reveal-distance, 24px), 0);
|
transform: translate3d(0, var(--reveal-distance, 24px), 0);
|
||||||
@@ -51,215 +305,4 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translate3d(0, 0, 0);
|
transform: translate3d(0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#how-it-works {
|
|
||||||
background: #fff;
|
|
||||||
padding-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-it-works-inner {
|
|
||||||
max-width: var(--max-w);
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-it-works-header {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-it-works-intro {
|
|
||||||
max-width: 640px;
|
|
||||||
margin: 14px auto 0;
|
|
||||||
color: #4c5056;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-it-works-flow {
|
|
||||||
display: grid;
|
|
||||||
position: relative;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 28px;
|
|
||||||
align-items: stretch;
|
|
||||||
margin-top: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-it-works-flow::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: 18px;
|
|
||||||
right: 18px;
|
|
||||||
top: 22px;
|
|
||||||
height: 1px;
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
rgba(33, 48, 33, 0.12) 0%,
|
|
||||||
rgba(33, 48, 33, 0.28) 50%,
|
|
||||||
rgba(33, 48, 33, 0.12) 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-it-works-step {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 0 18px 0 0;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-it-works-step-payoff {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-it-works-rail-node {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 44px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-it-works-rail-dot {
|
|
||||||
display: inline-flex;
|
|
||||||
width: 11px;
|
|
||||||
height: 11px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--gw-green);
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 7px #fff,
|
|
||||||
0 0 0 8px rgba(33, 48, 33, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-it-works-step-top {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-it-works-count {
|
|
||||||
color: rgba(33, 48, 33, 0.3);
|
|
||||||
font-family: var(--font-head);
|
|
||||||
font-size: 36px;
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-it-works-phase {
|
|
||||||
color: var(--gw-green);
|
|
||||||
font-family: var(--font-head);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-it-works-copy {
|
|
||||||
padding: 22px 22px 20px;
|
|
||||||
border-radius: 24px;
|
|
||||||
background:
|
|
||||||
linear-gradient(180deg, rgba(255, 250, 240, 0.92) 0%, rgba(248, 244, 234, 0.92) 100%);
|
|
||||||
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-it-works-step h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 20px;
|
|
||||||
line-height: 1.18;
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-it-works-benefit {
|
|
||||||
margin: 10px 0 0;
|
|
||||||
color: #6b5830;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-it-works-body {
|
|
||||||
margin: 14px 0 0;
|
|
||||||
padding: 0 4px 0 22px;
|
|
||||||
color: #4c5056;
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
#how-it-works {
|
|
||||||
padding-top: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-it-works-inner {
|
|
||||||
padding: 0 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-it-works-intro {
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-it-works-flow {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 24px;
|
|
||||||
margin-top: 26px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-it-works-flow::before {
|
|
||||||
left: 5px;
|
|
||||||
right: auto;
|
|
||||||
top: 22px;
|
|
||||||
bottom: 22px;
|
|
||||||
width: 1px;
|
|
||||||
height: auto;
|
|
||||||
background: linear-gradient(
|
|
||||||
180deg,
|
|
||||||
rgba(33, 48, 33, 0.12) 0%,
|
|
||||||
rgba(33, 48, 33, 0.28) 50%,
|
|
||||||
rgba(33, 48, 33, 0.12) 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-it-works-step {
|
|
||||||
padding: 0 0 0 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-it-works-step-payoff {
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-it-works-rail-node {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
min-height: 0;
|
|
||||||
margin: 18px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-it-works-rail-dot {
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 5px #fff,
|
|
||||||
0 0 0 6px rgba(33, 48, 33, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-it-works-step-top {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-it-works-step h3 {
|
|
||||||
font-size: 18px;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-it-works-benefit {
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.how-it-works-body {
|
|
||||||
margin-top: 2px;
|
|
||||||
padding: 14px 2px 0 18px;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { accordion } from '$lib/actions/accordion';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import type { InfoContent } from '$lib/types';
|
import type { InfoContent } from '$lib/types';
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@
|
|||||||
|
|
||||||
<div class="info-block">
|
<div class="info-block">
|
||||||
<h2><Icon name="fas fa-circle-question" /> {info.faqTitle}</h2>
|
<h2><Icon name="fas fa-circle-question" /> {info.faqTitle}</h2>
|
||||||
<div class="faq">
|
<div use:accordion class="faq">
|
||||||
{#each info.faqs as faq}
|
{#each info.faqs as faq}
|
||||||
<details>
|
<details>
|
||||||
<summary>{faq.question}</summary>
|
<summary>{faq.question}</summary>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
export let instagram: HomePageContent['instagram'];
|
export let instagram: HomePageContent['instagram'];
|
||||||
|
|
||||||
const dogCutoutSrc = '/images/dog-cutout.png';
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside id="instagram" aria-label="Follow Goodwalk on Instagram">
|
<aside id="instagram" aria-label="Follow Goodwalk on Instagram">
|
||||||
@@ -22,7 +21,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="instagram-dog-wrap" aria-hidden="true">
|
<div class="instagram-dog-wrap" aria-hidden="true">
|
||||||
<img class="instagram-dog" src={dogCutoutSrc} alt="" loading="lazy" decoding="async" />
|
<enhanced:img src="$lib/images/dog-cutout.png" alt="" class="instagram-dog" loading="lazy" decoding="async" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -104,8 +104,10 @@
|
|||||||
border-left: 3px solid var(--gw-green);
|
border-left: 3px solid var(--gw-green);
|
||||||
font-family: var(--font-head);
|
font-family: var(--font-head);
|
||||||
font-size: clamp(14px, 1.4vw, 17px);
|
font-size: clamp(14px, 1.4vw, 17px);
|
||||||
line-height: 1.3;
|
font-weight: 700;
|
||||||
letter-spacing: -0.01em;
|
line-height: 1.08;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
text-wrap: balance;
|
||||||
color: #000;
|
color: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,863 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { sharedServices } from '$lib/content/services';
|
||||||
|
import { reveal } from '$lib/actions/reveal';
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
|
import { getEnhancedImage } from '$lib/enhanced-images';
|
||||||
|
import { getSeededTestimonialIndex } from '$lib/testimonials';
|
||||||
|
import type { LocationPageContent, TestimonialContent } from '$lib/types';
|
||||||
|
|
||||||
|
export let location: LocationPageContent;
|
||||||
|
export let testimonials: TestimonialContent[];
|
||||||
|
|
||||||
|
type ParkWithImage = LocationPageContent['parks'][number] & {
|
||||||
|
image: NonNullable<LocationPageContent['parks'][number]['image']>;
|
||||||
|
enhanced: ReturnType<typeof getEnhancedImage>;
|
||||||
|
};
|
||||||
|
|
||||||
|
$: featuredTestimonial = testimonials[getSeededTestimonialIndex(testimonials, location.slug)];
|
||||||
|
$: parksWithImages = location.parks
|
||||||
|
.filter((park): park is LocationPageContent['parks'][number] & { image: NonNullable<LocationPageContent['parks'][number]['image']> } => Boolean(park.image))
|
||||||
|
.map(
|
||||||
|
(park): ParkWithImage => ({
|
||||||
|
...park,
|
||||||
|
enhanced: getEnhancedImage(park.image.src)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
$: serviceLinks = sharedServices.map((service) => ({
|
||||||
|
label: service.title,
|
||||||
|
href: service.href,
|
||||||
|
desc: service.locationDescription,
|
||||||
|
icon: service.icon
|
||||||
|
}));
|
||||||
|
$: locationHighlights = [
|
||||||
|
{
|
||||||
|
icon: 'fas fa-map-location-dot',
|
||||||
|
label: 'Local routes',
|
||||||
|
value: `${location.parks.length}+ parks`,
|
||||||
|
detail: `Regular walking options in and around ${location.suburb}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-paw',
|
||||||
|
label: 'Services',
|
||||||
|
value: '3 ways to help',
|
||||||
|
detail: 'Pack walks, 1:1 walks, and puppy visits'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-van-shuttle',
|
||||||
|
label: 'Included',
|
||||||
|
value: 'Free pickup',
|
||||||
|
detail: 'Pickup and drop-off across the central suburbs'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="loc-page">
|
||||||
|
|
||||||
|
<!-- ── Hero ── -->
|
||||||
|
<section class="loc-hero">
|
||||||
|
<div class="loc-inner">
|
||||||
|
<span class="loc-hero-eyebrow">Auckland Central Dog Walking</span>
|
||||||
|
<h1>Dog walkers in {location.suburb}</h1>
|
||||||
|
<p class="loc-hero-desc">{location.intro}</p>
|
||||||
|
<div class="loc-hero-actions">
|
||||||
|
<a href="/contact-us" class="btn btn-yellow btn-mobile-center">Book a free Meet & Greet</a>
|
||||||
|
<a href="tel:+64226421011" class="loc-hero-phone">or call (022) 642 1011</a>
|
||||||
|
</div>
|
||||||
|
<div class="loc-hero-chips">
|
||||||
|
<a
|
||||||
|
href="https://g.page/r/CUsvrWPhkYrAEB0/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="loc-chip loc-chip-link"
|
||||||
|
>
|
||||||
|
<span class="loc-chip-stars" aria-hidden="true">★★★★★</span>
|
||||||
|
30+ five-star Google reviews
|
||||||
|
</a>
|
||||||
|
<span class="loc-chip">Small dog specialists</span>
|
||||||
|
<span class="loc-chip">Free pickup & drop-off</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="loc-highlights" aria-label={`Goodwalk highlights in ${location.suburb}`}>
|
||||||
|
<div class="loc-inner">
|
||||||
|
<div class="loc-highlights-grid">
|
||||||
|
{#each locationHighlights as highlight}
|
||||||
|
<div class="loc-highlight-card">
|
||||||
|
<div class="loc-highlight-top">
|
||||||
|
<div class="loc-highlight-icon-wrap">
|
||||||
|
<Icon name={highlight.icon} className="loc-highlight-icon" />
|
||||||
|
</div>
|
||||||
|
<span class="loc-highlight-label">{highlight.label}</span>
|
||||||
|
</div>
|
||||||
|
<strong>{highlight.value}</strong>
|
||||||
|
<p>{highlight.detail}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Parks ── -->
|
||||||
|
<section use:reveal={{ delay: 30 }} class="loc-parks reveal-block">
|
||||||
|
<div class="loc-inner">
|
||||||
|
<div class="loc-section-header">
|
||||||
|
<span class="loc-eyebrow">Where we walk</span>
|
||||||
|
<h2>Parks & walks in {location.suburb}</h2>
|
||||||
|
<p class="loc-section-intro">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="loc-parks-grid">
|
||||||
|
{#each location.parks as park}
|
||||||
|
<div class="loc-park-card">
|
||||||
|
<div class="loc-park-icon" aria-hidden="true">🐾</div>
|
||||||
|
<h3>{park.name}</h3>
|
||||||
|
<p>{park.description}</p>
|
||||||
|
{#if park.leashNote}
|
||||||
|
<span class="loc-park-leash">{park.leashNote}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if parksWithImages.length > 0}
|
||||||
|
<section use:reveal={{ delay: 30 }} class="loc-gallery reveal-block">
|
||||||
|
<div class="loc-inner">
|
||||||
|
<div class="loc-section-header">
|
||||||
|
<span class="loc-eyebrow">Local parks</span>
|
||||||
|
<h2>Park photos from {location.suburb}</h2>
|
||||||
|
<p class="loc-section-intro">
|
||||||
|
Real images from the parks we mention help each suburb page feel more specific and give search engines clearer local context.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="loc-gallery-grid">
|
||||||
|
{#each parksWithImages as park}
|
||||||
|
<figure class="loc-gallery-card">
|
||||||
|
{#if park.enhanced}
|
||||||
|
<picture>
|
||||||
|
<img src={park.enhanced.img.src} alt={park.image.alt} loading="lazy" decoding="async" />
|
||||||
|
</picture>
|
||||||
|
{:else}
|
||||||
|
<img src={park.image.src} alt={park.image.alt} loading="lazy" decoding="async" />
|
||||||
|
{/if}
|
||||||
|
<figcaption>
|
||||||
|
<strong>{park.name}</strong>
|
||||||
|
{#if park.image.caption}
|
||||||
|
<span>{park.image.caption}</span>
|
||||||
|
{/if}
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- ── Services ── -->
|
||||||
|
<section use:reveal={{ delay: 30 }} class="loc-services reveal-block">
|
||||||
|
<div class="loc-inner">
|
||||||
|
<div class="loc-section-header">
|
||||||
|
<span class="loc-eyebrow">What we offer</span>
|
||||||
|
<h2>Goodwalk services in {location.suburb}</h2>
|
||||||
|
<p class="loc-section-intro">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="loc-services-grid">
|
||||||
|
{#each serviceLinks as svc}
|
||||||
|
<a href={svc.href} class="loc-service-card">
|
||||||
|
<div class="loc-service-icon-bubble">
|
||||||
|
<Icon name={svc.icon} className="loc-service-icon" />
|
||||||
|
</div>
|
||||||
|
<h3>{svc.label}</h3>
|
||||||
|
<p>{svc.desc}</p>
|
||||||
|
<span class="loc-service-link">Learn more →</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Testimonial ── -->
|
||||||
|
{#if featuredTestimonial}
|
||||||
|
<section use:reveal={{ delay: 30 }} class="loc-review reveal-block">
|
||||||
|
<div class="loc-inner">
|
||||||
|
<div class="loc-review-card">
|
||||||
|
<span class="loc-review-stars" aria-hidden="true">★★★★★</span>
|
||||||
|
<blockquote class="loc-review-quote">"{featuredTestimonial.quote}"</blockquote>
|
||||||
|
<cite class="loc-review-cite">
|
||||||
|
{featuredTestimonial.reviewer}
|
||||||
|
{#if featuredTestimonial.detail}
|
||||||
|
<span class="loc-review-detail">— {featuredTestimonial.detail}</span>
|
||||||
|
{/if}
|
||||||
|
</cite>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- ── CTA ── -->
|
||||||
|
<section use:reveal={{ delay: 30 }} class="loc-cta reveal-block">
|
||||||
|
<div class="loc-inner">
|
||||||
|
<div class="loc-cta-card">
|
||||||
|
<span class="loc-cta-eyebrow">Get in touch</span>
|
||||||
|
<h2>Ready to get started in {location.suburb}?</h2>
|
||||||
|
<p class="loc-cta-desc">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<a class="btn btn-yellow btn-mobile-center loc-cta-btn" href="/contact-us">Book a free Meet & Greet</a>
|
||||||
|
<div class="loc-cta-links">
|
||||||
|
<a class="loc-cta-link" href="mailto:info@goodwalk.co.nz">info@goodwalk.co.nz</a>
|
||||||
|
<a class="loc-cta-link" href="tel:+64226421011">(022) 642 1011</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.loc-page {
|
||||||
|
background: var(--off-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-inner {
|
||||||
|
max-width: var(--max-w);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Eyebrow ── */
|
||||||
|
.loc-eyebrow,
|
||||||
|
.loc-hero-eyebrow,
|
||||||
|
.loc-cta-eyebrow {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-eyebrow {
|
||||||
|
background: rgba(33, 48, 33, 0.08);
|
||||||
|
color: var(--gw-green);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-hero-eyebrow,
|
||||||
|
.loc-cta-eyebrow {
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hero ── */
|
||||||
|
.loc-hero {
|
||||||
|
background: var(--gw-green);
|
||||||
|
color: #fff;
|
||||||
|
padding: 80px 0 112px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-hero h1 {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: clamp(36px, 5vw, 64px);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.02;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-hero-desc {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto 28px;
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-hero-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-hero-phone {
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
font-size: 15px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-hero-phone:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-hero-chips {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-chip-link {
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-chip-link:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-chip-stars {
|
||||||
|
color: var(--yellow);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Highlights ── */
|
||||||
|
.loc-highlights {
|
||||||
|
margin-top: -56px;
|
||||||
|
padding: 0 0 88px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-highlights-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-highlight-card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 24px 24px 22px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(255, 209, 71, 0.22), transparent 34%),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.99) 0%, #f7f4ec 100%);
|
||||||
|
border: 1px solid rgba(17, 20, 24, 0.07);
|
||||||
|
box-shadow: 0 18px 44px rgba(13, 26, 13, 0.09);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-highlight-card::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: -18px;
|
||||||
|
bottom: -18px;
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(33, 48, 33, 0.05);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-highlight-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-highlight-icon-wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(180deg, #ffe173 0%, #ffd54a 100%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(17, 20, 24, 0.05),
|
||||||
|
0 10px 18px rgba(255, 209, 71, 0.24);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.loc-highlight-icon-wrap .loc-highlight-icon) {
|
||||||
|
color: var(--gw-green);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-highlight-label {
|
||||||
|
display: inline-block;
|
||||||
|
color: var(--gw-green);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-highlight-card strong {
|
||||||
|
display: block;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: #0d1a0d;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: clamp(22px, 2.5vw, 28px);
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-highlight-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: #4c5056;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section headers ── */
|
||||||
|
.loc-section-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-section-header h2 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: clamp(26px, 3vw, 38px);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.08;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
text-wrap: balance;
|
||||||
|
color: #0d1a0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-section-intro {
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
color: #4c5056;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Parks ── */
|
||||||
|
.loc-parks {
|
||||||
|
padding: 0 0 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-parks-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-park-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 32px 28px;
|
||||||
|
border: 1px solid rgba(17, 20, 24, 0.07);
|
||||||
|
box-shadow: 0 4px 16px rgba(17, 20, 24, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-park-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-park-card h3 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: #0d1a0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-park-card p {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
color: #4c5056;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-park-leash {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(33, 48, 33, 0.07);
|
||||||
|
color: var(--gw-green);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Gallery ── */
|
||||||
|
.loc-gallery {
|
||||||
|
padding: 0 0 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-gallery-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-gallery-card {
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(17, 20, 24, 0.07);
|
||||||
|
box-shadow: 0 8px 28px rgba(17, 20, 24, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-gallery-card picture,
|
||||||
|
.loc-gallery-card img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-gallery-card img {
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-gallery-card figcaption {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 18px 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-gallery-card strong {
|
||||||
|
color: #0d1a0d;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-gallery-card span {
|
||||||
|
color: #4c5056;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Services ── */
|
||||||
|
.loc-services {
|
||||||
|
padding: 0 0 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-services-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-service-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 28px 24px;
|
||||||
|
background: var(--gw-green);
|
||||||
|
border-radius: 20px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: transform 0.18s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.loc-highlight-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 20px 40px rgba(13, 26, 13, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-park-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 16px 32px rgba(17, 20, 24, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-service-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 16px 36px rgba(33, 48, 33, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-park-card,
|
||||||
|
.loc-highlight-card,
|
||||||
|
.loc-gallery-card,
|
||||||
|
.loc-service-card {
|
||||||
|
transition: transform 0.18s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-service-icon-bubble {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
margin: 0 0 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(180deg, #ffe173 0%, #ffd54a 100%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(17, 20, 24, 0.05),
|
||||||
|
0 10px 24px rgba(17, 20, 24, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.loc-service-icon-bubble .loc-service-icon) {
|
||||||
|
color: var(--gw-green);
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-service-card h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-service-card p {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-service-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 18px;
|
||||||
|
color: var(--yellow);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Review ── */
|
||||||
|
.loc-review {
|
||||||
|
padding: 0 0 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-review-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 48px 56px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid rgba(17, 20, 24, 0.06);
|
||||||
|
box-shadow: 0 8px 32px rgba(17, 20, 24, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-review-stars {
|
||||||
|
display: block;
|
||||||
|
color: var(--yellow);
|
||||||
|
font-size: 20px;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-review-quote {
|
||||||
|
margin: 0 0 20px;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: clamp(18px, 2.2vw, 24px);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: #0d1a0d;
|
||||||
|
font-style: normal;
|
||||||
|
max-width: 720px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-review-cite {
|
||||||
|
font-style: normal;
|
||||||
|
color: var(--gw-green);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-review-detail {
|
||||||
|
font-weight: 400;
|
||||||
|
color: #888;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CTA ── */
|
||||||
|
.loc-cta {
|
||||||
|
padding: 0 0 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-cta-card {
|
||||||
|
background: var(--gw-green);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 28px;
|
||||||
|
padding: 56px 48px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 20px 48px rgba(33, 48, 33, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-cta-card h2 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: clamp(26px, 3vw, 40px);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.08;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-cta-desc {
|
||||||
|
max-width: 460px;
|
||||||
|
margin: 0 auto 28px;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-cta-btn {
|
||||||
|
display: flex;
|
||||||
|
width: fit-content;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-cta-links {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 32px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-cta-link {
|
||||||
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-cta-link:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reveal ── */
|
||||||
|
:global(.reveal-ready.reveal-block) {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(0, var(--reveal-distance, 24px), 0);
|
||||||
|
transition:
|
||||||
|
opacity 0.55s ease,
|
||||||
|
transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
transition-delay: var(--reveal-delay, 0ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.reveal-visible.reveal-block) {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tablet ── */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.loc-highlights-grid,
|
||||||
|
.loc-parks-grid,
|
||||||
|
.loc-gallery-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.loc-inner {
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-hero {
|
||||||
|
padding: 56px 0 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-hero h1 {
|
||||||
|
font-size: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-hero-desc {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-hero-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-highlights {
|
||||||
|
margin-top: -24px;
|
||||||
|
padding-bottom: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-highlights-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-highlight-card {
|
||||||
|
padding: 20px 18px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-parks {
|
||||||
|
padding: 60px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-parks-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-gallery {
|
||||||
|
padding-bottom: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-gallery-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-services {
|
||||||
|
padding-bottom: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-services-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-review {
|
||||||
|
padding-bottom: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-review-card {
|
||||||
|
padding: 32px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-cta {
|
||||||
|
padding-bottom: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-cta-card {
|
||||||
|
padding: 36px 24px;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-cta-links {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -24,7 +24,11 @@
|
|||||||
const mobileCtaButtonEnabled = isMobileCtaButtonEnabled();
|
const mobileCtaButtonEnabled = isMobileCtaButtonEnabled();
|
||||||
|
|
||||||
$: pathname = $page.url.pathname;
|
$: 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 visible = false;
|
||||||
let triggerPassed = false;
|
let triggerPassed = false;
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
|
|
||||||
|
export let context: 'onboarding' | 'contract' = 'onboarding';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{ authenticated: { email: string; profile: Record<string, string>; draft: Record<string, unknown> } }>();
|
||||||
|
|
||||||
|
const ownerEmail = 'info@goodwalk.co.nz';
|
||||||
|
const ownerPhone = '(022) 642 1011';
|
||||||
|
|
||||||
|
let stage: 'email' | 'code' = 'email';
|
||||||
|
let emailValue = '';
|
||||||
|
let codeValue = '';
|
||||||
|
let loading = false;
|
||||||
|
let error = '';
|
||||||
|
|
||||||
|
async function requestCode() {
|
||||||
|
const trimmed = emailValue.trim();
|
||||||
|
if (!trimmed) { error = 'Please enter your email address'; return; }
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/request-code', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: trimmed }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
if (!res.ok) throw new Error(data?.detail ?? 'Failed to send code. Please try again.');
|
||||||
|
stage = 'code';
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Something went wrong';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyCode() {
|
||||||
|
const trimmed = codeValue.trim();
|
||||||
|
if (!trimmed) { error = 'Please enter the code'; return; }
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/verify-code', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: emailValue.trim(), code: trimmed }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
if (!res.ok) throw new Error(data?.detail ?? 'Incorrect code. Please try again.');
|
||||||
|
try { window.localStorage.setItem('gw_onboarding_session', data.token); } catch { /* ignore */ }
|
||||||
|
let profile: Record<string, string> = {};
|
||||||
|
let draft: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
const verifyRes = await fetch('/api/auth/verify', {
|
||||||
|
headers: { Authorization: `Bearer ${data.token}` },
|
||||||
|
});
|
||||||
|
if (verifyRes.ok) {
|
||||||
|
const verifyData = await verifyRes.json();
|
||||||
|
profile = verifyData.profile ?? {};
|
||||||
|
draft = verifyData.draft ?? {};
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
dispatch('authenticated', { email: data.email, profile, draft });
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Something went wrong';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEmailKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') requestCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCodeKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') verifyCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
stage = 'email';
|
||||||
|
codeValue = '';
|
||||||
|
error = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="auth-wrap">
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="auth-icon">
|
||||||
|
<Icon name="fas fa-lock" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if stage === 'email'}
|
||||||
|
<h2>Sign in to continue</h2>
|
||||||
|
<p>Enter the email address you used when enquiring with Goodwalk. We'll send you a one-time code.</p>
|
||||||
|
|
||||||
|
<div class="auth-field">
|
||||||
|
<label for="auth-email">Email address</label>
|
||||||
|
<input
|
||||||
|
id="auth-email"
|
||||||
|
type="email"
|
||||||
|
bind:value={emailValue}
|
||||||
|
on:keydown={handleEmailKey}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
autocomplete="email"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="auth-error">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button class="btn btn-yellow auth-btn" on:click={requestCode} disabled={loading}>
|
||||||
|
{#if loading}Sending…{:else}Send code <Icon name="fas fa-arrow-right" />{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<h2>Enter your code</h2>
|
||||||
|
<p>We sent a 6-digit code to <strong>{emailValue}</strong>. It expires in 10 minutes.</p>
|
||||||
|
|
||||||
|
<div class="auth-field">
|
||||||
|
<label for="auth-code">One-time code</label>
|
||||||
|
<input
|
||||||
|
id="auth-code"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
maxlength="6"
|
||||||
|
bind:value={codeValue}
|
||||||
|
on:keydown={handleCodeKey}
|
||||||
|
placeholder="123456"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
disabled={loading}
|
||||||
|
class="auth-code-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="auth-error">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button class="btn btn-yellow auth-btn" on:click={verifyCode} disabled={loading}>
|
||||||
|
{#if loading}Verifying…{:else}Verify code <Icon name="fas fa-arrow-right" />{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="auth-back" on:click={goBack}>
|
||||||
|
<Icon name="fas fa-arrow-left" /> Use a different email
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="auth-help">
|
||||||
|
<span>Need help?</span>
|
||||||
|
<a href="mailto:{ownerEmail}">{ownerEmail}</a>
|
||||||
|
<span>or</span>
|
||||||
|
<a href="tel:{ownerPhone.replace(/[^0-9+]/g, '')}">{ownerPhone}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="auth-copyright">
|
||||||
|
<a href="https://goodwalk.co.nz">goodwalk.co.nz</a>
|
||||||
|
<span>·</span>
|
||||||
|
<span>© {new Date().getFullYear()} Goodwalk. All rights reserved.</span>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.auth-wrap {
|
||||||
|
padding: 32px 28px 64px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
padding: 36px 32px;
|
||||||
|
border-radius: 28px;
|
||||||
|
background: rgba(255, 255, 255, 0.88);
|
||||||
|
border: 1px solid rgba(33, 48, 33, 0.08);
|
||||||
|
box-shadow: 0 20px 48px rgba(33, 48, 33, 0.09);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-icon {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(180deg, #ffe36b 0%, #ffd100 100%);
|
||||||
|
color: #213021;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 22px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card h2 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: clamp(22px, 3vw, 30px);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: #213021;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card p {
|
||||||
|
margin: 0 0 24px;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: rgba(33, 48, 33, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card p strong {
|
||||||
|
color: #213021;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-field label {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: #213021;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-field input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 15px 16px;
|
||||||
|
border: 1px solid rgba(33, 48, 33, 0.14);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #fff;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #213021;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.18s ease, box-shadow 0.18s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-field input:focus {
|
||||||
|
border-color: rgba(255, 209, 0, 0.9);
|
||||||
|
box-shadow: 0 0 0 4px rgba(255, 209, 0, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-code-input {
|
||||||
|
font-size: 28px !important;
|
||||||
|
font-family: var(--font-head) !important;
|
||||||
|
font-weight: 800 !important;
|
||||||
|
letter-spacing: 0.22em !important;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #fff3ef;
|
||||||
|
color: #a43f2c;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-btn {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(33, 48, 33, 0.12);
|
||||||
|
background: transparent;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(33, 48, 33, 0.65);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-back:hover {
|
||||||
|
background: rgba(33, 48, 33, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-help {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid rgba(33, 48, 33, 0.07);
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(33, 48, 33, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-help a {
|
||||||
|
color: rgba(33, 48, 33, 0.75);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-help a:hover {
|
||||||
|
color: #213021;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-copyright {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 12px 28px;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.07);
|
||||||
|
font-size: 12px;
|
||||||
|
color: #aaa;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-copyright a {
|
||||||
|
color: #888;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-copyright a:hover {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.auth-wrap {
|
||||||
|
padding: 20px 18px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
padding: 26px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
|
|
||||||
|
export let email = '';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{ logout: void }>();
|
||||||
|
|
||||||
|
let loggingOut = false;
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
loggingOut = true;
|
||||||
|
try {
|
||||||
|
const token = window.localStorage.getItem('gw_onboarding_session') ?? '';
|
||||||
|
if (token) {
|
||||||
|
await fetch('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}).catch(() => { /* ignore network errors on logout */ });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { window.localStorage.removeItem('gw_onboarding_session'); } catch { /* ignore */ }
|
||||||
|
loggingOut = false;
|
||||||
|
dispatch('logout');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<footer class="ob-footer">
|
||||||
|
<div class="ob-footer-inner">
|
||||||
|
<div class="ob-footer-identity">
|
||||||
|
<Icon name="fas fa-circle-check" />
|
||||||
|
<span>Signed in as <strong>{email}</strong></span>
|
||||||
|
</div>
|
||||||
|
<button class="ob-footer-logout" on:click={logout} disabled={loggingOut}>
|
||||||
|
<Icon name="fas fa-right-from-bracket" />
|
||||||
|
{loggingOut ? 'Signing out…' : 'Sign out'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="ob-footer-copyright">
|
||||||
|
<a href="https://goodwalk.co.nz">goodwalk.co.nz</a>
|
||||||
|
<span>·</span>
|
||||||
|
<span>© {new Date().getFullYear()} Goodwalk. All rights reserved.</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ob-footer {
|
||||||
|
background: #213021;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ob-footer-inner {
|
||||||
|
max-width: 1120px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 28px;
|
||||||
|
height: 52px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ob-footer-identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ob-footer-identity strong {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ob-footer-logout {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
background: transparent;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ob-footer-logout:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ob-footer-copyright {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 28px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.25);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ob-footer-copyright a {
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ob-footer-copyright a:hover {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.ob-footer-inner {
|
||||||
|
padding: 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ob-footer-identity span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ob-footer-copyright {
|
||||||
|
padding: 10px 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
export let value = '';
|
||||||
|
export let disabled = false;
|
||||||
|
|
||||||
|
let canvas: HTMLCanvasElement;
|
||||||
|
let isDrawing = false;
|
||||||
|
let hasSigned = false;
|
||||||
|
let activePointerId: number | null = null;
|
||||||
|
let lines: { x: number; y: number }[][] = [];
|
||||||
|
|
||||||
|
function resizeCanvas() {
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ratio = Math.max(window.devicePixelRatio || 1, 1);
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
canvas.width = Math.max(1, Math.round(rect.width * ratio));
|
||||||
|
canvas.height = Math.max(1, Math.round(rect.height * ratio));
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||||
|
drawAllLines();
|
||||||
|
syncValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContext() {
|
||||||
|
return canvas?.getContext('2d') ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawAllLines() {
|
||||||
|
const ctx = getContext();
|
||||||
|
if (!ctx || !canvas) return;
|
||||||
|
|
||||||
|
const width = canvas.width / Math.max(window.devicePixelRatio || 1, 1);
|
||||||
|
const height = canvas.height / Math.max(window.devicePixelRatio || 1, 1);
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.strokeStyle = '#213021';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.length) continue;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(line[0].x, line[0].y);
|
||||||
|
|
||||||
|
if (line.length === 1) {
|
||||||
|
ctx.lineTo(line[0].x + 0.01, line[0].y + 0.01);
|
||||||
|
} else {
|
||||||
|
for (const point of line.slice(1)) {
|
||||||
|
ctx.lineTo(point.x, point.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointFromEvent(event: PointerEvent) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: event.clientX - rect.left,
|
||||||
|
y: event.clientY - rect.top
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncValue() {
|
||||||
|
value = hasSigned && canvas ? canvas.toDataURL('image/png') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDrawing(event: PointerEvent) {
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
activePointerId = event.pointerId;
|
||||||
|
isDrawing = true;
|
||||||
|
canvas.setPointerCapture(event.pointerId);
|
||||||
|
const point = pointFromEvent(event);
|
||||||
|
lines = [...lines, [point]];
|
||||||
|
hasSigned = true;
|
||||||
|
drawAllLines();
|
||||||
|
syncValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
function continueDrawing(event: PointerEvent) {
|
||||||
|
if (!isDrawing || disabled || activePointerId !== event.pointerId) return;
|
||||||
|
|
||||||
|
const point = pointFromEvent(event);
|
||||||
|
const nextLines = [...lines];
|
||||||
|
const currentLine = nextLines[nextLines.length - 1];
|
||||||
|
|
||||||
|
if (!currentLine) return;
|
||||||
|
|
||||||
|
currentLine.push(point);
|
||||||
|
lines = nextLines;
|
||||||
|
drawAllLines();
|
||||||
|
syncValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopDrawing(event?: PointerEvent) {
|
||||||
|
if (event && activePointerId === event.pointerId && canvas.hasPointerCapture(event.pointerId)) {
|
||||||
|
canvas.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
activePointerId = null;
|
||||||
|
isDrawing = false;
|
||||||
|
syncValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clear() {
|
||||||
|
lines = [];
|
||||||
|
hasSigned = false;
|
||||||
|
drawAllLines();
|
||||||
|
syncValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
resizeCanvas();
|
||||||
|
window.addEventListener('resize', resizeCanvas);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', resizeCanvas);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class:signature-disabled={disabled} class="signature-shell">
|
||||||
|
<canvas
|
||||||
|
bind:this={canvas}
|
||||||
|
class="signature-canvas"
|
||||||
|
aria-label="Draw your signature"
|
||||||
|
on:pointerdown={startDrawing}
|
||||||
|
on:pointermove={continueDrawing}
|
||||||
|
on:pointerup={stopDrawing}
|
||||||
|
on:pointerleave={stopDrawing}
|
||||||
|
on:pointercancel={stopDrawing}
|
||||||
|
></canvas>
|
||||||
|
{#if !value}
|
||||||
|
<div class="signature-hint" aria-hidden="true">Sign here</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.signature-shell {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 180px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-shell.signature-disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 180px;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-disabled .signature-canvas {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-hint {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 24px;
|
||||||
|
color: rgba(33, 48, 33, 0.22);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.signature-shell {
|
||||||
|
min-height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-canvas {
|
||||||
|
height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-hint {
|
||||||
|
font-size: 21px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a class="btn btn-yellow pricing-section-mobile-cta" href="#newlead">
|
<a class="btn btn-yellow btn-mobile-center pricing-section-mobile-cta" href="#newlead">
|
||||||
Book a Meet & Greet
|
Book a Meet & Greet
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -205,7 +205,7 @@
|
|||||||
<p>
|
<p>
|
||||||
Book a free Meet & Greet and we’ll help you choose the right walk or visit for your dog.
|
Book a free Meet & Greet and we’ll help you choose the right walk or visit for your dog.
|
||||||
</p>
|
</p>
|
||||||
<a class="btn btn-outline btn-outline-green pricing-mobile-consult-cta" href="#newlead">
|
<a class="btn btn-outline btn-outline-green btn-mobile-center pricing-mobile-consult-cta" href="#newlead">
|
||||||
Talk it through with us
|
Talk it through with us
|
||||||
</a>
|
</a>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -214,7 +214,11 @@
|
|||||||
</section>
|
</section>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<TestimonialsSection heading={pageContent.testimonialsHeading} testimonials={content.testimonials} />
|
<TestimonialsSection
|
||||||
|
heading={pageContent.testimonialsHeading}
|
||||||
|
testimonials={content.testimonials}
|
||||||
|
seedKey="/our-pricing"
|
||||||
|
/>
|
||||||
<BookingSection booking={pageContent.booking} />
|
<BookingSection booking={pageContent.booking} />
|
||||||
|
|
||||||
{#if showMeetGreetPrompt}
|
{#if showMeetGreetPrompt}
|
||||||
@@ -281,8 +285,11 @@
|
|||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
font-family: var(--font-head);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition:
|
transition:
|
||||||
background 0.2s ease,
|
background 0.2s ease,
|
||||||
@@ -308,10 +315,6 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pricing-trust-label {
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.pricing-trust .pricing-trust-arrow) {
|
:global(.pricing-trust .pricing-trust-arrow) {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
@@ -322,8 +325,10 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
font-family: var(--font-head);
|
font-family: var(--font-head);
|
||||||
font-size: clamp(24px, 2.8vw, 36px);
|
font-size: clamp(24px, 2.8vw, 36px);
|
||||||
line-height: 1.1;
|
font-weight: 700;
|
||||||
|
line-height: 1.08;
|
||||||
letter-spacing: -0.03em;
|
letter-spacing: -0.03em;
|
||||||
|
text-wrap: balance;
|
||||||
color: #000;
|
color: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,7 +663,7 @@
|
|||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pricing-plan-popular {
|
.pricing-plan-card {
|
||||||
order: var(--mobile-order, 0);
|
order: var(--mobile-order, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getImageMetadata } from '$lib/image-metadata';
|
import { getEnhancedImage } from '$lib/enhanced-images';
|
||||||
import type { PromiseContent } from '$lib/types';
|
import type { PromiseContent } from '$lib/types';
|
||||||
|
|
||||||
export let promise: PromiseContent;
|
export let promise: PromiseContent;
|
||||||
|
|
||||||
$: promiseImage = getImageMetadata(promise.imageUrl);
|
$: promiseEnhanced = getEnhancedImage(promise.imageUrl);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section id="promise">
|
<section id="promise">
|
||||||
<div class="promise-inner">
|
<div class="promise-inner">
|
||||||
<div class="promise-text">
|
<div class="promise-text">
|
||||||
<h2>
|
<h2 class="promise-heading">
|
||||||
{promise.title}<br />
|
<span class="promise-heading-desktop">
|
||||||
{promise.subtitle}
|
<span class="promise-title-main">{promise.title}</span>
|
||||||
|
<br />
|
||||||
|
<span class="promise-title-highlight">{promise.subtitle}</span>
|
||||||
|
</span>
|
||||||
|
<span class="promise-heading-mobile">
|
||||||
|
<span class="promise-title-main">{promise.title}</span>
|
||||||
|
<span class="promise-title-highlight">{promise.subtitle}</span>
|
||||||
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{#each promise.body as paragraph, idx}
|
{#each promise.body as paragraph, idx}
|
||||||
@@ -29,15 +36,88 @@
|
|||||||
|
|
||||||
<div class="promise-img">
|
<div class="promise-img">
|
||||||
<div class="promise-img-frame">
|
<div class="promise-img-frame">
|
||||||
<img
|
{#if promiseEnhanced}
|
||||||
src={promise.imageUrl}
|
<enhanced:img
|
||||||
alt={promise.imageAlt}
|
src={promiseEnhanced}
|
||||||
width={promiseImage?.width}
|
alt={promise.imageAlt}
|
||||||
height={promiseImage?.height}
|
loading="lazy"
|
||||||
loading="lazy"
|
decoding="async"
|
||||||
decoding="async"
|
/>
|
||||||
/>
|
{:else}
|
||||||
|
<img src={promise.imageUrl} alt={promise.imageAlt} loading="lazy" decoding="async" />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.promise-heading-desktop {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promise-heading-mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promise-heading-mobile .promise-title-main,
|
||||||
|
.promise-heading-mobile .promise-title-highlight {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promise-title-main {
|
||||||
|
color: #0d1a0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promise-title-highlight {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
color: #0d1a0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promise-title-highlight::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: -6px;
|
||||||
|
bottom: -16px;
|
||||||
|
height: 24px;
|
||||||
|
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 34' fill='none'%3E%3Cpath d='M4 24C67 10 131 4 198 5c43 1 82 6 118 18' stroke='%23192419' stroke-width='8' stroke-linecap='round'/%3E%3C/svg%3E")
|
||||||
|
center/contain no-repeat;
|
||||||
|
transform-origin: left center;
|
||||||
|
animation: promise-underline-draw 0.9s cubic-bezier(0.22, 1, 0.36, 1) 0.2s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes promise-underline-draw {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scaleX(0.2) translateY(6px) rotate(-1.5deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
65% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scaleX(1.04) translateY(0) rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scaleX(1) translateY(0) rotate(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.promise-heading-desktop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promise-heading-mobile {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.promise-title-highlight::after {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { reveal } from '$lib/actions/reveal';
|
import { reveal } from '$lib/actions/reveal';
|
||||||
import BookingSection from '$lib/components/BookingSection.svelte';
|
import BookingSection from '$lib/components/BookingSection.svelte';
|
||||||
import TestimonialsSection from '$lib/components/TestimonialsSection.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';
|
import type { ServicePageContent, SiteSharedContent } from '$lib/types';
|
||||||
|
|
||||||
export let content: SiteSharedContent;
|
export let content: SiteSharedContent;
|
||||||
@@ -30,12 +30,12 @@
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
$: heroImage = getImageMetadata(pageContent.hero.imageUrl);
|
$: heroEnhanced = getEnhancedImage(pageContent.hero.imageUrl);
|
||||||
$: highlightImage = pageContent.highlight ? getImageMetadata(pageContent.highlight.imageUrl) : null;
|
$: highlightEnhanced = pageContent.highlight ? getEnhancedImage(pageContent.highlight.imageUrl) : null;
|
||||||
$: highlightCollageImages =
|
$: highlightCollageImages =
|
||||||
pageContent.highlight?.collageImages?.map((image) => ({
|
pageContent.highlight?.collageImages?.map((image) => ({
|
||||||
...image,
|
...image,
|
||||||
meta: getImageMetadata(image.imageUrl)
|
enhanced: getEnhancedImage(image.imageUrl)
|
||||||
})) ?? [];
|
})) ?? [];
|
||||||
$: relatedServices = content.services.filter((s) => s.href && s.href !== currentPath);
|
$: relatedServices = content.services.filter((s) => s.href && s.href !== currentPath);
|
||||||
$: pricingPlans = decoratePlans(pageContent.pricing.plans);
|
$: pricingPlans = decoratePlans(pageContent.pricing.plans);
|
||||||
@@ -73,15 +73,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="service-hero-media">
|
<div class="service-hero-media">
|
||||||
<img
|
{#if heroEnhanced}
|
||||||
src={pageContent.hero.imageUrl}
|
<enhanced:img
|
||||||
alt={pageContent.hero.imageAlt}
|
src={heroEnhanced}
|
||||||
width={heroImage?.width}
|
alt={pageContent.hero.imageAlt}
|
||||||
height={heroImage?.height}
|
loading="eager"
|
||||||
loading="eager"
|
fetchpriority="high"
|
||||||
fetchpriority="high"
|
decoding="async"
|
||||||
decoding="async"
|
/>
|
||||||
/>
|
{:else}
|
||||||
|
<img
|
||||||
|
src={pageContent.hero.imageUrl}
|
||||||
|
alt={pageContent.hero.imageAlt}
|
||||||
|
loading="eager"
|
||||||
|
fetchpriority="high"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -98,27 +106,31 @@
|
|||||||
<div class="service-highlight-collage" aria-label={pageContent.highlight.title}>
|
<div class="service-highlight-collage" aria-label={pageContent.highlight.title}>
|
||||||
{#each highlightCollageImages as image, index}
|
{#each highlightCollageImages as image, index}
|
||||||
<figure class={`service-collage-card service-collage-card-${index + 1}`}>
|
<figure class={`service-collage-card service-collage-card-${index + 1}`}>
|
||||||
<img
|
{#if image.enhanced}
|
||||||
src={image.imageUrl}
|
<enhanced:img src={image.enhanced} alt={image.imageAlt} loading="lazy" decoding="async" />
|
||||||
alt={image.imageAlt}
|
{:else}
|
||||||
width={image.meta?.width}
|
<img src={image.imageUrl} alt={image.imageAlt} loading="lazy" decoding="async" />
|
||||||
height={image.meta?.height}
|
{/if}
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
/>
|
|
||||||
</figure>
|
</figure>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="service-highlight-image">
|
<div class="service-highlight-image">
|
||||||
<img
|
{#if highlightEnhanced}
|
||||||
src={pageContent.highlight.imageUrl}
|
<enhanced:img
|
||||||
alt={pageContent.highlight.imageAlt}
|
src={highlightEnhanced}
|
||||||
width={highlightImage?.width}
|
alt={pageContent.highlight.imageAlt}
|
||||||
height={highlightImage?.height}
|
loading="lazy"
|
||||||
loading="lazy"
|
decoding="async"
|
||||||
decoding="async"
|
/>
|
||||||
/>
|
{:else}
|
||||||
|
<img
|
||||||
|
src={pageContent.highlight.imageUrl}
|
||||||
|
alt={pageContent.highlight.imageAlt}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -172,7 +184,7 @@
|
|||||||
Every booking starts with a free, no-obligation Meet & Greet.
|
Every booking starts with a free, no-obligation Meet & Greet.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a class="btn btn-yellow service-plan-mobile-cta" href="#newlead">Book a Meet & Greet</a>
|
<a class="btn btn-yellow btn-mobile-center service-plan-mobile-cta" href="#newlead">Book a Meet & Greet</a>
|
||||||
|
|
||||||
{#if pageContent.pricing.extras?.length}
|
{#if pageContent.pricing.extras?.length}
|
||||||
<div class="service-extras">
|
<div class="service-extras">
|
||||||
@@ -246,7 +258,11 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<TestimonialsSection heading={pageContent.testimonialsHeading} testimonials={content.testimonials} />
|
<TestimonialsSection
|
||||||
|
heading={pageContent.testimonialsHeading}
|
||||||
|
testimonials={content.testimonials}
|
||||||
|
seedKey={currentPath}
|
||||||
|
/>
|
||||||
<BookingSection booking={pageContent.booking} />
|
<BookingSection booking={pageContent.booking} />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -276,67 +292,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.service-related-card {
|
.service-related-card {
|
||||||
position: relative;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
gap: 10px;
|
text-align: center;
|
||||||
padding: 28px 26px;
|
gap: 0;
|
||||||
|
padding: 34px 28px 30px;
|
||||||
border-radius: 28px;
|
border-radius: 28px;
|
||||||
background: #fff;
|
background: var(--off-white);
|
||||||
box-shadow: 0 14px 34px rgba(17, 20, 24, 0.05);
|
box-shadow: 0 10px 28px rgba(17, 20, 24, 0.05);
|
||||||
color: #000;
|
color: #000;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
overflow: hidden;
|
|
||||||
transition:
|
transition:
|
||||||
transform 0.18s cubic-bezier(0.22, 1, 0.36, 1),
|
transform 0.18s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
box-shadow 0.22s ease;
|
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) {
|
@media (hover: hover) {
|
||||||
.service-related-card:hover {
|
.service-related-card:hover {
|
||||||
transform: translateY(-6px) scale(1.012);
|
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;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 52px;
|
width: 72px;
|
||||||
height: 52px;
|
height: 72px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #efe4d1;
|
background: linear-gradient(180deg, #ffe173 0%, #ffd54a 100%);
|
||||||
color: var(--gw-green);
|
color: var(--gw-green);
|
||||||
font-size: 18px;
|
font-size: 28px;
|
||||||
margin-bottom: 8px;
|
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 {
|
.service-related-card h3 {
|
||||||
margin: 0;
|
margin: 0 0 10px;
|
||||||
font-family: var(--font-head);
|
font-family: var(--font-head);
|
||||||
font-size: 22px;
|
font-size: 20px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,37 +346,44 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
color: #34363a;
|
color: #34363a;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.55;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
.service-related-meta {
|
.service-related-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.service-related-price {
|
.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-family: var(--font-head);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--gw-green);
|
color: var(--gw-green);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.service-related-pill {
|
.service-related-pill {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 4px 12px;
|
padding: 6px 14px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #f3f4f6;
|
background: rgba(33, 48, 33, 0.06);
|
||||||
color: #4b5563;
|
color: var(--gw-green);
|
||||||
|
font-family: var(--font-head);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.service-related-link {
|
.service-related-link {
|
||||||
margin-top: auto;
|
margin-top: 18px;
|
||||||
padding-top: 6px;
|
|
||||||
color: var(--gw-green);
|
color: var(--gw-green);
|
||||||
font-family: var(--font-head);
|
font-family: var(--font-head);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -418,9 +403,20 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--font-head);
|
font-family: var(--font-head);
|
||||||
font-size: clamp(34px, 4vw, 56px);
|
font-size: clamp(34px, 4vw, 56px);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-hero-copy h1 {
|
||||||
line-height: 1.05;
|
line-height: 1.05;
|
||||||
letter-spacing: -0.04em;
|
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,
|
.service-hero-copy p,
|
||||||
@@ -834,7 +830,7 @@
|
|||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.service-plan-popular {
|
.service-plan-card {
|
||||||
order: var(--mobile-order, 0);
|
order: var(--mobile-order, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -148,4 +148,5 @@
|
|||||||
text-underline-offset: 0.18em;
|
text-underline-offset: 0.18em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { reveal } from '$lib/actions/reveal';
|
import { reveal } from '$lib/actions/reveal';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
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';
|
import type { TestimonialContent } from '$lib/types';
|
||||||
|
|
||||||
export let testimonials: TestimonialContent[];
|
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 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 instagramHref = 'https://www.instagram.com/goodwalk.nz/';
|
||||||
export let instagramLabel = 'goodwalk.nz';
|
export let instagramLabel = 'goodwalk.nz';
|
||||||
|
export let seedKey = '';
|
||||||
|
|
||||||
type TestimonialSlide = TestimonialContent & { imageUrl: string };
|
type TestimonialSlide = TestimonialContent & { imageUrl: string };
|
||||||
|
|
||||||
@@ -50,6 +52,7 @@
|
|||||||
let inView = false;
|
let inView = false;
|
||||||
let prefersReducedMotion = false;
|
let prefersReducedMotion = false;
|
||||||
let carouselEl: HTMLDivElement | undefined;
|
let carouselEl: HTMLDivElement | undefined;
|
||||||
|
let slideSignature = '';
|
||||||
|
|
||||||
$: slides = testimonials
|
$: slides = testimonials
|
||||||
.map((testimonial) => wordpressTestimonials[testimonial.reviewer] ?? testimonial)
|
.map((testimonial) => wordpressTestimonials[testimonial.reviewer] ?? testimonial)
|
||||||
@@ -59,6 +62,15 @@
|
|||||||
activeIndex = 0;
|
activeIndex = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
const nextSignature = `${seedKey}:${slides.map((slide) => slide.reviewer).join('|')}`;
|
||||||
|
|
||||||
|
if (nextSignature !== slideSignature) {
|
||||||
|
slideSignature = nextSignature;
|
||||||
|
activeIndex = getSeededTestimonialIndex(slides, seedKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function dogNameFromDetail(detail: string) {
|
function dogNameFromDetail(detail: string) {
|
||||||
const match = detail.match(/^([^'’]+)/);
|
const match = detail.match(/^([^'’]+)/);
|
||||||
return match ? match[1].trim() : '';
|
return match ? match[1].trim() : '';
|
||||||
@@ -163,16 +175,24 @@
|
|||||||
<div class="testimonial-photo-wrap">
|
<div class="testimonial-photo-wrap">
|
||||||
<div class="testimonial-photo-frame">
|
<div class="testimonial-photo-frame">
|
||||||
{#if index === activeIndex}
|
{#if index === activeIndex}
|
||||||
{@const imageMeta = getImageMetadata(testimonial.imageUrl)}
|
{@const enhancedPhoto = getEnhancedImage(testimonial.imageUrl)}
|
||||||
<img
|
{#if enhancedPhoto}
|
||||||
class="testimonial-photo"
|
<enhanced:img
|
||||||
src={testimonial.imageUrl}
|
class="testimonial-photo"
|
||||||
alt={testimonialAlt(testimonial)}
|
src={enhancedPhoto}
|
||||||
width={imageMeta?.width}
|
alt={testimonialAlt(testimonial)}
|
||||||
height={imageMeta?.height}
|
loading="lazy"
|
||||||
loading="lazy"
|
decoding="async"
|
||||||
decoding="async"
|
/>
|
||||||
/>
|
{:else}
|
||||||
|
<img
|
||||||
|
class="testimonial-photo"
|
||||||
|
src={testimonial.imageUrl}
|
||||||
|
alt={testimonialAlt(testimonial)}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -489,8 +509,11 @@
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #f8f8f8;
|
background: #f8f8f8;
|
||||||
color: #0a304e;
|
color: #0a304e;
|
||||||
|
font-family: var(--font-head);
|
||||||
font-size: 14px;
|
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);
|
box-shadow: 0 0 0 1px rgba(10, 48, 78, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { homepageContent } from '$lib/content/homepage';
|
|||||||
import type { TestimonialContent } from '$lib/types';
|
import type { TestimonialContent } from '$lib/types';
|
||||||
|
|
||||||
const expectedMappedSlides = [
|
const expectedMappedSlides = [
|
||||||
{ reviewer: 'Kate', src: '/images/archie-auckland-dog-walking-review.png' },
|
{ reviewer: 'Kate' },
|
||||||
{ reviewer: 'Estelle', src: '/images/monty-auckland-dog-walking-review.png' },
|
{ reviewer: 'Estelle' },
|
||||||
{ reviewer: 'Ross', src: '/images/otis-auckland-dog-walking-review.png' },
|
{ reviewer: 'Ross' },
|
||||||
{ reviewer: 'Nina', src: '/images/wallace-auckland-dog-walking-review.png' }
|
{ reviewer: 'Nina' }
|
||||||
];
|
];
|
||||||
|
|
||||||
function getActiveSlide(container: HTMLElement) {
|
function getActiveSlide(container: HTMLElement) {
|
||||||
@@ -23,6 +23,14 @@ function getActiveImage(container: HTMLElement) {
|
|||||||
return getActiveSlide(container).querySelector('img') as HTMLImageElement;
|
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', () => {
|
describe('TestimonialsSection', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
@@ -33,11 +41,11 @@ describe('TestimonialsSection', () => {
|
|||||||
testimonials: homepageContent.testimonials
|
testimonials: homepageContent.testimonials
|
||||||
});
|
});
|
||||||
|
|
||||||
const nextButton = screen.getByRole('button', { name: /next testimonial/i });
|
const nextButton = getNextButton(container);
|
||||||
|
|
||||||
for (const [index, slide] of expectedMappedSlides.entries()) {
|
for (const [index, slide] of expectedMappedSlides.entries()) {
|
||||||
expect(getActiveReviewer(container)).toBe(slide.reviewer);
|
expect(getActiveReviewer(container)).toBe(slide.reviewer);
|
||||||
expect(getActiveImage(container).getAttribute('src')).toBe(slide.src);
|
expect(getActiveImage(container)).toBeTruthy();
|
||||||
|
|
||||||
if (index < expectedMappedSlides.length - 1) {
|
if (index < expectedMappedSlides.length - 1) {
|
||||||
await fireEvent.click(nextButton);
|
await fireEvent.click(nextButton);
|
||||||
@@ -52,7 +60,7 @@ describe('TestimonialsSection', () => {
|
|||||||
testimonials: homepageContent.testimonials
|
testimonials: homepageContent.testimonials
|
||||||
});
|
});
|
||||||
|
|
||||||
const nextButton = screen.getByRole('button', { name: /next testimonial/i });
|
const nextButton = getNextButton(container);
|
||||||
|
|
||||||
expect(getActiveReviewer(container)).toBe('Kate');
|
expect(getActiveReviewer(container)).toBe('Kate');
|
||||||
|
|
||||||
@@ -68,16 +76,14 @@ describe('TestimonialsSection', () => {
|
|||||||
testimonials: homepageContent.testimonials
|
testimonials: homepageContent.testimonials
|
||||||
});
|
});
|
||||||
|
|
||||||
const previousButton = screen.getByRole('button', { name: /previous testimonial/i });
|
const previousButton = getPreviousButton(container);
|
||||||
|
|
||||||
expect(getActiveReviewer(container)).toBe('Kate');
|
expect(getActiveReviewer(container)).toBe('Kate');
|
||||||
|
|
||||||
await fireEvent.click(previousButton);
|
await fireEvent.click(previousButton);
|
||||||
|
|
||||||
expect(getActiveReviewer(container)).toBe('Nina');
|
expect(getActiveReviewer(container)).toBe('Nina');
|
||||||
expect(getActiveImage(container).getAttribute('src')).toBe(
|
expect(getActiveImage(container)).toBeTruthy();
|
||||||
'/images/wallace-auckland-dog-walking-review.png'
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps custom testimonial images and filters out testimonials with no image', async () => {
|
it('keeps custom testimonial images and filters out testimonials with no image', async () => {
|
||||||
@@ -100,7 +106,7 @@ describe('TestimonialsSection', () => {
|
|||||||
testimonials: customTestimonials
|
testimonials: customTestimonials
|
||||||
});
|
});
|
||||||
|
|
||||||
const nextButton = screen.getByRole('button', { name: /next testimonial/i });
|
const nextButton = getNextButton(container);
|
||||||
|
|
||||||
expect(container.querySelectorAll('.testimonial-slide')).toHaveLength(5);
|
expect(container.querySelectorAll('.testimonial-slide')).toHaveLength(5);
|
||||||
|
|
||||||
@@ -109,7 +115,16 @@ describe('TestimonialsSection', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expect(getActiveReviewer(container)).toBe('Casey');
|
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();
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,9 +30,13 @@
|
|||||||
<div class="values-grid">
|
<div class="values-grid">
|
||||||
{#each orderedValues as value}
|
{#each orderedValues as value}
|
||||||
<div class="value-card">
|
<div class="value-card">
|
||||||
<Icon name={value.icon} className="value-card-icon" />
|
<div class="value-icon-wrap">
|
||||||
<h3>{value.title}</h3>
|
<Icon name={value.icon} className="value-card-icon" />
|
||||||
<p>{value.body}</p>
|
</div>
|
||||||
|
<div class="value-text">
|
||||||
|
<h3>{value.title}</h3>
|
||||||
|
<p>{value.body}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,36 +4,77 @@ export const aboutPageContent: AboutPageContent = {
|
|||||||
title: 'About Us',
|
title: 'About Us',
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
|
eyebrow: 'Our story',
|
||||||
title: 'Who we are',
|
title: 'Who we are',
|
||||||
body: [
|
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!",
|
"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.",
|
||||||
"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."
|
"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',
|
imageUrl: '/images/auckland-pack-walk-small-dogs-group.png',
|
||||||
imageAlt: 'Dog on a Goodwalk pack walk'
|
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: [
|
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.",
|
"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.",
|
||||||
"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."
|
"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',
|
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,
|
reverse: true,
|
||||||
accent: 'gradient'
|
accent: 'gradient'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Meet the team',
|
eyebrow: 'Meet the founder',
|
||||||
|
title: 'The heart of Goodwalk',
|
||||||
body: [
|
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.',
|
"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.",
|
||||||
"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."
|
"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',
|
imageUrl: '/images/founder-image-aless-goodwalk.png',
|
||||||
imageAlt: 'Goodwalk staff member Aless'
|
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: {
|
contact: {
|
||||||
title: "Let's get started!",
|
title: "Let's get started!",
|
||||||
email: 'info@goodwalk.co.nz',
|
email: 'info@goodwalk.co.nz',
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ export const dogWalkingContent: ServicePageContent = {
|
|||||||
paragraphs: [
|
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.',
|
'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.',
|
'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',
|
imageUrl: '/images/auckland-large-dog-one-on-one-walk.jpg',
|
||||||
imageAlt: 'Large breed dog enjoying a Goodwalk one on one dog walk'
|
imageAlt: 'Large breed dog enjoying a Goodwalk one on one dog walk'
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { HomePageContent } from '$lib/types';
|
import type { HomePageContent } from '$lib/types';
|
||||||
|
import { sharedServices } from '$lib/content/services';
|
||||||
|
|
||||||
export const homepageContent: HomePageContent = {
|
export const homepageContent: HomePageContent = {
|
||||||
seo: {
|
seo: {
|
||||||
@@ -23,11 +24,12 @@ export const homepageContent: HomePageContent = {
|
|||||||
],
|
],
|
||||||
cta: { label: 'Contact Us', href: '/contact-us', variant: 'yellow' },
|
cta: { label: 'Contact Us', href: '/contact-us', variant: 'yellow' },
|
||||||
instagram: { href: 'https://www.instagram.com/goodwalk.nz/', external: true },
|
instagram: { href: 'https://www.instagram.com/goodwalk.nz/', external: true },
|
||||||
megaMenuServices: [
|
megaMenuServices: sharedServices.map((service) => ({
|
||||||
{ icon: 'fas fa-paw', label: 'Pack Walks', description: 'Tiny Gang outdoor adventures', href: '/pack-walks' },
|
icon: service.icon,
|
||||||
{ icon: 'fas fa-person-walking', label: '1:1 Walks', description: 'Personalised solo walks', href: '/dog-walking' },
|
label: service.title,
|
||||||
{ icon: 'fas fa-dog', label: 'Puppy Visits', description: 'Home visits for young pups', href: '/puppy-visits' }
|
description: service.megaMenuDescription,
|
||||||
],
|
href: service.href
|
||||||
|
})),
|
||||||
megaMenuFooter: { label: 'View all pricing', href: '/our-pricing' }
|
megaMenuFooter: { label: 'View all pricing', href: '/our-pricing' }
|
||||||
},
|
},
|
||||||
hero: {
|
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.',
|
'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' },
|
primaryCta: { label: 'Book a Meet & Greet', href: '#newlead', variant: 'yellow' },
|
||||||
secondaryCta: {
|
secondaryCta: {
|
||||||
label: 'Message us on Instagram',
|
label: 'See how it works',
|
||||||
href: 'https://www.instagram.com/goodwalk.nz/',
|
href: '#how-it-works',
|
||||||
variant: 'outline',
|
variant: 'outline'
|
||||||
external: true
|
|
||||||
},
|
},
|
||||||
imageUrl: '/images/auckland-dog-walking-happy-dog-hero.png',
|
imageUrl: '/images/auckland-dog-walking-happy-dog-hero.png',
|
||||||
imageAlt: 'Happy dog ready for a professional pack walk with Goodwalk Auckland dog walking service'
|
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,',
|
title: 'Meet Aless,',
|
||||||
subtitle: 'the heart of Goodwalk',
|
subtitle: 'the heart of Goodwalk',
|
||||||
body: [
|
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.',
|
'Goodwalk is built around one thing: a dog who knows the routine, and an owner who stops worrying.',
|
||||||
"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"
|
'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?',
|
emphasis: 'TINY GANG?',
|
||||||
cta: { label: 'Book a free Meet & Greet', href: '/contact-us', variant: 'green' },
|
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'
|
imageAlt: 'Alessandra from Goodwalk with a dog in Auckland'
|
||||||
},
|
},
|
||||||
services: [
|
services: sharedServices.map((service) => ({
|
||||||
{
|
icon: service.icon,
|
||||||
icon: 'fas fa-dog',
|
title: service.title,
|
||||||
title: 'Pack Walks',
|
body: service.cardBody,
|
||||||
body: 'Small group Tiny Gang walks of 4-8 dogs - calm, social, and full of fun for your pup.',
|
priceFrom: service.priceFrom,
|
||||||
priceFrom: 'From $49.50 / walk',
|
href: service.href
|
||||||
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'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
howItWorks: {
|
howItWorks: {
|
||||||
title: 'How it works',
|
title: 'How it works',
|
||||||
intro:
|
intro:
|
||||||
@@ -125,40 +111,25 @@ export const homepageContent: HomePageContent = {
|
|||||||
icon: 'fas fa-heart',
|
icon: 'fas fa-heart',
|
||||||
title: 'Calm, kind handling',
|
title: 'Calm, kind handling',
|
||||||
body:
|
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',
|
icon: 'fas fa-camera',
|
||||||
title: 'Daily updates you will actually want',
|
title: 'Updates you will actually want',
|
||||||
body:
|
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',
|
icon: 'fas fa-users',
|
||||||
title: 'Small Pack Sizes',
|
title: 'Small pack sizes',
|
||||||
order: 2,
|
|
||||||
body:
|
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',
|
icon: 'fas fa-shield-heart',
|
||||||
title: 'Safety-first by default',
|
title: 'Safety-first by default',
|
||||||
order: 1,
|
|
||||||
body:
|
body:
|
||||||
'Pet first aid, careful screening, and proactive handling are built into every walk, not added on as a nice extra.'
|
'Pet first aid, careful screening, and proactive handling built into every walk — not added as an 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."
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
testimonials: [
|
testimonials: [
|
||||||
|
|||||||
@@ -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])
|
||||||
|
);
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
import type { PricingPageContent } from '$lib/types';
|
import type { PricingPageContent } from '$lib/types';
|
||||||
|
import { sharedServices } from '$lib/content/services';
|
||||||
import { dogWalkingContent } from './dog-walking';
|
import { dogWalkingContent } from './dog-walking';
|
||||||
import { packWalksContent } from './pack-walks';
|
import { packWalksContent } from './pack-walks';
|
||||||
import { puppyVisitsContent } from './puppy-visits';
|
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 = {
|
export const ourPricingContent: PricingPageContent = {
|
||||||
title: 'Our Pricing',
|
title: 'Our Pricing',
|
||||||
subtitle: 'Choose the Goodwalk routine that fits your dog, your week, and the kind of support you need.',
|
subtitle: 'Choose the Goodwalk routine that fits your dog, your week, and the kind of support you need.',
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
title: 'Pack Walks',
|
title: 'Pack Walks',
|
||||||
icon: 'fas fa-paw',
|
icon: packWalksService?.icon ?? 'fas fa-paw',
|
||||||
blurb:
|
blurb:
|
||||||
'Our specialty for sociable small and medium dogs who thrive with calm structure, regular weekly outings, and the right dog company.',
|
'Our specialty for sociable small and medium dogs who thrive with calm structure, regular weekly outings, and the right dog company.',
|
||||||
detailCta: {
|
detailCta: {
|
||||||
@@ -21,7 +26,7 @@ export const ourPricingContent: PricingPageContent = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '1:1 Walks',
|
title: '1:1 Walks',
|
||||||
icon: 'fas fa-person-walking',
|
icon: oneToOneService?.icon ?? 'fas fa-person-walking',
|
||||||
blurb:
|
blurb:
|
||||||
'A more individual option for dogs who need extra attention, more space, or a walk shaped around their own pace and confidence.',
|
'A more individual option for dogs who need extra attention, more space, or a walk shaped around their own pace and confidence.',
|
||||||
detailCta: {
|
detailCta: {
|
||||||
@@ -33,7 +38,7 @@ export const ourPricingContent: PricingPageContent = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Puppy Visits',
|
title: 'Puppy Visits',
|
||||||
icon: 'fas fa-dog',
|
icon: puppyVisitsService?.icon ?? 'fas fa-dog',
|
||||||
blurb:
|
blurb:
|
||||||
'Home visits for young puppies who need company, toilet breaks, routine support, and a calmer start before they are ready for bigger adventures.',
|
'Home visits for young puppies who need company, toilet breaks, routine support, and a calmer start before they are ready for bigger adventures.',
|
||||||
detailCta: {
|
detailCta: {
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ export const packWalksContent: ServicePageContent = {
|
|||||||
paragraphs: [
|
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.',
|
'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.',
|
'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',
|
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"
|
imageAlt: "Small dogs from Goodwalk's Tiny Gang pack walk sitting together in an Auckland park"
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ export const puppyVisitsContent: ServicePageContent = {
|
|||||||
paragraphs: [
|
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.',
|
'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.',
|
'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',
|
imageUrl: '/images/auckland-puppy-home-visit.jpg',
|
||||||
imageAlt: 'Puppy receiving a calm Goodwalk home visit in Auckland'
|
imageAlt: 'Puppy receiving a calm Goodwalk home visit in Auckland'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -1,37 +1,38 @@
|
|||||||
export const staticPages = {
|
export const staticPages = {
|
||||||
'pack-walks': {
|
'pack-walks': {
|
||||||
title: 'Pack Walks for Small & Medium Dogs | Auckland Central',
|
title: 'Pack Walks for Small Dogs | Mt Eden, Kingsland & Auckland Central | Goodwalk',
|
||||||
description:
|
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'
|
canonicalPath: '/pack-walks'
|
||||||
},
|
},
|
||||||
'dog-walking': {
|
'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:
|
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'
|
canonicalPath: '/dog-walking'
|
||||||
},
|
},
|
||||||
'puppy-visits': {
|
'puppy-visits': {
|
||||||
title: 'Puppy Visits & In-Home Puppy Care | Auckland Central',
|
title: 'Puppy Visits Auckland Central | Mt Eden, Ponsonby & Grey Lynn | Goodwalk',
|
||||||
description:
|
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'
|
canonicalPath: '/puppy-visits'
|
||||||
},
|
},
|
||||||
'our-pricing': {
|
'our-pricing': {
|
||||||
title: 'Our Pricing',
|
title: 'Dog Walking Prices Auckland | Pack Walks & 1:1 Walks | Goodwalk',
|
||||||
description:
|
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'
|
canonicalPath: '/our-pricing'
|
||||||
},
|
},
|
||||||
about: {
|
about: {
|
||||||
title: 'About Us | Dog Walkers',
|
title: 'About Goodwalk | Dog Walkers in Mt Eden, Kingsland & Auckland Central',
|
||||||
description:
|
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'
|
canonicalPath: '/about'
|
||||||
},
|
},
|
||||||
'contact-us': {
|
'contact-us': {
|
||||||
title: 'Contact Us',
|
title: 'Book a Dog Walker in Auckland | Contact Goodwalk',
|
||||||
description: 'Book a Meet & Greet or send a general enquiry to Goodwalk Auckland dog walking services.',
|
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'
|
canonicalPath: '/contact-us'
|
||||||
},
|
},
|
||||||
'terms-and-conditions': {
|
'terms-and-conditions': {
|
||||||
|
|||||||
@@ -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<string, { default: Picture }> = import.meta.env.DEV
|
||||||
|
? {}
|
||||||
|
: (import.meta.glob('./images/**/*.{jpg,jpeg,png,webp,avif,gif}', {
|
||||||
|
eager: true,
|
||||||
|
query: { enhanced: true }
|
||||||
|
}) as Record<string, { default: Picture }>);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ const imageMetadata: Record<string, ImageMetadata> = {
|
|||||||
'/images/auckland-puppy-visits-cavalier-king-charles-spaniel.jpg': { width: 3327, height: 2217 },
|
'/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-pack-walk-dog.jpg': { width: 480, height: 640 },
|
||||||
'/images/auckland-dog-group-outing.jpg': { width: 640, height: 480 },
|
'/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 {
|
export function getImageMetadata(src: string | undefined | null): ImageMetadata | null {
|
||||||
|
|||||||
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 619 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 564 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 852 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 606 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 614 KiB |
|
After Width: | Height: | Size: 549 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 907 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 262">
|
||||||
|
<path fill="#4285F4" d="M255.68 133.53c0-8.89-.8-17.43-2.29-25.63H130.8v48.48h70.06c-3.02 16.31-12.2 30.12-25.99 39.35v32.65h41.95c24.54-22.6 38.86-55.92 38.86-94.85Z"/>
|
||||||
|
<path fill="#34A853" d="M130.8 261.1c35.1 0 64.53-11.63 86.04-31.55l-41.95-32.65c-11.63 7.79-26.53 12.38-44.09 12.38-33.88 0-62.58-22.88-72.83-53.62H14.6v33.67c21.39 42.42 65.29 71.77 116.2 71.77Z"/>
|
||||||
|
<path fill="#FBBC05" d="M57.97 155.65c-2.61-7.79-4.09-16.11-4.09-24.65s1.48-16.86 4.09-24.65V72.68H14.6C5.28 91.24 0 110.62 0 131s5.28 39.76 14.6 58.32l43.37-33.67Z"/>
|
||||||
|
<path fill="#EA4335" d="M130.8 52.72c19.08 0 36.23 6.57 49.72 19.48l37.29-37.29C195.28 13.35 165.87 0 130.8 0 79.89 0 35.99 29.35 14.6 72.68l43.37 33.67c10.25-30.74 38.95-53.63 72.83-53.63Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 809 B |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 762 KiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 963 KiB |
|
After Width: | Height: | Size: 912 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 313 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 922 KiB |
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>[]
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,8 +4,11 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 13px 28px;
|
padding: 13px 28px;
|
||||||
|
font-family: var(--font-head);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
border-radius: 40px;
|
border-radius: 40px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
@@ -71,3 +74,11 @@
|
|||||||
background: var(--gw-green);
|
background: var(--gw-green);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.btn-mobile-center {
|
||||||
|
display: flex;
|
||||||
|
width: fit-content;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -105,6 +105,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.booking-title {
|
||||||
|
font-size: clamp(36px, 5vw, 52px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.booking-title-highlight::after {
|
.booking-title-highlight::after {
|
||||||
animation: none;
|
animation: none;
|
||||||
@@ -189,19 +195,23 @@
|
|||||||
.booking-panel {
|
.booking-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 18px;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.booking-panel-banner {
|
.booking-panel-banner {
|
||||||
background: linear-gradient(180deg, #f6f2ea 0%, #f1ece3 100%);
|
background: linear-gradient(180deg, #f6f2ea 0%, #f1ece3 100%);
|
||||||
color: #34363a;
|
color: #34363a;
|
||||||
border-radius: 30px;
|
border-radius: 28px 28px 0 0;
|
||||||
padding: 24px 28px 34px;
|
padding: 22px 28px 28px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.55;
|
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 {
|
.booking-card-grid {
|
||||||
@@ -210,7 +220,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.booking-card-grid-with-banner {
|
.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 {
|
.booking-card-grid-owner {
|
||||||
|
|||||||
@@ -353,7 +353,6 @@ nav {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.services-grid,
|
.services-grid,
|
||||||
.values-grid,
|
|
||||||
.testimonials-grid {
|
.testimonials-grid {
|
||||||
margin-top: 48px;
|
margin-top: 48px;
|
||||||
}
|
}
|
||||||
@@ -366,8 +365,10 @@ nav {
|
|||||||
|
|
||||||
.values-grid {
|
.values-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 28px;
|
gap: 0;
|
||||||
|
margin-top: 48px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.testimonials-grid {
|
.testimonials-grid {
|
||||||
@@ -385,7 +386,7 @@ nav {
|
|||||||
|
|
||||||
.footer-inner {
|
.footer-inner {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.25fr 0.95fr 1.15fr;
|
grid-template-columns: 1.1fr 0.8fr 0.8fr 1fr;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
margin-bottom: 48px;
|
margin-bottom: 48px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
|
|||||||
@@ -28,10 +28,6 @@
|
|||||||
.hero-text h1 {
|
.hero-text h1 {
|
||||||
font-size: 40px;
|
font-size: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.values-grid {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -108,8 +104,11 @@
|
|||||||
padding: 11px 14px;
|
padding: 11px 14px;
|
||||||
background: rgba(33, 48, 33, 0.1);
|
background: rgba(33, 48, 33, 0.1);
|
||||||
color: var(--gw-green);
|
color: var(--gw-green);
|
||||||
|
font-family: var(--font-head);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-phone .icon {
|
.mobile-phone .icon {
|
||||||
@@ -191,6 +190,14 @@
|
|||||||
animation-delay: 210ms;
|
animation-delay: 210ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-menu.open a:nth-child(8) {
|
||||||
|
animation-delay: 240ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu.open a:nth-child(9) {
|
||||||
|
animation-delay: 270ms;
|
||||||
|
}
|
||||||
|
|
||||||
#hero {
|
#hero {
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
padding: 50px 20px 0;
|
padding: 50px 20px 0;
|
||||||
@@ -221,7 +228,7 @@
|
|||||||
.hero-subtitle {
|
.hero-subtitle {
|
||||||
margin: -10px 0 22px;
|
margin: -10px 0 22px;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
font-size: 15.5px;
|
font-size: 16px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,7 +246,7 @@
|
|||||||
display: block;
|
display: block;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: var(--font-head);
|
font-family: var(--font-head);
|
||||||
font-size: 33.5px;
|
font-size: 36px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
line-height: 1.08;
|
line-height: 1.08;
|
||||||
letter-spacing: -0.04em;
|
letter-spacing: -0.04em;
|
||||||
@@ -258,7 +265,7 @@
|
|||||||
width: 0;
|
width: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 14px 10px;
|
padding: 14px 10px;
|
||||||
font-size: 12.5px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -376,7 +383,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.services-grid,
|
.services-grid,
|
||||||
.values-grid,
|
|
||||||
.testimonials-grid,
|
.testimonials-grid,
|
||||||
.info-inner,
|
.info-inner,
|
||||||
.field-group,
|
.field-group,
|
||||||
@@ -384,6 +390,18 @@
|
|||||||
grid-template-columns: 1fr;
|
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 {
|
.service-icon-bubble {
|
||||||
width: 78px;
|
width: 78px;
|
||||||
height: 78px;
|
height: 78px;
|
||||||
@@ -489,6 +507,10 @@
|
|||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.booking-card-grid-with-banner .booking-field-card {
|
||||||
|
border-radius: 0 0 24px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.booking-field-card-group {
|
.booking-field-card-group {
|
||||||
padding: 24px 22px;
|
padding: 24px 22px;
|
||||||
}
|
}
|
||||||
@@ -645,7 +667,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hero-text h1 .hero-heading-mobile {
|
.hero-text h1 .hero-heading-mobile {
|
||||||
font-size: 30px;
|
font-size: 31px;
|
||||||
line-height: 1.12;
|
line-height: 1.12;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
section {
|
#promise,
|
||||||
|
#services,
|
||||||
|
#values,
|
||||||
|
#testimonials,
|
||||||
|
#info,
|
||||||
|
#newlead {
|
||||||
padding: 80px 0;
|
padding: 80px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,8 +48,11 @@ section {
|
|||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
font-family: var(--font-head);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition:
|
transition:
|
||||||
background 0.2s ease,
|
background 0.2s ease,
|
||||||
@@ -95,6 +103,12 @@ section {
|
|||||||
margin: -12px -18px -72px 0;
|
margin: -12px -18px -72px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.hero-img img {
|
||||||
|
margin-bottom: -32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#intro {
|
#intro {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
padding: 8px 50px 26px;
|
padding: 8px 50px 26px;
|
||||||
@@ -219,6 +233,11 @@ section {
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.intro-trust-cta:focus-visible {
|
||||||
|
outline: 2px solid rgba(10, 48, 78, 0.28);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes introGoogleShine {
|
@keyframes introGoogleShine {
|
||||||
0%,
|
0%,
|
||||||
64%,
|
64%,
|
||||||
@@ -420,38 +439,57 @@ footer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.value-card {
|
.value-card {
|
||||||
background: rgba(255, 255, 255, 0.07);
|
display: flex;
|
||||||
border-radius: 28px;
|
flex-direction: row;
|
||||||
padding: 32px 28px;
|
align-items: flex-start;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
gap: 22px;
|
||||||
transition:
|
padding: 36px 32px;
|
||||||
transform 0.18s cubic-bezier(0.22, 1, 0.36, 1),
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
box-shadow 0.22s ease,
|
transition: background 0.2s ease;
|
||||||
border-color 0.22s ease;
|
}
|
||||||
|
|
||||||
|
.value-card:nth-child(odd) {
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
.value-card:hover {
|
.value-card:hover {
|
||||||
transform: translateY(-6px) scale(1.012);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.14);
|
|
||||||
border-color: rgba(255, 255, 255, 0.18);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.value-card:active {
|
.value-icon-wrap {
|
||||||
transform: translateY(-1px) scale(0.994);
|
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 {
|
.value-card .value-card-icon {
|
||||||
font-size: 28px;
|
font-size: 22px;
|
||||||
color: var(--yellow);
|
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 {
|
.value-card p {
|
||||||
|
margin: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
opacity: 0.85;
|
opacity: 0.82;
|
||||||
}
|
}
|
||||||
|
|
||||||
.testimonial-card {
|
.testimonial-card {
|
||||||
@@ -501,36 +539,78 @@ footer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.faq details {
|
.faq details {
|
||||||
border-bottom: 1px solid #ddd;
|
border-radius: 16px;
|
||||||
padding: 16px 0;
|
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 {
|
.faq summary {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px 22px;
|
||||||
|
border-radius: 16px;
|
||||||
|
transition: background 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.faq summary::-webkit-details-marker {
|
.faq summary::-webkit-details-marker {
|
||||||
display: none;
|
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 {
|
.faq summary::after {
|
||||||
content: '+';
|
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);
|
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 {
|
.faq details[open] summary::after {
|
||||||
content: '−';
|
content: '−';
|
||||||
|
background: rgba(33, 48, 33, 0.1);
|
||||||
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.faq details p {
|
.faq details p {
|
||||||
margin-top: 10px;
|
margin: 0;
|
||||||
|
padding: 16px 22px 20px;
|
||||||
color: var(--gray);
|
color: var(--gray);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
#instagram {
|
#instagram {
|
||||||
@@ -552,7 +632,7 @@ footer {
|
|||||||
|
|
||||||
.footer-logo {
|
.footer-logo {
|
||||||
display: block;
|
display: block;
|
||||||
height: 24px;
|
height: 30px;
|
||||||
width: auto;
|
width: auto;
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
@@ -625,6 +705,10 @@ footer {
|
|||||||
gap: 2px 24px;
|
gap: 2px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer-explore .footer-nav {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.footer-nav li {
|
.footer-nav li {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -669,8 +753,11 @@ footer {
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--yellow);
|
background: var(--yellow);
|
||||||
color: #000;
|
color: #000;
|
||||||
|
font-family: var(--font-head);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
transition: background 0.2s, transform 0.15s;
|
transition: background 0.2s, transform 0.15s;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
@@ -697,7 +784,11 @@ footer {
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(255, 255, 255, 0.07);
|
background: rgba(255, 255, 255, 0.07);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
font-family: var(--font-head);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
transition: background 0.2s, opacity 0.2s;
|
transition: background 0.2s, opacity 0.2s;
|
||||||
}
|
}
|
||||||
@@ -747,6 +838,7 @@ footer {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
width: 100%;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
padding-top: 28px;
|
padding-top: 28px;
|
||||||
@@ -754,10 +846,17 @@ footer {
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer-bottom > span {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.footer-legal {
|
.footer-legal {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-legal a {
|
.footer-legal a {
|
||||||
|
|||||||
@@ -25,6 +25,9 @@
|
|||||||
#instagram h2 {
|
#instagram h2 {
|
||||||
font-family: var(--font-head);
|
font-family: var(--font-head);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
line-height: 1.08;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-heading {
|
.section-heading {
|
||||||
|
|||||||
@@ -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<Pick<TestimonialContent, 'reviewer'>>,
|
||||||
|
seedKey: string
|
||||||
|
) {
|
||||||
|
if (!testimonials.length) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!seedKey) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hashString(seedKey) % testimonials.length;
|
||||||
|
}
|
||||||
@@ -172,17 +172,20 @@ export interface PricingPageContent {
|
|||||||
|
|
||||||
export interface AboutPageSection {
|
export interface AboutPageSection {
|
||||||
title: string;
|
title: string;
|
||||||
|
eyebrow?: string;
|
||||||
body: string[];
|
body: string[];
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
imageAlt: string;
|
imageAlt: string;
|
||||||
reverse?: boolean;
|
reverse?: boolean;
|
||||||
accent?: 'plain' | 'gradient';
|
accent?: 'plain' | 'gradient' | 'founder';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AboutPageContent {
|
export interface AboutPageContent {
|
||||||
title: string;
|
title: string;
|
||||||
sections: AboutPageSection[];
|
sections: AboutPageSection[];
|
||||||
servicesTitle: string;
|
servicesTitle?: string;
|
||||||
|
faqTitle?: string;
|
||||||
|
faqs?: FaqItem[];
|
||||||
contact: {
|
contact: {
|
||||||
title: string;
|
title: string;
|
||||||
email: 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 {
|
export interface LegalPageBlock {
|
||||||
type: 'paragraph' | 'list';
|
type: 'paragraph' | 'list';
|
||||||
content: string | string[];
|
content: string | string[];
|
||||||
|
|||||||
@@ -1,8 +1,24 @@
|
|||||||
import { getHomepageContent } from '$lib/server/content';
|
import { getHomepageContent } from '$lib/server/content';
|
||||||
import { isHomepageHowItWorksEnabled } from '$lib/server/feature-flags';
|
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 {
|
return {
|
||||||
|
siteVariant,
|
||||||
content: await getHomepageContent(),
|
content: await getHomepageContent(),
|
||||||
howItWorksEnabled: isHomepageHowItWorksEnabled()
|
howItWorksEnabled: isHomepageHowItWorksEnabled()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import ServicesSection from '$lib/components/ServicesSection.svelte';
|
import ServicesSection from '$lib/components/ServicesSection.svelte';
|
||||||
import TestimonialsSection from '$lib/components/TestimonialsSection.svelte';
|
import TestimonialsSection from '$lib/components/TestimonialsSection.svelte';
|
||||||
import ValuesSection from '$lib/components/ValuesSection.svelte';
|
import ValuesSection from '$lib/components/ValuesSection.svelte';
|
||||||
|
import OnboardingPage from '$lib/components/OnboardingPage.svelte';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
@@ -25,127 +26,136 @@
|
|||||||
return `${siteUrl}${value.startsWith('/') ? value : `/${value}`}`;
|
return `${siteUrl}${value.startsWith('/') ? value : `/${value}`}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
$: homepageStructuredData = [
|
$: homepageStructuredData =
|
||||||
{
|
data.siteVariant === 'marketing' && data.content
|
||||||
'@context': 'https://schema.org',
|
? [
|
||||||
'@type': 'WebSite',
|
{
|
||||||
name: 'Goodwalk',
|
'@context': 'https://schema.org',
|
||||||
url: siteUrl,
|
'@type': 'WebSite',
|
||||||
inLanguage: 'en-NZ'
|
name: 'Goodwalk',
|
||||||
},
|
url: siteUrl,
|
||||||
{
|
inLanguage: 'en-NZ'
|
||||||
'@context': 'https://schema.org',
|
},
|
||||||
'@type': 'LocalBusiness',
|
{
|
||||||
name: 'Goodwalk',
|
'@context': 'https://schema.org',
|
||||||
description:
|
'@type': 'LocalBusiness',
|
||||||
'Professional dog walking services across Auckland Central, including pack walks, 1:1 walks, and puppy visits.',
|
name: 'Goodwalk',
|
||||||
url: siteUrl,
|
description:
|
||||||
logo: `${siteUrl}/images/goodwalk-auckland-dog-walking-logo.png`,
|
'Professional dog walking services across Auckland Central, including pack walks, 1:1 walks, and puppy visits.',
|
||||||
image: absoluteUrl(data.content.hero.imageUrl),
|
url: siteUrl,
|
||||||
email: 'info@goodwalk.co.nz',
|
logo: `${siteUrl}/images/goodwalk-auckland-dog-walking-logo.png`,
|
||||||
telephone: '+64-22-642-1011',
|
image: absoluteUrl(data.content.hero.imageUrl),
|
||||||
sameAs: ['https://www.instagram.com/goodwalk.nz/', 'https://g.page/r/CUsvrWPhkYrAEB0/'],
|
email: 'info@goodwalk.co.nz',
|
||||||
address: {
|
telephone: '+64-22-642-1011',
|
||||||
'@type': 'PostalAddress',
|
sameAs: ['https://www.instagram.com/goodwalk.nz/', 'https://g.page/r/CUsvrWPhkYrAEB0/'],
|
||||||
addressLocality: 'Auckland Central',
|
address: {
|
||||||
addressRegion: 'Auckland',
|
'@type': 'PostalAddress',
|
||||||
addressCountry: 'NZ'
|
addressLocality: 'Auckland Central',
|
||||||
},
|
addressRegion: 'Auckland',
|
||||||
areaServed: [
|
addressCountry: 'NZ'
|
||||||
'Morningside',
|
},
|
||||||
'Kingsland',
|
areaServed: [
|
||||||
'Ponsonby',
|
'Morningside',
|
||||||
'Grey Lynn',
|
'Kingsland',
|
||||||
'Mt Albert',
|
'Ponsonby',
|
||||||
'Mt Eden',
|
'Grey Lynn',
|
||||||
'Sandringham',
|
'Mt Albert',
|
||||||
'Mt Roskill',
|
'Mt Eden',
|
||||||
'Arch Hill',
|
'Sandringham',
|
||||||
'Freemans Bay',
|
'Mt Roskill',
|
||||||
'Herne Bay',
|
'Arch Hill',
|
||||||
'Pt Chevalier',
|
'Freemans Bay',
|
||||||
'Avondale',
|
'Herne Bay',
|
||||||
'Three Kings',
|
'Pt Chevalier',
|
||||||
'Hillsborough',
|
'Avondale',
|
||||||
'Eden Terrace',
|
'Three Kings',
|
||||||
'Balmoral'
|
'Hillsborough',
|
||||||
],
|
'Eden Terrace',
|
||||||
openingHoursSpecification: [
|
'Balmoral'
|
||||||
{
|
],
|
||||||
'@type': 'OpeningHoursSpecification',
|
openingHoursSpecification: [
|
||||||
dayOfWeek: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
|
{
|
||||||
opens: '08:00',
|
'@type': 'OpeningHoursSpecification',
|
||||||
closes: '16:00'
|
dayOfWeek: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
|
||||||
}
|
opens: '08:00',
|
||||||
],
|
closes: '16:00'
|
||||||
hasOfferCatalog: {
|
}
|
||||||
'@type': 'OfferCatalog',
|
],
|
||||||
name: 'Dog Walking Services',
|
hasOfferCatalog: {
|
||||||
itemListElement: data.content.services.map((service) => ({
|
'@type': 'OfferCatalog',
|
||||||
'@type': 'Offer',
|
name: 'Dog Walking Services',
|
||||||
itemOffered: {
|
itemListElement: data.content.services.map((service) => ({
|
||||||
'@type': 'Service',
|
'@type': 'Offer',
|
||||||
name: service.title,
|
itemOffered: {
|
||||||
url: `${siteUrl}${service.href}`
|
'@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
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
];
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<SeoHead
|
{#if data.siteVariant === 'onboarding'}
|
||||||
title={data.content.seo.title}
|
<OnboardingPage preview={data.isPreview} />
|
||||||
description={data.content.seo.description}
|
{:else}
|
||||||
canonicalPath="/"
|
{@const content = data.content!}
|
||||||
image={data.content.hero.imageUrl}
|
<SeoHead
|
||||||
imageAlt={data.content.hero.imageAlt}
|
title={content.seo.title}
|
||||||
structuredData={homepageStructuredData}
|
description={content.seo.description}
|
||||||
preloadImage={true}
|
canonicalPath="/"
|
||||||
/>
|
image={content.hero.imageUrl}
|
||||||
|
imageAlt={content.hero.imageAlt}
|
||||||
|
structuredData={homepageStructuredData}
|
||||||
|
preloadImage={true}
|
||||||
|
/>
|
||||||
|
|
||||||
<Header navigation={data.content.navigation} />
|
<Header navigation={content.navigation} />
|
||||||
<HeroSection hero={data.content.hero} reviewCta={data.content.intro.reviewCta} />
|
<HeroSection hero={content.hero} reviewCta={content.intro.reviewCta} />
|
||||||
<PromiseSection promise={data.content.promise} />
|
<PromiseSection promise={content.promise} />
|
||||||
<ServicesSection services={data.content.services} />
|
<ServicesSection services={content.services} />
|
||||||
{#if data.howItWorksEnabled}
|
{#if data.howItWorksEnabled}
|
||||||
<HowItWorksSection content={data.content.howItWorks} />
|
<HowItWorksSection content={content.howItWorks} />
|
||||||
|
{/if}
|
||||||
|
<TestimonialsSection testimonials={content.testimonials} seedKey="/" />
|
||||||
|
<ValuesSection values={content.values} />
|
||||||
|
<BookingSection booking={content.booking} />
|
||||||
|
<InfoSection info={content.info} />
|
||||||
|
<InstagramSection instagram={content.instagram} />
|
||||||
|
<Footer footer={content.footer} />
|
||||||
{/if}
|
{/if}
|
||||||
<TestimonialsSection testimonials={data.content.testimonials} />
|
|
||||||
<ValuesSection values={data.content.values} />
|
|
||||||
<BookingSection booking={data.content.booking} />
|
|
||||||
<InfoSection info={data.content.info} />
|
|
||||||
<InstagramSection instagram={data.content.instagram} />
|
|
||||||
<Footer footer={data.content.footer} />
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import { privacyPolicyContent } from '$lib/content/privacy-policy';
|
import { privacyPolicyContent } from '$lib/content/privacy-policy';
|
||||||
import { puppyVisitsContent } from '$lib/content/puppy-visits';
|
import { puppyVisitsContent } from '$lib/content/puppy-visits';
|
||||||
import { termsAndConditionsContent } from '$lib/content/terms-and-conditions';
|
import { termsAndConditionsContent } from '$lib/content/terms-and-conditions';
|
||||||
|
import { buildAreaServed, buildBreadcrumb, absoluteUrl } from '$lib/seo';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
@@ -22,14 +23,6 @@
|
|||||||
const defaultSeoImage = '/images/auckland-dog-walking-happy-dog-hero.png';
|
const defaultSeoImage = '/images/auckland-dog-walking-happy-dog-hero.png';
|
||||||
const defaultSeoImageAlt = 'Goodwalk Auckland dog walking services';
|
const defaultSeoImageAlt = 'Goodwalk Auckland dog walking services';
|
||||||
|
|
||||||
function absoluteUrl(value: string) {
|
|
||||||
if (value.startsWith('http://') || value.startsWith('https://')) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${siteUrl}${value.startsWith('/') ? value : `/${value}`}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function aggregateOfferSchema(plans: { price: string }[]) {
|
function aggregateOfferSchema(plans: { price: string }[]) {
|
||||||
const numericPrices = plans
|
const numericPrices = plans
|
||||||
.map((plan) => Number(plan.price.replace(/[^0-9.]/g, '')))
|
.map((plan) => Number(plan.price.replace(/[^0-9.]/g, '')))
|
||||||
@@ -52,26 +45,7 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function breadcrumbSchema(name: string, path: string) {
|
const areaServed = buildAreaServed();
|
||||||
return {
|
|
||||||
'@context': 'https://schema.org',
|
|
||||||
'@type': 'BreadcrumbList',
|
|
||||||
itemListElement: [
|
|
||||||
{
|
|
||||||
'@type': 'ListItem',
|
|
||||||
position: 1,
|
|
||||||
name: 'Home',
|
|
||||||
item: siteUrl
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'@type': 'ListItem',
|
|
||||||
position: 2,
|
|
||||||
name,
|
|
||||||
item: `${siteUrl}${path}`
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let seoImage = defaultSeoImage;
|
let seoImage = defaultSeoImage;
|
||||||
let seoImageAlt = defaultSeoImageAlt;
|
let seoImageAlt = defaultSeoImageAlt;
|
||||||
@@ -90,7 +64,10 @@
|
|||||||
description: data.page.description,
|
description: data.page.description,
|
||||||
url: `${siteUrl}${data.page.canonicalPath}`
|
url: `${siteUrl}${data.page.canonicalPath}`
|
||||||
},
|
},
|
||||||
breadcrumbSchema(data.page.title, data.page.canonicalPath)
|
buildBreadcrumb([
|
||||||
|
{ name: 'Home', url: siteUrl },
|
||||||
|
{ name: data.page.title, path: data.page.canonicalPath }
|
||||||
|
])
|
||||||
];
|
];
|
||||||
|
|
||||||
if (data.slug === 'pack-walks') {
|
if (data.slug === 'pack-walks') {
|
||||||
@@ -109,12 +86,15 @@
|
|||||||
name: 'Goodwalk',
|
name: 'Goodwalk',
|
||||||
url: siteUrl
|
url: siteUrl
|
||||||
},
|
},
|
||||||
areaServed: 'Auckland Central, New Zealand',
|
areaServed,
|
||||||
image: absoluteUrl(seoImage),
|
image: absoluteUrl(seoImage),
|
||||||
url: `${siteUrl}${data.page.canonicalPath}`,
|
url: `${siteUrl}${data.page.canonicalPath}`,
|
||||||
offers: aggregateOfferSchema(packWalksContent.pricing.plans)
|
offers: aggregateOfferSchema(packWalksContent.pricing.plans)
|
||||||
},
|
},
|
||||||
breadcrumbSchema('Pack Walks', data.page.canonicalPath)
|
buildBreadcrumb([
|
||||||
|
{ name: 'Home', url: siteUrl },
|
||||||
|
{ name: 'Pack Walks', path: data.page.canonicalPath }
|
||||||
|
])
|
||||||
];
|
];
|
||||||
} else if (data.slug === 'dog-walking') {
|
} else if (data.slug === 'dog-walking') {
|
||||||
preloadHeroImage = true;
|
preloadHeroImage = true;
|
||||||
@@ -132,12 +112,15 @@
|
|||||||
name: 'Goodwalk',
|
name: 'Goodwalk',
|
||||||
url: siteUrl
|
url: siteUrl
|
||||||
},
|
},
|
||||||
areaServed: 'Auckland Central, New Zealand',
|
areaServed,
|
||||||
image: absoluteUrl(seoImage),
|
image: absoluteUrl(seoImage),
|
||||||
url: `${siteUrl}${data.page.canonicalPath}`,
|
url: `${siteUrl}${data.page.canonicalPath}`,
|
||||||
offers: aggregateOfferSchema(dogWalkingContent.pricing.plans)
|
offers: aggregateOfferSchema(dogWalkingContent.pricing.plans)
|
||||||
},
|
},
|
||||||
breadcrumbSchema('1:1 Walks', data.page.canonicalPath)
|
buildBreadcrumb([
|
||||||
|
{ name: 'Home', url: siteUrl },
|
||||||
|
{ name: '1:1 Walks', path: data.page.canonicalPath }
|
||||||
|
])
|
||||||
];
|
];
|
||||||
} else if (data.slug === 'puppy-visits') {
|
} else if (data.slug === 'puppy-visits') {
|
||||||
preloadHeroImage = true;
|
preloadHeroImage = true;
|
||||||
@@ -155,12 +138,15 @@
|
|||||||
name: 'Goodwalk',
|
name: 'Goodwalk',
|
||||||
url: siteUrl
|
url: siteUrl
|
||||||
},
|
},
|
||||||
areaServed: 'Auckland Central, New Zealand',
|
areaServed,
|
||||||
image: absoluteUrl(seoImage),
|
image: absoluteUrl(seoImage),
|
||||||
url: `${siteUrl}${data.page.canonicalPath}`,
|
url: `${siteUrl}${data.page.canonicalPath}`,
|
||||||
offers: aggregateOfferSchema(puppyVisitsContent.pricing.plans)
|
offers: aggregateOfferSchema(puppyVisitsContent.pricing.plans)
|
||||||
},
|
},
|
||||||
breadcrumbSchema('Puppy Visits', data.page.canonicalPath)
|
buildBreadcrumb([
|
||||||
|
{ name: 'Home', url: siteUrl },
|
||||||
|
{ name: 'Puppy Visits', path: data.page.canonicalPath }
|
||||||
|
])
|
||||||
];
|
];
|
||||||
} else if (data.slug === 'our-pricing') {
|
} else if (data.slug === 'our-pricing') {
|
||||||
pageStructuredData = [
|
pageStructuredData = [
|
||||||
@@ -171,7 +157,10 @@
|
|||||||
description: data.page.description,
|
description: data.page.description,
|
||||||
url: `${siteUrl}${data.page.canonicalPath}`
|
url: `${siteUrl}${data.page.canonicalPath}`
|
||||||
},
|
},
|
||||||
breadcrumbSchema('Our Pricing', data.page.canonicalPath)
|
buildBreadcrumb([
|
||||||
|
{ name: 'Home', url: siteUrl },
|
||||||
|
{ name: 'Our Pricing', path: data.page.canonicalPath }
|
||||||
|
])
|
||||||
];
|
];
|
||||||
} else if (data.slug === 'about') {
|
} else if (data.slug === 'about') {
|
||||||
seoImage = aboutPageContent.sections[0].imageUrl;
|
seoImage = aboutPageContent.sections[0].imageUrl;
|
||||||
@@ -185,7 +174,21 @@
|
|||||||
url: `${siteUrl}${data.page.canonicalPath}`,
|
url: `${siteUrl}${data.page.canonicalPath}`,
|
||||||
image: absoluteUrl(seoImage)
|
image: absoluteUrl(seoImage)
|
||||||
},
|
},
|
||||||
breadcrumbSchema('About Us', data.page.canonicalPath)
|
...(aboutPageContent.faqs && aboutPageContent.faqs.length
|
||||||
|
? [{
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: aboutPageContent.faqs.map((faq) => ({
|
||||||
|
'@type': 'Question',
|
||||||
|
name: faq.question,
|
||||||
|
acceptedAnswer: { '@type': 'Answer', text: faq.answer }
|
||||||
|
}))
|
||||||
|
}]
|
||||||
|
: []),
|
||||||
|
buildBreadcrumb([
|
||||||
|
{ name: 'Home', url: siteUrl },
|
||||||
|
{ name: 'About Us', path: data.page.canonicalPath }
|
||||||
|
])
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,7 +215,7 @@
|
|||||||
{:else if data.slug === 'our-pricing'}
|
{:else if data.slug === 'our-pricing'}
|
||||||
<PricingPage content={data.content} pageContent={ourPricingContent} />
|
<PricingPage content={data.content} pageContent={ourPricingContent} />
|
||||||
{:else if data.slug === 'about'}
|
{:else if data.slug === 'about'}
|
||||||
<AboutPage content={data.content} pageContent={aboutPageContent} />
|
<AboutPage pageContent={aboutPageContent} />
|
||||||
{:else if data.slug === 'terms-and-conditions'}
|
{:else if data.slug === 'terms-and-conditions'}
|
||||||
<LegalPage pageContent={termsAndConditionsContent} />
|
<LegalPage pageContent={termsAndConditionsContent} />
|
||||||
{:else if data.slug === 'privacy-policy'}
|
{:else if data.slug === 'privacy-policy'}
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import { render, screen } from '@testing-library/svelte';
|
import { render, screen } from '@testing-library/svelte';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { aboutPageContent } from '$lib/content/about';
|
||||||
|
import { dogWalkingContent } from '$lib/content/dog-walking';
|
||||||
|
import { ourPricingContent } from '$lib/content/our-pricing';
|
||||||
|
import { packWalksContent } from '$lib/content/pack-walks';
|
||||||
|
import { puppyVisitsContent } from '$lib/content/puppy-visits';
|
||||||
|
import { staticPages } from '$lib/content/static-pages';
|
||||||
import SlugPage from './+page.svelte';
|
import SlugPage from './+page.svelte';
|
||||||
import { createStaticRouteData } from '../../test/fixtures';
|
import { createStaticRouteData } from '../../test/fixtures';
|
||||||
|
|
||||||
describe('static slug route page', () => {
|
describe('static slug route page', () => {
|
||||||
it.each([
|
it.each([
|
||||||
['pack-walks', 'Join our Tiny Gang!'],
|
['pack-walks', packWalksContent.hero.title],
|
||||||
['dog-walking', 'Walks for larger breeds, too!'],
|
['dog-walking', dogWalkingContent.hero.title],
|
||||||
['puppy-visits', 'Introducing Puppy Visits: Building strong foundations for our pack walks!'],
|
['puppy-visits', puppyVisitsContent.hero.title],
|
||||||
['our-pricing', 'Simple, transparent pricing — no lock-in contracts.'],
|
['our-pricing', ourPricingContent.subtitle],
|
||||||
['about', 'Who we are'],
|
['about', aboutPageContent.sections[0].title],
|
||||||
['contact-us', "Fill in the form below and we'll be in touch to arrange a free introduction."],
|
['contact-us', "Let's meet!"],
|
||||||
['terms-and-conditions', '1. Application of Terms'],
|
['terms-and-conditions', '1. Application of Terms'],
|
||||||
['privacy-policy', 'How we collect your information']
|
['privacy-policy', 'How we collect your information']
|
||||||
] as const)('renders the %s page branch', (slug, expectedText) => {
|
] as const)('renders the %s page branch', (slug, expectedText) => {
|
||||||
@@ -18,6 +24,11 @@ describe('static slug route page', () => {
|
|||||||
data: createStaticRouteData(slug)
|
data: createStaticRouteData(slug)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (slug === 'contact-us') {
|
||||||
|
expect(screen.getByRole('heading', { name: expectedText })).toBeInTheDocument();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
expect(screen.getByText(expectedText)).toBeInTheDocument();
|
expect(screen.getByText(expectedText)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -26,7 +37,7 @@ describe('static slug route page', () => {
|
|||||||
data: createStaticRouteData('about')
|
data: createStaticRouteData('about')
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(document.title).toBe('About Us | Dog Walkers | Goodwalk');
|
expect(document.title).toBe(staticPages.about.title);
|
||||||
expect(document.head.innerHTML).toContain('AboutPage');
|
expect(document.head.innerHTML).toContain('AboutPage');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -37,10 +48,8 @@ describe('static slug route page', () => {
|
|||||||
data: createStaticRouteData(slug)
|
data: createStaticRouteData(slug)
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(
|
expect(screen.queryByText("Let's meet!")).not.toBeInTheDocument();
|
||||||
screen.queryByText("Fill in the form below and we'll be in touch to arrange a free introduction.")
|
expect(screen.queryByText('What our clients say')).not.toBeInTheDocument();
|
||||||
).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Why people choose us!')).not.toBeInTheDocument();
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
const onboardingHosts = new Set(['onboarding.goodwalk.co.nz']);
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
|
const hostname = url.hostname.toLowerCase();
|
||||||
|
const isOnboardingHost =
|
||||||
|
onboardingHosts.has(hostname) || url.searchParams.get('preview') === 'contract';
|
||||||
|
|
||||||
|
if (!isOnboardingHost) {
|
||||||
|
throw error(404, 'Not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isPreview: url.searchParams.get('preview') === 'contract',
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ContractPage from '$lib/components/ContractPage.svelte';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
export let data: PageData;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ContractPage preview={data.isPreview} />
|
||||||
@@ -16,12 +16,19 @@ vi.mock('$lib/server/feature-flags', () => ({
|
|||||||
|
|
||||||
import { load } from './+page.server';
|
import { load } from './+page.server';
|
||||||
|
|
||||||
|
function createLoadEvent(url = 'https://www.goodwalk.co.nz/') {
|
||||||
|
return {
|
||||||
|
url: new URL(url)
|
||||||
|
} as Parameters<typeof load>[0];
|
||||||
|
}
|
||||||
|
|
||||||
describe('home page server load', () => {
|
describe('home page server load', () => {
|
||||||
it('returns homepage content', async () => {
|
it('returns homepage content', async () => {
|
||||||
getHomepageContent.mockResolvedValue(homepageContent);
|
getHomepageContent.mockResolvedValue(homepageContent);
|
||||||
isHomepageHowItWorksEnabled.mockReturnValue(false);
|
isHomepageHowItWorksEnabled.mockReturnValue(false);
|
||||||
|
|
||||||
await expect(load()).resolves.toEqual({
|
await expect(load(createLoadEvent())).resolves.toEqual({
|
||||||
|
siteVariant: 'marketing',
|
||||||
content: homepageContent,
|
content: homepageContent,
|
||||||
howItWorksEnabled: false
|
howItWorksEnabled: false
|
||||||
});
|
});
|
||||||
@@ -31,7 +38,8 @@ describe('home page server load', () => {
|
|||||||
getHomepageContent.mockResolvedValue(homepageContent);
|
getHomepageContent.mockResolvedValue(homepageContent);
|
||||||
isHomepageHowItWorksEnabled.mockReturnValue(true);
|
isHomepageHowItWorksEnabled.mockReturnValue(true);
|
||||||
|
|
||||||
await expect(load()).resolves.toEqual({
|
await expect(load(createLoadEvent())).resolves.toEqual({
|
||||||
|
siteVariant: 'marketing',
|
||||||
content: homepageContent,
|
content: homepageContent,
|
||||||
howItWorksEnabled: true
|
howItWorksEnabled: true
|
||||||
});
|
});
|
||||||
|
|||||||