Add honeypot, spam protection to contact form
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import SuccessModal from '$lib/components/SuccessModal.svelte';
|
||||
import ErrorModal from '$lib/components/ErrorModal.svelte';
|
||||
@@ -17,6 +18,8 @@
|
||||
let location = '';
|
||||
let message = '';
|
||||
let selectedServices: string[] = [];
|
||||
let website = '';
|
||||
let formStartedAt = 0;
|
||||
|
||||
let fullNameInput: HTMLInputElement;
|
||||
let emailInput: HTMLInputElement;
|
||||
@@ -63,6 +66,10 @@
|
||||
$: ownerStepLabel = booking.ownerStepLabel?.trim() || 'Owner Details';
|
||||
$: dogStepLabel = booking.dogStepLabel?.trim() || 'Your dog';
|
||||
|
||||
onMount(() => {
|
||||
formStartedAt = Date.now();
|
||||
});
|
||||
|
||||
function splitBookingTitle(title: string) {
|
||||
const trimmed = title.trim();
|
||||
const lastSpace = trimmed.lastIndexOf(' ');
|
||||
@@ -147,6 +154,8 @@
|
||||
body: JSON.stringify({
|
||||
fullName, email, phone, petName, location, message,
|
||||
services: selectedServices,
|
||||
website,
|
||||
formStartedAt,
|
||||
referrer: document.referrer,
|
||||
page: window.location.href,
|
||||
}),
|
||||
@@ -224,6 +233,18 @@
|
||||
novalidate
|
||||
on:submit={handleSubmit}
|
||||
>
|
||||
<div class="booking-honeypot" aria-hidden="true">
|
||||
<label for="website">Website</label>
|
||||
<input
|
||||
bind:value={website}
|
||||
type="text"
|
||||
id="website"
|
||||
name="website"
|
||||
tabindex="-1"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if step === 1}
|
||||
<div class="booking-panel">
|
||||
{#if hasBanner}
|
||||
@@ -443,4 +464,15 @@
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
.booking-honeypot {
|
||||
position: absolute;
|
||||
left: -10000px;
|
||||
top: auto;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -101,8 +101,10 @@ describe('BookingSection', () => {
|
||||
location: 'Kingsland',
|
||||
message: 'Loves small group walks.',
|
||||
services: ['Pack Walks', 'Other Services'],
|
||||
website: '',
|
||||
referrer: 'https://www.google.com/'
|
||||
});
|
||||
expect(payload.formStartedAt).toEqual(expect.any(Number));
|
||||
|
||||
expect(screen.getByRole('dialog', { name: /Booking confirmed/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /on our radar/i })).toBeInTheDocument();
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
<div class="footer-action">
|
||||
<p class="footer-col-label">Get Started</p>
|
||||
<a href="/booking" class="footer-book-btn">
|
||||
<a href="/contact-us" class="footer-book-btn">
|
||||
Book a Meet & Greet
|
||||
<Icon name="fas fa-arrow-right" />
|
||||
</a>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { reveal } from '$lib/actions/reveal';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { IconCard } from '$lib/types';
|
||||
|
||||
@@ -6,7 +7,7 @@
|
||||
|
||||
</script>
|
||||
|
||||
<section id="services">
|
||||
<section id="services" use:reveal={{ delay: 20 }} class="reveal-block">
|
||||
<div class="services-inner">
|
||||
<h2 class="section-heading">What we do</h2>
|
||||
|
||||
@@ -27,3 +28,49 @@
|
||||
</div>
|
||||
</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);
|
||||
}
|
||||
|
||||
@media (hover: hover) and (min-width: 769px) {
|
||||
:global(.reveal-visible.reveal-block) .service-card {
|
||||
animation: service-card-settle 0.28s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
:global(.reveal-visible.reveal-block) .service-card:nth-child(1) {
|
||||
animation-delay: 0.02s;
|
||||
}
|
||||
|
||||
:global(.reveal-visible.reveal-block) .service-card:nth-child(2) {
|
||||
animation-delay: 0.06s;
|
||||
}
|
||||
|
||||
:global(.reveal-visible.reveal-block) .service-card:nth-child(3) {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes service-card-settle {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -40,7 +40,7 @@ export const aboutPageContent: AboutPageContent = {
|
||||
phone: '(022) 642 1011',
|
||||
cta: {
|
||||
label: 'Contact us',
|
||||
href: '/booking',
|
||||
href: '/contact-us',
|
||||
variant: 'yellow'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export const dogWalkingContent: ServicePageContent = {
|
||||
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',
|
||||
formAction: '/contact-us',
|
||||
serviceOptions: [],
|
||||
ownerStepLabel: 'Your details',
|
||||
dogStepLabel: 'Your dog',
|
||||
|
||||
@@ -19,12 +19,12 @@ export const homepageContent: HomePageContent = {
|
||||
{ label: 'Puppy Visits', href: '/puppy-visits' },
|
||||
{ label: 'Our Pricing', href: '/our-pricing' },
|
||||
{ label: 'About Us', href: '/about' },
|
||||
{ label: 'Contact Us', href: '/booking' }
|
||||
{ label: 'Contact Us', href: '/contact-us' }
|
||||
],
|
||||
cta: { label: 'Contact Us', href: '/booking', variant: 'yellow' },
|
||||
cta: { label: 'Contact Us', href: '/contact-us', 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-paw', label: 'Pack Walks', description: 'Tiny Gang 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' }
|
||||
]
|
||||
@@ -60,7 +60,7 @@ export const homepageContent: HomePageContent = {
|
||||
{
|
||||
icon: 'fas fa-dog',
|
||||
title: 'Pack Walks',
|
||||
body: 'Small group walks of 4-8 dogs - calm, social, and full of fun for your pup.',
|
||||
body: 'Small group Tiny Gang walks of 4-8 dogs - calm, social, and full of fun for your pup.',
|
||||
href: '/pack-walks'
|
||||
},
|
||||
{
|
||||
@@ -150,7 +150,7 @@ export const homepageContent: HomePageContent = {
|
||||
title: "Let's meet!",
|
||||
subtitle:
|
||||
'Ready to get started? Book your free, no-obligation Meet & Greet today — just enter your details below',
|
||||
formAction: '/booking',
|
||||
formAction: '/contact-us',
|
||||
serviceOptions: ['Pack Walks', '1:1 Walks', 'Puppy Visits', 'Other Services']
|
||||
},
|
||||
info: {
|
||||
@@ -205,10 +205,10 @@ export const homepageContent: HomePageContent = {
|
||||
{ label: '1:1 Walks', href: '/dog-walking' },
|
||||
{ label: 'Puppy Visits', href: '/puppy-visits' },
|
||||
{ label: 'Our Pricing', href: '/our-pricing' },
|
||||
{ label: 'Contact Us', href: '/booking' }
|
||||
{ label: 'Contact Us', href: '/contact-us' }
|
||||
],
|
||||
contactLinks: [
|
||||
{ label: 'Book a walk', href: '/booking' },
|
||||
{ label: 'Book a walk', href: '/contact-us' },
|
||||
{ label: 'Instagram', href: 'https://www.instagram.com/goodwalk.nz/', external: true },
|
||||
{ label: 'Google Reviews', href: 'https://g.page/r/CUsvrWPhkYrAEB0', external: true }
|
||||
],
|
||||
|
||||
@@ -48,7 +48,7 @@ export const ourPricingContent: PricingPageContent = {
|
||||
booking: {
|
||||
title: 'Ready to join the Tiny Gang?',
|
||||
subtitle: '',
|
||||
formAction: '/booking',
|
||||
formAction: '/contact-us',
|
||||
serviceOptions: [],
|
||||
ownerStepLabel: 'Your details',
|
||||
dogStepLabel: 'Dog details',
|
||||
|
||||
@@ -88,7 +88,7 @@ export const packWalksContent: ServicePageContent = {
|
||||
booking: {
|
||||
title: 'Join the Tiny Gang!',
|
||||
subtitle: '',
|
||||
formAction: '/booking',
|
||||
formAction: '/contact-us',
|
||||
serviceOptions: [],
|
||||
ownerStepLabel: 'Your details',
|
||||
dogStepLabel: 'Dog details',
|
||||
|
||||
@@ -58,7 +58,7 @@ export const puppyVisitsContent: ServicePageContent = {
|
||||
booking: {
|
||||
title: 'Ready to join the Tiny Gang?',
|
||||
subtitle: '',
|
||||
formAction: '/booking',
|
||||
formAction: '/contact-us',
|
||||
serviceOptions: [],
|
||||
ownerStepLabel: 'Your details',
|
||||
dogStepLabel: 'Dog details',
|
||||
|
||||
@@ -35,10 +35,10 @@ export const staticPages = {
|
||||
'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',
|
||||
'contact-us': {
|
||||
title: 'Contact Us',
|
||||
description: 'Book a Meet & Greet with Goodwalk Auckland dog walking services.',
|
||||
canonicalPath: '/booking'
|
||||
canonicalPath: '/contact-us'
|
||||
},
|
||||
'terms-and-conditions': {
|
||||
title: 'Terms & Conditions',
|
||||
|
||||
@@ -112,7 +112,11 @@ nav {
|
||||
flex: 1;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
transition: background 0.15s;
|
||||
transform: translateY(0);
|
||||
transition:
|
||||
background 0.15s,
|
||||
transform 0.18s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.mega-service:hover {
|
||||
@@ -129,13 +133,62 @@ nav {
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
transition: background 0.15s;
|
||||
box-shadow: 0 10px 22px rgba(33, 48, 33, 0.12);
|
||||
transform: translateY(0) rotate(0deg) scale(1);
|
||||
transition:
|
||||
background 0.15s,
|
||||
transform 0.2s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.mega-service:hover .mega-icon {
|
||||
background: #2d4230;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (min-width: 769px) {
|
||||
.has-mega:hover .mega-service {
|
||||
animation: mega-service-settle 0.28s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.has-mega:hover .mega-service:nth-child(1) {
|
||||
animation-delay: 0.02s;
|
||||
}
|
||||
|
||||
.has-mega:hover .mega-service:nth-child(2) {
|
||||
animation-delay: 0.06s;
|
||||
}
|
||||
|
||||
.has-mega:hover .mega-service:nth-child(3) {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
.mega-service:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 26px rgba(17, 20, 24, 0.08);
|
||||
}
|
||||
|
||||
.mega-service:hover .mega-icon {
|
||||
transform: translateY(-3px) rotate(-5deg) scale(1.04);
|
||||
box-shadow: 0 16px 28px rgba(33, 48, 33, 0.18);
|
||||
}
|
||||
|
||||
.mega-service:nth-child(even):hover .mega-icon {
|
||||
transform: translateY(-3px) rotate(5deg) scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mega-service-settle {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.mega-service-label {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
|
||||
@@ -196,6 +196,10 @@ section {
|
||||
|
||||
.service-icon-bubble {
|
||||
background: linear-gradient(180deg, #ffe173 0%, #ffd54a 100%);
|
||||
transform: translateY(0) rotate(0deg) scale(1);
|
||||
transition:
|
||||
transform 0.2s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
@@ -205,6 +209,19 @@ section {
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: hover) and (min-width: 769px) {
|
||||
.service-card:hover .service-icon-bubble {
|
||||
transform: translateY(-3px) rotate(-5deg) scale(1.04);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(17, 20, 24, 0.05),
|
||||
0 16px 28px rgba(17, 20, 24, 0.14);
|
||||
}
|
||||
|
||||
.service-card:nth-child(even):hover .service-icon-bubble {
|
||||
transform: translateY(-3px) rotate(5deg) scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
.service-card:active {
|
||||
transform: translateY(-1px) scale(0.992);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
<div class="error-actions">
|
||||
<a href="/" class="btn btn-yellow">Take me home</a>
|
||||
<a href="/booking" class="btn btn-outline">Book a Meet & Greet</a>
|
||||
<a href="/contact-us" class="btn btn-outline">Book a Meet & Greet</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -7,6 +7,10 @@ export async function load({ params }) {
|
||||
throw redirect(301, '/about');
|
||||
}
|
||||
|
||||
if (params.slug === 'booking') {
|
||||
throw redirect(301, '/contact-us');
|
||||
}
|
||||
|
||||
const slug = params.slug as StaticPageSlug;
|
||||
const page = staticPages[slug];
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@
|
||||
<LegalPage pageContent={termsAndConditionsContent} />
|
||||
{:else if data.slug === 'privacy-policy'}
|
||||
<LegalPage pageContent={privacyPolicyContent} />
|
||||
{:else if data.slug === 'booking'}
|
||||
{:else if data.slug === 'contact-us'}
|
||||
<BookingPage booking={data.content.booking} />
|
||||
{:else}
|
||||
<main class="static-page">
|
||||
|
||||
@@ -24,6 +24,13 @@ describe('static slug page server load', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects the legacy booking slug to /contact-us', async () => {
|
||||
await expect(load({ params: { slug: 'booking' } } as never)).rejects.toMatchObject({
|
||||
status: 301,
|
||||
location: '/contact-us'
|
||||
});
|
||||
});
|
||||
|
||||
it('throws a 404 for unknown slugs', async () => {
|
||||
await expect(load({ params: { slug: 'missing-page' } } as never)).rejects.toMatchObject({
|
||||
status: 404
|
||||
|
||||
@@ -10,7 +10,7 @@ describe('static slug route page', () => {
|
||||
['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."],
|
||||
['contact-us', "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) => {
|
||||
|
||||
@@ -29,7 +29,7 @@ describe('root layout navigation behavior', () => {
|
||||
|
||||
navigateHandler({
|
||||
from: { url: new URL('https://www.goodwalk.co.nz/about') },
|
||||
to: { url: new URL('https://www.goodwalk.co.nz/booking') }
|
||||
to: { url: new URL('https://www.goodwalk.co.nz/contact-us') }
|
||||
});
|
||||
|
||||
expect(disableScrollHandling).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -8,7 +8,7 @@ const routes = [
|
||||
'/puppy-visits',
|
||||
'/our-pricing',
|
||||
'/about',
|
||||
'/booking',
|
||||
'/contact-us',
|
||||
'/terms-and-conditions',
|
||||
'/privacy-policy'
|
||||
];
|
||||
|
||||
@@ -15,6 +15,7 @@ describe('sitemap endpoint', () => {
|
||||
|
||||
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/contact-us</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);
|
||||
|
||||
Reference in New Issue
Block a user