4.0.1 - fixes

This commit is contained in:
2026-05-02 19:44:45 +12:00
parent b0bb692972
commit 07c754da12
34 changed files with 497 additions and 233 deletions
+1
View File
@@ -5,3 +5,4 @@ build
.env.*
npm-debug.log*
migration-backups
deploy-data/
+38
View File
@@ -36,6 +36,12 @@ containers untouched.
- Server-side helper that updates only the `goodwalk-svelte` compose project.
- [docker-compose.prod.yml](docker-compose.prod.yml)
- Production compose file for the new Svelte app, mail API, and Postgres.
- `scripts/export-homepage-content.mjs`
- Local helper that exports the current `src/lib/content/homepage.ts` into a
deployable JSON payload before each deployment.
- `scripts/sync-homepage-content.mjs`
- Runtime helper that upserts the exported homepage content into PostgreSQL
after deploys that affect the app/database.
- [ssh-config](ssh-config)
- Repo-local SSH config used by the deployment script.
- [nginx/goodwalk.co.nz.svelte.conf.example](nginx/goodwalk.co.nz.svelte.conf.example)
@@ -61,9 +67,13 @@ mkdir -p /docker/goodwalk-svelte
It is created from [deploy.env.template](deploy.env.template). Current template contents:
```env
APP_VERSION=4.0.1
TZ=Pacific/Auckland
POSTGRES_DB=goodwalk
POSTGRES_USER=goodwalk
POSTGRES_PASSWORD=gw_Pg_7Jm9!Qx4#Ld2@Vr8
POSTGRES_PASSWORD_URLENCODED=gw_Pg_7Jm9%21Qx4%23Ld2%40Vr8
RESEND_API_KEY=replace-me
OWNER_EMAIL=replace-me
@@ -105,6 +115,29 @@ Or skip the confirmation prompt:
powershell -ExecutionPolicy Bypass -File .\deploy.ps1 -Force
```
To rebuild and restart only one service, for example the mail API:
```powershell
powershell -ExecutionPolicy Bypass -File .\deploy.ps1 -Force -Service mail-api
```
## Homepage content sync
Local development can feel fresher than production because production reads the
homepage/shared content from PostgreSQL whenever `DATABASE_URL` is set.
The deployment flow now handles that automatically:
1. `deploy.ps1` exports the current `src/lib/content/homepage.ts` into
`deploy-data/homepage-content.json`.
2. The deploy archive uploads that JSON payload with the app source.
3. After the Goodwalk stack is updated, the remote helper runs a content sync
inside the app container.
4. That sync upserts the `homepage` row in `site_content`.
This means future deploys will carry your latest file-based homepage/navigation/
shared content changes into production PostgreSQL automatically.
## Cutover nginx
After the new Svelte stack is up and healthy, update the shared nginx config on
@@ -122,6 +155,11 @@ Use the repo example as the new target config:
nginx/goodwalk.co.nz.svelte.conf.example
```
Important:
- The normal `deploy.ps1` flow does not deploy or reload the shared nginx stack.
- Copy the updated nginx config to `/docker/nginx/conf.d/goodwalk.co.nz.conf` and reload nginx once.
- The repo example now uses Docker's internal resolver so future app/mail container rebuilds will not leave nginx pinned to stale upstream IPs.
Then reload nginx:
```bash
+9
View File
@@ -1,4 +1,7 @@
ARG APP_VERSION=4.0.1
FROM node:22-alpine AS builder
ARG APP_VERSION
WORKDIR /app
@@ -6,16 +9,22 @@ COPY package.json ./
RUN npm install
COPY . .
RUN node --experimental-strip-types scripts/export-homepage-content.mjs
RUN npm run build
FROM node:22-alpine AS runner
ARG APP_VERSION
WORKDIR /app
ENV NODE_ENV=production
ENV APP_VERSION=${APP_VERSION}
LABEL org.opencontainers.image.version="${APP_VERSION}"
COPY --from=builder /app/package.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/build ./build
COPY --from=builder /app/scripts/sync-homepage-content.mjs ./scripts/sync-homepage-content.mjs
COPY --from=builder /app/deploy-data/homepage-content.json ./deploy-data/homepage-content.json
EXPOSE 3000
+5 -1
View File
@@ -1,11 +1,15 @@
APP_VERSION=4.0.1
TZ=Pacific/Auckland
POSTGRES_DB=goodwalk
POSTGRES_USER=goodwalk
POSTGRES_PASSWORD=gw_Pg_7Jm9!Qx4#Ld2@Vr8
POSTGRES_PASSWORD_URLENCODED=gw_Pg_7Jm9%21Qx4%23Ld2%40Vr8
RESEND_API_KEY=re_hcDByLp8_HEBW93wDirr7o9g16FgCeYNF
OWNER_EMAIL=mattcohen0@gmail.com
FROM_EMAIL=GoodWalk <info@goodwalk.co.nz>
REPLY_TO=mattcohen0@gmail.com
REPLY_TO=info@goodwalk.co.nz
FORM_MIN_SECONDS=4
FORM_MAX_SECONDS=7200
+41 -2
View File
@@ -1,7 +1,8 @@
[CmdletBinding()]
param(
[switch]$Force,
[switch]$SkipSiteCheck
[switch]$SkipSiteCheck,
[string]$Service
)
# ---------------------------------------------------------------------------
@@ -25,6 +26,7 @@ $VerifyUrl = 'https://www.goodwalk.co.nz/api/health'
$RemoteArchivePath = '/tmp/goodwalk-deploy.tgz'
$RemoteHelperPath = '/tmp/goodwalk-deploy-remote.sh'
$LocalRemoteHelperPath = Join-Path $LocalProjectPath 'scripts\deploy-remote.sh'
$GeneratedHomepageContentPath = Join-Path $LocalProjectPath 'deploy-data\homepage-content.json'
function Assert-NotBlank {
param(
@@ -108,6 +110,31 @@ function New-DeployArchive {
return $archivePath
}
function Export-HomepageContent {
param(
[string]$ProjectPath,
[string]$OutputPath
)
$scriptPath = Join-Path $ProjectPath 'scripts\export-homepage-content.mjs'
if (-not (Test-Path -LiteralPath $scriptPath)) {
throw "Homepage content export script not found: $scriptPath"
}
Push-Location $ProjectPath
try {
Invoke-External -FilePath 'node' -Arguments @(
'--experimental-strip-types',
$scriptPath,
$OutputPath
)
}
finally {
Pop-Location
}
}
function Invoke-SiteCheck {
param([string]$Url)
@@ -126,6 +153,7 @@ function Invoke-SiteCheck {
Assert-Command ssh
Assert-Command scp
Assert-Command tar
Assert-Command node
Assert-NotBlank -Name 'SshUser' -Value $SshUser
Assert-NotBlank -Name 'ServerHost' -Value $ServerHost
@@ -134,6 +162,10 @@ Assert-NotBlank -Name 'RemoteDeploymentPath' -Value $RemoteDeploymentPath
Assert-NotBlank -Name 'ComposeFileName' -Value $ComposeFileName
Assert-NotBlank -Name 'DockerProjectName' -Value $DockerProjectName
if (-not [string]::IsNullOrWhiteSpace($Service)) {
$Service = $Service.Trim()
}
if (-not [string]::IsNullOrWhiteSpace($SshConfigPath) -and -not (Test-Path -LiteralPath $SshConfigPath)) {
throw "SSH config file not found: $SshConfigPath"
}
@@ -166,6 +198,9 @@ Write-Host "[deploy] Remote compose file: $ComposeFileName"
Write-Host "[deploy] Docker project name: $DockerProjectName"
Write-Host "[deploy] SSH target: $sshTarget"
Write-Host "[deploy] SSH config: $SshConfigPath"
if (-not [string]::IsNullOrWhiteSpace($Service)) {
Write-Host "[deploy] Target service: $Service"
}
if ([string]::IsNullOrWhiteSpace($SshKeyPath)) {
Write-Host '[deploy] SSH auth: interactive password prompt'
} else {
@@ -188,6 +223,10 @@ if (-not $Force) {
$archivePath = $null
try {
Write-Host ''
Write-Host '[deploy] Exporting current homepage content for PostgreSQL sync'
Export-HomepageContent -ProjectPath $LocalProjectPath -OutputPath $GeneratedHomepageContentPath
Write-Host ''
Write-Host '[deploy] Creating deployment archive'
$archivePath = New-DeployArchive -ProjectPath $LocalProjectPath
@@ -215,7 +254,7 @@ try {
$ComposeFileName,
'--project-name',
$DockerProjectName
))
) + $(if (-not [string]::IsNullOrWhiteSpace($Service)) { @('--service', $Service) } else { @() }))
Write-Host ''
Write-Host '[deploy] Cleaning remote temporary files'
+9 -1
View File
@@ -2,11 +2,15 @@ services:
app:
build:
context: .
args:
APP_VERSION: ${APP_VERSION:-4.0.1}
container_name: goodwalk_svelte_app
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-goodwalk}:${POSTGRES_PASSWORD:-goodwalk}@db:5432/${POSTGRES_DB:-goodwalk}
APP_VERSION: ${APP_VERSION:-4.0.1}
DATABASE_URL: postgresql://${POSTGRES_USER:-goodwalk}:${POSTGRES_PASSWORD_URLENCODED:-goodwalk}@db:5432/${POSTGRES_DB:-goodwalk}
NODE_ENV: production
PORT: 3000
TZ: ${TZ:-Pacific/Auckland}
depends_on:
- db
expose:
@@ -19,8 +23,11 @@ services:
mail-api:
build:
context: ./mail-api
args:
APP_VERSION: ${APP_VERSION:-4.0.1}
container_name: goodwalk_svelte_mail_api
environment:
APP_VERSION: ${APP_VERSION:-4.0.1}
RESEND_API_KEY: ${RESEND_API_KEY}
OWNER_EMAIL: ${OWNER_EMAIL}
FROM_EMAIL: ${FROM_EMAIL:-GoodWalk <bookings@goodwalk.co.nz>}
@@ -32,6 +39,7 @@ services:
RATE_LIMIT_MAX_PER_EMAIL: ${RATE_LIMIT_MAX_PER_EMAIL:-3}
RATE_LIMIT_MIN_INTERVAL_SECONDS: ${RATE_LIMIT_MIN_INTERVAL_SECONDS:-20}
PYTHONUNBUFFERED: '1'
TZ: ${TZ:-Pacific/Auckland}
expose:
- '8000'
restart: unless-stopped
+8
View File
@@ -2,10 +2,14 @@ services:
app:
build:
context: .
args:
APP_VERSION: ${APP_VERSION:-4.0.1}
environment:
APP_VERSION: ${APP_VERSION:-4.0.1}
DATABASE_URL: postgresql://${POSTGRES_USER:-goodwalk}:${POSTGRES_PASSWORD:-goodwalk}@db:5432/${POSTGRES_DB:-goodwalk}
NODE_ENV: production
PORT: ${APP_PORT:-3000}
TZ: ${TZ:-Pacific/Auckland}
depends_on:
- db
restart: unless-stopped
@@ -13,11 +17,15 @@ services:
mail-api:
build:
context: ./mail-api
args:
APP_VERSION: ${APP_VERSION:-4.0.1}
environment:
APP_VERSION: ${APP_VERSION:-4.0.1}
RESEND_API_KEY: ${RESEND_API_KEY}
OWNER_EMAIL: ${OWNER_EMAIL}
FROM_EMAIL: ${FROM_EMAIL:-GoodWalk <bookings@goodwalk.co.nz>}
REPLY_TO: ${REPLY_TO:-aless@goodwalk.co.nz}
TZ: ${TZ:-Pacific/Auckland}
restart: unless-stopped
db:
-195
View File
@@ -3,198 +3,3 @@ create table if not exists site_content (
value jsonb not null,
updated_at timestamptz not null default now()
);
insert into site_content (key, value)
values (
'homepage',
'{
"seo": {
"title": "Home | Auckland Dog Walking | Goodwalk",
"description": "At Goodwalk, we offer Tiny Gang pack walks and one on one dog walking services throughout Auckland. Give your dog his best life with Goodwalk!"
},
"navigation": {
"desktopLinks": [
{ "label": "Our Services", "href": "#services" },
{ "label": "Our Pricing", "href": "/our-pricing" },
{ "label": "About Us", "href": "/about" }
],
"mobileLinks": [
{ "label": "Home", "href": "/" },
{ "label": "Pack Walks", "href": "/pack-walks" },
{ "label": "1:1 Walks", "href": "/dog-walking" },
{ "label": "Puppy Visits", "href": "/puppy-visits" },
{ "label": "Our Pricing", "href": "/our-pricing" },
{ "label": "About Us", "href": "/about" },
{ "label": "Contact Us", "href": "/booking" }
],
"cta": { "label": "Book Now", "href": "/booking", "variant": "green" }
},
"hero": {
"title": "Unleashing Fun in",
"highlight": "Your Dog''s Day!",
"mobileTitle": "Unleashing Fun in\nYour Dog''s Day!",
"primaryCta": { "label": "Learn more", "href": "#services", "variant": "yellow" },
"secondaryCta": { "label": "Enroll today", "href": "#newlead", "variant": "outline" },
"imageUrl": "/images/auckland-dog-walking-happy-dog-hero.png",
"imageAlt": "Happy dog on a walk with Goodwalk"
},
"intro": {
"text": "Goodwalk delivers trusted, professional dog walking services across Auckland Central.",
"reviewCta": {
"label": "All 5 star reviews on Google!",
"href": "https://g.page/r/CUsvrWPhkYrAEB0/",
"external": true
}
},
"promise": {
"title": "Happy pets,",
"subtitle": "happy humans",
"body": "Offering tailored pack walks for small and medium dogs, and one-on-one walks for large breeds. Our walkers give personalised attention to each dog, easing stress, anxiety and ensuring a quality experience. Our expertise in small-medium breeds ensures tailored care for their unique needs. Join our",
"emphasis": "TINY GANG!",
"cta": { "label": "Book now", "href": "#newlead", "variant": "green" },
"imageUrl": "/images/auckland-dog-walking-happy-dogs-happy-humans.png",
"imageAlt": "Woman cuddling a dog for Goodwalk Auckland dog walking services"
},
"services": [
{
"icon": "fas fa-dog",
"title": "Pack Walks",
"body": "Small group walks of 4-8 dogs - calm, social, and full of fun for your pup.",
"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.",
"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.",
"href": "/puppy-visits"
}
],
"values": [
{
"icon": "fas fa-heart",
"title": "Kindness",
"body": "With gentle care and genuine affection, we make every walk a calm, happy experience. We use positive reinforcement to encourage good behavior because kindness is at the heart of everything we do."
},
{
"icon": "fas fa-camera",
"title": "Daily Updates",
"body": "Catch your pup in action with daily social updates - showcasing their walks, playtime, and mischief with the Tiny Gang. It''s your window into their happiest moments."
},
{
"icon": "fas fa-users",
"title": "Small Pack Sizes",
"body": "With just 4-8 dogs per group, our walks are calm, controlled, and respectful of public spaces - ensuring every dog gets the attention and care they deserve."
},
{
"icon": "fas fa-shield-heart",
"title": "Safety",
"body": "Our team is fully pet first aid certified and trained to handle any situation calmly and confidently. With proactive safety protocols and constant situational awareness, we create a secure environment for every walk."
},
{
"icon": "fas fa-calendar-check",
"title": "Flexibility",
"body": "We know life gets busy - so while we specialise in regular, permanent walks, we''re always happy to adapt. Just give us a little notice, and we''ll do our best to accommodate your changing schedule."
},
{
"icon": "fas fa-clock",
"title": "Reliability",
"body": "We guarantee punctuality and consistency, so you can count on us. With clear communication, you''ll always be in the loop - and your dog''s needs will always be our top priority."
}
],
"testimonials": [
{
"quote": "Love Aless! She is so amazing with my slightly hyper and anxious dog. She is great with communication if anything on either of our ends need to change. Archie love his walks, and I love the photos she posts of him.",
"reviewer": "Kate",
"detail": "Archie''s mum",
"imageUrl": "/images/archie-auckland-dog-walking-review.jpg"
},
{
"quote": "GoodWalk was the best dog walking service for my little pooch ! Aless was very helpful - basically doubled as a second mum to Monty. She always provided feedback on his outings and assisted where possible with any additional training that she felt he could work on and made recommendations where necessary which i feel is what every dog mum wants and needs!",
"reviewer": "Estelle",
"detail": "Monty''s mum",
"imageUrl": "/images/monty-auckland-dog-walking-review.jpg"
},
{
"quote": "Truly the best dog walker in Auckland! I feel so lucky to have found Aless and my little terrier Otis absolutely adores her. He enjoys his regular weekly walks and always comes back happy & tired. Love the updates on social media so I can see how my dog is enjoying his day! Aless makes logistics so easy too. Highly highly recommend, theres a reason she has 5 stars!",
"reviewer": "Ross",
"detail": "Otis''s Dad",
"imageUrl": "/images/otis-auckland-dog-walking-review.jpg"
},
{
"quote": "Alessandra has been walking and spending time with my pup since she was 10 weeks old, coming over and doing puppy visits through to transitioning her to pack walks with her little doggo friends. I know Alassandra loves and cares for my dog as much as I do and my dog has a great time! Cant recommend enough",
"reviewer": "Nina",
"detail": "Wallace''s mum",
"imageUrl": "/images/wallace-auckland-dog-walking-review.jpg"
}
],
"booking": {
"title": "Let''s meet!",
"subtitle": "Ready to get started? Book your free, no-obligation Meet & Greet today — just enter your details below",
"formAction": "/booking",
"serviceOptions": ["Pack Walks", "1:1 Walks", "Puppy Visits", "Other Services"]
},
"info": {
"title": "Locations & Hours",
"intro": "We cover most of Auckland Central''s suburbs:",
"suburbs": "Morningside, Kingsland, Ponsonby, Grey Lynn, Mt Albert, Mt Eden, Sandringham, Mt Roskill, Arch Hill, Freemans Bay, Herne Bay, Pt Chevalier, Avondale, Three Kings, Hillsborough, Eden Terrace, Balmoral.",
"nearbyText": "Live in a nearby suburb?",
"nearbyCta": { "label": "Get in touch!", "href": "#newlead" },
"hoursLabel": "Opening Hours",
"hours": "Monday to Friday, 8am - 4pm.",
"faqTitle": "FAQ''s",
"faqs": [
{
"question": "What happens if the weather is bad?",
"answer": "We operate in all weather conditions, except when there is a danger to the dog''s health and safety."
},
{
"question": "What requirements does my dog need?",
"answer": "All dogs onboarding with Goodwalk need to have a current Auckland Council dog registration and be up to date with vaccinations to ensure the health and safety of other dogs."
},
{
"question": "Can any dog use your service?",
"answer": "All dogs that are onboarded with us must go through our screening process, which includes a minimum of two assessment walks."
},
{
"question": "How does payment work?",
"answer": "All walks are paid for a week in advance, via invoice."
},
{
"question": "Do you have insurance or First Aid training?",
"answer": "All walkers are covered by public liability insurance, and all walkers hold a current First Aid training certificate."
}
]
},
"instagram": {
"title": "Follow us on Instagram",
"label": "@goodwalk.nz",
"href": "https://www.instagram.com/goodwalk.nz/",
"variant": "green",
"external": true
},
"footer": {
"brandText": "Professional Dog Walking Services\nAuckland Central",
"navigationLinks": [
{ "label": "Home", "href": "/" },
{ "label": "Pack Walks", "href": "/pack-walks" },
{ "label": "1:1 Walks", "href": "/dog-walking" },
{ "label": "Puppy Visits", "href": "/puppy-visits" },
{ "label": "Our Pricing", "href": "/our-pricing" },
{ "label": "Contact Us", "href": "/booking" }
],
"contactLinks": [
{ "label": "Book a walk", "href": "/booking" },
{ "label": "Instagram", "href": "https://www.instagram.com/goodwalk.nz/", "external": true },
{ "label": "Google Reviews", "href": "https://g.page/r/CUsvrWPhkYrAEB0", "external": true }
],
"copyright": "© 2024 Goodwalk. All rights reserved."
}
}'::jsonb
)
on conflict (key) do nothing;
+9
View File
@@ -1,8 +1,17 @@
ARG APP_VERSION=4.0.1
FROM python:3.12-slim
ARG APP_VERSION
WORKDIR /app
ENV APP_VERSION=${APP_VERSION}
ENV TZ=Pacific/Auckland
LABEL org.opencontainers.image.version="${APP_VERSION}"
COPY requirements.txt .
RUN apt-get update \
&& apt-get install -y --no-install-recommends tzdata \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
Binary file not shown.
+34 -3
View File
@@ -25,7 +25,7 @@ def _setup_logging() -> logging.Logger:
fmt = logging.Formatter(
"%(asctime)s %(levelname)-8s %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
datefmt="%d/%m/%Y %H:%M:%S %Z",
)
root = logging.getLogger()
@@ -100,6 +100,7 @@ def _load_config() -> dict:
_config = _load_config()
APP_VERSION = os.environ.get("APP_VERSION", "unknown")
resend.api_key = _config["resend_api_key"]
OWNER_EMAIL = _config["owner_email"]
FROM_EMAIL = _config["from_email"]
@@ -115,7 +116,9 @@ RATE_LIMIT_MIN_INTERVAL_SECONDS = _config["rate_limit_min_interval_seconds"]
LOGO_URL = "https://www.goodwalk.co.nz/images/goodwalk-auckland-dog-walking-logo.png"
logger.info(
"Mail API config: from=%r reply_to=%r owner=%r max_attempts=%d form_min=%ss form_max=%ss rate_window=%ss per_ip=%d per_email=%d min_interval=%ss",
"Mail API config: version=%r timezone=%r from=%r reply_to=%r owner=%r max_attempts=%d form_min=%ss form_max=%ss rate_window=%ss per_ip=%d per_email=%d min_interval=%ss",
APP_VERSION,
os.environ.get("TZ", "system-default"),
FROM_EMAIL,
REPLY_TO,
OWNER_EMAIL,
@@ -471,6 +474,7 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
services_text = ", ".join(data.services) if data.services else ""
now = datetime.now()
submitted_at = now.strftime("%d %b %Y at %I:%M %p").lstrip("0")
first_name = data.fullName.split()[0] if data.fullName.strip() else "them"
message_block = f"""
<tr>
@@ -522,6 +526,33 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
<tr>
<td style="background:#ffffff;padding:40px 48px 36px;">
<!-- Quick contact -->
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
style="background:#213021;border-radius:12px;margin-bottom:28px;">
<tr>
<td style="padding:22px 24px;">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:11px;font-weight:700;letter-spacing:0.1em;color:#7aaa7a;
text-transform:uppercase;margin-bottom:10px;">
Quick contact
</div>
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:14px;color:#d8e6d8;line-height:1.6;margin-bottom:10px;">
Email {first_name} directly:
</div>
<div style="font-family:Menlo,Consolas,'SFMono-Regular',monospace;
font-size:20px;font-weight:700;color:#ffffff;line-height:1.4;
word-break:break-all;margin-bottom:12px;">
{data.email}
</div>
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:12px;color:#b7cbb7;line-height:1.6;">
Tap and hold the address to copy on iPhone, or tap below to open a new email.
</div>
</td>
</tr>
</table>
<!-- Owner details -->
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:11px;font-weight:700;letter-spacing:0.1em;color:#888;
@@ -612,7 +643,7 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str:
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
font-size:14px;font-weight:600;text-decoration:none;
border-radius:8px;padding:12px 24px;">
Reply to {data.fullName.split()[0]}
Email {first_name}
</a>
</td>
<td>
+8 -2
View File
@@ -49,6 +49,10 @@ server {
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss image/svg+xml;
# Re-resolve Docker service/container names after container rebuilds so
# nginx does not keep stale upstream IPs in memory.
resolver 127.0.0.11 ipv6=off valid=30s;
location ~* /\.(git|env|htaccess) {
deny all;
}
@@ -62,8 +66,9 @@ server {
}
location /api/submit {
set $goodwalk_mail_api goodwalk_svelte_mail_api:8000;
limit_req zone=goodwalk_limit burst=10 nodelay;
proxy_pass http://goodwalk_svelte_mail_api:8000/submit;
proxy_pass http://$goodwalk_mail_api/submit;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -72,7 +77,8 @@ server {
}
location / {
proxy_pass http://goodwalk_svelte_app:3000;
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;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "goodwalk-svelte-port",
"version": "0.1.0",
"version": "4.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "goodwalk-svelte-port",
"version": "0.1.0",
"version": "4.0.1",
"dependencies": {
"canvas-confetti": "^1.9.4",
"pg": "^8.13.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "goodwalk-svelte-port",
"version": "0.1.0",
"version": "4.0.1",
"private": true,
"type": "module",
"scripts": {
+32
View File
@@ -5,11 +5,13 @@ ARCHIVE_PATH=""
DEPLOY_PATH=""
COMPOSE_FILE=""
PROJECT_NAME=""
SERVICE_NAME=""
usage() {
cat <<'EOF'
Usage:
deploy-remote.sh --archive <path> --deploy-path <path> --compose-file <name> --project-name <name>
deploy-remote.sh --archive <path> --deploy-path <path> --compose-file <name> --project-name <name> [--service <name>]
This script only updates the main Goodwalk compose project at the specified
deployment path. It does not touch unrelated Docker projects or global Docker
@@ -40,6 +42,10 @@ while [[ $# -gt 0 ]]; do
PROJECT_NAME="${2:-}"
shift 2
;;
--service)
SERVICE_NAME="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
@@ -54,6 +60,9 @@ done
[[ -n "$DEPLOY_PATH" ]] || fail "--deploy-path is required"
[[ -n "$COMPOSE_FILE" ]] || fail "--compose-file is required"
[[ -n "$PROJECT_NAME" ]] || fail "--project-name is required"
if [[ -n "$SERVICE_NAME" ]]; then
SERVICE_NAME="$(printf '%s' "$SERVICE_NAME" | xargs)"
fi
[[ "$DEPLOY_PATH" != "/" ]] || fail "Refusing to deploy to /"
[[ -f "$ARCHIVE_PATH" ]] || fail "Archive not found: $ARCHIVE_PATH"
@@ -77,6 +86,9 @@ echo "[deploy-remote] Deploying main Goodwalk stack"
echo "[deploy-remote] Target deployment path: $DEPLOY_PATH"
echo "[deploy-remote] Compose file: $COMPOSE_FILE"
echo "[deploy-remote] Docker project: $PROJECT_NAME"
if [[ -n "$SERVICE_NAME" ]]; then
echo "[deploy-remote] Target service: $SERVICE_NAME"
fi
echo "[deploy-remote] Staging archive in: $STAGING_DIR"
mkdir -p "$DEPLOY_PATH"
@@ -130,13 +142,33 @@ cd "$DEPLOY_PATH"
echo "[deploy-remote] Validating compose configuration"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" config >/dev/null
if [[ -n "$SERVICE_NAME" ]]; then
AVAILABLE_SERVICES="$("${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" config --services)"
if ! grep -Fxq "$SERVICE_NAME" <<<"$AVAILABLE_SERVICES"; then
fail "Service '$SERVICE_NAME' was not found in $COMPOSE_FILE. Available services: $(tr '\n' ',' <<<"$AVAILABLE_SERVICES" | sed 's/,$//')"
fi
fi
if [[ -n "$SERVICE_NAME" ]]; then
echo "[deploy-remote] Stopping only the Goodwalk service: $SERVICE_NAME"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" stop "$SERVICE_NAME" || true
echo "[deploy-remote] Rebuilding and starting only the Goodwalk service: $SERVICE_NAME"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" up -d --build "$SERVICE_NAME"
else
echo "[deploy-remote] Stopping only the Goodwalk project containers"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" stop || true
echo "[deploy-remote] Rebuilding and starting only the Goodwalk project containers"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" up -d --build --remove-orphans
fi
echo "[deploy-remote] Current Goodwalk container status"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" ps
if [[ -z "$SERVICE_NAME" || "$SERVICE_NAME" == "app" || "$SERVICE_NAME" == "db" ]]; then
echo "[deploy-remote] Syncing homepage content into PostgreSQL"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" exec -T app node scripts/sync-homepage-content.mjs
fi
echo "[deploy-remote] Remote deployment finished"
+20
View File
@@ -0,0 +1,20 @@
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
const outputPath = process.argv[2]
? path.resolve(process.argv[2])
: path.join(projectRoot, 'deploy-data', 'homepage-content.json');
const homepageModuleUrl = pathToFileURL(
path.join(projectRoot, 'src', 'lib', 'content', 'homepage.ts')
).href;
const { homepageContent } = await import(homepageModuleUrl);
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(homepageContent, null, 2)}\n`, 'utf8');
console.log(`[deploy] Exported homepage content to ${outputPath}`);
+41
View File
@@ -0,0 +1,41 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import pg from 'pg';
const { Pool } = pg;
const contentPath = process.argv[2]
? path.resolve(process.argv[2])
: path.resolve('deploy-data', 'homepage-content.json');
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL is required for homepage content sync.');
}
const rawContent = await readFile(contentPath, 'utf8');
const homepageContent = JSON.parse(rawContent);
const pool = new Pool({ connectionString });
try {
await pool.query(`
create table if not exists site_content (
key text primary key,
value jsonb not null,
updated_at timestamptz not null default now()
)
`);
await pool.query(
`
insert into site_content (key, value)
values ($1, $2::jsonb)
on conflict (key)
do update set value = excluded.value, updated_at = now()
`,
['homepage', JSON.stringify(homepageContent)]
);
console.log(`[content-sync] Synced homepage content from ${contentPath}`);
} finally {
await pool.end();
}
+24
View File
@@ -26,10 +26,34 @@
rel="stylesheet"
/>
</noscript>
<link
rel="preconnect"
href="https://cdnjs.cloudflare.com"
crossorigin="anonymous"
/>
<link
rel="preload"
as="style"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"
media="print"
onload="this.media='all'"
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<noscript>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
</noscript>
<!-- Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-K7TLSFJVP1"></script>
<script>
+9 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import Icon from '$lib/components/Icon.svelte';
import { reveal } from '$lib/actions/reveal';
import { getImageMetadata } from '$lib/image-metadata';
import type { AboutPageContent, SiteSharedContent } from '$lib/types';
export let content: SiteSharedContent;
@@ -29,7 +30,14 @@
</div>
<div class="about-media">
<img src={section.imageUrl} alt={section.imageAlt} loading="lazy" />
<img
src={section.imageUrl}
alt={section.imageAlt}
width={getImageMetadata(section.imageUrl)?.width}
height={getImageMetadata(section.imageUrl)?.height}
loading="lazy"
decoding="async"
/>
</div>
</div>
</section>
+4 -1
View File
@@ -18,7 +18,10 @@
src="/images/goodwalk-auckland-dog-walking-logo.png"
alt="Goodwalk Auckland dog walking service logo"
class="footer-logo"
height="28"
width="241"
height="48"
loading="lazy"
decoding="async"
/>
<p>{footer.brandText}</p>
<div class="social-links">
+7 -1
View File
@@ -119,7 +119,13 @@
media="(max-width: 768px)"
srcset="/images/goodwalk-auckland-dog-walking-logo-mobile.png"
/>
<img src="/images/goodwalk-auckland-dog-walking-logo.png" alt="Goodwalk Auckland dog walking service logo" height="21" />
<img
src="/images/goodwalk-auckland-dog-walking-logo.png"
alt="Goodwalk Auckland dog walking service logo"
width="241"
height="48"
decoding="async"
/>
</picture>
</a>
+4
View File
@@ -1,10 +1,12 @@
<script lang="ts">
import { getImageMetadata } from '$lib/image-metadata';
import type { HeroContent } from '$lib/types';
export let hero: HeroContent;
$: titleParts = splitTitle(hero.title);
$: mobileTitle = hero.mobileTitle?.trim() || `${hero.title} ${hero.highlight}`.trim();
$: heroImage = getImageMetadata(hero.imageUrl);
function splitTitle(title: string) {
const trimmed = title.trim();
@@ -48,6 +50,8 @@
<img
src={hero.imageUrl}
alt={hero.imageAlt}
width={heroImage?.width}
height={heroImage?.height}
loading="eager"
fetchpriority="high"
decoding="async"
+11 -1
View File
@@ -1,7 +1,10 @@
<script lang="ts">
import { getImageMetadata } from '$lib/image-metadata';
import type { PromiseContent } from '$lib/types';
export let promise: PromiseContent;
$: promiseImage = getImageMetadata(promise.imageUrl);
</script>
<section id="promise">
@@ -21,7 +24,14 @@
</div>
<div class="promise-img">
<img src={promise.imageUrl} alt={promise.imageAlt} />
<img
src={promise.imageUrl}
alt={promise.imageAlt}
width={promiseImage?.width}
height={promiseImage?.height}
loading="lazy"
decoding="async"
/>
</div>
</div>
</section>
+8
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { getImageMetadata } from '$lib/image-metadata';
export let title: string;
export let description: string;
export let canonicalPath: string;
@@ -31,6 +33,7 @@
$: pageTitle = fullTitle(title);
$: canonicalUrl = absoluteUrl(canonicalPath);
$: imageUrl = absoluteUrl(image);
$: imageMeta = getImageMetadata(image);
</script>
<svelte:head>
@@ -62,8 +65,13 @@
<meta property="og:image" content={imageUrl} />
<meta property="og:image:secure_url" content={imageUrl} />
<meta property="og:image:alt" content={imageAlt} />
{#if imageMeta}
<meta property="og:image:width" content={String(imageMeta.width)} />
<meta property="og:image:height" content={String(imageMeta.height)} />
{/if}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@goodwalk.nz" />
<meta name="twitter:title" content={pageTitle} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={imageUrl} />
@@ -3,10 +3,14 @@
import { reveal } from '$lib/actions/reveal';
import BookingSection from '$lib/components/BookingSection.svelte';
import TestimonialsSection from '$lib/components/TestimonialsSection.svelte';
import { getImageMetadata } from '$lib/image-metadata';
import type { ServicePageContent, SiteSharedContent } from '$lib/types';
export let content: SiteSharedContent;
export let pageContent: ServicePageContent;
$: heroImage = getImageMetadata(pageContent.hero.imageUrl);
$: highlightImage = pageContent.highlight ? getImageMetadata(pageContent.highlight.imageUrl) : null;
</script>
<main class="service-page">
@@ -25,6 +29,8 @@
<img
src={pageContent.hero.imageUrl}
alt={pageContent.hero.imageAlt}
width={heroImage?.width}
height={heroImage?.height}
loading="eager"
fetchpriority="high"
decoding="async"
@@ -45,7 +51,10 @@
<img
src={pageContent.highlight.imageUrl}
alt={pageContent.highlight.imageAlt}
width={highlightImage?.width}
height={highlightImage?.height}
loading="lazy"
decoding="async"
/>
</div>
</div>
@@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { reveal } from '$lib/actions/reveal';
import Icon from '$lib/components/Icon.svelte';
import { getImageMetadata } from '$lib/image-metadata';
import type { TestimonialContent } from '$lib/types';
export let testimonials: TestimonialContent[];
@@ -123,11 +124,14 @@
<div class="testimonial-photo-wrap">
<div class="testimonial-photo-frame">
{#if index === activeIndex}
{@const imageMeta = getImageMetadata(testimonial.imageUrl)}
<img
class="testimonial-photo"
src={testimonial.imageUrl}
alt={`${testimonial.reviewer}'s dog`}
loading={activeIndex === 0 ? 'eager' : 'lazy'}
width={imageMeta?.width}
height={imageMeta?.height}
loading="lazy"
decoding="async"
/>
{/if}
+85 -10
View File
@@ -1,21 +1,48 @@
import { fireEvent, render } from '@testing-library/svelte';
import { fireEvent, render, screen } from '@testing-library/svelte';
import { afterEach, describe, expect, it, vi } from 'vitest';
import TestimonialsSection from './TestimonialsSection.svelte';
import { homepageContent } from '$lib/content/homepage';
import type { TestimonialContent } from '$lib/types';
const expectedMappedSlides = [
{ reviewer: 'Kate', src: '/images/archie-auckland-dog-walking-review.png' },
{ reviewer: 'Estelle', src: '/images/monty-auckland-dog-walking-review.png' },
{ reviewer: 'Ross', src: '/images/otis-auckland-dog-walking-review.png' },
{ reviewer: 'Nina', src: '/images/wallace-auckland-dog-walking-review.png' }
];
function getActiveSlide(container: HTMLElement) {
return container.querySelector('.testimonial-slide-active') as HTMLElement;
}
function getActiveReviewer(container: HTMLElement) {
return getActiveSlide(container).querySelector('.testimonial-author-name')?.textContent;
}
function getActiveImage(container: HTMLElement) {
return getActiveSlide(container).querySelector('img') as HTMLImageElement;
}
describe('TestimonialsSection', () => {
afterEach(() => {
vi.useRealTimers();
});
it('uses the mapped local image assets for known testimonials', () => {
it('maps all known testimonial images to the local PNG assets', async () => {
const { container } = render(TestimonialsSection, {
testimonials: homepageContent.testimonials
});
const activeImage = container.querySelector('.testimonial-slide-active img') as HTMLImageElement;
const nextButton = screen.getByRole('button', { name: /next testimonial/i });
expect(activeImage.getAttribute('src')).toBe('/images/archie-auckland-dog-walking-review.jpg');
for (const [index, slide] of expectedMappedSlides.entries()) {
expect(getActiveReviewer(container)).toBe(slide.reviewer);
expect(getActiveImage(container).getAttribute('src')).toBe(slide.src);
if (index < expectedMappedSlides.length - 1) {
await fireEvent.click(nextButton);
}
}
});
it('moves to the next testimonial on arrow click and auto-rotation', async () => {
@@ -25,16 +52,64 @@ describe('TestimonialsSection', () => {
testimonials: homepageContent.testimonials
});
const nextButton = container.querySelector('.testimonial-arrow-right') as HTMLButtonElement;
const activeReviewer = () =>
(container.querySelector('.testimonial-slide-active h6 strong') as HTMLElement).textContent;
const nextButton = screen.getByRole('button', { name: /next testimonial/i });
expect(activeReviewer()).toBe('Kate');
expect(getActiveReviewer(container)).toBe('Kate');
await fireEvent.click(nextButton);
expect(activeReviewer()).toBe('Estelle');
expect(getActiveReviewer(container)).toBe('Estelle');
await vi.advanceTimersByTimeAsync(5000);
expect(activeReviewer()).toBe('Ross');
expect(getActiveReviewer(container)).toBe('Ross');
});
it('wraps to the last testimonial when navigating backwards from the first slide', async () => {
const { container } = render(TestimonialsSection, {
testimonials: homepageContent.testimonials
});
const previousButton = screen.getByRole('button', { name: /previous testimonial/i });
expect(getActiveReviewer(container)).toBe('Kate');
await fireEvent.click(previousButton);
expect(getActiveReviewer(container)).toBe('Nina');
expect(getActiveImage(container).getAttribute('src')).toBe(
'/images/wallace-auckland-dog-walking-review.png'
);
});
it('keeps custom testimonial images and filters out testimonials with no image', async () => {
const customTestimonials: TestimonialContent[] = [
...homepageContent.testimonials,
{
reviewer: 'Casey',
detail: "Poppy's mum",
quote: 'Thoughtful updates and a very happy dog after every walk.',
imageUrl: '/images/custom-casey-review.png'
},
{
reviewer: 'Jordan',
detail: "Scout's dad",
quote: 'Should be hidden because there is no image.'
}
];
const { container } = render(TestimonialsSection, {
testimonials: customTestimonials
});
const nextButton = screen.getByRole('button', { name: /next testimonial/i });
expect(container.querySelectorAll('.testimonial-slide')).toHaveLength(5);
for (let step = 0; step < 4; step += 1) {
await fireEvent.click(nextButton);
}
expect(getActiveReviewer(container)).toBe('Casey');
expect(getActiveImage(container).getAttribute('src')).toBe('/images/custom-casey-review.png');
expect(screen.queryByText('Jordan')).not.toBeInTheDocument();
});
});
+1 -1
View File
@@ -53,7 +53,7 @@ export const homepageContent: HomePageContent = {
'Offering tailored pack walks for small and medium dogs, and one-on-one walks for large breeds. Our walkers give personalised attention to each dog, easing stress, anxiety and ensuring a quality experience. Our expertise in small-medium breeds ensures tailored care for their unique needs. Join our',
emphasis: 'TINY GANG!',
cta: { label: 'Book now', href: '#newlead', variant: 'green' },
imageUrl: '/images/auckland-dog-walking-happy-dogs-happy-humans.png',
imageUrl: '/images/auckland-dog-walking-happy-dogs-happy-humans.webp',
imageAlt: 'Woman cuddling a dog for Goodwalk Auckland dog walking services'
},
services: [
+31
View File
@@ -0,0 +1,31 @@
export interface ImageMetadata {
width: number;
height: number;
}
const imageMetadata: Record<string, ImageMetadata> = {
'/images/goodwalk-auckland-dog-walking-logo.png': { width: 241, height: 48 },
'/images/goodwalk-auckland-dog-walking-logo-mobile.png': { width: 206, height: 41 },
'/images/auckland-dog-walking-happy-dog-hero.png': { width: 500, height: 500 },
'/images/auckland-dog-walking-happy-dogs-happy-humans.webp': { width: 1222, height: 1312 },
'/images/archie-auckland-dog-walking-review.png': { width: 1122, height: 1402 },
'/images/monty-auckland-dog-walking-review.png': { width: 1254, height: 1254 },
'/images/otis-auckland-dog-walking-review.png': { width: 1254, height: 1254 },
'/images/wallace-auckland-dog-walking-review.png': { width: 1254, height: 1254 },
'/images/auckland-small-dog-pack-walk.jpg': { width: 640, height: 480 },
'/images/tiny-gang-auckland-dog-pack.jpg': { width: 1024, height: 297 },
'/images/auckland-large-dog-one-on-one-walk.jpg': { width: 1024, height: 970 },
'/images/auckland-dogs-outdoor-pack.jpg': { width: 1024, height: 297 },
'/images/auckland-puppy-home-visit.jpg': { width: 640, height: 427 },
'/images/auckland-pack-walk-dog.jpg': { width: 480, height: 640 },
'/images/auckland-dog-group-outing.jpg': { width: 640, height: 480 },
'/images/goodwalk-dog-walker-alessandra.png': { width: 640, height: 640 }
};
export function getImageMetadata(src: string | undefined | null): ImageMetadata | null {
if (!src) {
return null;
}
return imageMetadata[src] ?? null;
}
+12
View File
@@ -79,4 +79,16 @@ describe('content server helpers', () => {
footer: homepageContent.footer
});
});
it('returns cloned shared page sections instead of original references', async () => {
vi.mocked(getPool).mockReturnValue(null);
const result = await getSharedPageContent();
expect(result.navigation).not.toBe(homepageContent.navigation);
expect(result.services).not.toBe(homepageContent.services);
expect(result.testimonials).not.toBe(homepageContent.testimonials);
expect(result.booking).not.toBe(homepageContent.booking);
expect(result.footer).not.toBe(homepageContent.footer);
});
});
+3
View File
@@ -1,5 +1,8 @@
header {
position: relative;
z-index: 100;
isolation: isolate;
overflow: visible;
background: var(--green);
}
+9 -1
View File
@@ -17,6 +17,14 @@
const siteUrl = 'https://www.goodwalk.co.nz';
function absoluteUrl(value: string) {
if (value.startsWith('http://') || value.startsWith('https://')) {
return value;
}
return `${siteUrl}${value.startsWith('/') ? value : `/${value}`}`;
}
$: homepageStructuredData = [
{
'@context': 'https://schema.org',
@@ -33,7 +41,7 @@
'Professional dog walking services across Auckland Central, including pack walks, 1:1 walks, and puppy visits.',
url: siteUrl,
logo: `${siteUrl}/images/goodwalk-auckland-dog-walking-logo.png`,
image: data.content.hero.imageUrl,
image: absoluteUrl(data.content.hero.imageUrl),
email: 'info@goodwalk.co.nz',
telephone: '+64-22-642-1011',
sameAs: ['https://www.instagram.com/goodwalk.nz/', 'https://g.page/r/CUsvrWPhkYrAEB0/'],
+12 -4
View File
@@ -22,6 +22,14 @@
const defaultSeoImage = '/images/auckland-dog-walking-happy-dog-hero.png';
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 breadcrumbSchema(name: string, path: string) {
return {
'@context': 'https://schema.org',
@@ -80,7 +88,7 @@
url: siteUrl
},
areaServed: 'Auckland Central, New Zealand',
image: seoImage,
image: absoluteUrl(seoImage),
url: `${siteUrl}${data.page.canonicalPath}`
},
breadcrumbSchema('Pack Walks', data.page.canonicalPath)
@@ -102,7 +110,7 @@
url: siteUrl
},
areaServed: 'Auckland Central, New Zealand',
image: seoImage,
image: absoluteUrl(seoImage),
url: `${siteUrl}${data.page.canonicalPath}`
},
breadcrumbSchema('1:1 Walks', data.page.canonicalPath)
@@ -124,7 +132,7 @@
url: siteUrl
},
areaServed: 'Auckland Central, New Zealand',
image: seoImage,
image: absoluteUrl(seoImage),
url: `${siteUrl}${data.page.canonicalPath}`
},
breadcrumbSchema('Puppy Visits', data.page.canonicalPath)
@@ -150,7 +158,7 @@
name: data.page.title,
description: data.page.description,
url: `${siteUrl}${data.page.canonicalPath}`,
image: seoImage
image: absoluteUrl(seoImage)
},
breadcrumbSchema('About Us', data.page.canonicalPath)
];

Before

Width:  |  Height:  |  Size: 564 KiB

After

Width:  |  Height:  |  Size: 564 KiB