diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..c4c4ffc6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.zip diff --git a/backend/src/main/java/com/petshop/backend/controller/AuthController.java b/backend/src/main/java/com/petshop/backend/controller/AuthController.java index b426aadc..106ea66f 100644 --- a/backend/src/main/java/com/petshop/backend/controller/AuthController.java +++ b/backend/src/main/java/com/petshop/backend/controller/AuthController.java @@ -9,6 +9,7 @@ import com.petshop.backend.dto.auth.RegisterResponse; import com.petshop.backend.dto.auth.UserInfoResponse; import com.petshop.backend.entity.EmployeeStore; import com.petshop.backend.entity.User; +import com.petshop.backend.repository.CustomerRepository; import com.petshop.backend.repository.EmployeeRepository; import com.petshop.backend.repository.EmployeeStoreRepository; import com.petshop.backend.repository.UserRepository; @@ -47,8 +48,9 @@ public class AuthController { private final EmployeeRepository employeeRepository; private final EmployeeStoreRepository employeeStoreRepository; private final AvatarStorageService avatarStorageService; + private final CustomerRepository customerRepository; - public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, UserBusinessLinkageService userBusinessLinkageService, EmployeeRepository employeeRepository, EmployeeStoreRepository employeeStoreRepository, AvatarStorageService avatarStorageService) { + public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, UserBusinessLinkageService userBusinessLinkageService, EmployeeRepository employeeRepository, EmployeeStoreRepository employeeStoreRepository, AvatarStorageService avatarStorageService, CustomerRepository customerRepository) { this.authenticationManager = authenticationManager; this.userRepository = userRepository; this.jwtUtil = jwtUtil; @@ -57,6 +59,7 @@ public class AuthController { this.employeeRepository = employeeRepository; this.employeeStoreRepository = employeeStoreRepository; this.avatarStorageService = avatarStorageService; + this.customerRepository = customerRepository; } @PostMapping("/register") @@ -147,6 +150,7 @@ public class AuthController { User user = getAuthenticatedUser(); EmployeeStore employeeStore = resolveEmployeeStore(user); + Long customerId = resolveCustomerId(user); return ResponseEntity.ok(new UserInfoResponse( user.getId(), @@ -156,6 +160,7 @@ public class AuthController { user.getPhone(), avatarStorageService.toOwnerAvatarUrl(user), user.getRole().name(), + customerId, employeeStore != null ? employeeStore.getStore().getStoreId() : null, employeeStore != null ? employeeStore.getStore().getStoreName() : null )); @@ -216,6 +221,7 @@ public class AuthController { userBusinessLinkageService.syncLinkedRecords(updatedUser); EmployeeStore employeeStore = resolveEmployeeStore(updatedUser); + Long customerId = resolveCustomerId(updatedUser); return ResponseEntity.ok(new UserInfoResponse( updatedUser.getId(), @@ -225,6 +231,7 @@ public class AuthController { updatedUser.getPhone(), avatarStorageService.toOwnerAvatarUrl(updatedUser), updatedUser.getRole().name(), + customerId, employeeStore != null ? employeeStore.getStore().getStoreId() : null, employeeStore != null ? employeeStore.getStore().getStoreName() : null )); @@ -240,6 +247,12 @@ public class AuthController { .orElse(null); } + private Long resolveCustomerId(User user) { + return customerRepository.findByUserId(user.getId()) + .map(c -> c.getCustomerId()) + .orElse(null); + } + private String trimToNull(String value) { if (value == null) { return null; diff --git a/backend/src/main/java/com/petshop/backend/controller/CustomerPetController.java b/backend/src/main/java/com/petshop/backend/controller/CustomerPetController.java new file mode 100644 index 00000000..4fd6648b --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/controller/CustomerPetController.java @@ -0,0 +1,118 @@ +package com.petshop.backend.controller; + +import com.petshop.backend.dto.customerpet.CustomerPetRequest; +import com.petshop.backend.dto.customerpet.CustomerPetResponse; +import com.petshop.backend.service.CatalogImageStorageService; +import com.petshop.backend.service.CustomerPetService; +import com.petshop.backend.entity.CustomerPet; +import com.petshop.backend.repository.CustomerPetRepository; +import com.petshop.backend.repository.CustomerRepository; +import com.petshop.backend.repository.UserRepository; +import com.petshop.backend.entity.Customer; +import com.petshop.backend.util.AuthenticationHelper; +import jakarta.validation.Valid; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/my-pets") +@PreAuthorize("hasRole('CUSTOMER')") +public class CustomerPetController { + + private final CustomerPetService customerPetService; + private final CustomerPetRepository customerPetRepository; + private final CustomerRepository customerRepository; + private final UserRepository userRepository; + private final CatalogImageStorageService catalogImageStorageService; + + public CustomerPetController(CustomerPetService customerPetService, + CustomerPetRepository customerPetRepository, + CustomerRepository customerRepository, + UserRepository userRepository, + CatalogImageStorageService catalogImageStorageService) { + this.customerPetService = customerPetService; + this.customerPetRepository = customerPetRepository; + this.customerRepository = customerRepository; + this.userRepository = userRepository; + this.catalogImageStorageService = catalogImageStorageService; + } + + @GetMapping + public ResponseEntity> getMyPets() { + + return ResponseEntity.ok(customerPetService.getMyPets()); + } + + @PostMapping + public ResponseEntity createPet(@Valid @RequestBody CustomerPetRequest request) { + + return ResponseEntity.status(HttpStatus.CREATED).body(customerPetService.createPet(request)); + } + + @PutMapping("/{id}") + public ResponseEntity updatePet(@PathVariable Long id, @Valid @RequestBody CustomerPetRequest request) { + + return ResponseEntity.ok(customerPetService.updatePet(id, request)); + } + + @DeleteMapping("/{id}") + public ResponseEntity deletePet(@PathVariable Long id) { + customerPetService.deletePet(id); + + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{id}/image") + public ResponseEntity uploadImage(@PathVariable Long id, @RequestParam("image") MultipartFile image) { + try { + + return ResponseEntity.ok(customerPetService.uploadImage(id, image)); + } + + catch (IllegalArgumentException ex) { + Map error = new HashMap<>(); + error.put("message", ex.getMessage()); + + return ResponseEntity.badRequest().body(error); + } + + catch (IOException ex) { + Map error = new HashMap<>(); + error.put("message", "Failed to upload image: " + ex.getMessage()); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); + } + } + + @GetMapping("/{id}/image") + public ResponseEntity getImage(@PathVariable Long id) { + Customer customer = AuthenticationHelper.getAuthenticatedCustomer(userRepository, customerRepository); + CustomerPet pet = customerPetRepository.findByCustomerPetIdAndCustomerCustomerId(id, customer.getCustomerId()).orElse(null); + + if (pet == null || pet.getImageUrl() == null || pet.getImageUrl().isBlank()) { + + return ResponseEntity.notFound().build(); + } + + Resource resource = catalogImageStorageService.loadPetImage(pet.getImageUrl()); + MediaType mediaType = catalogImageStorageService.resolveMediaType(resource); + + return ResponseEntity.ok().contentType(mediaType).body(resource); + } + + @DeleteMapping("/{id}/image") + public ResponseEntity deleteImage(@PathVariable Long id) { + + return ResponseEntity.ok(customerPetService.deleteImage(id)); + } +} diff --git a/backend/src/main/java/com/petshop/backend/dto/appointment/AppointmentRequest.java b/backend/src/main/java/com/petshop/backend/dto/appointment/AppointmentRequest.java index 247e5ae4..8423d090 100644 --- a/backend/src/main/java/com/petshop/backend/dto/appointment/AppointmentRequest.java +++ b/backend/src/main/java/com/petshop/backend/dto/appointment/AppointmentRequest.java @@ -1,6 +1,5 @@ package com.petshop.backend.dto.appointment; -import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import java.time.LocalDate; import java.time.LocalTime; @@ -26,9 +25,10 @@ public class AppointmentRequest { @NotNull(message = "Appointment status is required") private String appointmentStatus; - @NotEmpty(message = "At least one pet must be specified") private List petIds; + private List customerPetIds; + public Long getCustomerId() { return customerId; } @@ -85,6 +85,14 @@ public class AppointmentRequest { this.petIds = petIds; } + public List getCustomerPetIds() { + return customerPetIds; + } + + public void setCustomerPetIds(List customerPetIds) { + this.customerPetIds = customerPetIds; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -96,12 +104,13 @@ public class AppointmentRequest { Objects.equals(appointmentDate, that.appointmentDate) && Objects.equals(appointmentTime, that.appointmentTime) && Objects.equals(appointmentStatus, that.appointmentStatus) && - Objects.equals(petIds, that.petIds); + Objects.equals(petIds, that.petIds) && + Objects.equals(customerPetIds, that.customerPetIds); } @Override public int hashCode() { - return Objects.hash(customerId, storeId, serviceId, appointmentDate, appointmentTime, appointmentStatus, petIds); + return Objects.hash(customerId, storeId, serviceId, appointmentDate, appointmentTime, appointmentStatus, petIds, customerPetIds); } @Override @@ -114,6 +123,7 @@ public class AppointmentRequest { ", appointmentTime=" + appointmentTime + ", appointmentStatus='" + appointmentStatus + '\'' + ", petIds=" + petIds + + ", customerPetIds=" + customerPetIds + '}'; } } diff --git a/backend/src/main/java/com/petshop/backend/dto/appointment/AppointmentResponse.java b/backend/src/main/java/com/petshop/backend/dto/appointment/AppointmentResponse.java index c7d2e8d7..f6398248 100644 --- a/backend/src/main/java/com/petshop/backend/dto/appointment/AppointmentResponse.java +++ b/backend/src/main/java/com/petshop/backend/dto/appointment/AppointmentResponse.java @@ -19,6 +19,8 @@ public class AppointmentResponse { private String appointmentStatus; private List petNames; private List petIds; + private List customerPetNames; + private List customerPetIds; private LocalDateTime createdAt; private LocalDateTime updatedAt; @@ -138,6 +140,24 @@ public class AppointmentResponse { this.petIds = petIds; } + public List getCustomerPetNames() { + + return customerPetNames; + } + + public void setCustomerPetNames(List customerPetNames) { + this.customerPetNames = customerPetNames; + } + + public List getCustomerPetIds() { + + return customerPetIds; + } + + public void setCustomerPetIds(List customerPetIds) { + this.customerPetIds = customerPetIds; + } + public LocalDateTime getCreatedAt() { return createdAt; } diff --git a/backend/src/main/java/com/petshop/backend/dto/auth/UserInfoResponse.java b/backend/src/main/java/com/petshop/backend/dto/auth/UserInfoResponse.java index ba714a49..84372638 100644 --- a/backend/src/main/java/com/petshop/backend/dto/auth/UserInfoResponse.java +++ b/backend/src/main/java/com/petshop/backend/dto/auth/UserInfoResponse.java @@ -10,13 +10,14 @@ public class UserInfoResponse { private String phone; private String avatarUrl; private String role; + private Long customerId; private Long storeId; private String storeName; public UserInfoResponse() { } - public UserInfoResponse(Long id, String username, String email, String fullName, String phone, String avatarUrl, String role, Long storeId, String storeName) { + public UserInfoResponse(Long id, String username, String email, String fullName, String phone, String avatarUrl, String role, Long customerId, Long storeId, String storeName) { this.id = id; this.username = username; this.email = email; @@ -24,6 +25,7 @@ public class UserInfoResponse { this.phone = phone; this.avatarUrl = avatarUrl; this.role = role; + this.customerId = customerId; this.storeId = storeId; this.storeName = storeName; } @@ -84,6 +86,15 @@ public class UserInfoResponse { this.role = role; } + public Long getCustomerId() { + + return customerId; + } + + public void setCustomerId(Long customerId) { + this.customerId = customerId; + } + public Long getStoreId() { return storeId; } @@ -112,13 +123,14 @@ public class UserInfoResponse { Objects.equals(phone, that.phone) && Objects.equals(avatarUrl, that.avatarUrl) && Objects.equals(role, that.role) && + Objects.equals(customerId, that.customerId) && Objects.equals(storeId, that.storeId) && Objects.equals(storeName, that.storeName); } @Override public int hashCode() { - return Objects.hash(id, username, email, fullName, phone, avatarUrl, role, storeId, storeName); + return Objects.hash(id, username, email, fullName, phone, avatarUrl, role, customerId, storeId, storeName); } @Override @@ -131,6 +143,7 @@ public class UserInfoResponse { ", phone='" + phone + '\'' + ", avatarUrl='" + avatarUrl + '\'' + ", role='" + role + '\'' + + ", customerId=" + customerId + ", storeId=" + storeId + ", storeName='" + storeName + '\'' + '}'; diff --git a/backend/src/main/java/com/petshop/backend/dto/customerpet/CustomerPetRequest.java b/backend/src/main/java/com/petshop/backend/dto/customerpet/CustomerPetRequest.java new file mode 100644 index 00000000..b4b37355 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/dto/customerpet/CustomerPetRequest.java @@ -0,0 +1,66 @@ +package com.petshop.backend.dto.customerpet; + +import jakarta.validation.constraints.NotBlank; + +import java.util.Objects; + +public class CustomerPetRequest { + + @NotBlank(message = "Pet name is required") + private String petName; + + @NotBlank(message = "Species is required") + private String species; + + private String breed; + + public String getPetName() { + + return petName; + } + + public void setPetName(String petName) { + this.petName = petName; + } + + public String getSpecies() { + + return species; + } + + public void setSpecies(String species) { + this.species = species; + } + + public String getBreed() { + + return breed; + } + + public void setBreed(String breed) { + this.breed = breed; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + + return true; + } + + if (o == null || getClass() != o.getClass()) { + + return false; + } + + CustomerPetRequest that = (CustomerPetRequest) o; + + return Objects.equals(petName, that.petName) && Objects.equals(species, that.species) && Objects.equals(breed, that.breed); + } + + @Override + public int hashCode() { + + return Objects.hash(petName, species, breed); + } +} diff --git a/backend/src/main/java/com/petshop/backend/dto/customerpet/CustomerPetResponse.java b/backend/src/main/java/com/petshop/backend/dto/customerpet/CustomerPetResponse.java new file mode 100644 index 00000000..8e9ab37d --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/dto/customerpet/CustomerPetResponse.java @@ -0,0 +1,123 @@ +package com.petshop.backend.dto.customerpet; + +import java.time.LocalDateTime; +import java.util.Objects; + +public class CustomerPetResponse { + + private Long customerPetId; + private Long customerId; + private String petName; + private String species; + private String breed; + private String imageUrl; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public CustomerPetResponse() { + } + + public CustomerPetResponse(Long customerPetId, Long customerId, String petName, String species, String breed, String imageUrl, LocalDateTime createdAt, LocalDateTime updatedAt) { + this.customerPetId = customerPetId; + this.customerId = customerId; + this.petName = petName; + this.species = species; + this.breed = breed; + this.imageUrl = imageUrl; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public Long getCustomerPetId() { + + return customerPetId; + } + + public void setCustomerPetId(Long customerPetId) { + this.customerPetId = customerPetId; + } + + public Long getCustomerId() { + + return customerId; + } + + public void setCustomerId(Long customerId) { + this.customerId = customerId; + } + + public String getPetName() { + + return petName; + } + + public void setPetName(String petName) { + this.petName = petName; + } + + public String getSpecies() { + + return species; + } + + public void setSpecies(String species) { + this.species = species; + } + + public String getBreed() { + + return breed; + } + + public void setBreed(String breed) { + this.breed = breed; + } + + public String getImageUrl() { + + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + + public LocalDateTime getCreatedAt() { + + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (o == null || getClass() != o.getClass()) { + return false; + } + + CustomerPetResponse that = (CustomerPetResponse) o; + + return Objects.equals(customerPetId, that.customerPetId); + } + + @Override + public int hashCode() { + + return Objects.hash(customerPetId); + } +} diff --git a/backend/src/main/java/com/petshop/backend/entity/Appointment.java b/backend/src/main/java/com/petshop/backend/entity/Appointment.java index 101ff885..cec10224 100644 --- a/backend/src/main/java/com/petshop/backend/entity/Appointment.java +++ b/backend/src/main/java/com/petshop/backend/entity/Appointment.java @@ -48,6 +48,14 @@ public class Appointment { ) private Set pets = new HashSet<>(); + @ManyToMany + @JoinTable( + name = "appointment_customer_pet", + joinColumns = @JoinColumn(name = "appointment_id"), + inverseJoinColumns = @JoinColumn(name = "customer_pet_id") + ) + private Set customerPets = new HashSet<>(); + @CreationTimestamp @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; @@ -136,6 +144,15 @@ public class Appointment { this.pets = pets; } + public Set getCustomerPets() { + + return customerPets; + } + + public void setCustomerPets(Set customerPets) { + this.customerPets = customerPets; + } + public LocalDateTime getCreatedAt() { return createdAt; } diff --git a/backend/src/main/java/com/petshop/backend/entity/CustomerPet.java b/backend/src/main/java/com/petshop/backend/entity/CustomerPet.java new file mode 100644 index 00000000..df75df8c --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/entity/CustomerPet.java @@ -0,0 +1,137 @@ +package com.petshop.backend.entity; + +import jakarta.persistence.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Entity +@Table(name = "customer_pet") +public class CustomerPet { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "customer_pet_id") + private Long customerPetId; + + @ManyToOne + @JoinColumn(name = "customer_id", nullable = false) + private Customer customer; + + @Column(name = "pet_name", nullable = false, length = 50) + private String petName; + + @Column(nullable = false, length = 50) + private String species; + + @Column(length = 50) + private String breed; + + @Column(name = "image_url", length = 255) + private String imageUrl; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + public CustomerPet() { + } + + public Long getCustomerPetId() { + + return customerPetId; + } + + public void setCustomerPetId(Long customerPetId) { + this.customerPetId = customerPetId; + } + + public Customer getCustomer() { + + return customer; + } + + public void setCustomer(Customer customer) { + this.customer = customer; + } + + public String getPetName() { + + return petName; + } + + public void setPetName(String petName) { + this.petName = petName; + } + + public String getSpecies() { + + return species; + } + + public void setSpecies(String species) { + this.species = species; + } + + public String getBreed() { + + return breed; + } + + public void setBreed(String breed) { + this.breed = breed; + } + + public String getImageUrl() { + + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + + public LocalDateTime getCreatedAt() { + + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (o == null || getClass() != o.getClass()) { + return false; + } + + CustomerPet that = (CustomerPet) o; + return Objects.equals(customerPetId, that.customerPetId); + } + + @Override + public int hashCode() { + + return Objects.hash(customerPetId); + } +} diff --git a/backend/src/main/java/com/petshop/backend/repository/CustomerPetRepository.java b/backend/src/main/java/com/petshop/backend/repository/CustomerPetRepository.java new file mode 100644 index 00000000..8d08f8b9 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/repository/CustomerPetRepository.java @@ -0,0 +1,16 @@ +package com.petshop.backend.repository; + +import com.petshop.backend.entity.CustomerPet; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface CustomerPetRepository extends JpaRepository { + + List findByCustomerCustomerIdOrderByCreatedAtDesc(Long customerId); + + Optional findByCustomerPetIdAndCustomerCustomerId(Long customerPetId, Long customerId); +} diff --git a/backend/src/main/java/com/petshop/backend/service/AppointmentService.java b/backend/src/main/java/com/petshop/backend/service/AppointmentService.java index ef5f9cdb..67ce4f36 100644 --- a/backend/src/main/java/com/petshop/backend/service/AppointmentService.java +++ b/backend/src/main/java/com/petshop/backend/service/AppointmentService.java @@ -5,6 +5,7 @@ import com.petshop.backend.dto.appointment.AppointmentResponse; import com.petshop.backend.dto.common.BulkDeleteRequest; import com.petshop.backend.entity.Appointment; import com.petshop.backend.entity.Customer; +import com.petshop.backend.entity.CustomerPet; import com.petshop.backend.entity.Employee; import com.petshop.backend.entity.EmployeeStore; import com.petshop.backend.entity.Pet; @@ -12,6 +13,7 @@ import com.petshop.backend.entity.StoreLocation; import com.petshop.backend.entity.User; import com.petshop.backend.exception.ResourceNotFoundException; import com.petshop.backend.repository.AppointmentRepository; +import com.petshop.backend.repository.CustomerPetRepository; import com.petshop.backend.repository.CustomerRepository; import com.petshop.backend.repository.EmployeeRepository; import com.petshop.backend.repository.EmployeeStoreRepository; @@ -40,6 +42,7 @@ public class AppointmentService { private final AppointmentRepository appointmentRepository; private final CustomerRepository customerRepository; + private final CustomerPetRepository customerPetRepository; private final ServiceRepository serviceRepository; private final PetRepository petRepository; private final StoreRepository storeRepository; @@ -47,9 +50,10 @@ public class AppointmentService { private final EmployeeRepository employeeRepository; private final EmployeeStoreRepository employeeStoreRepository; - public AppointmentService(AppointmentRepository appointmentRepository, CustomerRepository customerRepository, ServiceRepository serviceRepository, PetRepository petRepository, StoreRepository storeRepository, UserRepository userRepository, EmployeeRepository employeeRepository, EmployeeStoreRepository employeeStoreRepository) { + public AppointmentService(AppointmentRepository appointmentRepository, CustomerRepository customerRepository, CustomerPetRepository customerPetRepository, ServiceRepository serviceRepository, PetRepository petRepository, StoreRepository storeRepository, UserRepository userRepository, EmployeeRepository employeeRepository, EmployeeStoreRepository employeeStoreRepository) { this.appointmentRepository = appointmentRepository; this.customerRepository = customerRepository; + this.customerPetRepository = customerPetRepository; this.serviceRepository = serviceRepository; this.petRepository = petRepository; this.storeRepository = storeRepository; @@ -107,7 +111,16 @@ public class AppointmentService { validateStoreAccess(store.getStoreId()); validateAvailability(store, service, request.getAppointmentDate(), request.getAppointmentTime(), null); - Set pets = fetchPets(request.getPetIds()); + boolean hasPetIds = request.getPetIds() != null && !request.getPetIds().isEmpty(); + boolean hasCustomerPetIds = request.getCustomerPetIds() != null && !request.getCustomerPetIds().isEmpty(); + + if (!hasPetIds && !hasCustomerPetIds) { + + throw new IllegalArgumentException("Please specify at least one pet."); + } + + Set pets = hasPetIds ? fetchPets(request.getPetIds()) : new HashSet<>(); + Set customerPets = hasCustomerPetIds ? fetchCustomerPets(request.getCustomerPetIds()) : new HashSet<>(); Appointment appointment = new Appointment(); appointment.setCustomer(customer); @@ -117,6 +130,7 @@ public class AppointmentService { appointment.setAppointmentTime(request.getAppointmentTime()); appointment.setAppointmentStatus(request.getAppointmentStatus()); appointment.setPets(pets); + appointment.setCustomerPets(customerPets); appointment = appointmentRepository.save(appointment); return mapToResponse(appointment); @@ -141,7 +155,16 @@ public class AppointmentService { validateStoreAccess(store.getStoreId()); validateAvailability(store, service, request.getAppointmentDate(), request.getAppointmentTime(), id); - Set pets = fetchPets(request.getPetIds()); + boolean hasPetIds = request.getPetIds() != null && !request.getPetIds().isEmpty(); + boolean hasCustomerPetIds = request.getCustomerPetIds() != null && !request.getCustomerPetIds().isEmpty(); + + if (!hasPetIds && !hasCustomerPetIds) { + + throw new IllegalArgumentException("Please specify at least one pet."); + } + + Set pets = hasPetIds ? fetchPets(request.getPetIds()) : new HashSet<>(); + Set customerPets = hasCustomerPetIds ? fetchCustomerPets(request.getCustomerPetIds()) : new HashSet<>(); appointment.setCustomer(customer); appointment.setStore(store); @@ -150,6 +173,7 @@ public class AppointmentService { appointment.setAppointmentTime(request.getAppointmentTime()); appointment.setAppointmentStatus(request.getAppointmentStatus()); appointment.setPets(pets); + appointment.setCustomerPets(customerPets); appointment = appointmentRepository.save(appointment); return mapToResponse(appointment); @@ -223,6 +247,17 @@ public class AppointmentService { return pets; } + private Set fetchCustomerPets(List customerPetIds) { + Set customerPets = new HashSet<>(); + for (Long customerPetId : customerPetIds) { + CustomerPet customerPet = customerPetRepository.findById(customerPetId) + .orElseThrow(() -> new ResourceNotFoundException("Customer pet not found with id: " + customerPetId)); + customerPets.add(customerPet); + } + + return customerPets; + } + private AppointmentResponse mapToResponse(Appointment appointment) { List petNames = appointment.getPets().stream() .map(Pet::getPetName) @@ -232,22 +267,33 @@ public class AppointmentService { .map(Pet::getPetId) .collect(Collectors.toList()); - return new AppointmentResponse( - appointment.getAppointmentId(), - appointment.getCustomer().getCustomerId(), - appointment.getCustomer().getFirstName() + " " + appointment.getCustomer().getLastName(), - appointment.getStore().getStoreId(), - appointment.getStore().getStoreName(), - appointment.getService().getServiceId(), - appointment.getService().getServiceName(), - appointment.getAppointmentDate(), - appointment.getAppointmentTime(), - appointment.getAppointmentStatus(), - petNames, - petIds, - appointment.getCreatedAt(), - appointment.getUpdatedAt() - ); + List customerPetNames = appointment.getCustomerPets().stream() + .map(CustomerPet::getPetName) + .collect(Collectors.toList()); + + List customerPetIds = appointment.getCustomerPets().stream() + .map(CustomerPet::getCustomerPetId) + .collect(Collectors.toList()); + + AppointmentResponse response = new AppointmentResponse(); + response.setAppointmentId(appointment.getAppointmentId()); + response.setCustomerId(appointment.getCustomer().getCustomerId()); + response.setCustomerName(appointment.getCustomer().getFirstName() + " " + appointment.getCustomer().getLastName()); + response.setStoreId(appointment.getStore().getStoreId()); + response.setStoreName(appointment.getStore().getStoreName()); + response.setServiceId(appointment.getService().getServiceId()); + response.setServiceName(appointment.getService().getServiceName()); + response.setAppointmentDate(appointment.getAppointmentDate()); + response.setAppointmentTime(appointment.getAppointmentTime()); + response.setAppointmentStatus(appointment.getAppointmentStatus()); + response.setPetNames(petNames); + response.setPetIds(petIds); + response.setCustomerPetNames(customerPetNames); + response.setCustomerPetIds(customerPetIds); + response.setCreatedAt(appointment.getCreatedAt()); + response.setUpdatedAt(appointment.getUpdatedAt()); + + return response; } //------------------------------------ diff --git a/backend/src/main/java/com/petshop/backend/service/CustomerPetService.java b/backend/src/main/java/com/petshop/backend/service/CustomerPetService.java new file mode 100644 index 00000000..fa424737 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/service/CustomerPetService.java @@ -0,0 +1,163 @@ +package com.petshop.backend.service; + +import com.petshop.backend.dto.customerpet.CustomerPetRequest; +import com.petshop.backend.dto.customerpet.CustomerPetResponse; +import com.petshop.backend.entity.Customer; +import com.petshop.backend.entity.CustomerPet; +import com.petshop.backend.exception.ResourceNotFoundException; +import com.petshop.backend.repository.CustomerPetRepository; +import com.petshop.backend.repository.CustomerRepository; +import com.petshop.backend.repository.UserRepository; +import com.petshop.backend.util.AuthenticationHelper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + +@Service +public class CustomerPetService { + + private final CustomerPetRepository customerPetRepository; + private final CustomerRepository customerRepository; + private final UserRepository userRepository; + private final CatalogImageStorageService catalogImageStorageService; + + public CustomerPetService(CustomerPetRepository customerPetRepository, + CustomerRepository customerRepository, + UserRepository userRepository, + CatalogImageStorageService catalogImageStorageService) { + this.customerPetRepository = customerPetRepository; + this.customerRepository = customerRepository; + this.userRepository = userRepository; + this.catalogImageStorageService = catalogImageStorageService; + } + + @Transactional(readOnly = true) + public List getMyPets() { + Customer customer = AuthenticationHelper.getAuthenticatedCustomer(userRepository, customerRepository); + + return customerPetRepository.findByCustomerCustomerIdOrderByCreatedAtDesc(customer.getCustomerId()) + .stream() + .map(this::mapToResponse) + .collect(Collectors.toList()); + } + + @Transactional + public CustomerPetResponse createPet(CustomerPetRequest request) { + Customer customer = AuthenticationHelper.getAuthenticatedCustomer(userRepository, customerRepository); + + CustomerPet pet = new CustomerPet(); + pet.setCustomer(customer); + pet.setPetName(request.getPetName()); + pet.setSpecies(request.getSpecies()); + pet.setBreed(request.getBreed()); + + pet = customerPetRepository.save(pet); + + return mapToResponse(pet); + } + + @Transactional + public CustomerPetResponse updatePet(Long id, CustomerPetRequest request) { + Customer customer = AuthenticationHelper.getAuthenticatedCustomer(userRepository, customerRepository); + CustomerPet pet = customerPetRepository.findByCustomerPetIdAndCustomerCustomerId(id, customer.getCustomerId()) + .orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + id)); + + pet.setPetName(request.getPetName()); + pet.setSpecies(request.getSpecies()); + pet.setBreed(request.getBreed()); + + pet = customerPetRepository.save(pet); + + return mapToResponse(pet); + } + + @Transactional + public void deletePet(Long id) { + Customer customer = AuthenticationHelper.getAuthenticatedCustomer(userRepository, customerRepository); + CustomerPet pet = customerPetRepository.findByCustomerPetIdAndCustomerCustomerId(id, customer.getCustomerId()).orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + id)); + deleteStoredImageIfPresent(pet.getImageUrl()); + + customerPetRepository.delete(pet); + } + + @Transactional + public CustomerPetResponse uploadImage(Long id, MultipartFile file) throws IOException { + validateImageFile(file); + Customer customer = AuthenticationHelper.getAuthenticatedCustomer(userRepository, customerRepository); + CustomerPet pet = customerPetRepository.findByCustomerPetIdAndCustomerCustomerId(id, customer.getCustomerId()).orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + id)); + deleteStoredImageIfPresent(pet.getImageUrl()); + pet.setImageUrl(catalogImageStorageService.storePetImage(file)); + + return mapToResponse(customerPetRepository.save(pet)); + } + + @Transactional + public CustomerPetResponse deleteImage(Long id) { + Customer customer = AuthenticationHelper.getAuthenticatedCustomer(userRepository, customerRepository); + CustomerPet pet = customerPetRepository.findByCustomerPetIdAndCustomerCustomerId(id, customer.getCustomerId()).orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + id)); + deleteStoredImageIfPresent(pet.getImageUrl()); + pet.setImageUrl(null); + + return mapToResponse(customerPetRepository.save(pet)); + } + + private CustomerPetResponse mapToResponse(CustomerPet pet) { + return new CustomerPetResponse( + pet.getCustomerPetId(), + pet.getCustomer().getCustomerId(), + pet.getPetName(), + pet.getSpecies(), + pet.getBreed(), + pet.getImageUrl() != null && !pet.getImageUrl().isBlank() + ? "/api/v1/my-pets/" + pet.getCustomerPetId() + "/image" + : null, + pet.getCreatedAt(), + pet.getUpdatedAt() + ); + } + + private void validateImageFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + + throw new IllegalArgumentException("Please select an image to upload"); + } + + if (file.getSize() > 5 * 1024 * 1024) { + + throw new IllegalArgumentException("Image file size must be less than 5MB"); + } + + String contentType = file.getContentType(); + + if (contentType == null) { + + throw new IllegalArgumentException("Only JPG, PNG, and GIF images are allowed"); + } + + String normalized = contentType.toLowerCase(Locale.ROOT); + + if (!normalized.equals("image/jpeg") && !normalized.equals("image/png") && !normalized.equals("image/gif")) { + + throw new IllegalArgumentException("Only JPG, PNG, and GIF images are allowed"); + } + } + + private void deleteStoredImageIfPresent(String storedImagePath) { + if (storedImagePath == null || storedImagePath.isBlank()) { + + return; + } + + try { + catalogImageStorageService.deletePetImage(storedImagePath); + } + + catch (IOException ignored) { + } + } +} diff --git a/backend/src/main/resources/db/migration/V10__adoption_service.sql b/backend/src/main/resources/db/migration/V10__adoption_service.sql new file mode 100644 index 00000000..dd5a57ca --- /dev/null +++ b/backend/src/main/resources/db/migration/V10__adoption_service.sql @@ -0,0 +1,2 @@ +INSERT INTO service (serviceName, serviceDesc, serviceDuration, servicePrice) +VALUES ('Pet Adoption', 'Schedule a visit to meet and adopt an available pet', 30, 0.00); diff --git a/backend/src/main/resources/db/migration/V11__appointment_customer_pet.sql b/backend/src/main/resources/db/migration/V11__appointment_customer_pet.sql new file mode 100644 index 00000000..d112fda0 --- /dev/null +++ b/backend/src/main/resources/db/migration/V11__appointment_customer_pet.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS appointment_customer_pet ( + appointment_id BIGINT NOT NULL, + customer_pet_id BIGINT NOT NULL, + PRIMARY KEY (appointment_id, customer_pet_id), + FOREIGN KEY (appointment_id) REFERENCES appointment(appointmentId), + FOREIGN KEY (customer_pet_id) REFERENCES customer_pet(customer_pet_id) +); diff --git a/backend/src/main/resources/db/migration/V9__customer_pet.sql b/backend/src/main/resources/db/migration/V9__customer_pet.sql new file mode 100644 index 00000000..0981bca2 --- /dev/null +++ b/backend/src/main/resources/db/migration/V9__customer_pet.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS customer_pet ( + customer_pet_id BIGINT AUTO_INCREMENT PRIMARY KEY, + customer_id BIGINT NOT NULL, + pet_name VARCHAR(50) NOT NULL, + species VARCHAR(50) NOT NULL, + breed VARCHAR(50) NULL, + image_url VARCHAR(255) NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (customer_id) REFERENCES customer(customerId) +); diff --git a/web/app/adopt/[id]/page.js b/web/app/adopt/[id]/page.js index 21a24aa4..0834ca01 100644 --- a/web/app/adopt/[id]/page.js +++ b/web/app/adopt/[id]/page.js @@ -38,12 +38,14 @@ export default function PetDetailPage() { {!loading && !error && pet && ( )} diff --git a/web/app/adopt/page.js b/web/app/adopt/page.js index 9a30f6ea..430a1653 100644 --- a/web/app/adopt/page.js +++ b/web/app/adopt/page.js @@ -119,6 +119,7 @@ export default function AdoptPage() { petName={pet.petName} petSpecies={pet.petSpecies} petStatus={pet.petStatus} + imageUrl={pet.imageUrl} /> ))} diff --git a/web/app/appointments/page.js b/web/app/appointments/page.js new file mode 100644 index 00000000..d3f7943e --- /dev/null +++ b/web/app/appointments/page.js @@ -0,0 +1,640 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useAuth } from "@/context/AuthContext"; + +const API_BASE = ""; + +const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", +]; + +function DatePicker({ value, minDate, onChange }) { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const min = minDate ? new Date(minDate + "T00:00:00") : today; + + const parsed = value ? new Date(value + "T00:00:00") : null; + const [viewYear, setViewYear] = useState(parsed ? parsed.getFullYear() : min.getFullYear()); + const [viewMonth, setViewMonth] = useState(parsed ? parsed.getMonth() : min.getMonth()); + + function prevMonth() { + if (viewMonth === 0) { + setViewMonth(11); + setViewYear((y) => y - 1); + } + + else { + setViewMonth((m) => m - 1); + } + } + + function nextMonth() { + if (viewMonth === 11) { + setViewMonth(0); setViewYear((y) => y + 1); + } + + else { + setViewMonth((m) => m + 1); + } + } + + const firstDay = new Date(viewYear, viewMonth, 1).getDay(); + const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate(); + + const minYear = min.getFullYear(); + const minMonth = min.getMonth(); + const isPrevDisabled = viewYear < minYear || (viewYear === minYear && viewMonth <= minMonth); + + function selectDay(day) { + const d = new Date(viewYear, viewMonth, day); + if (d < min) { + return; + } + const iso = `${viewYear}-${String(viewMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; + + onChange(iso); + } + + function isSelected(day) { + if (!parsed) return false; + return parsed.getFullYear() === viewYear && parsed.getMonth() === viewMonth && parsed.getDate() === day; + } + + function isDisabled(day) { + return new Date(viewYear, viewMonth, day) < min; + } + + const cells = []; + for (let i = 0; i < firstDay; i++) cells.push(null); + for (let d = 1; d <= daysInMonth; d++) cells.push(d); + + const s = { + widget: { + border: "1px solid #ddd", + borderRadius: "10px", + overflow: "hidden", + background: "white", + userSelect: "none", + fontFamily: "inherit", + }, + header: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + background: "orange", + padding: "0.55rem 0.75rem", + }, + monthLabel: { + fontSize: "0.95rem", + fontWeight: 700, + color: "white", + }, + nav: { + background: "none", + border: "none", + color: "white", + fontSize: "1.5rem", + lineHeight: 1, + cursor: "pointer", + padding: "0 0.4rem", + borderRadius: "4px", + }, + grid: { + display: "grid", + gridTemplateColumns: "repeat(7, 1fr)", + gap: "3px", + padding: "0.6rem", + }, + dayName: { + textAlign: "center", + fontSize: "0.7rem", + fontWeight: 700, + color: "#aaa", + padding: "0.25rem 0", + textTransform: "uppercase", + }, + dayBase: { + display: "flex", + alignItems: "center", + justifyContent: "center", + aspectRatio: "1 / 1", + border: "none", + borderRadius: "6px", + background: "none", + fontSize: "0.875rem", + cursor: "pointer", + color: "#333", + fontFamily: "inherit", + padding: 0, + width: "100%", + }, + daySelected: { + background: "orange", + color: "white", + fontWeight: 700, + }, + dayDisabled: { + color: "#ccc", + cursor: "default", + }, + selectedLabel: { + textAlign: "center", + fontSize: "0.82rem", + color: "#666", + padding: "0.35rem 0.5rem 0.5rem", + borderTop: "1px solid #f0f0f0", + }, + }; + + return ( +
+
+ + {MONTHS[viewMonth]} {viewYear} + +
+
+ {DAYS.map((d) => ( + {d} + ))} + {cells.map((day, i) => + day === null ? ( + + ) : ( + + ) + )} +
+ {parsed && ( +
+ Selected: {MONTHS[parsed.getMonth()]} {parsed.getDate()}, {parsed.getFullYear()} +
+ )} +
+ ); +} + +export default function AppointmentsPage() { + const { user, token, loading: authLoading } = useAuth(); + const router = useRouter(); + const searchParams = useSearchParams(); + const preselectedPetId = searchParams.get("petId"); + const didPreselectRef = useRef(false); + + const [stores, setStores] = useState([]); + const [services, setServices] = useState([]); + const [allPets, setAllPets] = useState([]); + const [customerPets, setCustomerPets] = useState([]); + const [availableSlots, setAvailableSlots] = useState([]); + + const [storeId, setStoreId] = useState(""); + const [serviceId, setServiceId] = useState(""); + const [appointmentDate, setAppointmentDate] = useState(""); + const [appointmentTime, setAppointmentTime] = useState(""); + const [selectedPetIds, setSelectedPetIds] = useState([]); + + const [loadingSlots, setLoadingSlots] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const [appointments, setAppointments] = useState([]); + const [loadingAppointments, setLoadingAppointments] = useState(false); + + useEffect(() => { + if (!authLoading && !user) { + router.push("/login"); + } + + }, [authLoading, user, router]); + + useEffect(() => { + if (!token) { + return; + } + + fetch(`${API_BASE}/api/v1/dropdowns/stores`, { + headers: { Authorization: `Bearer ${token}` }, + }) + .then((r) => r.json()) + .then(setStores) + .catch(() => {}); + + fetch(`${API_BASE}/api/v1/services?size=100`) + .then((r) => r.json()) + .then((data) => setServices(data.content ?? [])) + .catch(() => {}); + + fetch(`${API_BASE}/api/v1/pets?size=200&sort=id,asc`) + .then((r) => r.json()) + .then((data) => setAllPets(data.content ?? [])) + .catch(() => {}); + + fetch(`${API_BASE}/api/v1/my-pets`, { + headers: { Authorization: `Bearer ${token}` }, + }) + .then((r) => r.json()) + .then((data) => setCustomerPets(Array.isArray(data) ? data : [])) + .catch(() => {}); + }, [token]); + + useEffect(() => { + if (didPreselectRef.current) { + return; + } + if (!preselectedPetId || services.length === 0 || allPets.length === 0) { + return; + } + + const adoptionSvc = services.find((s) => + s.serviceName.toLowerCase().includes("adopt") + ); + + if (adoptionSvc) { + setServiceId(String(adoptionSvc.serviceId)); + } + setSelectedPetIds([Number(preselectedPetId)]); + didPreselectRef.current = true; + }, [preselectedPetId, services, allPets]); + + const loadAppointments = useCallback(() => { + + if (!token) { + return; + } + setLoadingAppointments(true); + fetch(`${API_BASE}/api/v1/appointments?size=50&sort=appointmentDate,desc`, { + headers: {Authorization: `Bearer ${token}`}, + }) + .then((r) => r.json()) + .then((data) => setAppointments(data.content ?? [])) + .catch(() => {}) + .finally(() => setLoadingAppointments(false)); + }, [token]); + + useEffect(() => { + loadAppointments(); + }, [loadAppointments]); + + useEffect(() => { + if (!storeId || !serviceId || !appointmentDate) { + setAvailableSlots([]); + setAppointmentTime(""); + + return; + } + setLoadingSlots(true); + setAppointmentTime(""); + const params = new URLSearchParams({ storeId, serviceId, date: appointmentDate }); + fetch(`${API_BASE}/api/v1/appointments/availability?${params}`) + .then((r) => { + if (!r.ok) { + throw new Error("Failed to check availability"); + } + + return r.json(); + }) + .then(setAvailableSlots) + .catch(() => setAvailableSlots([])) + .finally(() => setLoadingSlots(false)); + }, [storeId, serviceId, appointmentDate]); + + const selectedService = services.find((s) => s.serviceId === Number(serviceId)); + const isAdoptionService = selectedService ? selectedService.serviceName.toLowerCase().includes("adopt") : false; + const isCustomerPetService = !!selectedService && !isAdoptionService; + + const adoptablePets = allPets.filter( + (p) => p.petStatus && p.petStatus.toLowerCase() === "available" + ); + + function handleServiceChange(newServiceId) { + setServiceId(newServiceId); + setSelectedPetIds([]); + } + + function togglePet(petId) { + if (isAdoptionService) { + setSelectedPetIds((prev) => + prev.includes(petId) ? [] : [petId] + ); + } + + else { + setSelectedPetIds((prev) => + prev.includes(petId) ? prev.filter((id) => id !== petId) : [...prev, petId] + ); + } + } + + function formatTime(timeStr) { + const [h, m] = timeStr.split(":"); + const hour = parseInt(h, 10); + const ampm = hour >= 12 ? "PM" : "AM"; + const display = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; + + return `${display}:${m} ${ampm}`; + } + + function getMinDate() { + const d = new Date(); + d.setDate(d.getDate() + 1); + + return d.toISOString().split("T")[0]; + } + + const formValid = + storeId && serviceId && appointmentDate && appointmentTime && selectedPetIds.length > 0; + + async function handleSubmit(e) { + e.preventDefault(); + setError(null); + setSuccess(null); + + if (!user?.customerId) { + setError("Customer account not found. Please contact support."); + + return; + } + + if (selectedPetIds.length === 0) { + setError(isAdoptionService ? "Please select a pet to adopt." : "Please select at least one pet."); + + return; + } + + setSubmitting(true); + + try { + const body = { + customerId: user.customerId, + storeId: Number(storeId), + serviceId: Number(serviceId), + appointmentDate, + appointmentTime: appointmentTime + ":00", + appointmentStatus: "Booked", + }; + + if (isCustomerPetService) { + body.customerPetIds = selectedPetIds; + } + + else { + body.petIds = selectedPetIds; + } + + const res = await fetch(`${API_BASE}/api/v1/appointments`, { + 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("Appointment booked successfully!"); + setStoreId(""); + setServiceId(""); + setAppointmentDate(""); + setAppointmentTime(""); + setSelectedPetIds([]); + setAvailableSlots([]); + loadAppointments(); + } + + catch (err) { + setError(err.message); + } + + finally { + setSubmitting(false); + } + } + + if (authLoading) { + + return ( +
+

Loading...

+
+ ); + } + + 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. Please add your pets in your profile before booking."; + + return ( +
+
+

Schedule an Appointment

+

Book a service for your pet or schedule a pet adoption visit

+
+
+ +
+
+

New Appointment

+ + {error &&
{error}
} + {success &&
{success}
} + + + + + + {selectedService && ( +
+

{selectedService.serviceDesc}

+
+ )} + +
+ Date + +
+ + {storeId && serviceId && appointmentDate && ( +
+ Available Time Slots + {loadingSlots ? ( +

Checking availability...

+ ) : availableSlots.length === 0 ? ( +

No available slots for this date. Please try another date.

+ ) : ( +
+ {availableSlots.map((slot) => ( + + ))} +
+ )} +
+ )} + + {serviceId && ( +
+ {petSectionLabel} + {petsToShow.length === 0 ? ( +

{noPetsMessage}

+ ) : isAdoptionService ? ( +
+ {petsToShow.map((p) => ( + + ))} +
+ ) : ( +
+ {petsToShow.map((p) => ( + + ))} +
+ )} +
+ )} + + +
+ +
+

Your Appointments

+ {loadingAppointments ? ( +

Loading appointments...

+ ) : appointments.length === 0 ? ( +

No appointments yet.

+ ) : ( +
+ {appointments.map((a) => ( +
+
+ {a.serviceName} + + {a.appointmentStatus} + +
+
+ {a.storeName} + {a.appointmentDate} at {formatTime(a.appointmentTime)} +
+ {a.petNames && a.petNames.length > 0 && ( +
+ Pets: {a.petNames.join(", ")} +
+ )} + {a.customerPetNames && a.customerPetNames.length > 0 && ( +
+ Pets: {a.customerPetNames.join(", ")} +
+ )} +
+ ))} +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/web/app/globals.css b/web/app/globals.css index 465c429d..500ccbb0 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -232,7 +232,7 @@ body { border-radius: 2px; } -/* ─── Adopt Page ─────────────────────────────────────────────── */ +/* Adopt page */ .adopt-page { min-height: 100vh; @@ -373,6 +373,12 @@ body { line-height: 1; } +.pet-card-image { + width: 100%; + height: 100%; + object-fit: cover; +} + .pet-card-body { padding: 1rem 1.25rem 1.25rem; display: flex; @@ -459,7 +465,7 @@ body { color: #555; } -/* ─── Pet Detail Page ─────────────────────────────────────────── */ +/* Pet details */ .pet-detail-page { min-height: 100vh; @@ -508,6 +514,12 @@ body { line-height: 1; } +.pet-detail-image { + width: 100%; + height: 100%; + object-fit: cover; +} + .pet-detail-info { flex: 1; padding: 2.5rem 2.5rem 2.5rem 0; @@ -599,7 +611,41 @@ body { background: #e69500; } -/* ─── Responsive Design ──────────────────────────────────────── */ +/* Products Page */ + +.products-page { + min-height: 100vh; +} + +.products-hero { + text-align: center; + padding: 4rem 2rem 3rem; + background: linear-gradient(to bottom, #f9f9f9, #ffffff); +} + +.products-hero-title { + font-size: 3rem; + color: #333; + margin-bottom: 1rem; + font-weight: 700; + letter-spacing: -0.5px; +} + +.products-hero-subtitle { + font-size: 1.5rem; + color: #666; + margin-bottom: 2rem; + font-weight: 300; +} + +.product-card-price { + display: inline-block; + margin-top: 0.4rem; + font-size: 1.05rem; + font-weight: 700; + color: #1a7a3c; +} + /* Responsive Design */ @media (max-width: 1024px) { .adopt-grid { @@ -608,11 +654,13 @@ body { } @media (max-width: 768px) { - .adopt-hero-title { + .adopt-hero-title, + .products-hero-title { font-size: 2rem; } - .adopt-hero-subtitle { + .adopt-hero-subtitle, + .products-hero-subtitle { font-size: 1.2rem; } @@ -641,7 +689,8 @@ body { grid-template-columns: 1fr; } - .adopt-hero-title { + .adopt-hero-title, + .products-hero-title { font-size: 1.6rem; } @@ -705,7 +754,7 @@ body { padding: 2rem 1rem; } } -/* ─── Adopt diagnostic additions ────────────────────────────── */ +/* Adopt diagnostics */ .adopt-controls-row { display: flex; @@ -1020,3 +1069,633 @@ body { color: #888; font-size: 1rem; } + +/* Appointments Page */ + +.appt-page { + min-height: 100vh; +} + +.appt-hero { + text-align: center; + padding: 3rem 2rem 2rem; + background: linear-gradient(135deg, #fff8f0 0%, #fff3e0 100%); +} + +.appt-hero-title { + font-size: 2.2rem; + font-weight: 800; + color: #222; + margin: 0 0 0.5rem; +} + +.appt-hero-subtitle { + font-size: 1.1rem; + color: #666; + margin: 0 0 1rem; +} + +.appt-content { + max-width: 900px; + margin: 0 auto; + padding: 2rem; + display: flex; + flex-direction: column; + gap: 2.5rem; +} + +.appt-form { + background: white; + border-radius: 16px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08); + padding: 2rem; + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.appt-form-title { + font-size: 1.35rem; + font-weight: 700; + color: #222; + margin: 0; +} + +.appt-label { + display: flex; + flex-direction: column; + gap: 0.35rem; + font-size: 0.9rem; + font-weight: 600; + color: #444; +} + +.appt-select, +.appt-input { + padding: 0.6rem 0.85rem; + border: 1px solid #ddd; + border-radius: 8px; + font-size: 1rem; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + outline: none; + background: white; +} + +.appt-select:focus, +.appt-input:focus { + border-color: orange; + box-shadow: 0 0 0 3px rgba(255, 165, 0, 0.2); +} + +.appt-service-info { + background: #fff8f0; + border: 1px solid #ffd180; + border-radius: 8px; + padding: 0.75rem 1rem; + font-size: 0.9rem; + color: #555; +} + +.appt-service-info p { + margin: 0; +} + +.appt-slots-grid { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.25rem; +} + +.appt-slot-btn { + padding: 0.45rem 0.9rem; + border: 1px solid #ddd; + border-radius: 20px; + background: white; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.appt-slot-btn:hover { + border-color: orange; + background: #fff8f0; +} + +.appt-slot-btn--selected { + background: orange; + color: white; + border-color: orange; +} + +.appt-slot-btn--selected:hover { + background: #e69500; +} + +.appt-slots-loading, +.appt-no-slots { + font-size: 0.9rem; + color: #888; + font-weight: 400; + margin: 0.25rem 0 0; +} + +.appt-pets-grid { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.25rem; +} + +.appt-pet-chip { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.4rem 0.85rem; + border: 1px solid #ddd; + border-radius: 20px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + user-select: none; +} + +.appt-pet-chip:hover { + border-color: orange; + background: #fff8f0; +} + +.appt-pet-chip--selected { + background: #fff3e0; + border-color: orange; + color: #c47600; +} + +.appt-pet-chip-species { + font-weight: 400; + color: #888; +} + +.appt-pet-chip--selected .appt-pet-chip-species { + color: #c47600; +} + +.appt-pet-checkbox { + accent-color: orange; +} + +.appt-link { + color: orange; + font-weight: 600; + text-decoration: none; +} + +.appt-link:hover { + text-decoration: underline; +} + +.appt-submit-btn { + margin-top: 0.5rem; + padding: 0.75rem; + background: orange; + color: white; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 700; + cursor: pointer; + transition: background 0.2s ease, transform 0.1s ease; +} + +.appt-submit-btn:hover:not(:disabled) { + background: #e69500; +} + +.appt-submit-btn:active:not(:disabled) { + transform: scale(0.98); +} + +.appt-submit-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.appt-error { + background: #fff0f0; + border: 1px solid #f5c6c6; + color: #c0392b; + border-radius: 8px; + padding: 0.65rem 1rem; + font-size: 0.9rem; +} + +.appt-success { + background: #f0fff4; + border: 1px solid #b2dfdb; + color: #1a7a3c; + border-radius: 8px; + padding: 0.65rem 1rem; + font-size: 0.9rem; +} + +.appt-loading, +.appt-empty { + text-align: center; + color: #888; + font-size: 0.95rem; + padding: 1rem 0; +} + +.appt-history { + background: white; + border-radius: 16px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08); + padding: 2rem; +} + +.appt-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 1rem; +} + +.appt-card { + border: 1px solid #eee; + border-radius: 10px; + padding: 1rem 1.25rem; + transition: box-shadow 0.2s ease; +} + +.appt-card:hover { + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06); +} + +.appt-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.4rem; +} + +.appt-card-service { + font-weight: 700; + font-size: 1rem; + color: #222; +} + +.appt-card-status { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + padding: 0.2rem 0.7rem; + border-radius: 20px; + letter-spacing: 0.03em; +} + +.appt-card-status--booked { + background: #e3f2fd; + color: #1565c0; +} + +.appt-card-status--completed { + background: #e8f5e9; + color: #2e7d32; +} + +.appt-card-status--cancelled { + background: #fce4ec; + color: #c62828; +} + +.appt-card-details { + display: flex; + justify-content: space-between; + font-size: 0.88rem; + color: #666; +} + +.appt-card-pets { + font-size: 0.85rem; + color: #888; + margin-top: 0.35rem; +} + +/* Adoption Pet Selection */ + +.appt-adopt-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.75rem; + margin-top: 0.25rem; +} + +.appt-adopt-card { + display: flex; + align-items: center; + gap: 0.75rem; + border: 2px solid #eee; + border-radius: 12px; + padding: 0.65rem 0.85rem; + cursor: pointer; + transition: all 0.2s ease; + user-select: none; +} + +.appt-adopt-card:hover { + border-color: orange; + background: #fffaf5; +} + +.appt-adopt-card--selected { + border-color: orange; + background: #fff3e0; +} + +.appt-adopt-radio { + display: none; +} + +.appt-adopt-img { + width: 48px; + height: 48px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + +.appt-adopt-img-placeholder { + width: 48px; + height: 48px; + border-radius: 50%; + background: #f5f5f5; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.3rem; + flex-shrink: 0; +} + +.appt-adopt-info { + display: flex; + flex-direction: column; + gap: 0.05rem; + min-width: 0; +} + +.appt-adopt-name { + font-weight: 700; + font-size: 0.9rem; + color: #222; +} + +.appt-adopt-detail { + font-size: 0.78rem; + color: #888; +} + +@media (max-width: 640px) { + .appt-content { + padding: 1rem; + } + + .appt-form, + .appt-history { + padding: 1.25rem; + } + + .appt-hero-title { + font-size: 1.6rem; + } + + .appt-card-details { + flex-direction: column; + gap: 0.15rem; + } +} + +/* Profile Page Layout (with pets section) */ + +.profile-page-layout { + min-height: calc(100vh - 70px); + display: flex; + flex-direction: column; + align-items: center; + padding: 2rem 1rem; + background: #fafafa; + gap: 2rem; +} + +/* Profile Pets Section */ + +.profile-pets-section { + background: white; + border-radius: 16px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1); + padding: 2rem; + width: 100%; + max-width: 640px; +} + +.profile-pets-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.profile-pets-title { + font-size: 1.35rem; + font-weight: 700; + color: #222; + margin: 0; +} + +.profile-pets-add-btn { + background: orange; + color: white; + border: none; + border-radius: 20px; + padding: 0.4rem 1rem; + font-size: 0.85rem; + font-weight: 700; + cursor: pointer; + transition: background 0.2s ease; +} + +.profile-pets-add-btn:hover { + background: #e69500; +} + +.profile-pets-empty { + text-align: center; + color: #888; + font-size: 0.95rem; + padding: 1.5rem 0; +} + +.profile-pets-grid { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.profile-pet-card { + display: flex; + align-items: center; + gap: 1rem; + border: 1px solid #eee; + border-radius: 12px; + padding: 0.75rem 1rem; + transition: box-shadow 0.2s ease; +} + +.profile-pet-card:hover { + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06); +} + +.profile-pet-card-img-area { + position: relative; + width: 56px; + height: 56px; + flex-shrink: 0; +} + +.profile-pet-card-img { + width: 56px; + height: 56px; + border-radius: 50%; + object-fit: cover; +} + +.profile-pet-card-placeholder { + width: 56px; + height: 56px; + border-radius: 50%; + background: #f5f5f5; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; +} + +.profile-pet-upload-label { + position: absolute; + bottom: -2px; + right: -2px; + width: 22px; + height: 22px; + background: white; + border: 1px solid #ddd; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.7rem; + cursor: pointer; + transition: border-color 0.2s ease; +} + +.profile-pet-upload-label:hover { + border-color: orange; +} + +.profile-pet-upload-input { + display: none; +} + +.profile-pet-card-info { + display: flex; + flex-direction: column; + gap: 0.1rem; + flex: 1; + min-width: 0; +} + +.profile-pet-card-name { + font-weight: 700; + font-size: 0.95rem; + color: #222; +} + +.profile-pet-card-detail { + font-size: 0.82rem; + color: #888; +} + +.profile-pet-card-actions { + display: flex; + gap: 0.4rem; + flex-shrink: 0; +} + +.profile-pet-edit-btn, +.profile-pet-delete-btn { + padding: 0.3rem 0.7rem; + border-radius: 6px; + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; + border: 1px solid #ddd; + background: white; + transition: all 0.2s ease; +} + +.profile-pet-edit-btn:hover { + border-color: orange; + color: orange; +} + +.profile-pet-delete-btn:hover { + border-color: #c0392b; + color: #c0392b; +} + +/* Pet Add/Edit Form */ + +.profile-pet-form { + background: #fafafa; + border: 1px solid #eee; + border-radius: 12px; + padding: 1.25rem; + margin-bottom: 1rem; + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.profile-pet-form-title { + font-size: 1rem; + font-weight: 700; + color: #222; + margin: 0; +} + +.profile-pet-form-actions { + display: flex; + gap: 0.75rem; +} + +.profile-pet-form-actions .appt-submit-btn { + flex: 1; + margin-top: 0; +} + +.profile-pet-cancel-btn { + flex: 1; + padding: 0.75rem; + background: white; + color: #666; + border: 1px solid #ddd; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.profile-pet-cancel-btn:hover { + border-color: #999; + color: #333; +} \ No newline at end of file diff --git a/web/app/products/[id]/page.js b/web/app/products/[id]/page.js new file mode 100644 index 00000000..833cbd24 --- /dev/null +++ b/web/app/products/[id]/page.js @@ -0,0 +1,57 @@ +"use client"; + +import Link from "next/link"; +import { useState, useEffect } from "react"; +import { useParams } from "next/navigation"; +import ProductProfile from "@/components/ProductProfile"; + +const API_BASE = ""; + +export default function ProductDetailPage() { + const { id } = useParams(); + const [product, setProduct] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!id) { + return; + } + + setLoading(true); + setError(null); + + fetch(`${API_BASE}/api/v1/products/${id}`) + .then((res) => { + if (!res.ok) { + throw new Error(`HTTP ${res.status} – ${res.statusText}`); + } + + return res.json(); + }) + .then((data) => setProduct(data)) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)); + }, [id]); + + return ( +
+
+ ← Back to Products + + {loading &&

Loading product details...

} + {error &&

{error}

} + + {!loading && !error && product && ( + + )} +
+
+ ); +} diff --git a/web/app/products/page.js b/web/app/products/page.js new file mode 100644 index 00000000..f44475f1 --- /dev/null +++ b/web/app/products/page.js @@ -0,0 +1,133 @@ +"use client"; + +import { useState, useEffect } from "react"; +import ProductCard from "@/components/ProductCard"; + +const API_BASE = ""; + +export default function ProductsPage() { + const [products, setProducts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [search, setSearch] = useState(""); + const [query, setQuery] = useState(""); + const [page, setPage] = useState(0); + const [totalPages, setTotalPages] = useState(0); + + const PAGE_SIZE = 12; + + useEffect(() => { + setLoading(true); + setError(null); + + const params = new URLSearchParams({ page, size: PAGE_SIZE, sort: "prodId,asc" }); + if (query) { + params.set("q", query); + } + + fetch(`${API_BASE}/api/v1/products?${params}`) + .then((res) => { + if (!res.ok) { + throw new Error(`HTTP ${res.status} – ${res.statusText}`); + } + + return res.json(); + }) + .then((data) => { + setProducts(data.content ?? []); + setTotalPages(data.totalPages ?? 0); + }) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)); + }, [page, query]); + + function handleSearch(e) { + e.preventDefault(); + setPage(0); + setQuery(search.trim()); + } + + return ( +
+
+

Shop Our Products

+

Everything your pet needs, all in one place

+
+
+ +
+
+
+ setSearch(e.target.value)} + /> + + {query && ( + + )} +
+
+
+ +
+ {loading &&

Loading products...

} + + {error && ( +
+

Failed to load products

+ {error} +
+ )} + + {!loading && !error && products.length === 0 && ( +

No products found.

+ )} + + {!loading && !error && products.length > 0 && ( +
+ {products.map((product) => ( + + ))} +
+ )} + + {!loading && totalPages > 1 && ( +
+ + Page {page + 1} of {totalPages} + +
+ )} +
+
+ ); +} diff --git a/web/app/profile/page.js b/web/app/profile/page.js index fcc903cc..6b28aa35 100644 --- a/web/app/profile/page.js +++ b/web/app/profile/page.js @@ -1,25 +1,158 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useState, useCallback } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/context/AuthContext"; +const API_BASE = ""; + export default function ProfilePage() { - const { user, loading, logout } = useAuth(); + const {user, token, loading, logout} = useAuth(); const router = useRouter(); + const [pets, setPets] = useState([]); + const [loadingPets, setLoadingPets] = useState(false); + const [showForm, setShowForm] = useState(false); + const [editingPet, setEditingPet] = useState(null); + const [petName, setPetName] = useState(""); + const [species, setSpecies] = useState(""); + const [breed, setBreed] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [petError, setPetError] = useState(null); + useEffect(() => { if (!loading && !user) { router.replace("/login"); } + }, [user, loading, router]); + const loadPets = useCallback(() => { + if (!token) return; + setLoadingPets(true); + fetch(`${API_BASE}/api/v1/my-pets`, { + headers: { Authorization: `Bearer ${token}` }, + }) + .then((r) => r.json()) + .then(setPets) + .catch(() => {}) + .finally(() => setLoadingPets(false)); + }, [token]); + + useEffect(() => { + if (user?.role === "CUSTOMER") { + loadPets(); + } + }, [user, loadPets]); + function handleLogout() { logout(); - router.push("/"); } + function openAddForm() { + setEditingPet(null); + setPetName(""); + setSpecies(""); + setBreed(""); + setPetError(null); + setShowForm(true); + } + + function openEditForm(pet) { + setEditingPet(pet); + setPetName(pet.petName); + setSpecies(pet.species); + setBreed(pet.breed || ""); + setPetError(null); + setShowForm(true); + } + + function closeForm() { + setShowForm(false); + setEditingPet(null); + setPetError(null); + } + + async function handlePetSubmit(e) { + e.preventDefault(); + setPetError(null); + setSubmitting(true); + + const url = editingPet + ? `${API_BASE}/api/v1/my-pets/${editingPet.customerPetId}` + : `${API_BASE}/api/v1/my-pets`; + + try { + const res = await fetch(url, { + method: editingPet ? "PUT" : "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ petName, species, breed: breed || null }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => null); + throw new Error(data?.message || `Request failed (${res.status})`); + } + + closeForm(); + loadPets(); + } + + catch (err) { + setPetError(err.message); + } + + finally { + setSubmitting(false); + } + } + + async function handleDeletePet(id) { + if (!confirm("Remove this pet profile?")) { + return; + } + + try { + await fetch(`${API_BASE}/api/v1/my-pets/${id}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); + loadPets(); + } + + catch { + } + } + + async function handleImageUpload(petId, file) { + const formData = new FormData(); + formData.append("image", file); + + try { + const res = await fetch(`${API_BASE}/api/v1/my-pets/${petId}/image`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: formData, + }); + + if (!res.ok) { + const data = await res.json().catch(() => null); + alert(data?.message || "Failed to upload image"); + return; + } + + loadPets(); + } + + catch { + alert("Failed to upload image"); + } + } + if (loading || !user) { return

Loading…

; } @@ -30,11 +163,11 @@ export default function ProfilePage() { {label: "Email", value: user.email}, {label: "Phone", value: user.phone || "β€”"}, {label: "Role", value: user.role}, - ...(user.storeName ? [{label: "Store", value: user.storeName}] : []), + ...(user.storeName ? [{ label: "Store", value: user.storeName }] : []), ]; return ( -
+
{(user.fullName || user.username).charAt(0).toUpperCase()} @@ -56,6 +189,111 @@ export default function ProfilePage() { Log Out
+ + {user.role === "CUSTOMER" && ( +
+
+

My Pets

+ +
+ + {showForm && ( +
+

+ {editingPet ? "Edit Pet" : "Add a New Pet"} +

+ {petError &&
{petError}
} + + + +
+ + +
+
+ )} + + {loadingPets ? ( +

Loading pets...

+ ) : pets.length === 0 && !showForm ? ( +

No pet profiles yet. Add your first pet above!

+ ) : ( +
+ {pets.map((pet) => ( +
+
+ {pet.imageUrl ? ( + {pet.petName} + ) : ( +
🐾
+ )} + +
+
+ {pet.petName} + {pet.species} + {pet.breed && {pet.breed}} +
+
+ + +
+
+ ))} +
+ )} +
+ )}
); } diff --git a/web/components/Navigation.js b/web/components/Navigation.js index 2e7f7cac..cfd17924 100644 --- a/web/components/Navigation.js +++ b/web/components/Navigation.js @@ -25,7 +25,7 @@ export default function DisplayNav() {
Home Adopt a Pet - Online Store + Online Store Schedule an Appointment Contact Us About Us diff --git a/web/components/PetCard.js b/web/components/PetCard.js index 5ccb0219..4370bca1 100644 --- a/web/components/PetCard.js +++ b/web/components/PetCard.js @@ -1,13 +1,11 @@ -//Pet cards (on adopt page) - import Link from "next/link"; -import { getSpeciesEmoji, getStatusClass } from "@/components/petUtils"; +import { getStatusClass } from "@/components/petUtils"; -export default function PetCard({petId, petName, petSpecies, petStatus}) { +export default function PetCard({petId, petName, petSpecies, petStatus, imageUrl}) { return (
- {getSpeciesEmoji(petSpecies)} + {petName}

{petName}

diff --git a/web/components/PetProfile.js b/web/components/PetProfile.js index 87d4b059..a00732a4 100644 --- a/web/components/PetProfile.js +++ b/web/components/PetProfile.js @@ -1,11 +1,11 @@ import Link from "next/link"; -import { getSpeciesEmoji, getStatusClass } from "@/components/petUtils"; +import { getStatusClass } from "@/components/petUtils"; -export default function PetProfile({ petName, petSpecies, petBreed, petAge, petStatus, petPrice }) { +export default function PetProfile({ petId, petName, petSpecies, petBreed, petAge, petStatus, petPrice, imageUrl }) { return (
- {getSpeciesEmoji(petSpecies)} + {petName}
@@ -45,7 +45,7 @@ export default function PetProfile({ petName, petSpecies, petBreed, petAge, petS

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

- + Schedule an Appointment
diff --git a/web/components/ProductCard.js b/web/components/ProductCard.js new file mode 100644 index 00000000..e27abbbd --- /dev/null +++ b/web/components/ProductCard.js @@ -0,0 +1,18 @@ +import Link from "next/link"; + +export default function ProductCard({ prodId, prodName, categoryName, prodPrice, imageUrl }) { + return ( + +
+ {prodName} +
+
+

{prodName}

+

{categoryName}

+ {prodPrice != null && ( + ${parseFloat(prodPrice).toFixed(2)} + )} +
+ + ); +} diff --git a/web/components/ProductProfile.js b/web/components/ProductProfile.js new file mode 100644 index 00000000..b8d0a9b2 --- /dev/null +++ b/web/components/ProductProfile.js @@ -0,0 +1,34 @@ +import Link from "next/link"; + +export default function ProductProfile({ prodName, categoryName, prodDesc, prodPrice, imageUrl }) { + return ( +
+
+ {prodName} +
+ +
+
+

{prodName}

+
+ +
+
+ Category + {categoryName ?? "β€”"} +
+
+ Price + + {prodPrice != null ? `$${parseFloat(prodPrice).toFixed(2)}` : "β€”"} + +
+
+ Description + {prodDesc ?? "β€”"} +
+
+
+
+ ); +} diff --git a/web/public/images/home/navimages/about.jpg b/web/public/images/home/navimages/about.jpg new file mode 100644 index 00000000..64537455 Binary files /dev/null and b/web/public/images/home/navimages/about.jpg differ diff --git a/web/public/images/pet-placeholder.png b/web/public/images/pet-placeholder.png new file mode 100644 index 00000000..207e9d29 Binary files /dev/null and b/web/public/images/pet-placeholder.png differ