diff --git a/backend/src/main/java/com/petshop/backend/controller/AdoptionController.java b/backend/src/main/java/com/petshop/backend/controller/AdoptionController.java index 23e372bf..ac10dfdc 100644 --- a/backend/src/main/java/com/petshop/backend/controller/AdoptionController.java +++ b/backend/src/main/java/com/petshop/backend/controller/AdoptionController.java @@ -2,6 +2,7 @@ package com.petshop.backend.controller; import com.petshop.backend.dto.adoption.AdoptionRequest; import com.petshop.backend.dto.adoption.AdoptionResponse; +import com.petshop.backend.dto.adoption.CustomerAdoptionRequest; import com.petshop.backend.dto.common.BulkDeleteRequest; import com.petshop.backend.entity.User; import com.petshop.backend.repository.UserRepository; @@ -81,6 +82,15 @@ public class AdoptionController { return ResponseEntity.status(HttpStatus.CREATED).body(adoptionService.createAdoption(request)); } + @PostMapping("/request") + @PreAuthorize("hasAnyRole('CUSTOMER', 'ADMIN')") + public ResponseEntity 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()) + ); + } + @PutMapping("/{id}") @PreAuthorize("hasAnyRole('STAFF', 'ADMIN')") public ResponseEntity updateAdoption( diff --git a/backend/src/main/java/com/petshop/backend/dto/adoption/CustomerAdoptionRequest.java b/backend/src/main/java/com/petshop/backend/dto/adoption/CustomerAdoptionRequest.java new file mode 100644 index 00000000..8312ed38 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/dto/adoption/CustomerAdoptionRequest.java @@ -0,0 +1,37 @@ +package com.petshop.backend.dto.adoption; + +import jakarta.validation.constraints.NotNull; + +public class CustomerAdoptionRequest { + + @NotNull(message = "Pet ID is required") + private Long petId; + + private Long employeeId; + + private Long sourceStoreId; + + public Long getPetId() { + return petId; + } + + public void setPetId(Long petId) { + this.petId = petId; + } + + public Long getEmployeeId() { + return employeeId; + } + + public void setEmployeeId(Long employeeId) { + this.employeeId = employeeId; + } + + public Long getSourceStoreId() { + return sourceStoreId; + } + + public void setSourceStoreId(Long sourceStoreId) { + this.sourceStoreId = sourceStoreId; + } +} diff --git a/backend/src/main/java/com/petshop/backend/service/AdoptionService.java b/backend/src/main/java/com/petshop/backend/service/AdoptionService.java index 0874aded..c4a1ae03 100644 --- a/backend/src/main/java/com/petshop/backend/service/AdoptionService.java +++ b/backend/src/main/java/com/petshop/backend/service/AdoptionService.java @@ -147,6 +147,33 @@ public class AdoptionService { return mapToResponse(adoption); } + @Transactional + public AdoptionResponse requestAdoption(Long customerId, Long petId, Long employeeId, Long sourceStoreId) { + 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; + + validatePetAvailability(pet, null, null); + + Adoption adoption = new Adoption(); + adoption.setPet(pet); + adoption.setCustomer(customer); + adoption.setEmployee(employee); + adoption.setSourceStore(sourceStore); + adoption.setAdoptionDate(null); + adoption.setAdoptionStatus(ADOPTION_STATUS_PENDING); + + adoption = adoptionRepository.save(adoption); + syncPetStatus(pet, ADOPTION_STATUS_PENDING, adoption.getAdoptionId(), customer); + return mapToResponse(adoption); + } + @Transactional public void deleteAdoption(Long id) { if (!adoptionRepository.existsById(id)) { diff --git a/web/app/adopt/[id]/page.js b/web/app/adopt/[id]/page.js index 6c7b70e0..f2ade475 100644 --- a/web/app/adopt/[id]/page.js +++ b/web/app/adopt/[id]/page.js @@ -44,6 +44,8 @@ export default function PetDetailPage() { petStatus={pet.petStatus} petPrice={pet.petPrice} imageUrl={pet.imageUrl} + storeId={pet.storeId} + storeName={pet.storeName} /> )} diff --git a/web/app/appointments/page.js b/web/app/appointments/page.js index b4dd5cee..f6cd1b6c 100644 --- a/web/app/appointments/page.js +++ b/web/app/appointments/page.js @@ -294,6 +294,16 @@ function AppointmentsPage() { const router = useRouter(); const searchParams = useSearchParams(); const preselectedPetId = searchParams.get("petId"); + + // Adoption mode — set when arriving from a pet detail page + const adoptionMode = searchParams.get("adoptionMode") === "true"; + const adoptionPetId = searchParams.get("petId"); + const adoptionPetName = searchParams.get("petName") || ""; + const adoptionPetSpecies = searchParams.get("petSpecies") || ""; + const adoptionPetBreed = searchParams.get("petBreed") || ""; + const adoptionStoreId = searchParams.get("storeId") || ""; + const adoptionStoreName = searchParams.get("storeName") || ""; + const didPreselectRef = useRef(false); const [stores, setStores] = useState([]); @@ -366,9 +376,24 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN"; }, [token, loadCustomerPets]); useEffect(() => { - if (didPreselectRef.current) { + if (didPreselectRef.current) return; + + if (adoptionMode) { + // Need both the store (so employees load) and a serviceId (so availability slots load) + if (adoptionStoreId && services.length > 0) { + setStoreId(adoptionStoreId); + // Prefer a service named "adopt", fall back to the first available service + const adoptionSvc = + services.find((s) => s.serviceName.toLowerCase().includes("adopt")) || + services[0]; + if (adoptionSvc) { + setServiceId(String(adoptionSvc.serviceId)); + didPreselectRef.current = true; + } + } return; } + if (!preselectedPetId || services.length === 0 || allPets.length === 0) { return; } @@ -382,7 +407,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN"; } setSelectedPetIds([Number(preselectedPetId)]); didPreselectRef.current = true; - }, [preselectedPetId, services, allPets]); + }, [adoptionMode, adoptionStoreId, preselectedPetId, services, allPets]); const loadAppointments = useCallback(() => { @@ -495,8 +520,9 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN"; return d.toISOString().split("T")[0]; } - const formValid = - storeId && serviceId && appointmentDate && appointmentTime && selectedPetIds.length > 0; + const formValid = adoptionMode + ? Boolean(employeeId && appointmentDate && appointmentTime) + : storeId && serviceId && appointmentDate && appointmentTime && selectedPetIds.length > 0; async function handleSubmit(e) { e.preventDefault(); @@ -509,7 +535,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN"; return; } - if (selectedPetIds.length === 0) { + if (!adoptionMode && selectedPetIds.length === 0) { setError(isAdoptionService ? "Please select a pet to adopt." : "Please select at least one pet."); return; @@ -518,6 +544,33 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN"; setSubmitting(true); try { + if (adoptionMode) { + // Submit an adoption request directly to the adoption table + const body = { + petId: Number(adoptionPetId), + employeeId: employeeId ? Number(employeeId) : undefined, + sourceStoreId: adoptionStoreId ? Number(adoptionStoreId) : undefined, + }; + + const res = await fetch(`${API_BASE}/api/v1/adoptions/request`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const data = await res.json().catch(() => null); + throw new Error(data?.message || data?.error || `Request failed (${res.status})`); + } + + setSuccess(`Adoption request submitted! ${adoptionPetName} is now marked as Pending. We'll be in touch soon.`); + setEmployeeId(""); + return; + } + const body = { customerId: user.customerId || user.id, storeId: Number(storeId), @@ -609,34 +662,44 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN"; {employees.length > 0 && ( @@ -654,7 +717,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN"; )} - {selectedService && ( + {!adoptionMode && selectedService && (

{selectedService.serviceDesc}

@@ -693,7 +756,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN"; )} - {serviceId && ( + {!adoptionMode && serviceId && (
{petSectionLabel} {isCustomerPetService && ( @@ -762,7 +825,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN"; className="appt-submit-btn" disabled={!formValid || submitting} > - {submitting ? "Booking..." : isAdoptionService ? "Schedule Adoption Visit" : "Book Appointment"} + {submitting ? "Booking..." : adoptionMode ? "Schedule Appointment" : isAdoptionService ? "Schedule Adoption Visit" : "Book Appointment"} ) : null} diff --git a/web/app/globals.css b/web/app/globals.css index dd607b01..0b263f02 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -1356,6 +1356,17 @@ body { box-shadow: 0 0 0 3px rgba(255, 165, 0, 0.2); } +.appt-locked-field { + padding: 0.6rem 0.85rem; + border: 1px solid #e0e0e0; + border-radius: 8px; + font-size: 1rem; + background: #f5f5f5; + color: #555; + font-weight: 600; + cursor: not-allowed; +} + .appt-service-info { background: #fff8f0; border: 1px solid #ffd180; diff --git a/web/components/PetProfile.js b/web/components/PetProfile.js index 8b7fd6f0..7242dcd6 100644 --- a/web/components/PetProfile.js +++ b/web/components/PetProfile.js @@ -1,7 +1,7 @@ import Link from "next/link"; import { getStatusClass } from "@/components/petUtils"; -export default function PetProfile({ petId, petName, petSpecies, petBreed, petAge, petStatus, petPrice, imageUrl }) { +export default function PetProfile({ petId, petName, petSpecies, petBreed, petAge, petStatus, petPrice, imageUrl, storeId, storeName }) { return (
@@ -53,7 +53,10 @@ export default function PetProfile({ petId, petName, petSpecies, petBreed, petAg

Interested in adopting {petName}? Visit us in store or schedule an appointment.

- + Schedule an Appointment