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:
@@ -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
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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}
|
||||
@@ -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 }
|
||||
};
|
||||
};
|
||||
@@ -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} />
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user