diff --git a/.gitignore b/.gitignore index 439bcde..4a77626 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ build .env.* npm-debug.log* migration-backups +deploy-data/ diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index cf411c9..0907d6a 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -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 diff --git a/Dockerfile b/Dockerfile index daa5c7e..4092e83 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/deploy.env.template b/deploy.env.template index 307ae19..c886e1c 100644 --- a/deploy.env.template +++ b/deploy.env.template @@ -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 -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 \ No newline at end of file +RATE_LIMIT_MIN_INTERVAL_SECONDS=20 diff --git a/deploy.ps1 b/deploy.ps1 index 9b1d59c..c3a13b8 100644 --- a/deploy.ps1 +++ b/deploy.ps1 @@ -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' diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index dc38737..3b80592 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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 } @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index c78850f..9a763d6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 } REPLY_TO: ${REPLY_TO:-aless@goodwalk.co.nz} + TZ: ${TZ:-Pacific/Auckland} restart: unless-stopped db: diff --git a/docker/postgres/init/001-site-content.sql b/docker/postgres/init/001-site-content.sql index 526314f..1c33c9e 100644 --- a/docker/postgres/init/001-site-content.sql +++ b/docker/postgres/init/001-site-content.sql @@ -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; diff --git a/mail-api/Dockerfile b/mail-api/Dockerfile index 330f254..d94b1d4 100644 --- a/mail-api/Dockerfile +++ b/mail-api/Dockerfile @@ -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 . diff --git a/mail-api/__pycache__/main.cpython-314.pyc b/mail-api/__pycache__/main.cpython-314.pyc deleted file mode 100644 index 5dc8a7d..0000000 Binary files a/mail-api/__pycache__/main.cpython-314.pyc and /dev/null differ diff --git a/mail-api/main.py b/mail-api/main.py index 3058575..3b2eda8 100644 --- a/mail-api/main.py +++ b/mail-api/main.py @@ -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""" @@ -522,6 +526,33 @@ def owner_email(data: BookingSubmission, ip: str, browser: str) -> str: + + + + + +
+
+ Quick contact +
+
+ Email {first_name} directly: +
+
+ {data.email} +
+
+ Tap and hold the address to copy on iPhone, or tap below to open a new email. +
+
+
- Reply to {data.fullName.split()[0]} + Email {first_name} diff --git a/nginx/goodwalk.co.nz.svelte.conf.example b/nginx/goodwalk.co.nz.svelte.conf.example index 2aecc13..e509f4d 100644 --- a/nginx/goodwalk.co.nz.svelte.conf.example +++ b/nginx/goodwalk.co.nz.svelte.conf.example @@ -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; diff --git a/package-lock.json b/package-lock.json index 6b1764b..e31d669 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index f95877f..d2867b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "goodwalk-svelte-port", - "version": "0.1.0", + "version": "4.0.1", "private": true, "type": "module", "scripts": { diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh index d5f777d..d19dd1d 100644 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -5,11 +5,13 @@ ARCHIVE_PATH="" DEPLOY_PATH="" COMPOSE_FILE="" PROJECT_NAME="" +SERVICE_NAME="" usage() { cat <<'EOF' Usage: deploy-remote.sh --archive --deploy-path --compose-file --project-name + deploy-remote.sh --archive --deploy-path --compose-file --project-name [--service ] 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" diff --git a/scripts/export-homepage-content.mjs b/scripts/export-homepage-content.mjs new file mode 100644 index 0000000..92898c6 --- /dev/null +++ b/scripts/export-homepage-content.mjs @@ -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}`); diff --git a/scripts/sync-homepage-content.mjs b/scripts/sync-homepage-content.mjs new file mode 100644 index 0000000..2410129 --- /dev/null +++ b/scripts/sync-homepage-content.mjs @@ -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(); +} diff --git a/src/app.html b/src/app.html index 7a2dea8..6168892 100644 --- a/src/app.html +++ b/src/app.html @@ -26,10 +26,34 @@ rel="stylesheet" /> + + +
@@ -21,7 +24,14 @@
- {promise.imageAlt} + {promise.imageAlt}
diff --git a/src/lib/components/SeoHead.svelte b/src/lib/components/SeoHead.svelte index 11281f0..b535c93 100644 --- a/src/lib/components/SeoHead.svelte +++ b/src/lib/components/SeoHead.svelte @@ -1,4 +1,6 @@ @@ -62,8 +65,13 @@ + {#if imageMeta} + + + {/if} + diff --git a/src/lib/components/ServiceLandingPage.svelte b/src/lib/components/ServiceLandingPage.svelte index f90cc98..d7a483c 100644 --- a/src/lib/components/ServiceLandingPage.svelte +++ b/src/lib/components/ServiceLandingPage.svelte @@ -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;
@@ -25,6 +29,8 @@ {pageContent.hero.imageAlt} diff --git a/src/lib/components/TestimonialsSection.svelte b/src/lib/components/TestimonialsSection.svelte index 8052261..08dce18 100644 --- a/src/lib/components/TestimonialsSection.svelte +++ b/src/lib/components/TestimonialsSection.svelte @@ -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 @@
{#if index === activeIndex} + {@const imageMeta = getImageMetadata(testimonial.imageUrl)} {`${testimonial.reviewer}'s {/if} diff --git a/src/lib/components/TestimonialsSection.test.ts b/src/lib/components/TestimonialsSection.test.ts index 1792334..8ece345 100644 --- a/src/lib/components/TestimonialsSection.test.ts +++ b/src/lib/components/TestimonialsSection.test.ts @@ -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(); }); }); diff --git a/src/lib/content/homepage.ts b/src/lib/content/homepage.ts index d43844e..81488d6 100644 --- a/src/lib/content/homepage.ts +++ b/src/lib/content/homepage.ts @@ -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: [ diff --git a/src/lib/image-metadata.ts b/src/lib/image-metadata.ts new file mode 100644 index 0000000..d91c708 --- /dev/null +++ b/src/lib/image-metadata.ts @@ -0,0 +1,31 @@ +export interface ImageMetadata { + width: number; + height: number; +} + +const imageMetadata: Record = { + '/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; +} diff --git a/src/lib/server/content.test.ts b/src/lib/server/content.test.ts index 008b218..756b73e 100644 --- a/src/lib/server/content.test.ts +++ b/src/lib/server/content.test.ts @@ -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); + }); }); diff --git a/src/lib/styles/layout.css b/src/lib/styles/layout.css index 646264b..c23bc27 100644 --- a/src/lib/styles/layout.css +++ b/src/lib/styles/layout.css @@ -1,5 +1,8 @@ header { + position: relative; z-index: 100; + isolation: isolate; + overflow: visible; background: var(--green); } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 3294fdc..fd7b815 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -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/'], diff --git a/src/routes/[slug]/+page.svelte b/src/routes/[slug]/+page.svelte index 544be33..55fa2c0 100644 --- a/src/routes/[slug]/+page.svelte +++ b/src/routes/[slug]/+page.svelte @@ -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) ]; diff --git a/static/images/auckland-dog-walking-happy-dogs-happy-humans.png b/static/images/auckland-dog-walking-happy-dogs-happy-humans.webp similarity index 100% rename from static/images/auckland-dog-walking-happy-dogs-happy-humans.png rename to static/images/auckland-dog-walking-happy-dogs-happy-humans.webp