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