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

Last change on this file since ae83647 was ae83647, checked in by veronika-ils <ilioskaveronika@…>, 2 days ago

add functionality so that users can change passwords

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