source: petify-frontend/src/views/ProfileView.vue@ 92e7c7a

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

Petify fullstack project

  • Property mode set to 100644
File size: 68.8 KB
RevLine 
[92e7c7a]1<template>
2 <div class="profile-container">
3 <!-- Back to listings if not authenticated -->
4 <div v-if="!auth.isAuthenticated" class="container py-5">
5 <div class="alert alert-warning alert-dismissible fade show" role="alert">
6 <strong>Not logged in</strong>
7 <p class="mb-0">Please <RouterLink to="/login" class="alert-link">log in</RouterLink> to view your profile.</p>
8 </div>
9 </div>
10
11 <!-- User Info Header -->
12 <div v-else class="header-section">
13 <div class="container">
14 <div class="profile-card">
15 <div class="profile-content">
16 <div class="profile-info">
17 <div style="display: flex; align-items: center; gap: 12px;">
18 <h1 class="profile-name">{{ auth.user?.firstName }} {{ auth.user?.lastName }}</h1>
19 <span v-if="auth.user?.verified" class="verified-badge">
20 <img src="@/img/star.png" alt="verified" class="badge-star" /> Top 10
21 </span>
22 </div>
23 <p class="profile-username">@{{ auth.user?.username }}</p>
24 <p class="profile-email">
25 <i class="bi bi-envelope"></i> {{ auth.user?.email }}
26 </p>
27 </div>
28 <div class="profile-badge">
29 <span class="badge bg-primary">{{ userType }}</span>
30 </div>
31 </div>
32 </div>
33 </div>
34 </div>
35
36 <!-- Tabs for content -->
37 <div v-if="auth.isAuthenticated" class="main-content">
38 <div class="container">
39 <div class="tabs-container">
40 <ul class="nav nav-tabs nav-fill" role="tablist">
41 <li class="nav-item" role="presentation">
42 <button
43 class="nav-link"
44 :class="{ active: activeTab === 'listings' }"
45 @click="activeTab = 'listings'"
46 type="button"
47 role="tab"
48 >
49 <i class="bi bi-bookmark-fill"></i> My Listings
50 </button>
51 </li>
52 <li class="nav-item" role="presentation">
53 <button
54 class="nav-link"
55 :class="{ active: activeTab === 'pets' }"
56 @click="activeTab = 'pets'"
57 type="button"
58 role="tab"
59 >
60 <i class="bi bi-paw-fill"></i> My Pets
61 </button>
62 </li>
63 <li class="nav-item" role="presentation">
64 <button
65 class="nav-link"
66 :class="{ active: activeTab === 'favorites' }"
67 @click="activeTab = 'favorites'"
68 type="button"
69 role="tab"
70 >
71 <i class="bi bi-heart-fill"></i> Favorites
72 </button>
73 </li>
74 <li v-if="pets.length > 0" class="nav-item" role="presentation">
75 <button
76 class="nav-link"
77 :class="{ active: activeTab === 'create-listing' }"
78 @click="activeTab = 'create-listing'"
79 type="button"
80 role="tab"
81 >
82 <i class="bi bi-plus-circle-fill"></i> Create Listing
83 </button>
84 </li>
85 <li v-if="isOwner" class="nav-item" role="presentation">
86 <button
87 class="nav-link"
88 :class="{ active: activeTab === 'appointments' }"
89 @click="activeTab = 'appointments'"
90 type="button"
91 role="tab"
92 >
93 <i class="bi bi-calendar-check-fill"></i> Appointments
94 </button>
95 </li>
96 </ul>
97
98 <!-- Listings Tab -->
99 <div v-if="activeTab === 'listings'" class="tab-content-section">
100 <h2 class="section-title">My Listings</h2>
101 <div v-if="listings.length === 0" class="empty-state">
102 <div class="empty-icon">📋</div>
103 <p class="empty-text">You haven't created any listings yet.</p>
104 <a href="#" @click.prevent="activeTab = 'create-listing'" class="btn btn-primary btn-sm">
105 Create your first listing
106 </a>
107 </div>
108 <div v-else class="grid-container">
109 <div v-for="listing in listings" :key="listing.listingId" class="listing-card-wrapper">
110 <div class="listing-card">
111 <div class="listing-header">
112 <h5 class="listing-title">{{ getPetName(listing.animalId) }}</h5>
113 <span class="badge" :class="getStatusBadgeClass(listing.status)">
114 {{ listing.status }}
115 </span>
116 </div>
117 <p class="listing-description">{{ listing.description }}</p>
118 <div class="listing-footer">
119 <div class="listing-price">${{ listing.price.toFixed(2) }}</div>
120 <small class="listing-date">{{ formatDate(listing.createdAt) }}</small>
121 </div>
122 <div class="listing-actions">
123 <select
124 v-model="listing.status"
125 @change="updateStatus(listing)"
126 class="form-select form-select-sm"
127 >
128 <option value="DRAFT">Draft</option>
129 <option value="ACTIVE">Active</option>
130 <option value="SOLD">Sold</option>
131 <option value="ARCHIVED">Archived</option>
132 </select>
133 <button
134 @click="deleteListing_(listing.listingId)"
135 class="btn btn-sm btn-danger"
136 title="Delete listing"
137 >
138 <i class="bi bi-trash"></i>
139 </button>
140 </div>
141 </div>
142 </div>
143 </div>
144 </div>
145
146 <!-- Pets Tab -->
147 <div v-if="activeTab === 'pets'" class="tab-content-section">
148 <div class="section-header-row">
149 <h2 class="section-title">My Pets</h2>
150 <button class="btn btn-primary btn-sm" type="button" @click="openAddPetForm">
151 <i class="bi bi-plus-circle-fill"></i> Add Pet
152 </button>
153 </div>
154 <div v-if="pets.length === 0" class="empty-state">
155 <img src="@/img/all_outline.png" alt="No pets" class="empty-icon-img" />
156 <p class="empty-text">You don't have any pets yet.</p>
157 <p class="empty-subtext">Add pets to create listings!</p>
158 <a href="#" @click.prevent="openAddPetForm" class="btn btn-primary btn-sm">
159 Add your first pet
160 </a>
161 </div>
162 <div v-else class="grid-container">
163 <div v-for="pet in pets" :key="pet.animalId" class="pet-card-wrapper">
164 <div class="pet-card">
165 <div class="pet-image-wrapper">
166 <img
167 v-if="pet.photoUrl"
168 :src="pet.photoUrl"
169 :alt="pet.name"
170 class="pet-image"
171 />
172 <img v-else src="@/img/all_outline.png" :alt="`${pet.name} placeholder`" class="pet-image-placeholder-img" />
173 </div>
174 <div class="pet-header">
175 <h5 class="pet-name">{{ pet.name }}</h5>
176 </div>
177 <div class="pet-details">
178 <div class="pet-detail-row">
179 <span class="label">Species:</span>
180 <span class="value">{{ pet.species }}</span>
181 </div>
182 <div v-if="pet.type" class="pet-detail-row">
183 <span class="label">Type:</span>
184 <span class="value">{{ pet.type }}</span>
185 </div>
186 <div v-if="pet.breed" class="pet-detail-row">
187 <span class="label">Breed:</span>
188 <span class="value">{{ pet.breed }}</span>
189 </div>
190 <div v-if="pet.sex" class="pet-detail-row">
191 <span class="label">Sex:</span>
192 <span class="value">{{ pet.sex }}</span>
193 </div>
194 <div v-if="pet.dateOfBirth" class="pet-detail-row">
195 <span class="label">DOB:</span>
196 <span class="value">{{ formatDate(pet.dateOfBirth) }}</span>
197 </div>
198 <div v-if="pet.locatedName" class="pet-detail-row">
199 <span class="label">Location:</span>
200 <span class="value">{{ pet.locatedName }}</span>
201 </div>
202 </div>
203 <button
204 v-if="isOwner"
205 @click="selectPetForListing(pet)"
206 class="btn btn-primary btn-sm w-100"
207 >
208 Create Listing for {{ pet.name }}
209 </button>
210 <button
211 v-if="isOwner"
212 @click="selectPetForAppointment(pet)"
213 class="btn btn-outline-secondary btn-sm w-100"
214 >
215 Schedule Appointment for {{ pet.name }}
216 </button>
217 </div>
218 </div>
219 </div>
220 </div>
221
222 <!-- Create Listing Tab -->
223 <div v-if="activeTab === 'create-listing'" class="tab-content-section">
224 <h2 class="section-title">Create New Listing</h2>
225 <div v-if="pets.length === 0" class="empty-state">
226 <p>You need to have at least one pet to create a listing.</p>
227 </div>
228 <div v-else class="form-card">
229 <form @submit.prevent="submitListing">
230 <div class="form-group">
231 <label for="petSelect" class="form-label">Select Pet *</label>
232 <select
233 v-model.number="newListing.animalId"
234 id="petSelect"
235 class="form-select"
236 required
237 >
238 <option value="">Choose a pet...</option>
239 <option v-for="pet in pets" :key="pet.animalId" :value="pet.animalId">
240 {{ pet.name }} ({{ pet.species }})
241 </option>
242 </select>
243 </div>
244
245 <div class="form-group">
246 <label for="description" class="form-label">Description *</label>
247 <textarea
248 v-model="newListing.description"
249 id="description"
250 class="form-control"
251 rows="5"
252 placeholder="Describe your pet and why they're available..."
253 required
254 ></textarea>
255 </div>
256
257 <div class="form-group">
258 <label for="price" class="form-label">Price *</label>
259 <div class="input-group">
260 <span class="input-group-text">$</span>
261 <input
262 v-model.number="newListing.price"
263 type="number"
264 id="price"
265 class="form-control"
266 placeholder="0.00"
267 step="0.01"
268 min="0"
269 required
270 />
271 </div>
272 </div>
273
274 <div v-if="errorMessage" class="alert alert-danger">
275 {{ errorMessage }}
276 </div>
277
278 <div class="form-actions">
279 <button type="submit" class="btn btn-primary" :disabled="isSubmitting">
280 <span v-if="isSubmitting" class="spinner-border spinner-border-sm me-2"></span>
281 {{ isSubmitting ? 'Creating...' : 'Create Listing' }}
282 </button>
283 <button type="button" @click="resetForm" class="btn btn-outline-secondary" :disabled="isSubmitting">
284 Reset
285 </button>
286 </div>
287 </form>
288 </div>
289 </div>
290
291 <!-- Add Pet Form -->
292 <div v-if="activeTab === 'pets' && showAddPetForm" ref="addPetPanel" class="tab-content-section add-pet-panel">
293 <h2 class="section-title">Add New Pet</h2>
294 <div class="form-card">
295 <form @submit.prevent="submitPet">
296 <div class="form-row">
297 <div class="form-group">
298 <label for="petName" class="form-label">Pet Name *</label>
299 <input
300 v-model="newPet.name"
301 type="text"
302 id="petName"
303 class="form-control"
304 placeholder="e.g., Buddy"
305 required
306 />
307 </div>
308 <div class="form-group">
309 <label for="petSex" class="form-label">Sex *</label>
310 <select v-model="newPet.sex" id="petSex" class="form-select" required>
311 <option value="">Select sex...</option>
312 <option value="MALE">Male</option>
313 <option value="FEMALE">Female</option>
314 <option value="UNKNOWN">Unknown</option>
315 </select>
316 </div>
317 </div>
318
319 <div class="form-row">
320 <div class="form-group">
321 <label for="petSpecies" class="form-label">Species *</label>
322 <select v-model="newPet.species" id="petSpecies" class="form-select" required>
323 <option value="">Select species...</option>
324 <option value="Dog">Dog</option>
325 <option value="Cat">Cat</option>
326 <option value="Bird">Bird</option>
327 <option value="Rabbit">Rabbit</option>
328 <option value="Hamster">Hamster</option>
329 <option value="Guinea Pig">Guinea Pig</option>
330 <option value="Other">Other</option>
331 </select>
332 </div>
333 <div class="form-group">
334 <label for="petBreed" class="form-label">Breed</label>
335 <input
336 v-model="newPet.breed"
337 type="text"
338 id="petBreed"
339 class="form-control"
340 placeholder="e.g., Golden Retriever"
341 />
342 </div>
343 </div>
344
345 <div class="form-row">
346 <div class="form-group">
347 <label for="petDateOfBirth" class="form-label">Date of Birth</label>
348 <input
349 v-model="newPet.dateOfBirth"
350 type="date"
351 id="petDateOfBirth"
352 class="form-control"
353 />
354 </div>
355 <div class="form-group">
356 <label for="petLocatedName" class="form-label">Location</label>
357 <input
358 v-model="newPet.locatedName"
359 type="text"
360 id="petLocatedName"
361 class="form-control"
362 placeholder="e.g., Skopje"
363 />
364 </div>
365 </div>
366
367 <div class="form-group">
368 <label for="petPhoto" class="form-label">Photo</label>
369 <input
370 type="file"
371 id="petPhoto"
372 class="form-control"
373 accept="image/jpeg,image/png,image/webp,image/gif"
374 @change="handlePetPhotoChange"
375 />
376 <small class="text-muted">JPG, PNG, WEBP, or GIF up to 5MB.</small>
377 <div v-if="petPhotoPreview" class="pet-photo-preview">
378 <img :src="petPhotoPreview" alt="Selected pet preview" />
379 <button type="button" class="btn btn-sm btn-outline-secondary" @click="clearPetPhoto">
380 Remove photo
381 </button>
382 </div>
383 </div>
384
385 <div v-if="errorMessage" class="alert alert-danger">
386 {{ errorMessage }}
387 </div>
388
389 <div class="form-actions">
390 <button type="submit" class="btn btn-primary" :disabled="isPetSubmitting">
391 <span v-if="isPetSubmitting" class="spinner-border spinner-border-sm me-2"></span>
392 {{ isPetSubmitting ? 'Adding Pet...' : 'Add Pet' }}
393 </button>
394 <button type="button" @click="resetPetForm" class="btn btn-outline-secondary" :disabled="isPetSubmitting">
395 Reset
396 </button>
397 <button type="button" @click="hideAddPetForm" class="btn btn-outline-secondary" :disabled="isPetSubmitting">
398 Cancel
399 </button>
400 </div>
401 </form>
402 </div>
403 </div>
404
405 <!-- Favorites Tab -->
406 <div v-if="activeTab === 'favorites'" class="tab-content-section">
407 <h2 class="section-title">Favorite Listings</h2>
408 <div v-if="favorites.length === 0" class="empty-state">
409 <div class="empty-icon">♡</div>
410 <p class="empty-text">You haven't added any favorites yet.</p>
411 <p class="empty-subtext">Browse listings and click the heart icon to save them!</p>
412 </div>
413 <div v-else class="grid-container">
414 <div v-for="listing in favorites" :key="listing.listingId" class="listing-card-wrapper">
415 <div
416 class="listing-card favorite-listing"
417 @click="goToListing(listing.listingId)"
418 role="button"
419 tabindex="0"
420 @keydown.enter="goToListing(listing.listingId)"
421 >
422 <div class="favorite-image-wrapper">
423 <img
424 :src="getFavoriteListingImage(listing)"
425 :alt="getPetName(listing.animalId)"
426 class="favorite-image"
427 @error="(e) => handleFavoriteImageError(e)"
428 />
429 </div>
430 <div class="favorite-content">
431 <h5 class="listing-title">{{ getPetName(listing.animalId) }}</h5>
432 <span class="badge" :class="getStatusBadgeClass(listing.status)">
433 {{ listing.status }}
434 </span>
435 </div>
436 <p class="listing-description">{{ listing.description }}</p>
437 <div class="listing-footer">
438 <div class="listing-price">${{ listing.price.toFixed(2) }}</div>
439 <small class="listing-date">{{ formatDate(listing.createdAt) }}</small>
440 </div>
441 <button
442 @click.stop="removeFavorite(listing.listingId)"
443 class="btn btn-sm btn-outline-danger w-100"
444 >
445 <i class="bi bi-heart-fill"></i> Remove from Favorites
446 </button>
447 </div>
448 </div>
449 </div>
450 </div>
451
452 <!-- Create Appointment Tab -->
453 <div v-if="activeTab === 'appointments'" class="tab-content-section">
454 <div v-if="appointmentsError" class="alert alert-danger">
455 {{ appointmentsError }}
456 </div>
457 <div v-if="isAppointmentsLoading" class="alert alert-info">Loading appointments...</div>
458 <OwnerAppointmentsCalendar
459 v-if="!isAppointmentsLoading"
460 :appointments="appointments"
461 :selected-date="selectedAppointmentDate"
462 @select="handleCalendarSelect"
463 />
464 <div class="appointments-day-section">
465 <h3 class="appointments-day-title">
466 Appointments for {{ selectedAppointmentDate || 'Select a day' }}
467 </h3>
468 <div v-if="selectedDayAppointments.length === 0" class="empty-state appointments-empty">
469 <p>No appointments scheduled for this day.</p>
470 </div>
471 <div v-else class="appointments-list">
472 <div v-for="appt in selectedDayAppointments" :key="appt.appointmentId" class="appointment-card">
473 <div class="appointment-header">
474 <div class="appointment-title">
475 {{ appt.petName || 'Pet' }}
476 <span v-if="appt.petSpecies">({{ appt.petSpecies }})</span>
477 </div>
478 <span class="badge" :class="getStatusBadgeClass(appt.status)">
479 {{ appt.status }}
480 </span>
481 </div>
482 <div class="appointment-meta">
483 <div class="appointment-time">{{ formatDateTime(appt.dateTime) }}</div>
484 <div class="appointment-clinic">
485 {{ appt.clinicName || 'Clinic' }} - {{ appt.clinicCity || '' }} {{ appt.clinicAddress || '' }}
486 </div>
487 </div>
488 <div v-if="appt.notes" class="appointment-notes">{{ appt.notes }}</div>
489 <div v-if="appointmentError" class="alert alert-danger appointment-inline-error">
490 {{ appointmentError }}
491 </div>
492 <div v-if="canCancelAppointment(appt)" class="appointment-actions">
493 <button
494 type="button"
495 class="btn btn-sm btn-outline-danger"
496 :disabled="cancellingAppointmentId === appt.appointmentId"
497 @click="cancelAppointment(appt)"
498 >
499 {{ cancellingAppointmentId === appt.appointmentId ? 'Cancelling...' : 'Cancel appointment' }}
500 </button>
501 </div>
502 <div v-if="appt.status === 'DONE'" class="clinic-review-section">
503 <div v-if="getClinicReview(appt.clinicId) && activeClinicReviewAppointmentId !== appt.appointmentId" class="clinic-review-summary">
504 <div>
505 <div class="clinic-review-stars">
506 <span v-for="i in 5" :key="i" :class="{ active: i <= (getClinicReview(appt.clinicId)?.rating || 0) }">★</span>
507 </div>
508 <p class="clinic-review-comment">{{ getClinicReview(appt.clinicId)?.comment || 'No comment' }}</p>
509 </div>
510 <div class="clinic-review-actions">
511 <button type="button" class="btn btn-sm btn-outline-primary" @click="startClinicReview(appt)">
512 Edit review
513 </button>
514 <button type="button" class="btn btn-sm btn-outline-danger" @click="deleteClinicReview(appt)">
515 Delete
516 </button>
517 </div>
518 </div>
519
520 <button
521 v-else-if="activeClinicReviewAppointmentId !== appt.appointmentId"
522 type="button"
523 class="btn btn-sm btn-outline-primary"
524 @click="startClinicReview(appt)"
525 >
526 Leave clinic review
527 </button>
528
529 <form v-else class="clinic-review-form" @submit.prevent="submitClinicReview(appt)">
530 <div class="clinic-review-stars editable">
531 <button
532 v-for="i in 5"
533 :key="i"
534 type="button"
535 :class="{ active: i <= clinicReviewForm.rating }"
536 @click="clinicReviewForm.rating = i"
537 >
538
539 </button>
540 </div>
541 <textarea
542 v-model="clinicReviewForm.comment"
543 class="form-control"
544 rows="3"
545 placeholder="Share how the clinic visit went..."
546 ></textarea>
547 <div v-if="clinicReviewError" class="alert alert-danger">
548 {{ clinicReviewError }}
549 </div>
550 <div class="clinic-review-actions">
551 <button type="submit" class="btn btn-sm btn-primary">
552 {{ getClinicReview(appt.clinicId) ? 'Save review' : 'Submit review' }}
553 </button>
554 <button type="button" class="btn btn-sm btn-outline-secondary" @click="cancelClinicReview">
555 Cancel
556 </button>
557 </div>
558 </form>
559 </div>
560 <div v-if="appt.status === 'DONE'" class="health-record-section">
561 <div v-if="getHealthRecordForAppointment(appt) && activeHealthRecordAppointmentId !== appt.appointmentId" class="health-record-summary">
562 <div>
563 <strong>{{ getHealthRecordForAppointment(appt)?.type }}</strong>
564 <p>{{ getHealthRecordForAppointment(appt)?.description || 'No description' }}</p>
565 <small>{{ formatDate(getHealthRecordForAppointment(appt)?.date || appt.dateTime) }}</small>
566 </div>
567 </div>
568
569 <button
570 v-else-if="activeHealthRecordAppointmentId !== appt.appointmentId"
571 type="button"
572 class="btn btn-sm btn-outline-success"
573 @click="startHealthRecord(appt)"
574 >
575 Add health record
576 </button>
577
578 <form v-else class="health-record-form" @submit.prevent="submitHealthRecord(appt)">
579 <div class="form-group">
580 <label class="form-label">Record type *</label>
581 <input
582 v-model="healthRecordForm.type"
583 class="form-control"
584 placeholder="Vaccination, checkup, treatment..."
585 required
586 />
587 </div>
588 <div class="form-group">
589 <label class="form-label">Description</label>
590 <textarea
591 v-model="healthRecordForm.description"
592 class="form-control"
593 rows="3"
594 placeholder="Describe what happened during the appointment..."
595 ></textarea>
596 </div>
597 <div v-if="healthRecordError" class="alert alert-danger">
598 {{ healthRecordError }}
599 </div>
600 <div class="clinic-review-actions">
601 <button type="submit" class="btn btn-sm btn-success" :disabled="isHealthRecordSubmitting">
602 {{ isHealthRecordSubmitting ? 'Saving...' : 'Save health record' }}
603 </button>
604 <button type="button" class="btn btn-sm btn-outline-secondary" @click="cancelHealthRecord">
605 Cancel
606 </button>
607 </div>
608 </form>
609 </div>
610 </div>
611 </div>
612 </div>
613 <h2 class="section-title">Create Appointment</h2>
614 <div v-if="pets.length === 0" class="empty-state">
615 <p>You need to have at least one pet to create an appointment.</p>
616 </div>
617 <div v-else class="form-card">
618 <form @submit.prevent="submitAppointment">
619 <div class="form-group">
620 <label for="appointmentPet" class="form-label">Select Pet *</label>
621 <select
622 v-model.number="newAppointment.animalId"
623 id="appointmentPet"
624 class="form-select"
625 required
626 >
627 <option value="">Choose a pet...</option>
628 <option v-for="pet in pets" :key="pet.animalId" :value="pet.animalId">
629 {{ pet.name }} ({{ pet.species }})
630 </option>
631 </select>
632 </div>
633
634 <div class="form-group">
635 <label for="appointmentClinic" class="form-label">Clinic *</label>
636 <select
637 v-model.number="newAppointment.clinicId"
638 id="appointmentClinic"
639 class="form-select"
640 required
641 >
642 <option value="">Choose a clinic...</option>
643 <option v-for="clinic in clinics" :key="clinic.clinicId" :value="clinic.clinicId">
644 {{ clinic.name }} - {{ clinic.city }}, {{ clinic.address }}
645 </option>
646 </select>
647 <small v-if="clinicsError" class="text-danger">{{ clinicsError }}</small>
648 </div>
649
650 <div class="form-group">
651 <label for="appointmentDate" class="form-label">Date *</label>
652 <input
653 v-model="appointmentDate"
654 type="date"
655 id="appointmentDate"
656 class="form-control"
657 :min="todayDate"
658 required
659 />
660 </div>
661
662 <div class="form-group">
663 <label for="appointmentSlot" class="form-label">Available Time Slot *</label>
664 <select
665 v-model="newAppointment.dateTime"
666 id="appointmentSlot"
667 class="form-select"
668 :disabled="!newAppointment.clinicId || !appointmentDate || isSlotsLoading || availableSlots.length === 0"
669 required
670 >
671 <option value="">{{ appointmentSlotPlaceholder }}</option>
672 <option v-for="slot in availableSlots" :key="slot.dateTime" :value="slot.dateTime">
673 {{ slot.label }}
674 </option>
675 </select>
676 <small v-if="isSlotsLoading" class="text-muted">Loading available slots...</small>
677 <small v-else-if="slotsError" class="text-danger">{{ slotsError }}</small>
678 <small v-else-if="newAppointment.clinicId && appointmentDate && availableSlots.length === 0" class="text-muted">
679 No available slots for this clinic on the selected date.
680 </small>
681 </div>
682
683 <div class="form-group">
684 <label for="appointmentNotes" class="form-label">Notes</label>
685 <textarea
686 v-model="newAppointment.notes"
687 id="appointmentNotes"
688 class="form-control"
689 rows="4"
690 placeholder="Add notes for the clinic..."
691 ></textarea>
692 </div>
693
694 <div v-if="appointmentError" class="alert alert-danger">
695 {{ appointmentError }}
696 </div>
697
698 <div class="form-actions">
699 <button type="submit" class="btn btn-primary" :disabled="isAppointmentSubmitting">
700 <span v-if="isAppointmentSubmitting" class="spinner-border spinner-border-sm me-2"></span>
701 {{ isAppointmentSubmitting ? 'Creating...' : 'Create Appointment' }}
702 </button>
703 <button type="button" @click="resetAppointmentForm" class="btn btn-outline-secondary" :disabled="isAppointmentSubmitting">
704 Reset
705 </button>
706 </div>
707 </form>
708 </div>
709 </div>
710
711 </div>
712 </div>
713 </div>
714 </div>
715</template>
716
717<script setup lang="ts">
718import { ref, computed, nextTick, onBeforeUnmount, onMounted, watch } from 'vue'
719import { useRouter } from 'vue-router'
720import { useAuthStore } from '../stores/auth'
721import OwnerAppointmentsCalendar from '../components/OwnerAppointmentsCalendar.vue'
722import {
723 getUserListings,
724 getUserPets,
725 createListing,
726 createPet,
727 deleteListing,
728 updateListingStatus,
729 getPet,
730 loadUserVerificationStatus,
731 createAppointment,
732 cancelOwnerAppointment,
733 createHealthRecord,
734 getClinics,
735 getClinicAvailableSlots,
736 getPetHealthRecords,
737 getOwnerAppointments,
738 type HealthRecord,
739} from '../api/profile'
740import { getFavoritedListings, removeFavorite as removeFavoriteAPI } from '../api/favorites'
741import {
742 createClinicReview,
743 deleteReview as deleteReviewAPI,
744 getMyClinicReview,
745 updateReview as updateReviewAPI,
746 type Review,
747} from '../api/reviews'
748
749const router = useRouter()
750const auth = useAuthStore()
751
752const activeTab = ref<'listings' | 'pets' | 'create-listing' | 'favorites' | 'appointments'>('listings')
753const listings = ref<any[]>([])
754const pets = ref<any[]>([])
755const favorites = ref<any[]>([])
756const clinics = ref<any[]>([])
757const availableSlots = ref<any[]>([])
758const appointments = ref<any[]>([])
759const clinicReviews = ref<Record<number, Review | null>>({})
760const healthRecordsByPet = ref<Record<number, HealthRecord[]>>({})
761const selectedAppointmentDate = ref('')
762const appointmentDate = ref('')
763const activeClinicReviewAppointmentId = ref<number | null>(null)
764const activeHealthRecordAppointmentId = ref<number | null>(null)
765const appointmentsError = ref('')
766const isLoading = ref(false)
767const isSubmitting = ref(false)
768const isPetSubmitting = ref(false)
769const isAppointmentSubmitting = ref(false)
770const cancellingAppointmentId = ref<number | null>(null)
771const isAppointmentsLoading = ref(false)
772const isHealthRecordSubmitting = ref(false)
773const isSlotsLoading = ref(false)
774const errorMessage = ref('')
775const appointmentError = ref('')
776const clinicsError = ref('')
777const slotsError = ref('')
778const clinicReviewError = ref('')
779const healthRecordError = ref('')
780const showAddPetForm = ref(false)
781const addPetPanel = ref<HTMLElement | null>(null)
782const petPhotoFile = ref<File | null>(null)
783const petPhotoPreview = ref('')
784
785const newListing = ref({
786 animalId: null as number | null,
787 description: '',
788 price: null as number | null,
789})
790
791const newPet = ref({
792 name: '',
793 sex: '',
794 dateOfBirth: '',
795 type: 'PET',
796 species: '',
797 breed: '',
798 locatedName: '',
799})
800
801const newAppointment = ref({
802 clinicId: null as number | null,
803 animalId: null as number | null,
804 dateTime: '',
805 notes: '',
806})
807
808const clinicReviewForm = ref({
809 rating: 0,
810 comment: '',
811})
812
813const healthRecordForm = ref({
814 type: '',
815 description: '',
816})
817
818const isOwner = computed(() => {
819 return auth.user?.userType === 'OWNER'
820})
821
822const userType = computed(() => {
823 return auth.user?.userType || 'Unknown'
824})
825
826const todayDate = computed(() => toDateKey(new Date()))
827
828const appointmentSlotPlaceholder = computed(() => {
829 if (!newAppointment.value.clinicId) return 'Choose a clinic first...'
830 if (!appointmentDate.value) return 'Choose a date first...'
831 if (isSlotsLoading.value) return 'Loading slots...'
832 if (availableSlots.value.length === 0) return 'No available slots'
833 return 'Choose a time slot...'
834})
835
836// Store pet details cache for images
837const petDetailsCache = ref<Map<number, any>>(new Map())
838
839// Create a map of petId to pet name
840const petNameMap = computed(() => {
841 const map: Record<number, string> = {}
842 // Add pets from the pets list
843 pets.value.forEach((pet) => {
844 map[pet.animalId] = pet.name
845 })
846 // Add pets from cache (favorites)
847 petDetailsCache.value.forEach((pet, animalId) => {
848 map[animalId] = pet.name
849 })
850 return map
851})
852
853// Get pet name for listing
854function getPetName(animalId: number): string {
855 // Check cache first (from favorites loading)
856 if (petDetailsCache.value.has(animalId)) {
857 return petDetailsCache.value.get(animalId)?.name || 'Unknown Pet'
858 }
859 // Fall back to petNameMap (from pets list)
860 return petNameMap.value[animalId] || 'Unknown Pet'
861}
862
863function formatDate(dateString: string): string {
864 const date = new Date(dateString)
865 return date.toLocaleDateString('en-US', {
866 year: 'numeric',
867 month: 'short',
868 day: 'numeric',
869 })
870}
871
872function formatDateTime(dateString: string): string {
873 const date = new Date(dateString)
874 return date.toLocaleString('en-US', {
875 year: 'numeric',
876 month: 'short',
877 day: 'numeric',
878 hour: '2-digit',
879 minute: '2-digit',
880 })
881}
882
883function getStatusBadgeClass(status: string): string {
884 switch (status) {
885 case 'ACTIVE':
886 case 'CONFIRMED':
887 case 'DONE':
888 return 'bg-success'
889 case 'INACTIVE':
890 case 'CANCELLED':
891 case 'CANCELED':
892 case 'NO_SHOW':
893 return 'bg-secondary'
894 case 'SOLD':
895 return 'bg-danger'
896 default:
897 return 'bg-warning'
898 }
899}
900
901async function loadListings() {
902 if (!auth.user?.userId || !isOwner.value) {
903 listings.value = []
904 return
905 }
906 try {
907 isLoading.value = true
908 listings.value = await getUserListings(auth.user.userId)
909 } catch (error) {
910 console.error('Failed to load listings:', error)
911 } finally {
912 isLoading.value = false
913 }
914}
915
916async function loadPets() {
917 if (!auth.user?.userId) return
918 try {
919 isLoading.value = true
920 pets.value = await getUserPets(auth.user.userId)
921 } catch (error) {
922 console.error('Failed to load pets:', error)
923 // If no pets exist, just show empty list
924 pets.value = []
925 } finally {
926 isLoading.value = false
927 }
928}
929
930async function loadClinics() {
931 try {
932 clinics.value = await getClinics()
933 clinicsError.value = ''
934 } catch (error) {
935 clinics.value = []
936 clinicsError.value = error instanceof Error ? error.message : 'Failed to load clinics'
937 }
938}
939
940async function loadAppointments() {
941 if (!auth.user?.userId || !isOwner.value) return
942 try {
943 isAppointmentsLoading.value = true
944 appointments.value = await getOwnerAppointments(auth.user.userId)
945 if (!selectedAppointmentDate.value) {
946 selectedAppointmentDate.value = toDateKey(new Date())
947 }
948 appointmentsError.value = ''
949 } catch (error) {
950 appointments.value = []
951 appointmentsError.value = error instanceof Error ? error.message : 'Failed to load appointments'
952 } finally {
953 isAppointmentsLoading.value = false
954 }
955
956 await loadClinicReviewsForDoneAppointments()
957 await loadHealthRecordsForAppointmentPets()
958}
959
960async function loadClinicReviewsForDoneAppointments() {
961 if (!auth.user?.userId) return
962
963 const clinicIds = Array.from(new Set(
964 appointments.value
965 .filter((appt) => appt.status === 'DONE' && appt.clinicId)
966 .map((appt) => Number(appt.clinicId))
967 ))
968
969 const nextReviews: Record<number, Review | null> = {}
970 await Promise.all(clinicIds.map(async (clinicId) => {
971 try {
972 nextReviews[clinicId] = await getMyClinicReview(clinicId, auth.user!.userId)
973 } catch (error) {
974 console.error(`Failed to load clinic review for clinic ${clinicId}:`, error)
975 nextReviews[clinicId] = null
976 }
977 }))
978 clinicReviews.value = nextReviews
979}
980
981async function loadHealthRecordsForAppointmentPets() {
982 const petIds = Array.from(new Set(
983 appointments.value
984 .filter((appt) => appt.animalId)
985 .map((appt) => Number(appt.animalId))
986 ))
987
988 const nextRecords: Record<number, HealthRecord[]> = {}
989 await Promise.all(petIds.map(async (petId) => {
990 try {
991 nextRecords[petId] = await getPetHealthRecords(petId)
992 } catch (error) {
993 console.error(`Failed to load health records for pet ${petId}:`, error)
994 nextRecords[petId] = []
995 }
996 }))
997 healthRecordsByPet.value = nextRecords
998}
999
1000async function loadAvailableSlots() {
1001 newAppointment.value.dateTime = ''
1002 availableSlots.value = []
1003 slotsError.value = ''
1004
1005 if (!newAppointment.value.clinicId || !appointmentDate.value) {
1006 return
1007 }
1008
1009 try {
1010 isSlotsLoading.value = true
1011 availableSlots.value = await getClinicAvailableSlots(newAppointment.value.clinicId, appointmentDate.value)
1012 } catch (error) {
1013 availableSlots.value = []
1014 slotsError.value = error instanceof Error ? error.message : 'Failed to load available slots'
1015 } finally {
1016 isSlotsLoading.value = false
1017 }
1018}
1019
1020function toDateKey(date: Date): string {
1021 const year = date.getFullYear()
1022 const month = String(date.getMonth() + 1).padStart(2, '0')
1023 const day = String(date.getDate()).padStart(2, '0')
1024 return `${year}-${month}-${day}`
1025}
1026
1027const selectedDayAppointments = computed(() => {
1028 if (!selectedAppointmentDate.value) return []
1029 return appointments.value.filter((appt) => toDateKey(new Date(appt.dateTime)) === selectedAppointmentDate.value)
1030})
1031
1032function handleCalendarSelect(dateKey: string) {
1033 selectedAppointmentDate.value = dateKey
1034}
1035
1036function canCancelAppointment(appt: any): boolean {
1037 return appt.status === 'CONFIRMED' && new Date(appt.dateTime).getTime() > Date.now()
1038}
1039
1040async function cancelAppointment(appt: any) {
1041 if (!auth.user?.userId) {
1042 appointmentError.value = 'Please log in to cancel an appointment'
1043 return
1044 }
1045
1046 if (!confirm('Cancel this appointment? The clinic will be notified.')) {
1047 return
1048 }
1049
1050 try {
1051 cancellingAppointmentId.value = appt.appointmentId
1052 appointmentError.value = ''
1053 const updatedAppointment = await cancelOwnerAppointment(auth.user.userId, appt.appointmentId)
1054 appointments.value = appointments.value.map((appointment) =>
1055 appointment.appointmentId === updatedAppointment.appointmentId ? updatedAppointment : appointment
1056 )
1057 const cancelledDate = String(appt.dateTime || '').slice(0, 10)
1058 if (
1059 Number(newAppointment.value.clinicId) === Number(appt.clinicId) &&
1060 appointmentDate.value === cancelledDate
1061 ) {
1062 await loadAvailableSlots()
1063 }
1064 } catch (error) {
1065 appointmentError.value = error instanceof Error ? error.message : 'Failed to cancel appointment'
1066 } finally {
1067 cancellingAppointmentId.value = null
1068 }
1069}
1070
1071function getClinicReview(clinicId: number): Review | null {
1072 return clinicReviews.value[clinicId] || null
1073}
1074
1075function startClinicReview(appt: any) {
1076 const existingReview = getClinicReview(appt.clinicId)
1077 activeClinicReviewAppointmentId.value = appt.appointmentId
1078 clinicReviewError.value = ''
1079 clinicReviewForm.value = {
1080 rating: existingReview?.rating || 0,
1081 comment: existingReview?.comment || '',
1082 }
1083}
1084
1085function cancelClinicReview() {
1086 activeClinicReviewAppointmentId.value = null
1087 clinicReviewForm.value = {
1088 rating: 0,
1089 comment: '',
1090 }
1091 clinicReviewError.value = ''
1092}
1093
1094async function submitClinicReview(appt: any) {
1095 if (!auth.user?.userId) {
1096 clinicReviewError.value = 'Please log in to review this clinic'
1097 return
1098 }
1099
1100 if (clinicReviewForm.value.rating < 1) {
1101 clinicReviewError.value = 'Please select a rating'
1102 return
1103 }
1104
1105 try {
1106 const existingReview = getClinicReview(appt.clinicId)
1107 const savedReview = existingReview
1108 ? await updateReviewAPI(existingReview.reviewId, auth.user.userId, clinicReviewForm.value.rating, clinicReviewForm.value.comment)
1109 : await createClinicReview(appt.clinicId, auth.user.userId, clinicReviewForm.value.rating, clinicReviewForm.value.comment)
1110
1111 clinicReviews.value = {
1112 ...clinicReviews.value,
1113 [appt.clinicId]: savedReview,
1114 }
1115 cancelClinicReview()
1116 } catch (error) {
1117 clinicReviewError.value = error instanceof Error ? error.message : 'Failed to save clinic review'
1118 }
1119}
1120
1121async function deleteClinicReview(appt: any) {
1122 if (!auth.user?.userId) {
1123 clinicReviewError.value = 'Please log in to delete this review'
1124 return
1125 }
1126
1127 const existingReview = getClinicReview(appt.clinicId)
1128 if (!existingReview) return
1129 if (!confirm('Delete your review for this clinic?')) return
1130
1131 try {
1132 await deleteReviewAPI(existingReview.reviewId, auth.user.userId)
1133 clinicReviews.value = {
1134 ...clinicReviews.value,
1135 [appt.clinicId]: null,
1136 }
1137 cancelClinicReview()
1138 } catch (error) {
1139 clinicReviewError.value = error instanceof Error ? error.message : 'Failed to delete clinic review'
1140 }
1141}
1142
1143function getHealthRecordForAppointment(appt: any): HealthRecord | null {
1144 if (!appt.animalId) return null
1145 return (healthRecordsByPet.value[Number(appt.animalId)] || [])
1146 .find((record) => record.appointmentId === appt.appointmentId) || null
1147}
1148
1149function startHealthRecord(appt: any) {
1150 activeHealthRecordAppointmentId.value = appt.appointmentId
1151 healthRecordError.value = ''
1152 healthRecordForm.value = {
1153 type: '',
1154 description: '',
1155 }
1156}
1157
1158function cancelHealthRecord() {
1159 activeHealthRecordAppointmentId.value = null
1160 healthRecordError.value = ''
1161 healthRecordForm.value = {
1162 type: '',
1163 description: '',
1164 }
1165}
1166
1167async function submitHealthRecord(appt: any) {
1168 if (!auth.user?.userId) {
1169 healthRecordError.value = 'Please log in to add a health record'
1170 return
1171 }
1172
1173 if (!healthRecordForm.value.type.trim()) {
1174 healthRecordError.value = 'Please enter the health record type'
1175 return
1176 }
1177
1178 try {
1179 isHealthRecordSubmitting.value = true
1180 healthRecordError.value = ''
1181 const saved = await createHealthRecord(auth.user.userId, {
1182 appointmentId: appt.appointmentId,
1183 type: healthRecordForm.value.type.trim(),
1184 description: healthRecordForm.value.description || undefined,
1185 })
1186
1187 const petId = Number(saved.animalId)
1188 healthRecordsByPet.value = {
1189 ...healthRecordsByPet.value,
1190 [petId]: [
1191 saved,
1192 ...(healthRecordsByPet.value[petId] || []).filter((record) => record.healthRecordId !== saved.healthRecordId),
1193 ],
1194 }
1195 cancelHealthRecord()
1196 } catch (error) {
1197 healthRecordError.value = error instanceof Error ? error.message : 'Failed to add health record'
1198 } finally {
1199 isHealthRecordSubmitting.value = false
1200 }
1201}
1202
1203async function submitListing() {
1204 if (!auth.user?.userId || !newListing.value.animalId || !newListing.value.price) {
1205 errorMessage.value = 'Please fill in all required fields'
1206 return
1207 }
1208
1209 try {
1210 isSubmitting.value = true
1211 errorMessage.value = ''
1212
1213 await createListing(auth.user.userId, {
1214 animalId: newListing.value.animalId,
1215 description: newListing.value.description,
1216 price: newListing.value.price,
1217 })
1218
1219 // Reload listings
1220 await loadListings()
1221 resetForm()
1222 activeTab.value = 'listings'
1223 } catch (error) {
1224 errorMessage.value = error instanceof Error ? error.message : 'Failed to create listing'
1225 } finally {
1226 isSubmitting.value = false
1227 }
1228}
1229
1230async function updateStatus(listing: any) {
1231
1232 if (!auth.user?.userId) {
1233 return
1234 }
1235
1236 try {
1237 await updateListingStatus(auth.user.userId, listing.listingId, listing.status)
1238 } catch (error) {
1239 errorMessage.value = error instanceof Error ? error.message : 'Failed to update listing'
1240 await loadListings()
1241 }
1242}
1243
1244async function deleteListing_(listingId: number) {
1245 if (!auth.user?.userId) return
1246
1247 if (!confirm('Are you sure you want to delete this listing?')) return
1248
1249 try {
1250 await deleteListing(auth.user.userId, listingId)
1251 await loadListings()
1252 } catch (error) {
1253 errorMessage.value = error instanceof Error ? error.message : 'Failed to delete listing'
1254 }
1255}
1256
1257function selectPetForListing(pet: any) {
1258 newListing.value.animalId = pet.animalId
1259 activeTab.value = 'create-listing'
1260}
1261
1262function selectPetForAppointment(pet: any) {
1263 newAppointment.value.animalId = pet.animalId
1264 activeTab.value = 'appointments'
1265}
1266
1267function resetForm() {
1268 newListing.value = {
1269 animalId: null,
1270 description: '',
1271 price: null,
1272 }
1273 errorMessage.value = ''
1274}
1275
1276function resetAppointmentForm() {
1277 newAppointment.value = {
1278 clinicId: null,
1279 animalId: null,
1280 dateTime: '',
1281 notes: '',
1282 }
1283 appointmentDate.value = ''
1284 availableSlots.value = []
1285 appointmentError.value = ''
1286 slotsError.value = ''
1287}
1288
1289function clearPetPhoto() {
1290 if (petPhotoPreview.value) {
1291 URL.revokeObjectURL(petPhotoPreview.value)
1292 }
1293 petPhotoPreview.value = ''
1294 petPhotoFile.value = null
1295
1296 const input = document.getElementById('petPhoto') as HTMLInputElement | null
1297 if (input) {
1298 input.value = ''
1299 }
1300}
1301
1302function handlePetPhotoChange(event: Event) {
1303 const input = event.target as HTMLInputElement
1304 const file = input.files?.[0] || null
1305
1306 if (!file) {
1307 clearPetPhoto()
1308 return
1309 }
1310
1311 const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
1312 if (!allowedTypes.includes(file.type)) {
1313 errorMessage.value = 'Pet photo must be a JPG, PNG, WEBP, or GIF image'
1314 clearPetPhoto()
1315 return
1316 }
1317
1318 if (file.size > 5 * 1024 * 1024) {
1319 errorMessage.value = 'Pet photo must be 5MB or smaller'
1320 clearPetPhoto()
1321 return
1322 }
1323
1324 if (petPhotoPreview.value) {
1325 URL.revokeObjectURL(petPhotoPreview.value)
1326 }
1327 errorMessage.value = ''
1328 petPhotoFile.value = file
1329 petPhotoPreview.value = URL.createObjectURL(file)
1330}
1331
1332function resetPetForm() {
1333 newPet.value = {
1334 name: '',
1335 sex: '',
1336 dateOfBirth: '',
1337 type: 'PET',
1338 species: '',
1339 breed: '',
1340 locatedName: '',
1341 }
1342 clearPetPhoto()
1343 errorMessage.value = ''
1344}
1345
1346async function openAddPetForm() {
1347 showAddPetForm.value = true
1348 await nextTick()
1349 addPetPanel.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
1350}
1351
1352function hideAddPetForm() {
1353 resetPetForm()
1354 showAddPetForm.value = false
1355}
1356
1357async function submitPet() {
1358 if (!auth.user?.userId || !newPet.value.name || !newPet.value.sex || !newPet.value.type || !newPet.value.species) {
1359 errorMessage.value = 'Please fill in pet name, sex, type, and species'
1360 return
1361 }
1362
1363 try {
1364 isPetSubmitting.value = true
1365 errorMessage.value = ''
1366
1367 const petPayload = {
1368 name: newPet.value.name,
1369 sex: newPet.value.sex,
1370 dateOfBirth: newPet.value.dateOfBirth || undefined,
1371 photo: petPhotoFile.value || undefined,
1372 type: newPet.value.type,
1373 species: newPet.value.species,
1374 breed: newPet.value.breed || undefined,
1375 locatedName: newPet.value.locatedName || undefined,
1376 }
1377
1378 console.log('📤 Sending pet payload:', petPayload)
1379 console.log('User ID:', auth.user.userId)
1380
1381 await createPet(auth.user.userId, petPayload)
1382
1383 // Reload pets and promote user to owner if needed
1384 await loadPets()
1385 resetPetForm()
1386 showAddPetForm.value = false
1387 activeTab.value = 'pets'
1388 } catch (error) {
1389 errorMessage.value = error instanceof Error ? error.message : 'Failed to add pet'
1390 } finally {
1391 isPetSubmitting.value = false
1392 }
1393}
1394
1395async function submitAppointment() {
1396 if (!auth.user?.userId || !newAppointment.value.animalId || !newAppointment.value.clinicId || !newAppointment.value.dateTime) {
1397 appointmentError.value = 'Please fill in clinic, pet, and date/time'
1398 return
1399 }
1400
1401 try {
1402 isAppointmentSubmitting.value = true
1403 appointmentError.value = ''
1404
1405 await createAppointment(auth.user.userId, {
1406 clinicId: newAppointment.value.clinicId,
1407 animalId: newAppointment.value.animalId,
1408 dateTime: newAppointment.value.dateTime,
1409 notes: newAppointment.value.notes || undefined,
1410 })
1411
1412 await loadAppointments()
1413 resetAppointmentForm()
1414 activeTab.value = 'pets'
1415 } catch (error) {
1416 appointmentError.value = error instanceof Error ? error.message : 'Failed to create appointment'
1417 } finally {
1418 isAppointmentSubmitting.value = false
1419 }
1420}
1421
1422async function loadFavorites() {
1423 if (!auth.user?.userId) return
1424 try {
1425 isLoading.value = true
1426 const favoritesData = await getFavoritedListings(auth.user.userId)
1427
1428 // Fetch pet images for each favorite listing
1429 const favoritesWithImages = await Promise.all(
1430 favoritesData.map(async (listing) => {
1431 try {
1432 if (listing.animalId) {
1433 const pet = await getPet(listing.animalId)
1434 petDetailsCache.value.set(listing.animalId, pet)
1435 return {
1436 ...listing,
1437 imageUrl: pet.photoUrl || new URL('../img/all_outline.png', import.meta.url).href,
1438 }
1439 }
1440 } catch (err) {
1441 console.error(`Failed to fetch pet ${listing.animalId}:`, err)
1442 }
1443 return {
1444 ...listing,
1445 imageUrl: new URL('../img/all_outline.png', import.meta.url).href,
1446 }
1447 })
1448 )
1449
1450 favorites.value = favoritesWithImages
1451 } catch (error) {
1452 console.error('Failed to load favorites:', error)
1453 favorites.value = []
1454 } finally {
1455 isLoading.value = false
1456 }
1457}
1458
1459async function removeFavorite(listingId: number) {
1460 if (!auth.user?.userId) return
1461
1462 try {
1463 await removeFavoriteAPI(auth.user.userId, listingId)
1464 await loadFavorites()
1465 } catch (error) {
1466 errorMessage.value = error instanceof Error ? error.message : 'Failed to remove favorite'
1467 }
1468}
1469
1470function getFavoriteListingImage(listing: any): string {
1471 return listing.imageUrl || new URL('../img/all_outline.png', import.meta.url).href
1472}
1473
1474function handleFavoriteImageError(e: Event) {
1475 const img = e.target as HTMLImageElement
1476 img.src = new URL('../img/all_outline.png', import.meta.url).href
1477}
1478
1479function goToListing(listingId: number) {
1480 router.push({ name: 'listing-details', params: { id: listingId } })
1481}
1482
1483
1484async function loadUserVerification() {
1485 if (!auth.user?.userId) return
1486
1487 try {
1488 const isVerified = await loadUserVerificationStatus(auth.user.userId)
1489 if (auth.user) {
1490 auth.user.verified = isVerified
1491 }
1492 console.log(`✅ User verification status loaded: ${isVerified}`)
1493 } catch (error) {
1494 console.error('Failed to load user verification status:', error)
1495 }
1496}
1497
1498onMounted(() => {
1499 if (!auth.isAuthenticated) {
1500 router.push('/login')
1501 return
1502 }
1503
1504 loadListings()
1505 loadPets()
1506 loadFavorites()
1507 loadUserVerification()
1508 loadClinics()
1509 loadAppointments()
1510
1511})
1512
1513onBeforeUnmount(() => {
1514 if (petPhotoPreview.value) {
1515 URL.revokeObjectURL(petPhotoPreview.value)
1516 }
1517})
1518
1519
1520watch([() => newAppointment.value.clinicId, appointmentDate], () => {
1521 loadAvailableSlots()
1522})
1523</script>
1524
1525<style scoped>
1526.profile-container {
1527 background: linear-gradient(135deg, #f5f7fa 0%, #f0f3f8 100%);
1528 min-height: 100vh;
1529 padding-bottom: 60px;
1530}
1531
1532/* Header Section */
1533.header-section {
1534 background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
1535 padding: 40px 0;
1536 margin-bottom: 40px;
1537 box-shadow: 0 10px 30px rgba(249, 115, 22, 0.2);
1538}
1539
1540.profile-card {
1541 background: white;
1542 border-radius: 16px;
1543 padding: 30px;
1544 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
1545}
1546
1547.profile-content {
1548 display: flex;
1549 justify-content: space-between;
1550 align-items: flex-start;
1551 gap: 30px;
1552}
1553
1554.profile-info {
1555 flex: 1;
1556}
1557
1558.profile-name {
1559 font-size: 2.5rem;
1560 font-weight: 700;
1561 color: #1a202c;
1562 margin: 0 0 12px 0;
1563 letter-spacing: -0.5px;
1564}
1565
1566.profile-username {
1567 font-size: 1.1rem;
1568 color: #718096;
1569 margin: 0 0 8px 0;
1570 font-weight: 500;
1571}
1572
1573.profile-email {
1574 font-size: 1rem;
1575 color: #4a5568;
1576 margin: 0;
1577 display: flex;
1578 align-items: center;
1579 gap: 8px;
1580}
1581
1582.profile-badge {
1583 display: flex;
1584 align-items: center;
1585}
1586
1587.profile-badge .badge {
1588 font-size: 0.95rem;
1589 padding: 8px 16px;
1590 border-radius: 8px;
1591}
1592
1593.verified-badge {
1594 background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
1595 color: white;
1596 padding: 6px 14px;
1597 border-radius: 20px;
1598 font-size: 0.85rem;
1599 font-weight: 600;
1600 white-space: nowrap;
1601 letter-spacing: 0.5px;
1602 display: flex;
1603 align-items: center;
1604 gap: 6px;
1605 box-shadow: 0 2px 8px rgba(245, 158, 11, 0.3);
1606}
1607
1608.badge-star {
1609 width: 18px;
1610 height: 18px;
1611 object-fit: contain;
1612 filter: brightness(0) invert(1);
1613}
1614
1615.badge-star {
1616 width: 18px;
1617 height: 18px;
1618 object-fit: contain;
1619 filter: brightness(0) invert(1);
1620}
1621
1622/* Main Content */
1623.main-content {
1624 padding: 0;
1625}
1626
1627.tabs-container {
1628 background: white;
1629 border-radius: 12px;
1630 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
1631 overflow: hidden;
1632}
1633
1634/* Tabs */
1635.nav-tabs {
1636 border-bottom: 2px solid #e2e8f0;
1637 background: #f7fafc;
1638 padding: 0;
1639 margin: 0;
1640}
1641
1642.nav-tabs .nav-link {
1643 color: #718096;
1644 border: none;
1645 border-bottom: 3px solid transparent;
1646 font-weight: 600;
1647 padding: 16px 24px;
1648 transition: all 0.3s ease;
1649 display: flex;
1650 align-items: center;
1651 gap: 8px;
1652 font-size: 0.95rem;
1653}
1654
1655.nav-tabs .nav-link:hover {
1656 color: #2d3748;
1657 background: #edf2f7;
1658}
1659
1660.nav-tabs .nav-link.active {
1661 color: #f97316;
1662 border-bottom-color: #f97316;
1663 background: white;
1664}
1665
1666/* Tab Content */
1667.tab-content-section {
1668 padding: 40px;
1669 animation: fadeIn 0.3s ease-in;
1670}
1671
1672@keyframes fadeIn {
1673 from {
1674 opacity: 0;
1675 transform: translateY(10px);
1676 }
1677 to {
1678 opacity: 1;
1679 transform: translateY(0);
1680 }
1681}
1682
1683.section-title {
1684 font-size: 1.8rem;
1685 font-weight: 700;
1686 color: #1a202c;
1687 margin: 0 0 30px 0;
1688 letter-spacing: -0.5px;
1689}
1690
1691.section-header-row {
1692 display: flex;
1693 align-items: center;
1694 justify-content: space-between;
1695 gap: 16px;
1696 margin-bottom: 30px;
1697}
1698
1699.section-header-row .section-title {
1700 margin-bottom: 0;
1701}
1702
1703.add-pet-panel {
1704 border-top: 1px solid #e2e8f0;
1705 padding-top: 32px;
1706}
1707
1708/* Empty State */
1709.empty-state {
1710 text-align: center;
1711 padding: 60px 40px;
1712 background: linear-gradient(135deg, #f5f7fa 0%, #f0f3f8 100%);
1713 border-radius: 12px;
1714 border: 2px dashed #cbd5e0;
1715}
1716
1717.empty-icon {
1718 font-size: 4rem;
1719 margin-bottom: 16px;
1720}
1721
1722.empty-icon-img {
1723 width: 120px;
1724 height: 120px;
1725 object-fit: contain;
1726 margin-bottom: 16px;
1727}
1728
1729.empty-text {
1730 font-size: 1.2rem;
1731 color: #2d3748;
1732 margin: 0 0 8px 0;
1733 font-weight: 600;
1734}
1735
1736.empty-subtext {
1737 font-size: 0.95rem;
1738 color: #718096;
1739 margin: 0 0 20px 0;
1740}
1741
1742/* Grid Container */
1743.grid-container {
1744 display: grid;
1745 grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
1746 gap: 24px;
1747}
1748
1749/* Listing Card */
1750.listing-card-wrapper {
1751 height: 100%;
1752}
1753
1754.listing-card {
1755 background: white;
1756 border: 1px solid #e2e8f0;
1757 border-radius: 12px;
1758 padding: 24px;
1759 height: 100%;
1760 display: flex;
1761 flex-direction: column;
1762 gap: 16px;
1763 transition: all 0.3s ease;
1764 position: relative;
1765}
1766
1767.listing-card:hover {
1768 border-color: #f97316;
1769 box-shadow: 0 8px 24px rgba(249, 115, 22, 0.15);
1770 transform: translateY(-4px);
1771}
1772
1773.favorite-listing .listing-description {
1774 padding: 0 20px;
1775}
1776
1777.favorite-listing .listing-footer {
1778 padding: 12px 20px;
1779}
1780
1781.favorite-listing .btn {
1782 margin: 0 20px 20px 20px;
1783}
1784
1785.favorite-badge {
1786 position: absolute;
1787 top: 12px;
1788 right: 12px;
1789 background: #f56565;
1790 color: white;
1791 width: 32px;
1792 height: 32px;
1793 border-radius: 50%;
1794 display: flex;
1795 align-items: center;
1796 justify-content: center;
1797 font-size: 1rem;
1798}
1799
1800.listing-header {
1801 display: flex;
1802 justify-content: space-between;
1803 align-items: flex-start;
1804 gap: 12px;
1805}
1806
1807.listing-title {
1808 font-size: 1.1rem;
1809 font-weight: 700;
1810 color: #1a202c;
1811 margin: 0;
1812}
1813
1814.listing-description {
1815 color: #4a5568;
1816 font-size: 0.95rem;
1817 line-height: 1.6;
1818 margin: 0;
1819 flex: 1;
1820 overflow: hidden;
1821 text-overflow: ellipsis;
1822 display: -webkit-box;
1823 -webkit-line-clamp: 2;
1824 -webkit-box-orient: vertical;
1825}
1826
1827.listing-footer {
1828 display: flex;
1829 justify-content: space-between;
1830 align-items: center;
1831 padding-top: 12px;
1832 border-top: 1px solid #e2e8f0;
1833}
1834
1835.listing-price {
1836 font-size: 1.3rem;
1837 font-weight: 700;
1838 color: #f97316;
1839}
1840
1841.listing-date {
1842 color: #a0aec0;
1843 font-size: 0.85rem;
1844}
1845
1846.listing-actions {
1847 display: flex;
1848 gap: 8px;
1849 margin-top: auto;
1850}
1851
1852.listing-actions .form-select {
1853 flex: 1;
1854}
1855
1856/* Pet Card */
1857.pet-card-wrapper {
1858 height: 100%;
1859}
1860
1861.pet-card {
1862 background: white;
1863 border: 1px solid #e2e8f0;
1864 border-radius: 12px;
1865 padding: 0;
1866 height: 100%;
1867 display: flex;
1868 flex-direction: column;
1869 gap: 16px;
1870 transition: all 0.3s ease;
1871 overflow: hidden;
1872}
1873
1874.pet-card:hover {
1875 border-color: #f97316;
1876 box-shadow: 0 8px 24px rgba(249, 115, 22, 0.15);
1877 transform: translateY(-4px);
1878}
1879
1880.pet-image-wrapper {
1881 width: 100%;
1882 height: 220px;
1883 background: linear-gradient(135deg, #f5f7fa 0%, #f0f3f8 100%);
1884 display: flex;
1885 align-items: center;
1886 justify-content: center;
1887 overflow: hidden;
1888}
1889
1890.pet-image {
1891 width: 100%;
1892 height: 100%;
1893 object-fit: cover;
1894}
1895
1896.pet-image-placeholder-img {
1897 width: 100%;
1898 height: 100%;
1899 object-fit: cover;
1900}
1901
1902.pet-header {
1903 border-bottom: none;
1904 padding: 0 20px 0 20px;
1905 padding-top: 16px;
1906}
1907
1908.pet-name {
1909 font-size: 1.3rem;
1910 font-weight: 700;
1911 color: #1a202c;
1912 margin: 0;
1913 line-height: 1.4;
1914}
1915
1916.pet-details {
1917 display: flex;
1918 flex-direction: column;
1919 gap: 12px;
1920 flex: 1;
1921 padding: 0 20px;
1922}
1923
1924.pet-detail-row {
1925 display: flex;
1926 justify-content: space-between;
1927 align-items: center;
1928 padding: 8px 0;
1929 border-bottom: none;
1930 font-size: 0.9rem;
1931}
1932
1933.pet-detail-row:last-child {
1934 border-bottom: none;
1935}
1936
1937.pet-detail-row .label {
1938 color: #718096;
1939 font-weight: 600;
1940 text-transform: capitalize;
1941}
1942
1943.pet-detail-row .value {
1944 color: #2d3748;
1945 font-weight: 500;
1946}
1947
1948.pet-card .btn {
1949 margin: 0 20px 20px 20px;
1950}
1951
1952/* Form Card */
1953.form-card {
1954 background: white;
1955 border: 1px solid #e2e8f0;
1956 border-radius: 12px;
1957 padding: 32px;
1958 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
1959}
1960
1961.form-row {
1962 display: grid;
1963 grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
1964 gap: 24px;
1965}
1966
1967.form-group {
1968 display: flex;
1969 flex-direction: column;
1970 gap: 8px;
1971}
1972
1973.form-label {
1974 font-weight: 600;
1975 color: #2d3748;
1976 font-size: 0.95rem;
1977}
1978
1979.form-control,
1980.form-select {
1981 border: 1.5px solid #e2e8f0;
1982 border-radius: 8px;
1983 padding: 10px 12px;
1984 font-size: 0.95rem;
1985 transition: all 0.2s ease;
1986}
1987
1988.form-control:focus,
1989.form-select:focus {
1990 border-color: #f97316;
1991 box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.1);
1992}
1993
1994.pet-photo-preview {
1995 display: flex;
1996 align-items: center;
1997 gap: 16px;
1998 flex-wrap: wrap;
1999 margin-top: 8px;
2000}
2001
2002.pet-photo-preview img {
2003 width: 120px;
2004 height: 120px;
2005 border-radius: 8px;
2006 border: 1px solid #e2e8f0;
2007 object-fit: cover;
2008}
2009
2010.form-actions {
2011 display: flex;
2012 gap: 12px;
2013 margin-top: 32px;
2014 padding-top: 24px;
2015 border-top: 1px solid #e2e8f0;
2016}
2017
2018/* Buttons */
2019.btn {
2020 border-radius: 8px;
2021 font-weight: 600;
2022 padding: 10px 20px;
2023 transition: all 0.2s ease;
2024 font-size: 0.95rem;
2025 border: none;
2026}
2027
2028.btn-primary {
2029 background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
2030 border: none;
2031 color: white;
2032}
2033
2034.btn-primary:hover:not(:disabled) {
2035 transform: translateY(-2px);
2036 box-shadow: 0 8px 20px rgba(249, 115, 22, 0.3);
2037}
2038
2039.btn-outline-secondary {
2040 border: 1.5px solid #cbd5e0;
2041 color: #4a5568;
2042 background: transparent;
2043}
2044
2045.btn-outline-secondary:hover {
2046 border-color: #2d3748;
2047 background: #edf2f7;
2048}
2049
2050.btn-outline-danger {
2051 border: 1.5px solid #f56565;
2052 color: #f56565;
2053}
2054
2055.btn-outline-danger:hover {
2056 background: #fff5f5;
2057 border-color: #e53e3e;
2058}
2059
2060.btn-sm {
2061 padding: 6px 12px;
2062 font-size: 0.85rem;
2063}
2064
2065.btn:disabled {
2066 opacity: 0.5;
2067 cursor: not-allowed;
2068}
2069
2070/* Favorite Listing Card */
2071.favorite-listing {
2072 border: none;
2073 padding: 0;
2074 overflow: hidden;
2075}
2076
2077.appointments-day-section {
2078 margin: 24px 0 32px;
2079}
2080
2081.appointments-day-title {
2082 font-size: 1.1rem;
2083 font-weight: 700;
2084 margin-bottom: 12px;
2085 color: #1a202c;
2086}
2087
2088.appointments-list {
2089 display: grid;
2090 gap: 12px;
2091}
2092
2093.appointment-card {
2094 background: #fff;
2095 border: 1px solid #e2e8f0;
2096 border-radius: 10px;
2097 padding: 16px;
2098 display: flex;
2099 flex-direction: column;
2100 gap: 8px;
2101}
2102
2103.appointment-header {
2104 display: flex;
2105 align-items: center;
2106 justify-content: space-between;
2107 gap: 12px;
2108}
2109
2110.appointment-title {
2111 font-weight: 700;
2112 color: #1a202c;
2113}
2114
2115.appointment-meta {
2116 color: #4a5568;
2117 font-size: 0.9rem;
2118 display: flex;
2119 flex-direction: column;
2120 gap: 4px;
2121}
2122
2123.appointment-notes {
2124 font-size: 0.9rem;
2125 color: #2d3748;
2126 background: #f7fafc;
2127 padding: 8px 12px;
2128 border-radius: 8px;
2129}
2130
2131.appointment-inline-error {
2132 margin: 4px 0 0;
2133}
2134
2135.appointment-actions {
2136 display: flex;
2137 flex-wrap: wrap;
2138 gap: 8px;
2139}
2140
2141.clinic-review-section {
2142 border-top: 1px solid #e2e8f0;
2143 margin-top: 6px;
2144 padding-top: 12px;
2145}
2146
2147.health-record-section {
2148 border-top: 1px solid #e2e8f0;
2149 margin-top: 6px;
2150 padding-top: 12px;
2151}
2152
2153.health-record-summary {
2154 background: #f0fdf4;
2155 border: 1px solid #bbf7d0;
2156 border-radius: 8px;
2157 color: #166534;
2158 padding: 12px;
2159}
2160
2161.health-record-summary p {
2162 color: #2f6f45;
2163 margin: 4px 0;
2164}
2165
2166.health-record-form {
2167 display: flex;
2168 flex-direction: column;
2169 gap: 10px;
2170}
2171
2172.clinic-review-summary {
2173 display: flex;
2174 align-items: flex-start;
2175 justify-content: space-between;
2176 gap: 16px;
2177}
2178
2179.clinic-review-stars {
2180 color: #cbd5e0;
2181 font-size: 1.15rem;
2182 line-height: 1;
2183}
2184
2185.clinic-review-stars .active,
2186.clinic-review-stars.editable button.active {
2187 color: #f97316;
2188}
2189
2190.clinic-review-stars.editable {
2191 display: flex;
2192 gap: 4px;
2193}
2194
2195.clinic-review-stars.editable button {
2196 background: transparent;
2197 border: 0;
2198 color: #cbd5e0;
2199 cursor: pointer;
2200 font-size: 1.4rem;
2201 line-height: 1;
2202 padding: 0 2px;
2203}
2204
2205.clinic-review-comment {
2206 color: #4a5568;
2207 font-size: 0.92rem;
2208 margin: 6px 0 0;
2209}
2210
2211.clinic-review-form {
2212 display: flex;
2213 flex-direction: column;
2214 gap: 10px;
2215}
2216
2217.clinic-review-actions {
2218 display: flex;
2219 flex-wrap: wrap;
2220 gap: 8px;
2221}
2222
2223.appointments-empty {
2224 padding: 24px;
2225}
2226
2227/* Badges */
2228.badge {
2229 border-radius: 6px;
2230 font-size: 0.75rem;
2231 font-weight: 700;
2232 padding: 4px 12px;
2233 text-transform: uppercase;
2234 letter-spacing: 0.5px;
2235}
2236
2237.bg-success {
2238 background: linear-gradient(135deg, #48bb78 0%, #38a169 100%);
2239}
2240
2241.bg-danger {
2242 background: linear-gradient(135deg, #f56565 0%, #e53e3e 100%);
2243}
2244
2245.bg-secondary {
2246 background: linear-gradient(135deg, #cbd5e0 0%, #a0aec0 100%);
2247}
2248
2249/* Responsive */
2250@media (max-width: 768px) {
2251 .header-section {
2252 padding: 24px 0;
2253 }
2254
2255 .profile-card {
2256 padding: 20px;
2257 }
2258
2259 .profile-content {
2260 flex-direction: column;
2261 }
2262
2263 .profile-name {
2264 font-size: 1.75rem;
2265 }
2266
2267 .nav-tabs .nav-link {
2268 padding: 12px 16px;
2269 font-size: 0.85rem;
2270 }
2271
2272 .tab-content-section {
2273 padding: 24px;
2274 }
2275
2276 .section-title {
2277 font-size: 1.4rem;
2278 margin-bottom: 20px;
2279 }
2280
2281 .section-header-row {
2282 align-items: stretch;
2283 flex-direction: column;
2284 }
2285
2286 .grid-container {
2287 grid-template-columns: 1fr;
2288 }
2289
2290 .form-row {
2291 grid-template-columns: 1fr;
2292 }
2293
2294 .form-card {
2295 padding: 20px;
2296 }
2297
2298 .listing-actions {
2299 flex-direction: column;
2300 }
2301
2302 .listing-actions .form-select {
2303 width: 100%;
2304 }
2305}
2306
2307@media (max-width: 576px) {
2308 .profile-name {
2309 font-size: 1.4rem;
2310 }
2311
2312 .section-title {
2313 font-size: 1.2rem;
2314 }
2315
2316 .nav-tabs {
2317 flex-wrap: wrap;
2318 }
2319
2320 .nav-tabs .nav-link {
2321 flex: 1;
2322 padding: 8px 12px;
2323 font-size: 0.8rem;
2324 }
2325
2326 .tab-content-section {
2327 padding: 16px;
2328 }
2329
2330 .empty-state {
2331 padding: 40px 20px;
2332 }
2333
2334 .empty-icon {
2335 font-size: 2.5rem;
2336 }
2337
2338 .form-actions {
2339 flex-direction: column;
2340 }
2341
2342 .form-actions .btn {
2343 width: 100%;
2344 }
2345}
2346
2347/* Admin Panel Styles */
2348.admin-panel {
2349 display: flex;
2350 flex-direction: column;
2351 gap: 30px;
2352}
2353
2354.admin-section {
2355 background: white;
2356 border-radius: 12px;
2357 padding: 24px;
2358 border: 1px solid #e2e8f0;
2359}
2360
2361.admin-subtitle {
2362 font-size: 1.25rem;
2363 font-weight: 700;
2364 color: #1a202c;
2365 margin-bottom: 20px;
2366 display: flex;
2367 align-items: center;
2368 gap: 8px;
2369}
2370
2371.admin-section-header,
2372.admin-pagination {
2373 display: flex;
2374 align-items: center;
2375 justify-content: space-between;
2376 gap: 16px;
2377}
2378
2379.admin-section-header {
2380 margin-bottom: 16px;
2381}
2382
2383.admin-section-header .admin-subtitle {
2384 margin-bottom: 4px;
2385}
2386
2387.admin-page-meta {
2388 color: #718096;
2389 font-size: 0.9rem;
2390 margin: 0;
2391}
2392
2393.admin-pagination {
2394 border-top: 1px solid #e2e8f0;
2395 color: #4a5568;
2396 margin-top: 16px;
2397 padding-top: 16px;
2398}
2399
2400.stats-grid {
2401 display: grid;
2402 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
2403 gap: 16px;
2404}
2405
2406.stat-card {
2407 background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
2408 color: white;
2409 padding: 20px;
2410 border-radius: 10px;
2411 text-align: center;
2412 box-shadow: 0 4px 12px rgba(249, 115, 22, 0.2);
2413}
2414
2415.stat-value {
2416 font-size: 2.5rem;
2417 font-weight: 700;
2418 margin-bottom: 8px;
2419}
2420
2421.stat-label {
2422 font-size: 0.9rem;
2423 opacity: 0.9;
2424}
2425
2426.admin-table-container {
2427 overflow-x: auto;
2428}
2429
2430.admin-table {
2431 width: 100%;
2432 border-collapse: collapse;
2433 font-size: 0.95rem;
2434}
2435
2436.admin-table thead {
2437 background: #f7fafc;
2438 border-bottom: 2px solid #e2e8f0;
2439}
2440
2441.admin-table thead th {
2442 padding: 12px 16px;
2443 text-align: left;
2444 font-weight: 600;
2445 color: #2d3748;
2446}
2447
2448.admin-table tbody td {
2449 padding: 12px 16px;
2450 border-bottom: 1px solid #e2e8f0;
2451 color: #4a5568;
2452}
2453
2454.admin-table tbody tr:hover {
2455 background: #f7fafc;
2456}
2457
2458.owner-info {
2459 display: flex;
2460 flex-direction: column;
2461 gap: 4px;
2462}
2463
2464.owner-name {
2465 font-weight: 600;
2466 color: #2d3748;
2467}
2468
2469.owner-username {
2470 font-size: 0.85rem;
2471 color: #718096;
2472}
2473
2474.badge {
2475 display: inline-block;
2476 padding: 4px 8px;
2477 border-radius: 4px;
2478 font-size: 0.8rem;
2479 font-weight: 600;
2480 text-transform: uppercase;
2481}
2482
2483.bg-success {
2484 background-color: #c6f6d5;
2485 color: #22543d;
2486}
2487
2488.bg-danger {
2489 background-color: #fed7d7;
2490 color: #742a2a;
2491}
2492
2493.bg-primary {
2494 background-color: #bee3f8;
2495 color: #2c5282;
2496}
2497
2498.bg-info {
2499 background-color: #b2e0d8;
2500 color: #234e52;
2501}
2502
2503.bg-secondary {
2504 background-color: #cbd5e0;
2505 color: #2d3748;
2506}
2507
2508@media (max-width: 768px) {
2509 .stats-grid {
2510 grid-template-columns: 1fr 1fr;
2511 }
2512
2513 .admin-table {
2514 font-size: 0.85rem;
2515 }
2516
2517 .admin-table thead th,
2518 .admin-table tbody td {
2519 padding: 8px 12px;
2520 }
2521}
2522</style>
Note: See TracBrowser for help on using the repository browser.