Merge web-v1 into main #299

Merged
RecentRunner merged 8 commits from web-v1 into main 2026-04-14 23:29:51 -06:00
26 changed files with 1088 additions and 306 deletions

View File

@@ -2,6 +2,7 @@ package com.petshop.backend.controller;
import com.petshop.backend.dto.adoption.AdoptionRequest; import com.petshop.backend.dto.adoption.AdoptionRequest;
import com.petshop.backend.dto.adoption.AdoptionResponse; 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.dto.common.BulkDeleteRequest;
import com.petshop.backend.entity.User; import com.petshop.backend.entity.User;
import com.petshop.backend.repository.UserRepository; import com.petshop.backend.repository.UserRepository;
@@ -81,6 +82,33 @@ public class AdoptionController {
return ResponseEntity.status(HttpStatus.CREATED).body(adoptionService.createAdoption(request)); return ResponseEntity.status(HttpStatus.CREATED).body(adoptionService.createAdoption(request));
} }
@PostMapping("/request")
@PreAuthorize("hasAnyRole('CUSTOMER', 'ADMIN')")
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(), 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}") @PutMapping("/{id}")
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')") @PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
public ResponseEntity<AdoptionResponse> updateAdoption( public ResponseEntity<AdoptionResponse> updateAdoption(

View File

@@ -98,6 +98,24 @@ public class AppointmentController {
return ResponseEntity.status(HttpStatus.CREATED).body(appointmentService.createAppointment(request)); 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}") @PutMapping("/{id}")
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')") @PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
public ResponseEntity<AppointmentResponse> updateAppointment( public ResponseEntity<AppointmentResponse> updateAppointment(

View File

@@ -73,7 +73,8 @@ public class AuthController {
public ResponseEntity<?> register(@Valid @RequestBody RegisterRequest request) { public ResponseEntity<?> register(@Valid @RequestBody RegisterRequest request) {
String username = trimToNull(request.getUsername()); String username = trimToNull(request.getUsername());
String email = trimToNull(request.getEmail()); String email = trimToNull(request.getEmail());
NameParts nameParts = splitFullName(request.getFullName()); String firstName = trimToNull(request.getFirstName());
String lastName = trimToNull(request.getLastName());
String phone = normalizePhone(request.getPhone()); String phone = normalizePhone(request.getPhone());
if (userRepository.findByUsername(username).isPresent()) { if (userRepository.findByUsername(username).isPresent()) {
@@ -101,9 +102,9 @@ public class AuthController {
user.setUsername(username); user.setUsername(username);
user.setPassword(passwordEncoder.encode(request.getPassword())); user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setEmail(email); user.setEmail(email);
user.setFirstName(nameParts.firstName()); user.setFirstName(firstName);
user.setLastName(nameParts.lastName()); user.setLastName(lastName);
user.setFullName(nameParts.fullName()); user.setFullName(joinFullName(firstName, lastName));
user.setPhone(phone); user.setPhone(phone);
user.setRole(User.Role.CUSTOMER); user.setRole(User.Role.CUSTOMER);
user.setActive(true); user.setActive(true);
@@ -208,11 +209,16 @@ public class AuthController {
user.setEmail(email); user.setEmail(email);
} }
if (request.getFullName() != null) { String firstName = trimToNull(request.getFirstName());
NameParts nameParts = splitFullName(request.getFullName()); if (firstName != null) {
user.setFirstName(nameParts.firstName()); user.setFirstName(firstName);
user.setLastName(nameParts.lastName()); }
user.setFullName(nameParts.fullName()); String lastName = trimToNull(request.getLastName());
if (lastName != null) {
user.setLastName(lastName);
}
if (firstName != null || lastName != null) {
user.setFullName(joinFullName(user.getFirstName(), user.getLastName()));
} }
if (request.getPhone() != null) { if (request.getPhone() != null) {
@@ -252,6 +258,8 @@ public class AuthController {
return new UserInfoResponse( return new UserInfoResponse(
user.getId(), user.getId(),
user.getUsername(), user.getUsername(),
user.getFirstName(),
user.getLastName(),
user.getEmail(), user.getEmail(),
fullName, fullName,
user.getPhone(), user.getPhone(),

View File

@@ -38,8 +38,8 @@ public class MyPetController {
} }
@GetMapping @GetMapping
public ResponseEntity<List<MyPetResponse>> getMyPets() { public ResponseEntity<List<MyPetResponse>> getMyPets(@RequestParam(required = false) String status) {
return ResponseEntity.ok(petService.getMyPets(currentUserId())); return ResponseEntity.ok(petService.getMyPets(currentUserId(), status));
} }
@PostMapping @PostMapping

View File

@@ -23,7 +23,6 @@ public class StoreController {
} }
@GetMapping @GetMapping
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Page<StoreResponse>> getAllStores( public ResponseEntity<Page<StoreResponse>> getAllStores(
@RequestParam(required = false) String q, @RequestParam(required = false) String q,
Pageable pageable) { Pageable pageable) {
@@ -31,7 +30,6 @@ public class StoreController {
} }
@GetMapping("/{id}") @GetMapping("/{id}")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<StoreResponse> getStoreById(@PathVariable Long id) { public ResponseEntity<StoreResponse> getStoreById(@PathVariable Long id) {
return ResponseEntity.ok(storeService.getStoreById(id)); return ResponseEntity.ok(storeService.getStoreById(id));
} }

View File

@@ -0,0 +1,49 @@
package com.petshop.backend.dto.adoption;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
public class CustomerAdoptionRequest {
@NotNull(message = "Pet ID is required")
private Long petId;
private Long employeeId;
private Long sourceStoreId;
@NotNull(message = "Appointment date is required")
private LocalDate adoptionDate;
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;
}
public LocalDate getAdoptionDate() {
return adoptionDate;
}
public void setAdoptionDate(LocalDate adoptionDate) {
this.adoptionDate = adoptionDate;
}
}

View File

@@ -11,8 +11,11 @@ public class ProfileUpdateRequest {
@Email(message = "Email must be valid") @Email(message = "Email must be valid")
private String email; private String email;
@Size(max = 100, message = "Full name must not exceed 100 characters") @Size(max = 50, message = "First name must not exceed 50 characters")
private String fullName; private String firstName;
@Size(max = 50, message = "Last name must not exceed 50 characters")
private String lastName;
@Size(max = 20, message = "Phone must not exceed 20 characters") @Size(max = 20, message = "Phone must not exceed 20 characters")
private String phone; private String phone;
@@ -36,12 +39,20 @@ public class ProfileUpdateRequest {
this.email = email; this.email = email;
} }
public String getFullName() { public String getFirstName() {
return fullName; return firstName;
} }
public void setFullName(String fullName) { public void setFirstName(String firstName) {
this.fullName = fullName; this.firstName = firstName;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
} }
public String getPhone() { public String getPhone() {
@@ -67,14 +78,15 @@ public class ProfileUpdateRequest {
ProfileUpdateRequest that = (ProfileUpdateRequest) o; ProfileUpdateRequest that = (ProfileUpdateRequest) o;
return Objects.equals(username, that.username) && return Objects.equals(username, that.username) &&
Objects.equals(email, that.email) && Objects.equals(email, that.email) &&
Objects.equals(fullName, that.fullName) && Objects.equals(firstName, that.firstName) &&
Objects.equals(lastName, that.lastName) &&
Objects.equals(phone, that.phone) && Objects.equals(phone, that.phone) &&
Objects.equals(password, that.password); Objects.equals(password, that.password);
} }
@Override @Override
public int hashCode() { public int hashCode() {
return Objects.hash(username, email, fullName, phone, password); return Objects.hash(username, email, firstName, lastName, phone, password);
} }
@Override @Override
@@ -82,7 +94,8 @@ public class ProfileUpdateRequest {
return "ProfileUpdateRequest{" + return "ProfileUpdateRequest{" +
"username='" + username + '\'' + "username='" + username + '\'' +
", email='" + email + '\'' + ", email='" + email + '\'' +
", fullName='" + fullName + '\'' + ", firstName='" + firstName + '\'' +
", lastName='" + lastName + '\'' +
", phone='" + phone + '\'' + ", phone='" + phone + '\'' +
", password='" + password + '\'' + ", password='" + password + '\'' +
'}'; '}';

View File

@@ -18,9 +18,13 @@ public class RegisterRequest {
@Email(message = "Email must be valid") @Email(message = "Email must be valid")
private String email; private String email;
@NotBlank(message = "Full name is required") @NotBlank(message = "First name is required")
@Size(max = 100, message = "Full name must not exceed 100 characters") @Size(max = 50, message = "First name must not exceed 50 characters")
private String fullName; private String firstName;
@NotBlank(message = "Last name is required")
@Size(max = 50, message = "Last name must not exceed 50 characters")
private String lastName;
@NotBlank(message = "Phone is required") @NotBlank(message = "Phone is required")
@Size(max = 20, message = "Phone must not exceed 20 characters") @Size(max = 20, message = "Phone must not exceed 20 characters")
@@ -50,12 +54,20 @@ public class RegisterRequest {
this.email = email; this.email = email;
} }
public String getFullName() { public String getFirstName() {
return fullName; return firstName;
} }
public void setFullName(String fullName) { public void setFirstName(String firstName) {
this.fullName = fullName; this.firstName = firstName;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
} }
public String getPhone() { public String getPhone() {
@@ -74,13 +86,14 @@ public class RegisterRequest {
return Objects.equals(username, that.username) && return Objects.equals(username, that.username) &&
Objects.equals(password, that.password) && Objects.equals(password, that.password) &&
Objects.equals(email, that.email) && Objects.equals(email, that.email) &&
Objects.equals(fullName, that.fullName) && Objects.equals(firstName, that.firstName) &&
Objects.equals(lastName, that.lastName) &&
Objects.equals(phone, that.phone); Objects.equals(phone, that.phone);
} }
@Override @Override
public int hashCode() { public int hashCode() {
return Objects.hash(username, password, email, fullName, phone); return Objects.hash(username, password, email, firstName, lastName, phone);
} }
@Override @Override
@@ -89,7 +102,8 @@ public class RegisterRequest {
"username='" + username + '\'' + "username='" + username + '\'' +
", password='" + password + '\'' + ", password='" + password + '\'' +
", email='" + email + '\'' + ", email='" + email + '\'' +
", fullName='" + fullName + '\'' + ", firstName='" + firstName + '\'' +
", lastName='" + lastName + '\'' +
", phone='" + phone + '\'' + ", phone='" + phone + '\'' +
'}'; '}';
} }

View File

@@ -5,6 +5,8 @@ import java.util.Objects;
public class UserInfoResponse { public class UserInfoResponse {
private Long id; private Long id;
private String username; private String username;
private String firstName;
private String lastName;
private String email; private String email;
private String fullName; private String fullName;
private String phone; private String phone;
@@ -17,9 +19,11 @@ public class UserInfoResponse {
public UserInfoResponse() { public UserInfoResponse() {
} }
public UserInfoResponse(Long id, String username, String email, String fullName, String phone, String avatarUrl, String role, Long customerId, Long storeId, String storeName) { public UserInfoResponse(Long id, String username, String firstName, String lastName, String email, String fullName, String phone, String avatarUrl, String role, Long customerId, Long storeId, String storeName) {
this.id = id; this.id = id;
this.username = username; this.username = username;
this.firstName = firstName;
this.lastName = lastName;
this.email = email; this.email = email;
this.fullName = fullName; this.fullName = fullName;
this.phone = phone; this.phone = phone;
@@ -46,6 +50,22 @@ public class UserInfoResponse {
this.username = username; this.username = username;
} }
public String getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
public String getEmail() { public String getEmail() {
return email; return email;
} }

View File

@@ -1,5 +1,7 @@
package com.petshop.backend.dto.pet; package com.petshop.backend.dto.pet;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
@@ -16,6 +18,10 @@ public class MyPetRequest {
@Size(max = 50, message = "Breed must not exceed 50 characters") @Size(max = 50, message = "Breed must not exceed 50 characters")
private String breed; private String breed;
@Min(value = 0, message = "Age must be 0 or greater")
@Max(value = 100, message = "Age must not exceed 100")
private Integer petAge;
public String getPetName() { public String getPetName() {
return petName; return petName;
} }
@@ -39,4 +45,12 @@ public class MyPetRequest {
public void setBreed(String breed) { public void setBreed(String breed) {
this.breed = breed; this.breed = breed;
} }
public Integer getPetAge() {
return petAge;
}
public void setPetAge(Integer petAge) {
this.petAge = petAge;
}
} }

View File

@@ -6,17 +6,21 @@ public class MyPetResponse {
private String petName; private String petName;
private String species; private String species;
private String breed; private String breed;
private Integer petAge;
private String imageUrl; private String imageUrl;
private String petStatus;
public MyPetResponse() { public MyPetResponse() {
} }
public MyPetResponse(Long customerPetId, String petName, String species, String breed, String imageUrl) { public MyPetResponse(Long customerPetId, String petName, String species, String breed, Integer petAge, String imageUrl, String petStatus) {
this.customerPetId = customerPetId; this.customerPetId = customerPetId;
this.petName = petName; this.petName = petName;
this.species = species; this.species = species;
this.breed = breed; this.breed = breed;
this.petAge = petAge;
this.imageUrl = imageUrl; this.imageUrl = imageUrl;
this.petStatus = petStatus;
} }
public Long getCustomerPetId() { public Long getCustomerPetId() {
@@ -51,6 +55,14 @@ public class MyPetResponse {
this.breed = breed; this.breed = breed;
} }
public Integer getPetAge() {
return petAge;
}
public void setPetAge(Integer petAge) {
this.petAge = petAge;
}
public String getImageUrl() { public String getImageUrl() {
return imageUrl; return imageUrl;
} }
@@ -58,4 +70,12 @@ public class MyPetResponse {
public void setImageUrl(String imageUrl) { public void setImageUrl(String imageUrl) {
this.imageUrl = imageUrl; this.imageUrl = imageUrl;
} }
public String getPetStatus() {
return petStatus;
}
public void setPetStatus(String petStatus) {
this.petStatus = petStatus;
}
} }

View File

@@ -51,5 +51,7 @@ 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'") @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> findPastBookedAppointments(@Param("currentDate") LocalDate currentDate, @Param("currentTime") LocalTime currentTime);
List<Appointment> findByPet_Id(Long petId);
List<Appointment> findByAppointmentDateAndAppointmentStatusIgnoreCase(LocalDate date, String status); List<Appointment> findByAppointmentDateAndAppointmentStatusIgnoreCase(LocalDate date, String status);
} }

View File

@@ -66,6 +66,7 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.GET, "/api/v1/products/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/products/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/services/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/services/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/categories/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/categories/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/stores/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/dropdowns/pet-species").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/dropdowns/pet-species").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/dropdowns/pet-breeds").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/dropdowns/pet-breeds").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/dropdowns/stores").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/dropdowns/stores").permitAll()
@@ -101,7 +102,7 @@ public class SecurityConfig {
public CorsConfigurationSource corsConfigurationSource() { public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration(); CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(List.of("http://localhost:*", "http://127.0.0.1:*")); 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.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true); config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();

View File

@@ -158,6 +158,57 @@ public class AdoptionService {
return mapToResponse(adoption); return mapToResponse(adoption);
} }
@Transactional
public AdoptionResponse requestAdoption(Long customerId, Long petId, Long employeeId, Long sourceStoreId, LocalDate adoptionDate) {
Pet pet = petRepository.findById(petId)
.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(adoptionDate);
adoption.setAdoptionStatus(ADOPTION_STATUS_PENDING);
adoption = adoptionRepository.save(adoption);
syncPetStatus(pet, ADOPTION_STATUS_PENDING, adoption.getAdoptionId(), customer);
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 @Transactional
public void deleteAdoption(Long id) { public void deleteAdoption(Long id) {
Adoption adoption = adoptionRepository.findById(id) Adoption adoption = adoptionRepository.findById(id)

View File

@@ -109,6 +109,22 @@ public class AppointmentService {
Pet pet = request.getPetId() != null ? fetchPet(request.getPetId()) : null; Pet pet = request.getPetId() != null ? fetchPet(request.getPetId()) : null;
User employee = resolveAppointmentEmployee(request.getEmployeeId(), store.getStoreId()); 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); validateStoreAccess(store.getStoreId(), authenticatedUser);
validatePetServiceCompatibility(pet, service); validatePetServiceCompatibility(pet, service);
validateAvailability(employee, service, request.getAppointmentDate(), request.getAppointmentTime(), null); validateAvailability(employee, service, request.getAppointmentDate(), request.getAppointmentTime(), null);
@@ -167,6 +183,23 @@ public class AppointmentService {
return mapToResponse(appointment); 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 @Transactional
public void deleteAppointment(Long id) { public void deleteAppointment(Long id) {
if (!appointmentRepository.existsById(id)) { if (!appointmentRepository.existsById(id)) {
@@ -350,6 +383,32 @@ public class AppointmentService {
return true; 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) { private void validateStoreAccess(Long requestedStoreId, User user) {
if (user.getRole() != User.Role.STAFF) { if (user.getRole() != User.Role.STAFF) {
return; return;

View File

@@ -12,6 +12,7 @@ import com.petshop.backend.entity.User;
import com.petshop.backend.exception.ResourceNotFoundException; import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.security.AppPrincipal; import com.petshop.backend.security.AppPrincipal;
import com.petshop.backend.repository.AdoptionRepository; import com.petshop.backend.repository.AdoptionRepository;
import com.petshop.backend.repository.AppointmentRepository;
import com.petshop.backend.repository.PetRepository; import com.petshop.backend.repository.PetRepository;
import com.petshop.backend.repository.StoreRepository; import com.petshop.backend.repository.StoreRepository;
import com.petshop.backend.repository.UserRepository; import com.petshop.backend.repository.UserRepository;
@@ -35,13 +36,15 @@ public class PetService {
private final PetRepository petRepository; private final PetRepository petRepository;
private final AdoptionRepository adoptionRepository; private final AdoptionRepository adoptionRepository;
private final AppointmentRepository appointmentRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final StoreRepository storeRepository; private final StoreRepository storeRepository;
private final CatalogImageStorageService catalogImageStorageService; 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.petRepository = petRepository;
this.adoptionRepository = adoptionRepository; this.adoptionRepository = adoptionRepository;
this.appointmentRepository = appointmentRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.storeRepository = storeRepository; this.storeRepository = storeRepository;
this.catalogImageStorageService = catalogImageStorageService; this.catalogImageStorageService = catalogImageStorageService;
@@ -87,8 +90,9 @@ public class PetService {
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<MyPetResponse> getMyPets(Long ownerUserId) { public List<MyPetResponse> getMyPets(Long ownerUserId, String status) {
return petRepository.findAllByOwner_IdOrderByPetNameAsc(ownerUserId).stream() return petRepository.findAllByOwner_IdOrderByPetNameAsc(ownerUserId).stream()
.filter(p -> status == null || status.isBlank() || status.equalsIgnoreCase(p.getPetStatus()))
.map(this::mapToMyPetResponse) .map(this::mapToMyPetResponse)
.toList(); .toList();
} }
@@ -117,6 +121,18 @@ public class PetService {
@Transactional @Transactional
public void deleteMyPet(Long ownerUserId, Long petId) { public void deleteMyPet(Long ownerUserId, Long petId) {
Pet pet = findOwnedPet(ownerUserId, 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()); deleteStoredImageIfPresent(pet.getImageUrl());
petRepository.delete(pet); petRepository.delete(pet);
} }
@@ -341,7 +357,9 @@ public class PetService {
pet.getPetName(), pet.getPetName(),
pet.getPetSpecies(), pet.getPetSpecies(),
pet.getPetBreed(), pet.getPetBreed(),
pet.getImageUrl() != null && !pet.getImageUrl().isBlank() ? "/api/v1/pets/" + pet.getPetId() + "/image" : null pet.getPetAge(),
pet.getImageUrl() != null && !pet.getImageUrl().isBlank() ? "/api/v1/pets/" + pet.getPetId() + "/image" : null,
pet.getPetStatus()
); );
} }
@@ -349,7 +367,7 @@ public class PetService {
pet.setPetName(request.getPetName().trim()); pet.setPetName(request.getPetName().trim());
pet.setPetSpecies(request.getSpecies().trim()); pet.setPetSpecies(request.getSpecies().trim());
pet.setPetBreed(normalizeOptional(request.getBreed())); pet.setPetBreed(normalizeOptional(request.getBreed()));
pet.setPetAge(null); pet.setPetAge(request.getPetAge());
pet.setPetPrice(null); pet.setPetPrice(null);
} }

View File

@@ -44,6 +44,8 @@ export default function PetDetailPage() {
petStatus={pet.petStatus} petStatus={pet.petStatus}
petPrice={pet.petPrice} petPrice={pet.petPrice}
imageUrl={pet.imageUrl} imageUrl={pet.imageUrl}
storeId={pet.storeId}
storeName={pet.storeName}
/> />
)} )}
</div> </div>

View File

@@ -7,6 +7,34 @@ import { useAuth } from "@/context/AuthContext";
const API_BASE = ""; 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 DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", 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>
<label className="appt-label"> <label className="appt-label">
Species Species
<input <select
className="appt-input" className="appt-select"
type="text"
value={species} value={species}
onChange={(e) => setSpecies(e.target.value)} onChange={(e) => { setSpecies(e.target.value); setBreed(""); }}
required 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>
<label className="appt-label"> <label className="appt-label">
Breed (optional) Breed
<input <select
className="appt-input" className="appt-select"
type="text"
value={breed} value={breed}
onChange={(e) => setBreed(e.target.value)} onChange={(e) => setBreed(e.target.value)}
maxLength={50} required
placeholder="e.g. Golden Retriever" 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> </label>
<div className="profile-pet-form-actions"> <div className="profile-pet-form-actions">
<button type="submit" className="appt-submit-btn" disabled={submitting}> <button type="submit" className="appt-submit-btn" disabled={submitting}>
@@ -294,8 +328,23 @@ function AppointmentsPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const preselectedPetId = searchParams.get("petId"); 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 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 [stores, setStores] = useState([]);
const [employees, setEmployees] = useState([]); const [employees, setEmployees] = useState([]);
const [services, setServices] = useState([]); const [services, setServices] = useState([]);
@@ -318,7 +367,11 @@ function AppointmentsPage() {
const [appointments, setAppointments] = useState([]); const [appointments, setAppointments] = useState([]);
const [loadingAppointments, setLoadingAppointments] = useState(false); const [loadingAppointments, setLoadingAppointments] = useState(false);
const [adoptions, setAdoptions] = useState([]);
const [loadingAdoptions, setLoadingAdoptions] = useState(false);
const [showAddPetModal, setShowAddPetModal] = useState(false); const [showAddPetModal, setShowAddPetModal] = useState(false);
const [cancellingId, setCancellingId] = useState(null);
const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN"; const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
@@ -330,6 +383,28 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
}, [authLoading, user, router, preselectedPetId]); }, [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(() => { const loadCustomerPets = useCallback(() => {
if (!token || !canBookAppointments) return; if (!token || !canBookAppointments) return;
fetch(`${API_BASE}/api/v1/my-pets`, { fetch(`${API_BASE}/api/v1/my-pets`, {
@@ -366,9 +441,24 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
}, [token, loadCustomerPets]); }, [token, loadCustomerPets]);
useEffect(() => { 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; return;
} }
if (!preselectedPetId || services.length === 0 || allPets.length === 0) { if (!preselectedPetId || services.length === 0 || allPets.length === 0) {
return; return;
} }
@@ -382,16 +472,13 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
} }
setSelectedPetIds([Number(preselectedPetId)]); setSelectedPetIds([Number(preselectedPetId)]);
didPreselectRef.current = true; didPreselectRef.current = true;
}, [preselectedPetId, services, allPets]); }, [adoptionMode, adoptionStoreId, preselectedPetId, services, allPets]);
const loadAppointments = useCallback(() => { const loadAppointments = useCallback(() => {
if (!token) return;
if (!token) {
return;
}
setLoadingAppointments(true); setLoadingAppointments(true);
fetch(`${API_BASE}/api/v1/appointments?size=50&sort=appointmentDate,desc`, { fetch(`${API_BASE}/api/v1/appointments?size=50&sort=appointmentDate,desc`, {
headers: {Authorization: `Bearer ${token}`}, headers: { Authorization: `Bearer ${token}` },
}) })
.then((r) => r.json()) .then((r) => r.json())
.then((data) => setAppointments(data.content ?? [])) .then((data) => setAppointments(data.content ?? []))
@@ -399,9 +486,62 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
.finally(() => setLoadingAppointments(false)); .finally(() => setLoadingAppointments(false));
}, [token]); }, [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(() => { useEffect(() => {
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(); loadAppointments();
}, [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(() => { useEffect(() => {
if (!token || !storeId) { if (!token || !storeId) {
@@ -453,30 +593,30 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
.finally(() => setLoadingSlots(false)); .finally(() => setLoadingSlots(false));
}, [storeId, serviceId, appointmentDate]); }, [storeId, serviceId, appointmentDate]);
const selectedService = services.find((s) => s.serviceId === Number(serviceId)); const eligiblePets = customerPets.filter(
const isAdoptionService = selectedService ? selectedService.serviceName.toLowerCase().includes("adopt") : false; (p) => p.petStatus === "Owned" || p.petStatus === "Adopted"
const isCustomerPetService = !!selectedService && !isAdoptionService;
const adoptablePets = allPets.filter(
(p) => p.petStatus && p.petStatus.toLowerCase() === "available"
); );
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) { function handleServiceChange(newServiceId) {
setServiceId(newServiceId); setServiceId(newServiceId);
setSelectedPetIds([]);
} }
function togglePet(petId) { function handlePetSelect(petId) {
if (isAdoptionService) { const newPet = eligiblePets.find((p) => p.customerPetId === petId);
setSelectedPetIds((prev) => setSelectedPetIds([petId]);
prev.includes(petId) ? [] : [petId] if (serviceId && newPet) {
); const newAvailable = getAvailableServices(services, newPet.species);
if (!newAvailable.some((s) => String(s.serviceId) === String(serviceId))) {
setServiceId("");
setAppointmentTime("");
setAvailableSlots([]);
} }
else {
setSelectedPetIds((prev) =>
prev.includes(petId) ? prev.filter((id) => id !== petId) : [...prev, petId]
);
} }
} }
@@ -495,8 +635,9 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
return d.toISOString().split("T")[0]; return d.toISOString().split("T")[0];
} }
const formValid = const formValid = adoptionMode
storeId && serviceId && appointmentDate && appointmentTime && selectedPetIds.length > 0; ? Boolean(employeeId && appointmentDate && adoptionVerified)
: storeId && serviceId && appointmentDate && appointmentTime && selectedPetIds.length > 0;
async function handleSubmit(e) { async function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
@@ -505,19 +646,59 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
if (!canBookAppointments) { if (!canBookAppointments) {
setError("Only customer accounts can book appointments from the web app."); setError("Only customer accounts can book appointments from the web app.");
return; return;
} }
if (selectedPetIds.length === 0) { 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; 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); setSubmitting(true);
try { 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,
adoptionDate: appointmentDate,
};
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("");
loadAdoptions();
return;
}
const body = { const body = {
customerId: user.customerId || user.id, customerId: user.customerId || user.id,
storeId: Number(storeId), storeId: Number(storeId),
@@ -577,12 +758,6 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
if (!user) return null; 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 ( return (
<main className="appt-page"> <main className="appt-page">
{showAddPetModal && ( {showAddPetModal && (
@@ -594,21 +769,74 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
)} )}
<section className="appt-hero"> <section className="appt-hero">
<h1 className="appt-hero-title">Schedule an Appointment</h1> <h1 className="appt-hero-title">{adoptionMode ? "Schedule an Adoption" : "Schedule an Appointment"}</h1>
<p className="appt-hero-subtitle">Book a service for your pet or schedule a pet adoption visit</p> <p className="appt-hero-subtitle">{adoptionMode ? "Schedule a pet adoption visit" : "Book a service for your pet."}</p>
<div className="title-decoration"></div> <div className="title-decoration"></div>
</section> </section>
<section className="appt-content"> <section className="appt-content">
{canBookAppointments ? ( {canBookAppointments ? (
<form className="appt-form" onSubmit={handleSubmit}> <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>} {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"> <label className="appt-label">
Store Location Store Location
{adoptionMode ? (
<div className="appt-locked-field">{adoptionStoreName || "Pet's store"}</div>
) : (
<select <select
className="appt-select" className="appt-select"
value={storeId} value={storeId}
@@ -620,10 +848,17 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
<option key={s.id} value={s.id}>{s.label}</option> <option key={s.id} value={s.id}>{s.label}</option>
))} ))}
</select> </select>
)}
</label> </label>
{!adoptionMode && (
<label className="appt-label"> <label className="appt-label">
Service Service
{availableServices.length === 0 ? (
<p className="appt-no-slots">
No services are available for {selectedPet?.species || "this pet"}.
</p>
) : (
<select <select
className="appt-select" className="appt-select"
value={serviceId} value={serviceId}
@@ -631,13 +866,15 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
required required
> >
<option value="">Select a service...</option> <option value="">Select a service...</option>
{services.map((s) => ( {availableServices.map((s) => (
<option key={s.serviceId} value={s.serviceId}> <option key={s.serviceId} value={s.serviceId}>
{s.serviceName} ${Number(s.servicePrice).toFixed(2)} ({s.serviceDuration} min) {s.serviceName} ${Number(s.servicePrice).toFixed(2)} ({s.serviceDuration} min)
</option> </option>
))} ))}
</select> </select>
)}
</label> </label>
)}
{employees.length > 0 && ( {employees.length > 0 && (
<label className="appt-label"> <label className="appt-label">
@@ -654,7 +891,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
</label> </label>
)} )}
{selectedService && ( {!adoptionMode && selectedService && (
<div className="appt-service-info"> <div className="appt-service-info">
<p>{selectedService.serviceDesc}</p> <p>{selectedService.serviceDesc}</p>
</div> </div>
@@ -669,7 +906,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
/> />
</div> </div>
{storeId && serviceId && appointmentDate && ( {!adoptionMode && storeId && serviceId && appointmentDate && (
<div className="appt-label"> <div className="appt-label">
<span>Available Time Slots</span> <span>Available Time Slots</span>
{loadingSlots ? ( {loadingSlots ? (
@@ -693,83 +930,62 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
</div> </div>
)} )}
{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 <button
type="submit" type="submit"
className="appt-submit-btn" className="appt-submit-btn"
disabled={!formValid || submitting} disabled={!formValid || submitting}
> >
{submitting ? "Booking..." : isAdoptionService ? "Schedule Adoption Visit" : "Book Appointment"} {submitting ? "Booking..." : adoptionMode ? "Schedule Adoption" : "Book Appointment"}
</button> </button>
{success && <div className="appt-success">{success}</div>}
</>)}
</>)}
</form> </form>
) : null} ) : null}
<div className="appt-history"> <div className="appt-history">
<h2 className="appt-form-title">{canBookAppointments ? "Your Appointments" : "Appointments"}</h2> <h2 className="appt-form-title">
{loadingAppointments ? ( {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> <p className="appt-loading">Loading appointments...</p>
) : appointments.length === 0 ? ( ) : appointments.length === 0 ? (
<p className="appt-empty">No appointments yet.</p> <p className="appt-empty">No appointments yet.</p>
@@ -797,6 +1013,18 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
Pets: {a.customerPetNames.join(", ")} Pets: {a.customerPetNames.join(", ")}
</div> </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>
))} ))}
</div> </div>

View File

@@ -1,31 +1,24 @@
const LOCATIONS = [ "use client";
{
name: "Downtown Branch",
address: "123 Main St",
phone: "(123) 456-7890",
email: "downtown@petshop.com",
},
{
name: "North Branch",
address: "456 North Ave",
phone: "(987) 654-3210",
email: "north@petshop.com",
},
{
name: "West Side Store",
address: "789 West Blvd",
phone: "(555) 123-4567",
email: "westside@petshop.com",
},
];
const PERSONNEL = [ import { useState, useEffect } from "react";
{ name: "John Doe", role: "Store Manager" },
{ name: "Sara Smith", role: "Staff" },
{ name: "Michael Johnson", role: "Grooming Team" },
];
export default function ContactPage() { export default function ContactPage() {
const [locations, setLocations] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const params = new URLSearchParams({ page: "0", size: "100", sort: "storeName,asc" });
fetch(`/api/v1/stores?${params}`)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => setLocations(data.content ?? []))
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []);
return ( return (
<main className="info-page"> <main className="info-page">
<section className="info-hero"> <section className="info-hero">
@@ -44,28 +37,40 @@ export default function ContactPage() {
<div className="info-card"> <div className="info-card">
<h2>Store Locations</h2> <h2>Store Locations</h2>
{loading && <p>Loading locations...</p>}
{error && <p style={{ color: "red" }}>Failed to load locations: {error}</p>}
{!loading && !error && locations.length === 0 && (
<p>No store locations found.</p>
)}
{!loading && !error && locations.length > 0 && (
<div className="info-card-grid"> <div className="info-card-grid">
{LOCATIONS.map((location) => ( {locations.map((location) => (
<article key={location.name} className="info-mini-card"> <article key={location.storeId} className="info-mini-card location-card">
<h3>{location.name}</h3> <div className="location-card-image-wrapper">
<img
src={location.imageUrl || "/images/pet-placeholder.png"}
alt={location.storeName}
className="location-card-image"
onError={(e) => {
e.currentTarget.onerror = null;
e.currentTarget.src = "/images/pet-placeholder.png";
}}
/>
</div>
<div className="location-card-body">
<h3>{location.storeName}</h3>
<p>{location.address}</p> <p>{location.address}</p>
<p>{location.phone}</p> <p>{location.phone}</p>
<p>{location.email}</p> <p>{location.email}</p>
</div>
</article> </article>
))} ))}
</div> </div>
</div> )}
<div className="info-card">
<h2>Store Personnel</h2>
<div className="info-card-grid">
{PERSONNEL.map((person) => (
<article key={person.name} className="info-mini-card">
<h3>{person.name}</h3>
<p>{person.role}</p>
</article>
))}
</div>
</div> </div>
</section> </section>
</main> </main>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 176 KiB

View File

@@ -38,7 +38,7 @@ body {
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
min-height: 70px; min-height: 70px;
border-radius: 0px 0px 10px 10px; /* border-radius: 0px 0px 10px 10px; */
} }
/* Add padding to body to account for fixed header */ /* Add padding to body to account for fixed header */
@@ -62,11 +62,9 @@ body {
.nav-links { .nav-links {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 2rem; gap: 1.25rem;
position: absolute; flex: 1;
left: 50%; justify-content: center;
top: 50%;
transform: translate(-50%, -50%);
} }
/* Indivdual Link Styles */ /* Indivdual Link Styles */
@@ -758,6 +756,39 @@ body {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.location-card {
padding: 0;
overflow: hidden;
}
.location-card-image-wrapper {
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
background: #eee;
}
.location-card-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.location-card-body {
padding: 1rem;
}
.location-card-body h3 {
margin-top: 0;
margin-bottom: 0.5rem;
}
.location-card-body p {
margin: 0.25rem 0;
font-size: 0.9rem;
color: #555;
}
.products-hero { .products-hero {
text-align: center; text-align: center;
padding: 4rem 2rem 3rem; padding: 4rem 2rem 3rem;
@@ -1356,6 +1387,17 @@ body {
box-shadow: 0 0 0 3px rgba(255, 165, 0, 0.2); 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 { .appt-service-info {
background: #fff8f0; background: #fff8f0;
border: 1px solid #ffd180; border: 1px solid #ffd180;
@@ -1616,6 +1658,11 @@ body {
color: #c62828; color: #c62828;
} }
.appt-card-status--pending {
background: #fff8e1;
color: #f57f17;
}
.appt-card-details { .appt-card-details {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -1629,6 +1676,34 @@ body {
margin-top: 0.35rem; 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 */ /* Adoption Pet Selection */
.appt-adopt-grid { .appt-adopt-grid {
@@ -2573,7 +2648,24 @@ body {
/* Mobile / Responsive */ /* Mobile / Responsive */
@media (max-width: 768px) { /* Compact nav at mid-range widths before collapsing to hamburger */
@media (min-width: 1101px) and (max-width: 1350px) {
.nav-links {
gap: 0.25rem;
}
.nav-link {
font-size: 0.9rem;
padding: 0.4rem 0.5rem;
}
.nav-auth {
gap: 0.35rem;
padding-left: 0.5rem;
}
}
@media (max-width: 1100px) {
.navbar { .navbar {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
} }
@@ -2629,7 +2721,7 @@ body {
display: none; display: none;
} }
@media (max-width: 768px) { @media (max-width: 1100px) {
/* Show hamburger bar, hide desktop nav */ /* Show hamburger bar, hide desktop nav */
.nav-mobile-bar { .nav-mobile-bar {
display: flex; display: flex;

View File

@@ -6,6 +6,18 @@ import { useAuth } from "@/context/AuthContext";
const API_BASE = ""; 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() { export default function ProfilePage() {
const {user, token, loading, logout, refreshUser} = useAuth(); const {user, token, loading, logout, refreshUser} = useAuth();
const router = useRouter(); const router = useRouter();
@@ -18,9 +30,11 @@ export default function ProfilePage() {
const [petName, setPetName] = useState(""); const [petName, setPetName] = useState("");
const [species, setSpecies] = useState(""); const [species, setSpecies] = useState("");
const [breed, setBreed] = useState(""); const [breed, setBreed] = useState("");
const [petAge, setPetAge] = useState("1");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [petError, setPetError] = useState(null); const [petError, setPetError] = useState(null);
const [profileForm, setProfileForm] = useState({ fullName: "", email: "", phone: "" }); const [avatarObjectUrl, setAvatarObjectUrl] = useState(null);
const [profileForm, setProfileForm] = useState({ firstName: "", lastName: "", email: "", phone: "", password: "", confirmPassword: "" });
const [profileSubmitting, setProfileSubmitting] = useState(false); const [profileSubmitting, setProfileSubmitting] = useState(false);
const [profileError, setProfileError] = useState(null); const [profileError, setProfileError] = useState(null);
const [profileSuccess, setProfileSuccess] = useState(null); const [profileSuccess, setProfileSuccess] = useState(null);
@@ -42,9 +56,12 @@ export default function ProfilePage() {
useEffect(() => { useEffect(() => {
setProfileForm({ setProfileForm({
fullName: user?.fullName || "", firstName: user?.firstName || "",
lastName: user?.lastName || "",
email: user?.email || "", email: user?.email || "",
phone: user?.phone || "", phone: user?.phone || "",
password: "",
confirmPassword: "",
}); });
}, [user]); }, [user]);
@@ -53,7 +70,7 @@ export default function ProfilePage() {
setLoadingPets(true); setLoadingPets(true);
try { 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}` }, headers: { Authorization: `Bearer ${token}` },
}); });
@@ -64,25 +81,21 @@ export default function ProfilePage() {
const petData = await response.json(); const petData = await response.json();
clearPetImageObjectUrls(); clearPetImageObjectUrls();
const petsWithResolvedImages = await Promise.all( const ownedPets = Array.isArray(petData) ? petData : [];
(Array.isArray(petData) ? petData : []).map(async (pet) => {
if (!pet.imageUrl) {
return pet;
}
const petsWithResolvedImages = await Promise.all(
ownedPets.map(async (pet) => {
if (!pet.imageUrl) return pet;
try { try {
const imageResponse = await fetch(`${API_BASE}${pet.imageUrl}`, { const imageResponse = await fetch(`${API_BASE}${pet.imageUrl}`, {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}); });
if (!imageResponse.ok) { if (!imageResponse.ok) return { ...pet, imageUrl: null };
return { ...pet, imageUrl: null };
}
const blob = await imageResponse.blob(); const blob = await imageResponse.blob();
const objectUrl = URL.createObjectURL(blob); const objectUrl = URL.createObjectURL(blob);
petImageObjectUrlsRef.current.push(objectUrl); petImageObjectUrlsRef.current.push(objectUrl);
return { ...pet, imageUrl: objectUrl }; return { ...pet, imageUrl: objectUrl };
} catch { } catch {
return { ...pet, imageUrl: null }; return { ...pet, imageUrl: null };
@@ -108,11 +121,37 @@ export default function ProfilePage() {
}, [clearPetImageObjectUrls]); }, [clearPetImageObjectUrls]);
useEffect(() => { useEffect(() => {
if (user?.role === "CUSTOMER" || user?.role === "ADMIN") { if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
loadPets(); loadPets();
} }
}, [user, loadPets]); }, [user, loadPets]);
useEffect(() => {
let objectUrl = null;
if (user?.avatarUrl && token) {
fetch(`${API_BASE}${user.avatarUrl}`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => (res.ok ? res.blob() : null))
.then((blob) => {
if (blob) {
objectUrl = URL.createObjectURL(blob);
setAvatarObjectUrl(objectUrl);
} else {
setAvatarObjectUrl(null);
}
})
.catch(() => setAvatarObjectUrl(null));
} else {
setAvatarObjectUrl(null);
}
return () => {
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [user?.avatarUrl, token]);
function handleLogout() { function handleLogout() {
logout(); logout();
router.push("/"); router.push("/");
@@ -120,10 +159,26 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
async function handleProfileSubmit(e) { async function handleProfileSubmit(e) {
e.preventDefault(); e.preventDefault();
setProfileSubmitting(true);
setProfileError(null); setProfileError(null);
if (profileForm.password && profileForm.password !== profileForm.confirmPassword) {
setProfileError("Passwords do not match.");
return;
}
setProfileSubmitting(true);
setProfileSuccess(null); setProfileSuccess(null);
const payload = {
firstName: profileForm.firstName,
lastName: profileForm.lastName,
email: profileForm.email,
phone: profileForm.phone,
};
if (profileForm.password) {
payload.password = profileForm.password;
}
try { try {
const res = await fetch(`${API_BASE}/api/v1/auth/me`, { const res = await fetch(`${API_BASE}/api/v1/auth/me`, {
method: "PUT", method: "PUT",
@@ -131,7 +186,7 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify(profileForm), body: JSON.stringify(payload),
}); });
const data = await res.json().catch(() => null); const data = await res.json().catch(() => null);
@@ -140,6 +195,7 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
} }
await refreshUser(); await refreshUser();
setProfileForm((prev) => ({ ...prev, password: "", confirmPassword: "" }));
setProfileSuccess("Profile updated successfully."); setProfileSuccess("Profile updated successfully.");
} }
@@ -222,6 +278,7 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
setPetName(""); setPetName("");
setSpecies(""); setSpecies("");
setBreed(""); setBreed("");
setPetAge("1");
setPetError(null); setPetError(null);
setShowForm(true); setShowForm(true);
} }
@@ -231,6 +288,7 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
setPetName(pet.petName); setPetName(pet.petName);
setSpecies(pet.species); setSpecies(pet.species);
setBreed(pet.breed || ""); setBreed(pet.breed || "");
setPetAge(pet.petAge != null ? String(pet.petAge) : "1");
setPetError(null); setPetError(null);
setShowForm(true); setShowForm(true);
} }
@@ -257,7 +315,7 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify({ petName, species, breed: breed || null }), body: JSON.stringify({ petName, species, breed: breed || null, petAge: Number(petAge) }),
}); });
if (!res.ok) { if (!res.ok) {
@@ -284,14 +342,19 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
} }
try { try {
await fetch(`${API_BASE}/api/v1/my-pets/${id}`, { const res = await fetch(`${API_BASE}/api/v1/my-pets/${id}`, {
method: "DELETE", method: "DELETE",
headers: { Authorization: `Bearer ${token}` }, 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(); loadPets();
} }
catch { catch (err) {
alert(err.message);
} }
} }
@@ -324,12 +387,14 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
return <main className="auth-page"><p className="profile-loading">Loading</p></main>; return <main className="auth-page"><p className="profile-loading">Loading</p></main>;
} }
const displayName = [user.firstName, user.lastName].filter(Boolean).join(" ") || user.username;
const fields = [ const fields = [
{label: "Full Name", value: user.fullName}, {label: "First Name", value: user.firstName || "N/A"},
{label: "Last Name", value: user.lastName || "N/A"},
{label: "Username", value: user.username}, {label: "Username", value: user.username},
{label: "Email", value: user.email}, {label: "Email", value: user.email},
{label: "Phone", value: user.phone || ""}, {label: "Phone", value: user.phone || "N/A"},
{label: "Role", value: user.role},
...(user.storeName ? [{ label: "Store", value: user.storeName }] : []), ...(user.storeName ? [{ label: "Store", value: user.storeName }] : []),
]; ];
@@ -337,14 +402,14 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
<main className="profile-page-layout"> <main className="profile-page-layout">
<div className="profile-card"> <div className="profile-card">
<div className="profile-avatar-circle"> <div className="profile-avatar-circle">
{user.avatarUrl ? ( {avatarObjectUrl ? (
<img src={user.avatarUrl} alt={user.fullName || user.username} className="profile-avatar-image" /> <img src={avatarObjectUrl} alt={displayName} className="profile-avatar-image" />
) : ( ) : (
(user.fullName || user.username).charAt(0).toUpperCase() displayName.charAt(0).toUpperCase()
)} )}
</div> </div>
<h1 className="profile-name">{user.fullName || user.username}</h1> <h1 className="profile-name">{displayName}</h1>
<span className="profile-role-badge">{user.role}</span> <span className="profile-role-badge">{user.role}</span>
<dl className="profile-fields"> <dl className="profile-fields">
@@ -361,13 +426,23 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
{profileError && <div className="appt-error">{profileError}</div>} {profileError && <div className="appt-error">{profileError}</div>}
{profileSuccess && <div className="appt-success">{profileSuccess}</div>} {profileSuccess && <div className="appt-success">{profileSuccess}</div>}
<label className="appt-label"> <label className="appt-label">
Full Name First Name
<input <input
className="appt-input" className="appt-input"
type="text" type="text"
value={profileForm.fullName} value={profileForm.firstName}
onChange={(e) => setProfileForm((current) => ({ ...current, fullName: e.target.value }))} onChange={(e) => setProfileForm((current) => ({ ...current, firstName: e.target.value }))}
maxLength={100} maxLength={50}
/>
</label>
<label className="appt-label">
Last Name
<input
className="appt-input"
type="text"
value={profileForm.lastName}
onChange={(e) => setProfileForm((current) => ({ ...current, lastName: e.target.value }))}
maxLength={50}
/> />
</label> </label>
<label className="appt-label"> <label className="appt-label">
@@ -390,6 +465,29 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
maxLength={20} maxLength={20}
/> />
</label> </label>
<label className="appt-label">
New Password
<input
className="appt-input"
type="password"
value={profileForm.password}
onChange={(e) => setProfileForm((current) => ({ ...current, password: e.target.value }))}
minLength={6}
autoComplete="new-password"
placeholder="Leave blank to keep current"
/>
</label>
<label className="appt-label">
Confirm New Password
<input
className="appt-input"
type="password"
value={profileForm.confirmPassword}
onChange={(e) => setProfileForm((current) => ({ ...current, confirmPassword: e.target.value }))}
autoComplete="new-password"
placeholder="Leave blank to keep current"
/>
</label>
<div className="profile-avatar-actions"> <div className="profile-avatar-actions">
<label className="profile-avatar-upload-btn"> <label className="profile-avatar-upload-btn">
<input <input
@@ -445,26 +543,45 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
</label> </label>
<label className="appt-label"> <label className="appt-label">
Species Species
<input <select
className="appt-input" className="appt-select"
type="text"
value={species} value={species}
onChange={(e) => setSpecies(e.target.value)} onChange={(e) => { setSpecies(e.target.value); setBreed(""); }}
required 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>
<label className="appt-label"> <label className="appt-label">
Breed (optional) Breed
<input <select
className="appt-input" className="appt-select"
type="text"
value={breed} value={breed}
onChange={(e) => setBreed(e.target.value)} onChange={(e) => setBreed(e.target.value)}
maxLength={50} required
placeholder="e.g. Golden Retriever" 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>
<label className="appt-label">
Age (years)
<select
className="appt-select"
value={petAge}
onChange={(e) => setPetAge(e.target.value)}
required
>
{Array.from({ length: 20 }, (_, i) => i + 1).map((n) => (
<option key={n} value={n}>{n}</option>
))}
</select>
</label> </label>
<div className="profile-pet-form-actions"> <div className="profile-pet-form-actions">
<button type="submit" className="appt-submit-btn" disabled={submitting}> <button type="submit" className="appt-submit-btn" disabled={submitting}>
@@ -512,6 +629,7 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
<span className="profile-pet-card-name">{pet.petName}</span> <span className="profile-pet-card-name">{pet.petName}</span>
<span className="profile-pet-card-detail">{pet.species}</span> <span className="profile-pet-card-detail">{pet.species}</span>
{pet.breed && <span className="profile-pet-card-detail">{pet.breed}</span>} {pet.breed && <span className="profile-pet-card-detail">{pet.breed}</span>}
{pet.petAge != null && <span className="profile-pet-card-detail">Age: {pet.petAge === 0 ? "< 1 yr" : `${pet.petAge} yr${pet.petAge !== 1 ? "s" : ""}`}</span>}
</div> </div>
<div className="profile-pet-card-actions"> <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-edit-btn" onClick={() => openEditForm(pet)}>Edit</button>

View File

@@ -22,12 +22,14 @@ function RegisterPage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [form, setForm] = useState({ const [form, setForm] = useState({
fullName: "", firstName: "",
lastName: "",
username: "", username: "",
email: "", email: "",
phone: "", phone: "",
password: "", password: "",
confirmPassword: "",}); confirmPassword: "",
});
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -47,7 +49,9 @@ function RegisterPage() {
setLoading(true); setLoading(true);
try { try {
await register({fullName: form.fullName, await register({
firstName: form.firstName,
lastName: form.lastName,
username: form.username, username: form.username,
email: form.email, email: form.email,
phone: form.phone, phone: form.phone,
@@ -74,12 +78,24 @@ function RegisterPage() {
<form className="auth-form" onSubmit={handleSubmit}> <form className="auth-form" onSubmit={handleSubmit}>
<label className="auth-label"> <label className="auth-label">
Full Name First Name
<input <input
className="auth-input" className="auth-input"
type="text" type="text"
name="fullName" name="firstName"
value={form.fullName} value={form.firstName}
onChange={handleChange}
required
/>
</label>
<label className="auth-label">
Last Name
<input
className="auth-input"
type="text"
name="lastName"
value={form.lastName}
onChange={handleChange} onChange={handleChange}
required required
/> />

View File

@@ -43,7 +43,6 @@ export default function Footer() {
<ul className="footer-links footer-contact"> <ul className="footer-links footer-contact">
<li>(403) 123-4567</li> <li>(403) 123-4567</li>
<li>support@leonspetstore.com</li> <li>support@leonspetstore.com</li>
<li>123 Street Street, Calgary, Alberta, Canada</li>
</ul> </ul>
</div> </div>

View File

@@ -1,7 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { getStatusClass } from "@/components/petUtils"; 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 ( return (
<div className="pet-detail-card"> <div className="pet-detail-card">
<div className="pet-detail-image-wrapper"> <div className="pet-detail-image-wrapper">
@@ -53,7 +53,10 @@ export default function PetProfile({ petId, petName, petSpecies, petBreed, petAg
<p className="pet-detail-cta-text"> <p className="pet-detail-cta-text">
Interested in adopting {petName}? Visit us in store or schedule an appointment. Interested in adopting {petName}? Visit us in store or schedule an appointment.
</p> </p>
<Link href={`/appointments?petId=${petId}`} className="pet-detail-cta-btn"> <Link
href={`/appointments?adoptionMode=true&petId=${petId}&petName=${encodeURIComponent(petName || "")}&petSpecies=${encodeURIComponent(petSpecies || "")}&petBreed=${encodeURIComponent(petBreed || "")}${storeId ? `&storeId=${storeId}` : ""}${storeName ? `&storeName=${encodeURIComponent(storeName)}` : ""}`}
className="pet-detail-cta-btn"
>
Schedule an Appointment Schedule an Appointment
</Link> </Link>
</div> </div>

View File

@@ -79,15 +79,21 @@ export function AuthProvider({ children }) {
return userInfo; return userInfo;
}, [refreshUser]); }, [refreshUser]);
const register = useCallback(async ({ username, password, email, fullName, phone }) => { const register = useCallback(async ({ username, password, email, firstName, lastName, phone }) => {
const res = await fetch("/api/v1/auth/register", { const res = await fetch("/api/v1/auth/register", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password, email, fullName, phone }), body: JSON.stringify({ username, password, email, firstName, lastName, phone }),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
if (data.errors && typeof data.errors === "object") {
const fieldErrors = Object.entries(data.errors)
.map(([field, msg]) => `${field}: ${msg}`)
.join(", ");
throw new Error(fieldErrors || data.message || "Registration failed");
}
throw new Error(data.message || "Registration failed"); throw new Error(data.message || "Registration failed");
} }