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

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

Petify fullstack project

  • Property mode set to 100644
File size: 29.4 KB
Line 
1<template>
2 <main class="listing-details-main">
3 <!-- Header with back button -->
4 <header class="header-section">
5 <div class="container">
6 <RouterLink to="/" class="back-link">Back to listings</RouterLink>
7 </div>
8 </header>
9
10 <!-- Loading State -->
11 <div v-if="loading" class="container py-5 text-center text-muted">
12 <p>Loading listing details…</p>
13 </div>
14
15 <!-- Error State -->
16 <div v-else-if="error" class="container py-5">
17 <div class="alert alert-danger" role="alert">
18 <div class="fw-semibold">Failed to load listing</div>
19 <div class="small">{{ error }}</div>
20 <button class="btn btn-sm btn-outline-danger mt-2" type="button" @click="reload">Try again</button>
21 </div>
22 </div>
23
24 <!-- Listing Details -->
25 <div v-else-if="listing" class="details-container">
26 <section class="image-section">
27 <div class="container">
28 <div class="image-wrapper">
29 <img :src="imageSrc" :alt="listing.title || 'Pet listing'" class="main-image" @error="onImageError" />
30 <button
31 class="favorite-btn"
32 type="button"
33 @click="toggleFavorite"
34 :aria-pressed="isFavorited"
35 :title="isFavorited ? 'Remove from favorites' : 'Add to favorites'"
36 >
37 {{ isFavorited ? '♥' : '♡' }}
38 </button>
39 <div v-if="listing.status" class="status-badge">{{ listing.status }}</div>
40 </div>
41 </div>
42 </section>
43
44 <section class="details-section">
45 <div class="container">
46 <div class="details-grid">
47 <!-- Left column: Main info -->
48 <div class="left-column">
49 <!-- Title and price -->
50 <div class="header-info">
51 <div>
52 <p v-if="animalName || ownerName" class="listing-kicker">
53 <span v-if="animalName">{{ animalName }}</span>
54 <span v-if="animalName && ownerName"> / </span>
55 <span v-if="ownerName">Listed by {{ ownerName }}</span>
56 </p>
57 <h1 class="listing-title">{{ listing.title }}</h1>
58 <p v-if="createdText" class="posted-date">Posted {{ createdText }}</p>
59 </div>
60 <div v-if="priceText" class="price-display">{{ priceText }}</div>
61 </div>
62
63 <!-- Pet characteristics -->
64 <div v-if="hasPetInfo" class="pet-info-card">
65 <h2 class="section-title">About {{ listing.title }}</h2>
66 <div class="pet-details">
67 <div v-if="listing.petType" class="detail-row">
68 <span class="label">Type:</span>
69 <span class="value">{{ listing.petType }}</span>
70 </div>
71 <div v-if="listing.breed" class="detail-row">
72 <span class="label">Breed:</span>
73 <span class="value">{{ listing.breed }}</span>
74 </div>
75 <div v-if="listing.age" class="detail-row">
76 <span class="label">Age:</span>
77 <span class="value">{{ listing.age }} {{ listing.ageUnit || 'years' }}</span>
78 </div>
79 <div v-if="listing.gender" class="detail-row">
80 <span class="label">Gender:</span>
81 <span class="value">{{ listing.gender }}</span>
82 </div>
83 <div v-if="listing.size" class="detail-row">
84 <span class="label">Size:</span>
85 <span class="value">{{ listing.size }}</span>
86 </div>
87 </div>
88 </div>
89
90 <!-- Health and traits -->
91 <div v-if="healthTraits.length > 0" class="health-traits-card">
92 <h2 class="section-title">Health & Traits</h2>
93 <ul class="traits-list">
94 <li v-for="trait in healthTraits" :key="trait" class="trait-item">{{ trait }}</li>
95 </ul>
96 </div>
97
98 <div class="health-records-card" id="health-records">
99 <h2 class="section-title">Health Records</h2>
100 <div v-if="healthRecordsLoading" class="muted-text">Loading health records...</div>
101 <div v-else-if="healthRecordsError" class="alert alert-danger">{{ healthRecordsError }}</div>
102 <div v-else-if="healthRecords.length === 0" class="muted-text">No health records have been added yet.</div>
103 <div v-else class="health-record-list">
104 <article v-for="record in healthRecords" :key="record.healthRecordId" class="health-record-item">
105 <div class="health-record-heading">
106 <strong>{{ record.type }}</strong>
107 <span>{{ formatRecordDate(record.date) }}</span>
108 </div>
109 <p>{{ record.description || 'No description' }}</p>
110 <small v-if="record.clinicName">{{ record.clinicName }}</small>
111 </article>
112 </div>
113 </div>
114
115 <!-- Description -->
116 <div v-if="listing.description" class="description-card">
117 <h2 class="section-title">Description</h2>
118 <p class="description-text">{{ listing.description }}</p>
119 </div>
120
121 <!-- Location -->
122 <div v-if="locationParts.length > 0" class="location-card">
123 <h2 class="section-title">Location</h2>
124 <p class="location-text">{{ locationParts.join(', ') }}</p>
125 </div>
126 </div>
127
128 <!-- Right column: Contact and owner info -->
129 <aside class="right-column">
130 <!-- Action buttons -->
131 <div class="action-buttons">
132 <button class="btn-primary" type="button" @click="contactOwner">
133 <span class="btn-text">Contact Owner</span>
134 </button>
135 <button class="btn-secondary" type="button" @click="seeOwner">
136 <span class="btn-text">See Owner</span>
137 </button>
138 <button class="btn-secondary" type="button" @click="toggleFavorite">
139 <span class="btn-text">{{ isFavorited ? 'Saved' : 'Save' }}</span>
140 </button>
141 </div>
142
143 <!-- Owner info placeholder -->
144 <div class="owner-card">
145 <h3 class="owner-title">Listing Details</h3>
146 <div class="owner-info">
147 <div v-if="ownerName" class="info-row">
148 <span class="label">Owner:</span>
149 <span class="value">{{ ownerName }}</span>
150 </div>
151 <div v-if="ownerEmail" class="info-row">
152 <span class="label">Email:</span>
153 <span class="value">{{ ownerEmail }}</span>
154 </div>
155 <div v-if="animalName" class="info-row">
156 <span class="label">Pet Name:</span>
157 <button class="pet-link" type="button" @click="scrollToHealthRecords">{{ animalName }}</button>
158 </div>
159 </div>
160 </div>
161
162 <!-- Share -->
163 <div class="share-section">
164 <p class="share-title">Share this listing</p>
165 <div class="share-buttons">
166 <button class="share-btn" type="button" @click="copyLink" :title="copyLinkText">Copy</button>
167 </div>
168 </div>
169 </aside>
170 </div>
171 </div>
172 </section>
173 </div>
174
175 <!-- Related Listings Section -->
176 <section v-if="relatedListings.length > 0" class="related-listings-section">
177 <div class="container">
178 <h2 class="related-title">More Listings</h2>
179 <div class="related-listings-grid">
180 <article
181 v-for="relatedListing in relatedListings"
182 :key="relatedListing.id"
183 class="related-listing-card"
184 @click="goToListing(relatedListing.id)"
185 role="button"
186 tabindex="0"
187 @keydown.enter="goToListing(relatedListing.id)"
188 >
189 <div class="related-image-wrapper">
190 <img
191 :src="getRelatedListingImage(relatedListing)"
192 :alt="relatedListing.title || 'Pet listing'"
193 class="related-image"
194 @error="(e) => handleRelatedImageError(e)"
195 />
196 <button
197 class="related-favorite-btn"
198 type="button"
199 @click.stop="toggleRelatedFavorite(relatedListing.id)"
200 :aria-pressed="isRelatedFavorited(relatedListing.id)"
201 :title="isRelatedFavorited(relatedListing.id) ? 'Remove from favorites' : 'Add to favorites'"
202 >
203 {{ isRelatedFavorited(relatedListing.id) ?'♥' : '♡' }}
204 </button>
205 <div v-if="relatedListing.status" class="related-badge">{{ relatedListing.status }}</div>
206 </div>
207 <div class="related-content">
208 <h3 class="related-listing-title">{{ relatedListing.title }}</h3>
209 <p v-if="relatedListing.petType" class="related-pet-type">{{ relatedListing.petType }}</p>
210 <div v-if="relatedListing.price" class="related-price">${{ Number(relatedListing.price).toFixed(2) }}</div>
211 </div>
212 </article>
213 </div>
214 </div>
215 </section>
216 </main>
217</template>
218
219<script setup lang="ts">
220import { computed, ref, onBeforeUnmount, onMounted, watch } from 'vue'
221import { useRoute, useRouter, RouterLink } from 'vue-router'
222import type { Listing } from '../types/listing'
223import { fetchListingById, fetchUserName, fetchPetName, fetchListings } from '../api/listings'
224import { getPet, getPetHealthRecords, getUserProfile, type HealthRecord } from '../api/profile'
225import { useAuthStore } from '../stores/auth'
226import { addFavorite, removeFavorite, getFavoritedListings } from '../api/favorites'
227
228const route = useRoute()
229const router = useRouter()
230const auth = useAuthStore()
231
232const id = computed(() => String(route.params.id || ''))
233const loading = ref(false)
234const error = ref<string | null>(null)
235const listing = ref<Listing | null>(null)
236const ownerName = ref<string | null>(null)
237const ownerEmail = ref<string | null>(null)
238const animalName = ref<string | null>(null)
239const isFavorited = ref(false)
240const imageBroken = ref(false)
241const copyLinkText = ref('Copy link')
242const relatedListings = ref<Listing[]>([])
243const relatedFavoritedIds = ref<Set<string | number>>(new Set())
244const userFavoritedIds = ref<Set<number>>(new Set())
245const healthRecords = ref<HealthRecord[]>([])
246const healthRecordsLoading = ref(false)
247const healthRecordsError = ref('')
248
249let abort: AbortController | null = null
250
251const placeholderImage = new URL('../img/all_outline.png', import.meta.url).href
252
253const imageSrc = computed(() => {
254 if (imageBroken.value) return placeholderImage
255 return (
256 listing.value?.imageUrl ||
257 listing.value?.image ||
258 listing.value?.photos?.[0] ||
259 listing.value?.images?.[0] ||
260 placeholderImage
261 )
262})
263
264const priceText = computed(() => {
265 const price = listing.value?.price ?? listing.value?.fee ?? listing.value?.adoptionFee
266 const currency = listing.value?.currency || 'USD'
267 if (price === null || price === undefined) return ''
268 const num = Number(price)
269 if (Number.isFinite(num)) {
270 try {
271 return new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(num)
272 } catch {
273 return `$${num}`
274 }
275 }
276 return String(price)
277})
278
279const createdText = computed(() => {
280 const raw = listing.value?.createdAt
281 if (!raw) return ''
282 const d = new Date(raw)
283 if (Number.isNaN(d.getTime())) return ''
284 try {
285 return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', year: 'numeric' }).format(d)
286 } catch {
287 return ''
288 }
289})
290
291const hasPetInfo = computed(() => {
292 if (!listing.value) return false
293 return !!(listing.value.petType || listing.value.breed || listing.value.age || listing.value.gender || listing.value.size)
294})
295
296const healthTraits = computed(() => {
297 if (!listing.value) return []
298 const traits: string[] = []
299 if (listing.value.vaccinated) traits.push('Vaccinated')
300 if (listing.value.neutered) traits.push('Neutered')
301 if (listing.value.spayed) traits.push('Spayed')
302 if (Array.isArray(listing.value.tags)) {
303 traits.push(...listing.value.tags.slice(0, 5))
304 }
305 return traits
306})
307
308const locationParts = computed(() => {
309 const parts: string[] = []
310 if (listing.value?.city) parts.push(listing.value.city)
311 if (listing.value?.state) parts.push(listing.value.state)
312 if (listing.value?.country) parts.push(listing.value.country)
313 return parts
314})
315
316async function load() {
317 loading.value = true
318 error.value = null
319 listing.value = null
320 ownerName.value = null
321 ownerEmail.value = null
322 animalName.value = null
323 isFavorited.value = false
324 relatedListings.value = []
325 healthRecords.value = []
326 healthRecordsError.value = ''
327
328 abort?.abort()
329 abort = new AbortController()
330
331 try {
332 const data = await fetchListingById(id.value, { signal: abort.signal })
333 listing.value = data
334
335 // Fetch owner, animal names, and pet image in parallel
336 const [ownerNameResult, ownerProfileResult, animalNameResult, petResult] = await Promise.all([
337 data.ownerId ? fetchUserName(data.ownerId, { signal: abort.signal }) : Promise.resolve(null),
338 data.ownerId ? getUserProfile(data.ownerId).catch(() => null) : Promise.resolve(null),
339 data.animalId ? fetchPetName(data.animalId, { signal: abort.signal }) : Promise.resolve(null),
340 data.animalId ? getPet(data.animalId) : Promise.resolve(null),
341 ])
342
343 ownerName.value = ownerProfileResult
344 ? `${ownerProfileResult.firstName || ''} ${ownerProfileResult.lastName || ''}`.trim() || ownerProfileResult.username || ownerNameResult
345 : ownerNameResult
346 ownerEmail.value = ownerProfileResult?.email || null
347 animalName.value = animalNameResult
348
349 // Add pet image to listing
350 if (listing.value && petResult?.photoUrl) {
351 listing.value.imageUrl = petResult.photoUrl
352 }
353
354 if (data.animalId) {
355 await loadHealthRecords(data.animalId)
356 }
357
358 // Fetch related listings
359 const allListings = await fetchListings({ signal: abort.signal })
360 // Filter out the current listing and get up to 6 related listings
361 const related = allListings
362 .filter((l) => l.id !== id.value)
363 .slice(0, 6)
364
365 // Fetch pet images for related listings
366 const listingsWithImages = await Promise.all(
367 related.map(async (listing) => {
368 try {
369 if (listing.animalId) {
370 const pet = await getPet(listing.animalId)
371 return {
372 ...listing,
373 imageUrl: pet.photoUrl || new URL('../img/all_outline.png', import.meta.url).href,
374 }
375 }
376 } catch (err) {
377 console.error(`Failed to fetch pet ${listing.animalId}:`, err)
378 }
379 return {
380 ...listing,
381 imageUrl: new URL('../img/all_outline.png', import.meta.url).href,
382 }
383 })
384 )
385
386 relatedListings.value = listingsWithImages
387
388 // Load user's favorites to check if this listing is favorited
389 await loadUserFavorites()
390 } catch (e) {
391 const message = e instanceof Error ? e.message : String(e)
392 error.value = message
393 } finally {
394 loading.value = false
395 }
396}
397
398async function loadHealthRecords(animalId: number) {
399 try {
400 healthRecordsLoading.value = true
401 healthRecords.value = await getPetHealthRecords(animalId)
402 healthRecordsError.value = ''
403 } catch (error) {
404 healthRecords.value = []
405 healthRecordsError.value = error instanceof Error ? error.message : 'Failed to load health records'
406 } finally {
407 healthRecordsLoading.value = false
408 }
409}
410
411function formatRecordDate(value: string) {
412 const date = new Date(value)
413 if (Number.isNaN(date.getTime())) return value
414 return date.toLocaleDateString('en-US', {
415 year: 'numeric',
416 month: 'short',
417 day: 'numeric',
418 })
419}
420
421function scrollToHealthRecords() {
422 document.getElementById('health-records')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
423}
424
425function checkFavorite() {
426 if (!auth.isAuthenticated || !listing.value?.listingId) {
427 isFavorited.value = false
428 return
429 }
430
431 // Check if current listing is in user's favorites
432 const listingId = Number(listing.value.listingId)
433 isFavorited.value = userFavoritedIds.value.has(listingId)
434}
435
436async function loadUserFavorites() {
437 if (!auth.isAuthenticated || !auth.user?.userId) {
438 userFavoritedIds.value.clear()
439 relatedFavoritedIds.value.clear()
440 return
441 }
442
443 try {
444 const favorites = await getFavoritedListings(auth.user.userId)
445 userFavoritedIds.value.clear()
446
447 favorites.forEach((fav: any) => {
448 userFavoritedIds.value.add(Number(fav.listingId || fav.id))
449 })
450
451 // Also populate related favorites
452 relatedListings.value.forEach((listing) => {
453 if (userFavoritedIds.value.has(Number(listing.id || listing.listingId))) {
454 relatedFavoritedIds.value.add(listing.id)
455 }
456 })
457
458 checkFavorite()
459 } catch (error) {
460 console.error('Failed to load favorites:', error)
461 userFavoritedIds.value.clear()
462 }
463}
464
465function reload() {
466 load()
467}
468
469function onImageError() {
470 imageBroken.value = true
471}
472
473async function toggleFavorite() {
474 if (!auth.isAuthenticated || !auth.user?.userId || !listing.value?.listingId) {
475 alert('Please log in to save favorites')
476 return
477 }
478
479 try {
480 const listingId = Number(listing.value.listingId)
481 if (isFavorited.value) {
482 await removeFavorite(auth.user.userId, listingId)
483 isFavorited.value = false
484 } else {
485 await addFavorite(auth.user.userId, listingId)
486 isFavorited.value = true
487 }
488 } catch (error) {
489 console.error('Failed to update favorite:', error)
490 alert('Failed to update favorite. Please try again.')
491 }
492}
493
494function contactOwner() {
495 if (!ownerEmail.value) {
496 alert('Owner contact email is not available')
497 return
498 }
499 const petName = animalName.value || listing.value?.title || 'your pet'
500 const subject = encodeURIComponent(`Question about ${petName} on Petify`)
501 const body = encodeURIComponent(`Hi ${ownerName.value || ''},\n\nI saw your listing for ${petName} on Petify and would like to know more.\n\nThanks!`)
502 window.location.href = `mailto:${ownerEmail.value}?subject=${subject}&body=${body}`
503}
504
505function seeOwner() {
506 if (!listing.value?.ownerId) {
507 alert('Owner information not available')
508 return
509 }
510 router.push({ name: 'owner-profile', params: { ownerId: listing.value.ownerId } })
511}
512
513function copyLink() {
514 const url = window.location.href
515 navigator.clipboard
516 .writeText(url)
517 .then(() => {
518 copyLinkText.value = '✓ Copied!'
519 setTimeout(() => {
520 copyLinkText.value = 'Copy link'
521 }, 2000)
522 })
523 .catch(() => {
524 alert('Failed to copy link')
525 })
526}
527
528function getRelatedListingImage(relatedListing: Listing): string {
529 return relatedListing.imageUrl || new URL('../img/all_outline.png', import.meta.url).href
530}
531
532function handleRelatedImageError(e: Event) {
533 const img = e.target as HTMLImageElement
534 img.src = new URL('../img/all_outline.png', import.meta.url).href
535}
536
537function goToListing(listingId: string | number) {
538 router.push({ name: 'listing-details', params: { id: listingId } })
539}
540
541function isRelatedFavorited(listingId: string | number): boolean {
542 return relatedFavoritedIds.value.has(listingId)
543}
544
545async function toggleRelatedFavorite(listingId: string | number) {
546 if (!auth.isAuthenticated || !auth.user?.userId) {
547 alert('Please log in to save favorites')
548 return
549 }
550
551 try {
552 const id = Number(listingId)
553 if (isRelatedFavorited(listingId)) {
554 await removeFavorite(auth.user.userId, id)
555 relatedFavoritedIds.value.delete(listingId)
556 } else {
557 await addFavorite(auth.user.userId, id)
558 relatedFavoritedIds.value.add(listingId)
559 }
560 } catch (error) {
561 console.error('Failed to update favorite:', error)
562 alert('Failed to update favorite. Please try again.')
563 }
564}
565
566onMounted(load)
567onBeforeUnmount(() => abort?.abort())
568
569// Watch for route changes and reload listing
570watch(id, () => {
571 load()
572})
573</script>
574
575<style scoped>
576.listing-details-main {
577 background: #f5f7fb;
578 min-height: 100vh;
579 padding-bottom: 60px;
580}
581
582/* Header Section */
583.header-section {
584 background: white;
585 border-bottom: 1px solid #e5e7eb;
586 padding: 20px 0;
587 position: sticky;
588 top: 0;
589 z-index: 10;
590}
591
592.back-link {
593 display: inline-flex;
594 align-items: center;
595 color: #d97706;
596 text-decoration: none;
597 font-weight: 600;
598 font-size: 1rem;
599 transition: all 0.2s ease;
600}
601
602.back-link:hover {
603 color: #b45309;
604 gap: 4px;
605}
606
607/* Image Section */
608.image-section {
609 background:
610 linear-gradient(135deg, rgba(17, 24, 39, 0.78), rgba(249, 115, 22, 0.56)),
611 #111827;
612 padding: 42px 0;
613}
614
615.image-wrapper {
616 aspect-ratio: 4 / 3;
617 background: #f3f4f6;
618 border: 1px solid rgba(255, 255, 255, 0.24);
619 border-radius: 8px;
620 box-shadow: 0 24px 70px rgba(17, 24, 39, 0.34);
621 margin: 0 auto;
622 max-width: 760px;
623 overflow: hidden;
624 position: relative;
625 width: 100%;
626}
627
628.main-image {
629 width: 100%;
630 height: 100%;
631 object-fit: cover;
632 display: block;
633}
634
635.favorite-btn {
636 position: absolute;
637 top: 16px;
638 right: 16px;
639 width: 48px;
640 height: 48px;
641 border-radius: 50%;
642 background: rgba(255, 255, 255, 0.95);
643 border: 2px solid #e5e7eb;
644 font-size: 1.5rem;
645 cursor: pointer;
646 display: flex;
647 align-items: center;
648 justify-content: center;
649 transition: all 0.2s ease;
650 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
651}
652
653.favorite-btn:hover {
654 background: #fef3c7;
655 border-color: #d97706;
656 transform: scale(1.1);
657}
658
659.status-badge {
660 position: absolute;
661 bottom: 16px;
662 left: 16px;
663 background: #d97706;
664 color: white;
665 padding: 8px 16px;
666 border-radius: 8px;
667 font-weight: 600;
668 font-size: 0.875rem;
669}
670
671/* Details Section */
672.details-section {
673 padding: 36px 0 20px;
674}
675
676.details-grid {
677 display: grid;
678 grid-template-columns: 1fr 360px;
679 gap: 40px;
680 align-items: start;
681}
682
683.left-column {
684 display: flex;
685 flex-direction: column;
686 gap: 20px;
687}
688
689.right-column {
690 display: flex;
691 flex-direction: column;
692 gap: 16px;
693 position: sticky;
694 top: 100px;
695}
696
697/* Header Info */
698.header-info {
699 display: flex;
700 justify-content: space-between;
701 align-items: flex-start;
702 gap: 20px;
703}
704
705.listing-title {
706 color: #111827;
707 font-size: 2.35rem;
708 font-weight: 850;
709 line-height: 1.12;
710 margin: 0 0 12px 0;
711}
712
713.listing-kicker {
714 color: #ea580c;
715 font-size: 0.85rem;
716 font-weight: 800;
717 margin: 0 0 8px;
718}
719
720.posted-date {
721 color: #6b7280;
722 font-size: 0.875rem;
723 margin: 0;
724}
725
726.price-display {
727 background: #fff7ed;
728 border: 1px solid #fed7aa;
729 border-radius: 8px;
730 color: #ea580c;
731 font-size: 1.8rem;
732 font-weight: 900;
733 padding: 10px 14px;
734 white-space: nowrap;
735}
736
737/* Cards */
738.pet-info-card,
739.health-traits-card,
740.health-records-card,
741.description-card,
742.location-card,
743.owner-card {
744 background: white;
745 border: 1px solid #e5e7eb;
746 border-radius: 8px;
747 box-shadow: 0 10px 26px rgba(17, 24, 39, 0.04);
748 padding: 24px;
749}
750
751.health-record-list {
752 display: grid;
753 gap: 12px;
754}
755
756.health-record-item {
757 border: 1px solid #e5e7eb;
758 border-radius: 8px;
759 background: #fbfdff;
760 padding: 14px;
761}
762
763.health-record-heading {
764 display: flex;
765 justify-content: space-between;
766 gap: 12px;
767 color: #111827;
768}
769
770.health-record-heading span,
771.health-record-item small,
772.muted-text {
773 color: #6b7280;
774}
775
776.health-record-item p {
777 color: #374151;
778 margin: 8px 0 4px;
779}
780
781.pet-link {
782 background: transparent;
783 border: 0;
784 color: #d97706;
785 cursor: pointer;
786 font-weight: 700;
787 padding: 0;
788 text-align: right;
789}
790
791.pet-link:hover {
792 color: #b45309;
793 text-decoration: underline;
794}
795
796.section-title {
797 font-size: 1.25rem;
798 font-weight: 700;
799 margin: 0 0 16px 0;
800 color: #111827;
801}
802
803.pet-details {
804 display: flex;
805 flex-direction: column;
806 gap: 12px;
807}
808
809.detail-row {
810 display: flex;
811 justify-content: space-between;
812 align-items: center;
813 padding: 12px 0;
814 border-bottom: 1px solid #f3f4f6;
815}
816
817.detail-row:last-child {
818 border-bottom: none;
819 padding-bottom: 0;
820}
821
822.detail-row .label {
823 font-weight: 600;
824 color: #6b7280;
825 font-size: 0.875rem;
826}
827
828.detail-row .value {
829 color: #111827;
830 font-weight: 500;
831}
832
833.traits-list {
834 list-style: none;
835 padding: 0;
836 margin: 0;
837 display: flex;
838 flex-wrap: wrap;
839 gap: 12px;
840}
841
842.trait-item {
843 background: #ecfdf5;
844 border: 1px solid #bbf7d0;
845 color: #166534;
846 padding: 8px 12px;
847 border-radius: 6px;
848 font-size: 0.875rem;
849 font-weight: 500;
850}
851
852.description-text {
853 color: #374151;
854 line-height: 1.6;
855 margin: 0;
856 white-space: pre-wrap;
857 word-break: break-word;
858}
859
860.location-text {
861 color: #374151;
862 font-size: 1.125rem;
863 margin: 0;
864}
865
866/* Action Buttons */
867.action-buttons {
868 display: grid;
869 grid-template-columns: 1fr 1fr;
870 gap: 12px;
871 background: #ffffff;
872 border: 1px solid #e5e7eb;
873 border-radius: 8px;
874 box-shadow: 0 14px 34px rgba(17, 24, 39, 0.08);
875 padding: 16px;
876}
877
878.action-buttons .btn-secondary:last-child {
879 grid-column: 1 / -1;
880}
881
882.btn-primary,
883.btn-secondary {
884 padding: 12px 16px;
885 border-radius: 8px;
886 border: none;
887 font-weight: 600;
888 cursor: pointer;
889 transition: all 0.2s ease;
890 text-align: center;
891 font-size: 1rem;
892}
893
894.btn-primary {
895 background: #f97316;
896 color: white;
897}
898
899.btn-primary:hover {
900 background: #ea580c;
901 transform: translateY(-2px);
902 box-shadow: 0 4px 12px rgba(217, 119, 6, 0.2);
903}
904
905.btn-secondary {
906 background: white;
907 color: #111827;
908 border: 1px solid #d1d5db;
909}
910
911.btn-secondary:hover {
912 background: #f9fafb;
913 border-color: #f97316;
914}
915
916.btn-text {
917 display: block;
918}
919
920/* Owner Card */
921.owner-title {
922 font-size: 1.125rem;
923 font-weight: 700;
924 margin: 0 0 16px 0;
925 color: #111827;
926}
927
928.owner-info {
929 display: flex;
930 flex-direction: column;
931 gap: 12px;
932}
933
934.info-row {
935 display: flex;
936 justify-content: space-between;
937 align-items: center;
938 padding: 8px 0;
939}
940
941.info-row .label {
942 font-size: 0.875rem;
943 color: #6b7280;
944 font-weight: 600;
945}
946
947.info-row .value {
948 color: #374151;
949 font-weight: 500;
950}
951
952/* Share Section */
953.share-section {
954 background: white;
955 border: 1px solid #e5e7eb;
956 border-radius: 8px;
957 padding: 18px;
958}
959
960.share-title {
961 font-size: 0.875rem;
962 font-weight: 600;
963 color: #6b7280;
964 margin: 0 0 12px 0;
965}
966
967.share-buttons {
968 display: flex;
969 gap: 8px;
970}
971
972.share-btn {
973 flex: 1;
974 padding: 10px;
975 border: 1px solid #e5e7eb;
976 border-radius: 8px;
977 background: white;
978 color: #111827;
979 font-size: 0.92rem;
980 font-weight: 800;
981 cursor: pointer;
982 transition: all 0.2s ease;
983}
984
985.share-btn:hover {
986 border-color: #d97706;
987 background: #fef3c7;
988}
989
990/* Related Listings Section */
991.related-listings-section {
992 background: white;
993 padding: 60px 0;
994 border-top: 1px solid #e5e7eb;
995}
996
997.related-title {
998 font-size: 2rem;
999 font-weight: 700;
1000 color: #111827;
1001 margin: 0 0 40px 0;
1002 letter-spacing: -0.5px;
1003}
1004
1005.related-listings-grid {
1006 display: grid;
1007 grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
1008 gap: 24px;
1009}
1010
1011.related-listing-card {
1012 background: white;
1013 border: 1px solid #e5e7eb;
1014 border-radius: 12px;
1015 overflow: hidden;
1016 cursor: pointer;
1017 transition: all 0.3s ease;
1018}
1019
1020.related-listing-card:hover {
1021 border-color: #d97706;
1022 box-shadow: 0 8px 24px rgba(217, 119, 6, 0.15);
1023 transform: translateY(-4px);
1024}
1025
1026.related-image-wrapper {
1027 position: relative;
1028 width: 100%;
1029 height: 200px;
1030 background: #f3f4f6;
1031 overflow: hidden;
1032}
1033
1034.related-image {
1035 width: 100%;
1036 height: 100%;
1037 object-fit: cover;
1038}
1039
1040.related-badge {
1041 position: absolute;
1042 top: 12px;
1043 right: 12px;
1044 background: #d97706;
1045 color: white;
1046 padding: 6px 12px;
1047 border-radius: 6px;
1048 font-weight: 600;
1049 font-size: 0.75rem;
1050 text-transform: uppercase;
1051}
1052
1053.related-favorite-btn {
1054 position: absolute;
1055 top: 12px;
1056 left: 12px;
1057 width: 40px;
1058 height: 40px;
1059 border-radius: 50%;
1060 background: rgba(255, 255, 255, 0.95);
1061 border: 2px solid #e5e7eb;
1062 font-size: 1.25rem;
1063 cursor: pointer;
1064 display: flex;
1065 align-items: center;
1066 justify-content: center;
1067 transition: all 0.2s ease;
1068 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
1069 padding: 0;
1070}
1071
1072.related-favorite-btn:hover {
1073 background: #fef3c7;
1074 border-color: #d97706;
1075 transform: scale(1.1);
1076}
1077
1078.related-content {
1079 padding: 16px;
1080}
1081
1082.related-listing-title {
1083 font-size: 1.1rem;
1084 font-weight: 700;
1085 margin: 0 0 8px 0;
1086 color: #111827;
1087 line-height: 1.4;
1088}
1089
1090.related-pet-type {
1091 font-size: 0.875rem;
1092 color: #6b7280;
1093 margin: 0 0 8px 0;
1094}
1095
1096.related-price {
1097 font-size: 1.25rem;
1098 font-weight: 700;
1099 color: #d97706;
1100 margin: 0;
1101}
1102
1103/* Responsive */
1104@media (max-width: 991.98px) {
1105 .listing-title {
1106 font-size: 2rem;
1107 }
1108
1109 .header-info {
1110 flex-direction: column;
1111 align-items: flex-start;
1112 }
1113
1114 .price-display {
1115 font-size: 1.5rem;
1116 }
1117
1118 .details-grid {
1119 grid-template-columns: 1fr;
1120 gap: 24px;
1121 }
1122
1123 .right-column {
1124 position: static;
1125 grid-column: 1;
1126 }
1127
1128 .image-section {
1129 padding: 20px 0;
1130 }
1131
1132 .details-section {
1133 padding: 20px 0;
1134 }
1135}
1136
1137@media (max-width: 576px) {
1138 .header-section {
1139 padding: 16px 0;
1140 }
1141
1142 .back-link {
1143 font-size: 0.875rem;
1144 }
1145
1146 .listing-title {
1147 font-size: 1.5rem;
1148 }
1149
1150 .pet-info-card,
1151 .health-traits-card,
1152 .health-records-card,
1153 .description-card,
1154 .location-card,
1155 .owner-card,
1156 .share-section {
1157 padding: 16px;
1158 }
1159
1160 .section-title {
1161 font-size: 1rem;
1162 }
1163
1164 .action-buttons {
1165 grid-column: 1;
1166 flex-direction: row;
1167 }
1168
1169 .btn-primary,
1170 .btn-secondary {
1171 flex: 1;
1172 padding: 10px 12px;
1173 font-size: 0.875rem;
1174 }
1175}
1176</style>
Note: See TracBrowser for help on using the repository browser.