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

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

Petify fullstack project

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