Web products #60
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.zip
|
||||||
@@ -9,6 +9,7 @@ import com.petshop.backend.dto.auth.RegisterResponse;
|
|||||||
import com.petshop.backend.dto.auth.UserInfoResponse;
|
import com.petshop.backend.dto.auth.UserInfoResponse;
|
||||||
import com.petshop.backend.entity.EmployeeStore;
|
import com.petshop.backend.entity.EmployeeStore;
|
||||||
import com.petshop.backend.entity.User;
|
import com.petshop.backend.entity.User;
|
||||||
|
import com.petshop.backend.repository.CustomerRepository;
|
||||||
import com.petshop.backend.repository.EmployeeRepository;
|
import com.petshop.backend.repository.EmployeeRepository;
|
||||||
import com.petshop.backend.repository.EmployeeStoreRepository;
|
import com.petshop.backend.repository.EmployeeStoreRepository;
|
||||||
import com.petshop.backend.repository.UserRepository;
|
import com.petshop.backend.repository.UserRepository;
|
||||||
@@ -47,8 +48,9 @@ public class AuthController {
|
|||||||
private final EmployeeRepository employeeRepository;
|
private final EmployeeRepository employeeRepository;
|
||||||
private final EmployeeStoreRepository employeeStoreRepository;
|
private final EmployeeStoreRepository employeeStoreRepository;
|
||||||
private final AvatarStorageService avatarStorageService;
|
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.authenticationManager = authenticationManager;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.jwtUtil = jwtUtil;
|
this.jwtUtil = jwtUtil;
|
||||||
@@ -57,6 +59,7 @@ public class AuthController {
|
|||||||
this.employeeRepository = employeeRepository;
|
this.employeeRepository = employeeRepository;
|
||||||
this.employeeStoreRepository = employeeStoreRepository;
|
this.employeeStoreRepository = employeeStoreRepository;
|
||||||
this.avatarStorageService = avatarStorageService;
|
this.avatarStorageService = avatarStorageService;
|
||||||
|
this.customerRepository = customerRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
@@ -147,6 +150,7 @@ public class AuthController {
|
|||||||
User user = getAuthenticatedUser();
|
User user = getAuthenticatedUser();
|
||||||
|
|
||||||
EmployeeStore employeeStore = resolveEmployeeStore(user);
|
EmployeeStore employeeStore = resolveEmployeeStore(user);
|
||||||
|
Long customerId = resolveCustomerId(user);
|
||||||
|
|
||||||
return ResponseEntity.ok(new UserInfoResponse(
|
return ResponseEntity.ok(new UserInfoResponse(
|
||||||
user.getId(),
|
user.getId(),
|
||||||
@@ -156,6 +160,7 @@ public class AuthController {
|
|||||||
user.getPhone(),
|
user.getPhone(),
|
||||||
avatarStorageService.toOwnerAvatarUrl(user),
|
avatarStorageService.toOwnerAvatarUrl(user),
|
||||||
user.getRole().name(),
|
user.getRole().name(),
|
||||||
|
customerId,
|
||||||
employeeStore != null ? employeeStore.getStore().getStoreId() : null,
|
employeeStore != null ? employeeStore.getStore().getStoreId() : null,
|
||||||
employeeStore != null ? employeeStore.getStore().getStoreName() : null
|
employeeStore != null ? employeeStore.getStore().getStoreName() : null
|
||||||
));
|
));
|
||||||
@@ -216,6 +221,7 @@ public class AuthController {
|
|||||||
userBusinessLinkageService.syncLinkedRecords(updatedUser);
|
userBusinessLinkageService.syncLinkedRecords(updatedUser);
|
||||||
|
|
||||||
EmployeeStore employeeStore = resolveEmployeeStore(updatedUser);
|
EmployeeStore employeeStore = resolveEmployeeStore(updatedUser);
|
||||||
|
Long customerId = resolveCustomerId(updatedUser);
|
||||||
|
|
||||||
return ResponseEntity.ok(new UserInfoResponse(
|
return ResponseEntity.ok(new UserInfoResponse(
|
||||||
updatedUser.getId(),
|
updatedUser.getId(),
|
||||||
@@ -225,6 +231,7 @@ public class AuthController {
|
|||||||
updatedUser.getPhone(),
|
updatedUser.getPhone(),
|
||||||
avatarStorageService.toOwnerAvatarUrl(updatedUser),
|
avatarStorageService.toOwnerAvatarUrl(updatedUser),
|
||||||
updatedUser.getRole().name(),
|
updatedUser.getRole().name(),
|
||||||
|
customerId,
|
||||||
employeeStore != null ? employeeStore.getStore().getStoreId() : null,
|
employeeStore != null ? employeeStore.getStore().getStoreId() : null,
|
||||||
employeeStore != null ? employeeStore.getStore().getStoreName() : null
|
employeeStore != null ? employeeStore.getStore().getStoreName() : null
|
||||||
));
|
));
|
||||||
@@ -240,6 +247,12 @@ public class AuthController {
|
|||||||
.orElse(null);
|
.orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Long resolveCustomerId(User user) {
|
||||||
|
return customerRepository.findByUserId(user.getId())
|
||||||
|
.map(c -> c.getCustomerId())
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
private String trimToNull(String value) {
|
private String trimToNull(String value) {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -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<List<CustomerPetResponse>> getMyPets() {
|
||||||
|
|
||||||
|
return ResponseEntity.ok(customerPetService.getMyPets());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<CustomerPetResponse> createPet(@Valid @RequestBody CustomerPetRequest request) {
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(customerPetService.createPet(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<CustomerPetResponse> updatePet(@PathVariable Long id, @Valid @RequestBody CustomerPetRequest request) {
|
||||||
|
|
||||||
|
return ResponseEntity.ok(customerPetService.updatePet(id, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<Void> 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<String, String> error = new HashMap<>();
|
||||||
|
error.put("message", ex.getMessage());
|
||||||
|
|
||||||
|
return ResponseEntity.badRequest().body(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
catch (IOException ex) {
|
||||||
|
Map<String, String> 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<Resource> 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<CustomerPetResponse> deleteImage(@PathVariable Long id) {
|
||||||
|
|
||||||
|
return ResponseEntity.ok(customerPetService.deleteImage(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.petshop.backend.dto.appointment;
|
package com.petshop.backend.dto.appointment;
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotEmpty;
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalTime;
|
import java.time.LocalTime;
|
||||||
@@ -26,9 +25,10 @@ public class AppointmentRequest {
|
|||||||
@NotNull(message = "Appointment status is required")
|
@NotNull(message = "Appointment status is required")
|
||||||
private String appointmentStatus;
|
private String appointmentStatus;
|
||||||
|
|
||||||
@NotEmpty(message = "At least one pet must be specified")
|
|
||||||
private List<Long> petIds;
|
private List<Long> petIds;
|
||||||
|
|
||||||
|
private List<Long> customerPetIds;
|
||||||
|
|
||||||
public Long getCustomerId() {
|
public Long getCustomerId() {
|
||||||
return customerId;
|
return customerId;
|
||||||
}
|
}
|
||||||
@@ -85,6 +85,14 @@ public class AppointmentRequest {
|
|||||||
this.petIds = petIds;
|
this.petIds = petIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Long> getCustomerPetIds() {
|
||||||
|
return customerPetIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCustomerPetIds(List<Long> customerPetIds) {
|
||||||
|
this.customerPetIds = customerPetIds;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean equals(Object o) {
|
public boolean equals(Object o) {
|
||||||
if (this == o) return true;
|
if (this == o) return true;
|
||||||
@@ -96,12 +104,13 @@ public class AppointmentRequest {
|
|||||||
Objects.equals(appointmentDate, that.appointmentDate) &&
|
Objects.equals(appointmentDate, that.appointmentDate) &&
|
||||||
Objects.equals(appointmentTime, that.appointmentTime) &&
|
Objects.equals(appointmentTime, that.appointmentTime) &&
|
||||||
Objects.equals(appointmentStatus, that.appointmentStatus) &&
|
Objects.equals(appointmentStatus, that.appointmentStatus) &&
|
||||||
Objects.equals(petIds, that.petIds);
|
Objects.equals(petIds, that.petIds) &&
|
||||||
|
Objects.equals(customerPetIds, that.customerPetIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int hashCode() {
|
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
|
@Override
|
||||||
@@ -114,6 +123,7 @@ public class AppointmentRequest {
|
|||||||
", appointmentTime=" + appointmentTime +
|
", appointmentTime=" + appointmentTime +
|
||||||
", appointmentStatus='" + appointmentStatus + '\'' +
|
", appointmentStatus='" + appointmentStatus + '\'' +
|
||||||
", petIds=" + petIds +
|
", petIds=" + petIds +
|
||||||
|
", customerPetIds=" + customerPetIds +
|
||||||
'}';
|
'}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ public class AppointmentResponse {
|
|||||||
private String appointmentStatus;
|
private String appointmentStatus;
|
||||||
private List<String> petNames;
|
private List<String> petNames;
|
||||||
private List<Long> petIds;
|
private List<Long> petIds;
|
||||||
|
private List<String> customerPetNames;
|
||||||
|
private List<Long> customerPetIds;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
@@ -138,6 +140,24 @@ public class AppointmentResponse {
|
|||||||
this.petIds = petIds;
|
this.petIds = petIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<String> getCustomerPetNames() {
|
||||||
|
|
||||||
|
return customerPetNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCustomerPetNames(List<String> customerPetNames) {
|
||||||
|
this.customerPetNames = customerPetNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Long> getCustomerPetIds() {
|
||||||
|
|
||||||
|
return customerPetIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCustomerPetIds(List<Long> customerPetIds) {
|
||||||
|
this.customerPetIds = customerPetIds;
|
||||||
|
}
|
||||||
|
|
||||||
public LocalDateTime getCreatedAt() {
|
public LocalDateTime getCreatedAt() {
|
||||||
return createdAt;
|
return createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ public class UserInfoResponse {
|
|||||||
private String phone;
|
private String phone;
|
||||||
private String avatarUrl;
|
private String avatarUrl;
|
||||||
private String role;
|
private String role;
|
||||||
|
private Long customerId;
|
||||||
private Long storeId;
|
private Long storeId;
|
||||||
private String storeName;
|
private String storeName;
|
||||||
|
|
||||||
public UserInfoResponse() {
|
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.id = id;
|
||||||
this.username = username;
|
this.username = username;
|
||||||
this.email = email;
|
this.email = email;
|
||||||
@@ -24,6 +25,7 @@ public class UserInfoResponse {
|
|||||||
this.phone = phone;
|
this.phone = phone;
|
||||||
this.avatarUrl = avatarUrl;
|
this.avatarUrl = avatarUrl;
|
||||||
this.role = role;
|
this.role = role;
|
||||||
|
this.customerId = customerId;
|
||||||
this.storeId = storeId;
|
this.storeId = storeId;
|
||||||
this.storeName = storeName;
|
this.storeName = storeName;
|
||||||
}
|
}
|
||||||
@@ -84,6 +86,15 @@ public class UserInfoResponse {
|
|||||||
this.role = role;
|
this.role = role;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Long getCustomerId() {
|
||||||
|
|
||||||
|
return customerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCustomerId(Long customerId) {
|
||||||
|
this.customerId = customerId;
|
||||||
|
}
|
||||||
|
|
||||||
public Long getStoreId() {
|
public Long getStoreId() {
|
||||||
return storeId;
|
return storeId;
|
||||||
}
|
}
|
||||||
@@ -112,13 +123,14 @@ public class UserInfoResponse {
|
|||||||
Objects.equals(phone, that.phone) &&
|
Objects.equals(phone, that.phone) &&
|
||||||
Objects.equals(avatarUrl, that.avatarUrl) &&
|
Objects.equals(avatarUrl, that.avatarUrl) &&
|
||||||
Objects.equals(role, that.role) &&
|
Objects.equals(role, that.role) &&
|
||||||
|
Objects.equals(customerId, that.customerId) &&
|
||||||
Objects.equals(storeId, that.storeId) &&
|
Objects.equals(storeId, that.storeId) &&
|
||||||
Objects.equals(storeName, that.storeName);
|
Objects.equals(storeName, that.storeName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int hashCode() {
|
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
|
@Override
|
||||||
@@ -131,6 +143,7 @@ public class UserInfoResponse {
|
|||||||
", phone='" + phone + '\'' +
|
", phone='" + phone + '\'' +
|
||||||
", avatarUrl='" + avatarUrl + '\'' +
|
", avatarUrl='" + avatarUrl + '\'' +
|
||||||
", role='" + role + '\'' +
|
", role='" + role + '\'' +
|
||||||
|
", customerId=" + customerId +
|
||||||
", storeId=" + storeId +
|
", storeId=" + storeId +
|
||||||
", storeName='" + storeName + '\'' +
|
", storeName='" + storeName + '\'' +
|
||||||
'}';
|
'}';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,14 @@ public class Appointment {
|
|||||||
)
|
)
|
||||||
private Set<Pet> pets = new HashSet<>();
|
private Set<Pet> pets = new HashSet<>();
|
||||||
|
|
||||||
|
@ManyToMany
|
||||||
|
@JoinTable(
|
||||||
|
name = "appointment_customer_pet",
|
||||||
|
joinColumns = @JoinColumn(name = "appointment_id"),
|
||||||
|
inverseJoinColumns = @JoinColumn(name = "customer_pet_id")
|
||||||
|
)
|
||||||
|
private Set<CustomerPet> customerPets = new HashSet<>();
|
||||||
|
|
||||||
@CreationTimestamp
|
@CreationTimestamp
|
||||||
@Column(name = "created_at", updatable = false)
|
@Column(name = "created_at", updatable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
@@ -136,6 +144,15 @@ public class Appointment {
|
|||||||
this.pets = pets;
|
this.pets = pets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Set<CustomerPet> getCustomerPets() {
|
||||||
|
|
||||||
|
return customerPets;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCustomerPets(Set<CustomerPet> customerPets) {
|
||||||
|
this.customerPets = customerPets;
|
||||||
|
}
|
||||||
|
|
||||||
public LocalDateTime getCreatedAt() {
|
public LocalDateTime getCreatedAt() {
|
||||||
return createdAt;
|
return createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CustomerPet, Long> {
|
||||||
|
|
||||||
|
List<CustomerPet> findByCustomerCustomerIdOrderByCreatedAtDesc(Long customerId);
|
||||||
|
|
||||||
|
Optional<CustomerPet> findByCustomerPetIdAndCustomerCustomerId(Long customerPetId, Long customerId);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import com.petshop.backend.dto.appointment.AppointmentResponse;
|
|||||||
import com.petshop.backend.dto.common.BulkDeleteRequest;
|
import com.petshop.backend.dto.common.BulkDeleteRequest;
|
||||||
import com.petshop.backend.entity.Appointment;
|
import com.petshop.backend.entity.Appointment;
|
||||||
import com.petshop.backend.entity.Customer;
|
import com.petshop.backend.entity.Customer;
|
||||||
|
import com.petshop.backend.entity.CustomerPet;
|
||||||
import com.petshop.backend.entity.Employee;
|
import com.petshop.backend.entity.Employee;
|
||||||
import com.petshop.backend.entity.EmployeeStore;
|
import com.petshop.backend.entity.EmployeeStore;
|
||||||
import com.petshop.backend.entity.Pet;
|
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.entity.User;
|
||||||
import com.petshop.backend.exception.ResourceNotFoundException;
|
import com.petshop.backend.exception.ResourceNotFoundException;
|
||||||
import com.petshop.backend.repository.AppointmentRepository;
|
import com.petshop.backend.repository.AppointmentRepository;
|
||||||
|
import com.petshop.backend.repository.CustomerPetRepository;
|
||||||
import com.petshop.backend.repository.CustomerRepository;
|
import com.petshop.backend.repository.CustomerRepository;
|
||||||
import com.petshop.backend.repository.EmployeeRepository;
|
import com.petshop.backend.repository.EmployeeRepository;
|
||||||
import com.petshop.backend.repository.EmployeeStoreRepository;
|
import com.petshop.backend.repository.EmployeeStoreRepository;
|
||||||
@@ -40,6 +42,7 @@ public class AppointmentService {
|
|||||||
|
|
||||||
private final AppointmentRepository appointmentRepository;
|
private final AppointmentRepository appointmentRepository;
|
||||||
private final CustomerRepository customerRepository;
|
private final CustomerRepository customerRepository;
|
||||||
|
private final CustomerPetRepository customerPetRepository;
|
||||||
private final ServiceRepository serviceRepository;
|
private final ServiceRepository serviceRepository;
|
||||||
private final PetRepository petRepository;
|
private final PetRepository petRepository;
|
||||||
private final StoreRepository storeRepository;
|
private final StoreRepository storeRepository;
|
||||||
@@ -47,9 +50,10 @@ public class AppointmentService {
|
|||||||
private final EmployeeRepository employeeRepository;
|
private final EmployeeRepository employeeRepository;
|
||||||
private final EmployeeStoreRepository employeeStoreRepository;
|
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.appointmentRepository = appointmentRepository;
|
||||||
this.customerRepository = customerRepository;
|
this.customerRepository = customerRepository;
|
||||||
|
this.customerPetRepository = customerPetRepository;
|
||||||
this.serviceRepository = serviceRepository;
|
this.serviceRepository = serviceRepository;
|
||||||
this.petRepository = petRepository;
|
this.petRepository = petRepository;
|
||||||
this.storeRepository = storeRepository;
|
this.storeRepository = storeRepository;
|
||||||
@@ -107,7 +111,16 @@ public class AppointmentService {
|
|||||||
validateStoreAccess(store.getStoreId());
|
validateStoreAccess(store.getStoreId());
|
||||||
validateAvailability(store, service, request.getAppointmentDate(), request.getAppointmentTime(), null);
|
validateAvailability(store, service, request.getAppointmentDate(), request.getAppointmentTime(), null);
|
||||||
|
|
||||||
Set<Pet> 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<Pet> pets = hasPetIds ? fetchPets(request.getPetIds()) : new HashSet<>();
|
||||||
|
Set<CustomerPet> customerPets = hasCustomerPetIds ? fetchCustomerPets(request.getCustomerPetIds()) : new HashSet<>();
|
||||||
|
|
||||||
Appointment appointment = new Appointment();
|
Appointment appointment = new Appointment();
|
||||||
appointment.setCustomer(customer);
|
appointment.setCustomer(customer);
|
||||||
@@ -117,6 +130,7 @@ public class AppointmentService {
|
|||||||
appointment.setAppointmentTime(request.getAppointmentTime());
|
appointment.setAppointmentTime(request.getAppointmentTime());
|
||||||
appointment.setAppointmentStatus(request.getAppointmentStatus());
|
appointment.setAppointmentStatus(request.getAppointmentStatus());
|
||||||
appointment.setPets(pets);
|
appointment.setPets(pets);
|
||||||
|
appointment.setCustomerPets(customerPets);
|
||||||
|
|
||||||
appointment = appointmentRepository.save(appointment);
|
appointment = appointmentRepository.save(appointment);
|
||||||
return mapToResponse(appointment);
|
return mapToResponse(appointment);
|
||||||
@@ -141,7 +155,16 @@ public class AppointmentService {
|
|||||||
validateStoreAccess(store.getStoreId());
|
validateStoreAccess(store.getStoreId());
|
||||||
validateAvailability(store, service, request.getAppointmentDate(), request.getAppointmentTime(), id);
|
validateAvailability(store, service, request.getAppointmentDate(), request.getAppointmentTime(), id);
|
||||||
|
|
||||||
Set<Pet> 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<Pet> pets = hasPetIds ? fetchPets(request.getPetIds()) : new HashSet<>();
|
||||||
|
Set<CustomerPet> customerPets = hasCustomerPetIds ? fetchCustomerPets(request.getCustomerPetIds()) : new HashSet<>();
|
||||||
|
|
||||||
appointment.setCustomer(customer);
|
appointment.setCustomer(customer);
|
||||||
appointment.setStore(store);
|
appointment.setStore(store);
|
||||||
@@ -150,6 +173,7 @@ public class AppointmentService {
|
|||||||
appointment.setAppointmentTime(request.getAppointmentTime());
|
appointment.setAppointmentTime(request.getAppointmentTime());
|
||||||
appointment.setAppointmentStatus(request.getAppointmentStatus());
|
appointment.setAppointmentStatus(request.getAppointmentStatus());
|
||||||
appointment.setPets(pets);
|
appointment.setPets(pets);
|
||||||
|
appointment.setCustomerPets(customerPets);
|
||||||
|
|
||||||
appointment = appointmentRepository.save(appointment);
|
appointment = appointmentRepository.save(appointment);
|
||||||
return mapToResponse(appointment);
|
return mapToResponse(appointment);
|
||||||
@@ -223,6 +247,17 @@ public class AppointmentService {
|
|||||||
return pets;
|
return pets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Set<CustomerPet> fetchCustomerPets(List<Long> customerPetIds) {
|
||||||
|
Set<CustomerPet> 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) {
|
private AppointmentResponse mapToResponse(Appointment appointment) {
|
||||||
List<String> petNames = appointment.getPets().stream()
|
List<String> petNames = appointment.getPets().stream()
|
||||||
.map(Pet::getPetName)
|
.map(Pet::getPetName)
|
||||||
@@ -232,22 +267,33 @@ public class AppointmentService {
|
|||||||
.map(Pet::getPetId)
|
.map(Pet::getPetId)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
return new AppointmentResponse(
|
List<String> customerPetNames = appointment.getCustomerPets().stream()
|
||||||
appointment.getAppointmentId(),
|
.map(CustomerPet::getPetName)
|
||||||
appointment.getCustomer().getCustomerId(),
|
.collect(Collectors.toList());
|
||||||
appointment.getCustomer().getFirstName() + " " + appointment.getCustomer().getLastName(),
|
|
||||||
appointment.getStore().getStoreId(),
|
List<Long> customerPetIds = appointment.getCustomerPets().stream()
|
||||||
appointment.getStore().getStoreName(),
|
.map(CustomerPet::getCustomerPetId)
|
||||||
appointment.getService().getServiceId(),
|
.collect(Collectors.toList());
|
||||||
appointment.getService().getServiceName(),
|
|
||||||
appointment.getAppointmentDate(),
|
AppointmentResponse response = new AppointmentResponse();
|
||||||
appointment.getAppointmentTime(),
|
response.setAppointmentId(appointment.getAppointmentId());
|
||||||
appointment.getAppointmentStatus(),
|
response.setCustomerId(appointment.getCustomer().getCustomerId());
|
||||||
petNames,
|
response.setCustomerName(appointment.getCustomer().getFirstName() + " " + appointment.getCustomer().getLastName());
|
||||||
petIds,
|
response.setStoreId(appointment.getStore().getStoreId());
|
||||||
appointment.getCreatedAt(),
|
response.setStoreName(appointment.getStore().getStoreName());
|
||||||
appointment.getUpdatedAt()
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
//------------------------------------
|
//------------------------------------
|
||||||
|
|||||||
@@ -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<CustomerPetResponse> 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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
11
backend/src/main/resources/db/migration/V9__customer_pet.sql
Normal file
11
backend/src/main/resources/db/migration/V9__customer_pet.sql
Normal file
@@ -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)
|
||||||
|
);
|
||||||
@@ -38,12 +38,14 @@ export default function PetDetailPage() {
|
|||||||
|
|
||||||
{!loading && !error && pet && (
|
{!loading && !error && pet && (
|
||||||
<PetProfile
|
<PetProfile
|
||||||
|
petId={pet.petId}
|
||||||
petName={pet.petName}
|
petName={pet.petName}
|
||||||
petSpecies={pet.petSpecies}
|
petSpecies={pet.petSpecies}
|
||||||
petBreed={pet.petBreed}
|
petBreed={pet.petBreed}
|
||||||
petAge={pet.petAge}
|
petAge={pet.petAge}
|
||||||
petStatus={pet.petStatus}
|
petStatus={pet.petStatus}
|
||||||
petPrice={pet.petPrice}
|
petPrice={pet.petPrice}
|
||||||
|
imageUrl={pet.imageUrl}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export default function AdoptPage() {
|
|||||||
petName={pet.petName}
|
petName={pet.petName}
|
||||||
petSpecies={pet.petSpecies}
|
petSpecies={pet.petSpecies}
|
||||||
petStatus={pet.petStatus}
|
petStatus={pet.petStatus}
|
||||||
|
imageUrl={pet.imageUrl}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
640
web/app/appointments/page.js
Normal file
640
web/app/appointments/page.js
Normal file
@@ -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 (
|
||||||
|
<div style={s.widget}>
|
||||||
|
<div style={s.header}>
|
||||||
|
<button type="button" style={s.nav} onClick={prevMonth} disabled={isPrevDisabled} aria-label="Previous month">‹</button>
|
||||||
|
<span style={s.monthLabel}>{MONTHS[viewMonth]} {viewYear}</span>
|
||||||
|
<button type="button" style={s.nav} onClick={nextMonth} aria-label="Next month">›</button>
|
||||||
|
</div>
|
||||||
|
<div style={s.grid}>
|
||||||
|
{DAYS.map((d) => (
|
||||||
|
<span key={d} style={s.dayName}>{d}</span>
|
||||||
|
))}
|
||||||
|
{cells.map((day, i) =>
|
||||||
|
day === null ? (
|
||||||
|
<span key={`empty-${i}`} />
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={day}
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
...s.dayBase,
|
||||||
|
...(isSelected(day) ? s.daySelected : {}),
|
||||||
|
...(isDisabled(day) ? s.dayDisabled : {}),
|
||||||
|
}}
|
||||||
|
onClick={() => selectDay(day)}
|
||||||
|
disabled={isDisabled(day)}
|
||||||
|
aria-pressed={isSelected(day)}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{parsed && (
|
||||||
|
<div style={s.selectedLabel}>
|
||||||
|
Selected: {MONTHS[parsed.getMonth()]} {parsed.getDate()}, {parsed.getFullYear()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<main className="appt-page">
|
||||||
|
<p className="appt-loading">Loading...</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<main className="appt-page">
|
||||||
|
<section className="appt-hero">
|
||||||
|
<h1 className="appt-hero-title">Schedule an Appointment</h1>
|
||||||
|
<p className="appt-hero-subtitle">Book a service for your pet or schedule a pet adoption visit</p>
|
||||||
|
<div className="title-decoration"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="appt-content">
|
||||||
|
<form className="appt-form" onSubmit={handleSubmit}>
|
||||||
|
<h2 className="appt-form-title">New Appointment</h2>
|
||||||
|
|
||||||
|
{error && <div className="appt-error">{error}</div>}
|
||||||
|
{success && <div className="appt-success">{success}</div>}
|
||||||
|
|
||||||
|
<label className="appt-label">
|
||||||
|
Store Location
|
||||||
|
<select
|
||||||
|
className="appt-select"
|
||||||
|
value={storeId}
|
||||||
|
onChange={(e) => setStoreId(e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select a store...</option>
|
||||||
|
{stores.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>{s.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="appt-label">
|
||||||
|
Service
|
||||||
|
<select
|
||||||
|
className="appt-select"
|
||||||
|
value={serviceId}
|
||||||
|
onChange={(e) => handleServiceChange(e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select a service...</option>
|
||||||
|
{services.map((s) => (
|
||||||
|
<option key={s.serviceId} value={s.serviceId}>
|
||||||
|
{s.serviceName} — ${Number(s.servicePrice).toFixed(2)} ({s.serviceDuration} min)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{selectedService && (
|
||||||
|
<div className="appt-service-info">
|
||||||
|
<p>{selectedService.serviceDesc}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="appt-label">
|
||||||
|
Date
|
||||||
|
<DatePicker
|
||||||
|
value={appointmentDate}
|
||||||
|
minDate={getMinDate()}
|
||||||
|
onChange={setAppointmentDate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{storeId && serviceId && appointmentDate && (
|
||||||
|
<div className="appt-label">
|
||||||
|
<span>Available Time Slots</span>
|
||||||
|
{loadingSlots ? (
|
||||||
|
<p className="appt-slots-loading">Checking availability...</p>
|
||||||
|
) : availableSlots.length === 0 ? (
|
||||||
|
<p className="appt-no-slots">No available slots for this date. Please try another date.</p>
|
||||||
|
) : (
|
||||||
|
<div className="appt-slots-grid">
|
||||||
|
{availableSlots.map((slot) => (
|
||||||
|
<button
|
||||||
|
key={slot}
|
||||||
|
type="button"
|
||||||
|
className={`appt-slot-btn ${appointmentTime === slot ? "appt-slot-btn--selected" : ""}`}
|
||||||
|
onClick={() => setAppointmentTime(slot)}
|
||||||
|
>
|
||||||
|
{formatTime(slot)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{serviceId && (
|
||||||
|
<div className="appt-label">
|
||||||
|
<span>{petSectionLabel}</span>
|
||||||
|
{petsToShow.length === 0 ? (
|
||||||
|
<p className="appt-no-slots">{noPetsMessage}</p>
|
||||||
|
) : isAdoptionService ? (
|
||||||
|
<div className="appt-adopt-grid">
|
||||||
|
{petsToShow.map((p) => (
|
||||||
|
<label
|
||||||
|
key={p.petId}
|
||||||
|
className={`appt-adopt-card ${selectedPetIds.includes(p.petId) ? "appt-adopt-card--selected" : ""}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="adoptionPet"
|
||||||
|
value={p.petId}
|
||||||
|
checked={selectedPetIds.includes(p.petId)}
|
||||||
|
onChange={() => togglePet(p.petId)}
|
||||||
|
className="appt-adopt-radio"
|
||||||
|
/>
|
||||||
|
{p.imageUrl ? (
|
||||||
|
<img src={p.imageUrl} alt={p.petName} className="appt-adopt-img" />
|
||||||
|
) : (
|
||||||
|
<div className="appt-adopt-img-placeholder">🐾</div>
|
||||||
|
)}
|
||||||
|
<div className="appt-adopt-info">
|
||||||
|
<span className="appt-adopt-name">{p.petName}</span>
|
||||||
|
<span className="appt-adopt-detail">{p.petSpecies} · {p.petBreed}</span>
|
||||||
|
<span className="appt-adopt-detail">Age: {p.petAge}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="appt-pets-grid">
|
||||||
|
{petsToShow.map((p) => (
|
||||||
|
<label
|
||||||
|
key={p.customerPetId}
|
||||||
|
className={`appt-pet-chip ${selectedPetIds.includes(p.customerPetId) ? "appt-pet-chip--selected" : ""}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedPetIds.includes(p.customerPetId)}
|
||||||
|
onChange={() => togglePet(p.customerPetId)}
|
||||||
|
className="appt-pet-checkbox"
|
||||||
|
/>
|
||||||
|
{p.petName}
|
||||||
|
<span className="appt-pet-chip-species">({p.species})</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="appt-submit-btn"
|
||||||
|
disabled={!formValid || submitting}
|
||||||
|
>
|
||||||
|
{submitting ? "Booking..." : isAdoptionService ? "Schedule Adoption Visit" : "Book Appointment"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="appt-history">
|
||||||
|
<h2 className="appt-form-title">Your Appointments</h2>
|
||||||
|
{loadingAppointments ? (
|
||||||
|
<p className="appt-loading">Loading appointments...</p>
|
||||||
|
) : appointments.length === 0 ? (
|
||||||
|
<p className="appt-empty">No appointments yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="appt-list">
|
||||||
|
{appointments.map((a) => (
|
||||||
|
<div key={a.appointmentId} className="appt-card">
|
||||||
|
<div className="appt-card-header">
|
||||||
|
<span className="appt-card-service">{a.serviceName}</span>
|
||||||
|
<span className={`appt-card-status appt-card-status--${a.appointmentStatus.toLowerCase()}`}>
|
||||||
|
{a.appointmentStatus}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="appt-card-details">
|
||||||
|
<span>{a.storeName}</span>
|
||||||
|
<span>{a.appointmentDate} at {formatTime(a.appointmentTime)}</span>
|
||||||
|
</div>
|
||||||
|
{a.petNames && a.petNames.length > 0 && (
|
||||||
|
<div className="appt-card-pets">
|
||||||
|
Pets: {a.petNames.join(", ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{a.customerPetNames && a.customerPetNames.length > 0 && (
|
||||||
|
<div className="appt-card-pets">
|
||||||
|
Pets: {a.customerPetNames.join(", ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -232,7 +232,7 @@ body {
|
|||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Adopt Page ─────────────────────────────────────────────── */
|
/* Adopt page */
|
||||||
|
|
||||||
.adopt-page {
|
.adopt-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -373,6 +373,12 @@ body {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pet-card-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
.pet-card-body {
|
.pet-card-body {
|
||||||
padding: 1rem 1.25rem 1.25rem;
|
padding: 1rem 1.25rem 1.25rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -459,7 +465,7 @@ body {
|
|||||||
color: #555;
|
color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Pet Detail Page ─────────────────────────────────────────── */
|
/* Pet details */
|
||||||
|
|
||||||
.pet-detail-page {
|
.pet-detail-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -508,6 +514,12 @@ body {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pet-detail-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
.pet-detail-info {
|
.pet-detail-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 2.5rem 2.5rem 2.5rem 0;
|
padding: 2.5rem 2.5rem 2.5rem 0;
|
||||||
@@ -599,7 +611,41 @@ body {
|
|||||||
background: #e69500;
|
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 */
|
/* Responsive Design */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.adopt-grid {
|
.adopt-grid {
|
||||||
@@ -608,11 +654,13 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.adopt-hero-title {
|
.adopt-hero-title,
|
||||||
|
.products-hero-title {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.adopt-hero-subtitle {
|
.adopt-hero-subtitle,
|
||||||
|
.products-hero-subtitle {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -641,7 +689,8 @@ body {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.adopt-hero-title {
|
.adopt-hero-title,
|
||||||
|
.products-hero-title {
|
||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,7 +754,7 @@ body {
|
|||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* ─── Adopt diagnostic additions ────────────────────────────── */
|
/* Adopt diagnostics */
|
||||||
|
|
||||||
.adopt-controls-row {
|
.adopt-controls-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1020,3 +1069,633 @@ body {
|
|||||||
color: #888;
|
color: #888;
|
||||||
font-size: 1rem;
|
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;
|
||||||
|
}
|
||||||
57
web/app/products/[id]/page.js
Normal file
57
web/app/products/[id]/page.js
Normal file
@@ -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 (
|
||||||
|
<main className="pet-detail-page">
|
||||||
|
<div className="pet-detail-container">
|
||||||
|
<Link href="/products" className="pet-detail-back">← Back to Products</Link>
|
||||||
|
|
||||||
|
{loading && <p className="adopt-status-msg">Loading product details...</p>}
|
||||||
|
{error && <p className="adopt-status-msg adopt-error">{error}</p>}
|
||||||
|
|
||||||
|
{!loading && !error && product && (
|
||||||
|
<ProductProfile
|
||||||
|
prodName={product.prodName}
|
||||||
|
categoryName={product.categoryName}
|
||||||
|
prodDesc={product.prodDesc}
|
||||||
|
prodPrice={product.prodPrice}
|
||||||
|
imageUrl={product.imageUrl}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
web/app/products/page.js
Normal file
133
web/app/products/page.js
Normal file
@@ -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 (
|
||||||
|
<main className="products-page">
|
||||||
|
<section className="products-hero">
|
||||||
|
<h1 className="products-hero-title">Shop Our Products</h1>
|
||||||
|
<p className="products-hero-subtitle">Everything your pet needs, all in one place</p>
|
||||||
|
<div className="title-decoration"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="adopt-controls">
|
||||||
|
<div className="adopt-controls-row">
|
||||||
|
<form className="adopt-search-form" onSubmit={handleSearch}>
|
||||||
|
<input
|
||||||
|
className="adopt-search-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by name or category..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button className="adopt-search-btn" type="submit">Search</button>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
className="adopt-clear-btn"
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setSearch(""); setQuery(""); setPage(0); }}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="adopt-grid-section">
|
||||||
|
{loading && <p className="adopt-status-msg">Loading products...</p>}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="adopt-error-box">
|
||||||
|
<p className="adopt-error-title">Failed to load products</p>
|
||||||
|
<code className="adopt-error-detail">{error}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && products.length === 0 && (
|
||||||
|
<p className="adopt-status-msg">No products found.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && products.length > 0 && (
|
||||||
|
<div className="adopt-grid">
|
||||||
|
{products.map((product) => (
|
||||||
|
<ProductCard
|
||||||
|
key={product.prodId}
|
||||||
|
prodId={product.prodId}
|
||||||
|
prodName={product.prodName}
|
||||||
|
categoryName={product.categoryName}
|
||||||
|
prodPrice={product.prodPrice}
|
||||||
|
imageUrl={product.imageUrl}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && totalPages > 1 && (
|
||||||
|
<div className="adopt-pagination">
|
||||||
|
<button
|
||||||
|
className="pagination-btn"
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
>
|
||||||
|
← Prev
|
||||||
|
</button>
|
||||||
|
<span className="pagination-info">Page {page + 1} of {totalPages}</span>
|
||||||
|
<button
|
||||||
|
className="pagination-btn"
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,25 +1,158 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
|
||||||
|
const API_BASE = "";
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { user, loading, logout } = useAuth();
|
const {user, token, loading, logout} = useAuth();
|
||||||
const router = useRouter();
|
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(() => {
|
useEffect(() => {
|
||||||
if (!loading && !user) {
|
if (!loading && !user) {
|
||||||
router.replace("/login");
|
router.replace("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
}, [user, loading, router]);
|
}, [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() {
|
function handleLogout() {
|
||||||
logout();
|
logout();
|
||||||
|
|
||||||
router.push("/");
|
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) {
|
if (loading || !user) {
|
||||||
return <main className="auth-page"><p className="profile-loading">Loading…</p></main>;
|
return <main className="auth-page"><p className="profile-loading">Loading…</p></main>;
|
||||||
}
|
}
|
||||||
@@ -34,7 +167,7 @@ export default function ProfilePage() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="auth-page">
|
<main className="profile-page-layout">
|
||||||
<div className="profile-card">
|
<div className="profile-card">
|
||||||
<div className="profile-avatar-circle">
|
<div className="profile-avatar-circle">
|
||||||
{(user.fullName || user.username).charAt(0).toUpperCase()}
|
{(user.fullName || user.username).charAt(0).toUpperCase()}
|
||||||
@@ -56,6 +189,111 @@ export default function ProfilePage() {
|
|||||||
Log Out
|
Log Out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{user.role === "CUSTOMER" && (
|
||||||
|
<div className="profile-pets-section">
|
||||||
|
<div className="profile-pets-header">
|
||||||
|
<h2 className="profile-pets-title">My Pets</h2>
|
||||||
|
<button className="profile-pets-add-btn" onClick={openAddForm}>+ Add Pet</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<form className="profile-pet-form" onSubmit={handlePetSubmit}>
|
||||||
|
<h3 className="profile-pet-form-title">
|
||||||
|
{editingPet ? "Edit Pet" : "Add a New Pet"}
|
||||||
|
</h3>
|
||||||
|
{petError && <div className="appt-error">{petError}</div>}
|
||||||
|
<label className="appt-label">
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
className="appt-input"
|
||||||
|
type="text"
|
||||||
|
value={petName}
|
||||||
|
onChange={(e) => setPetName(e.target.value)}
|
||||||
|
required
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="appt-label">
|
||||||
|
Species
|
||||||
|
<input
|
||||||
|
className="appt-input"
|
||||||
|
type="text"
|
||||||
|
value={species}
|
||||||
|
onChange={(e) => setSpecies(e.target.value)}
|
||||||
|
required
|
||||||
|
maxLength={50}
|
||||||
|
placeholder="e.g. Dog, Cat, Bird"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="appt-label">
|
||||||
|
Breed (optional)
|
||||||
|
<input
|
||||||
|
className="appt-input"
|
||||||
|
type="text"
|
||||||
|
value={breed}
|
||||||
|
onChange={(e) => setBreed(e.target.value)}
|
||||||
|
maxLength={50}
|
||||||
|
placeholder="e.g. Golden Retriever"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="profile-pet-form-actions">
|
||||||
|
<button type="submit" className="appt-submit-btn" disabled={submitting}>
|
||||||
|
{submitting ? "Saving..." : editingPet ? "Save Changes" : "Add Pet"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="profile-pet-cancel-btn" onClick={closeForm}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadingPets ? (
|
||||||
|
<p className="appt-loading">Loading pets...</p>
|
||||||
|
) : pets.length === 0 && !showForm ? (
|
||||||
|
<p className="profile-pets-empty">No pet profiles yet. Add your first pet above!</p>
|
||||||
|
) : (
|
||||||
|
<div className="profile-pets-grid">
|
||||||
|
{pets.map((pet) => (
|
||||||
|
<div key={pet.customerPetId} className="profile-pet-card">
|
||||||
|
<div className="profile-pet-card-img-area">
|
||||||
|
{pet.imageUrl ? (
|
||||||
|
<img
|
||||||
|
src={pet.imageUrl}
|
||||||
|
alt={pet.petName}
|
||||||
|
className="profile-pet-card-img"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="profile-pet-card-placeholder">🐾</div>
|
||||||
|
)}
|
||||||
|
<label className="profile-pet-upload-label">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/gif"
|
||||||
|
className="profile-pet-upload-input"
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.files[0]) handleImageUpload(pet.customerPetId, e.target.files[0]);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
📷
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="profile-pet-card-info">
|
||||||
|
<span className="profile-pet-card-name">{pet.petName}</span>
|
||||||
|
<span className="profile-pet-card-detail">{pet.species}</span>
|
||||||
|
{pet.breed && <span className="profile-pet-card-detail">{pet.breed}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="profile-pet-card-actions">
|
||||||
|
<button className="profile-pet-edit-btn" onClick={() => openEditForm(pet)}>Edit</button>
|
||||||
|
<button className="profile-pet-delete-btn" onClick={() => handleDeletePet(pet.customerPetId)}>Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function DisplayNav() {
|
|||||||
<div className="nav-links">
|
<div className="nav-links">
|
||||||
<a href="/" className="nav-link">Home</a>
|
<a href="/" className="nav-link">Home</a>
|
||||||
<a href="/adopt" className="nav-link">Adopt a Pet</a>
|
<a href="/adopt" className="nav-link">Adopt a Pet</a>
|
||||||
<a href="/" className="nav-link">Online Store</a>
|
<a href="/products" className="nav-link">Online Store</a>
|
||||||
<a href="/appointments" className="nav-link">Schedule an Appointment</a>
|
<a href="/appointments" className="nav-link">Schedule an Appointment</a>
|
||||||
<a href="/contact" className="nav-link">Contact Us</a>
|
<a href="/contact" className="nav-link">Contact Us</a>
|
||||||
<a href="/aboutus" className="nav-link">About Us</a>
|
<a href="/aboutus" className="nav-link">About Us</a>
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
//Pet cards (on adopt page)
|
|
||||||
|
|
||||||
import Link from "next/link";
|
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 (
|
return (
|
||||||
<Link href={`/adopt/${petId}`} className="pet-card">
|
<Link href={`/adopt/${petId}`} className="pet-card">
|
||||||
<div className="pet-card-image-wrapper">
|
<div className="pet-card-image-wrapper">
|
||||||
<span className="pet-card-emoji">{getSpeciesEmoji(petSpecies)}</span>
|
<img src={imageUrl || "/images/pet-placeholder.png"} alt={petName} className="pet-card-image" />
|
||||||
</div>
|
</div>
|
||||||
<div className="pet-card-body">
|
<div className="pet-card-body">
|
||||||
<h3 className="pet-card-name">{petName}</h3>
|
<h3 className="pet-card-name">{petName}</h3>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import Link from "next/link";
|
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 (
|
return (
|
||||||
<div className="pet-detail-card">
|
<div className="pet-detail-card">
|
||||||
<div className="pet-detail-image-wrapper">
|
<div className="pet-detail-image-wrapper">
|
||||||
<span className="pet-detail-emoji">{getSpeciesEmoji(petSpecies)}</span>
|
<img src={imageUrl || "/images/pet-placeholder.png"} alt={petName} className="pet-detail-image" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pet-detail-info">
|
<div className="pet-detail-info">
|
||||||
@@ -45,7 +45,7 @@ export default function PetProfile({ petName, petSpecies, petBreed, petAge, petS
|
|||||||
<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" className="pet-detail-cta-btn">
|
<Link href={`/appointments?petId=${petId}`} className="pet-detail-cta-btn">
|
||||||
Schedule an Appointment
|
Schedule an Appointment
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
18
web/components/ProductCard.js
Normal file
18
web/components/ProductCard.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function ProductCard({ prodId, prodName, categoryName, prodPrice, imageUrl }) {
|
||||||
|
return (
|
||||||
|
<Link href={`/products/${prodId}`} className="pet-card">
|
||||||
|
<div className="pet-card-image-wrapper">
|
||||||
|
<img src={imageUrl || "/images/product-placeholder.png"} alt={prodName} className="pet-card-image" />
|
||||||
|
</div>
|
||||||
|
<div className="pet-card-body">
|
||||||
|
<h3 className="pet-card-name">{prodName}</h3>
|
||||||
|
<p className="pet-card-species">{categoryName}</p>
|
||||||
|
{prodPrice != null && (
|
||||||
|
<span className="product-card-price">${parseFloat(prodPrice).toFixed(2)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
web/components/ProductProfile.js
Normal file
34
web/components/ProductProfile.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function ProductProfile({ prodName, categoryName, prodDesc, prodPrice, imageUrl }) {
|
||||||
|
return (
|
||||||
|
<div className="pet-detail-card">
|
||||||
|
<div className="pet-detail-image-wrapper">
|
||||||
|
<img src={imageUrl || "/images/product-placeholder.png"} alt={prodName} className="pet-detail-image" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pet-detail-info">
|
||||||
|
<div className="pet-detail-header">
|
||||||
|
<h1 className="pet-detail-name">{prodName}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pet-detail-fields">
|
||||||
|
<div className="pet-detail-row">
|
||||||
|
<span className="pet-detail-label">Category</span>
|
||||||
|
<span className="pet-detail-value">{categoryName ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pet-detail-row">
|
||||||
|
<span className="pet-detail-label">Price</span>
|
||||||
|
<span className="pet-detail-value pet-detail-price">
|
||||||
|
{prodPrice != null ? `$${parseFloat(prodPrice).toFixed(2)}` : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="pet-detail-row">
|
||||||
|
<span className="pet-detail-label">Description</span>
|
||||||
|
<span className="pet-detail-value">{prodDesc ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
web/public/images/home/navimages/about.jpg
Normal file
BIN
web/public/images/home/navimages/about.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 246 KiB |
BIN
web/public/images/pet-placeholder.png
Normal file
BIN
web/public/images/pet-placeholder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
Reference in New Issue
Block a user