4.0.1 - fixes
This commit is contained in:
@@ -5,3 +5,4 @@ build
|
||||
.env.*
|
||||
npm-debug.log*
|
||||
migration-backups
|
||||
deploy-data/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+6
-2
@@ -1,15 +1,19 @@
|
||||
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
|
||||
RATE_LIMIT_WINDOW_SECONDS=900
|
||||
RATE_LIMIT_MAX_PER_IP=5
|
||||
RATE_LIMIT_MAX_PER_EMAIL=3
|
||||
RATE_LIMIT_MIN_INTERVAL_SECONDS=20
|
||||
RATE_LIMIT_MIN_INTERVAL_SECONDS=20
|
||||
|
||||
+41
-2
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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, there’s 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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "goodwalk-svelte-port",
|
||||
"version": "0.1.0",
|
||||
"version": "4.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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
|
||||
|
||||
echo "[deploy-remote] Stopping only the Goodwalk project containers"
|
||||
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" stop || true
|
||||
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
|
||||
|
||||
echo "[deploy-remote] Rebuilding and starting only the Goodwalk project containers"
|
||||
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" up -d --build --remove-orphans
|
||||
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"
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
header {
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
isolation: isolate;
|
||||
overflow: visible;
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
|
||||
@@ -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/'],
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user