source: petify-frontend/src/views/OwnerProfileView.vue@ f6ed6e4

Last change on this file since f6ed6e4 was f6ed6e4, checked in by veronika-ils <ilioskaveronika@…>, 25 hours ago

feat: The app is consistent with the changes made in phase 1 and phase 2

  • Property mode set to 100644
File size: 30.2 KB
Line 
1<template xmlns="http://www.w3.org/1999/html">
2 <div class="profile-container">
3 <!-- Header with back button -->
4 <header class="header-section header-simple">
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="isLoading" class="container py-5 text-center text-muted">
12 <p>Loading owner profile…</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 owner profile</div>
19 <div class="small">{{ error }}</div>
20 <button class="btn btn-sm btn-outline-danger mt-2" type="button" @click="reload">
21 Try again
22 </button>
23 </div>
24 </div>
25
26 <!-- Owner Profile Content -->
27 <div v-else-if="ownerInfo" class="profile-container">
28 <!-- Owner Info Card -->
29 <section class="header-section">
30 <div class="container">
31 <div class="profile-card">
32 <div class="profile-content">
33 <div class="profile-info">
34 <div style="display: flex; align-items: center; gap: 12px;">
35 <h1 class="profile-name">{{ ownerInfo.firstName }} {{ ownerInfo.lastName }}</h1>
36 <span v-if="ownerInfo.verified" class="verified-badge">
37 <img src="@/img/star.png" alt="verified" class="badge-star" /> Top 10
38 </span>
39 </div>
40 <p class="profile-username">@{{ ownerInfo.username }}</p>
41 <p class="profile-email">
42 <i class="bi bi-envelope"></i>
43 <a :href="`mailto:${ownerInfo.email}`">{{ ownerInfo.email }}</a>
44 </p>
45 </div>
46 <div class="profile-badge">
47 <button class="btn btn-primary btn-sm" type="button" @click="contactOwner">
48 <i class="bi bi-envelope"></i> Contact Owner
49 </button>
50 </div>
51 </div>
52 </div>
53 </div>
54 </section>
55
56 <!-- Tabs for listings and pets -->
57 <section class="main-content">
58 <div class="container">
59 <div class="tabs-container">
60 <ul class="nav nav-tabs nav-fill" role="tablist">
61 <li class="nav-item" role="presentation">
62 <button
63 class="nav-link"
64 :class="{ active: activeTab === 'listings' }"
65 @click="activeTab = 'listings'"
66 type="button"
67 role="tab"
68 >
69 <i class="bi bi-bookmark-fill"></i> Active Listings ({{ activeOwnerListings.length }})
70 </button>
71 </li>
72 <li class="nav-item" role="presentation">
73 <button
74 class="nav-link"
75 :class="{ active: activeTab === 'pets' }"
76 @click="activeTab = 'pets'"
77 type="button"
78 role="tab"
79 >
80 <i class="bi bi-paw-fill"></i> Pets ({{ ownerPets.length }})
81 </button>
82 </li>
83 <li class="nav-item" role="presentation">
84 <button
85 class="nav-link"
86 :class="{ active: activeTab === 'reviews' }"
87 @click="activeTab = 'reviews'"
88 type="button"
89 role="tab"
90 >
91 <i class="bi bi-star-fill"></i> Reviews ({{ ownerReviews.length }})
92 </button>
93 </li>
94 </ul>
95
96 <!-- Listings Tab -->
97 <div v-if="activeTab === 'listings'" class="tab-content-section">
98 <h2 class="section-title">Active Listings</h2>
99
100 <div v-if="activeOwnerListings.length === 0" class="empty-state">
101 <p>This owner doesn't have active listings right now.</p>
102 </div>
103
104 <div v-else class="grid-container">
105 <div v-for="listing in activeOwnerListings" :key="listing.listingId" class="listing-card-wrapper">
106 <RouterLink :to="`/listing/${listing.listingId}`" class="listing-link">
107 <div class="listing-card">
108 <div class="listing-status" :class="statusClass(listing.status)">
109 {{ listing.status || 'Active' }}
110 </div>
111
112 <h3 class="listing-title">{{ getPetName(listing.animalId) }}</h3>
113 <p class="listing-description">{{ listing.description }}</p>
114
115 <div class="listing-footer">
116 <span class="listing-price" v-if="hasPrice(listing.price)">
117 ${{ formatPrice(listing.price) }}
118 </span>
119 <span class="listing-date">
120 {{ formatDate(listing.createdAt) }}
121 </span>
122 </div>
123 </div>
124 </RouterLink>
125 </div>
126 </div>
127 </div>
128
129 <!-- Pets Tab -->
130 <div v-if="activeTab === 'pets'" class="tab-content-section">
131 <h2 class="section-title">Owner's Pets</h2>
132
133 <div v-if="ownerPets.length === 0" class="empty-state">
134 <p>This owner hasn't added any pets yet.</p>
135 </div>
136
137 <div v-else class="grid-container">
138 <div v-for="pet in ownerPets" :key="pet.animalId" class="pet-card-wrapper">
139 <div class="pet-card">
140 <div class="pet-image-wrapper">
141 <img
142 v-if="pet.photoUrl"
143 :src="pet.photoUrl"
144 :alt="pet.name"
145 class="pet-image"
146 @error="onPetImageError"
147 />
148 <img v-else src="@/img/all_outline.png" :alt="`${pet.name} placeholder`" class="pet-image-placeholder-img" />
149 </div>
150 <div class="pet-header">
151 <h3 class="pet-name">{{ pet.name }}</h3>
152 </div>
153 <div class="pet-details">
154 <div v-if="pet.species" class="pet-detail-row">
155 <span class="label">Species</span>
156 <span class="value">{{ pet.species }}</span>
157 </div>
158 <div v-if="pet.breed" class="pet-detail-row">
159 <span class="label">Breed</span>
160 <span class="value">{{ pet.breed }}</span>
161 </div>
162 <div v-if="pet.sex" class="pet-detail-row">
163 <span class="label">Sex</span>
164 <span class="value">{{ pet.sex }}</span>
165 </div>
166 <div v-if="pet.dateOfBirth" class="pet-detail-row">
167 <span class="label">DOB</span>
168 <span class="value">{{ formatDate(pet.dateOfBirth) }}</span>
169 </div>
170 </div>
171 </div>
172 </div>
173 </div>
174 </div>
175
176 <!-- Reviews Tab -->
177 <div v-if="activeTab === 'reviews'" class="tab-content-section">
178 <h2 class="section-title">Reviews ({{ ownerReviews.length }})</h2>
179
180 <!-- Add Review Form (only if logged in and not own profile) -->
181 <div
182 v-if="auth.isAuthenticated && ownerInfo && auth.user?.userId !== ownerInfo.userId"
183 class="form-card"
184 >
185 <h3 class="section-title" style="font-size: 1.3rem; margin-top: 0">Leave a Review</h3>
186 <form @submit.prevent="submitReview">
187 <div class="form-group">
188 <label class="form-label">Rating</label>
189 <div class="rating-input">
190 <button
191 v-for="i in 5"
192 :key="i"
193 type="button"
194 :class="['star-btn', { active: newReview.rating === i }]"
195 @click="newReview.rating = i"
196 >
197 <img
198 :src="starImg"
199 :alt="`${i} star rating`"
200 class="star-btn-img"
201 :style="{ opacity: i <= newReview.rating ? 1 : 0.3 }"
202 />
203
204
205 </button>
206 </div>
207 </div>
208
209 <div class="form-group">
210 <label class="form-label" for="interactionType">How did you interact?</label>
211 <select
212 id="interactionType"
213 v-model="newReview.interactionType"
214 class="form-control"
215 required
216 >
217 <option value="" disabled>Select an option</option>
218 <option
219 v-for="option in interactionTypeOptions"
220 :key="option.value"
221 :value="option.value"
222 >
223 {{ option.label }}
224 </option>
225 </select>
226 </div>
227
228 <div class="form-group">
229 <label class="form-label" for="comment">Comment</label>
230 <textarea
231 id="comment"
232 v-model="newReview.comment"
233 class="form-control"
234 placeholder="Share your experience with this owner..."
235 rows="4"
236 ></textarea>
237 </div>
238
239 <div v-if="reviewError" class="alert alert-danger" role="alert">
240 {{ reviewError }}
241 </div>
242
243 <div class="form-actions">
244 <button
245 type="submit"
246 class="btn btn-primary"
247 :disabled="isSubmittingReview || newReview.rating === 0 || !newReview.interactionType"
248 >
249 <span v-if="isSubmittingReview">Submitting...</span>
250 <span v-else>Submit Review</span>
251 </button>
252 </div>
253 </form>
254 </div>
255
256 <!-- Reviews List -->
257 <div v-if="ownerReviews.length === 0" class="empty-state">
258 <p>No reviews yet. Be the first to leave a review!</p>
259 </div>
260
261 <div v-else class="reviews-list">
262 <div v-for="review in ownerReviews" :key="review.reviewId" class="review-card">
263 <div class="review-header">
264 <div class="reviewer-info">
265 <h4 class="reviewer-name">{{ review.reviewerName }}</h4>
266 <p class="reviewer-username">@{{ review.reviewerUsername }}</p>
267 </div>
268 <div class="review-actions">
269 <div class="rating">
270 <img
271 v-for="i in Number(review.rating || 0)"
272 :key="i"
273 src="@/img/star.png"
274 alt="star"
275 class="review-star"
276 />
277 </div>
278 <button
279 v-if="
280 auth.isAuthenticated &&
281 (auth.user?.userId === review.reviewerId || auth.user?.userId === ownerInfo?.userId)
282 "
283 type="button"
284 class="delete-btn"
285 @click="deleteReview(review.reviewId)"
286 >
287 <img src="@/img/trashcan.png" alt="delete" class="delete-btn-img" />
288 </button>
289 </div>
290 </div>
291 <p class="review-comment">{{ review.comment }}</p>
292 <p class="review-date">{{ formatDate(review.createdAt) }}</p>
293 </div>
294 </div>
295 </div>
296 </div>
297 </div>
298 </section>
299 </div>
300 </div>
301</template>
302
303<script setup lang="ts">
304import starImg from '@/img/star.png'
305import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
306import { useRoute, RouterLink } from 'vue-router'
307import { getUserProfile, getUserListings, getUserPets, loadUserVerificationStatus } from '../api/profile'
308import {
309 createReview,
310 getReviewsByOwner,
311 deleteReview as deleteReviewAPI,
312 type UserReviewInteractionType,
313} from '../api/reviews'
314import { useAuthStore } from '../stores/auth'
315
316const route = useRoute()
317const auth = useAuthStore()
318
319const isLoading = ref(false)
320const error = ref<string | null>(null)
321
322const activeTab = ref<'listings' | 'pets' | 'reviews'>('listings')
323
324const ownerInfo = ref<any>(null)
325const ownerListings = ref<any[]>([])
326const ownerPets = ref<any[]>([])
327const ownerReviews = ref<any[]>([])
328
329const isSubmittingReview = ref(false)
330const reviewError = ref<string | null>(null)
331const interactionTypeOptions: Array<{ value: UserReviewInteractionType; label: string }> = [
332 { value: 'EVENT', label: 'Met at an event' },
333 { value: 'PERSONAL_INTERACTION', label: 'Personal interaction' },
334 { value: 'ONLINE', label: 'Online interaction' },
335 { value: 'PHONE_CALL', label: 'Phone call' },
336 { value: 'OTHER', label: 'Other' },
337]
338
339const newReview = ref<{
340 rating: number
341 comment: string
342 interactionType: UserReviewInteractionType | ''
343}>({
344 rating: 0,
345 comment: '',
346 interactionType: '',
347})
348
349// Create a map of petId to pet name
350const petNameMap = computed(() => {
351 const map: Record<number, string> = {}
352 ownerPets.value.forEach((pet) => {
353 map[pet.animalId] = pet.name
354 })
355 return map
356})
357
358const activeOwnerListings = computed(() => {
359 return ownerListings.value.filter((listing) => String(listing.status || 'ACTIVE').toUpperCase() === 'ACTIVE')
360})
361
362// Get pet name for listing
363function getPetName(animalId: number): string {
364 return petNameMap.value[animalId] || 'Unknown Pet'
365}
366
367let abort: AbortController | null = null
368
369function extractArray(res: any): any[] {
370 if (Array.isArray(res)) return res
371 if (res?.content && Array.isArray(res.content)) return res.content
372 if (res?.data && Array.isArray(res.data)) return res.data
373 return []
374}
375
376async function load() {
377 isLoading.value = true
378 error.value = null
379
380 ownerInfo.value = null
381 ownerListings.value = []
382 ownerPets.value = []
383 ownerReviews.value = []
384
385 abort?.abort()
386 abort = new AbortController()
387
388 try {
389 const id = Number(route.params.ownerId)
390 if (Number.isNaN(id)) throw new Error('Invalid owner ID')
391
392 // NOTE: AbortController is kept to cancel UI state updates on route change;
393 // if your API layer supports fetch signals, pass abort.signal inside those functions.
394 const [userInfo, listingsRes, petsRes, reviewsRes] = await Promise.all([
395 getUserProfile(id),
396 getUserListings(id),
397 getUserPets(id),
398 getReviewsByOwner(id),
399 ])
400
401 ownerInfo.value = userInfo
402 ownerListings.value = extractArray(listingsRes)
403 ownerPets.value = extractArray(petsRes)
404 ownerReviews.value = extractArray(reviewsRes)
405
406 // Load verification status
407 await loadOwnerVerification()
408 } catch (e) {
409 const message = e instanceof Error ? e.message : String(e)
410 error.value = message
411 console.error('Failed to load owner profile:', message)
412 } finally {
413 isLoading.value = false
414 }
415}
416
417function reload() {
418 load()
419}
420
421watch(
422 () => route.params.ownerId,
423 () => {
424 // if user opens a different owner profile without leaving the page
425 load()
426 }
427)
428
429function onPetImageError(event: Event) {
430 const img = event.target as HTMLImageElement
431 img.style.display = 'none'
432}
433
434function formatDate(dateString: string): string {
435 if (!dateString) return ''
436 try {
437 const date = new Date(dateString)
438 if (Number.isNaN(date.getTime())) return ''
439 return new Intl.DateTimeFormat(undefined, {
440 month: 'short',
441 day: 'numeric',
442 year: 'numeric',
443 }).format(date)
444 } catch {
445 return ''
446 }
447}
448
449function hasPrice(price: unknown): boolean {
450 const n = typeof price === 'number' ? price : Number(price)
451 return Number.isFinite(n) && n > 0
452}
453
454function formatPrice(price: unknown): string {
455 const n = typeof price === 'number' ? price : Number(price)
456 if (!Number.isFinite(n)) return ''
457 return n.toFixed(2)
458}
459
460function statusClass(status: unknown): string {
461 const s = String(status ?? 'active').toLowerCase()
462 return `status-${s}`
463}
464
465function contactOwner() {
466 if (!ownerInfo.value?.email) {
467 alert('Owner email not available')
468 return
469 }
470 window.location.href = `mailto:${ownerInfo.value.email}`
471}
472
473async function submitReview() {
474 if (!auth.isAuthenticated || !auth.user?.userId) {
475 alert('Please log in to submit a review')
476 return
477 }
478
479 if (newReview.value.rating === 0) {
480 reviewError.value = 'Please select a rating'
481 return
482 }
483
484 if (!newReview.value.interactionType) {
485 reviewError.value = 'Please choose how you interacted with this user'
486 return
487 }
488
489 isSubmittingReview.value = true
490 reviewError.value = null
491
492 try {
493 await createReview(
494 ownerInfo.value.userId,
495 auth.user.userId,
496 newReview.value.rating,
497 newReview.value.comment,
498 newReview.value.interactionType
499 )
500
501 // Reset form and reload reviews
502 newReview.value.rating = 0
503 newReview.value.comment = ''
504 newReview.value.interactionType = ''
505 await loadReviews()
506 } catch (err) {
507 reviewError.value = err instanceof Error ? err.message : 'Failed to submit review'
508 } finally {
509 isSubmittingReview.value = false
510 }
511}
512
513async function loadReviews() {
514 if (!ownerInfo.value?.userId) return
515 try {
516 const res = await getReviewsByOwner(ownerInfo.value.userId)
517 ownerReviews.value = extractArray(res)
518 } catch (err) {
519 console.error('Failed to load reviews:', err)
520 }
521}
522
523async function loadOwnerVerification() {
524 if (!ownerInfo.value?.userId) return
525
526 try {
527 const isVerified = await loadUserVerificationStatus(ownerInfo.value.userId)
528 if (ownerInfo.value) {
529 ownerInfo.value.verified = isVerified
530 }
531 console.log(`✅ Owner verification status loaded: ${isVerified}`)
532 } catch (error) {
533 console.error('Failed to load owner verification status:', error)
534 }
535}
536
537async function deleteReview(reviewId: number) {
538 if (!auth.isAuthenticated || !auth.user?.userId) {
539 alert('Please log in to delete a review')
540 return
541 }
542
543 if (confirm('Are you sure you want to delete this review?')) {
544 try {
545 await deleteReviewAPI( reviewId, auth.user.userId)
546 await loadReviews()
547 } catch (err) {
548 alert(err instanceof Error ? err.message : 'Failed to delete review')
549 }
550 }
551}
552
553onMounted(load)
554onBeforeUnmount(() => abort?.abort())
555</script>
556
557<style scoped>
558.profile-container {
559 background: linear-gradient(135deg, #f5f7fa 0%, #f0f3f8 100%);
560 min-height: 100vh;
561 padding-bottom: 60px;
562}
563
564/* Header Section */
565.header-section {
566 background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
567 padding: 40px 0;
568 margin-bottom: 40px;
569 box-shadow: 0 10px 30px rgba(249, 115, 22, 0.2);
570}
571
572.header-section.header-simple {
573 background: white;
574 padding: 20px 0;
575 margin-bottom: 0;
576 box-shadow: none;
577 border-bottom: 1px solid #e2e8f0;
578}
579
580.back-link {
581 display: inline-flex;
582 align-items: center;
583 color: #f97316;
584 text-decoration: none;
585 font-weight: 600;
586 font-size: 0.95rem;
587 transition: all 0.2s ease;
588}
589
590.back-link:hover {
591 color: #ea580c;
592 transform: translateX(-4px);
593}
594
595.profile-card {
596 background: white;
597 border-radius: 16px;
598 padding: 30px;
599 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
600}
601
602.profile-content {
603 display: flex;
604 justify-content: space-between;
605 align-items: flex-start;
606 gap: 30px;
607}
608
609.profile-info {
610 flex: 1;
611}
612
613.profile-name {
614 font-size: 2.5rem;
615 font-weight: 700;
616 color: #1a202c;
617 margin: 0 0 12px 0;
618 letter-spacing: -0.5px;
619}
620
621.profile-username {
622 font-size: 1.1rem;
623 color: #718096;
624 margin: 0 0 8px 0;
625 font-weight: 500;
626}
627
628.profile-email {
629 font-size: 1rem;
630 color: #4a5568;
631 margin: 0;
632 display: flex;
633 align-items: center;
634 gap: 8px;
635}
636
637.profile-email a {
638 color: #4a5568;
639 text-decoration: none;
640 transition: color 0.2s ease;
641}
642
643.profile-email a:hover {
644 color: #2d3748;
645}
646
647.profile-badge {
648 display: flex;
649 align-items: center;
650}
651
652.verified-badge {
653 background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
654 color: white;
655 padding: 6px 14px;
656 border-radius: 20px;
657 font-size: 0.85rem;
658 font-weight: 600;
659 white-space: nowrap;
660 display: flex;
661 align-items: center;
662 gap: 6px;
663 box-shadow: 0 2px 8px rgba(245, 158, 11, 0.3);
664}
665
666.badge-star {
667 width: 18px;
668 height: 18px;
669 object-fit: contain;
670 filter: brightness(0) invert(1);
671}
672
673/* Main Content */
674.main-content {
675 padding: 0;
676}
677
678.tabs-container {
679 background: white;
680 border-radius: 12px;
681 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
682 overflow: hidden;
683}
684
685/* Tabs */
686.nav-tabs {
687 border-bottom: 2px solid #e2e8f0;
688 background: #f7fafc;
689 padding: 0;
690 margin: 0;
691}
692
693.nav-tabs .nav-link {
694 color: #718096;
695 border: none;
696 border-bottom: 3px solid transparent;
697 font-weight: 600;
698 padding: 16px 24px;
699 transition: all 0.3s ease;
700 display: flex;
701 align-items: center;
702 gap: 8px;
703 font-size: 0.95rem;
704}
705
706.nav-tabs .nav-link:hover {
707 color: #2d3748;
708 background: #edf2f7;
709}
710
711.nav-tabs .nav-link.active {
712 color: #f97316;
713 border-bottom-color: #f97316;
714 background: white;
715}
716
717/* Tab Content */
718.tab-content-section {
719 padding: 40px;
720 animation: fadeIn 0.3s ease-in;
721}
722
723@keyframes fadeIn {
724 from {
725 opacity: 0;
726 transform: translateY(10px);
727 }
728 to {
729 opacity: 1;
730 transform: translateY(0);
731 }
732}
733
734.section-title {
735 font-size: 1.8rem;
736 font-weight: 700;
737 color: #1a202c;
738 margin: 0 0 30px 0;
739 letter-spacing: -0.5px;
740}
741
742/* Empty State */
743.empty-state {
744 text-align: center;
745 padding: 60px 20px;
746 color: #718096;
747}
748
749.empty-state p {
750 font-size: 1.1rem;
751 margin: 0;
752}
753
754/* Grid Container */
755.grid-container {
756 display: grid;
757 grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
758 gap: 24px;
759}
760
761/* Listing Card */
762.listing-card-wrapper {
763 height: 100%;
764}
765
766.listing-card {
767 background: white;
768 border: none;
769 border-radius: 16px;
770 padding: 24px;
771 height: 100%;
772 display: flex;
773 flex-direction: column;
774 gap: 16px;
775 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
776 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
777 position: relative;
778}
779
780.listing-card:hover {
781 box-shadow: 0 16px 32px rgba(249, 115, 22, 0.12);
782 transform: translateY(-8px);
783}
784
785.listing-link {
786 text-decoration: none;
787 display: block;
788 height: 100%;
789}
790
791.listing-status {
792 position: absolute;
793 top: 16px;
794 right: 16px;
795 padding: 8px 14px;
796 border-radius: 8px;
797 font-size: 0.75rem;
798 font-weight: 700;
799 text-transform: uppercase;
800 letter-spacing: 0.5px;
801}
802
803.status-active {
804 background: #d1fae5;
805 color: #065f46;
806}
807
808.status-pending {
809 background: #fef3c7;
810 color: #92400e;
811}
812
813.status-adopted {
814 background: #dbeafe;
815 color: #0c2d6b;
816}
817
818.listing-title {
819 font-size: 1.3rem;
820 font-weight: 700;
821 color: #1a202c;
822 margin: 0;
823 line-height: 1.4;
824}
825
826.listing-description {
827 color: #4a5568;
828 font-size: 0.95rem;
829 line-height: 1.6;
830 margin: 0;
831 flex: 1;
832 overflow: hidden;
833 text-overflow: ellipsis;
834 display: -webkit-box;
835 -webkit-line-clamp: 2;
836 -webkit-box-orient: vertical;
837}
838
839.listing-footer {
840 display: flex;
841 justify-content: space-between;
842 align-items: center;
843 padding-top: 16px;
844 margin-top: auto;
845}
846
847.listing-price {
848 font-size: 1.5rem;
849 font-weight: 800;
850 color: #f97316;
851 letter-spacing: -0.5px;
852}
853
854.listing-date {
855 color: #a0aec0;
856 font-size: 0.85rem;
857 font-weight: 500;
858}
859
860.listing-actions {
861 display: flex;
862 gap: 8px;
863 margin-top: auto;
864}
865
866.listing-actions .form-select {
867 flex: 1;
868}
869
870/* Pet Card */
871.pet-card-wrapper {
872 height: 100%;
873}
874
875.pet-card {
876 background: white;
877 border: none;
878 border-radius: 16px;
879 padding: 0;
880 height: 100%;
881 display: flex;
882 flex-direction: column;
883 gap: 16px;
884 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
885 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
886 overflow: hidden;
887}
888
889.pet-card:hover {
890 box-shadow: 0 16px 32px rgba(249, 115, 22, 0.12);
891 transform: translateY(-8px);
892}
893
894.pet-image-wrapper {
895 width: 100%;
896 height: 220px;
897 background: linear-gradient(135deg, #f5f7fa 0%, #f0f3f8 100%);
898 display: flex;
899 align-items: center;
900 justify-content: center;
901 overflow: hidden;
902}
903
904.pet-image {
905 width: 100%;
906 height: 100%;
907 object-fit: cover;
908}
909
910.pet-image-placeholder {
911 width: 100%;
912 height: 100%;
913 background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
914 display: flex;
915 align-items: center;
916 justify-content: center;
917}
918
919.pet-emoji {
920 font-size: 3rem;
921}
922
923.pet-image-placeholder-img {
924 width: 100%;
925 height: 100%;
926 object-fit: cover;
927}
928
929.pet-header {
930 border-bottom: none;
931 padding: 0 20px 0 20px;
932 padding-top: 16px;
933}
934
935.pet-name {
936 font-size: 1.3rem;
937 font-weight: 700;
938 margin: 0;
939 color: #1a202c;
940 line-height: 1.4;
941}
942
943.pet-details {
944 list-style: none;
945 padding: 0 20px 20px 20px;
946 margin: 0;
947 display: flex;
948 flex-direction: column;
949 gap: 12px;
950 flex: 1;
951}
952
953.pet-detail-row {
954 display: flex;
955 justify-content: space-between;
956 align-items: center;
957 padding: 10px 0;
958 border-bottom: none;
959 font-size: 0.9rem;
960}
961
962.pet-detail-row:last-child {
963 border-bottom: none;
964}
965
966.pet-detail-row .label {
967 color: #718096;
968 font-weight: 600;
969 text-transform: capitalize;
970 font-size: 0.9rem;
971}
972
973.pet-detail-row .value {
974 color: #2d3748;
975 font-weight: 500;
976}
977
978/* Review Card */
979.review-card {
980 background: white;
981 border: none;
982 border-radius: 16px;
983 padding: 24px;
984 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
985 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
986}
987
988.review-card:hover {
989 box-shadow: 0 12px 24px rgba(249, 115, 22, 0.1);
990 transform: translateY(-4px);
991}
992
993/* Form Card */
994.form-card {
995 background: white;
996 border: none;
997 border-radius: 16px;
998 padding: 32px;
999 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
1000 margin-bottom: 32px;
1001}
1002
1003.form-group {
1004 margin-bottom: 20px;
1005}
1006
1007.form-label {
1008 display: block;
1009 font-weight: 600;
1010 color: #2d3748;
1011 margin-bottom: 8px;
1012 font-size: 0.95rem;
1013}
1014
1015.rating-input {
1016 display: flex;
1017 gap: 8px;
1018 flex-wrap: wrap;
1019}
1020
1021.star-btn {
1022 background: white;
1023 border: 2px solid #e2e8f0;
1024 border-radius: 8px;
1025 padding: 10px 14px;
1026 font-size: 1.5rem;
1027 cursor: pointer;
1028 transition: all 0.2s ease;
1029 display: flex;
1030 align-items: center;
1031 justify-content: center;
1032}
1033
1034.star-btn:hover {
1035 border-color: #f97316;
1036 background: #fff8f1;
1037}
1038
1039.star-btn.active {
1040 border-color: #f97316;
1041 background: #fff8f1;
1042}
1043
1044.star-btn-img {
1045 width: 1.5rem;
1046 height: 1.5rem;
1047 object-fit: contain;
1048}
1049
1050.form-control {
1051 width: 100%;
1052 padding: 12px;
1053 border: 1.5px solid #e2e8f0;
1054 border-radius: 8px;
1055 font-family: inherit;
1056 font-size: 0.95rem;
1057 color: #1a202c;
1058 resize: vertical;
1059 transition: all 0.2s ease;
1060}
1061
1062.form-control:focus {
1063 outline: none;
1064 border-color: #f97316;
1065 box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.1);
1066}
1067
1068.form-actions {
1069 display: flex;
1070 gap: 12px;
1071 margin-top: 24px;
1072 padding-top: 24px;
1073 border-top: 1px solid #e2e8f0;
1074}
1075
1076.reviews-list {
1077 display: flex;
1078 flex-direction: column;
1079 gap: 20px;
1080}
1081
1082/* Buttons */
1083.btn {
1084 border-radius: 8px;
1085 font-weight: 600;
1086 padding: 10px 20px;
1087 transition: all 0.2s ease;
1088 font-size: 0.95rem;
1089 border: none;
1090 cursor: pointer;
1091}
1092
1093.btn-primary {
1094 background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
1095 color: white;
1096}
1097
1098.btn-primary:hover:not(:disabled) {
1099 transform: translateY(-2px);
1100 box-shadow: 0 8px 20px rgba(249, 115, 22, 0.3);
1101}
1102
1103.btn-sm {
1104 padding: 6px 12px;
1105 font-size: 0.85rem;
1106}
1107
1108.btn:disabled {
1109 opacity: 0.5;
1110 cursor: not-allowed;
1111}
1112
1113.btn-outline-danger {
1114 border: 1.5px solid #f56565;
1115 color: #f56565;
1116 background: transparent;
1117}
1118
1119.btn-outline-danger:hover {
1120 background: #fff5f5;
1121 border-color: #e53e3e;
1122}
1123
1124/* Alerts */
1125.alert {
1126 padding: 16px;
1127 border-radius: 8px;
1128 margin-bottom: 16px;
1129}
1130
1131.alert-danger {
1132 background: #fee2e2;
1133 color: #991b1b;
1134 border: 1px solid #fecaca;
1135}
1136
1137
1138.review-header {
1139 display: flex;
1140 justify-content: space-between;
1141 align-items: flex-start;
1142 margin-bottom: 16px;
1143 flex-wrap: wrap;
1144 gap: 12px;
1145}
1146
1147.reviewer-info {
1148 flex: 1;
1149}
1150
1151.reviewer-name {
1152 font-size: 1rem;
1153 font-weight: 700;
1154 margin: 0 0 4px 0;
1155 color: #111827;
1156}
1157
1158.reviewer-username {
1159 font-size: 0.875rem;
1160 color: #6b7280;
1161 margin: 0;
1162}
1163
1164.review-actions {
1165 display: flex;
1166 align-items: center;
1167 gap: 12px;
1168}
1169
1170.rating {
1171 font-size: 1rem;
1172 color: #f59e0b;
1173 display: flex;
1174 gap: 6px;
1175 align-items: center;
1176}
1177
1178.review-star {
1179 width: 1.5rem;
1180 height: 1.5rem;
1181 object-fit: contain;
1182}
1183
1184.delete-btn {
1185 background: none;
1186 border: none;
1187 cursor: pointer;
1188 opacity: 0.6;
1189 transition: opacity 0.2s ease;
1190 padding: 4px 8px;
1191 display: flex;
1192 align-items: center;
1193 justify-content: center;
1194}
1195
1196.delete-btn:hover {
1197 opacity: 1;
1198}
1199
1200.delete-btn-img {
1201 width: 1.25rem;
1202 height: 1.25rem;
1203 object-fit: contain;
1204}
1205
1206.review-comment {
1207 color: #374151;
1208 line-height: 1.6;
1209 margin: 0 0 12px 0;
1210 white-space: pre-wrap;
1211 word-break: break-word;
1212}
1213
1214.review-date {
1215 font-size: 0.875rem;
1216 color: #9ca3af;
1217 margin: 0;
1218}
1219
1220/* Responsive */
1221@media (max-width: 768px) {
1222 .owner-card {
1223 padding: 24px;
1224 }
1225
1226 .owner-header {
1227 flex-direction: column;
1228 align-items: flex-start;
1229 }
1230
1231 .owner-name {
1232 font-size: 1.75rem;
1233 }
1234
1235 .contact-actions {
1236 width: 100%;
1237 }
1238
1239 .btn-contact {
1240 flex: 1;
1241 }
1242
1243 .tab-content {
1244 padding: 24px;
1245 }
1246
1247 .listings-grid,
1248 .pets-grid {
1249 grid-template-columns: 1fr;
1250 }
1251}
1252
1253@media (max-width: 576px) {
1254 .header-section {
1255 padding: 12px 0;
1256 }
1257
1258 .back-link {
1259 font-size: 0.875rem;
1260 }
1261
1262 .owner-card {
1263 padding: 16px;
1264 }
1265
1266 .owner-name {
1267 font-size: 1.5rem;
1268 }
1269
1270 .owner-username,
1271 .owner-email {
1272 font-size: 0.875rem;
1273 }
1274
1275 .tabs-header {
1276 flex-direction: column;
1277 }
1278
1279 .tab-button {
1280 padding: 16px;
1281 border-right: 3px solid transparent;
1282 border-bottom: none;
1283 }
1284
1285 .tab-button.active {
1286 border-right-color: #d97706;
1287 border-bottom-color: transparent;
1288 }
1289
1290 .tab-content {
1291 padding: 16px;
1292 }
1293
1294 .section-title {
1295 font-size: 1.25rem;
1296 }
1297}
1298</style>
Note: See TracBrowser for help on using the repository browser.