Fixes for appointments and My Pets fields.
This commit is contained in:
@@ -87,10 +87,28 @@ public class AdoptionController {
|
||||
public ResponseEntity<AdoptionResponse> requestAdoption(@Valid @RequestBody CustomerAdoptionRequest request) {
|
||||
User user = AuthenticationHelper.getAuthenticatedUser(userRepository);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(
|
||||
adoptionService.requestAdoption(user.getId(), request.getPetId(), request.getEmployeeId(), request.getSourceStoreId())
|
||||
adoptionService.requestAdoption(user.getId(), request.getPetId(), request.getEmployeeId(), request.getSourceStoreId(), request.getAdoptionDate())
|
||||
);
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}/cancel")
|
||||
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
|
||||
public ResponseEntity<AdoptionResponse> cancelAdoption(@PathVariable Long id) {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
String role = authentication.getAuthorities().stream()
|
||||
.findFirst()
|
||||
.map(authority -> authority.getAuthority().replace("ROLE_", ""))
|
||||
.orElse(null);
|
||||
|
||||
Long customerId = null;
|
||||
if ("CUSTOMER".equals(role)) {
|
||||
User user = AuthenticationHelper.getAuthenticatedUser(userRepository);
|
||||
customerId = user.getId();
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(adoptionService.cancelAdoption(id, customerId));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
|
||||
public ResponseEntity<AdoptionResponse> updateAdoption(
|
||||
|
||||
@@ -98,6 +98,24 @@ public class AppointmentController {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(appointmentService.createAppointment(request));
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}/cancel")
|
||||
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
|
||||
public ResponseEntity<AppointmentResponse> cancelAppointment(@PathVariable Long id) {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
String role = authentication.getAuthorities().stream()
|
||||
.findFirst()
|
||||
.map(authority -> authority.getAuthority().replace("ROLE_", ""))
|
||||
.orElse(null);
|
||||
|
||||
Long customerId = null;
|
||||
if ("CUSTOMER".equals(role)) {
|
||||
User user = AuthenticationHelper.getAuthenticatedUser(userRepository);
|
||||
customerId = user.getId();
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(appointmentService.cancelAppointment(id, customerId));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
|
||||
public ResponseEntity<AppointmentResponse> updateAppointment(
|
||||
|
||||
@@ -38,8 +38,8 @@ public class MyPetController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<MyPetResponse>> getMyPets() {
|
||||
return ResponseEntity.ok(petService.getMyPets(currentUserId()));
|
||||
public ResponseEntity<List<MyPetResponse>> getMyPets(@RequestParam(required = false) String status) {
|
||||
return ResponseEntity.ok(petService.getMyPets(currentUserId(), status));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.petshop.backend.dto.adoption;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public class CustomerAdoptionRequest {
|
||||
|
||||
@@ -11,6 +12,9 @@ public class CustomerAdoptionRequest {
|
||||
|
||||
private Long sourceStoreId;
|
||||
|
||||
@NotNull(message = "Appointment date is required")
|
||||
private LocalDate adoptionDate;
|
||||
|
||||
public Long getPetId() {
|
||||
return petId;
|
||||
}
|
||||
@@ -34,4 +38,12 @@ public class CustomerAdoptionRequest {
|
||||
public void setSourceStoreId(Long sourceStoreId) {
|
||||
this.sourceStoreId = sourceStoreId;
|
||||
}
|
||||
|
||||
public LocalDate getAdoptionDate() {
|
||||
return adoptionDate;
|
||||
}
|
||||
|
||||
public void setAdoptionDate(LocalDate adoptionDate) {
|
||||
this.adoptionDate = adoptionDate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,16 +7,18 @@ public class MyPetResponse {
|
||||
private String species;
|
||||
private String breed;
|
||||
private String imageUrl;
|
||||
private String petStatus;
|
||||
|
||||
public MyPetResponse() {
|
||||
}
|
||||
|
||||
public MyPetResponse(Long customerPetId, String petName, String species, String breed, String imageUrl) {
|
||||
public MyPetResponse(Long customerPetId, String petName, String species, String breed, String imageUrl, String petStatus) {
|
||||
this.customerPetId = customerPetId;
|
||||
this.petName = petName;
|
||||
this.species = species;
|
||||
this.breed = breed;
|
||||
this.imageUrl = imageUrl;
|
||||
this.petStatus = petStatus;
|
||||
}
|
||||
|
||||
public Long getCustomerPetId() {
|
||||
@@ -58,4 +60,12 @@ public class MyPetResponse {
|
||||
public void setImageUrl(String imageUrl) {
|
||||
this.imageUrl = imageUrl;
|
||||
}
|
||||
|
||||
public String getPetStatus() {
|
||||
return petStatus;
|
||||
}
|
||||
|
||||
public void setPetStatus(String petStatus) {
|
||||
this.petStatus = petStatus;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,4 +50,6 @@ public interface AppointmentRepository extends JpaRepository<Appointment, Long>
|
||||
|
||||
@Query("SELECT a FROM Appointment a WHERE (a.appointmentDate < :currentDate OR (a.appointmentDate = :currentDate AND a.appointmentTime < :currentTime)) AND LOWER(a.appointmentStatus) = 'booked'")
|
||||
List<Appointment> findPastBookedAppointments(@Param("currentDate") LocalDate currentDate, @Param("currentTime") LocalTime currentTime);
|
||||
|
||||
List<Appointment> findByPet_Id(Long petId);
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ public class SecurityConfig {
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
config.setAllowedOriginPatterns(List.of("http://localhost:*", "http://127.0.0.1:*"));
|
||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||
config.setAllowedHeaders(List.of("*"));
|
||||
config.setAllowCredentials(true);
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
|
||||
@@ -148,25 +148,30 @@ public class AdoptionService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdoptionResponse requestAdoption(Long customerId, Long petId, Long employeeId, Long sourceStoreId) {
|
||||
public AdoptionResponse requestAdoption(Long customerId, Long petId, Long employeeId, Long sourceStoreId, LocalDate adoptionDate) {
|
||||
Pet pet = petRepository.findById(petId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + petId));
|
||||
User customer = userRepository.findById(customerId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Customer not found with id: " + customerId));
|
||||
User employee = resolveAdoptionEmployee(employeeId);
|
||||
StoreLocation sourceStore = sourceStoreId != null
|
||||
? storeRepository.findById(sourceStoreId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Store not found with id: " + sourceStoreId))
|
||||
: null;
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Pet not found"));
|
||||
|
||||
// Verify the pet is actually located at the claimed store
|
||||
if (pet.getStore() == null || !pet.getStore().getStoreId().equals(sourceStoreId)) {
|
||||
throw new IllegalArgumentException("The specified pet is not located at the selected store.");
|
||||
}
|
||||
|
||||
// Verify the pet is available for adoption
|
||||
validatePetAvailability(pet, null, null);
|
||||
|
||||
User customer = userRepository.findById(customerId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Customer not found"));
|
||||
User employee = resolveAdoptionEmployee(employeeId);
|
||||
StoreLocation sourceStore = storeRepository.findById(sourceStoreId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Store not found"));
|
||||
|
||||
Adoption adoption = new Adoption();
|
||||
adoption.setPet(pet);
|
||||
adoption.setCustomer(customer);
|
||||
adoption.setEmployee(employee);
|
||||
adoption.setSourceStore(sourceStore);
|
||||
adoption.setAdoptionDate(null);
|
||||
adoption.setAdoptionDate(adoptionDate);
|
||||
adoption.setAdoptionStatus(ADOPTION_STATUS_PENDING);
|
||||
|
||||
adoption = adoptionRepository.save(adoption);
|
||||
@@ -174,6 +179,25 @@ public class AdoptionService {
|
||||
return mapToResponse(adoption);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdoptionResponse cancelAdoption(Long adoptionId, Long requestingCustomerId) {
|
||||
Adoption adoption = adoptionRepository.findById(adoptionId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Adoption not found with id: " + adoptionId));
|
||||
|
||||
if (requestingCustomerId != null && !adoption.getCustomer().getId().equals(requestingCustomerId)) {
|
||||
throw new ResourceNotFoundException("Adoption not found");
|
||||
}
|
||||
|
||||
if (!ADOPTION_STATUS_PENDING.equalsIgnoreCase(adoption.getAdoptionStatus())) {
|
||||
throw new IllegalArgumentException("Only pending adoptions can be cancelled");
|
||||
}
|
||||
|
||||
adoption.setAdoptionStatus(ADOPTION_STATUS_CANCELLED);
|
||||
adoption = adoptionRepository.save(adoption);
|
||||
syncPetStatus(adoption.getPet(), ADOPTION_STATUS_CANCELLED, adoption.getAdoptionId(), adoption.getCustomer());
|
||||
return mapToResponse(adoption);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteAdoption(Long id) {
|
||||
if (!adoptionRepository.existsById(id)) {
|
||||
|
||||
@@ -101,6 +101,22 @@ public class AppointmentService {
|
||||
Pet pet = request.getPetId() != null ? fetchPet(request.getPetId()) : null;
|
||||
User employee = resolveAppointmentEmployee(request.getEmployeeId(), store.getStoreId());
|
||||
|
||||
// Customers must supply a pet that is Adopted and owned by them
|
||||
if (User.Role.CUSTOMER.equals(authenticatedUser.getRole())) {
|
||||
if (pet == null) {
|
||||
throw new IllegalArgumentException("A pet must be selected for your appointment");
|
||||
}
|
||||
if (pet.getOwner() == null || !pet.getOwner().getId().equals(authenticatedUser.getId())) {
|
||||
throw new IllegalArgumentException("The selected pet does not belong to your account");
|
||||
}
|
||||
String petStatus = pet.getPetStatus();
|
||||
if (!"Owned".equalsIgnoreCase(petStatus) && !"Adopted".equalsIgnoreCase(petStatus)) {
|
||||
throw new IllegalArgumentException("Only your own pets can be booked for appointments");
|
||||
}
|
||||
}
|
||||
|
||||
validateSpeciesServiceCompatibility(pet, service);
|
||||
|
||||
validateStoreAccess(store.getStoreId(), authenticatedUser);
|
||||
validateAvailability(employee, service, request.getAppointmentDate(), request.getAppointmentTime(), null);
|
||||
|
||||
@@ -155,6 +171,23 @@ public class AppointmentService {
|
||||
return mapToResponse(appointment);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AppointmentResponse cancelAppointment(Long appointmentId, Long requestingCustomerId) {
|
||||
Appointment appointment = appointmentRepository.findById(appointmentId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Appointment not found with id: " + appointmentId));
|
||||
|
||||
if (requestingCustomerId != null && !appointment.getCustomer().getId().equals(requestingCustomerId)) {
|
||||
throw new ResourceNotFoundException("Appointment not found");
|
||||
}
|
||||
|
||||
if (!"Booked".equalsIgnoreCase(appointment.getAppointmentStatus())) {
|
||||
throw new IllegalArgumentException("Only booked appointments can be cancelled");
|
||||
}
|
||||
|
||||
appointment.setAppointmentStatus("Cancelled");
|
||||
return mapToResponse(appointmentRepository.save(appointment));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteAppointment(Long id) {
|
||||
if (!appointmentRepository.existsById(id)) {
|
||||
@@ -313,6 +346,32 @@ public class AppointmentService {
|
||||
return true;
|
||||
}
|
||||
|
||||
private void validateSpeciesServiceCompatibility(Pet pet, com.petshop.backend.entity.Service service) {
|
||||
if (pet == null || service == null) return;
|
||||
String species = pet.getPetSpecies();
|
||||
if (species == null) return;
|
||||
String serviceName = service.getServiceName().toLowerCase();
|
||||
|
||||
switch (species.toLowerCase()) {
|
||||
case "bird":
|
||||
if (!serviceName.contains("wing clipping") && !serviceName.contains("beak and nail")) {
|
||||
throw new IllegalArgumentException(
|
||||
"Service '" + service.getServiceName() + "' is not available for birds. " +
|
||||
"Allowed services: Wing Clipping, Beak and Nail Care.");
|
||||
}
|
||||
break;
|
||||
case "fish":
|
||||
if (!serviceName.contains("aquarium health")) {
|
||||
throw new IllegalArgumentException(
|
||||
"Service '" + service.getServiceName() + "' is not available for fish. " +
|
||||
"Allowed service: Aquarium Health Check.");
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void validateStoreAccess(Long requestedStoreId, User user) {
|
||||
if (user.getRole() != User.Role.STAFF) {
|
||||
return;
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.petshop.backend.entity.User;
|
||||
import com.petshop.backend.exception.ResourceNotFoundException;
|
||||
import com.petshop.backend.security.AppPrincipal;
|
||||
import com.petshop.backend.repository.AdoptionRepository;
|
||||
import com.petshop.backend.repository.AppointmentRepository;
|
||||
import com.petshop.backend.repository.PetRepository;
|
||||
import com.petshop.backend.repository.StoreRepository;
|
||||
import com.petshop.backend.repository.UserRepository;
|
||||
@@ -35,13 +36,15 @@ public class PetService {
|
||||
|
||||
private final PetRepository petRepository;
|
||||
private final AdoptionRepository adoptionRepository;
|
||||
private final AppointmentRepository appointmentRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final StoreRepository storeRepository;
|
||||
private final CatalogImageStorageService catalogImageStorageService;
|
||||
|
||||
public PetService(PetRepository petRepository, AdoptionRepository adoptionRepository, UserRepository userRepository, StoreRepository storeRepository, CatalogImageStorageService catalogImageStorageService) {
|
||||
public PetService(PetRepository petRepository, AdoptionRepository adoptionRepository, AppointmentRepository appointmentRepository, UserRepository userRepository, StoreRepository storeRepository, CatalogImageStorageService catalogImageStorageService) {
|
||||
this.petRepository = petRepository;
|
||||
this.adoptionRepository = adoptionRepository;
|
||||
this.appointmentRepository = appointmentRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.storeRepository = storeRepository;
|
||||
this.catalogImageStorageService = catalogImageStorageService;
|
||||
@@ -87,8 +90,9 @@ public class PetService {
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<MyPetResponse> getMyPets(Long ownerUserId) {
|
||||
public List<MyPetResponse> getMyPets(Long ownerUserId, String status) {
|
||||
return petRepository.findAllByOwner_IdOrderByPetNameAsc(ownerUserId).stream()
|
||||
.filter(p -> status == null || status.isBlank() || status.equalsIgnoreCase(p.getPetStatus()))
|
||||
.map(this::mapToMyPetResponse)
|
||||
.toList();
|
||||
}
|
||||
@@ -117,6 +121,18 @@ public class PetService {
|
||||
@Transactional
|
||||
public void deleteMyPet(Long ownerUserId, Long petId) {
|
||||
Pet pet = findOwnedPet(ownerUserId, petId);
|
||||
List<com.petshop.backend.entity.Appointment> linkedAppointments = appointmentRepository.findByPet_Id(petId);
|
||||
boolean hasBooked = linkedAppointments.stream()
|
||||
.anyMatch(a -> "Booked".equalsIgnoreCase(a.getAppointmentStatus()));
|
||||
if (hasBooked) {
|
||||
throw new IllegalArgumentException(
|
||||
"Your pet has a booked appointment. Please cancel the appointment before removing your pet from our database.");
|
||||
}
|
||||
// Nullify the pet reference on non-booked appointments to avoid FK constraint violations
|
||||
for (com.petshop.backend.entity.Appointment appt : linkedAppointments) {
|
||||
appt.setPet(null);
|
||||
}
|
||||
appointmentRepository.saveAll(linkedAppointments);
|
||||
deleteStoredImageIfPresent(pet.getImageUrl());
|
||||
petRepository.delete(pet);
|
||||
}
|
||||
@@ -341,7 +357,8 @@ public class PetService {
|
||||
pet.getPetName(),
|
||||
pet.getPetSpecies(),
|
||||
pet.getPetBreed(),
|
||||
pet.getImageUrl() != null && !pet.getImageUrl().isBlank() ? "/api/v1/pets/" + pet.getPetId() + "/image" : null
|
||||
pet.getImageUrl() != null && !pet.getImageUrl().isBlank() ? "/api/v1/pets/" + pet.getPetId() + "/image" : null,
|
||||
pet.getPetStatus()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,34 @@ import { useAuth } from "@/context/AuthContext";
|
||||
|
||||
const API_BASE = "";
|
||||
|
||||
const SPECIES_BREEDS = {
|
||||
Dog: ["Beagle", "Boxer", "Bulldog", "Chihuahua", "Dachshund", "German Shepherd", "Golden Retriever", "Labrador Retriever", "Poodle", "Rottweiler", "Shih Tzu", "Siberian Husky", "Yorkshire Terrier", "Mixed / Other"],
|
||||
Cat: ["Abyssinian", "Bengal", "British Shorthair", "Maine Coon", "Persian", "Ragdoll", "Scottish Fold", "Siamese", "Sphynx", "Mixed / Other"],
|
||||
Bird: ["Canary", "Cockatiel", "Cockatoo", "Finch", "Lovebird", "Macaw", "Parakeet", "Parrot", "Other"],
|
||||
Rabbit: ["Dutch", "Flemish Giant", "Holland Lop", "Lionhead", "Mini Rex", "Other"],
|
||||
Hamster: ["Dwarf", "Roborovski", "Syrian", "Other"],
|
||||
"Guinea Pig": ["Abyssinian", "American", "Peruvian", "Teddy", "Other"],
|
||||
Reptile: ["Ball Python", "Bearded Dragon", "Blue-tongued Skink", "Corn Snake", "Leopard Gecko", "Other"],
|
||||
Fish: ["Angelfish", "Betta", "Cichlid", "Clownfish", "Goldfish", "Guppy", "Tetra", "Other"],
|
||||
Other: ["Other"],
|
||||
};
|
||||
|
||||
// Explicit allowlists for species with restricted service availability.
|
||||
// Species not listed here may use all services.
|
||||
const SPECIES_SERVICE_ALLOWLIST = {
|
||||
Bird: ["wing clipping", "beak and nail"],
|
||||
Fish: ["aquarium health"],
|
||||
};
|
||||
|
||||
function getAvailableServices(services, species) {
|
||||
if (!species) return services;
|
||||
const allowlist = SPECIES_SERVICE_ALLOWLIST[species];
|
||||
if (!allowlist) return services;
|
||||
return services.filter((s) =>
|
||||
allowlist.some((kw) => s.serviceName.toLowerCase().includes(kw))
|
||||
);
|
||||
}
|
||||
|
||||
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December",
|
||||
];
|
||||
@@ -254,26 +282,32 @@ function AddPetModal({ token, onClose, onAdded }) {
|
||||
</label>
|
||||
<label className="appt-label">
|
||||
Species
|
||||
<input
|
||||
className="appt-input"
|
||||
type="text"
|
||||
<select
|
||||
className="appt-select"
|
||||
value={species}
|
||||
onChange={(e) => setSpecies(e.target.value)}
|
||||
onChange={(e) => { setSpecies(e.target.value); setBreed(""); }}
|
||||
required
|
||||
maxLength={50}
|
||||
placeholder="e.g. Dog, Cat, Bird"
|
||||
/>
|
||||
>
|
||||
<option value="">Select a species...</option>
|
||||
{Object.keys(SPECIES_BREEDS).map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="appt-label">
|
||||
Breed (optional)
|
||||
<input
|
||||
className="appt-input"
|
||||
type="text"
|
||||
Breed
|
||||
<select
|
||||
className="appt-select"
|
||||
value={breed}
|
||||
onChange={(e) => setBreed(e.target.value)}
|
||||
maxLength={50}
|
||||
placeholder="e.g. Golden Retriever"
|
||||
/>
|
||||
required
|
||||
disabled={!species}
|
||||
>
|
||||
<option value="">{species ? "Select a breed..." : "Select a species first"}</option>
|
||||
{(SPECIES_BREEDS[species] || []).map((b) => (
|
||||
<option key={b} value={b}>{b}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="profile-pet-form-actions">
|
||||
<button type="submit" className="appt-submit-btn" disabled={submitting}>
|
||||
@@ -306,6 +340,11 @@ function AppointmentsPage() {
|
||||
|
||||
const didPreselectRef = useRef(false);
|
||||
|
||||
// Adoption-mode URL verification
|
||||
const [adoptionVerified, setAdoptionVerified] = useState(!adoptionMode);
|
||||
const [adoptionVerifyError, setAdoptionVerifyError] = useState(null);
|
||||
const [adoptionVerifyLoading, setAdoptionVerifyLoading] = useState(adoptionMode);
|
||||
|
||||
const [stores, setStores] = useState([]);
|
||||
const [employees, setEmployees] = useState([]);
|
||||
const [services, setServices] = useState([]);
|
||||
@@ -328,7 +367,11 @@ function AppointmentsPage() {
|
||||
const [appointments, setAppointments] = useState([]);
|
||||
const [loadingAppointments, setLoadingAppointments] = useState(false);
|
||||
|
||||
const [adoptions, setAdoptions] = useState([]);
|
||||
const [loadingAdoptions, setLoadingAdoptions] = useState(false);
|
||||
|
||||
const [showAddPetModal, setShowAddPetModal] = useState(false);
|
||||
const [cancellingId, setCancellingId] = useState(null);
|
||||
|
||||
const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
|
||||
@@ -340,6 +383,28 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
|
||||
}, [authLoading, user, router, preselectedPetId]);
|
||||
|
||||
// Verify the pet from the URL is real, available, and at the stated store
|
||||
useEffect(() => {
|
||||
if (!adoptionMode || !adoptionPetId) return;
|
||||
setAdoptionVerifyLoading(true);
|
||||
fetch(`${API_BASE}/api/v1/pets/${adoptionPetId}`)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error("Pet not found. This link may be invalid.");
|
||||
return r.json();
|
||||
})
|
||||
.then((pet) => {
|
||||
if (pet.petStatus?.toLowerCase() !== "available") {
|
||||
throw new Error(`${pet.petName || "This pet"} is no longer available for adoption (status: ${pet.petStatus}).`);
|
||||
}
|
||||
if (adoptionStoreId && String(pet.storeId) !== String(adoptionStoreId)) {
|
||||
throw new Error("Store mismatch: this pet is not located at the specified store.");
|
||||
}
|
||||
setAdoptionVerified(true);
|
||||
})
|
||||
.catch((err) => setAdoptionVerifyError(err.message))
|
||||
.finally(() => setAdoptionVerifyLoading(false));
|
||||
}, [adoptionMode, adoptionPetId, adoptionStoreId]);
|
||||
|
||||
const loadCustomerPets = useCallback(() => {
|
||||
if (!token || !canBookAppointments) return;
|
||||
fetch(`${API_BASE}/api/v1/my-pets`, {
|
||||
@@ -410,13 +475,10 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
}, [adoptionMode, adoptionStoreId, preselectedPetId, services, allPets]);
|
||||
|
||||
const loadAppointments = useCallback(() => {
|
||||
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
if (!token) return;
|
||||
setLoadingAppointments(true);
|
||||
fetch(`${API_BASE}/api/v1/appointments?size=50&sort=appointmentDate,desc`, {
|
||||
headers: {Authorization: `Bearer ${token}`},
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => setAppointments(data.content ?? []))
|
||||
@@ -424,9 +486,62 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
.finally(() => setLoadingAppointments(false));
|
||||
}, [token]);
|
||||
|
||||
const loadAdoptions = useCallback(() => {
|
||||
if (!token) return;
|
||||
setLoadingAdoptions(true);
|
||||
fetch(`${API_BASE}/api/v1/adoptions?size=50&sort=adoptionDate,desc`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => setAdoptions(data.content ?? []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoadingAdoptions(false));
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAppointments();
|
||||
}, [loadAppointments]);
|
||||
if (adoptionMode) loadAdoptions();
|
||||
else loadAppointments();
|
||||
}, [adoptionMode, loadAppointments, loadAdoptions]);
|
||||
|
||||
async function handleCancelAppointment(appointmentId) {
|
||||
if (!confirm("Cancel this appointment?")) return;
|
||||
setCancellingId(appointmentId);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/appointments/${appointmentId}/cancel`, {
|
||||
method: "PATCH",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
throw new Error(data?.message || `Failed to cancel appointment (${res.status})`);
|
||||
}
|
||||
loadAppointments();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setCancellingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancelAdoption(adoptionId) {
|
||||
if (!confirm("Cancel this adoption request?")) return;
|
||||
setCancellingId(adoptionId);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/adoptions/${adoptionId}/cancel`, {
|
||||
method: "PATCH",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
throw new Error(data?.message || `Failed to cancel adoption (${res.status})`);
|
||||
}
|
||||
loadAdoptions();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setCancellingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !storeId) {
|
||||
@@ -478,30 +593,30 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
.finally(() => setLoadingSlots(false));
|
||||
}, [storeId, serviceId, appointmentDate]);
|
||||
|
||||
const selectedService = services.find((s) => s.serviceId === Number(serviceId));
|
||||
const isAdoptionService = selectedService ? selectedService.serviceName.toLowerCase().includes("adopt") : false;
|
||||
const isCustomerPetService = !!selectedService && !isAdoptionService;
|
||||
|
||||
const adoptablePets = allPets.filter(
|
||||
(p) => p.petStatus && p.petStatus.toLowerCase() === "available"
|
||||
const eligiblePets = customerPets.filter(
|
||||
(p) => p.petStatus === "Owned" || p.petStatus === "Adopted"
|
||||
);
|
||||
|
||||
const selectedService = services.find((s) => s.serviceId === Number(serviceId));
|
||||
const selectedPet = !adoptionMode
|
||||
? (eligiblePets.find((p) => p.customerPetId === selectedPetIds[0]) || null)
|
||||
: null;
|
||||
const availableServices = getAvailableServices(services, selectedPet?.species);
|
||||
|
||||
function handleServiceChange(newServiceId) {
|
||||
setServiceId(newServiceId);
|
||||
setSelectedPetIds([]);
|
||||
}
|
||||
|
||||
function togglePet(petId) {
|
||||
if (isAdoptionService) {
|
||||
setSelectedPetIds((prev) =>
|
||||
prev.includes(petId) ? [] : [petId]
|
||||
);
|
||||
}
|
||||
|
||||
else {
|
||||
setSelectedPetIds((prev) =>
|
||||
prev.includes(petId) ? prev.filter((id) => id !== petId) : [...prev, petId]
|
||||
);
|
||||
function handlePetSelect(petId) {
|
||||
const newPet = eligiblePets.find((p) => p.customerPetId === petId);
|
||||
setSelectedPetIds([petId]);
|
||||
if (serviceId && newPet) {
|
||||
const newAvailable = getAvailableServices(services, newPet.species);
|
||||
if (!newAvailable.some((s) => String(s.serviceId) === String(serviceId))) {
|
||||
setServiceId("");
|
||||
setAppointmentTime("");
|
||||
setAvailableSlots([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,7 +636,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
}
|
||||
|
||||
const formValid = adoptionMode
|
||||
? Boolean(employeeId && appointmentDate && appointmentTime)
|
||||
? Boolean(employeeId && appointmentDate && adoptionVerified)
|
||||
: storeId && serviceId && appointmentDate && appointmentTime && selectedPetIds.length > 0;
|
||||
|
||||
async function handleSubmit(e) {
|
||||
@@ -531,16 +646,27 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
|
||||
if (!canBookAppointments) {
|
||||
setError("Only customer accounts can book appointments from the web app.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!adoptionMode && selectedPetIds.length === 0) {
|
||||
setError(isAdoptionService ? "Please select a pet to adopt." : "Please select at least one pet.");
|
||||
|
||||
setError("Please select a pet for your appointment.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!adoptionMode && selectedPet && selectedPet.petStatus !== "Owned" && selectedPet.petStatus !== "Adopted") {
|
||||
setError("The selected pet is no longer eligible for appointments. Please refresh the page.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!adoptionMode && selectedPet && serviceId) {
|
||||
const chosenService = services.find((s) => String(s.serviceId) === String(serviceId));
|
||||
if (chosenService && getAvailableServices([chosenService], selectedPet.species).length === 0) {
|
||||
setError(`"${chosenService.serviceName}" is not available for ${selectedPet.species}s. Please select a valid service.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
@@ -550,6 +676,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
petId: Number(adoptionPetId),
|
||||
employeeId: employeeId ? Number(employeeId) : undefined,
|
||||
sourceStoreId: adoptionStoreId ? Number(adoptionStoreId) : undefined,
|
||||
adoptionDate: appointmentDate,
|
||||
};
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/v1/adoptions/request`, {
|
||||
@@ -568,6 +695,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
|
||||
setSuccess(`Adoption request submitted! ${adoptionPetName} is now marked as Pending. We'll be in touch soon.`);
|
||||
setEmployeeId("");
|
||||
loadAdoptions();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -630,12 +758,6 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const petsToShow = isAdoptionService ? adoptablePets : isCustomerPetService ? customerPets : [];
|
||||
const petSectionLabel = isAdoptionService ? "Select a Pet to Adopt" : "Select Pet(s)";
|
||||
const noPetsMessage = isAdoptionService
|
||||
? "No pets are currently available for adoption."
|
||||
: "No pets found on your profile.";
|
||||
|
||||
return (
|
||||
<main className="appt-page">
|
||||
{showAddPetModal && (
|
||||
@@ -647,18 +769,68 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
)}
|
||||
|
||||
<section className="appt-hero">
|
||||
<h1 className="appt-hero-title">Schedule an Appointment</h1>
|
||||
<p className="appt-hero-subtitle">Book a service for your pet or schedule a pet adoption visit</p>
|
||||
<h1 className="appt-hero-title">{adoptionMode ? "Schedule an Adoption" : "Schedule an Appointment"}</h1>
|
||||
<p className="appt-hero-subtitle">{adoptionMode ? "Schedule a pet adoption visit" : "Book a service for your pet."}</p>
|
||||
<div className="title-decoration"></div>
|
||||
</section>
|
||||
|
||||
<section className="appt-content">
|
||||
{canBookAppointments ? (
|
||||
<form className="appt-form" onSubmit={handleSubmit}>
|
||||
<h2 className="appt-form-title">New Appointment</h2>
|
||||
<h2 className="appt-form-title">{adoptionMode ? "New Adoption" : "New Appointment"}</h2>
|
||||
|
||||
{error && <div className="appt-error">{error}</div>}
|
||||
{success && <div className="appt-success">{success}</div>}
|
||||
|
||||
{adoptionMode && adoptionVerifyLoading && (
|
||||
<p className="appt-loading">Verifying pet details…</p>
|
||||
)}
|
||||
{adoptionMode && adoptionVerifyError && (
|
||||
<div className="appt-error">{adoptionVerifyError}</div>
|
||||
)}
|
||||
|
||||
{(!adoptionMode || adoptionVerified) && (<>
|
||||
|
||||
{/* ADOPTION MODE: locked pet + store */}
|
||||
{adoptionMode && (
|
||||
<label className="appt-label">
|
||||
Pet
|
||||
<div className="appt-locked-field">
|
||||
{[adoptionPetName, adoptionPetSpecies, adoptionPetBreed].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* STEP 1 (non-adoption): select a pet first */}
|
||||
{!adoptionMode && (
|
||||
<div className="appt-label">
|
||||
<span>Select Your Pet</span>
|
||||
{eligiblePets.length === 0 ? (
|
||||
<p className="appt-no-slots">You have no adopted pets available for appointments.</p>
|
||||
) : (
|
||||
<div className="appt-pets-grid">
|
||||
{eligiblePets.map((p) => (
|
||||
<label
|
||||
key={p.customerPetId}
|
||||
className={`appt-pet-chip ${selectedPetIds.includes(p.customerPetId) ? "appt-pet-chip--selected" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="customerPet"
|
||||
checked={selectedPetIds.includes(p.customerPetId)}
|
||||
onChange={() => handlePetSelect(p.customerPetId)}
|
||||
className="appt-pet-checkbox"
|
||||
/>
|
||||
{p.petName}
|
||||
<span className="appt-pet-chip-species">({p.species})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remaining fields — shown after pet selected (or always in adoption mode) */}
|
||||
{(adoptionMode || selectedPetIds.length > 0) && (<>
|
||||
|
||||
<label className="appt-label">
|
||||
Store Location
|
||||
@@ -679,28 +851,30 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="appt-label">
|
||||
Service
|
||||
{adoptionMode ? (
|
||||
<div className="appt-locked-field">
|
||||
Adopting a Pet ({[adoptionPetName, adoptionPetSpecies, adoptionPetBreed].filter(Boolean).join(", ")})
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
className="appt-select"
|
||||
value={serviceId}
|
||||
onChange={(e) => handleServiceChange(e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="">Select a service...</option>
|
||||
{services.map((s) => (
|
||||
<option key={s.serviceId} value={s.serviceId}>
|
||||
{s.serviceName} — ${Number(s.servicePrice).toFixed(2)} ({s.serviceDuration} min)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</label>
|
||||
{!adoptionMode && (
|
||||
<label className="appt-label">
|
||||
Service
|
||||
{availableServices.length === 0 ? (
|
||||
<p className="appt-no-slots">
|
||||
No services are available for {selectedPet?.species || "this pet"}.
|
||||
</p>
|
||||
) : (
|
||||
<select
|
||||
className="appt-select"
|
||||
value={serviceId}
|
||||
onChange={(e) => handleServiceChange(e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="">Select a service...</option>
|
||||
{availableServices.map((s) => (
|
||||
<option key={s.serviceId} value={s.serviceId}>
|
||||
{s.serviceName} — ${Number(s.servicePrice).toFixed(2)} ({s.serviceDuration} min)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{employees.length > 0 && (
|
||||
<label className="appt-label">
|
||||
@@ -732,7 +906,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
/>
|
||||
</div>
|
||||
|
||||
{storeId && serviceId && appointmentDate && (
|
||||
{!adoptionMode && storeId && serviceId && appointmentDate && (
|
||||
<div className="appt-label">
|
||||
<span>Available Time Slots</span>
|
||||
{loadingSlots ? (
|
||||
@@ -756,83 +930,62 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!adoptionMode && serviceId && (
|
||||
<div className="appt-label">
|
||||
<span>{petSectionLabel}</span>
|
||||
{isCustomerPetService && (
|
||||
<button
|
||||
type="button"
|
||||
className="appt-add-pet-btn"
|
||||
onClick={() => setShowAddPetModal(true)}
|
||||
>
|
||||
+ Add New Pet
|
||||
</button>
|
||||
)}
|
||||
{petsToShow.length === 0 ? (
|
||||
<p className="appt-no-slots">{noPetsMessage}</p>
|
||||
) : isAdoptionService ? (
|
||||
<div className="appt-adopt-grid">
|
||||
{petsToShow.map((p) => (
|
||||
<label
|
||||
key={p.petId}
|
||||
className={`appt-adopt-card ${selectedPetIds.includes(p.petId) ? "appt-adopt-card--selected" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="adoptionPet"
|
||||
value={p.petId}
|
||||
checked={selectedPetIds.includes(p.petId)}
|
||||
onChange={() => togglePet(p.petId)}
|
||||
className="appt-adopt-radio"
|
||||
/>
|
||||
{p.imageUrl ? (
|
||||
<img src={p.imageUrl} alt={p.petName} className="appt-adopt-img" />
|
||||
) : (
|
||||
<div className="appt-adopt-img-placeholder">🐾</div>
|
||||
)}
|
||||
<div className="appt-adopt-info">
|
||||
<span className="appt-adopt-name">{p.petName}</span>
|
||||
<span className="appt-adopt-detail">{p.petSpecies} · {p.petBreed}</span>
|
||||
<span className="appt-adopt-detail">Age: {p.petAge}</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="appt-pets-grid">
|
||||
{petsToShow.map((p) => (
|
||||
<label
|
||||
key={p.customerPetId}
|
||||
className={`appt-pet-chip ${selectedPetIds.includes(p.customerPetId) ? "appt-pet-chip--selected" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPetIds.includes(p.customerPetId)}
|
||||
onChange={() => togglePet(p.customerPetId)}
|
||||
className="appt-pet-checkbox"
|
||||
/>
|
||||
{p.petName}
|
||||
<span className="appt-pet-chip-species">({p.species})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="appt-submit-btn"
|
||||
disabled={!formValid || submitting}
|
||||
>
|
||||
{submitting ? "Booking..." : adoptionMode ? "Schedule Appointment" : isAdoptionService ? "Schedule Adoption Visit" : "Book Appointment"}
|
||||
{submitting ? "Booking..." : adoptionMode ? "Schedule Adoption" : "Book Appointment"}
|
||||
</button>
|
||||
|
||||
{success && <div className="appt-success">{success}</div>}
|
||||
|
||||
</>)}
|
||||
|
||||
</>)}
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
<div className="appt-history">
|
||||
<h2 className="appt-form-title">{canBookAppointments ? "Your Appointments" : "Appointments"}</h2>
|
||||
{loadingAppointments ? (
|
||||
<h2 className="appt-form-title">
|
||||
{adoptionMode ? "Your Adoptions" : canBookAppointments ? "Your Appointments" : "Appointments"}
|
||||
</h2>
|
||||
{adoptionMode ? (
|
||||
loadingAdoptions ? (
|
||||
<p className="appt-loading">Loading adoptions...</p>
|
||||
) : adoptions.length === 0 ? (
|
||||
<p className="appt-empty">No adoption appointments yet.</p>
|
||||
) : (
|
||||
<div className="appt-list">
|
||||
{adoptions.map((a) => (
|
||||
<div key={a.adoptionId} className="appt-card">
|
||||
<div className="appt-card-header">
|
||||
<span className="appt-card-service">{a.petName}</span>
|
||||
<span className={`appt-card-status appt-card-status--${a.adoptionStatus?.toLowerCase()}`}>
|
||||
{a.adoptionStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="appt-card-details">
|
||||
<span>{a.sourceStoreName}</span>
|
||||
<span>{a.adoptionDate}</span>
|
||||
</div>
|
||||
{a.adoptionStatus?.toLowerCase() === "pending" && (
|
||||
<div className="appt-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="appt-cancel-btn"
|
||||
disabled={cancellingId === a.adoptionId}
|
||||
onClick={() => handleCancelAdoption(a.adoptionId)}
|
||||
>
|
||||
{cancellingId === a.adoptionId ? "Cancelling..." : "Cancel"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : loadingAppointments ? (
|
||||
<p className="appt-loading">Loading appointments...</p>
|
||||
) : appointments.length === 0 ? (
|
||||
<p className="appt-empty">No appointments yet.</p>
|
||||
@@ -860,6 +1013,18 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
Pets: {a.customerPetNames.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{a.appointmentStatus?.toLowerCase() === "booked" && (
|
||||
<div className="appt-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="appt-cancel-btn"
|
||||
disabled={cancellingId === a.appointmentId}
|
||||
onClick={() => handleCancelAppointment(a.appointmentId)}
|
||||
>
|
||||
{cancellingId === a.appointmentId ? "Cancelling..." : "Cancel"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1627,6 +1627,11 @@ body {
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.appt-card-status--pending {
|
||||
background: #fff8e1;
|
||||
color: #f57f17;
|
||||
}
|
||||
|
||||
.appt-card-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1640,6 +1645,34 @@ body {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.appt-card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.appt-cancel-btn {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.85rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e53935;
|
||||
background: transparent;
|
||||
color: #e53935;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.appt-cancel-btn:hover:not(:disabled) {
|
||||
background: #e53935;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.appt-cancel-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Adoption Pet Selection */
|
||||
|
||||
.appt-adopt-grid {
|
||||
|
||||
@@ -6,6 +6,18 @@ import { useAuth } from "@/context/AuthContext";
|
||||
|
||||
const API_BASE = "";
|
||||
|
||||
const SPECIES_BREEDS = {
|
||||
Dog: ["Beagle", "Boxer", "Bulldog", "Chihuahua", "Dachshund", "German Shepherd", "Golden Retriever", "Labrador Retriever", "Poodle", "Rottweiler", "Shih Tzu", "Siberian Husky", "Yorkshire Terrier", "Mixed / Other"],
|
||||
Cat: ["Abyssinian", "Bengal", "British Shorthair", "Maine Coon", "Persian", "Ragdoll", "Scottish Fold", "Siamese", "Sphynx", "Mixed / Other"],
|
||||
Bird: ["Canary", "Cockatiel", "Cockatoo", "Finch", "Lovebird", "Macaw", "Parakeet", "Parrot", "Other"],
|
||||
Rabbit: ["Dutch", "Flemish Giant", "Holland Lop", "Lionhead", "Mini Rex", "Other"],
|
||||
Hamster: ["Dwarf", "Roborovski", "Syrian", "Other"],
|
||||
"Guinea Pig": ["Abyssinian", "American", "Peruvian", "Teddy", "Other"],
|
||||
Reptile: ["Ball Python", "Bearded Dragon", "Blue-tongued Skink", "Corn Snake", "Leopard Gecko", "Other"],
|
||||
Fish: ["Angelfish", "Betta", "Cichlid", "Clownfish", "Goldfish", "Guppy", "Tetra", "Other"],
|
||||
Other: ["Other"],
|
||||
};
|
||||
|
||||
export default function ProfilePage() {
|
||||
const {user, token, loading, logout, refreshUser} = useAuth();
|
||||
const router = useRouter();
|
||||
@@ -53,7 +65,7 @@ export default function ProfilePage() {
|
||||
setLoadingPets(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/my-pets`, {
|
||||
const response = await fetch(`${API_BASE}/api/v1/my-pets?status=Owned`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
@@ -64,25 +76,21 @@ export default function ProfilePage() {
|
||||
const petData = await response.json();
|
||||
clearPetImageObjectUrls();
|
||||
|
||||
const petsWithResolvedImages = await Promise.all(
|
||||
(Array.isArray(petData) ? petData : []).map(async (pet) => {
|
||||
if (!pet.imageUrl) {
|
||||
return pet;
|
||||
}
|
||||
const ownedPets = Array.isArray(petData) ? petData : [];
|
||||
|
||||
const petsWithResolvedImages = await Promise.all(
|
||||
ownedPets.map(async (pet) => {
|
||||
if (!pet.imageUrl) return pet;
|
||||
try {
|
||||
const imageResponse = await fetch(`${API_BASE}${pet.imageUrl}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!imageResponse.ok) {
|
||||
return { ...pet, imageUrl: null };
|
||||
}
|
||||
if (!imageResponse.ok) return { ...pet, imageUrl: null };
|
||||
|
||||
const blob = await imageResponse.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
petImageObjectUrlsRef.current.push(objectUrl);
|
||||
|
||||
return { ...pet, imageUrl: objectUrl };
|
||||
} catch {
|
||||
return { ...pet, imageUrl: null };
|
||||
@@ -284,14 +292,19 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/v1/my-pets/${id}`, {
|
||||
const res = await fetch(`${API_BASE}/api/v1/my-pets/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
throw new Error(data?.message || `Failed to remove pet (${res.status})`);
|
||||
}
|
||||
loadPets();
|
||||
}
|
||||
|
||||
catch {
|
||||
|
||||
catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,26 +458,32 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
|
||||
</label>
|
||||
<label className="appt-label">
|
||||
Species
|
||||
<input
|
||||
className="appt-input"
|
||||
type="text"
|
||||
<select
|
||||
className="appt-select"
|
||||
value={species}
|
||||
onChange={(e) => setSpecies(e.target.value)}
|
||||
onChange={(e) => { setSpecies(e.target.value); setBreed(""); }}
|
||||
required
|
||||
maxLength={50}
|
||||
placeholder="e.g. Dog, Cat, Bird"
|
||||
/>
|
||||
>
|
||||
<option value="">Select a species...</option>
|
||||
{Object.keys(SPECIES_BREEDS).map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="appt-label">
|
||||
Breed (optional)
|
||||
<input
|
||||
className="appt-input"
|
||||
type="text"
|
||||
Breed
|
||||
<select
|
||||
className="appt-select"
|
||||
value={breed}
|
||||
onChange={(e) => setBreed(e.target.value)}
|
||||
maxLength={50}
|
||||
placeholder="e.g. Golden Retriever"
|
||||
/>
|
||||
required
|
||||
disabled={!species}
|
||||
>
|
||||
<option value="">{species ? "Select a breed..." : "Select a species first"}</option>
|
||||
{(SPECIES_BREEDS[species] || []).map((b) => (
|
||||
<option key={b} value={b}>{b}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="profile-pet-form-actions">
|
||||
<button type="submit" className="appt-submit-btn" disabled={submitting}>
|
||||
@@ -514,11 +533,11 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
|
||||
{pet.breed && <span className="profile-pet-card-detail">{pet.breed}</span>}
|
||||
</div>
|
||||
<div className="profile-pet-card-actions">
|
||||
<button type="button" className="profile-pet-edit-btn" onClick={() => openEditForm(pet)}>Edit</button>
|
||||
<button type="button" className="profile-pet-delete-btn" onClick={() => handleDeletePet(pet.customerPetId)}>Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="profile-pet-edit-btn" onClick={() => openEditForm(pet)}>Edit</button>
|
||||
<button type="button" className="profile-pet-delete-btn" onClick={() => handleDeletePet(pet.customerPetId)}>Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user