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 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 12:10:39 -05:00
co-authored by Claude Sonnet 4.6
parent 2fa3d4ec67
commit f9002898f8
7 changed files with 1264 additions and 0 deletions
+87
View File
@@ -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
}
};
};
+298
View File
@@ -0,0 +1,298 @@
<script lang="ts">
import PetCard from '$lib/components/public/PetCard.svelte';
import DonateButton from '$lib/components/public/DonateButton.svelte';
import StatsCounter from '$lib/components/public/StatsCounter.svelte';
import NewsletterSignup from '$lib/components/public/NewsletterSignup.svelte';
let { data } = $props();
const orgName = $derived(data.orgConfig.orgInfo.name);
const tagline = $derived(data.orgConfig.orgInfo.tagline || 'Every Animal Deserves a Home');
const facebookUrl = $derived(data.orgConfig.orgInfo.facebookUrl);
</script>
<svelte:head>
<title>{orgName} — {tagline}</title>
<meta name="description" content="{orgName} is a foster-based rescue dedicated to saving animals and finding them loving forever homes." />
<meta property="og:type" content="website" />
<meta property="og:title" content="{orgName} — {tagline}" />
<meta property="og:description" content="{orgName} is a foster-based rescue dedicated to saving animals and finding them loving forever homes." />
<meta property="og:site_name" content={orgName} />
<meta name="twitter:card" content="summary_large_image" />
</svelte:head>
<!-- Hero Section -->
<section class="relative overflow-hidden bg-gradient-to-br from-teal-dark via-teal-brand to-teal-dark">
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 md:py-32">
<div class="max-w-3xl">
<h1 class="text-4xl sm:text-5xl md:text-6xl font-extrabold text-white leading-tight">
{(data.content as Record<string, Record<string, string>>)?.hero?.heading ?? tagline}
</h1>
<p class="mt-6 text-lg md:text-xl text-white/85 leading-relaxed max-w-2xl">
{(data.content as Record<string, Record<string, string>>)?.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.'}
</p>
<div class="mt-10 flex flex-wrap gap-3">
<a
href="/pets?species=dog"
class="inline-flex items-center px-7 py-3.5 text-base font-semibold rounded-full bg-white text-teal-brand hover:bg-gray-soft transition-colors duration-200 shadow-lg hover:shadow-xl"
>
Meet Our Dogs
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 ml-2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</a>
<a
href="/pets?species=cat"
class="inline-flex items-center px-7 py-3.5 text-base font-semibold rounded-full bg-white text-teal-brand hover:bg-gray-soft transition-colors duration-200 shadow-lg hover:shadow-xl"
>
Meet Our Cats
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 ml-2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</a>
<DonateButton size="lg" variant="outline" />
</div>
</div>
</div>
<!-- Decorative wave -->
<div class="absolute bottom-0 left-0 right-0">
<svg viewBox="0 0 1440 60" fill="none" xmlns="http://www.w3.org/2000/svg" class="w-full">
<path d="M0 60V30C240 0 480 0 720 30C960 60 1200 60 1440 30V60H0Z" fill="var(--color-warm-bg)" />
</svg>
</div>
</section>
<!-- Stats Counter -->
<StatsCounter
totalRescued={data.stats.totalRescued}
totalAdopted={data.stats.totalAdopted}
currentFosters={data.stats.currentFosters}
availableNow={data.stats.availableNow}
yearsRescuing={data.stats.yearsRescuing}
/>
<!-- Featured Pets Section -->
<section class="py-16 md:py-24">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-bold text-gray-brand">Pets Looking for Homes</h2>
<p class="mt-3 text-gray-mid text-lg max-w-2xl mx-auto">
Meet some of our wonderful animals waiting for their forever families.
</p>
</div>
{#if data.featuredPets.length > 0}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{#each data.featuredPets as pet}
<PetCard {pet} />
{/each}
</div>
{:else}
<div class="text-center py-12 bg-white rounded-2xl">
<p class="text-gray-mid text-lg">No pets available at this time. Check back soon!</p>
</div>
{/if}
<div class="mt-12 text-center">
<a
href="/pets"
class="inline-flex items-center gap-2 px-8 py-3 text-base font-semibold text-teal-brand border-2 border-teal-brand rounded-full hover:bg-teal-brand hover:text-white transition-all duration-200"
>
View All Available Pets
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</a>
</div>
</div>
</section>
<!-- How You Can Help Section -->
<section class="py-16 md:py-24 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-bold text-gray-brand">How You Can Help</h2>
<p class="mt-3 text-gray-mid text-lg max-w-2xl mx-auto">
There are many ways to make a difference in the life of a rescue pet.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="bg-warm-bg rounded-2xl p-8 text-center hover:shadow-lg transition-shadow duration-300">
<div class="w-16 h-16 mx-auto mb-6 bg-teal-light rounded-2xl flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-8 h-8 text-teal-brand">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 21v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21m0 0h4.5V3.545M12.75 21h7.5V10.75M2.25 21h1.5m18 0h-18M2.25 9l4.5-1.636M18.75 3l-1.5.545m0 6.205l3 1m1.5.5l-1.5-.5M6.75 7.364V3h-3v18m3-13.636l10.5-3.819" />
</svg>
</div>
<h3 class="text-xl font-bold text-gray-brand mb-3">Adopt</h3>
<p class="text-gray-mid text-sm leading-relaxed mb-6">
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.
</p>
<a href="/pets" class="inline-flex items-center gap-1 text-teal-brand font-semibold hover:text-teal-dark transition-colors">
Browse Pets
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</a>
</div>
<div class="bg-warm-bg rounded-2xl p-8 text-center hover:shadow-lg transition-shadow duration-300">
<div class="w-16 h-16 mx-auto mb-6 bg-teal-light rounded-2xl flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-8 h-8 text-teal-brand">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" />
</svg>
</div>
<h3 class="text-xl font-bold text-gray-brand mb-3">Foster</h3>
<p class="text-gray-mid text-sm leading-relaxed mb-6">
Provide a temporary safe haven for a pet in need. Fostering saves lives and helps pets prepare for their forever homes.
</p>
<a href="/become-a-foster" class="inline-flex items-center gap-1 text-teal-brand font-semibold hover:text-teal-dark transition-colors">
Learn More
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</a>
</div>
<div class="bg-warm-bg rounded-2xl p-8 text-center hover:shadow-lg transition-shadow duration-300">
<div class="w-16 h-16 mx-auto mb-6 bg-teal-light rounded-2xl flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-8 h-8 text-teal-brand">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z" />
</svg>
</div>
<h3 class="text-xl font-bold text-gray-brand mb-3">Donate</h3>
<p class="text-gray-mid text-sm leading-relaxed mb-6">
Your generous donations help cover veterinary care, food, and supplies for our rescue pets. Every dollar makes a difference.
</p>
<a href="/donate" class="inline-flex items-center gap-1 text-teal-brand font-semibold hover:text-teal-dark transition-colors">
Give Now
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</a>
</div>
</div>
</div>
</section>
<!-- Newsletter Signup Banner -->
<section class="py-12 md:py-16 bg-gradient-to-r from-teal-dark via-teal-brand to-teal-dark">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex flex-col md:flex-row items-center gap-6 md:gap-10">
<div class="flex-1 text-center md:text-left">
<h2 class="text-2xl md:text-3xl font-bold text-white">Stay in the Loop</h2>
<p class="mt-2 text-white/75 text-sm md:text-base">
Get rescue updates, adoption stories, and event news delivered to your inbox.
</p>
</div>
<div class="w-full md:w-auto md:min-w-[380px]">
<NewsletterSignup />
</div>
</div>
</div>
</section>
<!-- Upcoming Events Section -->
<section class="py-16 md:py-24">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-3xl md:text-4xl font-bold text-gray-brand">Upcoming Events</h2>
<p class="mt-3 text-gray-mid text-lg max-w-2xl mx-auto">
Join us at an upcoming event to meet our pets and support our mission.
</p>
</div>
{#if data.upcomingEvents.length > 0}
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
{#each data.upcomingEvents as event}
<div class="bg-white rounded-2xl overflow-hidden shadow-sm hover:shadow-lg transition-shadow duration-300">
{#if event.imageUrl}
<div class="aspect-[16/9] overflow-hidden">
<img src={event.imageUrl} alt={event.title} class="w-full h-full object-cover" />
</div>
{:else}
<div class="aspect-[16/9] bg-teal-light flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-12 h-12 text-teal-brand/30">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />
</svg>
</div>
{/if}
<div class="p-6">
<div class="flex items-center gap-2 text-sm text-teal-brand font-medium mb-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />
</svg>
{new Date(event.startDate).toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric'
})}
</div>
<h3 class="text-lg font-bold text-gray-brand mb-2">{event.title}</h3>
{#if event.location}
<div class="flex items-center gap-1.5 text-sm text-gray-mid mb-3">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0z" />
</svg>
{event.location}
</div>
{/if}
{#if event.description}
<p class="text-sm text-gray-mid line-clamp-3">{event.description}</p>
{/if}
</div>
</div>
{/each}
</div>
<div class="mt-12 text-center">
<a href="/events" class="inline-flex items-center gap-2 text-teal-brand font-semibold hover:text-teal-dark transition-colors">
View All Events
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</a>
</div>
{:else}
<div class="text-center py-12 bg-white rounded-2xl">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-12 h-12 mx-auto text-gray-soft mb-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />
</svg>
<p class="text-gray-mid text-lg">No upcoming events at this time.</p>
{#if facebookUrl}
<p class="text-gray-mid/70 text-sm mt-1">Check back soon or follow us on Facebook for updates.</p>
{/if}
</div>
{/if}
</div>
</section>
<!-- Follow on Social -->
{#if facebookUrl}
<section class="py-16 md:py-24 bg-warm-bg">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 class="text-3xl md:text-4xl font-bold text-gray-brand">Follow Us</h2>
<p class="mt-3 text-gray-mid text-lg max-w-2xl mx-auto">
Stay up to date with our latest rescue stories, adoption news, and event announcements.
</p>
<div class="mt-8 flex justify-center gap-4">
{#if facebookUrl}
<a
href={facebookUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 text-teal-brand font-semibold hover:text-teal-dark transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
</svg>
Follow on Facebook
</a>
{/if}
</div>
</div>
</section>
{/if}
+63
View File
@@ -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 }
};
};
+247
View File
@@ -0,0 +1,247 @@
<script lang="ts">
import PetCard from '$lib/components/public/PetCard.svelte';
import { goto } from '$app/navigation';
let { data } = $props();
let q = $state('');
let species = $state('');
let sex = $state('');
type Pet = (typeof data.pets)[number];
let allPets = $state<Pet[]>([]);
let hasMore = $state(true);
let loading = $state(false);
let offset = $state(0);
let totalCount = $state(0);
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
const LIMIT = 12;
$effect(() => {
const f = data.filters;
q = f.q;
species = f.species;
sex = f.sex;
allPets = [...data.pets];
hasMore = data.hasMore;
offset = data.pets.length;
totalCount = data.total;
});
function handleScroll() {
if (loading || !hasMore) return;
const scrollBottom = window.innerHeight + window.scrollY;
const docHeight = document.documentElement.scrollHeight;
if (scrollBottom >= docHeight - 600) {
loadMore();
}
}
$effect(() => {
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
});
async function loadMore() {
if (loading || !hasMore) return;
loading = true;
try {
const params = new URLSearchParams();
params.set('offset', String(offset));
params.set('limit', String(LIMIT));
if (q) params.set('q', q);
if (species) params.set('species', species);
if (sex) params.set('sex', sex);
const res = await fetch(`/api/pets?${params.toString()}`);
if (!res.ok) throw new Error('Failed to fetch pets');
const json = await res.json();
allPets = [...allPets, ...json.pets];
hasMore = json.hasMore;
offset = allPets.length;
totalCount = json.total;
} catch (err) {
console.error('Error loading more pets:', err);
} finally {
loading = false;
}
}
function buildUrl(overrides: Record<string, string> = {}) {
const params = new URLSearchParams();
const vals = {
q: overrides.q ?? q,
species: overrides.species ?? species,
sex: overrides.sex ?? sex
};
for (const [key, val] of Object.entries(vals)) {
if (val) params.set(key, val);
}
const qs = params.toString();
return `/pets${qs ? `?${qs}` : ''}`;
}
function applyFilters() {
goto(buildUrl());
}
function clearFilters() {
q = '';
species = '';
sex = '';
goto('/pets');
}
function handleSearch(e: SubmitEvent) {
e.preventDefault();
applyFilters();
}
function onFilterChange() {
applyFilters();
}
function onSearchInput() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
const params = new URLSearchParams();
params.set('offset', '0');
params.set('limit', String(LIMIT));
if (q) params.set('q', q);
if (species) params.set('species', species);
if (sex) params.set('sex', sex);
fetch(`/api/pets?${params.toString()}`)
.then((res) => res.json())
.then((json) => {
allPets = json.pets;
hasMore = json.hasMore;
offset = json.pets.length;
totalCount = json.total;
history.replaceState({}, '', buildUrl());
})
.catch(() => {});
}, 400);
}
const hasActiveFilters = $derived(
data.filters.q || data.filters.species || data.filters.sex
);
const orgName = $derived(data.orgConfig.orgInfo.name);
</script>
<svelte:head>
<title>Available Pets | {orgName}</title>
<meta name="description" content="Browse adoptable dogs and cats at {orgName}. Find your perfect companion today." />
<meta property="og:type" content="website" />
<meta property="og:title" content="Available Pets | {orgName}" />
<meta property="og:description" content="Browse adoptable dogs and cats at {orgName}. Find your perfect companion today." />
<meta property="og:site_name" content={orgName} />
<meta name="twitter:card" content="summary_large_image" />
</svelte:head>
<!-- Hero -->
<section class="bg-gradient-to-br from-teal-brand to-teal-dark py-12 sm:py-16">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 text-center">
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-extrabold text-white">Available Pets</h1>
<p class="mt-3 text-lg text-teal-light max-w-2xl mx-auto">
Every one of these pets is looking for a loving forever home. Could you be the one?
</p>
</div>
</section>
<!-- Search & Filters -->
<section class="bg-white border-b border-gray-soft shadow-sm sticky top-0 z-20">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-4">
<form onsubmit={handleSearch} class="flex flex-col sm:flex-row gap-3">
<div class="relative flex-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 pointer-events-none">
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11ZM2 9a7 7 0 1 1 12.452 4.391l3.328 3.329a.75.75 0 1 1-1.06 1.06l-3.329-3.328A7 7 0 0 1 2 9Z" clip-rule="evenodd" />
</svg>
<input
type="text"
bind:value={q}
oninput={onSearchInput}
placeholder="Search by name..."
class="w-full pl-10 pr-4 py-2.5 rounded-lg border border-gray-soft bg-warm-bg text-gray-brand placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-brand/40 focus:border-teal-brand transition"
/>
</div>
<div class="flex flex-wrap gap-3">
<select bind:value={species} onchange={onFilterChange} class="px-4 py-2.5 rounded-lg border border-gray-soft bg-warm-bg text-gray-brand focus:outline-none focus:ring-2 focus:ring-teal-brand/40 focus:border-teal-brand transition">
<option value="">All Species</option>
<option value="dog">Dogs</option>
<option value="cat">Cats</option>
</select>
<select bind:value={sex} onchange={onFilterChange} class="px-4 py-2.5 rounded-lg border border-gray-soft bg-warm-bg text-gray-brand focus:outline-none focus:ring-2 focus:ring-teal-brand/40 focus:border-teal-brand transition">
<option value="">Any Sex</option>
<option value="male">Male</option>
<option value="female">Female</option>
</select>
<button type="submit" class="px-6 py-2.5 bg-teal-brand text-white rounded-lg font-semibold hover:bg-teal-dark transition focus:outline-none focus:ring-2 focus:ring-teal-brand/40">
Search
</button>
</div>
</form>
{#if hasActiveFilters}
<div class="mt-3 flex items-center gap-2 text-sm text-gray-mid">
<span>Showing {totalCount} result{totalCount === 1 ? '' : 's'}</span>
<span class="text-gray-soft">|</span>
<button type="button" onclick={clearFilters} class="text-teal-brand hover:text-teal-dark font-medium underline underline-offset-2 transition">
Clear All Filters
</button>
</div>
{/if}
</div>
</section>
<!-- Pet Grid -->
<section class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-10">
{#if allPets.length === 0 && !loading}
<div class="text-center py-20">
<div class="mx-auto w-24 h-24 rounded-full bg-teal-light flex items-center justify-center mb-6">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-12 h-12 text-teal-brand">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.182 16.318A4.486 4.486 0 0 0 12.016 15a4.486 4.486 0 0 0-3.198 1.318M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM9.75 9.75c0 .414-.168.75-.375.75S9 10.164 9 9.75 9.168 9 9.375 9s.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Zm5.625 0c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Z" />
</svg>
</div>
<h2 class="text-2xl font-bold text-gray-brand mb-2">No pets found</h2>
<p class="text-gray-mid mb-6 max-w-md mx-auto">
No pets match your current criteria. Try adjusting your filters or check back soon — new pets arrive regularly!
</p>
<a href="/pets" class="inline-flex items-center gap-2 px-6 py-3 bg-teal-brand text-white rounded-lg font-semibold hover:bg-teal-dark transition">
Clear Filters
</a>
</div>
{:else}
<div class="columns-1 sm:columns-2 lg:columns-3 xl:columns-4 gap-5 space-y-5">
{#each allPets as pet (pet.id)}
<div class="break-inside-avoid">
<PetCard {pet} />
</div>
{/each}
</div>
<div class="h-4"></div>
{#if loading}
<div class="flex items-center justify-center py-10 gap-3">
<svg class="animate-spin h-6 w-6 text-teal-brand" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="text-gray-mid text-sm font-medium">Loading more pets...</span>
</div>
{/if}
{#if !hasMore && allPets.length > 0 && !loading}
<p class="text-center text-gray-mid text-sm py-8">That's everyone!</p>
{/if}
{/if}
</section>
@@ -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
};
};
@@ -0,0 +1,426 @@
<script lang="ts">
import PetCard from '$lib/components/public/PetCard.svelte';
let { data } = $props();
let selectedPhotoIndex = $state(0);
let lightboxOpen = $state(false);
let lightboxIndex = $state(0);
let touchStartX = $state(0);
const pet = $derived(data.pet);
const photos = $derived(data.photos);
const relatedPets = $derived(data.relatedPets);
const pageUrl = $derived(data.pageUrl);
const ogImageUrl = $derived(data.ogImageUrl);
const orgName = $derived(data.orgConfig.orgInfo.name);
let linkCopied = $state(false);
let copyTimeout: ReturnType<typeof setTimeout> | undefined;
function copyLink() {
navigator.clipboard.writeText(pageUrl).then(() => {
linkCopied = true;
clearTimeout(copyTimeout);
copyTimeout = setTimeout(() => { linkCopied = false; }, 2000);
});
}
function openLightbox(index: number) {
if (photos.length === 0) return;
lightboxIndex = index;
lightboxOpen = true;
}
function closeLightbox() {
lightboxOpen = false;
}
function lightboxPrev() {
lightboxIndex = (lightboxIndex - 1 + photos.length) % photos.length;
}
function lightboxNext() {
lightboxIndex = (lightboxIndex + 1) % photos.length;
}
function handleLightboxKey(e: KeyboardEvent) {
if (!lightboxOpen) return;
if (e.key === 'Escape') closeLightbox();
if (e.key === 'ArrowLeft') lightboxPrev();
if (e.key === 'ArrowRight') lightboxNext();
}
function handleTouchStart(e: TouchEvent) {
touchStartX = e.touches[0].clientX;
}
function handleTouchEnd(e: TouchEvent) {
const diff = touchStartX - e.changedTouches[0].clientX;
if (Math.abs(diff) > 50) {
if (diff > 0) lightboxNext();
else lightboxPrev();
}
}
$effect(() => {
data.pet.id;
selectedPhotoIndex = 0;
});
const currentPhoto = $derived(photos.length > 0 ? photos[selectedPhotoIndex] : null);
const statusLabels: Record<string, string> = {
available: 'Available for Adoption',
adopted: 'Adopted',
foster: 'In Foster Care',
medical_hold: 'Medical Hold',
behavioral_hold: 'Behavioral Hold',
pending: 'Adoption Pending',
trial: 'Trial Adoption',
hospice: 'Hospice Care',
nursing: 'Nursing',
whelping: 'Whelping',
deceased: 'Deceased',
transferred: 'Transferred'
};
const statusColors: Record<string, string> = {
available: 'bg-green-100 text-green-800',
adopted: 'bg-blue-100 text-blue-800',
pending: 'bg-amber-100 text-amber-800',
foster: 'bg-purple-100 text-purple-800'
};
function formatDate(dateStr: string | Date | null): string {
if (!dateStr) return '';
const d = dateStr instanceof Date ? dateStr : new Date(dateStr);
return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
}
const ogDescription = $derived(
`${pet.breed ? pet.breed + ' \u00b7 ' : ''}${pet.age || pet.species} \u00b7 Available for adoption`
);
</script>
<svelte:head>
<title>{pet.name} | {orgName}</title>
<meta name="description" content={ogDescription} />
<meta property="og:type" content="article" />
<meta property="og:title" content="{pet.name} — {orgName}" />
<meta property="og:description" content={ogDescription} />
{#if ogImageUrl}
<meta property="og:image" content={ogImageUrl} />
{/if}
<meta property="og:url" content={pageUrl} />
<meta property="og:site_name" content={orgName} />
<meta name="twitter:card" content="summary_large_image" />
<link rel="canonical" href={data.pageUrl} />
</svelte:head>
<!-- Breadcrumb -->
<div class="bg-white border-b border-gray-soft">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-3">
<nav class="flex items-center gap-2 text-sm text-gray-mid">
<a href="/" class="hover:text-teal-brand transition">Home</a>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4 text-gray-soft">
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 0 1 .02-1.06L11.168 10 7.23 6.29a.75.75 0 1 1 1.04-1.08l4.5 4.25a.75.75 0 0 1 0 1.08l-4.5 4.25a.75.75 0 0 1-1.06-.02Z" clip-rule="evenodd" />
</svg>
<a href="/pets" class="hover:text-teal-brand transition">Available Pets</a>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4 text-gray-soft">
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 0 1 .02-1.06L11.168 10 7.23 6.29a.75.75 0 1 1 1.04-1.08l4.5 4.25a.75.75 0 0 1 0 1.08l-4.5 4.25a.75.75 0 0 1-1.06-.02Z" clip-rule="evenodd" />
</svg>
<span class="text-gray-brand font-medium">{pet.name}</span>
</nav>
</div>
</div>
<article class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12">
<!-- Photo Gallery -->
<div>
<div class="aspect-[4/3] rounded-2xl overflow-hidden bg-teal-light shadow-md {currentPhoto ? 'cursor-zoom-in' : ''}">
{#if currentPhoto}
<img
src={currentPhoto.url}
alt="{pet.name} - Photo {selectedPhotoIndex + 1}"
class="w-full h-full object-cover"
onclick={() => openLightbox(selectedPhotoIndex)}
role="button"
tabindex="0"
onkeydown={(e) => { if (e.key === 'Enter') openLightbox(selectedPhotoIndex); }}
/>
{:else}
<div class="w-full h-full flex flex-col items-center justify-center gap-3">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" class="w-28 h-28 text-teal-brand/25">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3.744c-.066.028-.132.058-.195.092a7.486 7.486 0 0 0-3.43 5.13 4.323 4.323 0 0 0 1.058 3.79 3.724 3.724 0 0 0 1.14.865c.28.14.437.45.372.756a6.04 6.04 0 0 0-.14.871c-.036.532.04 1.108.288 1.632.249.524.671.964 1.247 1.202.554.23 1.11.247 1.619.126a3.25 3.25 0 0 0 .825-.347c.214-.13.49-.127.7.01a5.015 5.015 0 0 0 2.766.827 5.015 5.015 0 0 0 2.766-.828c.21-.136.486-.14.7-.009.244.151.53.268.825.347.51.121 1.065.104 1.619-.126.576-.238.998-.678 1.247-1.202.248-.524.324-1.1.288-1.632a6.04 6.04 0 0 0-.14-.87c-.065-.307.092-.617.372-.757a3.72 3.72 0 0 0 1.14-.865 4.323 4.323 0 0 0 1.058-3.79 7.486 7.486 0 0 0-3.43-5.13 2.258 2.258 0 0 0-.195-.092" />
</svg>
<p class="text-teal-brand/40 text-sm font-medium">Photo coming soon</p>
</div>
{/if}
</div>
{#if photos.length > 1}
<div class="mt-4 flex gap-3 overflow-x-auto pb-2">
{#each photos as photo, i}
<button
type="button"
onclick={() => (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'}"
>
<img src={photo.url} alt="{pet.name} thumbnail {i + 1}" class="w-full h-full object-cover" />
</button>
{/each}
</div>
{/if}
</div>
<!-- Pet Info -->
<div>
<div class="flex flex-wrap items-center gap-3 mb-1">
<span class="inline-block px-3 py-1 rounded-full text-xs font-semibold uppercase tracking-wide {statusColors[pet.status] ?? 'bg-gray-100 text-gray-700'}">
{statusLabels[pet.status] ?? pet.status}
</span>
</div>
<h1 class="text-3xl sm:text-4xl font-extrabold text-gray-brand mt-2">{pet.name}</h1>
{#if pet.breed}
<p class="text-lg text-gray-mid mt-1">{pet.breed}</p>
{/if}
<!-- Share buttons -->
<div class="mt-4 flex items-center gap-2">
<span class="text-sm text-gray-500">Share:</span>
<a
href="https://www.facebook.com/sharer/sharer.php?u={encodeURIComponent(pageUrl)}"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-[#1877F2] text-white hover:bg-[#1565C0] transition"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
</svg>
Share
</a>
<button
type="button"
onclick={copyLink}
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-gray-soft text-gray-600 hover:bg-gray-50 transition cursor-pointer"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
{linkCopied ? 'Copied!' : 'Copy Link'}
</button>
</div>
<!-- Quick stats -->
<div class="mt-6 grid grid-cols-2 sm:grid-cols-3 gap-4">
{#if pet.species}
<div class="bg-warm-bg rounded-xl p-4 text-center">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-mid mb-1">Species</p>
<p class="text-lg font-bold text-gray-brand capitalize">{pet.species}</p>
</div>
{/if}
{#if pet.age}
<div class="bg-warm-bg rounded-xl p-4 text-center">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-mid mb-1">Age</p>
<p class="text-lg font-bold text-gray-brand">{pet.age}</p>
</div>
{/if}
{#if pet.sex && pet.sex !== 'unknown'}
<div class="bg-warm-bg rounded-xl p-4 text-center">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-mid mb-1">Sex</p>
<p class="text-lg font-bold text-gray-brand capitalize">{pet.sex}</p>
</div>
{/if}
{#if pet.weight}
<div class="bg-warm-bg rounded-xl p-4 text-center">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-mid mb-1">Weight</p>
<p class="text-lg font-bold text-gray-brand">{pet.weight}</p>
</div>
{/if}
</div>
<!-- Compatibility -->
{#if pet.goodWithDogs !== null || pet.goodWithCats !== null || pet.goodWithKids !== null}
<div class="mt-6 flex flex-wrap gap-2">
{#if pet.goodWithDogs === true}
<span class="px-3 py-1 rounded-full bg-green-100 text-green-800 text-xs font-semibold">Good with dogs</span>
{:else if pet.goodWithDogs === false}
<span class="px-3 py-1 rounded-full bg-red-100 text-red-800 text-xs font-semibold">Not good with dogs</span>
{/if}
{#if pet.goodWithCats === true}
<span class="px-3 py-1 rounded-full bg-green-100 text-green-800 text-xs font-semibold">Good with cats</span>
{:else if pet.goodWithCats === false}
<span class="px-3 py-1 rounded-full bg-red-100 text-red-800 text-xs font-semibold">Not good with cats</span>
{/if}
{#if pet.goodWithKids === true}
<span class="px-3 py-1 rounded-full bg-green-100 text-green-800 text-xs font-semibold">Good with kids</span>
{:else if pet.goodWithKids === false}
<span class="px-3 py-1 rounded-full bg-red-100 text-red-800 text-xs font-semibold">Not good with kids</span>
{/if}
{#if pet.houseTrained}
<span class="px-3 py-1 rounded-full bg-blue-100 text-blue-800 text-xs font-semibold">House trained</span>
{/if}
{#if pet.spayedNeutered}
<span class="px-3 py-1 rounded-full bg-blue-100 text-blue-800 text-xs font-semibold">Spayed/Neutered</span>
{/if}
{#if pet.microchipped}
<span class="px-3 py-1 rounded-full bg-blue-100 text-blue-800 text-xs font-semibold">Microchipped</span>
{/if}
</div>
{/if}
<!-- Description -->
{#if pet.description}
<div class="mt-8">
<h2 class="text-xl font-bold text-gray-brand mb-3">About {pet.name}</h2>
<div class="prose prose-gray max-w-none text-gray-mid leading-relaxed whitespace-pre-line">
{pet.description}
</div>
</div>
{/if}
<!-- CTA -->
{#if pet.status === 'available'}
<div class="mt-8">
<a
href="/apply/adopt?pet={pet.slug}"
class="inline-flex items-center gap-2 px-8 py-4 bg-teal-brand text-white text-lg font-bold rounded-xl hover:bg-teal-dark shadow-lg hover:shadow-xl transition-all duration-200 focus:outline-none focus:ring-4 focus:ring-teal-brand/30"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 0 1-.383-.218 25.18 25.18 0 0 1-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0 1 12 5.052 5.5 5.5 0 0 1 16.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 0 1-4.244 3.17 15.247 15.247 0 0 1-.383.219l-.022.012-.007.004-.003.001a.752.752 0 0 1-.704 0l-.003-.001Z" />
</svg>
Apply to Adopt {pet.name}
</a>
</div>
{:else if pet.status === 'pending'}
<div class="mt-8 p-4 bg-amber-50 border border-amber-200 rounded-xl">
<p class="text-amber-800 font-semibold">
An adoption application for {pet.name} is currently being reviewed. You may still
<a href="/apply/adopt?pet={pet.slug}" class="underline hover:text-amber-900">submit an application</a>
to be considered.
</p>
</div>
{/if}
{#if pet.adoptionFee}
<p class="mt-3 text-sm text-gray-mid">
Adoption fee: <span class="font-semibold text-gray-brand">${(pet.adoptionFee / 100).toFixed(2)}</span>
</p>
{/if}
</div>
</div>
<!-- Details Card -->
{#if pet.intakeDate || pet.microchipId || pet.birthday}
<div class="mt-12 bg-white rounded-2xl shadow-sm border border-gray-soft p-6 sm:p-8">
<h2 class="text-xl font-bold text-gray-brand mb-4">Details</h2>
<dl class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-4">
{#if pet.intakeDate}
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-mid">Intake Date</dt>
<dd class="mt-1 text-gray-brand font-medium">{formatDate(pet.intakeDate)}</dd>
</div>
{/if}
{#if pet.birthday}
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-mid">Date of Birth</dt>
<dd class="mt-1 text-gray-brand font-medium">{formatDate(pet.birthday)}</dd>
</div>
{/if}
{#if pet.microchipId}
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-mid">Microchip</dt>
<dd class="mt-1 text-gray-brand font-medium">{pet.microchipId}</dd>
</div>
{/if}
</dl>
</div>
{/if}
<!-- Medical Notes -->
{#if pet.medicalNotes}
<div class="mt-8 bg-blue-50 border border-blue-200 rounded-2xl p-6">
<h2 class="text-lg font-bold text-blue-900 mb-2">Medical Notes</h2>
<p class="text-blue-800 text-sm leading-relaxed whitespace-pre-line">{pet.medicalNotes}</p>
</div>
{/if}
<!-- Related Pets -->
{#if relatedPets.length > 0}
<section class="mt-16">
<h2 class="text-2xl sm:text-3xl font-extrabold text-gray-brand mb-6 text-center">You Might Also Like</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{#each relatedPets as rp (rp.id)}
<PetCard pet={rp} />
{/each}
</div>
</section>
{/if}
</article>
<!-- Lightbox -->
{#if lightboxOpen && photos.length > 0}
<div
class="fixed inset-0 z-50 bg-black/90 flex items-center justify-center"
onclick={closeLightbox}
onkeydown={handleLightboxKey}
ontouchstart={handleTouchStart}
ontouchend={handleTouchEnd}
role="dialog"
aria-label="Photo lightbox"
tabindex="-1"
>
<button onclick={closeLightbox} class="absolute top-4 right-4 z-10 p-2 rounded-full bg-black/50 text-white hover:bg-black/70 transition cursor-pointer" aria-label="Close lightbox">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div class="absolute top-4 left-4 z-10 px-3 py-1.5 rounded-full bg-black/50 text-white text-sm font-medium">
{lightboxIndex + 1} / {photos.length}
</div>
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 text-center">
<p class="text-white font-bold text-lg drop-shadow-lg">{pet.name}</p>
<p class="text-white/70 text-sm drop-shadow-lg">
{pet.breed ?? ''}{pet.breed && pet.sex ? ' · ' : ''}{pet.sex && pet.sex !== 'unknown' ? pet.sex.charAt(0).toUpperCase() + pet.sex.slice(1) : ''}
</p>
</div>
{#if photos.length > 1}
<button onclick={(e) => { 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">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<button onclick={(e) => { 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">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
{/if}
<img
src={photos[lightboxIndex].url}
alt="{pet.name} - Photo {lightboxIndex + 1}"
class="max-h-[85vh] max-w-[90vw] object-contain rounded-lg shadow-2xl"
onclick={(e) => e.stopPropagation()}
/>
{#if photos.length > 1}
<div class="absolute bottom-16 left-1/2 -translate-x-1/2 z-10 flex gap-2 px-4 py-2 rounded-full bg-black/50 overflow-x-auto max-w-[80vw]">
{#each photos as photo, i}
<button onclick={(e) => { 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">
<img src={photo.url} alt="" class="w-full h-full object-cover" />
</button>
{/each}
</div>
{/if}
</div>
{/if}
<svelte:window onkeydown={handleLightboxKey} />
+64
View File
@@ -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
});
};