diff --git a/mail-api/__pycache__/main.cpython-314.pyc b/mail-api/__pycache__/main.cpython-314.pyc new file mode 100644 index 0000000..5dc8a7d Binary files /dev/null and b/mail-api/__pycache__/main.cpython-314.pyc differ diff --git a/mail-api/logs/mail-api.log b/mail-api/logs/mail-api.log index cc42dd1..0185cda 100644 --- a/mail-api/logs/mail-api.log +++ b/mail-api/logs/mail-api.log @@ -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: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 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'] diff --git a/mail-api/main.py b/mail-api/main.py index 286cad4..72a0f3d 100644 --- a/mail-api/main.py +++ b/mail-api/main.py @@ -1,4 +1,5 @@ import asyncio +from collections import deque import logging import logging.handlers import os @@ -89,6 +90,12 @@ def _load_config() -> dict: "from_email": os.environ.get("FROM_EMAIL", "GoodWalk "), "reply_to": os.environ.get("REPLY_TO", "aless@goodwalk.co.nz"), "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"] REPLY_TO = _config["reply_to"] 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" logger.info( - "Mail API config: from=%r reply_to=%r owner=%r max_attempts=%d", - FROM_EMAIL, REPLY_TO, OWNER_EMAIL, MAX_SEND_ATTEMPTS, + "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, + 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") @@ -147,6 +169,8 @@ class BookingSubmission(BaseModel): location: str message: str = "" services: list[str] = [] + website: str = "" + formStartedAt: int | None = None referrer: str = "" page: str = "" @@ -160,6 +184,119 @@ def _get_ip(request: Request) -> str: 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: if not ua: 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()) + 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] = [] try: diff --git a/src/lib/components/BookingSection.svelte b/src/lib/components/BookingSection.svelte index 62d7eaf..578b645 100644 --- a/src/lib/components/BookingSection.svelte +++ b/src/lib/components/BookingSection.svelte @@ -1,4 +1,5 @@ -
+

What we do

