Add honeypot, spam protection to contact form
This commit is contained in:
Binary file not shown.
@@ -2,3 +2,5 @@
|
|||||||
2026-05-02 09:07:05 CRITICAL mail-api: Required environment variable RESEND_API_KEY is not set
|
2026-05-02 09:07:05 CRITICAL mail-api: Required environment variable RESEND_API_KEY is not set
|
||||||
2026-05-02 09:07:45 INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
|
2026-05-02 09:07:45 INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
|
||||||
2026-05-02 09:07:45 CRITICAL mail-api: Startup aborted: missing env vars: ['RESEND_API_KEY', 'OWNER_EMAIL']
|
2026-05-02 09:07:45 CRITICAL mail-api: Startup aborted: missing env vars: ['RESEND_API_KEY', 'OWNER_EMAIL']
|
||||||
|
2026-05-02 11:16:43 INFO mail-api: Logging initialised → console=INFO, file=logs\mail-api.log (DEBUG, rotating)
|
||||||
|
2026-05-02 11:16:43 CRITICAL mail-api: Startup aborted: missing env vars: ['RESEND_API_KEY', 'OWNER_EMAIL']
|
||||||
|
|||||||
+156
-2
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
from collections import deque
|
||||||
import logging
|
import logging
|
||||||
import logging.handlers
|
import logging.handlers
|
||||||
import os
|
import os
|
||||||
@@ -89,6 +90,12 @@ def _load_config() -> dict:
|
|||||||
"from_email": os.environ.get("FROM_EMAIL", "GoodWalk <bookings@goodwalk.co.nz>"),
|
"from_email": os.environ.get("FROM_EMAIL", "GoodWalk <bookings@goodwalk.co.nz>"),
|
||||||
"reply_to": os.environ.get("REPLY_TO", "aless@goodwalk.co.nz"),
|
"reply_to": os.environ.get("REPLY_TO", "aless@goodwalk.co.nz"),
|
||||||
"max_attempts": max(1, int(os.environ.get("MAIL_MAX_ATTEMPTS", "3"))),
|
"max_attempts": max(1, int(os.environ.get("MAIL_MAX_ATTEMPTS", "3"))),
|
||||||
|
"form_min_seconds": max(1, int(os.environ.get("FORM_MIN_SECONDS", "4"))),
|
||||||
|
"form_max_seconds": max(60, int(os.environ.get("FORM_MAX_SECONDS", "7200"))),
|
||||||
|
"rate_limit_window_seconds": max(60, int(os.environ.get("RATE_LIMIT_WINDOW_SECONDS", "900"))),
|
||||||
|
"rate_limit_max_per_ip": max(1, int(os.environ.get("RATE_LIMIT_MAX_PER_IP", "5"))),
|
||||||
|
"rate_limit_max_per_email": max(1, int(os.environ.get("RATE_LIMIT_MAX_PER_EMAIL", "3"))),
|
||||||
|
"rate_limit_min_interval_seconds": max(1, int(os.environ.get("RATE_LIMIT_MIN_INTERVAL_SECONDS", "20"))),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -98,12 +105,27 @@ OWNER_EMAIL = _config["owner_email"]
|
|||||||
FROM_EMAIL = _config["from_email"]
|
FROM_EMAIL = _config["from_email"]
|
||||||
REPLY_TO = _config["reply_to"]
|
REPLY_TO = _config["reply_to"]
|
||||||
MAX_SEND_ATTEMPTS = _config["max_attempts"]
|
MAX_SEND_ATTEMPTS = _config["max_attempts"]
|
||||||
|
FORM_MIN_SECONDS = _config["form_min_seconds"]
|
||||||
|
FORM_MAX_SECONDS = _config["form_max_seconds"]
|
||||||
|
RATE_LIMIT_WINDOW_SECONDS = _config["rate_limit_window_seconds"]
|
||||||
|
RATE_LIMIT_MAX_PER_IP = _config["rate_limit_max_per_ip"]
|
||||||
|
RATE_LIMIT_MAX_PER_EMAIL = _config["rate_limit_max_per_email"]
|
||||||
|
RATE_LIMIT_MIN_INTERVAL_SECONDS = _config["rate_limit_min_interval_seconds"]
|
||||||
|
|
||||||
LOGO_URL = "https://www.goodwalk.co.nz/static/images/goodwalk-auckland-dog-walking-logo.png"
|
LOGO_URL = "https://www.goodwalk.co.nz/static/images/goodwalk-auckland-dog-walking-logo.png"
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Mail API config: from=%r reply_to=%r owner=%r max_attempts=%d",
|
"Mail API config: from=%r reply_to=%r owner=%r max_attempts=%d form_min=%ss form_max=%ss rate_window=%ss per_ip=%d per_email=%d min_interval=%ss",
|
||||||
FROM_EMAIL, REPLY_TO, OWNER_EMAIL, MAX_SEND_ATTEMPTS,
|
FROM_EMAIL,
|
||||||
|
REPLY_TO,
|
||||||
|
OWNER_EMAIL,
|
||||||
|
MAX_SEND_ATTEMPTS,
|
||||||
|
FORM_MIN_SECONDS,
|
||||||
|
FORM_MAX_SECONDS,
|
||||||
|
RATE_LIMIT_WINDOW_SECONDS,
|
||||||
|
RATE_LIMIT_MAX_PER_IP,
|
||||||
|
RATE_LIMIT_MAX_PER_EMAIL,
|
||||||
|
RATE_LIMIT_MIN_INTERVAL_SECONDS,
|
||||||
)
|
)
|
||||||
|
|
||||||
app = FastAPI(title="GoodWalk Mail API")
|
app = FastAPI(title="GoodWalk Mail API")
|
||||||
@@ -147,6 +169,8 @@ class BookingSubmission(BaseModel):
|
|||||||
location: str
|
location: str
|
||||||
message: str = ""
|
message: str = ""
|
||||||
services: list[str] = []
|
services: list[str] = []
|
||||||
|
website: str = ""
|
||||||
|
formStartedAt: int | None = None
|
||||||
referrer: str = ""
|
referrer: str = ""
|
||||||
page: str = ""
|
page: str = ""
|
||||||
|
|
||||||
@@ -160,6 +184,119 @@ def _get_ip(request: Request) -> str:
|
|||||||
return request.client.host if request.client else "unknown"
|
return request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
_submit_attempts_by_ip: dict[str, deque[float]] = {}
|
||||||
|
_submit_attempts_by_email: dict[str, deque[float]] = {}
|
||||||
|
_submit_rate_limit_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _trimmed(value: str) -> str:
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_attempts(attempts: deque[float], now: float, window_seconds: int) -> None:
|
||||||
|
while attempts and now - attempts[0] > window_seconds:
|
||||||
|
attempts.popleft()
|
||||||
|
|
||||||
|
|
||||||
|
def _seconds_until_allowed(last_attempt_at: float, now: float, min_interval_seconds: int) -> int:
|
||||||
|
retry_after = max(1, int(min_interval_seconds - (now - last_attempt_at)))
|
||||||
|
return retry_after
|
||||||
|
|
||||||
|
|
||||||
|
async def _enforce_submit_rate_limits(request_id: str, ip: str, email: str) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
normalized_email = email.strip().lower()
|
||||||
|
|
||||||
|
async with _submit_rate_limit_lock:
|
||||||
|
ip_attempts = _submit_attempts_by_ip.setdefault(ip, deque())
|
||||||
|
email_attempts = _submit_attempts_by_email.setdefault(normalized_email, deque())
|
||||||
|
|
||||||
|
_prune_attempts(ip_attempts, now, RATE_LIMIT_WINDOW_SECONDS)
|
||||||
|
_prune_attempts(email_attempts, now, RATE_LIMIT_WINDOW_SECONDS)
|
||||||
|
|
||||||
|
if ip_attempts and now - ip_attempts[-1] < RATE_LIMIT_MIN_INTERVAL_SECONDS:
|
||||||
|
retry_after = _seconds_until_allowed(ip_attempts[-1], now, RATE_LIMIT_MIN_INTERVAL_SECONDS)
|
||||||
|
logger.warning(
|
||||||
|
"[%s] rate limited: ip=%s submitted again after %.1fs (minimum %ss)",
|
||||||
|
request_id,
|
||||||
|
ip,
|
||||||
|
now - ip_attempts[-1],
|
||||||
|
RATE_LIMIT_MIN_INTERVAL_SECONDS,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail=f"Please wait about {retry_after} seconds before trying again.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(ip_attempts) >= RATE_LIMIT_MAX_PER_IP:
|
||||||
|
logger.warning(
|
||||||
|
"[%s] rate limited: ip=%s exceeded %d submissions in %ss",
|
||||||
|
request_id,
|
||||||
|
ip,
|
||||||
|
RATE_LIMIT_MAX_PER_IP,
|
||||||
|
RATE_LIMIT_WINDOW_SECONDS,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="Too many enquiries from this connection. Please try again a little later.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(email_attempts) >= RATE_LIMIT_MAX_PER_EMAIL:
|
||||||
|
logger.warning(
|
||||||
|
"[%s] rate limited: email=%s exceeded %d submissions in %ss",
|
||||||
|
request_id,
|
||||||
|
normalized_email,
|
||||||
|
RATE_LIMIT_MAX_PER_EMAIL,
|
||||||
|
RATE_LIMIT_WINDOW_SECONDS,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="That email address has reached the enquiry limit for now. Please try again later.",
|
||||||
|
)
|
||||||
|
|
||||||
|
ip_attempts.append(now)
|
||||||
|
email_attempts.append(now)
|
||||||
|
|
||||||
|
|
||||||
|
def _enforce_form_timing(request_id: str, data: BookingSubmission) -> None:
|
||||||
|
if data.formStartedAt is None or data.formStartedAt <= 0:
|
||||||
|
logger.warning("[%s] rejected: missing or invalid formStartedAt", request_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Please refresh the page and try again.",
|
||||||
|
)
|
||||||
|
|
||||||
|
elapsed_seconds = (time.time() * 1000 - data.formStartedAt) / 1000
|
||||||
|
|
||||||
|
if elapsed_seconds < FORM_MIN_SECONDS:
|
||||||
|
logger.warning(
|
||||||
|
"[%s] rejected: form submitted too quickly (%.2fs < %ss)",
|
||||||
|
request_id,
|
||||||
|
elapsed_seconds,
|
||||||
|
FORM_MIN_SECONDS,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Please take a moment to fill in the form before sending it.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if elapsed_seconds > FORM_MAX_SECONDS:
|
||||||
|
logger.warning(
|
||||||
|
"[%s] rejected: stale form submission (%.0fs > %ss)",
|
||||||
|
request_id,
|
||||||
|
elapsed_seconds,
|
||||||
|
FORM_MAX_SECONDS,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="This form has been open for too long. Please refresh the page and try again.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_honeypot_triggered(data: BookingSubmission) -> bool:
|
||||||
|
return bool(_trimmed(data.website))
|
||||||
|
|
||||||
|
|
||||||
def _parse_ua(ua: str) -> str:
|
def _parse_ua(ua: str) -> str:
|
||||||
if not ua:
|
if not ua:
|
||||||
return "Unknown"
|
return "Unknown"
|
||||||
@@ -596,6 +733,23 @@ async def submit_booking(data: BookingSubmission, request: Request):
|
|||||||
)
|
)
|
||||||
logger.debug("[%s] full payload: %s", request_id, data.model_dump())
|
logger.debug("[%s] full payload: %s", request_id, data.model_dump())
|
||||||
|
|
||||||
|
await _enforce_submit_rate_limits(request_id, ip, str(data.email))
|
||||||
|
_enforce_form_timing(request_id, data)
|
||||||
|
|
||||||
|
if _is_honeypot_triggered(data):
|
||||||
|
logger.warning(
|
||||||
|
"[%s] honeypot triggered for ip=%s email=%s page=%r",
|
||||||
|
request_id,
|
||||||
|
ip,
|
||||||
|
data.email,
|
||||||
|
data.page,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"request_id": request_id,
|
||||||
|
"ignored": True,
|
||||||
|
}
|
||||||
|
|
||||||
failures: list[dict] = []
|
failures: list[dict] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import SuccessModal from '$lib/components/SuccessModal.svelte';
|
import SuccessModal from '$lib/components/SuccessModal.svelte';
|
||||||
import ErrorModal from '$lib/components/ErrorModal.svelte';
|
import ErrorModal from '$lib/components/ErrorModal.svelte';
|
||||||
@@ -17,6 +18,8 @@
|
|||||||
let location = '';
|
let location = '';
|
||||||
let message = '';
|
let message = '';
|
||||||
let selectedServices: string[] = [];
|
let selectedServices: string[] = [];
|
||||||
|
let website = '';
|
||||||
|
let formStartedAt = 0;
|
||||||
|
|
||||||
let fullNameInput: HTMLInputElement;
|
let fullNameInput: HTMLInputElement;
|
||||||
let emailInput: HTMLInputElement;
|
let emailInput: HTMLInputElement;
|
||||||
@@ -63,6 +66,10 @@
|
|||||||
$: ownerStepLabel = booking.ownerStepLabel?.trim() || 'Owner Details';
|
$: ownerStepLabel = booking.ownerStepLabel?.trim() || 'Owner Details';
|
||||||
$: dogStepLabel = booking.dogStepLabel?.trim() || 'Your dog';
|
$: dogStepLabel = booking.dogStepLabel?.trim() || 'Your dog';
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
formStartedAt = Date.now();
|
||||||
|
});
|
||||||
|
|
||||||
function splitBookingTitle(title: string) {
|
function splitBookingTitle(title: string) {
|
||||||
const trimmed = title.trim();
|
const trimmed = title.trim();
|
||||||
const lastSpace = trimmed.lastIndexOf(' ');
|
const lastSpace = trimmed.lastIndexOf(' ');
|
||||||
@@ -147,6 +154,8 @@
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
fullName, email, phone, petName, location, message,
|
fullName, email, phone, petName, location, message,
|
||||||
services: selectedServices,
|
services: selectedServices,
|
||||||
|
website,
|
||||||
|
formStartedAt,
|
||||||
referrer: document.referrer,
|
referrer: document.referrer,
|
||||||
page: window.location.href,
|
page: window.location.href,
|
||||||
}),
|
}),
|
||||||
@@ -224,6 +233,18 @@
|
|||||||
novalidate
|
novalidate
|
||||||
on:submit={handleSubmit}
|
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}
|
{#if step === 1}
|
||||||
<div class="booking-panel">
|
<div class="booking-panel">
|
||||||
{#if hasBanner}
|
{#if hasBanner}
|
||||||
@@ -443,4 +464,15 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translate3d(0, 0, 0);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -101,8 +101,10 @@ describe('BookingSection', () => {
|
|||||||
location: 'Kingsland',
|
location: 'Kingsland',
|
||||||
message: 'Loves small group walks.',
|
message: 'Loves small group walks.',
|
||||||
services: ['Pack Walks', 'Other Services'],
|
services: ['Pack Walks', 'Other Services'],
|
||||||
|
website: '',
|
||||||
referrer: 'https://www.google.com/'
|
referrer: 'https://www.google.com/'
|
||||||
});
|
});
|
||||||
|
expect(payload.formStartedAt).toEqual(expect.any(Number));
|
||||||
|
|
||||||
expect(screen.getByRole('dialog', { name: /Booking confirmed/i })).toBeInTheDocument();
|
expect(screen.getByRole('dialog', { name: /Booking confirmed/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('heading', { name: /on our radar/i })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: /on our radar/i })).toBeInTheDocument();
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
|
|
||||||
<div class="footer-action">
|
<div class="footer-action">
|
||||||
<p class="footer-col-label">Get Started</p>
|
<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
|
Book a Meet & Greet
|
||||||
<Icon name="fas fa-arrow-right" />
|
<Icon name="fas fa-arrow-right" />
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { reveal } from '$lib/actions/reveal';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import type { IconCard } from '$lib/types';
|
import type { IconCard } from '$lib/types';
|
||||||
|
|
||||||
@@ -6,7 +7,7 @@
|
|||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section id="services">
|
<section id="services" use:reveal={{ delay: 20 }} class="reveal-block">
|
||||||
<div class="services-inner">
|
<div class="services-inner">
|
||||||
<h2 class="section-heading">What we do</h2>
|
<h2 class="section-heading">What we do</h2>
|
||||||
|
|
||||||
@@ -27,3 +28,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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',
|
phone: '(022) 642 1011',
|
||||||
cta: {
|
cta: {
|
||||||
label: 'Contact us',
|
label: 'Contact us',
|
||||||
href: '/booking',
|
href: '/contact-us',
|
||||||
variant: 'yellow'
|
variant: 'yellow'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export const dogWalkingContent: ServicePageContent = {
|
|||||||
booking: {
|
booking: {
|
||||||
title: "Let's meet!",
|
title: "Let's meet!",
|
||||||
subtitle: 'Fill out your details below, and we can arrange a Meet & Greet for a one on one walk!',
|
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: [],
|
serviceOptions: [],
|
||||||
ownerStepLabel: 'Your details',
|
ownerStepLabel: 'Your details',
|
||||||
dogStepLabel: 'Your dog',
|
dogStepLabel: 'Your dog',
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ export const homepageContent: HomePageContent = {
|
|||||||
{ label: 'Puppy Visits', href: '/puppy-visits' },
|
{ label: 'Puppy Visits', href: '/puppy-visits' },
|
||||||
{ label: 'Our Pricing', href: '/our-pricing' },
|
{ label: 'Our Pricing', href: '/our-pricing' },
|
||||||
{ label: 'About Us', href: '/about' },
|
{ 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 },
|
instagram: { href: 'https://www.instagram.com/goodwalk.nz/', external: true },
|
||||||
megaMenuServices: [
|
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-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' }
|
{ 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',
|
icon: 'fas fa-dog',
|
||||||
title: 'Pack Walks',
|
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'
|
href: '/pack-walks'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -150,7 +150,7 @@ export const homepageContent: HomePageContent = {
|
|||||||
title: "Let's meet!",
|
title: "Let's meet!",
|
||||||
subtitle:
|
subtitle:
|
||||||
'Ready to get started? Book your free, no-obligation Meet & Greet today — just enter your details below',
|
'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']
|
serviceOptions: ['Pack Walks', '1:1 Walks', 'Puppy Visits', 'Other Services']
|
||||||
},
|
},
|
||||||
info: {
|
info: {
|
||||||
@@ -205,10 +205,10 @@ export const homepageContent: HomePageContent = {
|
|||||||
{ label: '1:1 Walks', href: '/dog-walking' },
|
{ label: '1:1 Walks', href: '/dog-walking' },
|
||||||
{ label: 'Puppy Visits', href: '/puppy-visits' },
|
{ label: 'Puppy Visits', href: '/puppy-visits' },
|
||||||
{ label: 'Our Pricing', href: '/our-pricing' },
|
{ label: 'Our Pricing', href: '/our-pricing' },
|
||||||
{ label: 'Contact Us', href: '/booking' }
|
{ label: 'Contact Us', href: '/contact-us' }
|
||||||
],
|
],
|
||||||
contactLinks: [
|
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: 'Instagram', href: 'https://www.instagram.com/goodwalk.nz/', external: true },
|
||||||
{ label: 'Google Reviews', href: 'https://g.page/r/CUsvrWPhkYrAEB0', external: true }
|
{ label: 'Google Reviews', href: 'https://g.page/r/CUsvrWPhkYrAEB0', external: true }
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const ourPricingContent: PricingPageContent = {
|
|||||||
booking: {
|
booking: {
|
||||||
title: 'Ready to join the Tiny Gang?',
|
title: 'Ready to join the Tiny Gang?',
|
||||||
subtitle: '',
|
subtitle: '',
|
||||||
formAction: '/booking',
|
formAction: '/contact-us',
|
||||||
serviceOptions: [],
|
serviceOptions: [],
|
||||||
ownerStepLabel: 'Your details',
|
ownerStepLabel: 'Your details',
|
||||||
dogStepLabel: 'Dog details',
|
dogStepLabel: 'Dog details',
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const packWalksContent: ServicePageContent = {
|
|||||||
booking: {
|
booking: {
|
||||||
title: 'Join the Tiny Gang!',
|
title: 'Join the Tiny Gang!',
|
||||||
subtitle: '',
|
subtitle: '',
|
||||||
formAction: '/booking',
|
formAction: '/contact-us',
|
||||||
serviceOptions: [],
|
serviceOptions: [],
|
||||||
ownerStepLabel: 'Your details',
|
ownerStepLabel: 'Your details',
|
||||||
dogStepLabel: 'Dog details',
|
dogStepLabel: 'Dog details',
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const puppyVisitsContent: ServicePageContent = {
|
|||||||
booking: {
|
booking: {
|
||||||
title: 'Ready to join the Tiny Gang?',
|
title: 'Ready to join the Tiny Gang?',
|
||||||
subtitle: '',
|
subtitle: '',
|
||||||
formAction: '/booking',
|
formAction: '/contact-us',
|
||||||
serviceOptions: [],
|
serviceOptions: [],
|
||||||
ownerStepLabel: 'Your details',
|
ownerStepLabel: 'Your details',
|
||||||
dogStepLabel: 'Dog 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.',
|
'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'
|
canonicalPath: '/about'
|
||||||
},
|
},
|
||||||
booking: {
|
'contact-us': {
|
||||||
title: 'Booking',
|
title: 'Contact Us',
|
||||||
description: 'Book a Meet & Greet with Goodwalk Auckland dog walking services.',
|
description: 'Book a Meet & Greet with Goodwalk Auckland dog walking services.',
|
||||||
canonicalPath: '/booking'
|
canonicalPath: '/contact-us'
|
||||||
},
|
},
|
||||||
'terms-and-conditions': {
|
'terms-and-conditions': {
|
||||||
title: 'Terms & Conditions',
|
title: 'Terms & Conditions',
|
||||||
|
|||||||
@@ -112,7 +112,11 @@ nav {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--text);
|
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 {
|
.mega-service:hover {
|
||||||
@@ -129,13 +133,62 @@ nav {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
color: #fff;
|
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 {
|
.mega-service:hover .mega-icon {
|
||||||
background: #2d4230;
|
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 {
|
.mega-service-label {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|||||||
@@ -196,6 +196,10 @@ section {
|
|||||||
|
|
||||||
.service-icon-bubble {
|
.service-icon-bubble {
|
||||||
background: linear-gradient(180deg, #ffe173 0%, #ffd54a 100%);
|
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) {
|
@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 {
|
.service-card:active {
|
||||||
transform: translateY(-1px) scale(0.992);
|
transform: translateY(-1px) scale(0.992);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
<div class="error-actions">
|
<div class="error-actions">
|
||||||
<a href="/" class="btn btn-yellow">Take me home</a>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ export async function load({ params }) {
|
|||||||
throw redirect(301, '/about');
|
throw redirect(301, '/about');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (params.slug === 'booking') {
|
||||||
|
throw redirect(301, '/contact-us');
|
||||||
|
}
|
||||||
|
|
||||||
const slug = params.slug as StaticPageSlug;
|
const slug = params.slug as StaticPageSlug;
|
||||||
const page = staticPages[slug];
|
const page = staticPages[slug];
|
||||||
|
|
||||||
|
|||||||
@@ -184,7 +184,7 @@
|
|||||||
<LegalPage pageContent={termsAndConditionsContent} />
|
<LegalPage pageContent={termsAndConditionsContent} />
|
||||||
{:else if data.slug === 'privacy-policy'}
|
{:else if data.slug === 'privacy-policy'}
|
||||||
<LegalPage pageContent={privacyPolicyContent} />
|
<LegalPage pageContent={privacyPolicyContent} />
|
||||||
{:else if data.slug === 'booking'}
|
{:else if data.slug === 'contact-us'}
|
||||||
<BookingPage booking={data.content.booking} />
|
<BookingPage booking={data.content.booking} />
|
||||||
{:else}
|
{:else}
|
||||||
<main class="static-page">
|
<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 () => {
|
it('throws a 404 for unknown slugs', async () => {
|
||||||
await expect(load({ params: { slug: 'missing-page' } } as never)).rejects.toMatchObject({
|
await expect(load({ params: { slug: 'missing-page' } } as never)).rejects.toMatchObject({
|
||||||
status: 404
|
status: 404
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ describe('static slug route page', () => {
|
|||||||
['puppy-visits', 'Introducing Puppy Visits: Building strong foundations for our pack walks!'],
|
['puppy-visits', 'Introducing Puppy Visits: Building strong foundations for our pack walks!'],
|
||||||
['our-pricing', 'Simple, transparent pricing — no lock-in contracts.'],
|
['our-pricing', 'Simple, transparent pricing — no lock-in contracts.'],
|
||||||
['about', 'Who we are'],
|
['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'],
|
['terms-and-conditions', '1. Application of Terms'],
|
||||||
['privacy-policy', 'How we collect your information']
|
['privacy-policy', 'How we collect your information']
|
||||||
] as const)('renders the %s page branch', (slug, expectedText) => {
|
] as const)('renders the %s page branch', (slug, expectedText) => {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ describe('root layout navigation behavior', () => {
|
|||||||
|
|
||||||
navigateHandler({
|
navigateHandler({
|
||||||
from: { url: new URL('https://www.goodwalk.co.nz/about') },
|
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);
|
expect(disableScrollHandling).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const routes = [
|
|||||||
'/puppy-visits',
|
'/puppy-visits',
|
||||||
'/our-pricing',
|
'/our-pricing',
|
||||||
'/about',
|
'/about',
|
||||||
'/booking',
|
'/contact-us',
|
||||||
'/terms-and-conditions',
|
'/terms-and-conditions',
|
||||||
'/privacy-policy'
|
'/privacy-policy'
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ describe('sitemap endpoint', () => {
|
|||||||
|
|
||||||
expect(response.headers.get('content-type')).toBe('application/xml; charset=utf-8');
|
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/</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('<loc>https://www.goodwalk.co.nz/privacy-policy</loc>');
|
||||||
expect(body).toContain('<lastmod>2026-05-01</lastmod>');
|
expect(body).toContain('<lastmod>2026-05-01</lastmod>');
|
||||||
expect(body.match(/<url>/g)).toHaveLength(9);
|
expect(body.match(/<url>/g)).toHaveLength(9);
|
||||||
|
|||||||
+208
-15
@@ -1,20 +1,213 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { connect } from 'node:net';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import { svelteTesting } from '@testing-library/svelte/vite';
|
import { svelteTesting } from '@testing-library/svelte/vite';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { createLogger, type Plugin, type ProxyOptions } from 'vite';
|
||||||
|
|
||||||
export default defineConfig({
|
const packageJson = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')) as {
|
||||||
plugins: [svelteTesting({ autoCleanup: false }), sveltekit()],
|
name: string;
|
||||||
server: {
|
version: string;
|
||||||
proxy: {
|
devDependencies?: Record<string, string>;
|
||||||
'/api/submit': {
|
};
|
||||||
target: 'http://localhost:8000',
|
|
||||||
rewrite: (path) => path.replace(/^\/api\/submit/, '/submit')
|
const appName = packageJson.name;
|
||||||
}
|
const appVersion = packageJson.version;
|
||||||
}
|
const viteVersion = packageJson.devDependencies?.vite ?? 'unknown';
|
||||||
},
|
const svelteKitVersion = packageJson.devDependencies?.['@sveltejs/kit'] ?? 'unknown';
|
||||||
test: {
|
const submitProxyPath = '/api/submit';
|
||||||
environment: 'jsdom',
|
const submitProxyTarget = 'http://localhost:8000';
|
||||||
setupFiles: ['./vitest.setup.ts'],
|
const submitProxyDestination = `${submitProxyTarget}/submit`;
|
||||||
include: ['src/**/*.test.ts']
|
|
||||||
|
function resolvePort(target: URL) {
|
||||||
|
if (target.port) {
|
||||||
|
return Number(target.port);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return target.protocol === 'https:' ? 443 : 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
function probeTcpPort(target: string, timeoutMs = 1200) {
|
||||||
|
const url = new URL(target);
|
||||||
|
|
||||||
|
return new Promise<{ ok: true } | { ok: false; error: NodeJS.ErrnoException }>((resolve) => {
|
||||||
|
const socket = connect({
|
||||||
|
host: url.hostname,
|
||||||
|
port: resolvePort(url)
|
||||||
|
});
|
||||||
|
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const finish = (result: { ok: true } | { ok: false; error: NodeJS.ErrnoException }) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
settled = true;
|
||||||
|
socket.destroy();
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.once('connect', () => finish({ ok: true }));
|
||||||
|
socket.once('error', (error: NodeJS.ErrnoException) => finish({ ok: false, error }));
|
||||||
|
socket.setTimeout(timeoutMs, () =>
|
||||||
|
finish({
|
||||||
|
ok: false,
|
||||||
|
error: Object.assign(new Error(`Timed out after ${timeoutMs}ms`), { code: 'ETIMEDOUT' })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatProxyError(error: NodeJS.ErrnoException) {
|
||||||
|
const code = error.code ?? 'UNKNOWN';
|
||||||
|
const hint =
|
||||||
|
code === 'ECONNREFUSED'
|
||||||
|
? `Mail API is not reachable at ${submitProxyTarget}. Start the Python API before submitting the form.`
|
||||||
|
: code === 'ETIMEDOUT'
|
||||||
|
? `Mail API at ${submitProxyTarget} did not respond in time.`
|
||||||
|
: 'The booking request could not be forwarded to the Mail API.';
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
hint
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGoodwalkLogger() {
|
||||||
|
const logger = createLogger();
|
||||||
|
const baseError = logger.error.bind(logger);
|
||||||
|
|
||||||
|
logger.error = (msg, options) => {
|
||||||
|
if (
|
||||||
|
typeof msg === 'string' &&
|
||||||
|
msg.includes('[vite] http proxy error') &&
|
||||||
|
(msg.includes('/submit') || msg.includes(submitProxyPath))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
baseError(msg, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
return logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goodwalkDevServerPlugin(logger: ReturnType<typeof createLogger>): Plugin {
|
||||||
|
let printedStartup = false;
|
||||||
|
|
||||||
|
const printStartup = async (serverHost: string | undefined, address: AddressInfo | string | null) => {
|
||||||
|
if (printedStartup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
printedStartup = true;
|
||||||
|
|
||||||
|
const localUrls = server.resolvedUrls?.local ?? [];
|
||||||
|
const networkUrls = server.resolvedUrls?.network ?? [];
|
||||||
|
const reachable = await probeTcpPort(submitProxyTarget);
|
||||||
|
const backendStatus = reachable.ok
|
||||||
|
? `ready at ${submitProxyTarget}`
|
||||||
|
: `${formatProxyError(reachable.error).code} at ${submitProxyTarget}`;
|
||||||
|
|
||||||
|
logger.info('');
|
||||||
|
logger.info(`[goodwalk] ${appName} dev server ready`);
|
||||||
|
logger.info(`[goodwalk] Version ${appVersion} | Node ${process.version} | Vite ${viteVersion} | SvelteKit ${svelteKitVersion}`);
|
||||||
|
|
||||||
|
if (localUrls.length) {
|
||||||
|
logger.info(`[goodwalk] Local: ${localUrls.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (networkUrls.length) {
|
||||||
|
logger.info(`[goodwalk] Network: ${networkUrls.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!localUrls.length && address && typeof address !== 'string') {
|
||||||
|
const host = serverHost && serverHost !== '0.0.0.0' ? serverHost : 'localhost';
|
||||||
|
logger.info(`[goodwalk] Local: http://${host}:${address.port}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[goodwalk] Booking proxy: ${submitProxyPath} -> ${submitProxyDestination}`);
|
||||||
|
|
||||||
|
if (reachable.ok) {
|
||||||
|
logger.info(`[goodwalk] Mail API: ${backendStatus}`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`[goodwalk] Mail API: ${backendStatus}`);
|
||||||
|
logger.warn(`[goodwalk] Hint: ${formatProxyError(reachable.error).hint}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('');
|
||||||
|
};
|
||||||
|
|
||||||
|
let server: import('vite').ViteDevServer;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'goodwalk-dev-server-logging',
|
||||||
|
configureServer(devServer) {
|
||||||
|
server = devServer;
|
||||||
|
|
||||||
|
devServer.httpServer?.once('listening', () => {
|
||||||
|
const address = devServer.httpServer?.address() ?? null;
|
||||||
|
const configuredHost =
|
||||||
|
typeof devServer.config.server.host === 'string' ? devServer.config.server.host : undefined;
|
||||||
|
|
||||||
|
void printStartup(configuredHost, address);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig(() => {
|
||||||
|
const logger = createGoodwalkLogger();
|
||||||
|
|
||||||
|
const submitProxy: ProxyOptions = {
|
||||||
|
target: submitProxyTarget,
|
||||||
|
rewrite: (path) => path.replace(/^\/api\/submit/, '/submit'),
|
||||||
|
configure(proxy) {
|
||||||
|
proxy.on('error', (error: NodeJS.ErrnoException, req, res) => {
|
||||||
|
const { code, hint } = formatProxyError(error);
|
||||||
|
const requestPath = req.url ?? submitProxyPath;
|
||||||
|
const requestMethod = req.method ?? 'POST';
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
[
|
||||||
|
'',
|
||||||
|
'[goodwalk] Booking proxy failed',
|
||||||
|
`[goodwalk] Request: ${requestMethod} ${requestPath}`,
|
||||||
|
`[goodwalk] Target: ${submitProxyDestination}`,
|
||||||
|
`[goodwalk] Code: ${code}`,
|
||||||
|
`[goodwalk] Message: ${error.message}`,
|
||||||
|
`[goodwalk] Hint: ${hint}`,
|
||||||
|
''
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res && 'writeHead' in res && !res.headersSent) {
|
||||||
|
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Mail API unavailable',
|
||||||
|
detail: hint
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
customLogger: logger,
|
||||||
|
plugins: [goodwalkDevServerPlugin(logger), svelteTesting({ autoCleanup: false }), sveltekit()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
[submitProxyPath]: submitProxy
|
||||||
|
}
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
|
include: ['src/**/*.test.ts']
|
||||||
|
}
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user