source: petify-frontend/src/views/ListingsView.vue

Last change on this file was 92e7c7a, checked in by veronika-ils <ilioskaveronika@…>, 5 hours ago

Petify fullstack project

  • Property mode set to 100644
File size: 18.7 KB
Line 
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">
119import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
120import { useRouter } from 'vue-router'
121import ListingCard from '@/components/ListingCard.vue'
122import type { Listing } from '../types/listing'
123import { fetchListings, fetchRecommendedListings } from '../api/listings'
124import { mockListings } from '../data/mockListings'
125import { useAuthStore } from '../stores/auth'
126import { addFavorite, removeFavorite, getFavoritedListings } from '../api/favorites'
127import { getPet, getUserProfile } from '../api/profile'
128
129const router = useRouter()
130const auth = useAuthStore()
131
132const loading = ref(false)
133const error = ref<string | null>(null)
134const listings = ref<Listing[]>([])
135const favoritedListingIds = ref<Set<number>>(new Set())
136
137const viewMode = ref<'all' | 'recommended'>('all')
138const petType = ref('')
139
140// Filters
141const selectedBreed = ref('')
142const selectedCity = ref('')
143
144const 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
152const petDetailsCache = ref<Map<number, any>>(new Map())
153
154// Store owner details cache
155const ownerDetailsCache = ref<Map<number, any>>(new Map())
156
157const 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
176const 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
185const 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
221function getListingSpecies(listing: Listing): string {
222 return String(listing.species || listing.petType || '').trim().toUpperCase()
223}
224
225let abort: AbortController | null = null
226
227async 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
318async 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
334function reload() {
335 load()
336}
337
338function goToDetails(id: string) {
339 router.push({ name: 'listing-details', params: { id } })
340}
341
342async 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
364function 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
376onMounted(load)
377onBeforeUnmount(() => abort?.abort())
378
379// Reset breed filter when pet type changes
380watch(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>
Note: See TracBrowser for help on using the repository browser.