@@ -27,3 +28,49 @@
+ + diff --git a/src/lib/content/about.ts b/src/lib/content/about.ts index 8059cea..36ddc34 100644 --- a/src/lib/content/about.ts +++ b/src/lib/content/about.ts @@ -40,7 +40,7 @@ export const aboutPageContent: AboutPageContent = { phone: '(022) 642 1011', cta: { label: 'Contact us', - href: '/booking', + href: '/contact-us', variant: 'yellow' } } diff --git a/src/lib/content/dog-walking.ts b/src/lib/content/dog-walking.ts index 35738d4..c9e5c3c 100644 --- a/src/lib/content/dog-walking.ts +++ b/src/lib/content/dog-walking.ts @@ -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', diff --git a/src/lib/content/homepage.ts b/src/lib/content/homepage.ts index 4444ee0..d43844e 100644 --- a/src/lib/content/homepage.ts +++ b/src/lib/content/homepage.ts @@ -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 } ], diff --git a/src/lib/content/our-pricing.ts b/src/lib/content/our-pricing.ts index 40e7869..4f48cf7 100644 --- a/src/lib/content/our-pricing.ts +++ b/src/lib/content/our-pricing.ts @@ -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', diff --git a/src/lib/content/pack-walks.ts b/src/lib/content/pack-walks.ts index 064166c..e2474cb 100644 --- a/src/lib/content/pack-walks.ts +++ b/src/lib/content/pack-walks.ts @@ -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', diff --git a/src/lib/content/puppy-visits.ts b/src/lib/content/puppy-visits.ts index f04fe61..627bfa2 100644 --- a/src/lib/content/puppy-visits.ts +++ b/src/lib/content/puppy-visits.ts @@ -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', diff --git a/src/lib/content/static-pages.ts b/src/lib/content/static-pages.ts index c313ac0..c60491d 100644 --- a/src/lib/content/static-pages.ts +++ b/src/lib/content/static-pages.ts @@ -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', diff --git a/src/lib/styles/layout.css b/src/lib/styles/layout.css index 7870704..646264b 100644 --- a/src/lib/styles/layout.css +++ b/src/lib/styles/layout.css @@ -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; diff --git a/src/lib/styles/sections.css b/src/lib/styles/sections.css index 24d5a13..dbc56fe 100644 --- a/src/lib/styles/sections.css +++ b/src/lib/styles/sections.css @@ -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); } diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte index 5b9b1a2..db1c11d 100644 --- a/src/routes/+error.svelte +++ b/src/routes/+error.svelte @@ -34,7 +34,7 @@ diff --git a/src/routes/[slug]/+page.server.ts b/src/routes/[slug]/+page.server.ts index e89398f..5434f75 100644 --- a/src/routes/[slug]/+page.server.ts +++ b/src/routes/[slug]/+page.server.ts @@ -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]; diff --git a/src/routes/[slug]/+page.svelte b/src/routes/[slug]/+page.svelte index a2ce197..544be33 100644 --- a/src/routes/[slug]/+page.svelte +++ b/src/routes/[slug]/+page.svelte @@ -184,7 +184,7 @@ {:else if data.slug === 'privacy-policy'} -{:else if data.slug === 'booking'} +{:else if data.slug === 'contact-us'} {:else}
diff --git a/src/routes/[slug]/slug-page.server.test.ts b/src/routes/[slug]/slug-page.server.test.ts index fde3cad..1bea88d 100644 --- a/src/routes/[slug]/slug-page.server.test.ts +++ b/src/routes/[slug]/slug-page.server.test.ts @@ -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 diff --git a/src/routes/[slug]/slug-page.test.ts b/src/routes/[slug]/slug-page.test.ts index 4bda782..bc36923 100644 --- a/src/routes/[slug]/slug-page.test.ts +++ b/src/routes/[slug]/slug-page.test.ts @@ -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) => { diff --git a/src/routes/layout.test.ts b/src/routes/layout.test.ts index 7409518..94cd63b 100644 --- a/src/routes/layout.test.ts +++ b/src/routes/layout.test.ts @@ -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); diff --git a/src/routes/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts index a6a126b..b353357 100644 --- a/src/routes/sitemap.xml/+server.ts +++ b/src/routes/sitemap.xml/+server.ts @@ -8,7 +8,7 @@ const routes = [ '/puppy-visits', '/our-pricing', '/about', - '/booking', + '/contact-us', '/terms-and-conditions', '/privacy-policy' ]; diff --git a/src/routes/sitemap.xml/sitemap.test.ts b/src/routes/sitemap.xml/sitemap.test.ts index ad4548d..a86b28a 100644 --- a/src/routes/sitemap.xml/sitemap.test.ts +++ b/src/routes/sitemap.xml/sitemap.test.ts @@ -15,6 +15,7 @@ describe('sitemap endpoint', () => { expect(response.headers.get('content-type')).toBe('application/xml; charset=utf-8'); expect(body).toContain('https://www.goodwalk.co.nz/'); + expect(body).toContain('https://www.goodwalk.co.nz/contact-us'); expect(body).toContain('https://www.goodwalk.co.nz/privacy-policy'); expect(body).toContain('2026-05-01'); expect(body.match(//g)).toHaveLength(9); diff --git a/vite.config.ts b/vite.config.ts index 2749a49..500a5c8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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 { 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({ - plugins: [svelteTesting({ autoCleanup: false }), sveltekit()], - server: { - proxy: { - '/api/submit': { - target: 'http://localhost:8000', - rewrite: (path) => path.replace(/^\/api\/submit/, '/submit') - } - } - }, - test: { - environment: 'jsdom', - setupFiles: ['./vitest.setup.ts'], - include: ['src/**/*.test.ts'] +const packageJson = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')) as { + name: string; + version: string; + devDependencies?: Record; +}; + +const appName = packageJson.name; +const appVersion = packageJson.version; +const viteVersion = packageJson.devDependencies?.vite ?? 'unknown'; +const svelteKitVersion = packageJson.devDependencies?.['@sveltejs/kit'] ?? 'unknown'; +const submitProxyPath = '/api/submit'; +const submitProxyTarget = 'http://localhost:8000'; +const submitProxyDestination = `${submitProxyTarget}/submit`; + +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): 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'] + } + }; });