Initial commit
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.svelte-kit
|
||||
build
|
||||
npm-debug.log*
|
||||
.git
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.svelte-kit
|
||||
build
|
||||
.env
|
||||
.env.*
|
||||
npm-debug.log*
|
||||
migration-backups
|
||||
@@ -0,0 +1,94 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Goodwalk Consumer facing brand website
|
||||
|
||||
Convert the existing dog walking business website from WordPress/Elementor to a SvelteKit frontend served behind NGINX, with a PostgreSQL backend for editable text/content.
|
||||
|
||||
The visual design must remain almost identical to the current WordPress site.
|
||||
|
||||
# Goodwalk brand colours
|
||||
#213021 - Goodwalk Green
|
||||
#FFD100 - Goodwalk Yellow for buttons
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
- Do not redesign the site.
|
||||
- Preserve the same colours, fonts, spacing, layout, section order, and visual hierarchy.
|
||||
- Any visual changes must be minimal and justified by technical necessity.
|
||||
- Match the existing site as closely as possible, including mobile layout.
|
||||
- Move all CSS into its own organised directory.
|
||||
- Use SvelteKit for the frontend.
|
||||
- Use PostgreSQL for editable site text/content.
|
||||
- Use NGINX as the public web server/reverse proxy.
|
||||
- Keep the stack suitable for Docker deployment.
|
||||
|
||||
## Frontend requirements
|
||||
|
||||
- Build with SvelteKit.
|
||||
- Use component-based structure.
|
||||
- Keep page layout faithful to the WordPress/Elementor original.
|
||||
- CSS must not be scattered randomly inside components unless scoped component styling is genuinely clearer.
|
||||
- Create a dedicated CSS structure, for example:
|
||||
|
||||
src/lib/styles/
|
||||
base.css
|
||||
variables.css
|
||||
layout.css
|
||||
typography.css
|
||||
buttons.css
|
||||
forms.css
|
||||
sections.css
|
||||
responsive.css
|
||||
|
||||
- Centralise colours, fonts, spacing, border radius, and shadows in variables.
|
||||
- Do not introduce a new design system.
|
||||
- Recreate the existing one cleanly.
|
||||
|
||||
## Backend/content requirements
|
||||
|
||||
- PostgreSQL should store editable text/content.
|
||||
- Content should be structured so business owners can later edit:
|
||||
- homepage hero text
|
||||
- service descriptions
|
||||
- pricing text if applicable
|
||||
- about text
|
||||
- contact details
|
||||
- call-to-action text
|
||||
- SEO title/description fields
|
||||
|
||||
## Migration approach
|
||||
|
||||
1. Inspect the existing WordPress/Elementor site.
|
||||
2. Identify all pages, sections, colours, fonts, spacing, and content blocks.
|
||||
3. Rebuild the frontend in SvelteKit.
|
||||
4. Extract repeated UI into reusable components.
|
||||
5. Move CSS into the dedicated styles directory.
|
||||
6. Add PostgreSQL-backed content models.
|
||||
7. Keep the first version visually static if needed, then wire content fields to the database.
|
||||
8. Do not change wording unless explicitly asked.
|
||||
|
||||
## Quality bar
|
||||
|
||||
- The finished site should look like the current site, not a “modernised” reinterpretation.
|
||||
- Prioritise visual fidelity over clever abstractions.
|
||||
- Keep code clean, simple, and easy to maintain.
|
||||
- Avoid unnecessary dependencies.
|
||||
- Ensure the site works well on desktop and mobile.
|
||||
- Use semantic HTML where practical.
|
||||
- Keep accessibility in mind without altering the visual design.
|
||||
|
||||
## Docker/infra expectation
|
||||
|
||||
The final stack should support:
|
||||
|
||||
- SvelteKit frontend
|
||||
- backend/API if needed
|
||||
- PostgreSQL database - Might not be required, maybe a simple json file.
|
||||
- NGINX reverse proxy
|
||||
- environment-based configuration
|
||||
- production-ready Docker Compose setup
|
||||
|
||||
## When uncertain
|
||||
|
||||
If a design detail is unclear, preserve the WordPress/Elementor behaviour as closely as possible.
|
||||
Do not make creative design decisions without being asked.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Deployment
|
||||
|
||||
## What the scripts do
|
||||
|
||||
- `scripts/migrate-wordpress.ps1`
|
||||
- Dumps the existing WordPress MySQL database to `migration-backups/<timestamp>/wordpress.sql`
|
||||
- Copies `wp-content/uploads` out of the legacy WordPress container into `static/wp-content/uploads`
|
||||
- Keeps an archive copy of the uploads in `migration-backups/<timestamp>/uploads`
|
||||
|
||||
- `scripts/deploy.ps1`
|
||||
- Optionally runs the migration step first
|
||||
- Optionally shuts down the legacy compose stack
|
||||
- Validates the new compose file
|
||||
- Builds and starts the new stack
|
||||
- Waits for `http://localhost/api/health` to return success
|
||||
|
||||
## Before cutover
|
||||
|
||||
1. Fill in `.env` from `.env.example`
|
||||
2. Make sure the legacy WordPress stack is still running
|
||||
3. Identify:
|
||||
- the legacy WordPress container name
|
||||
- the legacy MySQL container name
|
||||
- the legacy compose file path if you want the deploy script to shut it down for you
|
||||
- the WordPress MySQL database name, user, and password
|
||||
|
||||
## Example
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\deploy.ps1 `
|
||||
-RunMigration `
|
||||
-LegacyComposeFile C:\deploy\wordpress\docker-compose.yml `
|
||||
-LegacyProjectName goodwalk-wordpress `
|
||||
-LegacyWordPressContainer goodwalk-wordpress-1 `
|
||||
-LegacyDatabaseContainer goodwalk-db-1 `
|
||||
-MySqlDatabase wordpress `
|
||||
-MySqlUser wordpress `
|
||||
-MySqlPassword 'replace-me'
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The new app now uses root-relative `/wp-content/uploads/...` paths, so the copied uploads are served by the SvelteKit stack after cutover.
|
||||
- The deployment script does not destroy the legacy database dump. It writes a fresh backup on every migration run.
|
||||
- If you want to keep the legacy stack running while testing, omit `-LegacyComposeFile` or add `-SkipLegacyShutdown`.
|
||||
@@ -0,0 +1,22 @@
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/build ./build
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "build"]
|
||||
@@ -0,0 +1,49 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-goodwalk}:${POSTGRES_PASSWORD:-goodwalk}@db:5432/${POSTGRES_DB:-goodwalk}
|
||||
NODE_ENV: production
|
||||
PORT: ${APP_PORT:-3000}
|
||||
depends_on:
|
||||
- db
|
||||
restart: unless-stopped
|
||||
|
||||
mail-api:
|
||||
build:
|
||||
context: ./mail-api
|
||||
environment:
|
||||
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}
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-goodwalk}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-goodwalk}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-goodwalk}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./docker/postgres/init:/docker-entrypoint-initdb.d:ro
|
||||
restart: unless-stopped
|
||||
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
depends_on:
|
||||
- app
|
||||
- mail-api
|
||||
ports:
|
||||
- '80:80'
|
||||
- '443:443'
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- /etc/letsencrypt:/etc/letsencrypt:ro
|
||||
- /var/www/certbot:/var/www/certbot:ro
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: mysql
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: "qX7-mK9-vP2-nL4-wR8-xJ5"
|
||||
volumes:
|
||||
- /docker/mysql/data:/var/lib/mysql
|
||||
networks:
|
||||
- webnet
|
||||
|
||||
networks:
|
||||
webnet:
|
||||
external: true
|
||||
@@ -0,0 +1,19 @@
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- /docker/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
- /docker/certbot/conf:/etc/letsencrypt:ro
|
||||
- /docker/certbot/www:/var/www/certbot:ro
|
||||
- /docker/wordpress/goodwalk.co.nz/html:/var/www/html:ro
|
||||
networks:
|
||||
- webnet
|
||||
|
||||
networks:
|
||||
webnet:
|
||||
external: true
|
||||
@@ -0,0 +1,116 @@
|
||||
limit_req_zone $binary_remote_addr zone=wp_limit:10m rate=20r/s;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name goodwalk.co.nz www.goodwalk.co.nz;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://www.goodwalk.co.nz$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name goodwalk.co.nz;
|
||||
ssl_certificate /etc/letsencrypt/live/goodwalk.co.nz/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/goodwalk.co.nz/privkey.pem;
|
||||
return 301 https://www.goodwalk.co.nz$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name www.goodwalk.co.nz;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/goodwalk.co.nz/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/goodwalk.co.nz/privkey.pem;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_tickets off;
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss image/svg+xml;
|
||||
|
||||
root /var/www/html;
|
||||
index index.php;
|
||||
|
||||
location ~* /\.(git|env|htaccess) {
|
||||
deny all;
|
||||
}
|
||||
|
||||
location = /xmlrpc.php {
|
||||
deny all;
|
||||
}
|
||||
|
||||
location = /wp-login.php {
|
||||
limit_req zone=wp_limit burst=5 nodelay;
|
||||
fastcgi_pass wp_goodwalk_co_nz:9000;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/html$fastcgi_script_name;
|
||||
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
|
||||
fastcgi_param QUERY_STRING $query_string;
|
||||
fastcgi_param REQUEST_METHOD $request_method;
|
||||
fastcgi_param CONTENT_TYPE $content_type;
|
||||
fastcgi_param CONTENT_LENGTH $content_length;
|
||||
fastcgi_param REQUEST_URI $request_uri;
|
||||
fastcgi_param DOCUMENT_URI $document_uri;
|
||||
fastcgi_param DOCUMENT_ROOT /var/www/html;
|
||||
fastcgi_param SERVER_PROTOCOL $server_protocol;
|
||||
fastcgi_param GATEWAY_INTERFACE CGI/1.1;
|
||||
fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;
|
||||
fastcgi_param REMOTE_ADDR $remote_addr;
|
||||
fastcgi_param SERVER_ADDR $server_addr;
|
||||
fastcgi_param SERVER_PORT $server_port;
|
||||
fastcgi_param SERVER_NAME $server_name;
|
||||
fastcgi_param HTTP_HOST $host;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$args;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass wp_goodwalk_co_nz:9000;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/html$fastcgi_script_name;
|
||||
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
|
||||
fastcgi_param QUERY_STRING $query_string;
|
||||
fastcgi_param REQUEST_METHOD $request_method;
|
||||
fastcgi_param CONTENT_TYPE $content_type;
|
||||
fastcgi_param CONTENT_LENGTH $content_length;
|
||||
fastcgi_param REQUEST_URI $request_uri;
|
||||
fastcgi_param DOCUMENT_URI $document_uri;
|
||||
fastcgi_param DOCUMENT_ROOT /var/www/html;
|
||||
fastcgi_param SERVER_PROTOCOL $server_protocol;
|
||||
fastcgi_param GATEWAY_INTERFACE CGI/1.1;
|
||||
fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;
|
||||
fastcgi_param REMOTE_ADDR $remote_addr;
|
||||
fastcgi_param SERVER_ADDR $server_addr;
|
||||
fastcgi_param SERVER_PORT $server_port;
|
||||
fastcgi_param SERVER_NAME $server_name;
|
||||
fastcgi_param HTTP_HOST $host;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
log_not_found off;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
wordpress_goodwalk_co_nz:
|
||||
image: wordpress:php8.2-fpm
|
||||
container_name: wp_goodwalk_co_nz
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
WORDPRESS_DB_HOST: mysql
|
||||
WORDPRESS_DB_NAME: wp_sx2iw
|
||||
WORDPRESS_DB_USER: wp_zl29k
|
||||
WORDPRESS_DB_PASSWORD: 2@T0JdS1lm^c*Q~J
|
||||
volumes:
|
||||
- /docker/wordpress/goodwalk.co.nz/html:/var/www/html
|
||||
networks:
|
||||
- webnet
|
||||
|
||||
networks:
|
||||
webnet:
|
||||
external: true
|
||||
@@ -0,0 +1,200 @@
|
||||
create table if not exists site_content (
|
||||
key text primary key,
|
||||
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": "#reservation", "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": "#reservation", "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": "#reservation" },
|
||||
"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;
|
||||
@@ -0,0 +1,769 @@
|
||||
<!DOCTYPE html><html lang="en-NZ">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#192419" />
|
||||
<title>Home | Auckland Dog Walking | Goodwalk</title>
|
||||
<meta name="description" content="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!" />
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Readex+Pro:wght@300;400;500;600;700&family=Unbounded:wght@400;600;700;800&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" />
|
||||
|
||||
<!-- Google Analytics -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-K7TLSFJVP1"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', 'G-K7TLSFJVP1');
|
||||
</script>
|
||||
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--green: #213021;
|
||||
--navy: #002842;
|
||||
--gray: #59606D;
|
||||
--beige: #E5D6C2;
|
||||
--off-white:#FBFBFB;
|
||||
--text: #2E3031;
|
||||
--max-w: 1280px;
|
||||
--font-body: 'Readex Pro', sans-serif;
|
||||
--font-head: 'Unbounded', sans-serif;
|
||||
}
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
background: var(--off-white);
|
||||
}
|
||||
|
||||
img { display: block; max-width: 100%; height: auto; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
/* ── BUTTONS ─────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 14px 32px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
border-radius: 40px;
|
||||
cursor: pointer;
|
||||
transition: background .2s, color .2s;
|
||||
border: none;
|
||||
}
|
||||
.btn-navy { background: var(--navy); color: #fff; }
|
||||
.btn-navy:hover { background: #000; }
|
||||
.btn-yellow { background: var(--yellow); color: #000; }
|
||||
.btn-yellow:hover { background: #e6bb00; }
|
||||
.btn-outline { background: transparent; color: #fff; border: 2px solid #fff; }
|
||||
.btn-outline:hover { background: #fff; color: var(--navy); }
|
||||
|
||||
/* ── HEADER / NAV ────────────────────────────── */
|
||||
header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: #F6F4F1;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.08);
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 50px;
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
}
|
||||
.logo {
|
||||
font-family: var(--font-head);
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: var(--navy);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.logo span { color: var(--yellow); }
|
||||
.nav-links { display: flex; gap: 36px; align-items: center; list-style: none; }
|
||||
.nav-links a { font-weight: 500; transition: color .2s; }
|
||||
.nav-links a:hover { color: var(--navy); }
|
||||
.nav-links .btn { padding: 10px 24px; font-size: 14px; }
|
||||
|
||||
/* hamburger */
|
||||
.hamburger { display: none; flex-direction: column; gap: 5px; cursor: pointer; background: none; border: none; padding: 4px; }
|
||||
.hamburger span { display: block; width: 24px; height: 2px; background: var(--navy); border-radius: 2px; transition: .3s; }
|
||||
.mobile-menu { display: none; flex-direction: column; gap: 4px; background: #F6F4F1; padding: 0 50px 20px; }
|
||||
.mobile-menu a { display: block; padding: 10px 0; font-weight: 500; border-bottom: 1px solid #e0ddd8; }
|
||||
.mobile-menu.open { display: flex; }
|
||||
|
||||
/* ── HERO ────────────────────────────────────── */
|
||||
#hero {
|
||||
background: var(--navy);
|
||||
color: #fff;
|
||||
padding: 80px 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 60px;
|
||||
min-height: 580px;
|
||||
}
|
||||
.hero-inner {
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 60px;
|
||||
width: 100%;
|
||||
}
|
||||
.hero-text { flex: 1; }
|
||||
.hero-text h1 {
|
||||
font-family: var(--font-head);
|
||||
font-size: 56px;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 28px;
|
||||
color: #fff;
|
||||
}
|
||||
.hero-text h1 span { color: var(--yellow); }
|
||||
.hero-buttons { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||
.hero-img { flex: 0 0 420px; }
|
||||
.hero-img img { border-radius: 24px; width: 100%; }
|
||||
|
||||
/* ── INTRO STRIP ─────────────────────────────── */
|
||||
#intro {
|
||||
background: var(--yellow);
|
||||
padding: 24px 50px;
|
||||
text-align: center;
|
||||
}
|
||||
#intro p {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
max-width: 700px;
|
||||
margin: 0 auto 8px;
|
||||
}
|
||||
#intro a { font-weight: 700; text-decoration: underline; }
|
||||
|
||||
/* ── SECTION DEFAULTS ────────────────────────── */
|
||||
section { padding: 80px 0; }
|
||||
.section-heading {
|
||||
font-family: var(--font-head);
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ── PROMISE ─────────────────────────────────── */
|
||||
#promise { background: var(--off-white); }
|
||||
.promise-inner {
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
padding: 0 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 70px;
|
||||
}
|
||||
.promise-text { flex: 1; }
|
||||
.promise-text h2 { font-family: var(--font-head); font-size: 42px; font-weight: 700; margin-bottom: 20px; }
|
||||
.promise-text p { margin-bottom: 28px; font-size: 16px; max-width: 520px; }
|
||||
.promise-img { flex: 0 0 380px; }
|
||||
.promise-img img { border-radius: 50% 50% 50% 50% / 60% 60% 40% 40%; width: 100%; }
|
||||
|
||||
/* ── SERVICES ────────────────────────────────── */
|
||||
#services { background: #fff; }
|
||||
.services-inner { max-width: var(--max-w); margin: 0 auto; padding: 0 50px; }
|
||||
.services-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 32px;
|
||||
margin-top: 48px;
|
||||
}
|
||||
.service-card {
|
||||
background: var(--off-white);
|
||||
border-radius: 20px;
|
||||
padding: 40px 32px;
|
||||
text-align: center;
|
||||
transition: transform .2s, box-shadow .2s;
|
||||
}
|
||||
.service-card:hover { transform: translateY(-4px); box-shadow: 0 12px 40px rgba(0,0,0,.1); }
|
||||
.service-card i { font-size: 40px; color: var(--navy); margin-bottom: 20px; }
|
||||
.service-card h3 { font-family: var(--font-head); font-size: 20px; font-weight: 700; margin-bottom: 12px; }
|
||||
.service-card a { color: var(--navy); font-weight: 600; text-decoration: underline; }
|
||||
|
||||
/* ── VALUES ──────────────────────────────────── */
|
||||
#values { background: var(--navy); color: #fff; }
|
||||
.values-inner { max-width: var(--max-w); margin: 0 auto; padding: 0 50px; }
|
||||
.values-inner .section-heading { color: #fff; }
|
||||
.values-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 28px;
|
||||
margin-top: 48px;
|
||||
}
|
||||
.value-card {
|
||||
background: rgba(255,255,255,.07);
|
||||
border-radius: 16px;
|
||||
padding: 32px 28px;
|
||||
border: 1px solid rgba(255,255,255,.12);
|
||||
}
|
||||
.value-card i { font-size: 28px; color: var(--yellow); margin-bottom: 16px; }
|
||||
.value-card h3 { font-family: var(--font-head); font-size: 18px; font-weight: 700; margin-bottom: 10px; }
|
||||
.value-card p { font-size: 14px; line-height: 1.7; opacity: .85; }
|
||||
|
||||
/* ── TESTIMONIALS ────────────────────────────── */
|
||||
#testimonials { background: var(--off-white); }
|
||||
.testimonials-inner { max-width: var(--max-w); margin: 0 auto; padding: 0 50px; }
|
||||
.testimonials-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 28px;
|
||||
margin-top: 48px;
|
||||
}
|
||||
.testimonial-card {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 36px 32px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,.06);
|
||||
}
|
||||
.stars { color: var(--yellow); font-size: 18px; margin-bottom: 14px; }
|
||||
.testimonial-card blockquote { font-size: 15px; line-height: 1.7; font-style: italic; margin-bottom: 20px; }
|
||||
.reviewer { font-weight: 700; font-size: 14px; }
|
||||
.reviewer span { font-weight: 400; color: var(--gray); }
|
||||
|
||||
/* ── BOOKING FORM ────────────────────────────── */
|
||||
#reservation { background: #fff; }
|
||||
.form-inner { max-width: 700px; margin: 0 auto; padding: 0 50px; }
|
||||
.form-inner .section-heading { text-align: center; }
|
||||
.form-inner p.sub { text-align: center; color: var(--gray); margin-bottom: 40px; }
|
||||
|
||||
.form-steps { display: flex; gap: 0; margin-bottom: 40px; }
|
||||
.step-tab {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border-bottom: 3px solid #e0e0e0;
|
||||
color: #999;
|
||||
transition: .2s;
|
||||
}
|
||||
.step-tab.active { border-color: var(--navy); color: var(--navy); }
|
||||
|
||||
.form-step { display: none; }
|
||||
.form-step.active { display: block; }
|
||||
|
||||
.field-group { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px; }
|
||||
.field-group.single { grid-template-columns: 1fr; }
|
||||
.field-group.full { grid-template-columns: 1fr; }
|
||||
|
||||
label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; }
|
||||
input[type="text"], input[type="email"], input[type="tel"], textarea {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 1.5px solid #000;
|
||||
border-radius: 12px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
background: #fff;
|
||||
transition: border-color .2s;
|
||||
}
|
||||
input:focus, textarea:focus { outline: none; border-color: var(--navy); }
|
||||
textarea { resize: vertical; }
|
||||
|
||||
.checkbox-group { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; }
|
||||
.checkbox-group label {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-weight: 400; font-size: 14px; cursor: pointer;
|
||||
background: var(--off-white); border: 1.5px solid #ccc;
|
||||
padding: 8px 16px; border-radius: 30px; transition: .2s;
|
||||
}
|
||||
.checkbox-group label:has(input:checked) { border-color: var(--navy); background: #e8eef3; }
|
||||
.checkbox-group input { display: none; }
|
||||
|
||||
.form-nav { display: flex; justify-content: space-between; margin-top: 28px; }
|
||||
.form-nav button { min-width: 120px; }
|
||||
|
||||
/* ── INFO + FAQ ──────────────────────────────── */
|
||||
#info { background: var(--off-white); }
|
||||
.info-inner {
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
padding: 0 50px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 60px;
|
||||
align-items: start;
|
||||
}
|
||||
.info-block h2 { font-family: var(--font-head); font-size: 30px; font-weight: 700; margin-bottom: 16px; }
|
||||
.info-block h3 { font-size: 16px; font-weight: 700; margin: 20px 0 8px; }
|
||||
.info-block p { font-size: 15px; line-height: 1.7; color: var(--text); }
|
||||
|
||||
.faq details {
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding: 16px 0;
|
||||
}
|
||||
.faq summary {
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.faq summary::-webkit-details-marker { display: none; }
|
||||
.faq summary::after { content: '+'; font-size: 20px; color: var(--navy); }
|
||||
.faq details[open] summary::after { content: '−'; }
|
||||
.faq details p { margin-top: 10px; font-size: 14px; color: var(--gray); line-height: 1.7; }
|
||||
|
||||
/* ── INSTAGRAM CTA ───────────────────────────── */
|
||||
#instagram {
|
||||
background: var(--yellow);
|
||||
text-align: center;
|
||||
padding: 60px 50px;
|
||||
}
|
||||
#instagram h2 { font-family: var(--font-head); font-size: 36px; font-weight: 700; margin-bottom: 20px; }
|
||||
#instagram a.btn { background: var(--navy); color: #fff; }
|
||||
#instagram a.btn:hover { background: #000; }
|
||||
|
||||
/* ── FOOTER ──────────────────────────────────── */
|
||||
footer {
|
||||
background: var(--navy);
|
||||
color: #fff;
|
||||
padding: 60px 50px 32px;
|
||||
}
|
||||
.footer-inner {
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 48px;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
.footer-brand .logo { font-size: 24px; color: #fff; margin-bottom: 12px; }
|
||||
.footer-brand p { font-size: 14px; opacity: .75; margin-bottom: 20px; }
|
||||
.social-links { display: flex; gap: 14px; }
|
||||
.social-links a {
|
||||
width: 40px; height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,.12);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 16px; transition: background .2s;
|
||||
}
|
||||
.social-links a:hover { background: var(--yellow); color: #000; }
|
||||
|
||||
footer h4 { font-family: var(--font-head); font-size: 14px; font-weight: 700; margin-bottom: 16px; letter-spacing: .5px; opacity: .6; text-transform: uppercase; }
|
||||
.footer-nav { list-style: none; }
|
||||
.footer-nav li { margin-bottom: 10px; }
|
||||
.footer-nav a { font-size: 14px; opacity: .8; transition: opacity .2s; }
|
||||
.footer-nav a:hover { opacity: 1; }
|
||||
|
||||
.footer-bottom {
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
border-top: 1px solid rgba(255,255,255,.12);
|
||||
padding-top: 24px;
|
||||
font-size: 13px;
|
||||
opacity: .5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── RESPONSIVE ──────────────────────────────── */
|
||||
@media (max-width: 1024px) {
|
||||
nav, .mobile-menu { padding-left: 30px; padding-right: 30px; }
|
||||
#hero { padding-left: 30px; padding-right: 30px; }
|
||||
.promise-inner, .services-inner, .values-inner, .testimonials-inner,
|
||||
.info-inner, .form-inner { padding-left: 30px; padding-right: 30px; }
|
||||
footer { padding-left: 30px; padding-right: 30px; }
|
||||
.footer-inner { padding: 0; }
|
||||
.hero-inner { flex-direction: column; text-align: center; }
|
||||
.hero-text h1 { font-size: 40px; }
|
||||
.hero-img { flex: none; width: 100%; max-width: 420px; margin: 0 auto; }
|
||||
.hero-buttons { justify-content: center; }
|
||||
.values-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
nav { padding: 16px 24px; }
|
||||
.nav-links { display: none; }
|
||||
.hamburger { display: flex; }
|
||||
.mobile-menu { padding-left: 24px; padding-right: 24px; }
|
||||
|
||||
.promise-inner { flex-direction: column; padding: 0 24px; }
|
||||
.promise-img { flex: none; width: 100%; max-width: 320px; margin: 0 auto; }
|
||||
.services-grid { grid-template-columns: 1fr; }
|
||||
.services-inner { padding: 0 24px; }
|
||||
.values-grid { grid-template-columns: 1fr; }
|
||||
.values-inner { padding: 0 24px; }
|
||||
.testimonials-grid { grid-template-columns: 1fr; }
|
||||
.testimonials-inner { padding: 0 24px; }
|
||||
.info-inner { grid-template-columns: 1fr; padding: 0 24px; }
|
||||
.form-inner { padding: 0 24px; }
|
||||
.field-group { grid-template-columns: 1fr; }
|
||||
.section-heading { font-size: 30px; }
|
||||
footer { padding: 48px 24px 24px; }
|
||||
.footer-inner { grid-template-columns: 1fr; gap: 32px; }
|
||||
#instagram { padding: 48px 24px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ── HEADER ─────────────────────────────────────── -->
|
||||
<header>
|
||||
<nav>
|
||||
<a href="/" class="logo">Good<span>walk</span></a>
|
||||
<ul class="nav-links">
|
||||
<li><a href="#services">Our Services</a></li>
|
||||
<li><a href="https://www.goodwalk.co.nz/our-pricing/">Our Pricing</a></li>
|
||||
<li><a href="https://www.goodwalk.co.nz/about/">About Us</a></li>
|
||||
<li><a href="#reservation" class="btn btn-navy">Book Now</a></li>
|
||||
</ul>
|
||||
<button class="hamburger" aria-label="Menu" onclick="toggleMenu()">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="mobile-menu" id="mobileMenu">
|
||||
<a href="/">Home</a>
|
||||
<a href="https://www.goodwalk.co.nz/pack-walks/">Pack Walks</a>
|
||||
<a href="https://www.goodwalk.co.nz/dog-walking/">1:1 Walks</a>
|
||||
<a href="https://www.goodwalk.co.nz/puppy-visits/">Puppy Visits</a>
|
||||
<a href="https://www.goodwalk.co.nz/our-pricing/">Our Pricing</a>
|
||||
<a href="https://www.goodwalk.co.nz/about/">About Us</a>
|
||||
<a href="#reservation">Contact Us</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ── HERO ───────────────────────────────────────── -->
|
||||
<section id="hero">
|
||||
<div class="hero-inner">
|
||||
<div class="hero-text">
|
||||
<h1>Unleashing Fun in<br /><span>Your Dog's Day!</span></h1>
|
||||
<div class="hero-buttons">
|
||||
<a href="#services" class="btn btn-yellow">Learn more</a>
|
||||
<a href="#reservation" class="btn btn-outline">Enroll today</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-img">
|
||||
<img src="https://www.goodwalk.co.nz/wp-content/uploads/2024/04/homepage-hero-dog.png" alt="Happy dog on a walk with Goodwalk" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── INTRO STRIP ────────────────────────────────── -->
|
||||
<div id="intro">
|
||||
<p>Goodwalk delivers trusted, professional dog walking services across Auckland Central.</p>
|
||||
<a href="https://g.page/r/CUsvrWPhkYrAEB0/" target="_blank" rel="noopener">
|
||||
<i class="fas fa-star"></i> All 5 star reviews on Google!
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- ── PROMISE ────────────────────────────────────── -->
|
||||
<section id="promise">
|
||||
<div class="promise-inner">
|
||||
<div class="promise-text">
|
||||
<h2>Happy pets,<br />happy humans</h2>
|
||||
<p>Offering tailored pack walks for small & medium dogs, and one-on-one walks for large breeds! Our walkers give personalized 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 <strong>TINY GANG!</strong></p>
|
||||
<a href="#reservation" class="btn btn-navy">Book now</a>
|
||||
</div>
|
||||
<div class="promise-img">
|
||||
<img src="https://www.goodwalk.co.nz/wp-content/uploads/2024/04/homepage-hero-dog.png" alt="Dog enjoying a walk with Goodwalk" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── SERVICES ───────────────────────────────────── -->
|
||||
<section id="services">
|
||||
<div class="services-inner">
|
||||
<h2 class="section-heading">What we do</h2>
|
||||
<div class="services-grid">
|
||||
<div class="service-card">
|
||||
<i class="fas fa-dog"></i>
|
||||
<h3>Pack Walks</h3>
|
||||
<p>Small group walks of 4–8 dogs — calm, social, and full of fun for your pup.</p>
|
||||
<br />
|
||||
<a href="https://www.goodwalk.co.nz/pack-walks/">Learn more →</a>
|
||||
</div>
|
||||
<div class="service-card">
|
||||
<i class="fas fa-person-walking"></i>
|
||||
<h3>1:1 Walks</h3>
|
||||
<p>One-on-one walks tailored to your dog's individual pace, personality, and needs.</p>
|
||||
<br />
|
||||
<a href="https://www.goodwalk.co.nz/dog-walking/">Learn more →</a>
|
||||
</div>
|
||||
<div class="service-card">
|
||||
<i class="fas fa-house"></i>
|
||||
<h3>Puppy Visits</h3>
|
||||
<p>In-home visits to check in on your puppy, play, and keep them company.</p>
|
||||
<br />
|
||||
<a href="https://www.goodwalk.co.nz/puppy-visits/">Learn more →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── VALUES ─────────────────────────────────────── -->
|
||||
<section id="values">
|
||||
<div class="values-inner">
|
||||
<h2 class="section-heading">Where dogs come first</h2>
|
||||
<div class="values-grid">
|
||||
<div class="value-card">
|
||||
<i class="fas fa-heart"></i>
|
||||
<h3>Kindness</h3>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
<div class="value-card">
|
||||
<i class="fas fa-camera"></i>
|
||||
<h3>Daily Updates</h3>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
<div class="value-card">
|
||||
<i class="fas fa-users"></i>
|
||||
<h3>Small Pack Sizes</h3>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
<div class="value-card">
|
||||
<i class="fas fa-shield-heart"></i>
|
||||
<h3>Safety</h3>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
<div class="value-card">
|
||||
<i class="fas fa-calendar-check"></i>
|
||||
<h3>Flexibility</h3>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
<div class="value-card">
|
||||
<i class="fas fa-clock"></i>
|
||||
<h3>Reliability</h3>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── TESTIMONIALS ───────────────────────────────── -->
|
||||
<section id="testimonials">
|
||||
<div class="testimonials-inner">
|
||||
<h2 class="section-heading">Why people choose us!</h2>
|
||||
<div class="testimonials-grid">
|
||||
<div class="testimonial-card">
|
||||
<div class="stars"><i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i></div>
|
||||
<blockquote>"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 loves his walks, and I love the photos she posts of him."</blockquote>
|
||||
<p class="reviewer">Kate <span>— Archie's mum</span></p>
|
||||
</div>
|
||||
<div class="testimonial-card">
|
||||
<div class="stars"><i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i></div>
|
||||
<blockquote>"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, which I feel is what every dog mum wants and needs!"</blockquote>
|
||||
<p class="reviewer">Estelle <span>— Monty's mum</span></p>
|
||||
</div>
|
||||
<div class="testimonial-card">
|
||||
<div class="stars"><i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i></div>
|
||||
<blockquote>"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! Highly highly recommend."</blockquote>
|
||||
<p class="reviewer">Ross <span>— Otis's dad</span></p>
|
||||
</div>
|
||||
<div class="testimonial-card">
|
||||
<div class="stars"><i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i><i class="fas fa-star"></i></div>
|
||||
<blockquote>"Alessandra has been walking and spending time with my pup since she was 10 weeks old, coming over for puppy visits through to transitioning her to pack walks with her little doggo friends. I know Alessandra loves and cares for my dog as much as I do. Can't recommend enough!"</blockquote>
|
||||
<p class="reviewer">Nina <span>— Wallace's mum</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── BOOKING FORM ────────────────────────────────── -->
|
||||
<section id="reservation">
|
||||
<div class="form-inner">
|
||||
<h2 class="section-heading">Let's meet!</h2>
|
||||
<p class="sub">Tell us about yourself and your dog and we'll be in touch.</p>
|
||||
|
||||
<form id="bookingForm" action="https://www.goodwalk.co.nz/booking/" method="POST" novalidate>
|
||||
<div class="form-steps">
|
||||
<div class="step-tab active" id="tab1" onclick="showStep(1)">1. Your Details</div>
|
||||
<div class="step-tab" id="tab2" onclick="showStep(2)">2. Your Dog</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 1 -->
|
||||
<div class="form-step active" id="step1">
|
||||
<div class="field-group">
|
||||
<div>
|
||||
<label for="fullName">Full Name *</label>
|
||||
<input type="text" id="fullName" name="fullName" required placeholder="Enter full name" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="email1">Email *</label>
|
||||
<input type="email" id="email1" name="email" required placeholder="your@email.com" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-group single">
|
||||
<div>
|
||||
<label for="phone">Contact Number *</label>
|
||||
<input type="tel" id="phone" name="phone" required placeholder="E.g. 021 123 4567" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label>Services Interested In</label>
|
||||
<div class="checkbox-group">
|
||||
<label><input type="checkbox" name="services" value="Pack Walks" /> Pack Walks</label>
|
||||
<label><input type="checkbox" name="services" value="1:1 Walks" /> 1:1 Walks</label>
|
||||
<label><input type="checkbox" name="services" value="Homestays" /> Homestays</label>
|
||||
<label><input type="checkbox" name="services" value="Puppy Visits" /> Puppy Visits</label>
|
||||
<label><input type="checkbox" name="services" value="Other" /> Other Services</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-nav">
|
||||
<span></span>
|
||||
<button type="button" class="btn btn-navy" onclick="showStep(2)">Next →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<div class="form-step" id="step2">
|
||||
<div class="field-group">
|
||||
<div>
|
||||
<label for="petName">Pet's Name *</label>
|
||||
<input type="text" id="petName" name="petName" required placeholder="Your dog's name" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="location">Neighbourhood *</label>
|
||||
<input type="text" id="location" name="location" required placeholder="Neighbourhood, street…" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-group full">
|
||||
<div>
|
||||
<label for="message">About Your Dog</label>
|
||||
<textarea id="message" name="message" rows="4" placeholder="Describe your pet — any special needs, quirks, or things we should know?"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-nav">
|
||||
<button type="button" class="btn btn-outline" style="background:transparent;color:var(--navy);border-color:var(--navy);" onclick="showStep(1)">← Back</button>
|
||||
<button type="submit" class="btn btn-navy">Send →</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── INFO + FAQ ──────────────────────────────────── -->
|
||||
<section id="info">
|
||||
<div class="info-inner">
|
||||
|
||||
<div class="info-block">
|
||||
<h2>Locations & Hours</h2>
|
||||
<p>We cover most of Auckland Central's suburbs:</p>
|
||||
<p style="margin-top:10px;">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.</p>
|
||||
<p style="margin-top:10px;">Live in a nearby suburb? <a href="#reservation" style="color:var(--navy);font-weight:600;">Get in touch!</a></p>
|
||||
<h3>Opening Hours</h3>
|
||||
<p>Monday to Friday, 8am – 4pm.</p>
|
||||
</div>
|
||||
|
||||
<div class="info-block">
|
||||
<h2>FAQ's</h2>
|
||||
<div class="faq">
|
||||
<details>
|
||||
<summary>What happens if the weather is bad?</summary>
|
||||
<p>We operate in all weather conditions, except when there is a danger to the dog's health & safety.</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>What requirements does my dog need?</summary>
|
||||
<p>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.</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Can any dog use your service?</summary>
|
||||
<p>All dogs that are onboarded with us must go through our screening process, which includes a minimum of two assessment walks.</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>How does payment work?</summary>
|
||||
<p>All walks are paid for a week in advance, via invoice.</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Do you have insurance or First Aid training?</summary>
|
||||
<p>All walkers are covered by public liability insurance, and all walkers hold a current First Aid training certificate.</p>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── INSTAGRAM CTA ───────────────────────────────── -->
|
||||
<section id="instagram">
|
||||
<h2>Follow us on Instagram</h2>
|
||||
<a href="https://www.instagram.com/goodwalk.nz/" target="_blank" rel="noopener" class="btn">
|
||||
<i class="fab fa-instagram"></i> @goodwalk.nz
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<!-- ── FOOTER ──────────────────────────────────────── -->
|
||||
<footer>
|
||||
<div class="footer-inner">
|
||||
<div class="footer-brand">
|
||||
<div class="logo">Good<span>walk</span></div>
|
||||
<p>Professional Dog Walking Services<br />Auckland Central</p>
|
||||
<div class="social-links">
|
||||
<a href="https://www.instagram.com/goodwalk.nz/" target="_blank" rel="noopener" aria-label="Instagram"><i class="fab fa-instagram"></i></a>
|
||||
<a href="https://facebook.com/goodwalk.nz" target="_blank" rel="noopener" aria-label="Facebook"><i class="fab fa-facebook-f"></i></a>
|
||||
<a href="https://g.page/r/CUsvrWPhkYrAEB0" target="_blank" rel="noopener" aria-label="Google"><i class="fab fa-google"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Navigation</h4>
|
||||
<ul class="footer-nav">
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="https://www.goodwalk.co.nz/pack-walks/">Pack Walks</a></li>
|
||||
<li><a href="https://www.goodwalk.co.nz/dog-walking/">1:1 Walks</a></li>
|
||||
<li><a href="https://www.goodwalk.co.nz/puppy-visits/">Puppy Visits</a></li>
|
||||
<li><a href="https://www.goodwalk.co.nz/our-pricing/">Our Pricing</a></li>
|
||||
<li><a href="https://www.goodwalk.co.nz/contact-us/">Contact Us</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Contact</h4>
|
||||
<ul class="footer-nav">
|
||||
<li><a href="#reservation">Book a walk</a></li>
|
||||
<li><a href="https://www.instagram.com/goodwalk.nz/" target="_blank" rel="noopener">Instagram</a></li>
|
||||
<li><a href="https://g.page/r/CUsvrWPhkYrAEB0" target="_blank" rel="noopener">Google Reviews</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
© 2024 Goodwalk. All rights reserved.
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
function toggleMenu() {
|
||||
document.getElementById('mobileMenu').classList.toggle('open');
|
||||
}
|
||||
|
||||
function showStep(n) {
|
||||
document.querySelectorAll('.form-step').forEach(function(s, i) {
|
||||
s.classList.toggle('active', i + 1 === n);
|
||||
});
|
||||
document.querySelectorAll('.step-tab').forEach(function(t, i) {
|
||||
t.classList.toggle('active', i + 1 === n);
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('#mobileMenu a').forEach(function(a) {
|
||||
a.addEventListener('click', function() {
|
||||
document.getElementById('mobileMenu').classList.remove('open');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
OK","has_counter":true},"tumblr":{"title":"Tumblr"},"digg":{"title":"Digg"},"skype":{"title":"Skype"},"stumbleupon":{"title":"StumbleUpon","has_counter":true},"mix":{"title":"Mix"},"telegram":{"title":"Telegram"},"pocket":{"title":"Pocket","has_counter":true},"xing":{"title":"XING","has_counter":true},"whatsapp":{"title":"WhatsApp"},"email":{"title":"Email"},"print":{"title":"Print"}},"facebook_sdk":{"lang":"en_NZ","app_id":""},"lottie":{"defaultAnimationUrl":"https:\/\/www.goodwalk.co.nz\/wp-content\/plugins\/elementor-pro\/modules\/lottie\/assets\/animations\/default.json"}};
|
||||
//# sourceURL=elementor-pro-frontend-js-before
|
||||
/* ]]> */
|
||||
</script>
|
||||
<script type="3e0f79c494425893c5741014-text/javascript" src="https://www.goodwalk.co.nz/wp-content/plugins/elementor-pro/assets/js/frontend.min.js?ver=3.19.3" id="elementor-pro-frontend-js"></script>
|
||||
<script type="3e0f79c494425893c5741014-text/javascript" src="https://www.goodwalk.co.nz/wp-content/plugins/elementor-pro/assets/js/elements-handlers.min.js?ver=3.19.3" id="pro-elements-handlers-js"></script>
|
||||
<script type="3e0f79c494425893c5741014-text/javascript" src="https://www.goodwalk.co.nz/wp-content/plugins/vamtam-elementor-integration-petmania/assets/js/vamtam-elementor-frontend.min.js?ver=1.0.7" id="vamtam-elementor-frontend-js"></script>
|
||||
<script type="3e0f79c494425893c5741014-text/javascript" src="https://www.goodwalk.co.nz/wp-content/plugins/elementor-pro/assets/lib/sticky/jquery.sticky.min.js?ver=3.19.3" id="e-sticky-js"></script>
|
||||
<script src="/cdn-cgi/scripts/7d0fa10a/cloudflare-static/rocket-loader.min.js" data-cf-settings="3e0f79c494425893c5741014-|49" defer></script></body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY main.py .
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,459 @@
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import resend
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
resend.api_key = os.environ["RESEND_API_KEY"]
|
||||
|
||||
OWNER_EMAIL = os.environ["OWNER_EMAIL"]
|
||||
FROM_EMAIL = os.environ.get("FROM_EMAIL", "GoodWalk <bookings@goodwalk.co.nz>")
|
||||
REPLY_TO = os.environ.get("REPLY_TO", "aless@goodwalk.co.nz")
|
||||
|
||||
LOGO_URL = "https://www.goodwalk.co.nz/wp-content/uploads/2022/06/logo-v6.png"
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["POST"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
class BookingSubmission(BaseModel):
|
||||
fullName: str
|
||||
email: EmailStr
|
||||
phone: str
|
||||
petName: str
|
||||
location: str
|
||||
message: str = ""
|
||||
services: list[str] = []
|
||||
referrer: str = ""
|
||||
page: str = ""
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_ip(request: Request) -> str:
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def _parse_ua(ua: str) -> str:
|
||||
if not ua:
|
||||
return "Unknown"
|
||||
browsers = [("Edg/", "Edge"), ("OPR/", "Opera"), ("Chrome/", "Chrome"),
|
||||
("Firefox/", "Firefox"), ("Safari/", "Safari")]
|
||||
systems = [("Windows NT 10", "Windows 10/11"), ("Windows NT 6", "Windows 8"),
|
||||
("Mac OS X", "macOS"), ("iPhone", "iPhone"), ("iPad", "iPad"),
|
||||
("Android", "Android"), ("Linux", "Linux")]
|
||||
browser = next((n for p, n in browsers if p in ua), "Unknown browser")
|
||||
system = next((n for p, n in systems if p in ua), "Unknown OS")
|
||||
return f"{browser} on {system}"
|
||||
|
||||
|
||||
def _detail_row(label: str, value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
return f"""
|
||||
<tr>
|
||||
<td style="padding:8px 0;color:#888;font-size:13px;white-space:nowrap;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;width:130px;">{label}</td>
|
||||
<td style="padding:8px 0 8px 16px;color:#213021;font-size:14px;font-weight:500;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;">{value}</td>
|
||||
</tr>"""
|
||||
|
||||
|
||||
def _meta_row(label: str, value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
return f"""
|
||||
<tr>
|
||||
<td style="padding:5px 0;color:#aaa;font-size:12px;white-space:nowrap;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;width:100px;">{label}</td>
|
||||
<td style="padding:5px 0 5px 16px;color:#666;font-size:12px;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;word-break:break-all;">{value}</td>
|
||||
</tr>"""
|
||||
|
||||
|
||||
# ── Email templates ──────────────────────────────────────────────────────────
|
||||
|
||||
def _logo_header(badge_html: str = "", subtitle: str = "") -> str:
|
||||
badge = f'<div style="margin-top:20px;">{badge_html}</div>' if badge_html else ""
|
||||
sub = f"""<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:13px;color:#7aaa7a;letter-spacing:0.04em;margin-top:8px;">
|
||||
{subtitle}</div>""" if subtitle else ""
|
||||
return f"""
|
||||
<tr>
|
||||
<td style="background:#213021;padding:36px 48px 32px;text-align:center;">
|
||||
<img src="{LOGO_URL}" width="161" height="32" alt="GoodWalk"
|
||||
style="display:inline-block;max-width:161px;height:auto;border:0;">
|
||||
{sub}
|
||||
{badge}
|
||||
</td>
|
||||
</tr>"""
|
||||
|
||||
|
||||
def client_email(data: BookingSubmission) -> str:
|
||||
services_text = ", ".join(data.services) if data.services else "Not specified"
|
||||
message_row = _detail_row("About the dog", data.message) if data.message else ""
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>We received your enquiry</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f2f2f0;">
|
||||
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="background:#f2f2f0;padding:40px 16px;">
|
||||
<tr><td align="center">
|
||||
|
||||
<table width="600" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="max-width:600px;width:100%;border-radius:16px;overflow:hidden;
|
||||
box-shadow:0 4px 24px rgba(0,0,0,0.08);">
|
||||
|
||||
{_logo_header(subtitle="Auckland’s favourite dog walking service")}
|
||||
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="background:#ffffff;padding:48px 48px 40px;">
|
||||
|
||||
<h1 style="margin:0 0 8px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:26px;font-weight:700;color:#213021;line-height:1.2;">
|
||||
Thanks, {data.fullName.split()[0]}! 🐾
|
||||
</h1>
|
||||
<p style="margin:0 0 32px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:16px;color:#555;line-height:1.65;">
|
||||
We’ve received your enquiry and Aless will be in touch shortly to arrange
|
||||
a <strong style="color:#213021;">Meet & Greet</strong> with you and
|
||||
{data.petName}.
|
||||
</p>
|
||||
|
||||
<!-- Details card -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="background:#f8f7f4;border-radius:12px;margin-bottom:36px;">
|
||||
<tr>
|
||||
<td style="padding:28px 32px;">
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:11px;font-weight:700;letter-spacing:0.1em;
|
||||
color:#888;text-transform:uppercase;margin-bottom:20px;">
|
||||
Your enquiry summary
|
||||
</div>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
{_detail_row("Your name", data.fullName)}
|
||||
{_detail_row("Email", data.email)}
|
||||
{_detail_row("Phone", data.phone)}
|
||||
{_detail_row("Dog’s name", data.petName)}
|
||||
{_detail_row("Location", data.location)}
|
||||
{_detail_row("Services", services_text)}
|
||||
{message_row}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- What's next -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="border-left:3px solid #FFD100;margin-bottom:36px;">
|
||||
<tr>
|
||||
<td style="padding:4px 0 4px 20px;">
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:13px;font-weight:700;color:#213021;margin-bottom:6px;">
|
||||
What happens next?
|
||||
</div>
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:14px;color:#666;line-height:1.6;">
|
||||
Aless will review your details and reach out within 1–2 business days
|
||||
to schedule a free Meet & Greet. No commitment required — just a
|
||||
chance for {data.petName} to make a new best friend.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:14px;color:#888;line-height:1.6;">
|
||||
Questions? Just reply to this email or reach us at
|
||||
<a href="mailto:{REPLY_TO}" style="color:#213021;font-weight:600;
|
||||
text-decoration:none;">{REPLY_TO}</a>.
|
||||
</p>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background:#213021;padding:24px 48px;text-align:center;">
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:12px;color:#5a8a5a;line-height:1.6;">
|
||||
GoodWalk · Auckland, New Zealand<br>
|
||||
<a href="https://www.goodwalk.co.nz" style="color:#7aaa7a;text-decoration:none;">
|
||||
goodwalk.co.nz
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
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")
|
||||
|
||||
message_block = f"""
|
||||
<tr>
|
||||
<td colspan="2" style="padding:16px 0 0;">
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:11px;font-weight:700;letter-spacing:0.08em;color:#888;
|
||||
text-transform:uppercase;margin-bottom:8px;">About the dog</div>
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:14px;color:#444;line-height:1.6;background:#f0efe9;
|
||||
border-radius:8px;padding:14px 16px;">{data.message}</div>
|
||||
</td>
|
||||
</tr>""" if data.message else ""
|
||||
|
||||
badge = """<div style="display:inline-block;background:#FFD100;border-radius:100px;
|
||||
padding:10px 28px;">
|
||||
<span style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:18px;font-weight:700;color:#213021;">
|
||||
📩 New lead!
|
||||
</span>
|
||||
</div>
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:12px;color:#5a8a5a;margin-top:12px;">
|
||||
Submitted {submitted_at}
|
||||
</div>""".format(submitted_at=submitted_at)
|
||||
|
||||
referrer_row = _meta_row("Came from", data.referrer) if data.referrer else _meta_row("Came from", "Direct / bookmark")
|
||||
page_row = _meta_row("Page", data.page) if data.page else ""
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>New GoodWalk Lead</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f2f2f0;">
|
||||
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="background:#f2f2f0;padding:40px 16px;">
|
||||
<tr><td align="center">
|
||||
|
||||
<table width="600" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="max-width:600px;width:100%;border-radius:16px;overflow:hidden;
|
||||
box-shadow:0 4px 24px rgba(0,0,0,0.08);">
|
||||
|
||||
{_logo_header(badge_html=badge)}
|
||||
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="background:#ffffff;padding:40px 48px 36px;">
|
||||
|
||||
<!-- 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;
|
||||
text-transform:uppercase;margin-bottom:16px;">Owner details</div>
|
||||
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="background:#f8f7f4;border-radius:12px;margin-bottom:28px;">
|
||||
<tr><td style="padding:24px 28px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td style="padding:6px 0;font-size:13px;color:#888;width:80px;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;">Name</td>
|
||||
<td style="padding:6px 0 6px 16px;font-size:15px;font-weight:600;
|
||||
color:#213021;font-family:-apple-system,BlinkMacSystemFont,
|
||||
'Segoe UI',sans-serif;vertical-align:top;">{data.fullName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px 0;font-size:13px;color:#888;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;">Email</td>
|
||||
<td style="padding:6px 0 6px 16px;font-size:14px;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;">
|
||||
<a href="mailto:{data.email}" style="color:#213021;font-weight:500;
|
||||
text-decoration:none;">{data.email}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px 0;font-size:13px;color:#888;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;">Phone</td>
|
||||
<td style="padding:6px 0 6px 16px;font-size:14px;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;">
|
||||
<a href="tel:{data.phone}" style="color:#213021;font-weight:500;
|
||||
text-decoration:none;">{data.phone}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
<!-- Dog & service details -->
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:11px;font-weight:700;letter-spacing:0.1em;color:#888;
|
||||
text-transform:uppercase;margin-bottom:16px;">Dog & services</div>
|
||||
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="background:#f8f7f4;border-radius:12px;margin-bottom:28px;">
|
||||
<tr><td style="padding:24px 28px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td style="padding:6px 0;font-size:13px;color:#888;width:80px;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;">Dog</td>
|
||||
<td style="padding:6px 0 6px 16px;font-size:15px;font-weight:600;
|
||||
color:#213021;font-family:-apple-system,BlinkMacSystemFont,
|
||||
'Segoe UI',sans-serif;vertical-align:top;">{data.petName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px 0;font-size:13px;color:#888;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;">Location</td>
|
||||
<td style="padding:6px 0 6px 16px;font-size:14px;font-weight:500;color:#213021;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;">{data.location}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px 0;font-size:13px;color:#888;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;">Services</td>
|
||||
<td style="padding:6px 0 6px 16px;font-size:14px;color:#444;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
vertical-align:top;">{services_text}</td>
|
||||
</tr>
|
||||
{message_block}
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
<!-- CTA buttons -->
|
||||
<table cellpadding="0" cellspacing="0" role="presentation" style="margin-bottom:32px;">
|
||||
<tr>
|
||||
<td style="padding-right:12px;">
|
||||
<a href="mailto:{data.email}"
|
||||
style="display:inline-block;background:#213021;color:#FFD100;
|
||||
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]}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="tel:{data.phone}"
|
||||
style="display:inline-block;background:#f8f7f4;color:#213021;
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:14px;font-weight:600;text-decoration:none;
|
||||
border-radius:8px;padding:12px 24px;border:1px solid #e0e0d8;">
|
||||
Call {data.phone}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Session info -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="border-top:1px solid #eeeee8;padding-top:20px;">
|
||||
<tr><td>
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:11px;font-weight:700;letter-spacing:0.08em;color:#ccc;
|
||||
text-transform:uppercase;margin-bottom:12px;">Session info</div>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
{_meta_row("IP address", ip)}
|
||||
{_meta_row("Browser", browser)}
|
||||
{referrer_row}
|
||||
{page_row}
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background:#f8f7f4;padding:18px 48px;text-align:center;
|
||||
border-top:1px solid #e8e8e4;">
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
font-size:12px;color:#bbb;">
|
||||
Sent automatically by GoodWalk booking form
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
# ── Route ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.post("/submit")
|
||||
async def submit_booking(data: BookingSubmission, request: Request):
|
||||
ip = _get_ip(request)
|
||||
browser = _parse_ua(request.headers.get("user-agent", ""))
|
||||
logger.info("Booking from %s (%s, %s) for dog %s", data.email, ip, browser, data.petName)
|
||||
|
||||
errors = []
|
||||
|
||||
try:
|
||||
resend.Emails.send({
|
||||
"from": FROM_EMAIL,
|
||||
"to": [data.email],
|
||||
"reply_to": REPLY_TO,
|
||||
"subject": f"We received your enquiry, {data.fullName.split()[0]}! 🐾",
|
||||
"html": client_email(data),
|
||||
})
|
||||
logger.info("Client confirmation sent to %s", data.email)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send client email: %s", exc)
|
||||
errors.append("client_email")
|
||||
|
||||
try:
|
||||
resend.Emails.send({
|
||||
"from": FROM_EMAIL,
|
||||
"to": [OWNER_EMAIL],
|
||||
"reply_to": data.email,
|
||||
"subject": f"New GoodWalk lead — {data.fullName} ({data.petName})",
|
||||
"html": owner_email(data, ip, browser),
|
||||
})
|
||||
logger.info("Owner notification sent to %s", OWNER_EMAIL)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send owner email: %s", exc)
|
||||
errors.append("owner_email")
|
||||
|
||||
if "client_email" in errors and "owner_email" in errors:
|
||||
raise HTTPException(status_code=500, detail="Failed to send emails. Please try again.")
|
||||
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.32
|
||||
resend>=2.0
|
||||
pydantic[email]>=2.10
|
||||
@@ -0,0 +1,51 @@
|
||||
# Redirect all HTTP to HTTPS, except ACME challenges (certbot renewal)
|
||||
server {
|
||||
listen 80;
|
||||
server_name goodwalk.co.nz www.goodwalk.co.nz;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name goodwalk.co.nz www.goodwalk.co.nz;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/goodwalk.co.nz/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/goodwalk.co.nz/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||
|
||||
location /api/submit {
|
||||
proxy_pass http://mail-api:8000/submit;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://app:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "goodwalk-svelte-port",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --host 10.0.0.73",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"pg": "^8.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.2.11",
|
||||
"@sveltejs/kit": "^2.17.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/pg": "^8.11.10",
|
||||
"jsdom": "^29.1.1",
|
||||
"svelte": "^5.16.0",
|
||||
"svelte-check": "^4.1.1",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.7",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
.elementor-6541 .elementor-element.elementor-element-fb6e69b, .elementor-6541 .elementor-element.elementor-element-fb6e69b > .elementor-background-overlay{border-radius:18px 18px 18px 18px;}.elementor-6541 .elementor-element.elementor-element-fb6e69b{transition:background 0.3s, border 0.3s, border-radius 0.3s, box-shadow 0.3s;padding:0px 0px 0px 0px;}.elementor-6541 .elementor-element.elementor-element-fb6e69b > .elementor-background-overlay{transition:background 0.3s, border-radius 0.3s, opacity 0.3s;}.elementor-bc-flex-widget .elementor-6541 .elementor-element.elementor-element-1302c36.elementor-column .elementor-widget-wrap{align-items:flex-end;}.elementor-6541 .elementor-element.elementor-element-1302c36.elementor-column.elementor-element[data-element_type="column"] > .elementor-widget-wrap.elementor-element-populated{align-content:flex-end;align-items:flex-end;}.elementor-6541 .elementor-element.elementor-element-1302c36 > .elementor-widget-wrap > .elementor-widget:not(.elementor-widget__width-auto):not(.elementor-widget__width-initial):not(:last-child):not(.elementor-absolute){margin-bottom:0px;}.elementor-6541 .elementor-element.elementor-element-1302c36 > .elementor-element-populated{padding:30px 0px 0px 0px;}.elementor-6541 .elementor-element.elementor-element-e2bdea1{--spacer-size:440px;}.elementor-6541 .elementor-element.elementor-element-e2bdea1 > .elementor-widget-container{background-image:var(--e-bg-lazyload-loaded);--e-bg-lazyload:url("https://www.goodwalk.co.nz/wp-content/uploads/2022/08/review-archive-2.jpg");background-position:bottom center;background-repeat:no-repeat;background-size:contain;}.elementor-bc-flex-widget .elementor-6541 .elementor-element.elementor-element-f7837e0.elementor-column .elementor-widget-wrap{align-items:flex-end;}.elementor-6541 .elementor-element.elementor-element-f7837e0.elementor-column.elementor-element[data-element_type="column"] > .elementor-widget-wrap.elementor-element-populated{align-content:flex-end;align-items:flex-end;}.elementor-6541 .elementor-element.elementor-element-f7837e0 > .elementor-element-populated{padding:0px 30px 145px 50px;}.elementor-6541 .elementor-element.elementor-element-717a84c > .elementor-widget-container{margin:0px 0px 15px 0px;}.elementor-6541 .elementor-element.elementor-element-717a84c{width:var( --container-widget-width, 390px );max-width:390px;--container-widget-width:390px;--container-widget-flex-grow:0;}.elementor-6541 .elementor-element.elementor-element-62bcb5c .elementor-heading-title{font-weight:500;}.elementor-6541 .elementor-element.elementor-element-62bcb5c{width:var( --container-widget-width, 390px );max-width:390px;--container-widget-width:390px;--container-widget-flex-grow:0;}@media(max-width:1024px){.elementor-6541 .elementor-element.elementor-element-1302c36 > .elementor-widget-wrap > .elementor-widget:not(.elementor-widget__width-auto):not(.elementor-widget__width-initial):not(:last-child):not(.elementor-absolute){margin-bottom:0px;}.elementor-6541 .elementor-element.elementor-element-e2bdea1{--spacer-size:430px;}.elementor-6541 .elementor-element.elementor-element-f7837e0 > .elementor-element-populated{padding:0px 30px 160px 30px;}.elementor-6541 .elementor-element.elementor-element-717a84c{width:100%;max-width:100%;}.elementor-6541 .elementor-element.elementor-element-62bcb5c{width:100%;max-width:100%;}}@media(max-width:767px){.elementor-6541 .elementor-element.elementor-element-fb6e69b{padding:0px 0px 150px 0px;}.elementor-6541 .elementor-element.elementor-element-1302c36 > .elementor-widget-wrap > .elementor-widget:not(.elementor-widget__width-auto):not(.elementor-widget__width-initial):not(:last-child):not(.elementor-absolute){margin-bottom:0px;}.elementor-6541 .elementor-element.elementor-element-1302c36 > .elementor-element-populated{margin:0px 0px 20px 0px;--e-column-margin-right:0px;--e-column-margin-left:0px;padding:20px 20px 0px 20px;}.elementor-6541 .elementor-element.elementor-element-e2bdea1{--spacer-size:240px;}.elementor-6541 .elementor-element.elementor-element-e2bdea1 > .elementor-widget-container{background-size:contain;}.elementor-bc-flex-widget .elementor-6541 .elementor-element.elementor-element-f7837e0.elementor-column .elementor-widget-wrap{align-items:flex-start;}.elementor-6541 .elementor-element.elementor-element-f7837e0.elementor-column.elementor-element[data-element_type="column"] > .elementor-widget-wrap.elementor-element-populated{align-content:flex-start;align-items:flex-start;}.elementor-6541 .elementor-element.elementor-element-f7837e0 > .elementor-element-populated{margin:0px 0px 0px 0px;--e-column-margin-right:0px;--e-column-margin-left:0px;padding:0px 40px 0px 40px;}}@media(min-width:768px){.elementor-6541 .elementor-element.elementor-element-1302c36{width:45%;}.elementor-6541 .elementor-element.elementor-element-f7837e0{width:55%;}}
|
||||
@@ -0,0 +1 @@
|
||||
.elementor-6631 .elementor-element.elementor-element-ddfb808, .elementor-6631 .elementor-element.elementor-element-ddfb808 > .elementor-background-overlay{border-radius:18px 18px 18px 18px;}.elementor-6631 .elementor-element.elementor-element-ddfb808{transition:background 0.3s, border 0.3s, border-radius 0.3s, box-shadow 0.3s;padding:0px 0px 0px 0px;}.elementor-6631 .elementor-element.elementor-element-ddfb808 > .elementor-background-overlay{transition:background 0.3s, border-radius 0.3s, opacity 0.3s;}.elementor-bc-flex-widget .elementor-6631 .elementor-element.elementor-element-a62debb.elementor-column .elementor-widget-wrap{align-items:flex-end;}.elementor-6631 .elementor-element.elementor-element-a62debb.elementor-column.elementor-element[data-element_type="column"] > .elementor-widget-wrap.elementor-element-populated{align-content:flex-end;align-items:flex-end;}.elementor-6631 .elementor-element.elementor-element-a62debb > .elementor-widget-wrap > .elementor-widget:not(.elementor-widget__width-auto):not(.elementor-widget__width-initial):not(:last-child):not(.elementor-absolute){margin-bottom:0px;}.elementor-6631 .elementor-element.elementor-element-a62debb > .elementor-element-populated{padding:30px 0px 0px 0px;}.elementor-6631 .elementor-element.elementor-element-e20c5d3{--spacer-size:440px;}.elementor-6631 .elementor-element.elementor-element-e20c5d3 > .elementor-widget-container{background-image:var(--e-bg-lazyload-loaded);--e-bg-lazyload:url("https://www.goodwalk.co.nz/wp-content/uploads/2022/08/review-monty.jpg");background-position:bottom center;background-repeat:no-repeat;background-size:contain;}.elementor-bc-flex-widget .elementor-6631 .elementor-element.elementor-element-24afe2c.elementor-column .elementor-widget-wrap{align-items:flex-end;}.elementor-6631 .elementor-element.elementor-element-24afe2c.elementor-column.elementor-element[data-element_type="column"] > .elementor-widget-wrap.elementor-element-populated{align-content:flex-end;align-items:flex-end;}.elementor-6631 .elementor-element.elementor-element-24afe2c > .elementor-element-populated{padding:0px 30px 145px 50px;}.elementor-6631 .elementor-element.elementor-element-25d4f7c > .elementor-widget-container{margin:0px 0px 15px 0px;}.elementor-6631 .elementor-element.elementor-element-25d4f7c{width:var( --container-widget-width, 390px );max-width:390px;--container-widget-width:390px;--container-widget-flex-grow:0;}.elementor-6631 .elementor-element.elementor-element-41f2fa8 .elementor-heading-title{font-weight:300;}.elementor-6631 .elementor-element.elementor-element-41f2fa8{width:var( --container-widget-width, 390px );max-width:390px;--container-widget-width:390px;--container-widget-flex-grow:0;}@media(max-width:1024px){.elementor-6631 .elementor-element.elementor-element-a62debb > .elementor-widget-wrap > .elementor-widget:not(.elementor-widget__width-auto):not(.elementor-widget__width-initial):not(:last-child):not(.elementor-absolute){margin-bottom:0px;}.elementor-6631 .elementor-element.elementor-element-e20c5d3{--spacer-size:430px;}.elementor-6631 .elementor-element.elementor-element-24afe2c > .elementor-element-populated{padding:0px 30px 160px 30px;}.elementor-6631 .elementor-element.elementor-element-25d4f7c{width:100%;max-width:100%;}.elementor-6631 .elementor-element.elementor-element-41f2fa8{width:100%;max-width:100%;}}@media(max-width:767px){.elementor-6631 .elementor-element.elementor-element-ddfb808{padding:0px 0px 150px 0px;}.elementor-6631 .elementor-element.elementor-element-a62debb > .elementor-widget-wrap > .elementor-widget:not(.elementor-widget__width-auto):not(.elementor-widget__width-initial):not(:last-child):not(.elementor-absolute){margin-bottom:0px;}.elementor-6631 .elementor-element.elementor-element-a62debb > .elementor-element-populated{margin:0px 0px 20px 0px;--e-column-margin-right:0px;--e-column-margin-left:0px;padding:20px 20px 0px 20px;}.elementor-6631 .elementor-element.elementor-element-e20c5d3{--spacer-size:240px;}.elementor-6631 .elementor-element.elementor-element-e20c5d3 > .elementor-widget-container{background-size:contain;}.elementor-bc-flex-widget .elementor-6631 .elementor-element.elementor-element-24afe2c.elementor-column .elementor-widget-wrap{align-items:flex-start;}.elementor-6631 .elementor-element.elementor-element-24afe2c.elementor-column.elementor-element[data-element_type="column"] > .elementor-widget-wrap.elementor-element-populated{align-content:flex-start;align-items:flex-start;}.elementor-6631 .elementor-element.elementor-element-24afe2c > .elementor-element-populated{margin:0px 0px 0px 0px;--e-column-margin-right:0px;--e-column-margin-left:0px;padding:0px 40px 0px 40px;}}@media(min-width:768px){.elementor-6631 .elementor-element.elementor-element-a62debb{width:45%;}.elementor-6631 .elementor-element.elementor-element-24afe2c{width:55%;}}
|
||||
@@ -0,0 +1 @@
|
||||
.elementor-988267 .elementor-element.elementor-element-fb6e69b, .elementor-988267 .elementor-element.elementor-element-fb6e69b > .elementor-background-overlay{border-radius:18px 18px 18px 18px;}.elementor-988267 .elementor-element.elementor-element-fb6e69b{transition:background 0.3s, border 0.3s, border-radius 0.3s, box-shadow 0.3s;padding:0px 0px 0px 0px;}.elementor-988267 .elementor-element.elementor-element-fb6e69b > .elementor-background-overlay{transition:background 0.3s, border-radius 0.3s, opacity 0.3s;}.elementor-bc-flex-widget .elementor-988267 .elementor-element.elementor-element-1302c36.elementor-column .elementor-widget-wrap{align-items:flex-end;}.elementor-988267 .elementor-element.elementor-element-1302c36.elementor-column.elementor-element[data-element_type="column"] > .elementor-widget-wrap.elementor-element-populated{align-content:flex-end;align-items:flex-end;}.elementor-988267 .elementor-element.elementor-element-1302c36 > .elementor-widget-wrap > .elementor-widget:not(.elementor-widget__width-auto):not(.elementor-widget__width-initial):not(:last-child):not(.elementor-absolute){margin-bottom:0px;}.elementor-988267 .elementor-element.elementor-element-1302c36 > .elementor-element-populated{padding:30px 0px 0px 0px;}.elementor-988267 .elementor-element.elementor-element-e2bdea1{--spacer-size:440px;}.elementor-988267 .elementor-element.elementor-element-e2bdea1 > .elementor-widget-container{background-image:var(--e-bg-lazyload-loaded);--e-bg-lazyload:url("https://www.goodwalk.co.nz/wp-content/uploads/2024/03/otis.jpg");background-position:bottom center;background-repeat:no-repeat;background-size:contain;}.elementor-bc-flex-widget .elementor-988267 .elementor-element.elementor-element-f7837e0.elementor-column .elementor-widget-wrap{align-items:flex-end;}.elementor-988267 .elementor-element.elementor-element-f7837e0.elementor-column.elementor-element[data-element_type="column"] > .elementor-widget-wrap.elementor-element-populated{align-content:flex-end;align-items:flex-end;}.elementor-988267 .elementor-element.elementor-element-f7837e0 > .elementor-element-populated{padding:0px 30px 145px 50px;}.elementor-988267 .elementor-element.elementor-element-717a84c > .elementor-widget-container{margin:0px 0px 15px 0px;}.elementor-988267 .elementor-element.elementor-element-717a84c{width:var( --container-widget-width, 390px );max-width:390px;--container-widget-width:390px;--container-widget-flex-grow:0;}.elementor-988267 .elementor-element.elementor-element-62bcb5c .elementor-heading-title{font-weight:500;}.elementor-988267 .elementor-element.elementor-element-62bcb5c{width:var( --container-widget-width, 390px );max-width:390px;--container-widget-width:390px;--container-widget-flex-grow:0;}@media(max-width:1024px){.elementor-988267 .elementor-element.elementor-element-1302c36 > .elementor-widget-wrap > .elementor-widget:not(.elementor-widget__width-auto):not(.elementor-widget__width-initial):not(:last-child):not(.elementor-absolute){margin-bottom:0px;}.elementor-988267 .elementor-element.elementor-element-e2bdea1{--spacer-size:430px;}.elementor-988267 .elementor-element.elementor-element-f7837e0 > .elementor-element-populated{padding:0px 30px 160px 30px;}.elementor-988267 .elementor-element.elementor-element-717a84c{width:100%;max-width:100%;}.elementor-988267 .elementor-element.elementor-element-62bcb5c{width:100%;max-width:100%;}}@media(max-width:767px){.elementor-988267 .elementor-element.elementor-element-fb6e69b{padding:0px 0px 150px 0px;}.elementor-988267 .elementor-element.elementor-element-1302c36 > .elementor-widget-wrap > .elementor-widget:not(.elementor-widget__width-auto):not(.elementor-widget__width-initial):not(:last-child):not(.elementor-absolute){margin-bottom:0px;}.elementor-988267 .elementor-element.elementor-element-1302c36 > .elementor-element-populated{margin:0px 0px 20px 0px;--e-column-margin-right:0px;--e-column-margin-left:0px;padding:20px 20px 0px 20px;}.elementor-988267 .elementor-element.elementor-element-e2bdea1{--spacer-size:240px;}.elementor-988267 .elementor-element.elementor-element-e2bdea1 > .elementor-widget-container{background-size:contain;}.elementor-bc-flex-widget .elementor-988267 .elementor-element.elementor-element-f7837e0.elementor-column .elementor-widget-wrap{align-items:flex-start;}.elementor-988267 .elementor-element.elementor-element-f7837e0.elementor-column.elementor-element[data-element_type="column"] > .elementor-widget-wrap.elementor-element-populated{align-content:flex-start;align-items:flex-start;}.elementor-988267 .elementor-element.elementor-element-f7837e0 > .elementor-element-populated{margin:0px 0px 0px 0px;--e-column-margin-right:0px;--e-column-margin-left:0px;padding:0px 40px 0px 40px;}}@media(min-width:768px){.elementor-988267 .elementor-element.elementor-element-1302c36{width:45%;}.elementor-988267 .elementor-element.elementor-element-f7837e0{width:55%;}}
|
||||
@@ -0,0 +1 @@
|
||||
.elementor-988273 .elementor-element.elementor-element-fb6e69b, .elementor-988273 .elementor-element.elementor-element-fb6e69b > .elementor-background-overlay{border-radius:18px 18px 18px 18px;}.elementor-988273 .elementor-element.elementor-element-fb6e69b{transition:background 0.3s, border 0.3s, border-radius 0.3s, box-shadow 0.3s;padding:0px 0px 0px 0px;}.elementor-988273 .elementor-element.elementor-element-fb6e69b > .elementor-background-overlay{transition:background 0.3s, border-radius 0.3s, opacity 0.3s;}.elementor-bc-flex-widget .elementor-988273 .elementor-element.elementor-element-1302c36.elementor-column .elementor-widget-wrap{align-items:flex-end;}.elementor-988273 .elementor-element.elementor-element-1302c36.elementor-column.elementor-element[data-element_type="column"] > .elementor-widget-wrap.elementor-element-populated{align-content:flex-end;align-items:flex-end;}.elementor-988273 .elementor-element.elementor-element-1302c36 > .elementor-widget-wrap > .elementor-widget:not(.elementor-widget__width-auto):not(.elementor-widget__width-initial):not(:last-child):not(.elementor-absolute){margin-bottom:0px;}.elementor-988273 .elementor-element.elementor-element-1302c36 > .elementor-element-populated{padding:30px 0px 0px 0px;}.elementor-988273 .elementor-element.elementor-element-e2bdea1{--spacer-size:440px;}.elementor-988273 .elementor-element.elementor-element-e2bdea1 > .elementor-widget-container{background-image:var(--e-bg-lazyload-loaded);--e-bg-lazyload:url("https://www.goodwalk.co.nz/wp-content/uploads/2024/04/wallace-v2.jpg");background-position:bottom center;background-repeat:no-repeat;background-size:contain;}.elementor-bc-flex-widget .elementor-988273 .elementor-element.elementor-element-f7837e0.elementor-column .elementor-widget-wrap{align-items:flex-end;}.elementor-988273 .elementor-element.elementor-element-f7837e0.elementor-column.elementor-element[data-element_type="column"] > .elementor-widget-wrap.elementor-element-populated{align-content:flex-end;align-items:flex-end;}.elementor-988273 .elementor-element.elementor-element-f7837e0 > .elementor-element-populated{padding:0px 30px 145px 50px;}.elementor-988273 .elementor-element.elementor-element-717a84c > .elementor-widget-container{margin:0px 0px 15px 0px;}.elementor-988273 .elementor-element.elementor-element-717a84c{width:var( --container-widget-width, 390px );max-width:390px;--container-widget-width:390px;--container-widget-flex-grow:0;}.elementor-988273 .elementor-element.elementor-element-62bcb5c .elementor-heading-title{font-weight:500;}.elementor-988273 .elementor-element.elementor-element-62bcb5c{width:var( --container-widget-width, 390px );max-width:390px;--container-widget-width:390px;--container-widget-flex-grow:0;}@media(max-width:1024px){.elementor-988273 .elementor-element.elementor-element-1302c36 > .elementor-widget-wrap > .elementor-widget:not(.elementor-widget__width-auto):not(.elementor-widget__width-initial):not(:last-child):not(.elementor-absolute){margin-bottom:0px;}.elementor-988273 .elementor-element.elementor-element-e2bdea1{--spacer-size:430px;}.elementor-988273 .elementor-element.elementor-element-f7837e0 > .elementor-element-populated{padding:0px 30px 160px 30px;}.elementor-988273 .elementor-element.elementor-element-717a84c{width:100%;max-width:100%;}.elementor-988273 .elementor-element.elementor-element-62bcb5c{width:100%;max-width:100%;}}@media(max-width:767px){.elementor-988273 .elementor-element.elementor-element-fb6e69b{padding:0px 0px 150px 0px;}.elementor-988273 .elementor-element.elementor-element-1302c36 > .elementor-widget-wrap > .elementor-widget:not(.elementor-widget__width-auto):not(.elementor-widget__width-initial):not(:last-child):not(.elementor-absolute){margin-bottom:0px;}.elementor-988273 .elementor-element.elementor-element-1302c36 > .elementor-element-populated{margin:0px 0px 20px 0px;--e-column-margin-right:0px;--e-column-margin-left:0px;padding:20px 20px 0px 20px;}.elementor-988273 .elementor-element.elementor-element-e2bdea1{--spacer-size:240px;}.elementor-988273 .elementor-element.elementor-element-e2bdea1 > .elementor-widget-container{background-size:contain;}.elementor-bc-flex-widget .elementor-988273 .elementor-element.elementor-element-f7837e0.elementor-column .elementor-widget-wrap{align-items:flex-start;}.elementor-988273 .elementor-element.elementor-element-f7837e0.elementor-column.elementor-element[data-element_type="column"] > .elementor-widget-wrap.elementor-element-populated{align-content:flex-start;align-items:flex-start;}.elementor-988273 .elementor-element.elementor-element-f7837e0 > .elementor-element-populated{margin:0px 0px 0px 0px;--e-column-margin-right:0px;--e-column-margin-left:0px;padding:0px 40px 0px 40px;}}@media(min-width:768px){.elementor-988273 .elementor-element.elementor-element-1302c36{width:45%;}.elementor-988273 .elementor-element.elementor-element-f7837e0{width:55%;}}
|
||||
@@ -0,0 +1,102 @@
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Write-Step {
|
||||
param([string]$Message)
|
||||
Write-Host "==> $Message" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Write-Note {
|
||||
param([string]$Message)
|
||||
Write-Host " -> $Message" -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
function Assert-Command {
|
||||
param([string]$Name)
|
||||
|
||||
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
|
||||
throw "Required command '$Name' was not found in PATH."
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-AbsolutePath {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$BasePath,
|
||||
[Parameter(Mandatory = $true)][string]$Path
|
||||
)
|
||||
|
||||
if ([System.IO.Path]::IsPathRooted($Path)) {
|
||||
return [System.IO.Path]::GetFullPath($Path)
|
||||
}
|
||||
|
||||
return [System.IO.Path]::GetFullPath((Join-Path $BasePath $Path))
|
||||
}
|
||||
|
||||
function Invoke-Checked {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$FilePath,
|
||||
[Parameter(Mandatory = $true)][string[]]$Arguments,
|
||||
[string]$WorkingDirectory
|
||||
)
|
||||
|
||||
if ($WorkingDirectory) {
|
||||
Push-Location $WorkingDirectory
|
||||
}
|
||||
|
||||
try {
|
||||
& $FilePath @Arguments
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Command failed: $FilePath $($Arguments -join ' ')"
|
||||
}
|
||||
} finally {
|
||||
if ($WorkingDirectory) {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-DockerCompose {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$ComposeFile,
|
||||
[Parameter(Mandatory = $true)][string[]]$Arguments,
|
||||
[string]$ProjectName,
|
||||
[string]$WorkingDirectory
|
||||
)
|
||||
|
||||
$dockerArgs = @('compose', '-f', $ComposeFile)
|
||||
|
||||
if ($ProjectName) {
|
||||
$dockerArgs += @('-p', $ProjectName)
|
||||
}
|
||||
|
||||
$dockerArgs += $Arguments
|
||||
|
||||
Invoke-Checked -FilePath 'docker' -Arguments $dockerArgs -WorkingDirectory $WorkingDirectory
|
||||
}
|
||||
|
||||
function Wait-ForHttpOk {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Url,
|
||||
[int]$TimeoutSeconds = 120
|
||||
)
|
||||
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
try {
|
||||
$response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 10
|
||||
|
||||
if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 300) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
Start-Sleep -Seconds 2
|
||||
continue
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
throw "Timed out waiting for a healthy response from $Url."
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ComposeFile = 'docker-compose.yml',
|
||||
[string]$ProjectName = 'goodwalk',
|
||||
[switch]$RunMigration,
|
||||
[string]$LegacyComposeFile,
|
||||
[string]$LegacyProjectName = 'legacy-goodwalk',
|
||||
[string]$LegacyWordPressContainer,
|
||||
[string]$LegacyDatabaseContainer,
|
||||
[string]$LegacyUploadsPath = '/var/www/html/wp-content/uploads',
|
||||
[string]$MySqlDatabase,
|
||||
[string]$MySqlUser,
|
||||
[string]$MySqlPassword,
|
||||
[switch]$SkipLegacyShutdown,
|
||||
[switch]$SkipBuild,
|
||||
[int]$HealthTimeoutSeconds = 120,
|
||||
[string]$HealthUrl = 'http://localhost/api/health'
|
||||
)
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
. (Join-Path $scriptDir 'common.ps1')
|
||||
|
||||
$repoRoot = Split-Path -Parent $scriptDir
|
||||
$composeFilePath = Resolve-AbsolutePath -BasePath $repoRoot -Path $ComposeFile
|
||||
|
||||
Write-Step 'Validating deployment prerequisites'
|
||||
Assert-Command docker
|
||||
Invoke-DockerCompose -ComposeFile $composeFilePath -ProjectName $ProjectName -WorkingDirectory $repoRoot -Arguments @('config')
|
||||
|
||||
if ($RunMigration) {
|
||||
Write-Step 'Running the WordPress migration step'
|
||||
$migrationScript = Join-Path $scriptDir 'migrate-wordpress.ps1'
|
||||
$migrationArgs = @(
|
||||
'-LegacyWordPressContainer', $LegacyWordPressContainer,
|
||||
'-LegacyUploadsPath', $LegacyUploadsPath
|
||||
)
|
||||
|
||||
if ($LegacyDatabaseContainer) {
|
||||
$migrationArgs += @('-LegacyDatabaseContainer', $LegacyDatabaseContainer)
|
||||
} else {
|
||||
$migrationArgs += '-SkipDatabaseDump'
|
||||
}
|
||||
|
||||
if ($MySqlDatabase) { $migrationArgs += @('-MySqlDatabase', $MySqlDatabase) }
|
||||
if ($MySqlUser) { $migrationArgs += @('-MySqlUser', $MySqlUser) }
|
||||
if ($MySqlPassword) { $migrationArgs += @('-MySqlPassword', $MySqlPassword) }
|
||||
|
||||
& $migrationScript @migrationArgs
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw 'The WordPress migration step failed.'
|
||||
}
|
||||
}
|
||||
|
||||
if ($LegacyComposeFile -and -not $SkipLegacyShutdown) {
|
||||
$legacyComposeFilePath = Resolve-AbsolutePath -BasePath $repoRoot -Path $LegacyComposeFile
|
||||
Write-Step 'Stopping the legacy WordPress stack'
|
||||
Invoke-DockerCompose -ComposeFile $legacyComposeFilePath -ProjectName $LegacyProjectName -WorkingDirectory $repoRoot -Arguments @('down')
|
||||
}
|
||||
|
||||
Write-Step 'Building and starting the new stack'
|
||||
$upArgs = @('up', '-d', '--remove-orphans')
|
||||
if (-not $SkipBuild) {
|
||||
$upArgs += '--build'
|
||||
}
|
||||
|
||||
Invoke-DockerCompose -ComposeFile $composeFilePath -ProjectName $ProjectName -WorkingDirectory $repoRoot -Arguments $upArgs
|
||||
|
||||
Write-Step 'Waiting for the new site to become healthy'
|
||||
Wait-ForHttpOk -Url $HealthUrl -TimeoutSeconds $HealthTimeoutSeconds
|
||||
|
||||
Write-Step 'Current container status'
|
||||
Invoke-DockerCompose -ComposeFile $composeFilePath -ProjectName $ProjectName -WorkingDirectory $repoRoot -Arguments @('ps')
|
||||
|
||||
Write-Step 'Deployment completed'
|
||||
Write-Host "Health check passed at $HealthUrl"
|
||||
@@ -0,0 +1,78 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$LegacyWordPressContainer,
|
||||
[string]$LegacyDatabaseContainer,
|
||||
[string]$LegacyUploadsPath = '/var/www/html/wp-content/uploads',
|
||||
[string]$StaticUploadsTarget = 'static/wp-content/uploads',
|
||||
[string]$BackupRoot = 'migration-backups',
|
||||
[string]$MySqlDatabase,
|
||||
[string]$MySqlUser,
|
||||
[string]$MySqlPassword,
|
||||
[switch]$SkipUploads,
|
||||
[switch]$SkipDatabaseDump
|
||||
)
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
. (Join-Path $scriptDir 'common.ps1')
|
||||
|
||||
$repoRoot = Split-Path -Parent $scriptDir
|
||||
$backupRootPath = Resolve-AbsolutePath -BasePath $repoRoot -Path $BackupRoot
|
||||
$staticUploadsPath = Resolve-AbsolutePath -BasePath $repoRoot -Path $StaticUploadsTarget
|
||||
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$runRoot = Join-Path $backupRootPath $timestamp
|
||||
$stagingRoot = Join-Path $runRoot 'uploads-staging'
|
||||
$uploadsArchivePath = Join-Path $runRoot 'uploads'
|
||||
$sqlDumpPath = Join-Path $runRoot 'wordpress.sql'
|
||||
|
||||
Write-Step 'Preparing WordPress migration workspace'
|
||||
Assert-Command docker
|
||||
New-Item -ItemType Directory -Force -Path $runRoot | Out-Null
|
||||
|
||||
if (-not $SkipDatabaseDump) {
|
||||
if (-not $LegacyDatabaseContainer) {
|
||||
throw 'LegacyDatabaseContainer is required unless -SkipDatabaseDump is used.'
|
||||
}
|
||||
|
||||
if (-not $MySqlDatabase -or -not $MySqlUser -or -not $MySqlPassword) {
|
||||
throw 'MySqlDatabase, MySqlUser, and MySqlPassword are required unless -SkipDatabaseDump is used.'
|
||||
}
|
||||
|
||||
Write-Step 'Dumping the legacy WordPress database'
|
||||
$dumpCommand = "exec mysqldump --single-transaction --quick --lock-tables=false -u$MySqlUser -p`"$MySqlPassword`" $MySqlDatabase"
|
||||
$dumpOutput = & docker exec $LegacyDatabaseContainer sh -lc $dumpCommand
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw 'mysqldump failed.'
|
||||
}
|
||||
|
||||
[System.IO.File]::WriteAllText($sqlDumpPath, ($dumpOutput -join [Environment]::NewLine))
|
||||
Write-Note "Database dump saved to $sqlDumpPath"
|
||||
}
|
||||
|
||||
if (-not $SkipUploads) {
|
||||
Write-Step 'Copying wp-content/uploads from the legacy WordPress container'
|
||||
New-Item -ItemType Directory -Force -Path $stagingRoot | Out-Null
|
||||
|
||||
$sourceSpec = '{0}:{1}' -f $LegacyWordPressContainer, $LegacyUploadsPath
|
||||
Invoke-Checked -FilePath 'docker' -Arguments @('cp', $sourceSpec, $stagingRoot)
|
||||
|
||||
$copiedUploads = Join-Path $stagingRoot 'uploads'
|
||||
|
||||
if (-not (Test-Path $copiedUploads)) {
|
||||
throw "Expected copied uploads at $copiedUploads but it was not found."
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $staticUploadsPath) | Out-Null
|
||||
if (Test-Path $staticUploadsPath) {
|
||||
Remove-Item -LiteralPath $staticUploadsPath -Recurse -Force
|
||||
}
|
||||
|
||||
Move-Item -LiteralPath $copiedUploads -Destination $uploadsArchivePath
|
||||
Copy-Item -LiteralPath $uploadsArchivePath -Destination $staticUploadsPath -Recurse -Force
|
||||
|
||||
Write-Note "Uploads copied into $staticUploadsPath"
|
||||
Write-Note "Archive copy saved to $uploadsArchivePath"
|
||||
}
|
||||
|
||||
Write-Step 'Migration artifacts prepared'
|
||||
Write-Host "Backup folder: $runRoot"
|
||||
@@ -0,0 +1,9 @@
|
||||
declare global {
|
||||
namespace App {
|
||||
interface PageData {
|
||||
content: import('$lib/types').HomePageContent | import('$lib/types').SiteSharedContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,38 @@
|
||||
<!doctype html>
|
||||
<html lang="en-NZ">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#213021" />
|
||||
<link rel="icon" href="/images/goodwalk-favicon-32.png" sizes="32x32" type="image/png" />
|
||||
<link rel="icon" href="/images/goodwalk-favicon-192.png" sizes="192x192" type="image/png" />
|
||||
<link rel="apple-touch-icon" href="/images/goodwalk-favicon-192.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||
<link
|
||||
rel="preload"
|
||||
as="style"
|
||||
href="https://fonts.googleapis.com/css2?family=Fredoka+One&family=Readex+Pro:wght@400;500;600;700&family=Unbounded:wght@400;600;700;800&display=swap"
|
||||
/>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Fredoka+One&family=Readex+Pro:wght@400;500;600;700&family=Unbounded:wght@400;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
media="print"
|
||||
onload="this.media='all'"
|
||||
/>
|
||||
<noscript>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Fredoka+One&family=Readex+Pro:wght@400;500;600;700&family=Unbounded:wght@400;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</noscript>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"
|
||||
/>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,53 @@
|
||||
type RevealOptions = {
|
||||
delay?: number;
|
||||
distance?: number;
|
||||
threshold?: number;
|
||||
};
|
||||
|
||||
const defaultOptions: Required<RevealOptions> = {
|
||||
delay: 0,
|
||||
distance: 24,
|
||||
threshold: 0.18
|
||||
};
|
||||
|
||||
export function reveal(node: HTMLElement, options: RevealOptions = {}) {
|
||||
const settings = { ...defaultOptions, ...options };
|
||||
const media = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
|
||||
if (media.matches) {
|
||||
node.classList.add('reveal-visible');
|
||||
return {
|
||||
destroy() {}
|
||||
};
|
||||
}
|
||||
|
||||
node.style.setProperty('--reveal-delay', `${settings.delay}ms`);
|
||||
node.style.setProperty('--reveal-distance', `${settings.distance}px`);
|
||||
node.classList.add('reveal-ready');
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) {
|
||||
continue;
|
||||
}
|
||||
|
||||
node.classList.add('reveal-visible');
|
||||
observer.disconnect();
|
||||
break;
|
||||
}
|
||||
},
|
||||
{
|
||||
threshold: settings.threshold,
|
||||
rootMargin: '0px 0px -8% 0px'
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(node);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
observer.disconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import { reveal } from '$lib/actions/reveal';
|
||||
import type { AboutPageContent, SiteSharedContent } from '$lib/types';
|
||||
|
||||
export let content: SiteSharedContent;
|
||||
export let pageContent: AboutPageContent;
|
||||
</script>
|
||||
|
||||
<main class="about-page">
|
||||
<section class="about-hero">
|
||||
<div class="about-inner">
|
||||
<h1>{pageContent.title}</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#each pageContent.sections as section}
|
||||
<section
|
||||
use:reveal
|
||||
class:about-section-gradient={section.accent === 'gradient'}
|
||||
class="about-section reveal-block"
|
||||
>
|
||||
<div class:about-section-reverse={section.reverse} class="about-inner about-section-grid">
|
||||
<div class="about-copy">
|
||||
<h2>{section.title}</h2>
|
||||
{#each section.body as paragraph}
|
||||
<p>{paragraph}</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="about-media">
|
||||
<img src={section.imageUrl} alt={section.imageAlt} loading="lazy" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
<section use:reveal={{ delay: 40 }} class="about-services reveal-block">
|
||||
<div class="about-inner">
|
||||
<div class="about-section-heading">
|
||||
<h2>{pageContent.servicesTitle}</h2>
|
||||
</div>
|
||||
|
||||
<div class="about-service-grid">
|
||||
{#each content.services as service}
|
||||
<a class="about-service-card" href={service.href}>
|
||||
<div class="about-service-icon" aria-hidden="true">
|
||||
<Icon name={service.icon} />
|
||||
</div>
|
||||
<span>{service.title}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section use:reveal={{ delay: 70 }} class="about-contact reveal-block">
|
||||
<div class="about-inner">
|
||||
<div class="about-contact-card">
|
||||
<h2>{pageContent.contact.title}</h2>
|
||||
<div class="about-contact-grid">
|
||||
<a class="about-contact-link" href={`mailto:${pageContent.contact.email}`}>
|
||||
{pageContent.contact.email}
|
||||
</a>
|
||||
<a class="btn btn-yellow" href={pageContent.contact.cta.href}>
|
||||
{pageContent.contact.cta.label}
|
||||
</a>
|
||||
<a class="about-contact-link" href={`tel:${pageContent.contact.phone.replace(/[^0-9+]/g, '')}`}>
|
||||
{pageContent.contact.phone}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.about-page {
|
||||
background: var(--off-white);
|
||||
}
|
||||
|
||||
.about-inner {
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
padding: 0 50px;
|
||||
}
|
||||
|
||||
.about-hero {
|
||||
padding: 72px 0 40px;
|
||||
}
|
||||
|
||||
.about-hero h1,
|
||||
.about-section-heading h2,
|
||||
.about-copy h2,
|
||||
.about-contact-card h2 {
|
||||
margin: 0;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(34px, 4vw, 56px);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.about-hero h1,
|
||||
.about-section-heading {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.about-section {
|
||||
padding: 0 0 88px;
|
||||
}
|
||||
|
||||
.about-section-gradient {
|
||||
margin: 0 24px 88px;
|
||||
padding: 40px 0;
|
||||
border-radius: 36px;
|
||||
background: linear-gradient(180deg, #f5efe6 0%, #f9f6ef 100%);
|
||||
}
|
||||
|
||||
.about-section-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.7fr) minmax(0, 1.3fr);
|
||||
gap: 44px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.about-section-reverse {
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(0, 0.7fr);
|
||||
}
|
||||
|
||||
.about-section-reverse .about-copy {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.about-section-reverse .about-media {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.about-copy h2 {
|
||||
font-size: clamp(28px, 3vw, 40px);
|
||||
}
|
||||
|
||||
.about-copy p {
|
||||
margin: 18px 0 0;
|
||||
color: #34363a;
|
||||
font-size: 17px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.about-media img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
border-radius: 28px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 16px 40px rgba(17, 20, 24, 0.08);
|
||||
}
|
||||
|
||||
:global(.reveal-ready.reveal-block) {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, var(--reveal-distance, 24px), 0);
|
||||
transition:
|
||||
opacity 0.55s ease,
|
||||
transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
transition-delay: var(--reveal-delay, 0ms);
|
||||
}
|
||||
|
||||
:global(.reveal-visible.reveal-block) {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
.about-services {
|
||||
padding: 0 0 88px;
|
||||
}
|
||||
|
||||
.about-section-heading {
|
||||
margin-bottom: 34px;
|
||||
}
|
||||
|
||||
.about-section-heading h2 {
|
||||
font-size: clamp(28px, 3vw, 40px);
|
||||
}
|
||||
|
||||
.about-service-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.about-service-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
min-height: 200px;
|
||||
padding: 28px 24px;
|
||||
border-radius: 28px;
|
||||
background: #fff;
|
||||
box-shadow: 0 14px 34px rgba(17, 20, 24, 0.05);
|
||||
color: #000;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
transform 0.18s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 0.22s ease;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.about-service-card:hover {
|
||||
transform: translateY(-6px) scale(1.012);
|
||||
box-shadow: 0 20px 40px rgba(17, 20, 24, 0.09);
|
||||
}
|
||||
}
|
||||
|
||||
.about-service-card:active {
|
||||
transform: translateY(-1px) scale(0.992);
|
||||
}
|
||||
|
||||
.about-service-icon {
|
||||
font-size: 42px;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.about-service-card span {
|
||||
font-family: var(--font-head);
|
||||
font-size: 24px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.about-contact {
|
||||
padding: 0 0 88px;
|
||||
}
|
||||
|
||||
.about-contact-card {
|
||||
border-radius: 36px;
|
||||
background: #fff;
|
||||
padding: 42px 48px;
|
||||
box-shadow: 0 14px 34px rgba(17, 20, 24, 0.05);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.about-contact-card h2 {
|
||||
font-size: clamp(28px, 3vw, 42px);
|
||||
}
|
||||
|
||||
.about-contact-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.about-contact-link {
|
||||
color: #34363a;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.about-section-grid,
|
||||
.about-section-reverse {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.about-section-reverse .about-copy,
|
||||
.about-section-reverse .about-media {
|
||||
order: initial;
|
||||
}
|
||||
|
||||
.about-service-grid,
|
||||
.about-contact-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.about-inner {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.about-hero {
|
||||
padding: 56px 0 24px;
|
||||
}
|
||||
|
||||
.about-section,
|
||||
.about-services,
|
||||
.about-contact {
|
||||
padding-bottom: 64px;
|
||||
}
|
||||
|
||||
.about-section-gradient {
|
||||
margin: 0 12px 64px;
|
||||
padding: 28px 0;
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
.about-section-grid {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.about-copy h2,
|
||||
.about-section-heading h2,
|
||||
.about-contact-card h2 {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.about-copy p {
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.about-service-card {
|
||||
min-height: 168px;
|
||||
}
|
||||
|
||||
.about-contact-card {
|
||||
padding: 30px 24px;
|
||||
}
|
||||
|
||||
.about-contact-grid {
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.about-contact-link {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import BookingSection from '$lib/components/BookingSection.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { BookingContent } from '$lib/types';
|
||||
|
||||
export let booking: BookingContent;
|
||||
|
||||
const email = 'info@goodwalk.co.nz';
|
||||
const phone = '(022) 642 1011';
|
||||
</script>
|
||||
|
||||
<main class="booking-page">
|
||||
<section class="booking-page-hero">
|
||||
<div class="booking-page-inner">
|
||||
<h1>Book a Meet & Greet</h1>
|
||||
<p class="booking-page-sub">Fill in the form below and we'll be in touch to arrange a free introduction.</p>
|
||||
<div class="booking-page-contact">
|
||||
<a href="mailto:{email}" class="booking-contact-link">
|
||||
<Icon name="fas fa-envelope" />
|
||||
{email}
|
||||
</a>
|
||||
<a href="tel:{phone.replace(/[^0-9+]/g, '')}" class="booking-contact-link">
|
||||
<Icon name="fas fa-phone" />
|
||||
{phone}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<BookingSection {booking} />
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.booking-page {
|
||||
background: var(--off-white);
|
||||
}
|
||||
|
||||
.booking-page-hero {
|
||||
background: var(--green);
|
||||
color: #fff;
|
||||
padding: 64px 0 72px;
|
||||
}
|
||||
|
||||
.booking-page-inner {
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
padding: 0 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.booking-page-hero h1 {
|
||||
margin: 0 0 14px;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(32px, 4vw, 52px);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.booking-page-sub {
|
||||
margin: 0 auto 32px;
|
||||
max-width: 480px;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.booking-page-contact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.booking-contact-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.booking-contact-link:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.booking-page-hero {
|
||||
padding: 48px 0 56px;
|
||||
}
|
||||
|
||||
.booking-page-inner {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.booking-page-contact {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.booking-contact-link {
|
||||
font-size: 13px;
|
||||
padding: 9px 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,416 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import SuccessModal from '$lib/components/SuccessModal.svelte';
|
||||
import { reveal } from '$lib/actions/reveal';
|
||||
import type { BookingContent } from '$lib/types';
|
||||
|
||||
export let booking: BookingContent;
|
||||
|
||||
let step = 1;
|
||||
$: headingParts = splitBookingTitle(booking.title);
|
||||
|
||||
let fullName = '';
|
||||
let email = '';
|
||||
let phone = '';
|
||||
let petName = '';
|
||||
let location = '';
|
||||
let message = '';
|
||||
let selectedServices: string[] = [];
|
||||
|
||||
let fullNameInput: HTMLInputElement;
|
||||
let emailInput: HTMLInputElement;
|
||||
let phoneInput: HTMLInputElement;
|
||||
let petNameInput: HTMLInputElement;
|
||||
let locationInput: HTMLInputElement;
|
||||
|
||||
let errors: Record<string, string> = {};
|
||||
let submitting = false;
|
||||
let submitted = false;
|
||||
let submitError = '';
|
||||
|
||||
const defaultDogIntro =
|
||||
'Tell us about your dog and where you are based so we can plan the right Meet & Greet.';
|
||||
|
||||
$: dogIntro = booking.dogIntro?.trim() || defaultDogIntro;
|
||||
$: hasBanner = Boolean(booking.subtitle?.trim());
|
||||
$: hasServices = booking.serviceOptions.length > 0;
|
||||
$: ownerStepLabel = booking.ownerStepLabel?.trim() || 'Owner Details';
|
||||
$: dogStepLabel = booking.dogStepLabel?.trim() || 'Your dog';
|
||||
|
||||
function splitBookingTitle(title: string) {
|
||||
const trimmed = title.trim();
|
||||
const lastSpace = trimmed.lastIndexOf(' ');
|
||||
|
||||
if (lastSpace === -1) {
|
||||
return { plain: trimmed, highlight: '' };
|
||||
}
|
||||
|
||||
return {
|
||||
plain: trimmed.slice(0, lastSpace),
|
||||
highlight: trimmed.slice(lastSpace + 1)
|
||||
};
|
||||
}
|
||||
|
||||
function clearError(field: string) {
|
||||
if (errors[field]) {
|
||||
errors = { ...errors, [field]: '' };
|
||||
}
|
||||
}
|
||||
|
||||
function toggleService(service: string, checked: boolean) {
|
||||
if (checked) {
|
||||
selectedServices = [...selectedServices, service];
|
||||
return;
|
||||
}
|
||||
|
||||
selectedServices = selectedServices.filter((item) => item !== service);
|
||||
}
|
||||
|
||||
function validateStepOne(): boolean {
|
||||
const next: Record<string, string> = {};
|
||||
|
||||
if (!fullName.trim()) next.fullName = 'Please enter your full name';
|
||||
if (!email.trim() || !emailInput?.checkValidity()) next.email = 'Please enter a valid email address';
|
||||
if (!phone.trim()) next.phone = 'Please enter your contact number';
|
||||
|
||||
errors = next;
|
||||
|
||||
if (next.fullName) { fullNameInput?.focus(); return false; }
|
||||
if (next.email) { emailInput?.focus(); return false; }
|
||||
if (next.phone) { phoneInput?.focus(); return false; }
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function goToDogStep() {
|
||||
if (!validateStepOne()) return;
|
||||
errors = {};
|
||||
step = 2;
|
||||
}
|
||||
|
||||
async function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (step === 1) {
|
||||
goToDogStep();
|
||||
return;
|
||||
}
|
||||
|
||||
const next: Record<string, string> = {};
|
||||
if (!petName.trim()) next.petName = "Please enter your dog's name";
|
||||
if (!location.trim()) next.location = 'Please enter your location';
|
||||
|
||||
if (Object.keys(next).length > 0) {
|
||||
errors = next;
|
||||
if (next.petName) petNameInput?.focus();
|
||||
else if (next.location) locationInput?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
submitting = true;
|
||||
submitError = '';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/submit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
fullName, email, phone, petName, location, message,
|
||||
services: selectedServices,
|
||||
referrer: document.referrer,
|
||||
page: window.location.href,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.detail ?? 'Something went wrong. Please try again.');
|
||||
}
|
||||
|
||||
submitted = true;
|
||||
} catch (err: unknown) {
|
||||
submitError = err instanceof Error ? err.message : 'Something went wrong. Please try again.';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section id="reservation" use:reveal={{ delay: 70 }} class="reveal-block">
|
||||
<div class="form-inner">
|
||||
|
||||
{#if submitted}
|
||||
<SuccessModal
|
||||
firstName={fullName.split(' ')[0]}
|
||||
{petName}
|
||||
{email}
|
||||
onClose={() => (submitted = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="booking-header">
|
||||
<h2 class="booking-title">
|
||||
<span class="booking-title-plain">{headingParts.plain}</span>
|
||||
<span class="booking-title-highlight">{headingParts.highlight}</span>
|
||||
</h2>
|
||||
|
||||
<div class="booking-stepper" aria-label="Booking form steps">
|
||||
<button
|
||||
type="button"
|
||||
class:active={step === 1}
|
||||
class="booking-step"
|
||||
on:click={() => (step = 1)}
|
||||
>
|
||||
<span class="booking-step-number">1</span>
|
||||
<span class="booking-step-label">{ownerStepLabel}</span>
|
||||
</button>
|
||||
<span class="booking-step-divider" aria-hidden="true"></span>
|
||||
<button
|
||||
type="button"
|
||||
class:active={step === 2}
|
||||
class="booking-step"
|
||||
on:click={goToDogStep}
|
||||
>
|
||||
<span class="booking-step-number">2</span>
|
||||
<span class="booking-step-label">{dogStepLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="booking-form"
|
||||
id="bookingForm"
|
||||
novalidate
|
||||
on:submit={handleSubmit}
|
||||
>
|
||||
{#if step === 1}
|
||||
<div class="booking-panel">
|
||||
{#if hasBanner}
|
||||
<div class="booking-panel-banner">{booking.subtitle}</div>
|
||||
{/if}
|
||||
|
||||
<div class:booking-card-grid-with-banner={hasBanner} class="booking-card-grid booking-card-grid-owner">
|
||||
<div class="booking-field-card booking-field-card-group booking-field-card-full">
|
||||
<div class="booking-field-group booking-field-group-owner">
|
||||
<div class="booking-field-stack" class:booking-field-stack-invalid={errors.fullName}>
|
||||
<label for="fullName">
|
||||
<Icon name="fas fa-user" /> Full Name <span class="booking-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
bind:this={fullNameInput}
|
||||
bind:value={fullName}
|
||||
type="text"
|
||||
id="fullName"
|
||||
name="fullName"
|
||||
required
|
||||
placeholder="Enter full name"
|
||||
class:input-invalid={errors.fullName}
|
||||
on:input={() => clearError('fullName')}
|
||||
/>
|
||||
{#if errors.fullName}
|
||||
<p class="field-error">
|
||||
<Icon name="fas fa-circle-exclamation" />
|
||||
{errors.fullName}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="booking-field-stack" class:booking-field-stack-invalid={errors.email}>
|
||||
<label for="email">
|
||||
<Icon name="fas fa-envelope" /> Email <span class="booking-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
bind:this={emailInput}
|
||||
bind:value={email}
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
required
|
||||
placeholder="Email"
|
||||
class:input-invalid={errors.email}
|
||||
on:input={() => clearError('email')}
|
||||
/>
|
||||
{#if errors.email}
|
||||
<p class="field-error">
|
||||
<Icon name="fas fa-circle-exclamation" />
|
||||
{errors.email}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="booking-field-stack" class:booking-field-stack-invalid={errors.phone}>
|
||||
<label for="phone">
|
||||
<Icon name="fas fa-phone" /> Contact # <span class="booking-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
bind:this={phoneInput}
|
||||
bind:value={phone}
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
required
|
||||
placeholder="E.g. 021 1234567"
|
||||
class:input-invalid={errors.phone}
|
||||
on:input={() => clearError('phone')}
|
||||
/>
|
||||
{#if errors.phone}
|
||||
<p class="field-error">
|
||||
<Icon name="fas fa-circle-exclamation" />
|
||||
{errors.phone}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if hasServices}
|
||||
<div class="booking-service-row">
|
||||
<span class="booking-service-label"><Icon name="fas fa-paw" /> Services</span>
|
||||
<div class="booking-service-options">
|
||||
{#each booking.serviceOptions as service}
|
||||
<label class="booking-check-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="services"
|
||||
value={service}
|
||||
checked={selectedServices.includes(service)}
|
||||
on:change={(event) =>
|
||||
toggleService(service, (event.currentTarget as HTMLInputElement).checked)}
|
||||
/>
|
||||
<span class="booking-check-box" aria-hidden="true"></span>
|
||||
<span>{service}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="booking-actions booking-actions-next">
|
||||
<button type="button" class="btn btn-yellow booking-next-button" on:click={goToDogStep}>
|
||||
{dogStepLabel}
|
||||
<Icon name="fas fa-arrow-right" />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<input type="hidden" name="fullName" value={fullName} />
|
||||
<input type="hidden" name="email" value={email} />
|
||||
<input type="hidden" name="phone" value={phone} />
|
||||
{#each selectedServices as service}
|
||||
<input type="hidden" name="services" value={service} />
|
||||
{/each}
|
||||
|
||||
<div class="booking-panel">
|
||||
{#if dogIntro}
|
||||
<div class="booking-panel-banner">{dogIntro}</div>
|
||||
{/if}
|
||||
|
||||
<div class:booking-card-grid-with-banner={Boolean(dogIntro)} class="booking-card-grid booking-card-grid-dog">
|
||||
<div class="booking-field-card" class:booking-field-card-invalid={errors.petName}>
|
||||
<label for="petName">
|
||||
<Icon name="fas fa-dog" /> Pet's Name <span class="booking-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
bind:this={petNameInput}
|
||||
bind:value={petName}
|
||||
type="text"
|
||||
id="petName"
|
||||
name="petName"
|
||||
required
|
||||
placeholder="Your dog's name"
|
||||
class:input-invalid={errors.petName}
|
||||
on:input={() => clearError('petName')}
|
||||
/>
|
||||
{#if errors.petName}
|
||||
<p class="field-error">
|
||||
<Icon name="fas fa-circle-exclamation" />
|
||||
{errors.petName}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="booking-field-card booking-field-card-wide" class:booking-field-card-invalid={errors.location}>
|
||||
<label for="location">
|
||||
<Icon name="fas fa-location-dot" /> Location <span class="booking-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
bind:this={locationInput}
|
||||
bind:value={location}
|
||||
type="text"
|
||||
id="location"
|
||||
name="location"
|
||||
required
|
||||
placeholder="Neighborhood, street..."
|
||||
class:input-invalid={errors.location}
|
||||
on:input={() => clearError('location')}
|
||||
/>
|
||||
{#if errors.location}
|
||||
<p class="field-error">
|
||||
<Icon name="fas fa-circle-exclamation" />
|
||||
{errors.location}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="booking-field-card booking-field-card-full">
|
||||
<label for="message"><Icon name="fas fa-comment" /> About Your Dog</label>
|
||||
<textarea
|
||||
bind:value={message}
|
||||
id="message"
|
||||
name="message"
|
||||
rows="4"
|
||||
placeholder="Describe your pet, any special needs, or anything we should know."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="booking-actions booking-actions-final">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline btn-outline-green"
|
||||
on:click={() => { step = 1; errors = {}; }}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button type="submit" class="btn btn-yellow booking-submit-button" disabled={submitting}>
|
||||
{#if submitting}Sending…{:else}Send <Icon name="fas fa-arrow-right" />{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if submitError}
|
||||
<p class="booking-submit-error">
|
||||
<Icon name="fas fa-circle-exclamation" />
|
||||
{submitError}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
:global(.reveal-ready.reveal-block) {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, var(--reveal-distance, 24px), 0);
|
||||
transition:
|
||||
opacity 0.55s ease,
|
||||
transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
transition-delay: var(--reveal-delay, 0ms);
|
||||
}
|
||||
|
||||
:global(.reveal-visible.reveal-block) {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
.booking-submit-error {
|
||||
margin: 16px 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #c0392b;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,152 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import BookingSection from './BookingSection.svelte';
|
||||
import { homepageContent } from '$lib/content/homepage';
|
||||
|
||||
describe('BookingSection', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(document, 'referrer', {
|
||||
configurable: true,
|
||||
value: 'https://www.google.com/'
|
||||
});
|
||||
});
|
||||
|
||||
it('validates the owner details step before progressing', async () => {
|
||||
const { container } = render(BookingSection, {
|
||||
booking: homepageContent.booking
|
||||
});
|
||||
|
||||
await fireEvent.click(container.querySelector('.booking-next-button')!);
|
||||
|
||||
expect(screen.getByText('Please enter your full name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Please enter a valid email address')).toBeInTheDocument();
|
||||
expect(screen.getByText('Please enter your contact number')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('validates the dog details step before submitting', async () => {
|
||||
const { container } = render(BookingSection, {
|
||||
booking: homepageContent.booking
|
||||
});
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/Full Name/i), {
|
||||
target: { value: 'Alex Walker' }
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/^Email/i), {
|
||||
target: { value: 'alex@example.com' }
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/Contact #/i), {
|
||||
target: { value: '021 123 4567' }
|
||||
});
|
||||
|
||||
await fireEvent.click(container.querySelector('.booking-next-button')!);
|
||||
await fireEvent.click(container.querySelector('.booking-submit-button')!);
|
||||
|
||||
expect(screen.getByText("Please enter your dog's name")).toBeInTheDocument();
|
||||
expect(screen.getByText('Please enter your location')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits the completed booking flow and shows the success modal', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({})
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { container } = render(BookingSection, {
|
||||
booking: homepageContent.booking
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByLabelText('Pack Walks'));
|
||||
await fireEvent.click(screen.getByLabelText('Other Services'));
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/Full Name/i), {
|
||||
target: { value: 'Alex Walker' }
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/^Email/i), {
|
||||
target: { value: 'alex@example.com' }
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/Contact #/i), {
|
||||
target: { value: '021 123 4567' }
|
||||
});
|
||||
|
||||
await fireEvent.click(container.querySelector('.booking-next-button')!);
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/Pet's Name/i), {
|
||||
target: { value: 'Maya' }
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/Location/i), {
|
||||
target: { value: 'Kingsland' }
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/About Your Dog/i), {
|
||||
target: { value: 'Loves small group walks.' }
|
||||
});
|
||||
|
||||
await fireEvent.click(container.querySelector('.booking-submit-button')!);
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/submit',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
);
|
||||
|
||||
const payload = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
||||
expect(payload).toMatchObject({
|
||||
fullName: 'Alex Walker',
|
||||
email: 'alex@example.com',
|
||||
phone: '021 123 4567',
|
||||
petName: 'Maya',
|
||||
location: 'Kingsland',
|
||||
message: 'Loves small group walks.',
|
||||
services: ['Pack Walks', 'Other Services'],
|
||||
referrer: 'https://www.google.com/'
|
||||
});
|
||||
|
||||
expect(screen.getByRole('dialog', { name: /Booking confirmed/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /on our radar/i })).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /Sounds great!/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: /Booking confirmed/i })).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the API error message when submission fails', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: vi.fn().mockResolvedValue({ detail: 'Mail API unavailable' })
|
||||
})
|
||||
);
|
||||
|
||||
const { container } = render(BookingSection, {
|
||||
booking: homepageContent.booking
|
||||
});
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/Full Name/i), {
|
||||
target: { value: 'Alex Walker' }
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/^Email/i), {
|
||||
target: { value: 'alex@example.com' }
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/Contact #/i), {
|
||||
target: { value: '021 123 4567' }
|
||||
});
|
||||
await fireEvent.click(container.querySelector('.booking-next-button')!);
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/Pet's Name/i), {
|
||||
target: { value: 'Maya' }
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/Location/i), {
|
||||
target: { value: 'Kingsland' }
|
||||
});
|
||||
|
||||
await fireEvent.click(container.querySelector('.booking-submit-button')!);
|
||||
|
||||
expect(await screen.findByText('Mail API unavailable')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { FooterContent, LinkItem } from '$lib/types';
|
||||
|
||||
export let footer: FooterContent;
|
||||
|
||||
const socialLinks: LinkItem[] = [
|
||||
{ label: 'Instagram', href: 'https://www.instagram.com/goodwalk.nz/', external: true },
|
||||
{ label: 'Facebook', href: 'https://facebook.com/goodwalk.nz', external: true },
|
||||
{ label: 'Google', href: 'https://g.page/r/CUsvrWPhkYrAEB0', external: true }
|
||||
];
|
||||
</script>
|
||||
|
||||
<footer>
|
||||
<div class="footer-inner">
|
||||
<div class="footer-brand">
|
||||
<img
|
||||
src="/images/goodwalk-auckland-dog-walking-logo.png"
|
||||
alt="Goodwalk – Auckland dog walking service logo"
|
||||
class="footer-logo"
|
||||
height="28"
|
||||
/>
|
||||
<p>{footer.brandText}</p>
|
||||
<div class="social-links">
|
||||
<a href={socialLinks[0].href} target="_blank" rel="noopener" aria-label="Instagram">
|
||||
<Icon name="fab fa-instagram" />
|
||||
</a>
|
||||
<a href={socialLinks[1].href} target="_blank" rel="noopener" aria-label="Facebook">
|
||||
<Icon name="fab fa-facebook-f" />
|
||||
</a>
|
||||
<a href={socialLinks[2].href} target="_blank" rel="noopener" aria-label="Google">
|
||||
<Icon name="fab fa-google" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-explore">
|
||||
<p class="footer-col-label">Explore</p>
|
||||
<ul class="footer-nav">
|
||||
{#each footer.navigationLinks as link}
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
target={link.external ? '_blank' : undefined}
|
||||
rel={link.external ? 'noopener' : undefined}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="footer-action">
|
||||
<p class="footer-col-label">Get Started</p>
|
||||
<a href="/booking" class="footer-book-btn">
|
||||
Book a Meet & Greet
|
||||
<Icon name="fas fa-arrow-right" />
|
||||
</a>
|
||||
<p class="footer-book-note">Free, no-obligation introduction</p>
|
||||
<a
|
||||
href="https://g.page/r/CUsvrWPhkYrAEB0/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="footer-reviews"
|
||||
>
|
||||
<Icon name="fab fa-google" />
|
||||
<span>See our 5★ Google reviews</span>
|
||||
</a>
|
||||
|
||||
{#if footer.email || footer.phone}
|
||||
<div class="footer-contact">
|
||||
{#if footer.email}
|
||||
<a href="mailto:{footer.email}" class="footer-contact-link">
|
||||
<Icon name="fas fa-envelope" />
|
||||
{footer.email}
|
||||
</a>
|
||||
{/if}
|
||||
{#if footer.phone}
|
||||
<a href="tel:{footer.phone.replace(/[^0-9+]/g, '')}" class="footer-contact-link">
|
||||
<Icon name="fas fa-phone" />
|
||||
{footer.phone}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-bottom">
|
||||
<span>{footer.copyright}</span>
|
||||
<nav class="footer-legal">
|
||||
<a href="/terms-and-conditions">Terms & Conditions</a>
|
||||
<a href="/privacy-policy">Privacy Policy</a>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,174 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { NavigationContent } from '$lib/types';
|
||||
|
||||
export let navigation: NavigationContent;
|
||||
|
||||
let mobileMenuOpen = false;
|
||||
const mobilePhoneDisplay = '(022) 642 1011';
|
||||
const mobilePhoneHref = '+64226421011';
|
||||
|
||||
function closeMenu() {
|
||||
mobileMenuOpen = false;
|
||||
}
|
||||
|
||||
function linkTarget(external?: boolean) {
|
||||
return external ? '_blank' : undefined;
|
||||
}
|
||||
|
||||
function linkRel(external?: boolean) {
|
||||
return external ? 'noopener' : undefined;
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
mobileMenuOpen = !mobileMenuOpen;
|
||||
}
|
||||
|
||||
function normalizePath(path: string) {
|
||||
if (!path || path === '/') {
|
||||
return '/';
|
||||
}
|
||||
|
||||
const cleaned = path.split('#')[0].split('?')[0];
|
||||
return cleaned.endsWith('/') ? cleaned.slice(0, -1) : cleaned;
|
||||
}
|
||||
|
||||
function isServicesActive() {
|
||||
const pathname = normalizePath($page.url.pathname);
|
||||
return pathname === '/pack-walks' || pathname === '/dog-walking' || pathname === '/puppy-visits';
|
||||
}
|
||||
|
||||
function isActiveLink(href: string, isServicesLink = false) {
|
||||
if (!href || href.startsWith('http')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isServicesLink) {
|
||||
return isServicesActive();
|
||||
}
|
||||
|
||||
if (href.startsWith('#')) {
|
||||
return $page.url.pathname === '/' && $page.url.hash === href;
|
||||
}
|
||||
|
||||
return normalizePath($page.url.pathname) === normalizePath(href);
|
||||
}
|
||||
|
||||
function handleViewportChange() {
|
||||
if (window.innerWidth > 768) {
|
||||
mobileMenuOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
handleViewportChange();
|
||||
window.addEventListener('resize', handleViewportChange);
|
||||
|
||||
return () => window.removeEventListener('resize', handleViewportChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
<header>
|
||||
<nav>
|
||||
<ul class="nav-links">
|
||||
{#each navigation.desktopLinks as link, i}
|
||||
<li class:has-mega={i === 0 && navigation.megaMenuServices?.length}>
|
||||
<a
|
||||
href={link.href}
|
||||
target={linkTarget(link.external)}
|
||||
rel={linkRel(link.external)}
|
||||
class:nav-link-active={isActiveLink(link.href, i === 0 && Boolean(navigation.megaMenuServices?.length))}
|
||||
>
|
||||
{link.label}
|
||||
{#if i === 0 && navigation.megaMenuServices?.length}
|
||||
<Icon name="fas fa-chevron-down" className="mega-chevron" />
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
{#if i === 0 && navigation.megaMenuServices?.length}
|
||||
<div class="mega-menu">
|
||||
<div class="mega-menu-inner">
|
||||
{#each navigation.megaMenuServices as service}
|
||||
<a
|
||||
href={service.href}
|
||||
target={linkTarget(service.href.startsWith('http'))}
|
||||
rel={linkRel(service.href.startsWith('http'))}
|
||||
class="mega-service"
|
||||
>
|
||||
<div class="mega-icon">
|
||||
<Icon name={service.icon} />
|
||||
</div>
|
||||
<span class="mega-service-label">{service.label}</span>
|
||||
{#if service.description}
|
||||
<span class="mega-service-desc">{service.description}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<a href="/" class="logo" aria-label="Goodwalk – Auckland Dog Walking, home">
|
||||
<picture>
|
||||
<source
|
||||
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" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
<a href={`tel:${mobilePhoneHref}`} class="mobile-phone" aria-label={`Call Goodwalk on ${mobilePhoneDisplay}`}>
|
||||
<Icon name="fas fa-phone" />
|
||||
<span>{mobilePhoneDisplay}</span>
|
||||
</a>
|
||||
|
||||
<div class="nav-right">
|
||||
{#if navigation.instagram}
|
||||
<a
|
||||
href={navigation.instagram.href}
|
||||
target={linkTarget(navigation.instagram.external)}
|
||||
rel={linkRel(navigation.instagram.external)}
|
||||
class="instagram-icon"
|
||||
aria-label="Instagram"
|
||||
>
|
||||
<Icon name="fab fa-instagram" />
|
||||
</a>
|
||||
{/if}
|
||||
<a
|
||||
href={navigation.cta.href}
|
||||
class="btn btn-yellow"
|
||||
>{navigation.cta.label}</a>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="hamburger"
|
||||
type="button"
|
||||
aria-expanded={mobileMenuOpen}
|
||||
aria-controls="mobile-menu"
|
||||
aria-label="Toggle menu"
|
||||
on:click={toggleMenu}
|
||||
>
|
||||
<Icon name={mobileMenuOpen ? 'fas fa-xmark' : 'fas fa-bars'} />
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class:open={mobileMenuOpen} class="mobile-menu" id="mobile-menu">
|
||||
{#each navigation.mobileLinks as link}
|
||||
<a
|
||||
href={link.href}
|
||||
target={linkTarget(link.external)}
|
||||
rel={linkRel(link.external)}
|
||||
class:mobile-link-active={isActiveLink(link.href)}
|
||||
on:click={closeMenu}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,43 @@
|
||||
import { fireEvent, render } from '@testing-library/svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import Header from './Header.svelte';
|
||||
import { homepageContent } from '$lib/content/homepage';
|
||||
import { setMockPage } from '../../test/mocks/app-stores';
|
||||
|
||||
describe('Header', () => {
|
||||
it('marks the services link active for service detail pages', () => {
|
||||
setMockPage('https://www.goodwalk.co.nz/pack-walks');
|
||||
|
||||
const { container } = render(Header, {
|
||||
navigation: homepageContent.navigation
|
||||
});
|
||||
|
||||
expect(container.querySelector('a.nav-link-active[href="#services"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('opens and closes the mobile menu', async () => {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 390
|
||||
});
|
||||
|
||||
const { container } = render(Header, {
|
||||
navigation: homepageContent.navigation
|
||||
});
|
||||
|
||||
const menuToggle = container.querySelector('.hamburger') as HTMLButtonElement;
|
||||
const mobileMenu = container.querySelector('.mobile-menu') as HTMLDivElement;
|
||||
const firstMobileLink = mobileMenu.querySelector('a') as HTMLAnchorElement;
|
||||
|
||||
expect(menuToggle).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
await fireEvent.click(menuToggle);
|
||||
expect(menuToggle).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(mobileMenu.classList.contains('open')).toBe(true);
|
||||
|
||||
await fireEvent.click(firstMobileLink);
|
||||
expect(menuToggle).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(mobileMenu.classList.contains('open')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import type { HeroContent } from '$lib/types';
|
||||
|
||||
export let hero: HeroContent;
|
||||
|
||||
$: titleParts = splitTitle(hero.title);
|
||||
$: mobileTitle = hero.mobileTitle?.trim() || `${hero.title} ${hero.highlight}`.trim();
|
||||
|
||||
function splitTitle(title: string) {
|
||||
const trimmed = title.trim();
|
||||
|
||||
if (trimmed.toLowerCase().endsWith(' in')) {
|
||||
return {
|
||||
lead: trimmed.slice(0, -3),
|
||||
connector: 'in'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
lead: trimmed,
|
||||
connector: ''
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<section id="hero">
|
||||
<div class="hero-inner">
|
||||
<div class="hero-text">
|
||||
<h1 class="hero-heading">
|
||||
<span class="hero-heading-desktop">
|
||||
<span class="hero-title-main">{titleParts.lead}</span>
|
||||
{#if titleParts.connector}
|
||||
<span class="hero-title-connector"> {titleParts.connector}</span>
|
||||
{/if}
|
||||
<br />
|
||||
<span class="hero-title-highlight">{hero.highlight}</span>
|
||||
</span>
|
||||
<span class="hero-heading-mobile">{mobileTitle}</span>
|
||||
</h1>
|
||||
|
||||
<div class="hero-buttons">
|
||||
<a href={hero.primaryCta.href} class="btn btn-yellow">{hero.primaryCta.label}</a>
|
||||
<a href={hero.secondaryCta.href} class="btn btn-outline">{hero.secondaryCta.label}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-img">
|
||||
<img
|
||||
src={hero.imageUrl}
|
||||
alt={hero.imageAlt}
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
export let name: string;
|
||||
export let className = '';
|
||||
export let title: string | undefined = undefined;
|
||||
|
||||
$: classes = `${name} icon ${className}`.trim();
|
||||
</script>
|
||||
|
||||
<i class={classes} aria-hidden={title ? undefined : 'true'} title={title}></i>
|
||||
|
||||
<style>
|
||||
.icon {
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
line-height: 1;
|
||||
font-style: normal;
|
||||
text-rendering: auto;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { InfoContent } from '$lib/types';
|
||||
|
||||
export let info: InfoContent;
|
||||
</script>
|
||||
|
||||
<section id="info">
|
||||
<div class="info-inner">
|
||||
<div class="info-block">
|
||||
<h2><Icon name="fas fa-location-dot" /> {info.title}</h2>
|
||||
<p>{info.intro}</p>
|
||||
<p class="info-copy">{info.suburbs}</p>
|
||||
<p class="info-copy">
|
||||
{info.nearbyText}
|
||||
<a href={info.nearbyCta.href}>{info.nearbyCta.label}</a>
|
||||
</p>
|
||||
<h3>{info.hoursLabel}</h3>
|
||||
<p>{info.hours}</p>
|
||||
</div>
|
||||
|
||||
<div class="info-block">
|
||||
<h2><Icon name="fas fa-circle-question" /> {info.faqTitle}</h2>
|
||||
<div class="faq">
|
||||
{#each info.faqs as faq}
|
||||
<details>
|
||||
<summary>{faq.question}</summary>
|
||||
<p>{faq.answer}</p>
|
||||
</details>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { HomePageContent } from '$lib/types';
|
||||
|
||||
export let instagram: HomePageContent['instagram'];
|
||||
</script>
|
||||
|
||||
<section id="instagram">
|
||||
<h2>{instagram.title}</h2>
|
||||
<p class="instagram-blurb">See our dogs in action — walks, play, and happy pups</p>
|
||||
<a href={instagram.href} target="_blank" rel="noopener" class="btn btn-green">
|
||||
<Icon name="fab fa-instagram" />
|
||||
{instagram.label}
|
||||
</a>
|
||||
</section>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { IntroContent } from '$lib/types';
|
||||
|
||||
export let intro: IntroContent;
|
||||
|
||||
const stars = Array.from({ length: 5 });
|
||||
</script>
|
||||
|
||||
<div id="intro">
|
||||
<div class="intro-trust-badge">
|
||||
<div class="intro-trust-mark" aria-hidden="true">
|
||||
<Icon name="fab fa-google" />
|
||||
</div>
|
||||
|
||||
<div class="intro-trust-copy">
|
||||
<p>{intro.text}</p>
|
||||
|
||||
<div class="intro-trust-meta">
|
||||
<div class="intro-trust-stars" aria-label="5 star rating">
|
||||
{#each stars as _, index}
|
||||
<Icon name="fas fa-star" className={`intro-star intro-star-${index + 1}`} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<a href={intro.reviewCta.href} target="_blank" rel="noopener">
|
||||
{intro.reviewCta.label}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,187 @@
|
||||
<script lang="ts">
|
||||
import type { LegalPageBlock, LegalPageContent } from '$lib/types';
|
||||
|
||||
export let pageContent: LegalPageContent;
|
||||
|
||||
function isParagraph(block: LegalPageBlock): boolean {
|
||||
return block.type === 'paragraph';
|
||||
}
|
||||
|
||||
function getParagraphContent(block: LegalPageBlock): string {
|
||||
return typeof block.content === 'string' ? block.content : block.content.join(' ');
|
||||
}
|
||||
|
||||
function getListItems(block: LegalPageBlock): string[] {
|
||||
return Array.isArray(block.content) ? block.content : [block.content];
|
||||
}
|
||||
|
||||
function isSubItem(item: string): boolean {
|
||||
return /^\([a-z]\)/.test(item.trimStart());
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="legal-page">
|
||||
<section class="legal-hero">
|
||||
<div class="legal-inner">
|
||||
<h1>{pageContent.title}</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="legal-body">
|
||||
<div class="legal-inner">
|
||||
<div class="legal-card">
|
||||
{#each pageContent.sections as section}
|
||||
<section class="legal-section">
|
||||
<h2>{section.title}</h2>
|
||||
|
||||
{#each section.blocks as block}
|
||||
{#if isParagraph(block)}
|
||||
<p>{getParagraphContent(block)}</p>
|
||||
{:else}
|
||||
<ul class="legal-list">
|
||||
{#each getListItems(block) as item}
|
||||
<li class:sub-item={isSubItem(item)}>{item}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/each}
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.legal-page {
|
||||
background: var(--off-white);
|
||||
}
|
||||
|
||||
.legal-inner {
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
padding: 0 50px;
|
||||
}
|
||||
|
||||
.legal-hero {
|
||||
padding: 72px 0 28px;
|
||||
}
|
||||
|
||||
.legal-hero h1 {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(34px, 4vw, 56px);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.legal-body {
|
||||
padding: 0 0 88px;
|
||||
}
|
||||
|
||||
.legal-card {
|
||||
padding: 40px 44px;
|
||||
border-radius: 32px;
|
||||
background: #fff;
|
||||
box-shadow: 0 14px 34px rgba(17, 20, 24, 0.05);
|
||||
}
|
||||
|
||||
.legal-section {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.legal-section + .legal-section {
|
||||
margin-top: 34px;
|
||||
padding-top: 34px;
|
||||
border-top: 1px solid rgba(17, 20, 24, 0.08);
|
||||
}
|
||||
|
||||
.legal-section h2 {
|
||||
margin: 0 0 16px;
|
||||
padding-left: 14px;
|
||||
border-left: 3px solid var(--green);
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(14px, 1.4vw, 17px);
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.01em;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.legal-section p {
|
||||
margin: 16px 0 0;
|
||||
color: #34363a;
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.legal-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.legal-list + .legal-list,
|
||||
.legal-section p + .legal-list,
|
||||
.legal-list + p {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.legal-list li {
|
||||
position: relative;
|
||||
padding-left: 18px;
|
||||
color: #34363a;
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.legal-list li::before {
|
||||
content: '–';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--green);
|
||||
font-size: 14px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.legal-list li.sub-item {
|
||||
padding-left: 34px;
|
||||
}
|
||||
|
||||
.legal-list li + li {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.legal-inner {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.legal-hero {
|
||||
padding: 56px 0 20px;
|
||||
}
|
||||
|
||||
.legal-hero h1 {
|
||||
font-size: 34px;
|
||||
}
|
||||
|
||||
.legal-body {
|
||||
padding: 0 0 64px;
|
||||
}
|
||||
|
||||
.legal-card {
|
||||
padding: 28px 22px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.legal-section + .legal-section {
|
||||
margin-top: 26px;
|
||||
padding-top: 26px;
|
||||
}
|
||||
|
||||
.legal-section h2 {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,316 @@
|
||||
<script lang="ts">
|
||||
import { reveal } from '$lib/actions/reveal';
|
||||
import BookingSection from '$lib/components/BookingSection.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import TestimonialsSection from '$lib/components/TestimonialsSection.svelte';
|
||||
import type { PricingPageContent, SiteSharedContent } from '$lib/types';
|
||||
|
||||
export let content: SiteSharedContent;
|
||||
export let pageContent: PricingPageContent;
|
||||
</script>
|
||||
|
||||
<main class="pricing-page">
|
||||
<section class="pricing-page-hero">
|
||||
<div class="pricing-inner">
|
||||
<h1>{pageContent.title}</h1>
|
||||
{#if pageContent.subtitle}
|
||||
<p class="pricing-page-sub">{pageContent.subtitle}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#each pageContent.sections as section}
|
||||
<section use:reveal class="pricing-section reveal-block">
|
||||
<div class="pricing-inner">
|
||||
<div class="pricing-section-heading">
|
||||
{#if section.icon}
|
||||
<div class="pricing-section-icon">
|
||||
<Icon name={section.icon} />
|
||||
</div>
|
||||
{/if}
|
||||
<h2>{section.title}</h2>
|
||||
{#if section.blurb}
|
||||
<p class="pricing-section-blurb">{section.blurb}</p>
|
||||
{/if}
|
||||
{#if section.detailCta}
|
||||
<a
|
||||
class={`btn pricing-section-link ${section.detailCta.variant === 'yellow' ? 'btn-yellow' : section.detailCta.variant === 'outline' ? 'btn-outline' : 'btn-green'}`}
|
||||
href={section.detailCta.href}
|
||||
>
|
||||
{section.detailCta.label}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class:pricing-plan-grid-three={section.plans.length === 3} class="pricing-plan-grid">
|
||||
{#each section.plans as plan}
|
||||
<article class:pricing-plan-popular={plan.popular} class="pricing-plan-card">
|
||||
{#if plan.popular}
|
||||
<span class="pricing-plan-ribbon">Popular</span>
|
||||
{/if}
|
||||
|
||||
<h3>{plan.title}</h3>
|
||||
<div class="pricing-plan-price">{plan.price}</div>
|
||||
<p class="pricing-plan-period">{plan.period}</p>
|
||||
|
||||
<ul class="pricing-plan-features">
|
||||
{#each plan.features as feature}
|
||||
<li>{feature}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<a class="btn btn-yellow pricing-plan-cta" href="#reservation">Book Now</a>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
<TestimonialsSection heading={pageContent.testimonialsHeading} testimonials={content.testimonials} />
|
||||
<BookingSection booking={pageContent.booking} />
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.pricing-page {
|
||||
background: var(--off-white);
|
||||
}
|
||||
|
||||
.pricing-inner {
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
padding: 0 50px;
|
||||
}
|
||||
|
||||
.pricing-page-hero {
|
||||
background: var(--green);
|
||||
padding: 56px 0 64px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pricing-page-hero h1 {
|
||||
margin: 0 0 12px;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(34px, 4vw, 56px);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pricing-page-sub {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.pricing-section-heading h2 {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(24px, 2.8vw, 36px);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.03em;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.pricing-section {
|
||||
padding: 48px 0 72px;
|
||||
}
|
||||
|
||||
.pricing-section-heading {
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pricing-section-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 16px;
|
||||
background: var(--green);
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.pricing-plan-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.pricing-section-blurb {
|
||||
max-width: 680px;
|
||||
margin: 14px auto 0;
|
||||
color: #4c5056;
|
||||
font-size: 17px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.pricing-section-link {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.pricing-plan-grid-three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.pricing-plan-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
border-radius: 28px;
|
||||
padding: 30px 26px;
|
||||
box-shadow: 0 14px 34px rgba(17, 20, 24, 0.05);
|
||||
transition:
|
||||
transform 0.18s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 0.22s ease,
|
||||
border-color 0.22s ease;
|
||||
}
|
||||
|
||||
.pricing-plan-popular {
|
||||
border: 2px solid var(--yellow);
|
||||
}
|
||||
|
||||
.pricing-plan-ribbon {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 18px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--yellow);
|
||||
color: #000;
|
||||
font-family: var(--font-head);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pricing-plan-card h3 {
|
||||
margin: 0;
|
||||
font-family: var(--font-head);
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.pricing-plan-price {
|
||||
margin-top: 22px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 52px;
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.05em;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.pricing-plan-period {
|
||||
margin: 10px 0 0;
|
||||
color: #5e6167;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.pricing-plan-features {
|
||||
width: 100%;
|
||||
margin: 24px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.pricing-plan-features li {
|
||||
padding: 15px 0;
|
||||
border-top: 1px solid rgba(17, 20, 24, 0.08);
|
||||
color: #34363a;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pricing-plan-cta {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.pricing-plan-card:hover {
|
||||
transform: translateY(-8px) scale(1.012);
|
||||
box-shadow: 0 22px 44px rgba(17, 20, 24, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.pricing-plan-card:active {
|
||||
transform: translateY(-2px) scale(0.992);
|
||||
}
|
||||
|
||||
:global(.reveal-ready.reveal-block) {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, var(--reveal-distance, 24px), 0);
|
||||
transition:
|
||||
opacity 0.55s ease,
|
||||
transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
transition-delay: var(--reveal-delay, 0ms);
|
||||
}
|
||||
|
||||
:global(.reveal-visible.reveal-block) {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.pricing-plan-grid,
|
||||
.pricing-plan-grid-three {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pricing-inner {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.pricing-page-hero {
|
||||
padding: 56px 0 20px;
|
||||
}
|
||||
|
||||
.pricing-page-hero h1 {
|
||||
font-size: 34px;
|
||||
}
|
||||
|
||||
.pricing-section-heading h2 {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.pricing-section {
|
||||
padding: 30px 0 52px;
|
||||
}
|
||||
|
||||
.pricing-section-heading {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pricing-section-blurb {
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.pricing-plan-grid,
|
||||
.pricing-plan-grid-three {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.pricing-plan-card {
|
||||
padding: 28px 22px;
|
||||
}
|
||||
|
||||
.pricing-plan-price {
|
||||
font-size: 46px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import type { PromiseContent } from '$lib/types';
|
||||
|
||||
export let promise: PromiseContent;
|
||||
</script>
|
||||
|
||||
<section id="promise">
|
||||
<div class="promise-inner">
|
||||
<div class="promise-text">
|
||||
<h2>
|
||||
{promise.title}<br />
|
||||
{promise.subtitle}
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
{promise.body}
|
||||
<strong>{promise.emphasis}</strong>
|
||||
</p>
|
||||
|
||||
<a href={promise.cta.href} class="btn btn-green">{promise.cta.label}</a>
|
||||
</div>
|
||||
|
||||
<div class="promise-img">
|
||||
<img src={promise.imageUrl} alt={promise.imageAlt} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
export let title: string;
|
||||
export let description: string;
|
||||
export let canonicalPath: string;
|
||||
export let image = '/images/auckland-dog-walking-happy-dog-hero.png';
|
||||
export let imageAlt = 'Goodwalk Auckland dog walking services';
|
||||
export let type = 'website';
|
||||
export let structuredData: Record<string, unknown>[] = [];
|
||||
export let noindex = false;
|
||||
export let preloadImage = false;
|
||||
|
||||
const siteName = 'Goodwalk';
|
||||
const siteUrl = 'https://www.goodwalk.co.nz';
|
||||
|
||||
function absoluteUrl(value: string) {
|
||||
if (!value) {
|
||||
return siteUrl;
|
||||
}
|
||||
|
||||
if (value.startsWith('http://') || value.startsWith('https://')) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return `${siteUrl}${value.startsWith('/') ? value : `/${value}`}`;
|
||||
}
|
||||
|
||||
function fullTitle(value: string) {
|
||||
return value.includes(siteName) ? value : `${value} | ${siteName}`;
|
||||
}
|
||||
|
||||
$: pageTitle = fullTitle(title);
|
||||
$: canonicalUrl = absoluteUrl(canonicalPath);
|
||||
$: imageUrl = absoluteUrl(image);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{pageTitle}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta
|
||||
name="robots"
|
||||
content={noindex
|
||||
? 'noindex, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1'
|
||||
: 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1'}
|
||||
/>
|
||||
<meta name="author" content="Goodwalk" />
|
||||
<meta name="publisher" content="Goodwalk" />
|
||||
<meta name="geo.region" content="NZ-AUK" />
|
||||
<meta name="geo.placename" content="Auckland Central" />
|
||||
<link rel="canonical" href={canonicalUrl} />
|
||||
{#if preloadImage}
|
||||
<link rel="preload" as="image" href={imageUrl} />
|
||||
{/if}
|
||||
<link rel="alternate" hreflang="en-NZ" href={canonicalUrl} />
|
||||
<link rel="alternate" hreflang="x-default" href={canonicalUrl} />
|
||||
|
||||
<meta property="og:type" content={type} />
|
||||
<meta property="og:url" content={canonicalUrl} />
|
||||
<meta property="og:site_name" content={siteName} />
|
||||
<meta property="og:locale" content="en_NZ" />
|
||||
<meta property="og:title" content={pageTitle} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={imageUrl} />
|
||||
<meta property="og:image:secure_url" content={imageUrl} />
|
||||
<meta property="og:image:alt" content={imageAlt} />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={pageTitle} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={imageUrl} />
|
||||
<meta name="twitter:image:alt" content={imageAlt} />
|
||||
|
||||
{#each structuredData as schema}
|
||||
{@html `<script type="application/ld+json">${JSON.stringify(schema)}</script>`}
|
||||
{/each}
|
||||
</svelte:head>
|
||||
@@ -0,0 +1,482 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import { reveal } from '$lib/actions/reveal';
|
||||
import BookingSection from '$lib/components/BookingSection.svelte';
|
||||
import TestimonialsSection from '$lib/components/TestimonialsSection.svelte';
|
||||
import type { ServicePageContent, SiteSharedContent } from '$lib/types';
|
||||
|
||||
export let content: SiteSharedContent;
|
||||
export let pageContent: ServicePageContent;
|
||||
</script>
|
||||
|
||||
<main class="service-page">
|
||||
<section class="service-hero">
|
||||
<div class="service-inner service-hero-grid">
|
||||
<div class="service-hero-copy">
|
||||
<p class="service-eyebrow">{pageContent.hero.eyebrow}</p>
|
||||
<h1>{pageContent.hero.title}</h1>
|
||||
|
||||
{#each pageContent.hero.paragraphs as paragraph}
|
||||
<p>{paragraph}</p>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="service-hero-media">
|
||||
<img
|
||||
src={pageContent.hero.imageUrl}
|
||||
alt={pageContent.hero.imageAlt}
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if pageContent.highlight}
|
||||
<section use:reveal class="service-highlight reveal-block">
|
||||
<div class="service-inner service-highlight-copy">
|
||||
<p class="service-highlight-eyebrow">{pageContent.highlight.eyebrow}</p>
|
||||
<h2>{pageContent.highlight.title}</h2>
|
||||
</div>
|
||||
|
||||
<div class="service-inner">
|
||||
<div class="service-highlight-image">
|
||||
<img
|
||||
src={pageContent.highlight.imageUrl}
|
||||
alt={pageContent.highlight.imageAlt}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section use:reveal class="service-pricing reveal-block">
|
||||
<div class="service-inner">
|
||||
<div class="service-section-heading">
|
||||
<h2>{pageContent.pricing.title}</h2>
|
||||
{#if pageContent.pricing.intro}
|
||||
<p>{pageContent.pricing.intro}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class:service-plan-grid-three={pageContent.pricing.plans.length === 3} class="service-plan-grid">
|
||||
{#each pageContent.pricing.plans as plan}
|
||||
<article class:service-plan-popular={plan.popular} class="service-plan-card">
|
||||
{#if plan.popular}
|
||||
<span class="service-plan-ribbon">Popular</span>
|
||||
{/if}
|
||||
|
||||
<h3>{plan.title}</h3>
|
||||
<div class="service-plan-price">{plan.price}</div>
|
||||
<p class="service-plan-period">{plan.period}</p>
|
||||
|
||||
<ul class="service-plan-features">
|
||||
{#each plan.features as feature}
|
||||
<li>{feature}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<a class="btn btn-yellow service-plan-cta" href="#reservation">Book Now</a>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if pageContent.pricing.extras?.length}
|
||||
<div class="service-extras">
|
||||
<div class="service-extras-heading">Extras</div>
|
||||
{#each pageContent.pricing.extras as extra}
|
||||
<div class="service-extra-row">
|
||||
<span class="service-extra-label">
|
||||
{extra.label}
|
||||
{#if extra.note}
|
||||
<span class="service-extra-pill">{extra.note}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="service-extra-price">{extra.price}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section use:reveal class="service-benefits reveal-block">
|
||||
<div class="service-inner">
|
||||
<div class="service-section-heading">
|
||||
<h2>{pageContent.benefits.title}</h2>
|
||||
</div>
|
||||
|
||||
<div class="service-benefit-grid">
|
||||
{#each pageContent.benefits.items as benefit}
|
||||
<article class="service-benefit-card">
|
||||
<div class="service-benefit-icon" aria-hidden="true">
|
||||
<Icon name="fas fa-paw" />
|
||||
</div>
|
||||
<h3>{benefit.title}</h3>
|
||||
<p>{benefit.body}</p>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<TestimonialsSection heading={pageContent.testimonialsHeading} testimonials={content.testimonials} />
|
||||
<BookingSection booking={pageContent.booking} />
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.service-page {
|
||||
background: var(--off-white);
|
||||
}
|
||||
|
||||
.service-inner {
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
padding: 0 50px;
|
||||
}
|
||||
|
||||
.service-hero {
|
||||
padding: 72px 0 96px;
|
||||
}
|
||||
|
||||
.service-hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
|
||||
gap: 52px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.service-eyebrow {
|
||||
margin: 0 0 18px;
|
||||
color: var(--green);
|
||||
font-family: var(--font-head);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.service-hero-copy h1,
|
||||
.service-section-heading h2,
|
||||
.service-highlight-copy h2 {
|
||||
margin: 0;
|
||||
font-family: var(--font-head);
|
||||
font-size: clamp(34px, 4vw, 56px);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.service-hero-copy p,
|
||||
.service-section-heading p,
|
||||
.service-benefit-card p {
|
||||
margin: 20px 0 0;
|
||||
max-width: 680px;
|
||||
color: #34363a;
|
||||
font-size: 17px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.service-hero-media img,
|
||||
.service-highlight-image img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: 28px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 16px 40px rgba(17, 20, 24, 0.08);
|
||||
}
|
||||
|
||||
.service-highlight {
|
||||
padding: 0 0 96px;
|
||||
}
|
||||
|
||||
.service-highlight-copy {
|
||||
text-align: center;
|
||||
margin-bottom: 34px;
|
||||
}
|
||||
|
||||
.service-highlight-eyebrow {
|
||||
margin: 0 0 16px;
|
||||
color: var(--yellow);
|
||||
font-family: var(--font-head);
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.service-highlight-image img {
|
||||
max-height: 620px;
|
||||
}
|
||||
|
||||
.service-pricing,
|
||||
.service-benefits {
|
||||
padding: 0 0 96px;
|
||||
}
|
||||
|
||||
.service-section-heading {
|
||||
text-align: center;
|
||||
margin-bottom: 38px;
|
||||
}
|
||||
|
||||
.service-section-heading p {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.service-plan-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.service-plan-grid-three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.service-plan-card,
|
||||
.service-benefit-card {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-radius: 28px;
|
||||
padding: 30px 26px;
|
||||
box-shadow: 0 14px 34px rgba(17, 20, 24, 0.05);
|
||||
transition:
|
||||
transform 0.18s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 0.22s ease,
|
||||
border-color 0.22s ease;
|
||||
}
|
||||
|
||||
.service-plan-popular {
|
||||
border: 2px solid var(--yellow);
|
||||
}
|
||||
|
||||
.service-plan-ribbon {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 18px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--yellow);
|
||||
color: #000;
|
||||
font-family: var(--font-head);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.service-plan-card:hover,
|
||||
.service-benefit-card:hover {
|
||||
transform: translateY(-8px) scale(1.012);
|
||||
box-shadow: 0 22px 44px rgba(17, 20, 24, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.service-plan-card:active,
|
||||
.service-benefit-card:active {
|
||||
transform: translateY(-2px) scale(0.992);
|
||||
}
|
||||
|
||||
:global(.reveal-ready.reveal-block) {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, var(--reveal-distance, 24px), 0);
|
||||
transition:
|
||||
opacity 0.55s ease,
|
||||
transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
transition-delay: var(--reveal-delay, 0ms);
|
||||
}
|
||||
|
||||
:global(.reveal-visible.reveal-block) {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
.service-plan-card h3,
|
||||
.service-benefit-card h3 {
|
||||
margin: 0;
|
||||
font-family: var(--font-head);
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.service-plan-price {
|
||||
margin-top: 20px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 44px;
|
||||
line-height: 1;
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.service-plan-period {
|
||||
margin: 8px 0 0;
|
||||
color: #5d6166;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.service-plan-features {
|
||||
margin: 24px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.service-plan-features li {
|
||||
position: relative;
|
||||
padding-left: 24px;
|
||||
color: #34363a;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.service-plan-features li + li {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.service-plan-features li::before {
|
||||
content: '•';
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 0;
|
||||
color: var(--yellow);
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.service-plan-cta {
|
||||
display: inline-flex;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.service-extras {
|
||||
margin-top: 30px;
|
||||
border-radius: 28px;
|
||||
background: #fff;
|
||||
box-shadow: 0 14px 34px rgba(17, 20, 24, 0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.service-extras-heading {
|
||||
padding: 18px 28px 14px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #9ca3af;
|
||||
border-bottom: 1px solid #ece9e3;
|
||||
}
|
||||
|
||||
.service-extra-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 18px 28px;
|
||||
color: #34363a;
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.service-extra-row + .service-extra-row {
|
||||
border-top: 1px solid #ece9e3;
|
||||
}
|
||||
|
||||
.service-extra-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.service-extra-pill {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.service-extra-price {
|
||||
font-family: var(--font-head);
|
||||
font-weight: 700;
|
||||
color: var(--green);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.service-benefit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.service-benefit-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
background: #efe4d1;
|
||||
color: var(--green);
|
||||
font-size: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.service-benefit-card p {
|
||||
margin-top: 14px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.service-hero-grid,
|
||||
.service-plan-grid,
|
||||
.service-plan-grid-three,
|
||||
.service-benefit-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.service-hero-grid {
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.service-inner {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.service-hero {
|
||||
padding: 48px 0 72px;
|
||||
}
|
||||
|
||||
.service-hero-grid,
|
||||
.service-plan-grid,
|
||||
.service-plan-grid-three,
|
||||
.service-benefit-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.service-highlight,
|
||||
.service-pricing,
|
||||
.service-benefits {
|
||||
padding-bottom: 72px;
|
||||
}
|
||||
|
||||
.service-highlight-eyebrow {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.service-extra-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { IconCard } from '$lib/types';
|
||||
|
||||
export let services: IconCard[];
|
||||
|
||||
</script>
|
||||
|
||||
<section id="services">
|
||||
<div class="services-inner">
|
||||
<h2 class="section-heading">What we do</h2>
|
||||
|
||||
<div class="services-grid">
|
||||
{#each services as service}
|
||||
<div class="service-card">
|
||||
<div class="service-icon-bubble">
|
||||
<Icon name={service.icon} className="service-card-icon" />
|
||||
</div>
|
||||
<h3>{service.title}</h3>
|
||||
<p>{service.body}</p>
|
||||
|
||||
{#if service.href}
|
||||
<a href={service.href} class="btn btn-green">Learn more</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,202 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import confetti from 'canvas-confetti';
|
||||
|
||||
export let firstName: string;
|
||||
export let petName: string;
|
||||
export let email: string;
|
||||
export let onClose: () => void;
|
||||
|
||||
onMount(() => {
|
||||
const duration = 3200;
|
||||
const end = Date.now() + duration;
|
||||
|
||||
const frame = () => {
|
||||
confetti({
|
||||
particleCount: 3,
|
||||
angle: 60,
|
||||
spread: 65,
|
||||
origin: { x: 0, y: 0.75 },
|
||||
colors: ['#FFD100', '#213021', '#ffffff', '#7aaa7a', '#ffeaa0'],
|
||||
gravity: 0.9,
|
||||
scalar: 1.1,
|
||||
});
|
||||
confetti({
|
||||
particleCount: 3,
|
||||
angle: 120,
|
||||
spread: 65,
|
||||
origin: { x: 1, y: 0.75 },
|
||||
colors: ['#FFD100', '#213021', '#ffffff', '#7aaa7a', '#ffeaa0'],
|
||||
gravity: 0.9,
|
||||
scalar: 1.1,
|
||||
});
|
||||
|
||||
if (Date.now() < end) {
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
};
|
||||
|
||||
frame();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Booking confirmed"
|
||||
on:click|self={onClose}
|
||||
on:keydown={(e) => e.key === 'Escape' && onClose()}
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="modal-card">
|
||||
<button class="modal-close" type="button" aria-label="Close" on:click={onClose}>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<div class="modal-paw" aria-hidden="true">🐾</div>
|
||||
|
||||
<h2 class="modal-heading">You’re on our radar!</h2>
|
||||
|
||||
<p class="modal-body">
|
||||
Thanks, <strong>{firstName}</strong>! We’ve sent a confirmation to
|
||||
<strong>{email}</strong> and Aless will be in touch soon to arrange a
|
||||
Meet & Greet with <strong>{petName}</strong>.
|
||||
</p>
|
||||
|
||||
<div class="modal-divider"></div>
|
||||
|
||||
<p class="modal-sub">
|
||||
In the meantime, feel free to follow along on Instagram for daily walks and happy dogs.
|
||||
</p>
|
||||
|
||||
<button class="modal-btn" type="button" on:click={onClose}>
|
||||
Sounds great!
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
background: rgba(10, 20, 10, 0.55);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
animation: backdrop-in 0.25s ease;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
padding: 52px 48px 44px;
|
||||
background: #fff;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 24px 80px rgba(10, 20, 10, 0.22);
|
||||
text-align: center;
|
||||
animation: card-in 0.35s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 20px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: #f2f2f0;
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: #e8e8e4;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-paw {
|
||||
font-size: 52px;
|
||||
line-height: 1;
|
||||
margin-bottom: 20px;
|
||||
animation: bounce-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) 0.15s both;
|
||||
}
|
||||
|
||||
.modal-heading {
|
||||
margin: 0 0 14px;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #213021;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: #555;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.modal-divider {
|
||||
width: 48px;
|
||||
height: 3px;
|
||||
background: #FFD100;
|
||||
border-radius: 999px;
|
||||
margin: 28px auto;
|
||||
}
|
||||
|
||||
.modal-sub {
|
||||
margin: 0 0 32px;
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.modal-btn {
|
||||
display: inline-block;
|
||||
padding: 14px 36px;
|
||||
background: #213021;
|
||||
color: #FFD100;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.modal-btn:hover {
|
||||
background: #2e4a2e;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.modal-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@keyframes backdrop-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes card-in {
|
||||
from { opacity: 0; transform: scale(0.88) translateY(16px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes bounce-in {
|
||||
from { opacity: 0; transform: scale(0.4); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,623 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { reveal } from '$lib/actions/reveal';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { TestimonialContent } from '$lib/types';
|
||||
|
||||
export let testimonials: TestimonialContent[];
|
||||
export let heading = 'Why people choose us!';
|
||||
export let blurb =
|
||||
'Real dogs, real routines, real happy humans. Follow along on Instagram to see the Tiny Gang out on their daily adventures.';
|
||||
export let instagramHref = 'https://www.instagram.com/goodwalk.nz/';
|
||||
export let instagramLabel = '@goodwalk.nz';
|
||||
|
||||
type TestimonialSlide = TestimonialContent & { imageUrl: string };
|
||||
|
||||
const wordpressTestimonials: Record<string, TestimonialSlide> = {
|
||||
Kate: {
|
||||
reviewer: 'Kate',
|
||||
detail: "Archie's mum",
|
||||
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.',
|
||||
imageUrl: '/images/archie-auckland-dog-walking-review.png'
|
||||
},
|
||||
Estelle: {
|
||||
reviewer: 'Estelle',
|
||||
detail: "Monty's mum",
|
||||
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!',
|
||||
imageUrl: '/images/monty-auckland-dog-walking-review.png'
|
||||
},
|
||||
Ross: {
|
||||
reviewer: 'Ross',
|
||||
detail: "Otis's Dad",
|
||||
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!',
|
||||
imageUrl: '/images/otis-auckland-dog-walking-review.png'
|
||||
},
|
||||
Nina: {
|
||||
reviewer: 'Nina',
|
||||
detail: "Wallace's mum",
|
||||
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',
|
||||
imageUrl: '/images/wallace-auckland-dog-walking-review.png'
|
||||
}
|
||||
};
|
||||
|
||||
let activeIndex = 0;
|
||||
let paused = false;
|
||||
|
||||
$: slides = testimonials
|
||||
.map((testimonial) => wordpressTestimonials[testimonial.reviewer] ?? testimonial)
|
||||
.filter((testimonial): testimonial is TestimonialSlide => Boolean(testimonial.imageUrl));
|
||||
|
||||
$: if (activeIndex >= slides.length) {
|
||||
activeIndex = 0;
|
||||
}
|
||||
|
||||
function showPrevious() {
|
||||
if (!slides.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeIndex = (activeIndex - 1 + slides.length) % slides.length;
|
||||
}
|
||||
|
||||
function showNext() {
|
||||
if (!slides.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeIndex = (activeIndex + 1) % slides.length;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const interval = window.setInterval(() => {
|
||||
if (!paused && slides.length > 1) {
|
||||
showNext();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
|
||||
<section id="testimonials" use:reveal={{ delay: 40 }} class="reveal-block">
|
||||
<div class="testimonials-inner">
|
||||
<h2 class="section-heading">{heading}</h2>
|
||||
<div class="testimonials-intro">
|
||||
<p>{blurb}</p>
|
||||
<a href={instagramHref} target="_blank" rel="noopener" class="testimonials-instagram-link">
|
||||
<Icon name="fab fa-instagram" />
|
||||
<span>{instagramLabel}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if slides.length}
|
||||
<div
|
||||
class="testimonials-carousel"
|
||||
role="region"
|
||||
aria-label="Customer testimonials"
|
||||
on:mouseenter={() => (paused = true)}
|
||||
on:mouseleave={() => (paused = false)}
|
||||
>
|
||||
<button
|
||||
class="testimonial-arrow testimonial-arrow-left"
|
||||
type="button"
|
||||
aria-label="Previous testimonial"
|
||||
on:click={showPrevious}
|
||||
>
|
||||
<Icon name="fas fa-chevron-left" />
|
||||
</button>
|
||||
|
||||
<div class="testimonial-stage">
|
||||
<div class="testimonial-woof" aria-hidden="true">
|
||||
<span class="testimonial-woof-text">WOOF</span>
|
||||
<span class="testimonial-ray testimonial-ray-1"></span>
|
||||
<span class="testimonial-ray testimonial-ray-2"></span>
|
||||
<span class="testimonial-ray testimonial-ray-3"></span>
|
||||
</div>
|
||||
|
||||
{#each slides as testimonial, index}
|
||||
<article class:testimonial-slide-active={index === activeIndex} class="testimonial-slide">
|
||||
<div class="testimonial-photo-wrap">
|
||||
<div class="testimonial-photo-frame">
|
||||
{#if index === activeIndex}
|
||||
<img
|
||||
class="testimonial-photo"
|
||||
src={testimonial.imageUrl}
|
||||
alt={`${testimonial.reviewer}'s dog`}
|
||||
loading={activeIndex === 0 ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="testimonial-copy">
|
||||
<span class="testimonial-quote-mark">"</span>
|
||||
<h5>{testimonial.quote}</h5>
|
||||
<div class="testimonial-author">
|
||||
<span class="testimonial-author-name">{testimonial.reviewer}</span>
|
||||
<span class="testimonial-author-detail">{testimonial.detail}</span>
|
||||
</div>
|
||||
|
||||
<div class="testimonial-divider"></div>
|
||||
|
||||
<a
|
||||
class="testimonial-google"
|
||||
href="https://g.page/r/CUsvrWPhkYrAEB0/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<Icon name="fab fa-google" />
|
||||
<span>All 5 star reviews on Google!</span>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="testimonial-arrow testimonial-arrow-right"
|
||||
type="button"
|
||||
aria-label="Next testimonial"
|
||||
on:click={showNext}
|
||||
>
|
||||
<Icon name="fas fa-chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.testimonials-intro {
|
||||
max-width: 760px;
|
||||
margin: 18px auto 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.testimonials-intro p {
|
||||
margin: 0;
|
||||
color: #4c5056;
|
||||
font-size: 17px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.testimonials-instagram-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 999px;
|
||||
background: rgba(33, 48, 33, 0.06);
|
||||
color: var(--green);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
box-shadow: inset 0 0 0 1px rgba(17, 20, 24, 0.06);
|
||||
transition:
|
||||
transform 0.16s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
background 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
:global(.testimonials-instagram-link .icon) {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.testimonials-instagram-link:hover {
|
||||
transform: translateY(-2px);
|
||||
background: rgba(33, 48, 33, 0.09);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(17, 20, 24, 0.06),
|
||||
0 10px 22px rgba(17, 20, 24, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.testimonials-instagram-link:active {
|
||||
transform: translateY(1px) scale(0.985);
|
||||
}
|
||||
|
||||
.testimonials-carousel {
|
||||
position: relative;
|
||||
margin-top: 48px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.testimonials-intro {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.testimonials-intro p {
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.testimonials-instagram-link {
|
||||
margin-top: 14px;
|
||||
padding: 9px 14px;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.testimonial-arrow {
|
||||
transition:
|
||||
transform 0.16s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 0.2s ease,
|
||||
background 0.2s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.testimonial-arrow:hover {
|
||||
transform: translateY(-50%) scale(1.05);
|
||||
box-shadow: 0 14px 28px rgba(17, 20, 24, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.testimonial-arrow:active {
|
||||
transform: translateY(-50%) scale(0.95);
|
||||
}
|
||||
|
||||
:global(.reveal-ready.reveal-block) {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, var(--reveal-distance, 24px), 0);
|
||||
transition:
|
||||
opacity 0.55s ease,
|
||||
transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
transition-delay: var(--reveal-delay, 0ms);
|
||||
}
|
||||
|
||||
:global(.reveal-visible.reveal-block) {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
.testimonial-stage {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 24px;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 30px rgba(20, 24, 20, 0.06);
|
||||
min-height: 620px;
|
||||
}
|
||||
|
||||
.testimonial-slide {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 45% 55%;
|
||||
align-items: stretch;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.35s ease,
|
||||
transform 0.35s ease;
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.testimonial-slide-active {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.testimonial-photo-wrap {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 32px 24px 0 24px;
|
||||
}
|
||||
|
||||
.testimonial-photo-frame {
|
||||
width: min(100%, 340px);
|
||||
}
|
||||
|
||||
.testimonial-photo {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.testimonial-copy {
|
||||
align-self: start;
|
||||
padding: 118px 112px 76px 10px;
|
||||
}
|
||||
|
||||
.testimonial-quote-mark {
|
||||
display: block;
|
||||
font-family: Georgia, serif;
|
||||
font-size: 72px;
|
||||
line-height: 0.6;
|
||||
color: var(--yellow);
|
||||
margin-bottom: 20px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.testimonial-copy h5 {
|
||||
max-width: 500px;
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
letter-spacing: 0;
|
||||
color: #2e3031;
|
||||
}
|
||||
|
||||
.testimonial-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.testimonial-author-name {
|
||||
font-family: var(--font-head);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.testimonial-author-detail {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.testimonial-author-detail::before {
|
||||
content: '—';
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.testimonial-divider {
|
||||
width: 100%;
|
||||
max-width: 690px;
|
||||
height: 1px;
|
||||
margin: 44px 0 0;
|
||||
background: #e7e7e7;
|
||||
}
|
||||
|
||||
.testimonial-google {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 28px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 999px;
|
||||
background: #f8f8f8;
|
||||
color: #0a304e;
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
box-shadow: 0 0 0 1px rgba(10, 48, 78, 0.06);
|
||||
}
|
||||
|
||||
.testimonial-google :global(.icon) {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.testimonial-google:hover {
|
||||
background: #efe6d5;
|
||||
}
|
||||
|
||||
.testimonial-woof {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
right: 60px;
|
||||
z-index: 2;
|
||||
color: #2e3031;
|
||||
transform: rotate(-6deg);
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.testimonial-woof-text {
|
||||
display: inline-block;
|
||||
font-family: 'Fredoka One', var(--font-head), sans-serif;
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.testimonial-ray {
|
||||
position: absolute;
|
||||
border-radius: 999px;
|
||||
background: #ffd100;
|
||||
}
|
||||
|
||||
.testimonial-ray-1 {
|
||||
top: -12px;
|
||||
right: -48px;
|
||||
width: 32px;
|
||||
height: 11px;
|
||||
transform: rotate(-35deg);
|
||||
}
|
||||
|
||||
.testimonial-ray-2 {
|
||||
top: 6px;
|
||||
right: -60px;
|
||||
width: 46px;
|
||||
height: 13px;
|
||||
transform: rotate(-35deg);
|
||||
}
|
||||
|
||||
.testimonial-ray-3 {
|
||||
top: 24px;
|
||||
right: -50px;
|
||||
width: 36px;
|
||||
height: 11px;
|
||||
transform: rotate(-35deg);
|
||||
}
|
||||
|
||||
.testimonial-arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
z-index: 3;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #111;
|
||||
font-size: 22px;
|
||||
transform: translateY(-50%);
|
||||
box-shadow: 0 12px 28px rgba(20, 24, 20, 0.07);
|
||||
}
|
||||
|
||||
.testimonial-arrow:hover {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.testimonial-arrow-left {
|
||||
left: -38px;
|
||||
}
|
||||
|
||||
.testimonial-arrow-right {
|
||||
right: -38px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.testimonial-stage {
|
||||
min-height: 560px;
|
||||
}
|
||||
|
||||
.testimonial-photo-wrap {
|
||||
padding: 88px 16px 64px 44px;
|
||||
}
|
||||
|
||||
.testimonial-copy {
|
||||
padding: 96px 72px 64px 8px;
|
||||
}
|
||||
|
||||
.testimonial-copy h5 {
|
||||
max-width: 460px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.testimonial-woof {
|
||||
right: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.testimonials-carousel {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.testimonial-stage {
|
||||
min-height: unset;
|
||||
padding-bottom: 116px;
|
||||
}
|
||||
|
||||
.testimonial-slide {
|
||||
position: relative;
|
||||
display: none;
|
||||
grid-template-columns: 1fr;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.testimonial-slide-active {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.testimonial-photo-wrap {
|
||||
justify-content: center;
|
||||
padding: 48px 22px 16px;
|
||||
}
|
||||
|
||||
.testimonial-photo-frame {
|
||||
width: min(100%, 220px);
|
||||
}
|
||||
|
||||
.testimonial-photo {
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.testimonial-copy {
|
||||
padding: 8px 28px 32px;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.testimonial-quote-mark {
|
||||
font-size: 44px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.testimonial-copy h5 {
|
||||
font-size: 16px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.testimonial-divider {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.testimonial-google {
|
||||
margin-top: 28px;
|
||||
font-size: 16px;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.testimonial-google :global(.icon) {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.testimonial-woof {
|
||||
top: 24px;
|
||||
right: 22px;
|
||||
}
|
||||
|
||||
.testimonial-woof-text {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.testimonial-ray {
|
||||
right: -28px;
|
||||
width: 9px;
|
||||
}
|
||||
|
||||
.testimonial-ray-1 {
|
||||
top: -7px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.testimonial-ray-2 {
|
||||
top: 10px;
|
||||
right: -38px;
|
||||
width: 34px;
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
.testimonial-ray-3 {
|
||||
top: 35px;
|
||||
right: -28px;
|
||||
width: 27px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.testimonial-arrow {
|
||||
top: auto;
|
||||
bottom: 24px;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
font-size: 20px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.testimonial-arrow-left {
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.testimonial-arrow-right {
|
||||
right: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
import { fireEvent, render } from '@testing-library/svelte';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import TestimonialsSection from './TestimonialsSection.svelte';
|
||||
import { homepageContent } from '$lib/content/homepage';
|
||||
|
||||
describe('TestimonialsSection', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses the mapped local image assets for known testimonials', () => {
|
||||
const { container } = render(TestimonialsSection, {
|
||||
testimonials: homepageContent.testimonials
|
||||
});
|
||||
|
||||
const activeImage = container.querySelector('.testimonial-slide-active img') as HTMLImageElement;
|
||||
|
||||
expect(activeImage.getAttribute('src')).toBe('/images/archie-auckland-dog-walking-review.jpg');
|
||||
});
|
||||
|
||||
it('moves to the next testimonial on arrow click and auto-rotation', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const { container } = render(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;
|
||||
|
||||
expect(activeReviewer()).toBe('Kate');
|
||||
|
||||
await fireEvent.click(nextButton);
|
||||
expect(activeReviewer()).toBe('Estelle');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(activeReviewer()).toBe('Ross');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { IconCard } from '$lib/types';
|
||||
|
||||
export let values: IconCard[];
|
||||
</script>
|
||||
|
||||
<section id="values">
|
||||
<div class="values-inner">
|
||||
<h2 class="section-heading">Where dogs come first</h2>
|
||||
|
||||
<div class="values-grid">
|
||||
{#each values as value}
|
||||
<div class="value-card">
|
||||
<Icon name={value.icon} className="value-card-icon" />
|
||||
<h3>{value.title}</h3>
|
||||
<p>{value.body}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { AboutPageContent } from '$lib/types';
|
||||
|
||||
export const aboutPageContent: AboutPageContent = {
|
||||
title: 'About Us',
|
||||
sections: [
|
||||
{
|
||||
title: 'Who we are',
|
||||
body: [
|
||||
"At GoodWalk, we're not your average dog walking service. We're a team of passionate dog lovers dedicated to providing top-notch care for your furry friends. Specializing in small dogs, we understand their unique needs firsthand, being small dog owners ourselves!",
|
||||
"Our commitment to excellence has quickly made us a leader in Auckland Central's dog-walking scene. From pack walks to one-on-one sessions, we ensure the happiness and well-being of every dog in our care."
|
||||
],
|
||||
imageUrl: '/images/auckland-pack-walk-dog.jpg',
|
||||
imageAlt: 'Dog on a Goodwalk pack walk'
|
||||
},
|
||||
{
|
||||
title: 'Our impact',
|
||||
body: [
|
||||
"At GoodWalk, we believe in positive reinforcement training to help your dog thrive in the world. Safety, professionalism, well-being, fun, structure, and compassion are the cornerstones of our business ethos.",
|
||||
"When you choose GoodWalk, you're choosing a partner who will treat your dog like family, because that's exactly what they are to us."
|
||||
],
|
||||
imageUrl: '/images/auckland-dog-group-outing.jpg',
|
||||
imageAlt: 'Goodwalk dogs enjoying an outing together',
|
||||
reverse: true,
|
||||
accent: 'gradient'
|
||||
},
|
||||
{
|
||||
title: 'Meet the team',
|
||||
body: [
|
||||
'Behind GoodWalk is Alessandra, an Italian who has a deep passion for dogs. With her love for animals and years of experience, Alessandra leads our team with dedication and expertise, ensuring that every dog receives the love and attention they deserve.',
|
||||
"And let's not forget about Maya, our marketing manager! A Cavalier King Charles cross Shih Tzu, Maya is full of sass and personality, bringing a touch of charm and flair to everything we do."
|
||||
],
|
||||
imageUrl: '/images/goodwalk-dog-walker-alessandra.png',
|
||||
imageAlt: 'Goodwalk staff member Aless'
|
||||
}
|
||||
],
|
||||
servicesTitle: 'Explore our services',
|
||||
contact: {
|
||||
title: "Let's get started!",
|
||||
email: 'info@goodwalk.co.nz',
|
||||
phone: '(022) 642 1011',
|
||||
cta: {
|
||||
label: 'Contact us',
|
||||
href: '/booking',
|
||||
variant: 'yellow'
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { ServicePageContent } from '$lib/types';
|
||||
|
||||
export const dogWalkingContent: ServicePageContent = {
|
||||
hero: {
|
||||
eyebrow: '1:1 Walks',
|
||||
title: 'Walks for larger breeds, too!',
|
||||
paragraphs: [
|
||||
'Looking for personal attention for your big pup? Our professional dog walking service is perfect for larger dogs who walk well on leash and have good recall. Safety is paramount, so we only accommodate dogs with no reactivity issues. At Goodwalk, we pride ourselves on building relationships with both you and your furry family member.',
|
||||
"Give your dog his best life while joining our growing community of happy pet parents! For those seeking extra care, we offer specialized one-on-one walks tailored to your dog's individual needs and personality"
|
||||
],
|
||||
imageUrl:
|
||||
'/images/auckland-large-dog-one-on-one-walk.jpg',
|
||||
imageAlt: 'Large breed dog enjoying a Goodwalk one on one dog walk'
|
||||
},
|
||||
highlight: {
|
||||
eyebrow: '▼・ᴥ・▼',
|
||||
title: 'Personalized adventures for your dog!',
|
||||
imageUrl: '/images/auckland-dogs-outdoor-pack.jpg',
|
||||
imageAlt: 'Goodwalk dogs gathered together outdoors'
|
||||
},
|
||||
pricing: {
|
||||
title: '1:1 Large Dog Breed Prices',
|
||||
plans: [
|
||||
{
|
||||
title: '30 Minutes',
|
||||
price: '$45',
|
||||
period: 'Per Walk',
|
||||
features: ['Free pickup/dropoff', '30 minute walk', 'Social media updates', 'Basic training']
|
||||
},
|
||||
{
|
||||
title: '45 Minutes',
|
||||
price: '$55',
|
||||
period: 'Per Walk',
|
||||
popular: true,
|
||||
features: ['Free pickup/dropoff', '45 minute walk', 'Social media updates', 'Basic training']
|
||||
},
|
||||
{
|
||||
title: '60 Minutes',
|
||||
price: '$65',
|
||||
period: 'Per Walk',
|
||||
features: ['Free pickup/dropoff', '60 minute walk', 'Social media updates', 'Basic training']
|
||||
}
|
||||
]
|
||||
},
|
||||
benefits: {
|
||||
title: 'Benefits of our 1:1 walks',
|
||||
items: [
|
||||
{
|
||||
title: 'Individualized Attention',
|
||||
body: 'Large breeds receive personalized care and undivided attention from the walker, addressing their unique needs and preferences without competition from other dogs.'
|
||||
},
|
||||
{
|
||||
title: 'Tailored Exercise',
|
||||
body: 'Walkers can customize the pace, duration, and route of the walk to accommodate the energy levels and physical abilities of large breeds, providing an appropriate level of exercise tailored to their specific needs.'
|
||||
},
|
||||
{
|
||||
title: 'Bonding and Socialization',
|
||||
body: 'During one-on-one walks, large breeds bond closely with their walker and socialize with people and animals encountered, promoting confidence and social skills'
|
||||
},
|
||||
{
|
||||
title: 'Enhanced safety',
|
||||
body: "With one-on-one walks, there's reduced risk of potential conflicts or incidents that may arise in group settings, ensuring a safer walking experience for large breeds."
|
||||
},
|
||||
{
|
||||
title: 'Training Opportunities',
|
||||
body: 'One-on-one walks offer dedicated time for training and reinforcement of good behaviors, such as loose leash walking or obedience commands, helping large breeds develop and maintain positive habits.'
|
||||
},
|
||||
{
|
||||
title: 'Stress Reduction',
|
||||
body: 'Large breeds may feel more relaxed and comfortable during one-on-one walks, as they can explore and enjoy their surroundings without the potential stressors of a group dynamic, leading to a more positive walking experience overall.'
|
||||
}
|
||||
]
|
||||
},
|
||||
testimonialsHeading: 'What our clients say',
|
||||
booking: {
|
||||
title: "Let's meet!",
|
||||
subtitle: 'Fill out your details below, and we can arrange a Meet & Greet for a one on one walk!',
|
||||
formAction: '/booking',
|
||||
serviceOptions: [],
|
||||
ownerStepLabel: 'Your details',
|
||||
dogStepLabel: 'Your dog',
|
||||
dogIntro: 'Tell us about your dog, your area, and anything important we should know before arranging a one on one Meet & Greet.'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { HomePageContent } from '$lib/types';
|
||||
|
||||
export const homepageContent: HomePageContent = {
|
||||
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: 'Contact Us', href: '/booking', variant: 'yellow' },
|
||||
instagram: { href: 'https://www.instagram.com/goodwalk.nz/', external: true },
|
||||
megaMenuServices: [
|
||||
{ icon: 'fas fa-paw', label: 'Pack Walks', description: 'Group outdoor adventures', href: '/pack-walks' },
|
||||
{ icon: 'fas fa-person-walking', label: '1:1 Walks', description: 'Personalised solo walks', href: '/dog-walking' },
|
||||
{ icon: 'fas fa-dog', label: 'Puppy Visits', description: 'Home visits for young pups', href: '/puppy-visits' }
|
||||
]
|
||||
},
|
||||
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: '#reservation', variant: 'outline' },
|
||||
imageUrl: '/images/auckland-dog-walking-happy-dog-hero.png',
|
||||
imageAlt: 'Happy dog ready for a professional pack walk with Goodwalk Auckland dog walking service'
|
||||
},
|
||||
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: '#reservation', 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: '#reservation' },
|
||||
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: '© 2026 Goodwalk',
|
||||
email: 'info@goodwalk.co.nz',
|
||||
phone: '(022) 642 1011'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { PricingPageContent } from '$lib/types';
|
||||
import { dogWalkingContent } from './dog-walking';
|
||||
import { packWalksContent } from './pack-walks';
|
||||
import { puppyVisitsContent } from './puppy-visits';
|
||||
|
||||
export const ourPricingContent: PricingPageContent = {
|
||||
title: 'Our Pricing',
|
||||
subtitle: 'Simple, transparent pricing — no lock-in contracts.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Pack Walks',
|
||||
icon: 'fas fa-paw',
|
||||
blurb:
|
||||
'Small group adventures for calm, social dogs who thrive with structure, play, and regular weekly outings.',
|
||||
detailCta: {
|
||||
label: 'View Pack Walks',
|
||||
href: '/pack-walks',
|
||||
variant: 'green'
|
||||
},
|
||||
plans: packWalksContent.pricing.plans
|
||||
},
|
||||
{
|
||||
title: '1:1 Walks',
|
||||
icon: 'fas fa-person-walking',
|
||||
blurb:
|
||||
'One-on-one walks tailored to your dog’s pace, confidence, and personality for a more focused outing.',
|
||||
detailCta: {
|
||||
label: 'View 1:1 Walks',
|
||||
href: '/dog-walking',
|
||||
variant: 'green'
|
||||
},
|
||||
plans: dogWalkingContent.pricing.plans
|
||||
},
|
||||
{
|
||||
title: 'Puppy Visits',
|
||||
icon: 'fas fa-dog',
|
||||
blurb:
|
||||
'Short home visits for young pups who need company, enrichment, toilet breaks, and gentle routine support.',
|
||||
detailCta: {
|
||||
label: 'View Puppy Visits',
|
||||
href: '/puppy-visits',
|
||||
variant: 'green'
|
||||
},
|
||||
plans: puppyVisitsContent.pricing.plans
|
||||
}
|
||||
],
|
||||
testimonialsHeading: 'What our clients say',
|
||||
booking: {
|
||||
title: 'Ready to join the Tiny Gang?',
|
||||
subtitle: '',
|
||||
formAction: '/booking',
|
||||
serviceOptions: [],
|
||||
ownerStepLabel: 'Your details',
|
||||
dogStepLabel: 'Dog details',
|
||||
dogIntro:
|
||||
'Tell us about your dog, where you are based, and anything important we should know before we arrange a Meet & Greet.'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ServicePageContent } from '$lib/types';
|
||||
|
||||
export const packWalksContent: ServicePageContent = {
|
||||
hero: {
|
||||
eyebrow: 'Pack Walks',
|
||||
title: 'Join our Tiny Gang!',
|
||||
paragraphs: [
|
||||
'Fun, safe, and specially designed for little paws, these adventures help your dog build friendships and confidence in a calm, friendly group.',
|
||||
'We only welcome sociable dogs, so every outing feels secure and stress-free. As small dog owners ourselves, we know just what it takes to help your pup feel relaxed, happy, and right at home.',
|
||||
'Join the Tiny Gang today—because your dog deserves more than just a walk. They deserve a tail-wagging good time!'
|
||||
],
|
||||
imageUrl: '/images/auckland-small-dog-pack-walk.jpg',
|
||||
imageAlt: 'Small dogs together on a Goodwalk Tiny Gang pack walk'
|
||||
},
|
||||
highlight: {
|
||||
eyebrow: '▼・ᴥ・▼',
|
||||
title: 'Goodwalk is the best choice for small and medium size dogs!',
|
||||
imageUrl: '/images/tiny-gang-auckland-dog-pack.jpg',
|
||||
imageAlt: 'Goodwalk Tiny Gang dogs gathered together in Auckland'
|
||||
},
|
||||
pricing: {
|
||||
title: 'Tiny Gang Prices',
|
||||
intro:
|
||||
'Our pack walks are a permanent booking of at least one walk day a week. Our Tiny Gang pack outing typically lasts 2 hours or more, including a one-hour walk at one of Auckland’s scenic dog parks or beaches. Additionally, pick-up and drop-off services are provided for your convenience. We assist in reinforcing basic training, including recall, car manners, and leash etiquette. Gift your dog the best life!',
|
||||
plans: [
|
||||
{
|
||||
title: '1 Walk',
|
||||
price: '$58',
|
||||
period: 'Per Walk',
|
||||
features: ['Free pickup/dropoff', '1 hour adventure', 'Social media updates', 'Basic training']
|
||||
},
|
||||
{
|
||||
title: '2-3 Walks',
|
||||
price: '$55',
|
||||
period: 'Per Walk',
|
||||
popular: true,
|
||||
features: ['Free pickup/dropoff', '1 hour adventure', 'Social media updates', 'Basic training']
|
||||
},
|
||||
{
|
||||
title: '4-5 Walks',
|
||||
price: '$49.50',
|
||||
period: 'Per Walk',
|
||||
features: ['Free pickup/dropoff', '1 hour adventure', 'Social media updates', 'Basic training']
|
||||
},
|
||||
{
|
||||
title: 'Casual Walk',
|
||||
price: '$65',
|
||||
period: 'Per Walk',
|
||||
features: ['Free pickup/dropoff', '1 hour adventure', 'Social media updates', 'Basic training']
|
||||
}
|
||||
],
|
||||
extras: [
|
||||
{ label: 'Extra Dog', note: 'From same household', price: '$35' },
|
||||
{ label: 'Muddy Wash', price: '$35' },
|
||||
{ label: '5 Hour Day Out', note: 'Not suitable for all dogs', price: '$90' }
|
||||
]
|
||||
},
|
||||
benefits: {
|
||||
title: 'Tiny Gang membership benefits',
|
||||
items: [
|
||||
{
|
||||
title: 'Socialization with other dogs',
|
||||
body: 'Tiny Gang pack walks help small and medium-sized dogs mingle and learn social skills from each other, boosting their confidence and positive behavior.'
|
||||
},
|
||||
{
|
||||
title: 'Taliored peace',
|
||||
body: 'Our handlers can adjust the pace and intensity of the walk to suit the energy levels and abilities of small and medium-sized dogs, ensuring a pleasant and enjoyable experience for all participants.'
|
||||
},
|
||||
{
|
||||
title: 'Comfort',
|
||||
body: 'Smaller groups create a more relaxed and comfortable atmosphere for dogs, allowing them to explore and enjoy the walk without feeling overwhelmed by larger dogs.'
|
||||
},
|
||||
{
|
||||
title: 'Increased bonding',
|
||||
body: 'Tiny Gang pack walks foster stronger bonds between dogs and their walker, as well as between the dogs themselves, enhancing trust and companionship among the group.'
|
||||
},
|
||||
{
|
||||
title: 'Individualized attention',
|
||||
body: 'Small pack sizes allow for more personalized care and attention from the walker, addressing the unique needs and preferences of small and medium-sized breeds.'
|
||||
},
|
||||
{
|
||||
title: 'Safety',
|
||||
body: "With a smaller group composed of dogs of similar sizes, there's reduced risk of accidental injury or intimidation, ensuring a safer walking environment."
|
||||
}
|
||||
]
|
||||
},
|
||||
testimonialsHeading: 'What our clients say',
|
||||
booking: {
|
||||
title: 'Join the Tiny Gang!',
|
||||
subtitle: '',
|
||||
formAction: '/booking',
|
||||
serviceOptions: [],
|
||||
ownerStepLabel: 'Your details',
|
||||
dogStepLabel: 'Dog details',
|
||||
dogIntro: 'Tell us about your dog and where you are based so we can plan the right Tiny Gang Meet & Greet.'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { LegalPageContent } from '$lib/types';
|
||||
|
||||
export const privacyPolicyContent: LegalPageContent = {
|
||||
title: 'Privacy Policy',
|
||||
sections: [
|
||||
{
|
||||
title: 'Who we are',
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content:
|
||||
'Goodwalk Limited (we, us or our) is committed to protecting and maintaining the privacy, accuracy and security of your personal information. This privacy policy sets out details of how we collect, use, store and disclose your personal information. By accessing and using our website or our products and services, you consent to the collection, use, storage and disclosure of your personal information in accordance with this policy.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'How we collect your information',
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content:
|
||||
'We will only collect personal information relevant to our business relationship with you. The personal information we collect will generally include your: name, address, telephone numbers, email address, date of birth, vet contact and address, emergency contact. You may choose to not provide us with this information, but not doing so may affect our ability to provide you with our products and services. We will collect this information directly from you when you:'
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'Register to use our products and services;',
|
||||
'Use our products and services; or',
|
||||
'Communicate or interact with us, whether by email, telephone or otherwise. You are required to keep us informed of changes to your information to enable us to have proper administrative processes.'
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content:
|
||||
'We may also collect aggregated information generated by our system, which tracks your use of our products and services but does not identify you personally.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Cookies',
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content:
|
||||
'Our website uses server logs and web analytic tools (such as cookies). Cookies are small text files that are downloaded to your device by websites that you visit. When you use our website, these tools collect information such as the browser and operating system that you use, the internet protocol address of the device you use to access the site, search terms, your location and the content that you view when visiting the website. You can set your browser to block all cookies, including cookies associated with our website, or to indicate when a cookie is being set by us. If you set your browser to reject cookies you may not be able to access all the features of the website.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'How we use your information',
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: 'We may use your personal information to:'
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'identify you, so we can ensure that your account is secure;',
|
||||
'provide our products and services to you;',
|
||||
'communicate with you;',
|
||||
'carry out our business which includes maintaining your account, planning, training, product development, research and analysis;',
|
||||
'enforce our agreement with you in any way;',
|
||||
'fulfil our legal requirements (for example, disclosure to law enforcement agencies or the courts); or',
|
||||
'for any related purpose in connection with the above.'
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content:
|
||||
'We may use any information that we collect from you that is not personal information, so you cannot be personally identified from it, for our business purposes, including:'
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'to assess how our customers use our products and services;',
|
||||
'to improve our products and services; and',
|
||||
'for marketing and promotional purposes.'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Marketing',
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content:
|
||||
'We may use your information to offer you products and services that we believe meet your needs. You can notify us at any time if you do not wish to receive these offers by emailing us at info@goodwalk.co.nz or writing to us at Goodwalk, 8/54 Finch Street, Morningside, Auckland 1022. We will act promptly on any such request.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Who we may disclose your information to',
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content:
|
||||
'We may disclose information we retain about you, including your personal information, to the following persons or their agents:'
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'our employees, contractors, and our related entities;',
|
||||
'third parties engaged by us to perform specific functions for our business;',
|
||||
'service providers engaged by us, such as data storage, IT, software management, insurers and financial services;',
|
||||
'Government departments or law enforcement agencies, including the Police;',
|
||||
'liquidators, administrators or other persons appointed to administer your financial affairs;',
|
||||
'debt collection services or credit reporting agencies.',
|
||||
'Some of the persons listed above may be located overseas. We will only transfer your personal information to a recipient that is obliged to protect your personal information with comparable safeguards to those contained in the Privacy Act 2020, or otherwise we will obtain your express consent to transfer or store the personal information. We may disclose, sell or transfer to third parties any non-personal, aggregated information that we collect from you and our other customers.'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Information Security',
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content:
|
||||
'We will take reasonable steps to keep your personal information secure and confidential. This includes the following:'
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'only our staff and those who perform services on our behalf, and are authorised to handle your information, will have access to your personal information;',
|
||||
'we will not retain any of your information for any longer than it is required by us except to fulfil our legal obligations or where you have consented; and',
|
||||
'we will, with your help, keep your personal information accurate, complete and up-to-date.'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Advertising/Third Party Links',
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content:
|
||||
'Our website may contain links to a variety of advertising and third party website sources. Some of these links may request or record information from users or use cookies or other methods to collect information from you. We have no control over the content or privacy policy practices of those sites and encourage you to review the privacy policies of those sites before using them.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Information Access',
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content:
|
||||
'You can access most of the personal information we hold about you and request corrections. This right is subject to some exceptions, for example, you may not obtain access to information relating to existing or anticipated legal proceedings.'
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content:
|
||||
'You can request access to your information by emailing us at info@goodwalk.co.nz or writing to us at Goodwalk, 8/54 Finch Street, Morningside, Auckland 1022. This service is free unless the information you request requires significant research or preparation time. Before we act upon requests of this nature, we will tell you how much this service will cost.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "What to do if you think we've made an error",
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content:
|
||||
'We are committed to protecting your privacy and our policies, processes and systems have been developed with this in mind. However, if you think we have made an error, please email us at info@goodwalk.co.nz or write to us at Goodwalk, 8/54 Finch Street, Morningside, Auckland 1022, to let us know. Where we have made an error, we will endeavour to correct the error as soon as reasonably practicable.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Questions & Compliants',
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content:
|
||||
'If you have a question or complaint about the way we have dealt with your personal information, please contact us by email or in writing at the addresses above. We will endeavour to respond promptly to your question or complaint.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Privacy Breaches',
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content:
|
||||
'We take our privacy responsibilities seriously. In the unlikely event that a suspected or actual breach of your personal data occurs, we will investigate the breach. We will notify you and the Privacy Commissioner if we reasonably believe that the breach has caused you serious harm, or is likely to cause you serious harm.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Privacy Policy Changes',
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content:
|
||||
'We may change this policy at any time by publishing the amended policy on our website. We will endeavour to inform you of any changes to the policy by email or on the website.'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { ServicePageContent } from '$lib/types';
|
||||
|
||||
export const puppyVisitsContent: ServicePageContent = {
|
||||
hero: {
|
||||
eyebrow: 'Puppy Visits',
|
||||
title: 'Introducing Puppy Visits: Building strong foundations for our pack walks!',
|
||||
paragraphs: [
|
||||
"We love puppies! Our puppy home visits are perfect for young pups not quite ready to join the pack and busy owners with hectic schedules. We lay the groundwork for future pack walks, including fun games, potty breaks, and even feeding if required. Let us help your furry friend thrive while you're away!"
|
||||
],
|
||||
imageUrl: '/images/auckland-puppy-home-visit.jpg',
|
||||
imageAlt: 'Puppy Visits page splash image'
|
||||
},
|
||||
pricing: {
|
||||
title: 'Puppy Visits',
|
||||
plans: [
|
||||
{
|
||||
title: '20 Minutes',
|
||||
price: '$39',
|
||||
period: 'Per Visit',
|
||||
features: ['Bathroom break', 'Pet feed', 'Basic training', 'Enrichment games']
|
||||
},
|
||||
{
|
||||
title: '45 Minutes',
|
||||
price: '$49',
|
||||
period: 'Per Visit',
|
||||
features: ['Bathroom break', 'Pet feed', 'Basic training', 'Enrichment games']
|
||||
},
|
||||
{
|
||||
title: '1 Hour',
|
||||
price: '$55',
|
||||
period: 'Per Visit',
|
||||
features: ['Bathroom break', 'Pet feed', 'Basic training', 'Enrichment games']
|
||||
}
|
||||
]
|
||||
},
|
||||
benefits: {
|
||||
title: 'Puppy Visits benefits',
|
||||
items: [
|
||||
{
|
||||
title: 'Enrichment',
|
||||
body: 'From stimulating games to sensory toys, we keep those curious minds engaged and little tails wagging.'
|
||||
},
|
||||
{
|
||||
title: 'Setting up the basics for pack walks',
|
||||
body: "Lay the groundwork for your pup's adult life. We'll guide you through setting the right tone, offering basic training tips and tricks along the way."
|
||||
},
|
||||
{
|
||||
title: 'Reduce anxiety',
|
||||
body: "With time your pup will know when to expect a visit, reducing the chances of accidents while you're away. With regular visits, your pup will feel loved and secure, minimizing any time spent at home alone."
|
||||
},
|
||||
{
|
||||
title: 'Expert advise',
|
||||
body: "As experienced dog pawrents, we've been through it all with many adorable puppies. Consider us your go-to for any questions or concerns as your furry friend grows up."
|
||||
}
|
||||
]
|
||||
},
|
||||
testimonialsHeading: 'What our clients say',
|
||||
booking: {
|
||||
title: 'Ready to join the Tiny Gang?',
|
||||
subtitle: '',
|
||||
formAction: '/booking',
|
||||
serviceOptions: [],
|
||||
ownerStepLabel: 'Your details',
|
||||
dogStepLabel: 'Dog details',
|
||||
dogIntro: 'Tell us about your puppy, your area, and any special needs so we can plan the right visit.'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
export const staticPages = {
|
||||
'pack-walks': {
|
||||
title: 'Pack Walks | Join Our Tiny Gang',
|
||||
description:
|
||||
'Join our Tiny Gang pack walks. We take our dogs to beautiful parks and beaches around the Auckland region.',
|
||||
canonicalPath: '/pack-walks'
|
||||
},
|
||||
'dog-walking': {
|
||||
title: '1 on 1 Walks | Professional Dog Walking | Auckland Wide',
|
||||
description:
|
||||
'Our 1:1 (one on one) are perfect for dogs with great recall and leash manners, our walks guarantee a stress-free experience!',
|
||||
canonicalPath: '/dog-walking'
|
||||
},
|
||||
'puppy-visits': {
|
||||
title: 'Puppy Visits | Puppy Training',
|
||||
description:
|
||||
'Puppy Visits Introducing Puppy Visits: Building strong foundations for our pack walks! We love puppies! Our puppy home visits are perfect for young pups not quite ready to join the pack and busy owners with hectic schedules. We lay the groundwork for future pack walks, including fun games, potty breaks, and even feeding if required. Let us help your furry friend thrive while you are away!',
|
||||
canonicalPath: '/puppy-visits'
|
||||
},
|
||||
'our-pricing': {
|
||||
title: 'Our Pricing',
|
||||
description:
|
||||
'Learn more about the pricing for Goodwalk. Prices for our Tiny Gang pack walks and 1 on 1 solo walks.',
|
||||
canonicalPath: '/our-pricing'
|
||||
},
|
||||
about: {
|
||||
title: 'About Us | Dog Walkers',
|
||||
description:
|
||||
'Learn more about Kingsland based dog walking company Goodwalk. We offer our Tiny Gang pack walks throughout the Auckland region. Solo (1:1 walks), homestays and more.',
|
||||
canonicalPath: '/about'
|
||||
},
|
||||
'about-us': {
|
||||
title: 'About Us | Dog Walkers',
|
||||
description:
|
||||
'Learn more about Kingsland based dog walking company Goodwalk. We offer our Tiny Gang pack walks throughout the Auckland region. Solo (1:1 walks), homestays and more.',
|
||||
canonicalPath: '/about'
|
||||
},
|
||||
booking: {
|
||||
title: 'Booking',
|
||||
description: 'Book a Meet & Greet with Goodwalk Auckland dog walking services.',
|
||||
canonicalPath: '/booking'
|
||||
},
|
||||
'terms-and-conditions': {
|
||||
title: 'Terms & Conditions',
|
||||
description: 'Terms and conditions for Goodwalk Auckland dog walking services.',
|
||||
canonicalPath: '/terms-and-conditions'
|
||||
},
|
||||
'privacy-policy': {
|
||||
title: 'Privacy Policy',
|
||||
description: 'Privacy policy for Goodwalk Auckland dog walking services.',
|
||||
canonicalPath: '/privacy-policy'
|
||||
}
|
||||
} as const;
|
||||
|
||||
export type StaticPageSlug = keyof typeof staticPages;
|
||||
@@ -0,0 +1,382 @@
|
||||
import type { LegalPageContent } from '$lib/types';
|
||||
|
||||
export const termsAndConditionsContent: LegalPageContent = {
|
||||
title: 'Terms & Conditions',
|
||||
sections: [
|
||||
{
|
||||
title: '1. Application of Terms',
|
||||
blocks: [
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'1.1 Good Walk Limited (we, us or our) provides dog walking and other services relating to dogs (services) to our clients (you or your).',
|
||||
'1.2 These terms and conditions (terms) set out the terms upon which we will provide the services to you. By registering for or using our services, you acknowledge and agree that you have read, understood and accepted these terms and agree to be bound by them.',
|
||||
'1.3 If you do not agree to these terms, you must cease to use our services immediately.',
|
||||
'1.4 We may change these terms at any time by notifying you of the change by email or on our website. Any change to these terms will take effect from the date set out in the notice. By continuing to access or use our services, you agree to be bound by the amended terms.'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '2. Services',
|
||||
blocks: [
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'2.1 We will provide a dog walker (dog walker) or other person with suitable experience to provide the services. As far as reasonably practicable, we will endeavour to ensure that the same dog walker(s) is available to provide the services to you.',
|
||||
'2.2 We will provide the services at the location specified by us unless otherwise agreed in writing with you.',
|
||||
'2.3 In providing our services to you, we will act with due care and skill and in accordance with applicable laws.',
|
||||
'2.4 We reserve the right to change the services from time to time, including the date or time for a walk (for example because of bad weather conditions), the dog walker or the location of the services. We will endeavour to notify you of a change to the services and to agree to the change with you by email or text.',
|
||||
'2.5 We will keep safe and confidential all your keys, remote control entry devices, access codes and return the same to you when we cease providing services to you, or immediately upon demand.',
|
||||
'2.6 We carry out a police check on each dog walker.',
|
||||
'2.7 You agree that we may take photos and/or videos of your dogs during or after our walks. We own the intellectual property rights in these photos and/or videos, and we may post or use the image for any purpose whatsoever, including on our website, social media accounts and promotional materials. If you do not want us to use photos and/or videos then please contact us to have it removed from our website, social media accounts and promotional materials.'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '3. Registration',
|
||||
blocks: [
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'3.1 You may register for our services on our website or directly with us by email. If we require you to pay a deposit, your registration cannot be confirmed until you have paid the required deposit to our nominated bank account.',
|
||||
'3.2 Your registration is only confirmed once we send you an email to this effect. We reserve the right to reject your registration.'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '4. Assesement & Admission of Dogs',
|
||||
blocks: [
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'4.1 We assess each dog for suitability for our services. We may refuse to provide services to you at our sole discretion and at any time if we consider that any dog is not suitable.',
|
||||
'4.2 We only provide pack walks services in relation to small and medium sized dog breeds.',
|
||||
'4.3 We admit dogs to our pack walks based on the following criteria:'
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'(a) the dog is not aggressive and/or does not bite (and has no history of aggression and/or biting);',
|
||||
'(b) the dog is good natured;',
|
||||
'(c) the dog is suitably trained and has good recall skills;',
|
||||
'(d) the dog is up to date with vaccinations (and you must be able to provide evidence of this to us);',
|
||||
'(e) the dog is microchipped;',
|
||||
'(f) the dog is spayed or neutered;',
|
||||
'(g) the dog is regularly treated for fleas and ticks;',
|
||||
'(h) you hold a current council registration for the dog; and',
|
||||
'(i) the dog is over 6 months of age.'
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'4.4 We conduct assessment/onboarding walks with each dog that you ask us to assess (assessment walks). We require that each dog has a minimum of 2 to 4 assessment walks with a senior handler before we commence providing booked walks.',
|
||||
'4.5 Assessment walks last for around 30 minutes or such other period that we think is appropriate. The cost of each assessment walk will be notified to you by email or on our website. We require you to book a minimum of two assessment walks per week unless we agree otherwise.'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '5. Walks',
|
||||
blocks: [
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'5.1 Following the assessment walks, you must select your preferred permanent days of the week for walks, with a minimum commitment of one walk per week. Dogs must attend on these chosen days regularly for a minimum period of at least 6 months. Walks for dogs that show aggressive behavior may be cancelled with immediate effect.',
|
||||
'5.2 Walks will not take place during severe weather conditions such as high winds, heavy snow, heavy rain, thunder, and lightning. In these cases, your dog will be returned to your residence, or the walk may be shortened, cancelled or rescheduled.',
|
||||
'5.3 If you decide to cancel a walk due to bad weather or heat, you agree to pay for the cancelled walk in full. However, we will endeavour to walk dogs in heavy rain or hot weather as long as we consider that it is safe for the dogs and our dog walkers.',
|
||||
'5.4 As part of our service, we will work with you to reinforce recall training, leash training, and car behaviour for your dog, using positive reinforcement methods. However, we do not provide individual training sessions and are not responsible for training your dog or for its behaviour.',
|
||||
'5.5 Walks for female dogs and puppies experiencing heat will be paused for three weeks from the start of the heat cycle, with a payment of one walk per week required to hold the spot for the dog during this period.',
|
||||
'5.6 We may provide services at our sole discretion through one-on-one walks in relation to dogs with anxiety, young puppies, mild reactivity, or large size dogs. If the dog shows progress and is suitable for pack walks, then they may at our discretion be admitted to pack walks.',
|
||||
'5.7 We will provide leads, treats and waste bags. The walker will remove dogs’ faeces from all public places.',
|
||||
'5.8 If you would like to suspend walks for a holiday period exceeding two weeks, we may ask you to pay a fee of $25 per week to hold the dog’s spot.',
|
||||
'5.9 You acknowledge that your dog may be let off the leash unless otherwise agreed. If a dog runs away, the walker is not liable for any reason.',
|
||||
'5.10 For repeated change of frequency or suspensions throughout the year, we require your dog to come for at least one walk per week to maintain their spot or otherwise we will provide the services on a casual basis.'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '5.11. Pack Walks',
|
||||
blocks: [
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'5.11 Pack walks are scheduled on recurring days. We offer two shifts: AM and PM, Monday to Friday, with walks lasting one hour plus pick-up and drop-off, typically totalling an average of 2+ hours. The location will be at one of Auckland’s parks or beaches and is at the discretion of the dog walker.',
|
||||
'(a) AM pack: 9:00 AM – 11:30 AM.',
|
||||
'(b) PM pack: 12:30 PM – 2:30 PM.',
|
||||
'5.12 Pack walks cannot be booked by shift. Your dog should be available for collection at any time between the hours of 9:00 AM and 2:30 PM.',
|
||||
'5.13 We reserve the right to walk other compatible dogs at the same time but will limit the number of dogs walked with one person to 8 dogs.'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '5.14. One-on-One Walks (regular, casual)',
|
||||
blocks: [
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'5.14 One on one walks are 30,45 or 60 minutes long (see our website, goodwalk.co.nz).',
|
||||
'5.15 One-On-One Walks (and Puppy Visits) are offered Monday to Friday:',
|
||||
'(a) AM: 7:00 AM – 9:00 AM.',
|
||||
'(b) PM: 2:30 PM – 3:30 PM.',
|
||||
'5.16 You must select your preferred permanent days of the week for one-on-one walks (and Puppy Visits), with a minimum commitment of one walk per week. Dogs/puppies will attend on these chosen days regularly for a minimum period of at least 6 months.'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '5.17. Homestays',
|
||||
blocks: [
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'5.17 We provide homestay services for owners of dogs that are walked by us, for a minimum of one walk per week (regular client) at the discretion of the dog walker and depending on availability. Dogs need to be crate trained and comply with all our other requirements as set out on our website at goodwalk.co.nz.',
|
||||
'5.18 Homestays during public holidays will incur a 15% surcharge.',
|
||||
'5.19 We may cancel with immediate effect homestays for any dog that shows aggressive behaviour including biting.'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '5.20. Puppy Visits',
|
||||
blocks: [
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'5.20 Puppy visits are 20,45 or 60 minutes long and will be conducted at your residence, with walks outside only when puppies are fully vaccinated and registered.'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '5.21. Pet Taxi and Pet Wash',
|
||||
blocks: [
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'5.21 Pet Taxi and Pet Wash services are exclusively reserved for our regular clients. These services are tailored to each individual case, and prices may vary accordingly. We will notify you in advance of the price before you are charged.',
|
||||
'5.22 Pet Taxi and Pet Wash services can be booked after 3:30pm.'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '5.23. Casual Walks',
|
||||
blocks: [
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'5.23 Owners who require more flexibility with days and frequency may be accommodated through our Casual Walk service.'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '6. Collection of your Dog',
|
||||
blocks: [
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'6.1 We will arrange to collect your dog from the agreed address. You agree that we may enter the agreed address as needed to perform the necessary pick up and drop off services.',
|
||||
'6.2 To facilitate this process (and unless otherwise agreed in writing), you must provide us with:'
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'(a) a copy of your house key;',
|
||||
'(b) any necessary building access swipe card/codes; and',
|
||||
'(c) parking instructions.'
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'6.3 We can provide you with a lockbox to be kept at your property, containing a copy of your key. If you opt for the lockbox option, we will require a $50 deposit. This deposit will be reimbursed to you upon termination of our services. The deposit and cost of the key cut will be added to your first invoice.'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '7. Our Fees',
|
||||
blocks: [
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'7.1 You must pay us our fees for the services in accordance with these terms. Unless otherwise agreed by us in writing, all fees are payable in New Zealand dollars. The fees exclude any goods and services tax or other sales tax, unless otherwise stated.',
|
||||
'7.2 We will provide you with details of our bank account for payment. You should include your dog’s name and surname as a payment reference when you make the payment and follow payment instructions.',
|
||||
'7.3 The fees for our services will be notified to you by email on our website at goodwalk.co.nz.',
|
||||
'7.4 We reserve the right to offer a price which is different from the one displayed on our website, at our sole discretion. Any such price will be agreed with you before we provide the services.',
|
||||
'7.5 For any extra services requested by you, a discretionary charge will be applied. This charge will be agreed with you before we provide the services.',
|
||||
'7.6 If we ask you for a deposit, you must pay this at the time of registration.',
|
||||
'7.7 We generally send out invoices on the Monday of the then current walking week. Payment is due within 7 days from the date of the invoice.',
|
||||
'7.8 We offer various payment options in relation to our fees. The options are currently payment by electronic transfer following receipt of our invoice. If you pay any fees by credit card, a surcharge will apply as notified to you at the time of payment.',
|
||||
'7.9 If we cannot enter your residence to collect the dog, or cannot find the dog, and/or cannot make contact with you for a period of time not exceeding 10 minutes, the walk will be cancelled and you will be liable to make payment in full for the walk.'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '8. Late Payment & Cancellation',
|
||||
blocks: [
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'8.1 We may charge a late payment fee of 15% of the invoiced amount after your third reminder, and walks will be suspended until the payment is made.',
|
||||
'8.2 We operate on a 48-hour cancellation policy. Cancellations made with less than 48 hours’ notice will incur a charge of 50% of the fee for the relevant services. Cancellations on the same day as the scheduled walk will be invoiced (and you will pay for them) in full.',
|
||||
'8.3 We may cancel the services at any time and with immediate effect for dogs that show aggressive behaviour such as biting.',
|
||||
'8.4 We do not provide any services on public holidays, and accordingly all walks are automatically cancelled without notice, and can be rescheduled to a different day of the same week (subject to availability).'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '9. Your Obligations',
|
||||
blocks: [
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'9.1 For group walks, you must notify us in advance of any injury, virus or illness your dog experiences before their scheduled walk. This enables us to assess whether the walk needs to be paused for the health and safety of the entire pack.',
|
||||
'9.2 You will provide all information to the dog walker that may be relevant to the care or wellbeing of your dog or the pack.',
|
||||
'9.3 You will notify the dog walker of any concerns, before or as soon as reasonably possible after any walks.',
|
||||
'9.4 You will provide suitable harnesses, collars and leads as approved by the dog walker as well as coats or muzzles if required.',
|
||||
'9.5 You acknowledge that your dog may return dirty, muddy, or wet from our walks. We will endeavour to towel dry dogs before returning them to your residence.'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '10. Emergencies',
|
||||
blocks: [
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'10.1 In the event of an emergency, we will contact you on the telephone numbers provided by you to confirm your choice of action.',
|
||||
'10.2 If you cannot be reached timeously, you authorise us to:'
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'(a) transport the dog(s) to a veterinarian;',
|
||||
'(b) request on site treatment from a veterinarian; or',
|
||||
'(c) transport the dog(s) to an emergency clinic if the previous two options are not feasible.'
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
content: ['10.3 You are fully responsible for the veterinarian’s or clinic’s costs.']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '11. Liability',
|
||||
blocks: [
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'11.1 Notwithstanding any other provision set out in these terms, our total aggregate liability for all claims relating to these terms is limited to the greater of $100 and the amount you have paid for the services in the immediately preceding 1 month to which the claim relates.',
|
||||
'11.2 We are not liable for any indirect, consequential or special loss, damage, cost or expense incurred by you as a result of a breach by us of these terms.',
|
||||
'11.3 We are not liable to you for any failure to provide the services or to meet any of our obligations under these terms where that failure is caused by an event or circumstance outside of our reasonable control.',
|
||||
'11.4 Our liability for any claim relating to these terms will be reduced to the extent that you or any other person or organisation associated with you contributed to the loss arising from the claim.',
|
||||
'11.5 We and our dog walkers accept no responsibility and/or liability whatsoever for:'
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'(a) any breach of security or loss of or damage to your property if any other person has access to the property at the time of the loss or damage;',
|
||||
'(b) any costs, expenses or damages associated with changing your locks or which you incur as a result of a lost key;',
|
||||
'(c) any injury or damage caused to your dog, including by another dog or other animal, any person, or due to natural or environmental causes;',
|
||||
'(d) any medical event occurring in relation to the dog;',
|
||||
'(e) your dog escaping or getting lost or injured if the dog runs away from the dog walker;',
|
||||
'(f) dog diseases, dog fights, dog bites and other dog injuries; and',
|
||||
'(g) injuries or damage caused by your dog to humans, other dogs or any property.'
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'11.6 You acknowledge that if your dog bites or injures another dog, we will be responsible for reporting this to the relevant authorities.'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '12. Warrantities',
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content:
|
||||
'We do not warrant or guarantee that your use of our services will be error free or will meet your purposes or expectations. To the maximum extent permitted by law, we exclude all such warranties or guarantees.'
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'12.2 Other than as expressly stated in these terms and to the maximum extent permitted by law, we do not give any warranties or guarantees in relation to our services.'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '13. Termination',
|
||||
blocks: [
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'13.1 You may terminate the supply of the services under these terms by giving to us not less than two weeks’ written notice without incurring any charges. If you give us less than two weeks’ written notice of termination, you will pay for any services that we have agreed to provide to you up to the date of termination.',
|
||||
'13.2 We may terminate the supply of the services under these terms by giving to you not less than two weeks’ written notice.',
|
||||
'13.3 If you breach these terms, provide us with incorrect or misleading information, or your dog becomes aggressive or dangerous, we may terminate the services under these terms by giving you written notice with immediate effect.',
|
||||
'13.4 Alternatively, we may suspend the services if you breach these terms or provide us with incorrect or misleading information, until any such breach has been rectified by you.',
|
||||
'13.5 We will give you written notice that services to you under these terms have been suspended or terminated but we do not have to provide to you any reasons for the suspension or termination.'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '14. Disputes',
|
||||
blocks: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content:
|
||||
'If you have a complaint relating to our performances or otherwise to these terms, you should contact us by email at info@goodwalk.co.nz.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '15. Notices',
|
||||
blocks: [
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'15.1 You may send a notice to us under or in connection with these terms by emailing us at info@goodwalk.co.nz.',
|
||||
'15.2 We may send a notice to you under or in connection with these terms by emailing you at the last email address you have provided to us.',
|
||||
'15.3 You or we will be deemed to have received a notice sent by email at the time that you or we sent it, unless we know or ought reasonably to know that the email was not delivered.'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '16. General',
|
||||
blocks: [
|
||||
{
|
||||
type: 'list',
|
||||
content: [
|
||||
'16.1 These terms constitute the entire agreement between you and us relating to your use of the services. These terms replace any previous related agreements and understandings between you and us.',
|
||||
'16.2 We may assign our rights and obligations under these terms at any time to any other person.',
|
||||
'16.3 We may subcontract all or any of our obligations under these terms to a third party. If we do this, we will remain liable to you for the performance of the subcontractor’s obligations.',
|
||||
'16.4 No delay, neglect or forbearance in taking enforcement action in relation to any provision of these terms by a party will be a waiver, or in any way prejudice any right, of that party.',
|
||||
'16.5 If any part of these terms is held to be invalid, illegal or unenforceable, that part will be severed and the remainder of these terms will remain in full force and have full effect.',
|
||||
'16.6 These terms will be governed by the exclusive law of New Zealand.'
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { homepageContent } from '$lib/content/homepage';
|
||||
|
||||
vi.mock('$lib/server/db', () => ({
|
||||
getPool: vi.fn()
|
||||
}));
|
||||
|
||||
import { getPool } from '$lib/server/db';
|
||||
import { getHomepageContent, getSharedPageContent, saveHomepageContent } from './content';
|
||||
|
||||
describe('content server helpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getPool).mockReset();
|
||||
});
|
||||
|
||||
it('returns a cloned homepage fallback when the database is disabled', async () => {
|
||||
vi.mocked(getPool).mockReturnValue(null);
|
||||
|
||||
const result = await getHomepageContent();
|
||||
|
||||
expect(result).toEqual(homepageContent);
|
||||
expect(result).not.toBe(homepageContent);
|
||||
});
|
||||
|
||||
it('returns homepage content from the database when present', async () => {
|
||||
const dbContent = {
|
||||
...homepageContent,
|
||||
seo: {
|
||||
...homepageContent.seo,
|
||||
title: 'Updated title'
|
||||
}
|
||||
};
|
||||
|
||||
vi.mocked(getPool).mockReturnValue({
|
||||
query: vi.fn().mockResolvedValue({
|
||||
rowCount: 1,
|
||||
rows: [{ value: dbContent }]
|
||||
})
|
||||
} as never);
|
||||
|
||||
await expect(getHomepageContent()).resolves.toEqual(dbContent);
|
||||
});
|
||||
|
||||
it('falls back to file content when the database query fails', async () => {
|
||||
vi.mocked(getPool).mockReturnValue({
|
||||
query: vi.fn().mockRejectedValue(new Error('db unavailable'))
|
||||
} as never);
|
||||
|
||||
await expect(getHomepageContent()).resolves.toEqual(homepageContent);
|
||||
});
|
||||
|
||||
it('persists homepage content when the database is enabled', async () => {
|
||||
const query = vi.fn().mockResolvedValue({});
|
||||
vi.mocked(getPool).mockReturnValue({ query } as never);
|
||||
|
||||
const result = await saveHomepageContent(homepageContent);
|
||||
|
||||
expect(result).toEqual(homepageContent);
|
||||
expect(query).toHaveBeenCalledWith(expect.stringContaining('insert into site_content'), [
|
||||
'homepage',
|
||||
JSON.stringify(homepageContent)
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws when trying to save content without a database connection', async () => {
|
||||
vi.mocked(getPool).mockReturnValue(null);
|
||||
|
||||
await expect(saveHomepageContent(homepageContent)).rejects.toThrow('DATABASE_URL is not configured.');
|
||||
});
|
||||
|
||||
it('returns only shared page content for secondary pages', async () => {
|
||||
vi.mocked(getPool).mockReturnValue(null);
|
||||
|
||||
await expect(getSharedPageContent()).resolves.toEqual({
|
||||
navigation: homepageContent.navigation,
|
||||
services: homepageContent.services,
|
||||
testimonials: homepageContent.testimonials,
|
||||
booking: homepageContent.booking,
|
||||
footer: homepageContent.footer
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { homepageContent } from '$lib/content/homepage';
|
||||
import type { HomePageContent, SiteSharedContent } from '$lib/types';
|
||||
import { getPool } from '$lib/server/db';
|
||||
|
||||
const CONTENT_KEY = 'homepage';
|
||||
|
||||
export async function getHomepageContent(): Promise<HomePageContent> {
|
||||
const pool = getPool();
|
||||
|
||||
if (!pool) {
|
||||
return structuredClone(homepageContent);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pool.query<{ value: HomePageContent }>(
|
||||
'select value from site_content where key = $1 limit 1',
|
||||
[CONTENT_KEY]
|
||||
);
|
||||
|
||||
if (result.rowCount && result.rows[0]) {
|
||||
return result.rows[0].value;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to read homepage content from PostgreSQL.', error);
|
||||
}
|
||||
|
||||
return structuredClone(homepageContent);
|
||||
}
|
||||
|
||||
export async function saveHomepageContent(content: HomePageContent) {
|
||||
const pool = getPool();
|
||||
|
||||
if (!pool) {
|
||||
throw new Error('DATABASE_URL is not configured.');
|
||||
}
|
||||
|
||||
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()
|
||||
`,
|
||||
[CONTENT_KEY, JSON.stringify(content)]
|
||||
);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
export async function getSharedPageContent(): Promise<SiteSharedContent> {
|
||||
const content = await getHomepageContent();
|
||||
|
||||
return {
|
||||
navigation: content.navigation,
|
||||
services: content.services,
|
||||
testimonials: content.testimonials,
|
||||
booking: content.booking,
|
||||
footer: content.footer
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Pool } from 'pg';
|
||||
|
||||
let pool: Pool | null = null;
|
||||
|
||||
function useSsl(connectionString: string) {
|
||||
return !connectionString.includes('localhost') && !connectionString.includes('@db:');
|
||||
}
|
||||
|
||||
export function getPool() {
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
|
||||
if (!connectionString) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!pool) {
|
||||
pool = new Pool({
|
||||
connectionString,
|
||||
ssl: useSsl(connectionString) ? { rejectUnauthorized: false } : undefined
|
||||
});
|
||||
}
|
||||
|
||||
return pool;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
background: var(--off-white);
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 14px 32px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
border-radius: 40px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.2s,
|
||||
color 0.2s,
|
||||
border-color 0.2s,
|
||||
transform 0.16s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 0.2s ease,
|
||||
filter 0.2s ease;
|
||||
border: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.btn:hover {
|
||||
transform: translateY(-2px) scale(1.012);
|
||||
box-shadow: 0 12px 24px rgba(17, 20, 24, 0.14);
|
||||
}
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(1px) scale(0.985);
|
||||
box-shadow: 0 4px 10px rgba(17, 20, 24, 0.12);
|
||||
filter: saturate(1.03);
|
||||
}
|
||||
|
||||
.btn-green {
|
||||
background: var(--green);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-green:hover {
|
||||
background: #172217;
|
||||
}
|
||||
|
||||
.btn-yellow {
|
||||
background: var(--yellow);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-yellow:hover {
|
||||
background: #e6bb00;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
border: 2px solid #fff;
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: #fff;
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.btn-outline-green {
|
||||
color: var(--green);
|
||||
border-color: var(--green);
|
||||
}
|
||||
|
||||
.btn-outline-green:hover {
|
||||
background: var(--green);
|
||||
color: #fff;
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
.form-inner {
|
||||
max-width: 920px;
|
||||
}
|
||||
|
||||
.booking-header {
|
||||
text-align: center;
|
||||
margin-bottom: 44px;
|
||||
}
|
||||
|
||||
.booking-title {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 28px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 68px;
|
||||
font-weight: 800;
|
||||
line-height: 0.96;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.booking-title-plain {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.booking-title-highlight {
|
||||
position: relative;
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.booking-title-highlight::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: -8px;
|
||||
bottom: -18px;
|
||||
height: 28px;
|
||||
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 34' fill='none'%3E%3Cpath d='M4 24C67 10 131 4 198 5c43 1 82 6 118 18' stroke='%23192419' stroke-width='8' stroke-linecap='round'/%3E%3C/svg%3E")
|
||||
center/contain no-repeat;
|
||||
}
|
||||
|
||||
.booking-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.booking-step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #000;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: transform 0.14s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.booking-step-number {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border: 3px solid #111;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-head);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
background: #fff;
|
||||
transition:
|
||||
background 0.2s,
|
||||
border-color 0.2s,
|
||||
transform 0.16s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.booking-step.active .booking-step-number {
|
||||
background: #eadbbf;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.booking-step:hover .booking-step-number {
|
||||
transform: translateY(-2px) scale(1.04);
|
||||
box-shadow: 0 10px 20px rgba(17, 20, 24, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.booking-step:active .booking-step-number {
|
||||
transform: translateY(1px) scale(0.96);
|
||||
}
|
||||
|
||||
.booking-step-label {
|
||||
font-family: var(--font-head);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.booking-step-divider {
|
||||
width: 1px;
|
||||
height: 92px;
|
||||
background: #cfd2d6;
|
||||
}
|
||||
|
||||
.booking-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.booking-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.booking-panel-banner {
|
||||
background: #eadbbf;
|
||||
color: #34363a;
|
||||
border-radius: 30px 30px 0 0;
|
||||
padding: 28px 32px 42px;
|
||||
text-align: center;
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.booking-card-grid {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.booking-card-grid-with-banner {
|
||||
margin-top: -30px;
|
||||
}
|
||||
|
||||
.booking-card-grid-owner {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.booking-card-grid-dog {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.booking-field-card {
|
||||
background: #fff;
|
||||
border-radius: 28px;
|
||||
padding: 32px 38px 30px;
|
||||
box-shadow: 0 10px 30px rgba(17, 20, 24, 0.04);
|
||||
}
|
||||
|
||||
.booking-field-card-group {
|
||||
padding: 30px 32px;
|
||||
}
|
||||
|
||||
.booking-field-card-wide,
|
||||
.booking-field-card-full {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.booking-field-card-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.booking-field-group {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.booking-field-group-owner {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.booking-field-stack {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.booking-field-card label,
|
||||
.booking-service-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-bottom: 10px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #34363a;
|
||||
}
|
||||
|
||||
.booking-required {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.booking-field-card input,
|
||||
.booking-field-card textarea {
|
||||
width: 100%;
|
||||
border: 3px solid #111;
|
||||
border-radius: 20px;
|
||||
background: #fff;
|
||||
padding: 16px 28px;
|
||||
font-size: 16px;
|
||||
line-height: 1.2;
|
||||
color: #34363a;
|
||||
transition:
|
||||
background 0.2s,
|
||||
border-color 0.2s;
|
||||
}
|
||||
|
||||
.booking-field-card input:hover,
|
||||
.booking-field-card textarea:hover {
|
||||
background: #f6f0e5;
|
||||
}
|
||||
|
||||
.booking-field-card input:focus,
|
||||
.booking-field-card textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--green);
|
||||
background: #f6f0e5;
|
||||
}
|
||||
|
||||
.booking-field-card textarea {
|
||||
min-height: 140px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.booking-service-row {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
background: #fff;
|
||||
border-radius: 28px;
|
||||
padding: 22px 28px;
|
||||
box-shadow: 0 10px 30px rgba(17, 20, 24, 0.04);
|
||||
}
|
||||
|
||||
|
||||
.booking-service-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px 22px;
|
||||
}
|
||||
|
||||
.booking-check-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #34363a;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.booking-check-option input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.booking-check-box {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 3px solid #111;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
transition:
|
||||
background 0.2s,
|
||||
border-color 0.2s;
|
||||
}
|
||||
|
||||
.booking-check-option input:checked + .booking-check-box {
|
||||
background: var(--yellow);
|
||||
border-color: #111;
|
||||
}
|
||||
|
||||
.booking-check-option input:checked + .booking-check-box::after {
|
||||
content: '';
|
||||
width: 10px;
|
||||
height: 16px;
|
||||
border-right: 3px solid #111;
|
||||
border-bottom: 3px solid #111;
|
||||
transform: rotate(40deg) translate(-1px, -2px);
|
||||
}
|
||||
|
||||
.booking-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.booking-actions-next {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.booking-actions-final {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.booking-next-button,
|
||||
.booking-submit-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 18px 34px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.booking-next-button .icon,
|
||||
.booking-submit-button .icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.booking-field-card input.input-invalid,
|
||||
.booking-field-card textarea.input-invalid {
|
||||
border-color: #c94040;
|
||||
}
|
||||
|
||||
.booking-field-card-invalid,
|
||||
.booking-field-stack-invalid {
|
||||
box-shadow: 0 10px 30px rgba(201, 64, 64, 0.08);
|
||||
}
|
||||
|
||||
.field-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin: 10px 0 0;
|
||||
color: #c94040;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
animation: fieldErrorIn 0.16s ease;
|
||||
}
|
||||
|
||||
.field-error .icon {
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes fieldErrorIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
header {
|
||||
z-index: 100;
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
nav,
|
||||
.mobile-menu,
|
||||
.hero-inner,
|
||||
.promise-inner,
|
||||
.services-inner,
|
||||
.values-inner,
|
||||
.testimonials-inner,
|
||||
.info-inner,
|
||||
.form-inner,
|
||||
.footer-inner,
|
||||
.footer-bottom {
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
padding: 16px 50px;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.nav-links li {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-links > li > a {
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
padding: 8px 16px;
|
||||
border-radius: 40px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.nav-links > li > a:hover {
|
||||
background: #fff;
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.nav-links > li > a.nav-link-active {
|
||||
background: #eadbbf;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.mega-chevron {
|
||||
font-size: 11px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.has-mega:hover .mega-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.mega-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
padding-top: 12px;
|
||||
z-index: 200;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-6px);
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
transform 0.18s ease,
|
||||
visibility 0.18s;
|
||||
}
|
||||
|
||||
.mega-menu-inner {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
box-shadow:
|
||||
0 4px 6px rgba(0, 0, 0, 0.04),
|
||||
0 16px 40px rgba(0, 0, 0, 0.12);
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.has-mega:hover .mega-menu {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.mega-service {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 14px 14px;
|
||||
border-radius: 14px;
|
||||
flex: 1;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.mega-service:hover {
|
||||
background: rgba(33, 48, 33, 0.05);
|
||||
}
|
||||
|
||||
.mega-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: var(--green);
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.mega-service:hover .mega-icon {
|
||||
background: #2d4230;
|
||||
}
|
||||
|
||||
.mega-service-label {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mega-service-desc {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--gray);
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.mobile-phone {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-phone .icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.instagram-icon {
|
||||
color: #fff;
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.instagram-icon:hover {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.mobile-menu {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
background: #fff;
|
||||
padding: 0 0 18px;
|
||||
}
|
||||
|
||||
.mobile-menu.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.mobile-menu a {
|
||||
color: #0a304e;
|
||||
font-weight: 700;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.mobile-menu a:hover {
|
||||
color: #0a304e;
|
||||
}
|
||||
|
||||
.mobile-menu a.mobile-link-active {
|
||||
background: #eadbbf;
|
||||
color: #0a304e;
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
color: #fff;
|
||||
font-size: 26px;
|
||||
transition:
|
||||
transform 0.14s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 0.2s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.hamburger .icon {
|
||||
display: block;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.hamburger:hover {
|
||||
transform: translateY(-1px) scale(1.06);
|
||||
}
|
||||
}
|
||||
|
||||
.hamburger:active {
|
||||
transform: translateY(1px) scale(0.94);
|
||||
}
|
||||
|
||||
.hero-inner,
|
||||
.promise-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 60px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.promise-inner,
|
||||
.services-inner,
|
||||
.values-inner,
|
||||
.testimonials-inner,
|
||||
.info-inner,
|
||||
.form-inner {
|
||||
padding: 0 50px;
|
||||
}
|
||||
|
||||
.services-grid,
|
||||
.values-grid,
|
||||
.testimonials-grid {
|
||||
margin-top: 48px;
|
||||
}
|
||||
|
||||
.services-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.values-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.testimonials-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.info-inner {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 60px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.footer-inner {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1.4fr 2fr;
|
||||
gap: 60px;
|
||||
margin-bottom: 48px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.social-links {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
@media (max-width: 1024px) {
|
||||
nav,
|
||||
.mobile-menu {
|
||||
padding-left: 30px;
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
#hero {
|
||||
padding-left: 30px;
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
.promise-inner,
|
||||
.services-inner,
|
||||
.values-inner,
|
||||
.testimonials-inner,
|
||||
.info-inner,
|
||||
.form-inner {
|
||||
padding-left: 30px;
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
footer {
|
||||
padding-left: 30px;
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
.hero-text h1 {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.values-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@keyframes mobileMenuBounceIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px) scaleY(0.98);
|
||||
}
|
||||
|
||||
70% {
|
||||
opacity: 1;
|
||||
transform: translateY(2px) scaleY(1.01);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mobileMenuItemIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
nav {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
padding: 20px 24px;
|
||||
column-gap: 12px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
justify-content: flex-start;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
height: 25px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.mobile-phone {
|
||||
display: inline-flex;
|
||||
justify-self: center;
|
||||
align-self: center;
|
||||
padding: 9px 12px;
|
||||
background: rgba(33, 48, 33, 0.06);
|
||||
color: var(--green);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mobile-phone .icon {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
justify-content: flex-end;
|
||||
grid-column: 3;
|
||||
}
|
||||
|
||||
.nav-right .btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.instagram-icon {
|
||||
color: #0a304e;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
display: flex;
|
||||
color: #2e3031;
|
||||
grid-column: 4;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.mobile-menu {
|
||||
max-width: none;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.mobile-menu.open {
|
||||
animation: mobileMenuBounceIn 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.mobile-menu a {
|
||||
display: block;
|
||||
padding: 14px 24px;
|
||||
border-bottom: none;
|
||||
font-size: 16px;
|
||||
line-height: 1.35;
|
||||
animation: mobileMenuItemIn 220ms ease-out forwards;
|
||||
}
|
||||
|
||||
.mobile-menu.open a:nth-child(1) {
|
||||
animation-delay: 30ms;
|
||||
}
|
||||
|
||||
.mobile-menu.open a:nth-child(2) {
|
||||
animation-delay: 60ms;
|
||||
}
|
||||
|
||||
.mobile-menu.open a:nth-child(3) {
|
||||
animation-delay: 90ms;
|
||||
}
|
||||
|
||||
.mobile-menu.open a:nth-child(4) {
|
||||
animation-delay: 120ms;
|
||||
}
|
||||
|
||||
.mobile-menu.open a:nth-child(5) {
|
||||
animation-delay: 150ms;
|
||||
}
|
||||
|
||||
.mobile-menu.open a:nth-child(6) {
|
||||
animation-delay: 180ms;
|
||||
}
|
||||
|
||||
.mobile-menu.open a:nth-child(7) {
|
||||
animation-delay: 210ms;
|
||||
}
|
||||
|
||||
#hero {
|
||||
min-height: auto;
|
||||
padding: 50px 20px 0;
|
||||
}
|
||||
|
||||
.hero-inner {
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
align-items: stretch;
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hero-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hero-text h1,
|
||||
.hero-heading {
|
||||
margin-bottom: 22px;
|
||||
font-size: 38px;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.hero-heading-desktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero-text h1 .hero-heading-mobile {
|
||||
display: block;
|
||||
color: #fff;
|
||||
font-family: var(--font-head);
|
||||
font-size: 33.5px;
|
||||
font-weight: 800;
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.04em;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.hero-buttons {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.hero-buttons .btn {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
padding: 17px 28px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
border-radius: 999px;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.hero-buttons .btn:last-child {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.hero-buttons .btn-yellow {
|
||||
background: #e8dbc1;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.hero-buttons .btn-outline {
|
||||
background: var(--yellow);
|
||||
color: #000;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.hero-buttons .btn:active {
|
||||
transform: translateY(1px) scale(0.985);
|
||||
}
|
||||
|
||||
.hero-buttons .btn-yellow:active {
|
||||
background: #dccdb1;
|
||||
}
|
||||
|
||||
.hero-buttons .btn-outline:active {
|
||||
background: #e6bb00;
|
||||
}
|
||||
|
||||
.hero-img {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.hero-img img {
|
||||
width: min(100%, 500px);
|
||||
max-width: 100%;
|
||||
margin: 0 auto -7px;
|
||||
object-fit: contain;
|
||||
object-position: center top;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
#intro {
|
||||
padding: 30px 24px 24px;
|
||||
}
|
||||
|
||||
.intro-trust-badge {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
padding: 18px 18px 16px;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.intro-trust-mark {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
#intro p {
|
||||
font-size: 17px;
|
||||
line-height: 1.45;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.intro-trust-meta {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#intro a {
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.intro-trust-stars {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.promise-inner {
|
||||
flex-direction: column;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.promise-text {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.promise-img {
|
||||
order: 2;
|
||||
flex: none;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.services-grid,
|
||||
.values-grid,
|
||||
.testimonials-grid,
|
||||
.info-inner,
|
||||
.field-group,
|
||||
.footer-inner {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.service-icon-bubble {
|
||||
width: 78px;
|
||||
height: 78px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.service-card .service-card-icon {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.mobile-menu a.mobile-link-active {
|
||||
background: var(--yellow);
|
||||
color: #0a304e;
|
||||
}
|
||||
|
||||
.booking-header {
|
||||
margin-bottom: 34px;
|
||||
}
|
||||
|
||||
.booking-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
font-size: 34px;
|
||||
line-height: 0.98;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.booking-title-highlight::after {
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: -14px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.booking-stepper {
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.booking-step {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.booking-step-number {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.booking-step-label {
|
||||
font-size: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.booking-step-divider {
|
||||
margin-top: 22px;
|
||||
height: 1px;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.booking-panel-banner {
|
||||
padding: 22px 18px 34px;
|
||||
border-radius: 24px 24px 0 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.booking-card-grid-owner,
|
||||
.booking-card-grid-dog {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.booking-field-group-owner {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.booking-field-card {
|
||||
padding: 24px 22px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.booking-field-card-group {
|
||||
padding: 24px 22px;
|
||||
}
|
||||
|
||||
.booking-field-card label,
|
||||
.booking-service-label {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.booking-field-card input,
|
||||
.booking-field-card textarea {
|
||||
padding: 14px 18px;
|
||||
font-size: 15px;
|
||||
border-width: 2px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.booking-service-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
padding: 22px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.booking-service-options {
|
||||
gap: 12px 18px;
|
||||
}
|
||||
|
||||
.booking-check-option {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.booking-check-box {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-width: 2px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.booking-actions-final {
|
||||
gap: 14px;
|
||||
flex-direction: column-reverse;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.booking-actions-final .btn,
|
||||
.booking-actions-next .btn {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.services-inner,
|
||||
.values-inner,
|
||||
.testimonials-inner,
|
||||
.info-inner,
|
||||
.form-inner {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
footer {
|
||||
padding: 48px 24px 28px;
|
||||
}
|
||||
|
||||
.footer-inner {
|
||||
gap: 36px;
|
||||
}
|
||||
|
||||
.footer-action {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.footer-book-note {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.footer-nav a {
|
||||
padding: 11px 0;
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#instagram {
|
||||
padding: 48px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.mobile-phone {
|
||||
gap: 6px;
|
||||
padding: 9px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mobile-phone span {
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
section {
|
||||
padding: 80px 0;
|
||||
}
|
||||
|
||||
#hero {
|
||||
background: var(--green);
|
||||
color: #fff;
|
||||
padding: 44px 50px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 500px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-inner {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.hero-text,
|
||||
.promise-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hero-text {
|
||||
padding-bottom: 44px;
|
||||
}
|
||||
|
||||
.hero-buttons {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-img {
|
||||
flex: 0 0 46%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-self: flex-end;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.hero-img img {
|
||||
width: min(100%, 530px);
|
||||
margin: -12px -18px -72px 0;
|
||||
}
|
||||
|
||||
#intro {
|
||||
background: #fff;
|
||||
padding: 8px 50px 26px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.intro-trust-badge {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
margin: 0;
|
||||
padding: 18px 22px;
|
||||
border-radius: 26px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #fbf8f2 100%);
|
||||
box-shadow:
|
||||
0 1px 0 rgba(17, 20, 24, 0.04),
|
||||
0 14px 34px rgba(17, 20, 24, 0.06);
|
||||
}
|
||||
|
||||
.intro-trust-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
color: #e00706;
|
||||
font-size: 24px;
|
||||
box-shadow: inset 0 0 0 1px rgba(14, 27, 41, 0.08);
|
||||
}
|
||||
|
||||
.intro-trust-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#intro p {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
color: #34363a;
|
||||
}
|
||||
|
||||
.intro-trust-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.intro-trust-stars {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--yellow);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#intro a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 16px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: var(--green);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
box-shadow: inset 0 0 0 1px rgba(14, 27, 41, 0.08);
|
||||
}
|
||||
|
||||
#intro a .icon {
|
||||
color: #e00706;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
#promise,
|
||||
#testimonials,
|
||||
#info {
|
||||
background: var(--off-white);
|
||||
}
|
||||
|
||||
#services,
|
||||
#reservation {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.promise-text h2 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.promise-text p {
|
||||
margin-bottom: 28px;
|
||||
font-size: 16px;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.promise-text .btn {
|
||||
display: block;
|
||||
width: fit-content;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.promise-text {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.promise-img {
|
||||
order: 1;
|
||||
flex: 0 0 48%;
|
||||
max-width: 560px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.promise-img img {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.service-card {
|
||||
background: var(--off-white);
|
||||
border-radius: 20px;
|
||||
padding: 40px 32px;
|
||||
text-align: center;
|
||||
transition:
|
||||
transform 0.18s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 0.22s ease;
|
||||
}
|
||||
|
||||
.service-icon-bubble {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
margin: 0 auto 24px;
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(17, 20, 24, 0.05),
|
||||
0 10px 24px rgba(17, 20, 24, 0.08);
|
||||
}
|
||||
|
||||
.service-icon-bubble {
|
||||
background: linear-gradient(180deg, #ffe173 0%, #ffd54a 100%);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.service-card:hover {
|
||||
transform: translateY(-6px) scale(1.01);
|
||||
box-shadow: 0 18px 38px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.service-card:active {
|
||||
transform: translateY(-1px) scale(0.992);
|
||||
}
|
||||
|
||||
.service-card .service-card-icon {
|
||||
font-size: 34px;
|
||||
color: var(--green);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
.service-card a.btn {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
#values,
|
||||
footer {
|
||||
background: var(--green);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#values .section-heading,
|
||||
#testimonials .section-heading {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#services .section-heading {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
footer {
|
||||
padding: 60px 50px 32px;
|
||||
}
|
||||
|
||||
.values-inner .section-heading {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.value-card {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border-radius: 16px;
|
||||
padding: 32px 28px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
transition:
|
||||
transform 0.18s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 0.22s ease,
|
||||
border-color 0.22s ease;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.value-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.14);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
.value-card:active {
|
||||
transform: translateY(-1px) scale(0.994);
|
||||
}
|
||||
|
||||
.value-card .value-card-icon {
|
||||
font-size: 28px;
|
||||
color: var(--yellow);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.value-card p {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.testimonial-card {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 36px 32px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
|
||||
transition:
|
||||
transform 0.18s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 0.22s ease;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.testimonial-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.09);
|
||||
}
|
||||
}
|
||||
|
||||
.testimonial-card:active {
|
||||
transform: translateY(-1px) scale(0.994);
|
||||
}
|
||||
|
||||
.stars {
|
||||
color: var(--yellow);
|
||||
font-size: 18px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.reviewer {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.reviewer span {
|
||||
font-weight: 400;
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
.info-copy {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.info-copy a {
|
||||
color: var(--green);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.faq details {
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.faq summary {
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.faq summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.faq summary::after {
|
||||
content: '+';
|
||||
font-size: 20px;
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.faq details[open] summary::after {
|
||||
content: '−';
|
||||
}
|
||||
|
||||
.faq details p {
|
||||
margin-top: 10px;
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
#instagram {
|
||||
background: var(--yellow);
|
||||
text-align: center;
|
||||
padding: 60px 50px;
|
||||
}
|
||||
|
||||
.instagram-blurb {
|
||||
margin: -8px 0 24px;
|
||||
font-size: 16px;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
#instagram .btn {
|
||||
background: var(--green);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.footer-logo {
|
||||
display: block;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.footer-brand p {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
opacity: 0.65;
|
||||
margin-bottom: 24px;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.social-links a {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.social-links a:hover {
|
||||
background: var(--yellow);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.footer-col-label {
|
||||
margin: 0 0 16px;
|
||||
font-family: var(--font-head);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.footer-nav {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.footer-nav li {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.footer-nav a {
|
||||
display: block;
|
||||
padding: 8px 0;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
opacity: 0.75;
|
||||
transition: opacity 0.18s;
|
||||
}
|
||||
|
||||
.footer-nav a:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.footer-book-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 15px 22px;
|
||||
border-radius: 999px;
|
||||
background: var(--yellow);
|
||||
color: #000;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
transition: background 0.2s, transform 0.15s;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.footer-book-btn:hover {
|
||||
background: #ffe033;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.footer-book-note {
|
||||
margin: 0 0 20px;
|
||||
font-size: 13px;
|
||||
opacity: 0.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-reviews {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 16px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
font-size: 13px;
|
||||
opacity: 0.8;
|
||||
transition: background 0.2s, opacity 0.2s;
|
||||
}
|
||||
|
||||
.footer-reviews:hover {
|
||||
background: rgba(255, 255, 255, 0.13);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.footer-contact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.footer-contact-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.18s;
|
||||
}
|
||||
|
||||
.footer-contact-link:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.footer-contact-link .icon {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding-top: 24px;
|
||||
font-size: 13px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.footer-legal {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.footer-legal a {
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.footer-legal a:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logo img,
|
||||
.footer-logo {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
height: 30px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.nav-links a,
|
||||
.mobile-menu a {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.section-heading,
|
||||
.promise-text h2,
|
||||
.info-block h2,
|
||||
#instagram h2 {
|
||||
font-family: var(--font-head);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
font-size: 42px;
|
||||
color: #000;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.hero-text h1 {
|
||||
font-family: var(--font-head);
|
||||
font-size: 50.2px;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 28px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hero-text h1 .hero-heading-mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero-title-main,
|
||||
.hero-title-connector,
|
||||
.hero-title-highlight {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.promise-text h2 {
|
||||
font-size: 42px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.promise-text p,
|
||||
.info-block p,
|
||||
.faq details p,
|
||||
.testimonial-card blockquote {
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.service-card h3,
|
||||
.value-card h3 {
|
||||
font-family: var(--font-head);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.service-card h3 {
|
||||
font-size: 20px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.value-card h3 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.info-block h2 {
|
||||
font-size: 30px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-block h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin: 20px 0 8px;
|
||||
}
|
||||
|
||||
#instagram h2 {
|
||||
font-size: 36px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
footer h4 {
|
||||
font-family: var(--font-head);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
letter-spacing: 0.5px;
|
||||
opacity: 0.6;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
:root {
|
||||
--green: #213021;
|
||||
--yellow: #ffd100;
|
||||
--gray: #59606d;
|
||||
--beige: #e5d6c2;
|
||||
--off-white: #fbfbfb;
|
||||
--text: #2e3031;
|
||||
--max-w: 1280px;
|
||||
--font-body: 'Readex Pro', sans-serif;
|
||||
--font-head: 'Unbounded', sans-serif;
|
||||
|
||||
/* Legacy "navy" tokens now intentionally render as Goodwalk green. */
|
||||
--navy: var(--green);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
export interface LinkItem {
|
||||
label: string;
|
||||
href: string;
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
export interface CallToAction {
|
||||
label: string;
|
||||
href: string;
|
||||
variant?: 'green' | 'yellow' | 'outline';
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
export interface SeoContent {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface MegaMenuService {
|
||||
icon: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface NavigationContent {
|
||||
desktopLinks: LinkItem[];
|
||||
mobileLinks: LinkItem[];
|
||||
cta: CallToAction;
|
||||
instagram?: { href: string; external?: boolean };
|
||||
megaMenuServices?: MegaMenuService[];
|
||||
}
|
||||
|
||||
export interface HeroContent {
|
||||
title: string;
|
||||
highlight: string;
|
||||
mobileTitle?: string;
|
||||
primaryCta: CallToAction;
|
||||
secondaryCta: CallToAction;
|
||||
imageUrl: string;
|
||||
imageAlt: string;
|
||||
}
|
||||
|
||||
export interface IntroContent {
|
||||
text: string;
|
||||
reviewCta: CallToAction;
|
||||
}
|
||||
|
||||
export interface PromiseContent {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
body: string;
|
||||
emphasis: string;
|
||||
cta: CallToAction;
|
||||
imageUrl: string;
|
||||
imageAlt: string;
|
||||
}
|
||||
|
||||
export interface IconCard {
|
||||
icon: string;
|
||||
title: string;
|
||||
body: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export interface TestimonialContent {
|
||||
quote: string;
|
||||
reviewer: string;
|
||||
detail: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
export interface BookingContent {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
formAction: string;
|
||||
serviceOptions: string[];
|
||||
ownerStepLabel?: string;
|
||||
dogStepLabel?: string;
|
||||
dogIntro?: string;
|
||||
}
|
||||
|
||||
export interface ServicePricingPlan {
|
||||
title: string;
|
||||
price: string;
|
||||
period: string;
|
||||
popular?: boolean;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
export interface ServiceExtra {
|
||||
label: string;
|
||||
price: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface ServiceBenefit {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface ServicePageContent {
|
||||
hero: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
paragraphs: string[];
|
||||
imageUrl: string;
|
||||
imageAlt: string;
|
||||
};
|
||||
highlight?: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
imageUrl: string;
|
||||
imageAlt: string;
|
||||
};
|
||||
pricing: {
|
||||
title: string;
|
||||
intro?: string;
|
||||
plans: ServicePricingPlan[];
|
||||
extras?: ServiceExtra[];
|
||||
};
|
||||
benefits: {
|
||||
title: string;
|
||||
items: ServiceBenefit[];
|
||||
};
|
||||
testimonialsHeading: string;
|
||||
booking: BookingContent;
|
||||
}
|
||||
|
||||
export interface PricingPageSection {
|
||||
title: string;
|
||||
icon?: string;
|
||||
blurb?: string;
|
||||
detailCta?: CallToAction;
|
||||
plans: ServicePricingPlan[];
|
||||
}
|
||||
|
||||
export interface PricingPageContent {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
sections: PricingPageSection[];
|
||||
testimonialsHeading: string;
|
||||
booking: BookingContent;
|
||||
}
|
||||
|
||||
export interface AboutPageSection {
|
||||
title: string;
|
||||
body: string[];
|
||||
imageUrl: string;
|
||||
imageAlt: string;
|
||||
reverse?: boolean;
|
||||
accent?: 'plain' | 'gradient';
|
||||
}
|
||||
|
||||
export interface AboutPageContent {
|
||||
title: string;
|
||||
sections: AboutPageSection[];
|
||||
servicesTitle: string;
|
||||
contact: {
|
||||
title: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
cta: CallToAction;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LegalPageBlock {
|
||||
type: 'paragraph' | 'list';
|
||||
content: string | string[];
|
||||
}
|
||||
|
||||
export interface LegalPageSection {
|
||||
title: string;
|
||||
blocks: LegalPageBlock[];
|
||||
}
|
||||
|
||||
export interface LegalPageContent {
|
||||
title: string;
|
||||
sections: LegalPageSection[];
|
||||
}
|
||||
|
||||
export interface FaqItem {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export interface InfoContent {
|
||||
title: string;
|
||||
intro: string;
|
||||
suburbs: string;
|
||||
nearbyText: string;
|
||||
nearbyCta: CallToAction;
|
||||
hoursLabel: string;
|
||||
hours: string;
|
||||
faqTitle: string;
|
||||
faqs: FaqItem[];
|
||||
}
|
||||
|
||||
export interface FooterContent {
|
||||
brandText: string;
|
||||
navigationLinks: LinkItem[];
|
||||
contactLinks: LinkItem[];
|
||||
copyright: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface HomePageContent {
|
||||
seo: SeoContent;
|
||||
navigation: NavigationContent;
|
||||
hero: HeroContent;
|
||||
intro: IntroContent;
|
||||
promise: PromiseContent;
|
||||
services: IconCard[];
|
||||
values: IconCard[];
|
||||
testimonials: TestimonialContent[];
|
||||
booking: BookingContent;
|
||||
info: InfoContent;
|
||||
instagram: CallToAction & { title: string };
|
||||
footer: FooterContent;
|
||||
}
|
||||
|
||||
export interface SiteSharedContent {
|
||||
navigation: NavigationContent;
|
||||
services: IconCard[];
|
||||
testimonials: TestimonialContent[];
|
||||
booking: BookingContent;
|
||||
footer: FooterContent;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate, disableScrollHandling } from '$app/navigation';
|
||||
import '$lib/styles/variables.css';
|
||||
import '$lib/styles/base.css';
|
||||
import '$lib/styles/layout.css';
|
||||
import '$lib/styles/typography.css';
|
||||
import '$lib/styles/buttons.css';
|
||||
import '$lib/styles/forms.css';
|
||||
import '$lib/styles/sections.css';
|
||||
import '$lib/styles/responsive.css';
|
||||
|
||||
afterNavigate(({ from, to }) => {
|
||||
if (!from || !to || to.url.hash) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (from.url.pathname !== to.url.pathname) {
|
||||
disableScrollHandling();
|
||||
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
||||
document.documentElement.scrollTop = 0;
|
||||
document.body.scrollTop = 0;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
@@ -0,0 +1,7 @@
|
||||
import { getHomepageContent } from '$lib/server/content';
|
||||
|
||||
export async function load() {
|
||||
return {
|
||||
content: await getHomepageContent()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
import SeoHead from '$lib/components/SeoHead.svelte';
|
||||
import Footer from '$lib/components/Footer.svelte';
|
||||
import Header from '$lib/components/Header.svelte';
|
||||
import HeroSection from '$lib/components/HeroSection.svelte';
|
||||
import InfoSection from '$lib/components/InfoSection.svelte';
|
||||
import InstagramSection from '$lib/components/InstagramSection.svelte';
|
||||
import IntroStrip from '$lib/components/IntroStrip.svelte';
|
||||
import BookingSection from '$lib/components/BookingSection.svelte';
|
||||
import PromiseSection from '$lib/components/PromiseSection.svelte';
|
||||
import ServicesSection from '$lib/components/ServicesSection.svelte';
|
||||
import TestimonialsSection from '$lib/components/TestimonialsSection.svelte';
|
||||
import ValuesSection from '$lib/components/ValuesSection.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
const siteUrl = 'https://www.goodwalk.co.nz';
|
||||
|
||||
$: homepageStructuredData = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: 'Goodwalk',
|
||||
url: siteUrl,
|
||||
inLanguage: 'en-NZ'
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'LocalBusiness',
|
||||
name: 'Goodwalk',
|
||||
description:
|
||||
'Professional dog walking services across Auckland Central, including pack walks, 1:1 walks, and puppy visits.',
|
||||
url: siteUrl,
|
||||
logo: `${siteUrl}/images/goodwalk-auckland-dog-walking-logo.png`,
|
||||
image: data.content.hero.imageUrl,
|
||||
email: 'info@goodwalk.co.nz',
|
||||
telephone: '+64-22-642-1011',
|
||||
sameAs: ['https://www.instagram.com/goodwalk.nz/', 'https://g.page/r/CUsvrWPhkYrAEB0/'],
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: 'Auckland Central',
|
||||
addressRegion: 'Auckland',
|
||||
addressCountry: 'NZ'
|
||||
},
|
||||
areaServed: [
|
||||
'Morningside',
|
||||
'Kingsland',
|
||||
'Ponsonby',
|
||||
'Grey Lynn',
|
||||
'Mt Albert',
|
||||
'Mt Eden',
|
||||
'Sandringham',
|
||||
'Mt Roskill',
|
||||
'Arch Hill',
|
||||
'Freemans Bay',
|
||||
'Herne Bay',
|
||||
'Pt Chevalier',
|
||||
'Avondale',
|
||||
'Three Kings',
|
||||
'Hillsborough',
|
||||
'Eden Terrace',
|
||||
'Balmoral'
|
||||
],
|
||||
openingHoursSpecification: [
|
||||
{
|
||||
'@type': 'OpeningHoursSpecification',
|
||||
dayOfWeek: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
|
||||
opens: '08:00',
|
||||
closes: '16:00'
|
||||
}
|
||||
],
|
||||
hasOfferCatalog: {
|
||||
'@type': 'OfferCatalog',
|
||||
name: 'Dog Walking Services',
|
||||
itemListElement: data.content.services.map((service) => ({
|
||||
'@type': 'Offer',
|
||||
itemOffered: {
|
||||
'@type': 'Service',
|
||||
name: service.title,
|
||||
url: `${siteUrl}${service.href}`
|
||||
}
|
||||
}))
|
||||
}
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: data.content.info.faqs.map((faq) => ({
|
||||
'@type': 'Question',
|
||||
name: faq.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: faq.answer
|
||||
}
|
||||
}))
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<SeoHead
|
||||
title={data.content.seo.title}
|
||||
description={data.content.seo.description}
|
||||
canonicalPath="/"
|
||||
image={data.content.hero.imageUrl}
|
||||
imageAlt={data.content.hero.imageAlt}
|
||||
structuredData={homepageStructuredData}
|
||||
preloadImage={true}
|
||||
/>
|
||||
|
||||
<Header navigation={data.content.navigation} />
|
||||
<HeroSection hero={data.content.hero} />
|
||||
<IntroStrip intro={data.content.intro} />
|
||||
<PromiseSection promise={data.content.promise} />
|
||||
<ServicesSection services={data.content.services} />
|
||||
<ValuesSection values={data.content.values} />
|
||||
<TestimonialsSection testimonials={data.content.testimonials} />
|
||||
<BookingSection booking={data.content.booking} />
|
||||
<InfoSection info={data.content.info} />
|
||||
<InstagramSection instagram={data.content.instagram} />
|
||||
<Footer footer={data.content.footer} />
|
||||
@@ -0,0 +1,22 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { staticPages, type StaticPageSlug } from '$lib/content/static-pages';
|
||||
import { getSharedPageContent } from '$lib/server/content';
|
||||
|
||||
export async function load({ params }) {
|
||||
if (params.slug === 'about-us') {
|
||||
throw redirect(301, '/about');
|
||||
}
|
||||
|
||||
const slug = params.slug as StaticPageSlug;
|
||||
const page = staticPages[slug];
|
||||
|
||||
if (!page) {
|
||||
throw error(404, 'Page not found');
|
||||
}
|
||||
|
||||
return {
|
||||
content: await getSharedPageContent(),
|
||||
page,
|
||||
slug
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
<script lang="ts">
|
||||
import SeoHead from '$lib/components/SeoHead.svelte';
|
||||
import AboutPage from '$lib/components/AboutPage.svelte';
|
||||
import Footer from '$lib/components/Footer.svelte';
|
||||
import Header from '$lib/components/Header.svelte';
|
||||
import BookingPage from '$lib/components/BookingPage.svelte';
|
||||
import LegalPage from '$lib/components/LegalPage.svelte';
|
||||
import PricingPage from '$lib/components/PricingPage.svelte';
|
||||
import { aboutPageContent } from '$lib/content/about';
|
||||
import ServiceLandingPage from '$lib/components/ServiceLandingPage.svelte';
|
||||
import { dogWalkingContent } from '$lib/content/dog-walking';
|
||||
import { ourPricingContent } from '$lib/content/our-pricing';
|
||||
import { packWalksContent } from '$lib/content/pack-walks';
|
||||
import { privacyPolicyContent } from '$lib/content/privacy-policy';
|
||||
import { puppyVisitsContent } from '$lib/content/puppy-visits';
|
||||
import { termsAndConditionsContent } from '$lib/content/terms-and-conditions';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
const siteUrl = 'https://www.goodwalk.co.nz';
|
||||
const defaultSeoImage = '/images/auckland-dog-walking-happy-dog-hero.png';
|
||||
const defaultSeoImageAlt = 'Goodwalk Auckland dog walking services';
|
||||
|
||||
function breadcrumbSchema(name: string, path: string) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'Home',
|
||||
item: siteUrl
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name,
|
||||
item: `${siteUrl}${path}`
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
let seoImage = defaultSeoImage;
|
||||
let seoImageAlt = defaultSeoImageAlt;
|
||||
let preloadHeroImage = false;
|
||||
let pageStructuredData: Record<string, unknown>[] = [];
|
||||
|
||||
$: {
|
||||
seoImage = defaultSeoImage;
|
||||
seoImageAlt = defaultSeoImageAlt;
|
||||
preloadHeroImage = false;
|
||||
pageStructuredData = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
name: data.page.title,
|
||||
description: data.page.description,
|
||||
url: `${siteUrl}${data.page.canonicalPath}`
|
||||
},
|
||||
breadcrumbSchema(data.page.title, data.page.canonicalPath)
|
||||
];
|
||||
|
||||
if (data.slug === 'pack-walks') {
|
||||
preloadHeroImage = true;
|
||||
seoImage = packWalksContent.hero.imageUrl;
|
||||
seoImageAlt = packWalksContent.hero.imageAlt;
|
||||
pageStructuredData = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Service',
|
||||
name: packWalksContent.hero.title,
|
||||
description: data.page.description,
|
||||
serviceType: 'Pack Walks',
|
||||
provider: {
|
||||
'@type': 'LocalBusiness',
|
||||
name: 'Goodwalk',
|
||||
url: siteUrl
|
||||
},
|
||||
areaServed: 'Auckland Central, New Zealand',
|
||||
image: seoImage,
|
||||
url: `${siteUrl}${data.page.canonicalPath}`
|
||||
},
|
||||
breadcrumbSchema('Pack Walks', data.page.canonicalPath)
|
||||
];
|
||||
} else if (data.slug === 'dog-walking') {
|
||||
preloadHeroImage = true;
|
||||
seoImage = dogWalkingContent.hero.imageUrl;
|
||||
seoImageAlt = dogWalkingContent.hero.imageAlt;
|
||||
pageStructuredData = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Service',
|
||||
name: dogWalkingContent.hero.title,
|
||||
description: data.page.description,
|
||||
serviceType: '1:1 Dog Walking',
|
||||
provider: {
|
||||
'@type': 'LocalBusiness',
|
||||
name: 'Goodwalk',
|
||||
url: siteUrl
|
||||
},
|
||||
areaServed: 'Auckland Central, New Zealand',
|
||||
image: seoImage,
|
||||
url: `${siteUrl}${data.page.canonicalPath}`
|
||||
},
|
||||
breadcrumbSchema('1:1 Walks', data.page.canonicalPath)
|
||||
];
|
||||
} else if (data.slug === 'puppy-visits') {
|
||||
preloadHeroImage = true;
|
||||
seoImage = puppyVisitsContent.hero.imageUrl;
|
||||
seoImageAlt = puppyVisitsContent.hero.imageAlt;
|
||||
pageStructuredData = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Service',
|
||||
name: puppyVisitsContent.hero.title,
|
||||
description: data.page.description,
|
||||
serviceType: 'Puppy Visits',
|
||||
provider: {
|
||||
'@type': 'LocalBusiness',
|
||||
name: 'Goodwalk',
|
||||
url: siteUrl
|
||||
},
|
||||
areaServed: 'Auckland Central, New Zealand',
|
||||
image: seoImage,
|
||||
url: `${siteUrl}${data.page.canonicalPath}`
|
||||
},
|
||||
breadcrumbSchema('Puppy Visits', data.page.canonicalPath)
|
||||
];
|
||||
} else if (data.slug === 'our-pricing') {
|
||||
pageStructuredData = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: data.page.title,
|
||||
description: data.page.description,
|
||||
url: `${siteUrl}${data.page.canonicalPath}`
|
||||
},
|
||||
breadcrumbSchema('Our Pricing', data.page.canonicalPath)
|
||||
];
|
||||
} else if (data.slug === 'about' || data.slug === 'about-us') {
|
||||
seoImage = aboutPageContent.sections[0].imageUrl;
|
||||
seoImageAlt = aboutPageContent.sections[0].imageAlt;
|
||||
pageStructuredData = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'AboutPage',
|
||||
name: data.page.title,
|
||||
description: data.page.description,
|
||||
url: `${siteUrl}${data.page.canonicalPath}`,
|
||||
image: seoImage
|
||||
},
|
||||
breadcrumbSchema('About Us', data.page.canonicalPath)
|
||||
];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<SeoHead
|
||||
title={data.page.title}
|
||||
description={data.page.description}
|
||||
canonicalPath={data.page.canonicalPath}
|
||||
image={seoImage}
|
||||
imageAlt={seoImageAlt}
|
||||
structuredData={pageStructuredData}
|
||||
preloadImage={preloadHeroImage}
|
||||
/>
|
||||
|
||||
<Header navigation={data.content.navigation} />
|
||||
|
||||
{#if data.slug === 'pack-walks'}
|
||||
<ServiceLandingPage content={data.content} pageContent={packWalksContent} />
|
||||
{:else if data.slug === 'dog-walking'}
|
||||
<ServiceLandingPage content={data.content} pageContent={dogWalkingContent} />
|
||||
{:else if data.slug === 'puppy-visits'}
|
||||
<ServiceLandingPage content={data.content} pageContent={puppyVisitsContent} />
|
||||
{:else if data.slug === 'our-pricing'}
|
||||
<PricingPage content={data.content} pageContent={ourPricingContent} />
|
||||
{:else if data.slug === 'about' || data.slug === 'about-us'}
|
||||
<AboutPage content={data.content} pageContent={aboutPageContent} />
|
||||
{:else if data.slug === 'terms-and-conditions'}
|
||||
<LegalPage pageContent={termsAndConditionsContent} />
|
||||
{:else if data.slug === 'privacy-policy'}
|
||||
<LegalPage pageContent={privacyPolicyContent} />
|
||||
{:else if data.slug === 'booking'}
|
||||
<BookingPage booking={data.content.booking} />
|
||||
{:else}
|
||||
<main class="static-page">
|
||||
<section class="static-page-hero">
|
||||
<div class="static-page-inner">
|
||||
<h1>{data.page.title}</h1>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
<Footer footer={data.content.footer} />
|
||||
|
||||
<style>
|
||||
.static-page {
|
||||
min-height: 50vh;
|
||||
background: var(--off-white);
|
||||
}
|
||||
|
||||
.static-page-hero {
|
||||
padding: 96px 0 120px;
|
||||
}
|
||||
|
||||
.static-page-inner {
|
||||
max-width: var(--max-w);
|
||||
margin: 0 auto;
|
||||
padding: 0 50px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: var(--font-head);
|
||||
font-size: 56px;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.static-page-hero {
|
||||
padding: 56px 0 72px;
|
||||
}
|
||||
|
||||
.static-page-inner {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 34px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { staticPages } from '$lib/content/static-pages';
|
||||
import { sharedPageContent } from '../../test/fixtures';
|
||||
|
||||
const { getSharedPageContent } = vi.hoisted(() => ({
|
||||
getSharedPageContent: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/content', () => ({
|
||||
getSharedPageContent
|
||||
}));
|
||||
|
||||
import { load } from './+page.server';
|
||||
|
||||
describe('static slug page server load', () => {
|
||||
beforeEach(() => {
|
||||
getSharedPageContent.mockReset();
|
||||
});
|
||||
|
||||
it('redirects the legacy about-us slug to /about', async () => {
|
||||
await expect(load({ params: { slug: 'about-us' } } as never)).rejects.toMatchObject({
|
||||
status: 301,
|
||||
location: '/about'
|
||||
});
|
||||
});
|
||||
|
||||
it('throws a 404 for unknown slugs', async () => {
|
||||
await expect(load({ params: { slug: 'missing-page' } } as never)).rejects.toMatchObject({
|
||||
status: 404
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the shared content and page metadata for valid static routes', async () => {
|
||||
getSharedPageContent.mockResolvedValue(sharedPageContent);
|
||||
|
||||
await expect(load({ params: { slug: 'pack-walks' } } as never)).resolves.toEqual({
|
||||
content: sharedPageContent,
|
||||
page: staticPages['pack-walks'],
|
||||
slug: 'pack-walks'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import SlugPage from './+page.svelte';
|
||||
import { createStaticRouteData } from '../../test/fixtures';
|
||||
|
||||
describe('static slug route page', () => {
|
||||
it.each([
|
||||
['pack-walks', 'Join our Tiny Gang!'],
|
||||
['dog-walking', 'Walks for larger breeds, too!'],
|
||||
['puppy-visits', 'Introducing Puppy Visits: Building strong foundations for our pack walks!'],
|
||||
['our-pricing', 'Simple, transparent pricing — no lock-in contracts.'],
|
||||
['about', 'Who we are'],
|
||||
['booking', "Fill in the form below and we'll be in touch to arrange a free introduction."],
|
||||
['terms-and-conditions', '1. Application of Terms'],
|
||||
['privacy-policy', 'How we collect your information']
|
||||
] as const)('renders the %s page branch', (slug, expectedText) => {
|
||||
render(SlugPage, {
|
||||
data: createStaticRouteData(slug)
|
||||
});
|
||||
|
||||
expect(screen.getByText(expectedText)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sets SEO metadata for rendered slug pages', () => {
|
||||
render(SlugPage, {
|
||||
data: createStaticRouteData('about')
|
||||
});
|
||||
|
||||
expect(document.title).toBe('About Us | Dog Walkers | Goodwalk');
|
||||
expect(document.head.innerHTML).toContain('AboutPage');
|
||||
});
|
||||
|
||||
it.each(['terms-and-conditions', 'privacy-policy'] as const)(
|
||||
'does not render booking or testimonial sections on %s',
|
||||
(slug) => {
|
||||
render(SlugPage, {
|
||||
data: createStaticRouteData(slug)
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByText("Fill in the form below and we'll be in touch to arrange a free introduction.")
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Why people choose us!')).not.toBeInTheDocument();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { saveHomepageContent, getHomepageContent } from '$lib/server/content';
|
||||
import { json } from '@sveltejs/kit';
|
||||
|
||||
export async function GET() {
|
||||
return json(await getHomepageContent());
|
||||
}
|
||||
|
||||
export async function PUT({ request }) {
|
||||
const content = await request.json();
|
||||
return json(await saveHomepageContent(content));
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { homepageContent } from '$lib/content/homepage';
|
||||
|
||||
const { getHomepageContent, saveHomepageContent } = vi.hoisted(() => ({
|
||||
getHomepageContent: vi.fn(),
|
||||
saveHomepageContent: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/content', () => ({
|
||||
getHomepageContent,
|
||||
saveHomepageContent
|
||||
}));
|
||||
|
||||
import { GET, PUT } from './+server';
|
||||
|
||||
describe('homepage content endpoint', () => {
|
||||
beforeEach(() => {
|
||||
getHomepageContent.mockReset();
|
||||
saveHomepageContent.mockReset();
|
||||
});
|
||||
|
||||
it('returns homepage content for GET requests', async () => {
|
||||
getHomepageContent.mockResolvedValue(homepageContent);
|
||||
|
||||
const response = await GET();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual(homepageContent);
|
||||
});
|
||||
|
||||
it('saves homepage content for PUT requests', async () => {
|
||||
const updatedContent = {
|
||||
...homepageContent,
|
||||
seo: {
|
||||
...homepageContent.seo,
|
||||
title: 'Updated home title'
|
||||
}
|
||||
};
|
||||
|
||||
saveHomepageContent.mockResolvedValue(updatedContent);
|
||||
|
||||
const response = await PUT({
|
||||
request: new Request('https://www.goodwalk.co.nz/api/content/homepage', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatedContent)
|
||||
})
|
||||
} as never);
|
||||
|
||||
expect(saveHomepageContent).toHaveBeenCalledWith(updatedContent);
|
||||
await expect(response.json()).resolves.toEqual(updatedContent);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getPool } from '$lib/server/db';
|
||||
|
||||
export async function GET() {
|
||||
const pool = getPool();
|
||||
let database: 'disabled' | 'up' | 'down' = 'disabled';
|
||||
|
||||
if (pool) {
|
||||
try {
|
||||
await pool.query('select 1');
|
||||
database = 'up';
|
||||
} catch (error) {
|
||||
console.error('Health check database query failed.', error);
|
||||
database = 'down';
|
||||
}
|
||||
}
|
||||
|
||||
return json(
|
||||
{
|
||||
status: database === 'down' ? 'degraded' : 'ok',
|
||||
database,
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
status: database === 'down' ? 503 : 200
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('$lib/server/db', () => ({
|
||||
getPool: vi.fn()
|
||||
}));
|
||||
|
||||
import { getPool } from '$lib/server/db';
|
||||
import { GET } from './+server';
|
||||
|
||||
describe('health endpoint', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getPool).mockReset();
|
||||
});
|
||||
|
||||
it('reports a disabled database when no connection string is configured', async () => {
|
||||
vi.mocked(getPool).mockReturnValue(null);
|
||||
|
||||
const response = await GET();
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toMatchObject({
|
||||
status: 'ok',
|
||||
database: 'disabled'
|
||||
});
|
||||
});
|
||||
|
||||
it('reports an available database when the query succeeds', async () => {
|
||||
vi.mocked(getPool).mockReturnValue({
|
||||
query: vi.fn().mockResolvedValue({})
|
||||
} as never);
|
||||
|
||||
const response = await GET();
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toMatchObject({
|
||||
status: 'ok',
|
||||
database: 'up'
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a degraded status when the database query fails', async () => {
|
||||
vi.mocked(getPool).mockReturnValue({
|
||||
query: vi.fn().mockRejectedValue(new Error('connection refused'))
|
||||
} as never);
|
||||
|
||||
const response = await GET();
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(body).toMatchObject({
|
||||
status: 'degraded',
|
||||
database: 'down'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { homepageContent } from '$lib/content/homepage';
|
||||
|
||||
const { getHomepageContent } = vi.hoisted(() => ({
|
||||
getHomepageContent: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/content', () => ({
|
||||
getHomepageContent
|
||||
}));
|
||||
|
||||
import { load } from './+page.server';
|
||||
|
||||
describe('home page server load', () => {
|
||||
it('returns homepage content', async () => {
|
||||
getHomepageContent.mockResolvedValue(homepageContent);
|
||||
|
||||
await expect(load()).resolves.toEqual({
|
||||
content: homepageContent
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import HomePage from './+page.svelte';
|
||||
import { createHomepageRouteData } from '../test/fixtures';
|
||||
|
||||
describe('home page route', () => {
|
||||
it('renders the homepage sections and SEO metadata', () => {
|
||||
render(HomePage, {
|
||||
data: createHomepageRouteData()
|
||||
});
|
||||
|
||||
expect(screen.getAllByText("Your Dog's Day!").length).toBeGreaterThan(0);
|
||||
expect(document.body.textContent).toContain('Happy pets,');
|
||||
expect(screen.getByText('Locations & Hours')).toBeInTheDocument();
|
||||
expect(document.title).toBe('Home | Auckland Dog Walking | Goodwalk');
|
||||
expect(document.head.innerHTML).toContain('FAQPage');
|
||||
expect(document.head.innerHTML).toContain('https://www.goodwalk.co.nz/images/auckland-dog-walking-happy-dog-hero.png');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { render } from '@testing-library/svelte';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const afterNavigate = vi.fn();
|
||||
const disableScrollHandling = vi.fn();
|
||||
|
||||
vi.mock('$app/navigation', () => ({
|
||||
afterNavigate: (callback: (args: { from: { url: URL } | null; to: { url: URL } | null }) => void) =>
|
||||
afterNavigate(callback),
|
||||
disableScrollHandling
|
||||
}));
|
||||
|
||||
describe('root layout navigation behavior', () => {
|
||||
beforeEach(() => {
|
||||
afterNavigate.mockClear();
|
||||
disableScrollHandling.mockClear();
|
||||
});
|
||||
|
||||
it('resets scroll position after route changes without hashes', async () => {
|
||||
const { default: Layout } = await import('./+layout.svelte');
|
||||
render(Layout);
|
||||
|
||||
const navigateHandler = afterNavigate.mock.calls[0][0];
|
||||
const scrollToSpy = vi.spyOn(window, 'scrollTo');
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 0;
|
||||
});
|
||||
|
||||
navigateHandler({
|
||||
from: { url: new URL('https://www.goodwalk.co.nz/about') },
|
||||
to: { url: new URL('https://www.goodwalk.co.nz/booking') }
|
||||
});
|
||||
|
||||
expect(disableScrollHandling).toHaveBeenCalledTimes(1);
|
||||
expect(scrollToSpy).toHaveBeenCalledWith({ top: 0, left: 0, behavior: 'auto' });
|
||||
});
|
||||
|
||||
it('does not reset scroll position for hash navigation', async () => {
|
||||
const { default: Layout } = await import('./+layout.svelte');
|
||||
render(Layout);
|
||||
|
||||
const navigateHandler = afterNavigate.mock.calls[0][0];
|
||||
|
||||
navigateHandler({
|
||||
from: { url: new URL('https://www.goodwalk.co.nz/about') },
|
||||
to: { url: new URL('https://www.goodwalk.co.nz/about#team') }
|
||||
});
|
||||
|
||||
expect(disableScrollHandling).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = () => {
|
||||
const body = [
|
||||
'User-agent: *',
|
||||
'Allow: /',
|
||||
'',
|
||||
'Sitemap: https://www.goodwalk.co.nz/sitemap.xml'
|
||||
].join('\n');
|
||||
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { GET } from './+server';
|
||||
|
||||
describe('robots endpoint', () => {
|
||||
it('returns the crawl policy and sitemap location', async () => {
|
||||
const response = GET();
|
||||
const body = await response.text();
|
||||
|
||||
expect(response.headers.get('content-type')).toBe('text/plain; charset=utf-8');
|
||||
expect(body).toContain('User-agent: *');
|
||||
expect(body).toContain('Allow: /');
|
||||
expect(body).toContain('Sitemap: https://www.goodwalk.co.nz/sitemap.xml');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const siteUrl = 'https://www.goodwalk.co.nz';
|
||||
const routes = [
|
||||
'/',
|
||||
'/pack-walks',
|
||||
'/dog-walking',
|
||||
'/puppy-visits',
|
||||
'/our-pricing',
|
||||
'/about',
|
||||
'/booking',
|
||||
'/terms-and-conditions',
|
||||
'/privacy-policy'
|
||||
];
|
||||
|
||||
export const GET: RequestHandler = () => {
|
||||
const lastmod = new Date().toISOString().split('T')[0];
|
||||
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${routes
|
||||
.map(
|
||||
(path) => ` <url>
|
||||
<loc>${siteUrl}${path}</loc>
|
||||
<lastmod>${lastmod}</lastmod>
|
||||
<changefreq>${path === '/' ? 'weekly' : 'monthly'}</changefreq>
|
||||
<priority>${path === '/' ? '1.0' : '0.8'}</priority>
|
||||
</url>`
|
||||
)
|
||||
.join('\n')}
|
||||
</urlset>`;
|
||||
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml; charset=utf-8'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { GET } from './+server';
|
||||
|
||||
describe('sitemap endpoint', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns a sitemap covering the published routes', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-01T09:15:00Z'));
|
||||
|
||||
const response = GET();
|
||||
const body = await response.text();
|
||||
|
||||
expect(response.headers.get('content-type')).toBe('application/xml; charset=utf-8');
|
||||
expect(body).toContain('<loc>https://www.goodwalk.co.nz/</loc>');
|
||||
expect(body).toContain('<loc>https://www.goodwalk.co.nz/privacy-policy</loc>');
|
||||
expect(body).toContain('<lastmod>2026-05-01</lastmod>');
|
||||
expect(body.match(/<url>/g)).toHaveLength(9);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { homepageContent } from '$lib/content/homepage';
|
||||
import { staticPages, type StaticPageSlug } from '$lib/content/static-pages';
|
||||
|
||||
export const sharedPageContent = {
|
||||
navigation: homepageContent.navigation,
|
||||
services: homepageContent.services,
|
||||
testimonials: homepageContent.testimonials,
|
||||
booking: homepageContent.booking,
|
||||
footer: homepageContent.footer
|
||||
};
|
||||
|
||||
export function createHomepageRouteData() {
|
||||
return {
|
||||
content: homepageContent
|
||||
};
|
||||
}
|
||||
|
||||
export function createStaticRouteData(slug: StaticPageSlug) {
|
||||
return {
|
||||
content: sharedPageContent,
|
||||
page: staticPages[slug],
|
||||
slug
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export type MockPageStoreValue = {
|
||||
url: URL;
|
||||
params: Record<string, string>;
|
||||
route: {
|
||||
id: string | null;
|
||||
};
|
||||
status: number;
|
||||
error: App.Error | null;
|
||||
data: Record<string, unknown>;
|
||||
form: Record<string, unknown> | null;
|
||||
state: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function createMockPage(
|
||||
url = 'https://www.goodwalk.co.nz/',
|
||||
overrides: Partial<MockPageStoreValue> = {}
|
||||
): MockPageStoreValue {
|
||||
return {
|
||||
url: new URL(url),
|
||||
params: {},
|
||||
route: { id: null },
|
||||
status: 200,
|
||||
error: null,
|
||||
data: {},
|
||||
form: null,
|
||||
state: {},
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
export const pageStore = writable<MockPageStoreValue>(createMockPage());
|
||||
export const navigatingStore = writable<null>(null);
|
||||
const updatedWritable = writable(false);
|
||||
|
||||
export const updatedStore = {
|
||||
subscribe: updatedWritable.subscribe,
|
||||
check: async () => false
|
||||
};
|
||||
|
||||
export function setMockPage(url: string, overrides: Partial<MockPageStoreValue> = {}) {
|
||||
pageStore.set(createMockPage(url, overrides));
|
||||
}
|
||||
|
||||
export function resetMockPage() {
|
||||
pageStore.set(createMockPage());
|
||||
}
|
||||
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 619 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 564 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 261 KiB |