| [92e7c7a] | 1 | <template>
|
|---|
| 2 | <main class="listings-main">
|
|---|
| 3 | <section class="hero-section">
|
|---|
| 4 | <div class="hero-content">
|
|---|
| 5 | <div class="hero-left">
|
|---|
| 6 | <p class="hero-eyebrow">Petify marketplace</p>
|
|---|
| 7 | <h1 class="hero-title">Find your perfect pet</h1>
|
|---|
| 8 | <p class="hero-subtitle">Browse trusted listings from owners in the Petify community.</p>
|
|---|
| 9 |
|
|---|
| 10 | <div class="search-box">
|
|---|
| 11 | <div class="pet-type-selector mb-3">
|
|---|
| 12 | <button
|
|---|
| 13 | v-for="pet in petTypes"
|
|---|
| 14 | :key="pet.value"
|
|---|
| 15 | :class="['pet-btn', { active: petType === pet.value }]"
|
|---|
| 16 | @click="petType = pet.value"
|
|---|
| 17 | >
|
|---|
| 18 | <span class="pet-icon">
|
|---|
| 19 | <img v-if="pet.type === 'image'" :src="pet.icon" :alt="pet.label" class="pet-icon-img" />
|
|---|
| 20 | <span v-else>{{ pet.icon }}</span>
|
|---|
| 21 | </span>
|
|---|
| 22 | <span>{{ pet.label }}</span>
|
|---|
| 23 | </button>
|
|---|
| 24 | </div>
|
|---|
| 25 |
|
|---|
| 26 | <div class="search-inputs">
|
|---|
| 27 | <div class="filters">
|
|---|
| 28 | <select v-model="selectedBreed" class="form-select" aria-label="Filter by breed">
|
|---|
| 29 | <option value="">All breeds</option>
|
|---|
| 30 | <option v-for="b in breedOptions" :key="b" :value="b">{{ b }}</option>
|
|---|
| 31 | </select>
|
|---|
| 32 |
|
|---|
| 33 | <select v-model="selectedCity" class="form-select" aria-label="Filter by location">
|
|---|
| 34 | <option value="">All locations</option>
|
|---|
| 35 | <option v-for="c in cityOptions" :key="c" :value="c">{{ c }}</option>
|
|---|
| 36 | </select>
|
|---|
| 37 | </div>
|
|---|
| 38 | </div>
|
|---|
| 39 | </div>
|
|---|
| 40 | </div>
|
|---|
| 41 |
|
|---|
| 42 | <div class="hero-right">
|
|---|
| 43 | <aside class="benefits-card" aria-label="Why choose Petify">
|
|---|
| 44 | <h3 class="benefits-title">Why Petify?</h3>
|
|---|
| 45 | <ul class="benefits-list">
|
|---|
| 46 | <li><span class="benefit-icon" aria-hidden="true"></span><span>Verified pet owners</span></li>
|
|---|
| 47 | <li><span class="benefit-icon" aria-hidden="true"></span><span>Clear listing history</span></li>
|
|---|
| 48 | <li><span class="benefit-icon" aria-hidden="true"></span><span>Community review signals</span></li>
|
|---|
| 49 | </ul>
|
|---|
| 50 | </aside>
|
|---|
| 51 | </div>
|
|---|
| 52 | </div>
|
|---|
| 53 | </section>
|
|---|
| 54 |
|
|---|
| 55 | <!-- Listings Section -->
|
|---|
| 56 | <section class="listings-section">
|
|---|
| 57 | <div class="container">
|
|---|
| 58 | <div class="listings-header">
|
|---|
| 59 | <div>
|
|---|
| 60 | <div class="view-mode-tabs mb-3">
|
|---|
| 61 | <button
|
|---|
| 62 | :class="['view-mode-btn', { active: viewMode === 'all' }]"
|
|---|
| 63 | @click="viewMode = 'all'; load()"
|
|---|
| 64 | type="button"
|
|---|
| 65 | >
|
|---|
| 66 | All Listings
|
|---|
| 67 | </button>
|
|---|
| 68 | <button
|
|---|
| 69 | v-if="auth.isAuthenticated"
|
|---|
| 70 | :class="['view-mode-btn', { active: viewMode === 'recommended' }]"
|
|---|
| 71 | @click="viewMode = 'recommended'; load()"
|
|---|
| 72 | type="button"
|
|---|
| 73 | >
|
|---|
| 74 | Recommended
|
|---|
| 75 | </button>
|
|---|
| 76 | </div>
|
|---|
| 77 | <h2 class="listings-title">{{ viewMode === 'recommended' ? 'Your recommended listings' : 'Browse all listings' }}</h2>
|
|---|
| 78 | <p class="listings-subtitle">{{ viewMode === 'recommended' ? 'Personalized just for you' : 'Scroll to see available pets' }}</p>
|
|---|
| 79 | </div>
|
|---|
| 80 | <button class="btn btn-outline-secondary" type="button" @click="reload" :disabled="loading">
|
|---|
| 81 | {{ loading ? 'Loading…' : 'Reload' }}
|
|---|
| 82 | </button>
|
|---|
| 83 | </div>
|
|---|
| 84 |
|
|---|
| 85 | <div v-if="error" class="alert alert-warning d-flex align-items-center justify-content-between" role="alert">
|
|---|
| 86 | <div>
|
|---|
| 87 | <div class="fw-semibold">Couldn't load from API.</div>
|
|---|
| 88 | <div class="small">Showing sample listings instead. {{ error }}</div>
|
|---|
| 89 | </div>
|
|---|
| 90 | <button class="btn btn-sm btn-outline-dark" type="button" @click="reload">Try again</button>
|
|---|
| 91 | </div>
|
|---|
| 92 |
|
|---|
| 93 | <div v-if="loading" class="text-center py-5 text-muted">
|
|---|
| 94 | <p>Loading listings…</p>
|
|---|
| 95 | </div>
|
|---|
| 96 |
|
|---|
| 97 | <div v-else-if="filteredListings.length === 0" class="text-center py-5 text-muted">
|
|---|
| 98 | <p>No listings match your filters.</p>
|
|---|
| 99 | </div>
|
|---|
| 100 |
|
|---|
| 101 | <section v-else class="grid" aria-label="Pet listings">
|
|---|
| 102 | <ListingCard
|
|---|
| 103 | v-for="listing in filteredListings"
|
|---|
| 104 | :key="listing.id"
|
|---|
| 105 | :listing="listing"
|
|---|
| 106 | :favorited="listing.favorited"
|
|---|
| 107 | @click="goToDetails(listing.id)"
|
|---|
| 108 | @view="goToDetails(listing.id)"
|
|---|
| 109 | @contact="contactOwner"
|
|---|
| 110 | @favorite="handleFavorite"
|
|---|
| 111 | />
|
|---|
| 112 | </section>
|
|---|
| 113 | </div>
|
|---|
| 114 | </section>
|
|---|
| 115 | </main>
|
|---|
| 116 | </template>
|
|---|
| 117 |
|
|---|
| 118 | <script setup lang="ts">
|
|---|
| 119 | import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|---|
| 120 | import { useRouter } from 'vue-router'
|
|---|
| 121 | import ListingCard from '@/components/ListingCard.vue'
|
|---|
| 122 | import type { Listing } from '../types/listing'
|
|---|
| 123 | import { fetchListings, fetchRecommendedListings } from '../api/listings'
|
|---|
| 124 | import { mockListings } from '../data/mockListings'
|
|---|
| 125 | import { useAuthStore } from '../stores/auth'
|
|---|
| 126 | import { addFavorite, removeFavorite, getFavoritedListings } from '../api/favorites'
|
|---|
| 127 | import { getPet, getUserProfile } from '../api/profile'
|
|---|
| 128 |
|
|---|
| 129 | const router = useRouter()
|
|---|
| 130 | const auth = useAuthStore()
|
|---|
| 131 |
|
|---|
| 132 | const loading = ref(false)
|
|---|
| 133 | const error = ref<string | null>(null)
|
|---|
| 134 | const listings = ref<Listing[]>([])
|
|---|
| 135 | const favoritedListingIds = ref<Set<number>>(new Set())
|
|---|
| 136 |
|
|---|
| 137 | const viewMode = ref<'all' | 'recommended'>('all')
|
|---|
| 138 | const petType = ref('')
|
|---|
| 139 |
|
|---|
| 140 | // Filters
|
|---|
| 141 | const selectedBreed = ref('')
|
|---|
| 142 | const selectedCity = ref('')
|
|---|
| 143 |
|
|---|
| 144 | const petTypes = [
|
|---|
| 145 | { value: '', label: 'All pets', icon: new URL('../img/all_outline.png', import.meta.url).href, type: 'image' },
|
|---|
| 146 | { value: 'DOG', label: 'Dogs', icon: new URL('../img/dog_outline.png', import.meta.url).href, type: 'image' },
|
|---|
| 147 | { value: 'CAT', label: 'Cats', icon: new URL('../img/cat_outline.png', import.meta.url).href, type: 'image' },
|
|---|
| 148 | { value: 'Other', label: 'Other', icon: new URL('../img/bird_outline.png', import.meta.url).href, type: 'image' },
|
|---|
| 149 | ]
|
|---|
| 150 |
|
|---|
| 151 | // Store pet details cache to avoid repeated API calls
|
|---|
| 152 | const petDetailsCache = ref<Map<number, any>>(new Map())
|
|---|
| 153 |
|
|---|
| 154 | // Store owner details cache
|
|---|
| 155 | const ownerDetailsCache = ref<Map<number, any>>(new Map())
|
|---|
| 156 |
|
|---|
| 157 | const breedOptions = computed(() => {
|
|---|
| 158 | const set = new Set<string>()
|
|---|
| 159 | for (const l of listings.value) {
|
|---|
| 160 | // Keep breed dropdown relevant to selected pet type
|
|---|
| 161 | if (petType.value) {
|
|---|
| 162 | if (petType.value === 'Other') {
|
|---|
| 163 | if (['DOG', 'CAT'].includes(getListingSpecies(l))) continue
|
|---|
| 164 | } else if (getListingSpecies(l) !== petType.value) {
|
|---|
| 165 | continue
|
|---|
| 166 | }
|
|---|
| 167 | }
|
|---|
| 168 |
|
|---|
| 169 | // Get breed from cached pet details
|
|---|
| 170 | const b = String(l.breed || (l.animalId ? petDetailsCache.value.get(l.animalId)?.breed : '') || '').trim()
|
|---|
| 171 | if (b) set.add(b)
|
|---|
| 172 | }
|
|---|
| 173 | return Array.from(set).sort((a, b) => a.localeCompare(b))
|
|---|
| 174 | })
|
|---|
| 175 |
|
|---|
| 176 | const cityOptions = computed(() => {
|
|---|
| 177 | const set = new Set<string>()
|
|---|
| 178 | for (const l of listings.value) {
|
|---|
| 179 | const c = String(l.city || (l.animalId ? petDetailsCache.value.get(l.animalId)?.locatedName : '') || '').trim().toLowerCase()
|
|---|
| 180 | if (c) set.add(c)
|
|---|
| 181 | }
|
|---|
| 182 | return Array.from(set).sort((a, b) => a.localeCompare(b))
|
|---|
| 183 | })
|
|---|
| 184 |
|
|---|
| 185 | const filteredListings = computed(() => {
|
|---|
| 186 | return listings.value
|
|---|
| 187 | .filter((l) => {
|
|---|
| 188 | if (!auth.isAuthenticated || !auth.user?.userId) return true
|
|---|
| 189 | return Number(l.ownerId) !== Number(auth.user.userId)
|
|---|
| 190 | })
|
|---|
| 191 | .filter((l) => {
|
|---|
| 192 | // Species filter
|
|---|
| 193 | if (!petType.value) return true
|
|---|
| 194 | const species = getListingSpecies(l)
|
|---|
| 195 | if (petType.value === 'Other') return Boolean(species) && !['DOG', 'CAT'].includes(species)
|
|---|
| 196 | return species === petType.value
|
|---|
| 197 | })
|
|---|
| 198 | .filter((l) => {
|
|---|
| 199 | // Breed filter - if no breed is selected, show all
|
|---|
| 200 | if (!selectedBreed.value || selectedBreed.value === '') return true
|
|---|
| 201 |
|
|---|
| 202 | const breed = String(l.breed || (l.animalId ? petDetailsCache.value.get(l.animalId)?.breed : '') || '').trim()
|
|---|
| 203 | return breed === selectedBreed.value
|
|---|
| 204 | })
|
|---|
| 205 | .filter((l) => {
|
|---|
| 206 | // City filter
|
|---|
| 207 | if (!selectedCity.value || selectedCity.value === '') return true
|
|---|
| 208 |
|
|---|
| 209 | const city = String(l.city || (l.animalId ? petDetailsCache.value.get(l.animalId)?.locatedName : '') || '').trim().toLowerCase()
|
|---|
| 210 | return city === selectedCity.value.toLowerCase()
|
|---|
| 211 | })
|
|---|
| 212 | .map((l) => {
|
|---|
| 213 | const id = Number((l as any).id || (l as any).listingId)
|
|---|
| 214 | return {
|
|---|
| 215 | ...l,
|
|---|
| 216 | favorited: Number.isFinite(id) && favoritedListingIds.value.has(id),
|
|---|
| 217 | }
|
|---|
| 218 | })
|
|---|
| 219 | })
|
|---|
| 220 |
|
|---|
| 221 | function getListingSpecies(listing: Listing): string {
|
|---|
| 222 | return String(listing.species || listing.petType || '').trim().toUpperCase()
|
|---|
| 223 | }
|
|---|
| 224 |
|
|---|
| 225 | let abort: AbortController | null = null
|
|---|
| 226 |
|
|---|
| 227 | async function load() {
|
|---|
| 228 | loading.value = true
|
|---|
| 229 | error.value = null
|
|---|
| 230 |
|
|---|
| 231 | abort?.abort()
|
|---|
| 232 | abort = new AbortController()
|
|---|
| 233 |
|
|---|
| 234 | try {
|
|---|
| 235 | let data: Listing[]
|
|---|
| 236 |
|
|---|
| 237 | if (viewMode.value === 'recommended' && auth.isAuthenticated && auth.user?.userId) {
|
|---|
| 238 | // Load recommended listings
|
|---|
| 239 | data = await fetchRecommendedListings(auth.user.userId, { signal: abort.signal })
|
|---|
| 240 | } else {
|
|---|
| 241 | // Load all listings
|
|---|
| 242 | data = await fetchListings({ signal: abort.signal })
|
|---|
| 243 | }
|
|---|
| 244 |
|
|---|
| 245 | // Fetch pet images and owner details for each listing
|
|---|
| 246 | const listingsWithImages = await Promise.all(
|
|---|
| 247 | data.map(async (listing) => {
|
|---|
| 248 | const result = { ...listing }
|
|---|
| 249 |
|
|---|
| 250 | // The public listings API is backed by v_listings_enriched, so most card data
|
|---|
| 251 | // arrives already joined from the view. Fetch only when older responses are sparse.
|
|---|
| 252 | try {
|
|---|
| 253 | if (
|
|---|
| 254 | listing.animalId &&
|
|---|
| 255 | (!result.imageUrl || !result.animalName || !result.species || !result.city)
|
|---|
| 256 | ) {
|
|---|
| 257 | const pet = await getPet(listing.animalId)
|
|---|
| 258 | // Cache pet details for breed options
|
|---|
| 259 | petDetailsCache.value.set(listing.animalId, pet)
|
|---|
| 260 | result.imageUrl = pet.photoUrl || new URL('../img/all_outline.png', import.meta.url).href
|
|---|
| 261 | result.animalName = pet.name // Add pet name
|
|---|
| 262 | result.species = pet.species || pet.type
|
|---|
| 263 | result.petType = pet.species || pet.type
|
|---|
| 264 | result.breed = pet.breed || result.breed
|
|---|
| 265 | result.city = pet.locatedName || result.city
|
|---|
| 266 | } else if (listing.animalId) {
|
|---|
| 267 | petDetailsCache.value.set(listing.animalId, {
|
|---|
| 268 | name: result.animalName,
|
|---|
| 269 | species: result.species,
|
|---|
| 270 | breed: result.breed,
|
|---|
| 271 | locatedName: result.city,
|
|---|
| 272 | photoUrl: result.imageUrl,
|
|---|
| 273 | })
|
|---|
| 274 | }
|
|---|
| 275 | } catch (err) {
|
|---|
| 276 | console.error(`Failed to fetch pet ${listing.animalId}:`, err)
|
|---|
| 277 | result.imageUrl = new URL('../img/all_outline.png', import.meta.url).href
|
|---|
| 278 | }
|
|---|
| 279 |
|
|---|
| 280 | // Fetch owner details only if the enriched view did not provide them.
|
|---|
| 281 | try {
|
|---|
| 282 | if (listing.ownerId && (!result.ownerName || !result.ownerEmail)) {
|
|---|
| 283 | const owner = await getUserProfile(listing.ownerId)
|
|---|
| 284 | // Cache owner details
|
|---|
| 285 | ownerDetailsCache.value.set(listing.ownerId, owner)
|
|---|
| 286 | result.ownerName = `${owner.firstName} ${owner.lastName}` // Add owner name
|
|---|
| 287 | result.ownerEmail = owner.email
|
|---|
| 288 | } else if (listing.ownerId) {
|
|---|
| 289 | ownerDetailsCache.value.set(listing.ownerId, {
|
|---|
| 290 | firstName: result.ownerName,
|
|---|
| 291 | email: result.ownerEmail,
|
|---|
| 292 | })
|
|---|
| 293 | }
|
|---|
| 294 | } catch (err) {
|
|---|
| 295 | console.error(`Failed to fetch owner ${listing.ownerId}:`, err)
|
|---|
| 296 | }
|
|---|
| 297 |
|
|---|
| 298 | return result
|
|---|
| 299 | })
|
|---|
| 300 | )
|
|---|
| 301 |
|
|---|
| 302 | listings.value = listingsWithImages
|
|---|
| 303 | } catch (e) {
|
|---|
| 304 | const message = e instanceof Error ? e.message : String(e)
|
|---|
| 305 | error.value = message
|
|---|
| 306 | listings.value = mockListings
|
|---|
| 307 | }
|
|---|
| 308 |
|
|---|
| 309 | if (auth.isAuthenticated && auth.user?.userId) {
|
|---|
| 310 | await loadUserFavorites()
|
|---|
| 311 | } else {
|
|---|
| 312 | favoritedListingIds.value.clear()
|
|---|
| 313 | }
|
|---|
| 314 |
|
|---|
| 315 | loading.value = false
|
|---|
| 316 | }
|
|---|
| 317 |
|
|---|
| 318 | async function loadUserFavorites() {
|
|---|
| 319 | try {
|
|---|
| 320 | if (!auth.isAuthenticated || !auth.user?.userId) {
|
|---|
| 321 | favoritedListingIds.value.clear()
|
|---|
| 322 | return
|
|---|
| 323 | }
|
|---|
| 324 | const favorites = await getFavoritedListings(auth.user.userId)
|
|---|
| 325 | favoritedListingIds.value = new Set(
|
|---|
| 326 | favorites.map((f) => Number(f.listingId)).filter((id) => Number.isFinite(id) && id > 0),
|
|---|
| 327 | )
|
|---|
| 328 | } catch (error) {
|
|---|
| 329 | console.error('Failed to load favorites:', error)
|
|---|
| 330 | favoritedListingIds.value.clear()
|
|---|
| 331 | }
|
|---|
| 332 | }
|
|---|
| 333 |
|
|---|
| 334 | function reload() {
|
|---|
| 335 | load()
|
|---|
| 336 | }
|
|---|
| 337 |
|
|---|
| 338 | function goToDetails(id: string) {
|
|---|
| 339 | router.push({ name: 'listing-details', params: { id } })
|
|---|
| 340 | }
|
|---|
| 341 |
|
|---|
| 342 | async function handleFavorite(event: { listing: Listing; favorited: boolean }) {
|
|---|
| 343 | if (!auth.isAuthenticated || !auth.user?.userId) {
|
|---|
| 344 | alert('Please log in to save favorites')
|
|---|
| 345 | return
|
|---|
| 346 | }
|
|---|
| 347 |
|
|---|
| 348 | try {
|
|---|
| 349 | const listingId = Number((event.listing as any).id || (event.listing as any).listingId)
|
|---|
| 350 |
|
|---|
| 351 | if (event.favorited) {
|
|---|
| 352 | await addFavorite(auth.user.userId, listingId)
|
|---|
| 353 | favoritedListingIds.value.add(listingId)
|
|---|
| 354 | } else {
|
|---|
| 355 | await removeFavorite(auth.user.userId, listingId)
|
|---|
| 356 | favoritedListingIds.value.delete(listingId)
|
|---|
| 357 | }
|
|---|
| 358 | } catch (error) {
|
|---|
| 359 | console.error('Failed to update favorite:', error)
|
|---|
| 360 | alert('Failed to update favorite. Please try again.')
|
|---|
| 361 | }
|
|---|
| 362 | }
|
|---|
| 363 |
|
|---|
| 364 | function contactOwner(listing: Listing) {
|
|---|
| 365 | if (!listing.ownerEmail) {
|
|---|
| 366 | alert('This owner does not have a contact email available.')
|
|---|
| 367 | return
|
|---|
| 368 | }
|
|---|
| 369 |
|
|---|
| 370 | const petName = listing.animalName || listing.title || 'your pet'
|
|---|
| 371 | const subject = encodeURIComponent(`Question about ${petName} on Petify`)
|
|---|
| 372 | const body = encodeURIComponent(`Hi ${listing.ownerName || ''},\n\nI saw your listing for ${petName} on Petify and would like to know more.\n\nThanks!`)
|
|---|
| 373 | window.location.href = `mailto:${listing.ownerEmail}?subject=${subject}&body=${body}`
|
|---|
| 374 | }
|
|---|
| 375 |
|
|---|
| 376 | onMounted(load)
|
|---|
| 377 | onBeforeUnmount(() => abort?.abort())
|
|---|
| 378 |
|
|---|
| 379 | // Reset breed filter when pet type changes
|
|---|
| 380 | watch(petType, () => {
|
|---|
| 381 | selectedBreed.value = ''
|
|---|
| 382 | })
|
|---|
| 383 | </script>
|
|---|
| 384 |
|
|---|
| 385 | <style scoped>
|
|---|
| 386 | .listings-main {
|
|---|
| 387 | margin: 0;
|
|---|
| 388 | padding: 0;
|
|---|
| 389 | }
|
|---|
| 390 |
|
|---|
| 391 | /* Hero Section */
|
|---|
| 392 | .hero-section {
|
|---|
| 393 | background:
|
|---|
| 394 | linear-gradient(135deg, rgba(124, 45, 18, 0.92), rgba(249, 115, 22, 0.88)),
|
|---|
| 395 | url('../img/all_outline.png') right -120px center / 620px auto no-repeat;
|
|---|
| 396 | color: white;
|
|---|
| 397 | padding: 56px 40px 64px;
|
|---|
| 398 | }
|
|---|
| 399 |
|
|---|
| 400 | .hero-content {
|
|---|
| 401 | align-items: stretch;
|
|---|
| 402 | display: grid;
|
|---|
| 403 | gap: 32px;
|
|---|
| 404 | grid-template-columns: minmax(0, 1.45fr) minmax(280px, 0.55fr);
|
|---|
| 405 | margin: 0 auto;
|
|---|
| 406 | max-width: 1180px;
|
|---|
| 407 | width: 100%;
|
|---|
| 408 | }
|
|---|
| 409 |
|
|---|
| 410 | .hero-left {
|
|---|
| 411 | display: flex;
|
|---|
| 412 | flex-direction: column;
|
|---|
| 413 | justify-content: center;
|
|---|
| 414 | }
|
|---|
| 415 |
|
|---|
| 416 | .hero-eyebrow {
|
|---|
| 417 | color: #bbf7d0;
|
|---|
| 418 | font-size: 0.82rem;
|
|---|
| 419 | font-weight: 800;
|
|---|
| 420 | margin: 0 0 10px;
|
|---|
| 421 | text-transform: uppercase;
|
|---|
| 422 | }
|
|---|
| 423 |
|
|---|
| 424 | .hero-title {
|
|---|
| 425 | font-size: 3rem;
|
|---|
| 426 | font-weight: 800;
|
|---|
| 427 | line-height: 1.12;
|
|---|
| 428 | margin: 0 0 14px;
|
|---|
| 429 | max-width: 680px;
|
|---|
| 430 | }
|
|---|
| 431 |
|
|---|
| 432 | .hero-subtitle {
|
|---|
| 433 | color: #fff7ed;
|
|---|
| 434 | font-size: 1.1rem;
|
|---|
| 435 | margin: 0;
|
|---|
| 436 | max-width: 560px;
|
|---|
| 437 | }
|
|---|
| 438 |
|
|---|
| 439 | .search-box {
|
|---|
| 440 | background: rgba(255, 255, 255, 0.96);
|
|---|
| 441 | border: 1px solid rgba(255, 255, 255, 0.72);
|
|---|
| 442 | border-radius: 8px;
|
|---|
| 443 | box-shadow: 0 18px 45px rgba(67, 20, 7, 0.22);
|
|---|
| 444 | color: #1f2937;
|
|---|
| 445 | margin-top: 28px;
|
|---|
| 446 | padding: 22px;
|
|---|
| 447 | }
|
|---|
| 448 |
|
|---|
| 449 | .pet-type-selector {
|
|---|
| 450 | display: grid;
|
|---|
| 451 | grid-template-columns: repeat(4, 1fr);
|
|---|
| 452 | gap: 12px;
|
|---|
| 453 | }
|
|---|
| 454 |
|
|---|
| 455 | .pet-btn {
|
|---|
| 456 | display: flex;
|
|---|
| 457 | flex-direction: column;
|
|---|
| 458 | align-items: center;
|
|---|
| 459 | gap: 8px;
|
|---|
| 460 | padding: 16px;
|
|---|
| 461 | border: 1px solid #e5e7eb;
|
|---|
| 462 | border-radius: 8px;
|
|---|
| 463 | background: white;
|
|---|
| 464 | cursor: pointer;
|
|---|
| 465 | transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
|
|---|
| 466 | font-size: 0.875rem;
|
|---|
| 467 | font-weight: 500;
|
|---|
| 468 | }
|
|---|
| 469 |
|
|---|
| 470 | .pet-btn:hover {
|
|---|
| 471 | border-color: #d97706;
|
|---|
| 472 | background: #fef3c7;
|
|---|
| 473 | }
|
|---|
| 474 |
|
|---|
| 475 | .pet-btn.active {
|
|---|
| 476 | border-color: #d97706;
|
|---|
| 477 | background: #d97706;
|
|---|
| 478 | color: white;
|
|---|
| 479 | }
|
|---|
| 480 |
|
|---|
| 481 | .pet-icon {
|
|---|
| 482 | font-size: 1.5rem;
|
|---|
| 483 | }
|
|---|
| 484 |
|
|---|
| 485 | .pet-icon-img {
|
|---|
| 486 | width: 4.25rem;
|
|---|
| 487 | height: 3.4rem;
|
|---|
| 488 | object-fit: contain;
|
|---|
| 489 | }
|
|---|
| 490 |
|
|---|
| 491 | .search-inputs {
|
|---|
| 492 | display: flex;
|
|---|
| 493 | flex-direction: column;
|
|---|
| 494 | gap: 12px;
|
|---|
| 495 | }
|
|---|
| 496 |
|
|---|
| 497 | .filters {
|
|---|
| 498 | display: grid;
|
|---|
| 499 | grid-template-columns: 1fr 1fr;
|
|---|
| 500 | gap: 12px;
|
|---|
| 501 | align-items: center;
|
|---|
| 502 | }
|
|---|
| 503 |
|
|---|
| 504 | .form-select {
|
|---|
| 505 | background-color: #ffffff;
|
|---|
| 506 | border: 1px solid #d1d5db;
|
|---|
| 507 | border-radius: 8px;
|
|---|
| 508 | padding: 12px 16px;
|
|---|
| 509 | font-size: 1rem;
|
|---|
| 510 | transition: border-color 0.3s ease;
|
|---|
| 511 | }
|
|---|
| 512 |
|
|---|
| 513 | .form-select:focus {
|
|---|
| 514 | border-color: #d97706;
|
|---|
| 515 | outline: none;
|
|---|
| 516 | box-shadow: none;
|
|---|
| 517 | }
|
|---|
| 518 |
|
|---|
| 519 | .hero-right {
|
|---|
| 520 | align-items: center;
|
|---|
| 521 | display: flex;
|
|---|
| 522 | justify-content: center;
|
|---|
| 523 | }
|
|---|
| 524 |
|
|---|
| 525 | .benefits-card {
|
|---|
| 526 | align-self: center;
|
|---|
| 527 | background: rgba(255, 255, 255, 0.94);
|
|---|
| 528 | border: 1px solid rgba(255, 255, 255, 0.76);
|
|---|
| 529 | border-radius: 8px;
|
|---|
| 530 | box-shadow: 0 18px 45px rgba(67, 20, 7, 0.2);
|
|---|
| 531 | color: #111827;
|
|---|
| 532 | max-width: 340px;
|
|---|
| 533 | padding: 24px;
|
|---|
| 534 | width: 100%;
|
|---|
| 535 | }
|
|---|
| 536 |
|
|---|
| 537 | .benefits-title {
|
|---|
| 538 | color: #111827;
|
|---|
| 539 | font-size: 1.2rem;
|
|---|
| 540 | font-weight: 800;
|
|---|
| 541 | margin-bottom: 16px;
|
|---|
| 542 | margin-top: 0;
|
|---|
| 543 | }
|
|---|
| 544 |
|
|---|
| 545 | .benefits-list {
|
|---|
| 546 | list-style: none;
|
|---|
| 547 | padding: 0;
|
|---|
| 548 | margin: 0;
|
|---|
| 549 | }
|
|---|
| 550 |
|
|---|
| 551 | .benefits-list li {
|
|---|
| 552 | color: #374151;
|
|---|
| 553 | display: flex;
|
|---|
| 554 | align-items: center;
|
|---|
| 555 | gap: 12px;
|
|---|
| 556 | padding: 12px 0;
|
|---|
| 557 | font-size: 1rem;
|
|---|
| 558 | line-height: 1.5;
|
|---|
| 559 | }
|
|---|
| 560 |
|
|---|
| 561 | .benefit-icon {
|
|---|
| 562 | align-items: center;
|
|---|
| 563 | background: #dcfce7;
|
|---|
| 564 | border: 1px solid #86efac;
|
|---|
| 565 | border-radius: 999px;
|
|---|
| 566 | display: inline-flex;
|
|---|
| 567 | flex-shrink: 0;
|
|---|
| 568 | height: 24px;
|
|---|
| 569 | justify-content: center;
|
|---|
| 570 | width: 24px;
|
|---|
| 571 | }
|
|---|
| 572 |
|
|---|
| 573 | .benefit-icon::after {
|
|---|
| 574 | background: #16a34a;
|
|---|
| 575 | border-radius: 999px;
|
|---|
| 576 | content: '';
|
|---|
| 577 | height: 8px;
|
|---|
| 578 | width: 8px;
|
|---|
| 579 | }
|
|---|
| 580 |
|
|---|
| 581 | /* Listings Section */
|
|---|
| 582 | .listings-section {
|
|---|
| 583 | padding: 60px 40px;
|
|---|
| 584 | background: #f9fafb;
|
|---|
| 585 | }
|
|---|
| 586 |
|
|---|
| 587 | .listings-header {
|
|---|
| 588 | display: flex;
|
|---|
| 589 | justify-content: space-between;
|
|---|
| 590 | align-items: center;
|
|---|
| 591 | margin-bottom: 32px;
|
|---|
| 592 | }
|
|---|
| 593 |
|
|---|
| 594 | .listings-title {
|
|---|
| 595 | font-size: 2rem;
|
|---|
| 596 | font-weight: 700;
|
|---|
| 597 | margin: 0;
|
|---|
| 598 | color: #111827;
|
|---|
| 599 | }
|
|---|
| 600 |
|
|---|
| 601 | .listings-subtitle {
|
|---|
| 602 | margin: 8px 0 0;
|
|---|
| 603 | color: #6b7280;
|
|---|
| 604 | font-size: 1rem;
|
|---|
| 605 | }
|
|---|
| 606 |
|
|---|
| 607 | .view-mode-tabs {
|
|---|
| 608 | display: flex;
|
|---|
| 609 | gap: 12px;
|
|---|
| 610 | margin-bottom: 16px;
|
|---|
| 611 | }
|
|---|
| 612 |
|
|---|
| 613 | .view-mode-btn {
|
|---|
| 614 | padding: 8px 16px;
|
|---|
| 615 | border: 2px solid #e5e7eb;
|
|---|
| 616 | border-radius: 8px;
|
|---|
| 617 | background: white;
|
|---|
| 618 | color: #6b7280;
|
|---|
| 619 | font-weight: 500;
|
|---|
| 620 | cursor: pointer;
|
|---|
| 621 | transition: all 0.2s ease;
|
|---|
| 622 | }
|
|---|
| 623 |
|
|---|
| 624 | .view-mode-btn:hover {
|
|---|
| 625 | border-color: #d1d5db;
|
|---|
| 626 | color: #374151;
|
|---|
| 627 | }
|
|---|
| 628 |
|
|---|
| 629 | .view-mode-btn.active {
|
|---|
| 630 | border-color: #f97316;
|
|---|
| 631 | background: #f97316;
|
|---|
| 632 | color: white;
|
|---|
| 633 | }
|
|---|
| 634 |
|
|---|
| 635 | /* Grid */
|
|---|
| 636 | .grid {
|
|---|
| 637 | display: grid;
|
|---|
| 638 | grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|---|
| 639 | gap: 24px;
|
|---|
| 640 | }
|
|---|
| 641 |
|
|---|
| 642 | /* Responsive */
|
|---|
| 643 | @media (max-width: 991.98px) {
|
|---|
| 644 | .hero-section {
|
|---|
| 645 | padding: 40px 20px;
|
|---|
| 646 | min-height: auto;
|
|---|
| 647 | }
|
|---|
| 648 |
|
|---|
| 649 | .hero-content {
|
|---|
| 650 | grid-template-columns: 1fr;
|
|---|
| 651 | gap: 32px;
|
|---|
| 652 | }
|
|---|
| 653 |
|
|---|
| 654 | .hero-left {
|
|---|
| 655 | padding-right: 0;
|
|---|
| 656 | }
|
|---|
| 657 |
|
|---|
| 658 | .hero-title {
|
|---|
| 659 | font-size: 2.5rem;
|
|---|
| 660 | }
|
|---|
| 661 |
|
|---|
| 662 | .hero-subtitle {
|
|---|
| 663 | font-size: 1rem;
|
|---|
| 664 | }
|
|---|
| 665 |
|
|---|
| 666 | .search-box {
|
|---|
| 667 | padding: 20px;
|
|---|
| 668 | }
|
|---|
| 669 |
|
|---|
| 670 | .pet-type-selector {
|
|---|
| 671 | grid-template-columns: repeat(4, 1fr);
|
|---|
| 672 | gap: 8px;
|
|---|
| 673 | }
|
|---|
| 674 |
|
|---|
| 675 | .pet-btn {
|
|---|
| 676 | padding: 12px 8px;
|
|---|
| 677 | font-size: 0.75rem;
|
|---|
| 678 | }
|
|---|
| 679 |
|
|---|
| 680 | .pet-icon {
|
|---|
| 681 | font-size: 1.25rem;
|
|---|
| 682 | }
|
|---|
| 683 |
|
|---|
| 684 | .filters {
|
|---|
| 685 | grid-template-columns: 1fr;
|
|---|
| 686 | }
|
|---|
| 687 |
|
|---|
| 688 | .listings-section {
|
|---|
| 689 | padding: 40px 20px;
|
|---|
| 690 | }
|
|---|
| 691 |
|
|---|
| 692 | .listings-header {
|
|---|
| 693 | flex-direction: column;
|
|---|
| 694 | align-items: flex-start;
|
|---|
| 695 | }
|
|---|
| 696 |
|
|---|
| 697 | .listings-title {
|
|---|
| 698 | font-size: 1.5rem;
|
|---|
| 699 | }
|
|---|
| 700 |
|
|---|
| 701 | .grid {
|
|---|
| 702 | grid-template-columns: 1fr;
|
|---|
| 703 | gap: 16px;
|
|---|
| 704 | }
|
|---|
| 705 | }
|
|---|
| 706 | </style>
|
|---|