From f9002898f834541c96529de4d684e7befde62a93 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:10:39 -0500 Subject: [PATCH] Add home page, pets listing, pet detail, and API endpoint - Home page loads featured pets, upcoming events, content blocks, and stats - Pets listing page with search/filter by species and sex, infinite scroll - Pet detail page with photo gallery/lightbox, compatibility badges, and related pets - /api/pets GET endpoint for infinite scroll pagination - All schema adapted for pawprint (url, startDate, published, etc.) - All org name references driven by data.orgConfig.orgInfo.name Co-Authored-By: Claude Sonnet 4.6 --- src/routes/(public)/+page.server.ts | 87 ++++ src/routes/(public)/+page.svelte | 298 ++++++++++++ src/routes/(public)/pets/+page.server.ts | 63 +++ src/routes/(public)/pets/+page.svelte | 247 ++++++++++ .../(public)/pets/[slug]/+page.server.ts | 79 ++++ src/routes/(public)/pets/[slug]/+page.svelte | 426 ++++++++++++++++++ src/routes/api/pets/+server.ts | 64 +++ 7 files changed, 1264 insertions(+) create mode 100644 src/routes/(public)/+page.server.ts create mode 100644 src/routes/(public)/+page.svelte create mode 100644 src/routes/(public)/pets/+page.server.ts create mode 100644 src/routes/(public)/pets/+page.svelte create mode 100644 src/routes/(public)/pets/[slug]/+page.server.ts create mode 100644 src/routes/(public)/pets/[slug]/+page.svelte create mode 100644 src/routes/api/pets/+server.ts diff --git a/src/routes/(public)/+page.server.ts b/src/routes/(public)/+page.server.ts new file mode 100644 index 0000000..6702df4 --- /dev/null +++ b/src/routes/(public)/+page.server.ts @@ -0,0 +1,87 @@ +import { db } from '$lib/server/db'; +import { pets, petPhotos, events, fosterAssignments } from '$lib/server/schema'; +import { eq, and, gt, asc, or, isNotNull, desc, count } from 'drizzle-orm'; +import { getPageBlocks } from '$lib/server/content'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ parent }) => { + const parentData = await parent(); + + // Featured pets with primary photos + const rows = await db + .select({ + id: pets.id, + name: pets.name, + slug: pets.slug, + breed: pets.breed, + age: pets.age, + sex: pets.sex, + species: pets.species, + photoUrl: petPhotos.url + }) + .from(pets) + .leftJoin(petPhotos, and(eq(petPhotos.petId, pets.id), eq(petPhotos.isPrimary, true))) + .where(and( + eq(pets.status, 'available'), + eq(pets.published, true), + or(eq(pets.featured, true), isNotNull(petPhotos.url)) + )) + .orderBy(desc(pets.featured), pets.name) + .limit(8); + + // Upcoming events (next 3) + const now = new Date(); + const upcomingEvents = await db + .select() + .from(events) + .where(and(eq(events.published, true), gt(events.startDate, now))) + .orderBy(asc(events.startDate)) + .limit(3); + + const content = await getPageBlocks('home'); + + // Stats + const [{ totalRescued }] = await db + .select({ totalRescued: count() }) + .from(pets); + + const [{ totalAdopted }] = await db + .select({ totalAdopted: count() }) + .from(pets) + .where(eq(pets.status, 'adopted')); + + const [{ currentFosters }] = await db + .select({ currentFosters: count() }) + .from(fosterAssignments) + .where(eq(fosterAssignments.isCurrent, true)); + + const [{ availableNow }] = await db + .select({ availableNow: count() }) + .from(pets) + .where(and(eq(pets.status, 'available'), eq(pets.published, true))); + + const foundedYear = (parentData.orgConfig as { foundedYear?: number }).foundedYear; + const yearsRescuing = new Date().getFullYear() - (foundedYear ?? new Date().getFullYear()); + + return { + ...parentData, + featuredPets: rows.map((row) => ({ + name: row.name, + slug: row.slug, + breed: row.breed, + age: row.age, + sex: row.sex, + species: row.species, + primaryPhoto: row.photoUrl ?? null + })), + upcomingEvents, + content, + stats: { + totalRescued: totalRescued ?? 0, + totalAdopted: totalAdopted ?? 0, + currentFosters: currentFosters ?? 0, + availableNow: availableNow ?? 0, + yearsRescuing + } + }; +}; diff --git a/src/routes/(public)/+page.svelte b/src/routes/(public)/+page.svelte new file mode 100644 index 0000000..54e0714 --- /dev/null +++ b/src/routes/(public)/+page.svelte @@ -0,0 +1,298 @@ + + + + {orgName} — {tagline} + + + + + + + + + +
+
+
+

+ {(data.content as Record>)?.hero?.heading ?? tagline} +

+

+ {(data.content as Record>)?.hero?.subtext ?? 'A foster-based rescue committed to giving every animal a second chance. Through compassion, community, and dedicated foster families, we help pets find the loving homes they deserve.'} +

+ +
+
+ + +
+ + + +
+
+ + + + + +
+
+
+

Pets Looking for Homes

+

+ Meet some of our wonderful animals waiting for their forever families. +

+
+ + {#if data.featuredPets.length > 0} +
+ {#each data.featuredPets as pet} + + {/each} +
+ {:else} +
+

No pets available at this time. Check back soon!

+
+ {/if} + + +
+
+ + +
+
+
+

How You Can Help

+

+ There are many ways to make a difference in the life of a rescue pet. +

+
+ +
+
+
+ + + +
+

Adopt

+

+ Open your heart and home to a rescue pet. Our adoption process is thorough, and we will help you find the perfect match for your family. +

+ + Browse Pets + + + + +
+ +
+
+ + + +
+

Foster

+

+ Provide a temporary safe haven for a pet in need. Fostering saves lives and helps pets prepare for their forever homes. +

+ + Learn More + + + + +
+ +
+
+ + + +
+

Donate

+

+ Your generous donations help cover veterinary care, food, and supplies for our rescue pets. Every dollar makes a difference. +

+ + Give Now + + + + +
+
+
+
+ + +
+
+
+
+

Stay in the Loop

+

+ Get rescue updates, adoption stories, and event news delivered to your inbox. +

+
+
+ +
+
+
+
+ + +
+
+
+

Upcoming Events

+

+ Join us at an upcoming event to meet our pets and support our mission. +

+
+ + {#if data.upcomingEvents.length > 0} +
+ {#each data.upcomingEvents as event} +
+ {#if event.imageUrl} +
+ {event.title} +
+ {:else} +
+ + + +
+ {/if} +
+
+ + + + {new Date(event.startDate).toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric' + })} +
+

{event.title}

+ {#if event.location} +
+ + + + + {event.location} +
+ {/if} + {#if event.description} +

{event.description}

+ {/if} +
+
+ {/each} +
+ + + {:else} +
+ + + +

No upcoming events at this time.

+ {#if facebookUrl} +

Check back soon or follow us on Facebook for updates.

+ {/if} +
+ {/if} +
+
+ + +{#if facebookUrl} +
+
+

Follow Us

+

+ Stay up to date with our latest rescue stories, adoption news, and event announcements. +

+
+ {#if facebookUrl} + + + + + Follow on Facebook + + {/if} +
+
+
+{/if} diff --git a/src/routes/(public)/pets/+page.server.ts b/src/routes/(public)/pets/+page.server.ts new file mode 100644 index 0000000..13966b9 --- /dev/null +++ b/src/routes/(public)/pets/+page.server.ts @@ -0,0 +1,63 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { pets, petPhotos } from '$lib/server/schema'; +import { eq, and, like, count, or, isNotNull, desc } from 'drizzle-orm'; +import type { SQL } from 'drizzle-orm'; + +const PER_PAGE = 12; + +export const load: PageServerLoad = async ({ url, parent }) => { + const parentData = await parent(); + + const q = url.searchParams.get('q')?.trim() || ''; + const species = url.searchParams.get('species') || ''; + const sex = url.searchParams.get('sex') || ''; + + const conditions: SQL[] = [eq(pets.status, 'available'), eq(pets.published, true)]; + + if (q) { + conditions.push(like(pets.name, `%${q}%`)); + } + + if (species === 'dog' || species === 'cat') { + conditions.push(eq(pets.species, species)); + } + + if (sex === 'male' || sex === 'female') { + conditions.push(eq(pets.sex, sex)); + } + + const whereClause = and(...conditions)!; + + const [{ total }] = await db + .select({ total: count() }) + .from(pets) + .leftJoin(petPhotos, and(eq(petPhotos.petId, pets.id), eq(petPhotos.isPrimary, true))) + .where(and(whereClause, or(eq(pets.featured, true), isNotNull(petPhotos.url)))); + + const rows = await db + .select({ + id: pets.id, + name: pets.name, + slug: pets.slug, + species: pets.species, + breed: pets.breed, + age: pets.age, + sex: pets.sex, + primaryPhoto: petPhotos.url + }) + .from(pets) + .leftJoin(petPhotos, and(eq(petPhotos.petId, pets.id), eq(petPhotos.isPrimary, true))) + .where(and(whereClause, or(eq(pets.featured, true), isNotNull(petPhotos.url)))) + .orderBy(desc(pets.featured), desc(pets.createdAt)) + .limit(PER_PAGE) + .offset(0); + + return { + ...parentData, + pets: rows, + total, + hasMore: rows.length < total, + filters: { q, species, sex } + }; +}; diff --git a/src/routes/(public)/pets/+page.svelte b/src/routes/(public)/pets/+page.svelte new file mode 100644 index 0000000..fedb3e1 --- /dev/null +++ b/src/routes/(public)/pets/+page.svelte @@ -0,0 +1,247 @@ + + + + Available Pets | {orgName} + + + + + + + + + +
+
+

Available Pets

+

+ Every one of these pets is looking for a loving forever home. Could you be the one? +

+
+
+ + +
+
+
+
+ + + + +
+ +
+ + + + + +
+
+ + {#if hasActiveFilters} +
+ Showing {totalCount} result{totalCount === 1 ? '' : 's'} + | + +
+ {/if} +
+
+ + +
+ {#if allPets.length === 0 && !loading} +
+
+ + + +
+

No pets found

+

+ No pets match your current criteria. Try adjusting your filters or check back soon — new pets arrive regularly! +

+ + Clear Filters + +
+ {:else} +
+ {#each allPets as pet (pet.id)} +
+ +
+ {/each} +
+ +
+ + {#if loading} +
+ + + + + Loading more pets... +
+ {/if} + + {#if !hasMore && allPets.length > 0 && !loading} +

That's everyone!

+ {/if} + {/if} +
diff --git a/src/routes/(public)/pets/[slug]/+page.server.ts b/src/routes/(public)/pets/[slug]/+page.server.ts new file mode 100644 index 0000000..5a055c8 --- /dev/null +++ b/src/routes/(public)/pets/[slug]/+page.server.ts @@ -0,0 +1,79 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { pets, petPhotos } from '$lib/server/schema'; +import { eq, and, ne, asc } from 'drizzle-orm'; +import { error } from '@sveltejs/kit'; +import { trackPageView } from '$lib/server/track'; + +export const load: PageServerLoad = async ({ params, request, getClientAddress, url, parent }) => { + const parentData = await parent(); + + const [pet] = await db + .select() + .from(pets) + .where(eq(pets.slug, params.slug)) + .limit(1); + + if (!pet) { + throw error(404, 'Pet not found'); + } + + // Track page view (fire and forget) + const ipAddress = + request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + getClientAddress(); + trackPageView({ + path: `/pets/${params.slug}`, + petId: pet.id, + ipAddress, + userAgent: request.headers.get('user-agent') ?? undefined, + referrer: request.headers.get('referer') ?? undefined + }); + + const photos = await db + .select() + .from(petPhotos) + .where(eq(petPhotos.petId, pet.id)) + .orderBy(asc(petPhotos.sortOrder)); + + const primaryPhoto = photos.find((p) => p.isPrimary) ?? photos[0] ?? null; + + // Related pets: same species, available, not this pet, limit 4 + const relatedRows = await db + .select({ + id: pets.id, + name: pets.name, + slug: pets.slug, + species: pets.species, + breed: pets.breed, + age: pets.age, + sex: pets.sex, + primaryPhoto: petPhotos.url + }) + .from(pets) + .leftJoin(petPhotos, and(eq(petPhotos.petId, pets.id), eq(petPhotos.isPrimary, true))) + .where( + and( + eq(pets.species, pet.species), + eq(pets.status, 'available'), + eq(pets.published, true), + ne(pets.id, pet.id) + ) + ) + .orderBy(pets.name) + .limit(4); + + const origin = url.origin; + const pageUrl = `${origin}/pets/${params.slug}`; + const ogImageUrl = primaryPhoto ? `${origin}${primaryPhoto.url}` : null; + + return { + ...parentData, + pet, + photos, + primaryPhoto, + relatedPets: relatedRows, + pageUrl, + ogImageUrl + }; +}; diff --git a/src/routes/(public)/pets/[slug]/+page.svelte b/src/routes/(public)/pets/[slug]/+page.svelte new file mode 100644 index 0000000..8109090 --- /dev/null +++ b/src/routes/(public)/pets/[slug]/+page.svelte @@ -0,0 +1,426 @@ + + + + {pet.name} | {orgName} + + + + + {#if ogImageUrl} + + {/if} + + + + + + + +
+
+ +
+
+ +
+
+ +
+
+ {#if currentPhoto} + {pet.name} - Photo {selectedPhotoIndex + 1} openLightbox(selectedPhotoIndex)} + role="button" + tabindex="0" + onkeydown={(e) => { if (e.key === 'Enter') openLightbox(selectedPhotoIndex); }} + /> + {:else} +
+ + + +

Photo coming soon

+
+ {/if} +
+ + {#if photos.length > 1} +
+ {#each photos as photo, i} + + {/each} +
+ {/if} +
+ + +
+
+ + {statusLabels[pet.status] ?? pet.status} + +
+ +

{pet.name}

+ + {#if pet.breed} +

{pet.breed}

+ {/if} + + +
+ Share: + + + + + Share + + +
+ + +
+ {#if pet.species} +
+

Species

+

{pet.species}

+
+ {/if} + {#if pet.age} +
+

Age

+

{pet.age}

+
+ {/if} + {#if pet.sex && pet.sex !== 'unknown'} +
+

Sex

+

{pet.sex}

+
+ {/if} + {#if pet.weight} +
+

Weight

+

{pet.weight}

+
+ {/if} +
+ + + {#if pet.goodWithDogs !== null || pet.goodWithCats !== null || pet.goodWithKids !== null} +
+ {#if pet.goodWithDogs === true} + Good with dogs + {:else if pet.goodWithDogs === false} + Not good with dogs + {/if} + {#if pet.goodWithCats === true} + Good with cats + {:else if pet.goodWithCats === false} + Not good with cats + {/if} + {#if pet.goodWithKids === true} + Good with kids + {:else if pet.goodWithKids === false} + Not good with kids + {/if} + {#if pet.houseTrained} + House trained + {/if} + {#if pet.spayedNeutered} + Spayed/Neutered + {/if} + {#if pet.microchipped} + Microchipped + {/if} +
+ {/if} + + + {#if pet.description} +
+

About {pet.name}

+
+ {pet.description} +
+
+ {/if} + + + {#if pet.status === 'available'} + + {:else if pet.status === 'pending'} +
+

+ An adoption application for {pet.name} is currently being reviewed. You may still + submit an application + to be considered. +

+
+ {/if} + + {#if pet.adoptionFee} +

+ Adoption fee: ${(pet.adoptionFee / 100).toFixed(2)} +

+ {/if} +
+
+ + + {#if pet.intakeDate || pet.microchipId || pet.birthday} +
+

Details

+
+ {#if pet.intakeDate} +
+
Intake Date
+
{formatDate(pet.intakeDate)}
+
+ {/if} + {#if pet.birthday} +
+
Date of Birth
+
{formatDate(pet.birthday)}
+
+ {/if} + {#if pet.microchipId} +
+
Microchip
+
{pet.microchipId}
+
+ {/if} +
+
+ {/if} + + + {#if pet.medicalNotes} +
+

Medical Notes

+

{pet.medicalNotes}

+
+ {/if} + + + {#if relatedPets.length > 0} +
+

You Might Also Like

+
+ {#each relatedPets as rp (rp.id)} + + {/each} +
+
+ {/if} +
+ + +{#if lightboxOpen && photos.length > 0} + +{/if} + + diff --git a/src/routes/api/pets/+server.ts b/src/routes/api/pets/+server.ts new file mode 100644 index 0000000..979cb9b --- /dev/null +++ b/src/routes/api/pets/+server.ts @@ -0,0 +1,64 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { pets, petPhotos } from '$lib/server/schema'; +import { eq, and, like, count, or, isNotNull, desc } from 'drizzle-orm'; +import type { SQL } from 'drizzle-orm'; + +const DEFAULT_LIMIT = 12; + +export const GET: RequestHandler = async ({ url }) => { + const q = url.searchParams.get('q')?.trim() || ''; + const species = url.searchParams.get('species') || ''; + const sex = url.searchParams.get('sex') || ''; + const offset = Math.max(0, parseInt(url.searchParams.get('offset') || '0', 10)); + const limit = Math.min(48, Math.max(1, parseInt(url.searchParams.get('limit') || String(DEFAULT_LIMIT), 10))); + + const conditions: SQL[] = [eq(pets.status, 'available'), eq(pets.published, true)]; + + if (q) { + conditions.push(like(pets.name, `%${q}%`)); + } + + if (species === 'dog' || species === 'cat') { + conditions.push(eq(pets.species, species)); + } + + if (sex === 'male' || sex === 'female') { + conditions.push(eq(pets.sex, sex)); + } + + const whereClause = and(...conditions)!; + const photoFilter = and(whereClause, or(eq(pets.featured, true), isNotNull(petPhotos.url))); + + const [{ total }] = await db + .select({ total: count() }) + .from(pets) + .leftJoin(petPhotos, and(eq(petPhotos.petId, pets.id), eq(petPhotos.isPrimary, true))) + .where(photoFilter); + + const rows = await db + .select({ + id: pets.id, + name: pets.name, + slug: pets.slug, + species: pets.species, + breed: pets.breed, + age: pets.age, + sex: pets.sex, + status: pets.status, + primaryPhoto: petPhotos.url + }) + .from(pets) + .leftJoin(petPhotos, and(eq(petPhotos.petId, pets.id), eq(petPhotos.isPrimary, true))) + .where(photoFilter) + .orderBy(desc(pets.featured), desc(pets.createdAt)) + .limit(limit) + .offset(offset); + + return json({ + pets: rows, + hasMore: offset + rows.length < total, + total + }); +};