Initial commit
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user