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?.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}
+
+
+
+ {: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}
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}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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'}
+ |
+
+ Clear All Filters
+
+
+ {/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}
+
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}
+
(selectedPhotoIndex = i)}
+ class="flex-shrink-0 w-20 h-20 rounded-lg overflow-hidden border-2 transition-all {i === selectedPhotoIndex ? 'border-teal-brand ring-2 ring-teal-brand/30 shadow-md' : 'border-transparent opacity-70 hover:opacity-100'}"
+ >
+
+
+ {/each}
+
+ {/if}
+
+
+
+
+
+
+ {statusLabels[pet.status] ?? pet.status}
+
+
+
+
{pet.name}
+
+ {#if pet.breed}
+
{pet.breed}
+ {/if}
+
+
+
+
Share:
+
+
+
+
+ Share
+
+
+
+
+
+ {linkCopied ? 'Copied!' : 'Copy Link'}
+
+
+
+
+
+ {#if pet.species}
+
+
Species
+
{pet.species}
+
+ {/if}
+ {#if pet.age}
+
+ {/if}
+ {#if pet.sex && pet.sex !== 'unknown'}
+
+ {/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}
+
+
+
+
+
+
+
+
+ {lightboxIndex + 1} / {photos.length}
+
+
+
+
{pet.name}
+
+ {pet.breed ?? ''}{pet.breed && pet.sex ? ' · ' : ''}{pet.sex && pet.sex !== 'unknown' ? pet.sex.charAt(0).toUpperCase() + pet.sex.slice(1) : ''}
+
+
+
+ {#if photos.length > 1}
+
{ e.stopPropagation(); lightboxPrev(); }} class="absolute left-2 sm:left-6 top-1/2 -translate-y-1/2 z-10 p-3 rounded-full bg-black/50 text-white hover:bg-black/70 transition cursor-pointer" aria-label="Previous photo">
+
+
+
+
+
{ e.stopPropagation(); lightboxNext(); }} class="absolute right-2 sm:right-6 top-1/2 -translate-y-1/2 z-10 p-3 rounded-full bg-black/50 text-white hover:bg-black/70 transition cursor-pointer" aria-label="Next photo">
+
+
+
+
+ {/if}
+
+
e.stopPropagation()}
+ />
+
+ {#if photos.length > 1}
+
+ {#each photos as photo, i}
+
{ e.stopPropagation(); lightboxIndex = i; }} class="flex-shrink-0 w-12 h-12 rounded-lg overflow-hidden border-2 transition {i === lightboxIndex ? 'border-white' : 'border-transparent opacity-50 hover:opacity-80'} cursor-pointer">
+
+
+ {/each}
+
+ {/if}
+
+{/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
+ });
+};