Merge remote-tracking branch 'origin/main' into azure-deploy
This commit is contained in:
@@ -11,6 +11,7 @@ import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.bumptech.glide.load.model.GlideUrl;
|
||||
import com.bumptech.glide.load.model.LazyHeaders;
|
||||
import com.bumptech.glide.signature.ObjectKey;
|
||||
import com.example.petstoremobile.R;
|
||||
import com.example.petstoremobile.databinding.ItemMessageReceivedBinding;
|
||||
import com.example.petstoremobile.databinding.ItemMessageSentBinding;
|
||||
@@ -35,6 +36,13 @@ public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder
|
||||
public MessageAdapter(List<Message> messages, Long currentUserId) {
|
||||
this.messages = messages;
|
||||
this.currentUserId = currentUserId;
|
||||
setHasStableIds(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getItemId(int position) {
|
||||
Message m = messages.get(position);
|
||||
return m.getId() != null ? m.getId() : position;
|
||||
}
|
||||
|
||||
public void setCurrentUserId(Long id) {
|
||||
@@ -150,6 +158,7 @@ public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder
|
||||
}
|
||||
|
||||
if (url == null) {
|
||||
Glide.with(iv.getContext()).clear(iv);
|
||||
iv.setVisibility(View.GONE);
|
||||
tvName.setVisibility(View.GONE);
|
||||
return;
|
||||
@@ -166,18 +175,25 @@ public class MessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder
|
||||
.build());
|
||||
}
|
||||
|
||||
// Use a signature to prevent Glide from showing stale cached images for the same URL/ID
|
||||
String signatureKey = (m.getTimestamp() != null ? m.getTimestamp() : "") + m.getId();
|
||||
|
||||
Glide.with(iv.getContext()).clear(iv);
|
||||
Glide.with(iv.getContext())
|
||||
.load(loadTarget)
|
||||
.signature(new ObjectKey(signatureKey))
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||
.placeholder(R.drawable.placeholder)
|
||||
.error(R.drawable.placeholder)
|
||||
.into(iv);
|
||||
} else {
|
||||
Glide.with(iv.getContext()).clear(iv);
|
||||
iv.setVisibility(View.GONE);
|
||||
tvName.setVisibility(View.VISIBLE);
|
||||
tvName.setText(m.getAttachmentName() != null ? m.getAttachmentName() : "Attachment");
|
||||
}
|
||||
} else {
|
||||
Glide.with(iv.getContext()).clear(iv);
|
||||
iv.setVisibility(View.GONE);
|
||||
tvName.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
@@ -182,6 +182,7 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
|
||||
LinearLayoutManager lm = new LinearLayoutManager(getContext());
|
||||
lm.setStackFromEnd(true);
|
||||
binding.rvMessages.setLayoutManager(lm);
|
||||
binding.rvMessages.setItemAnimator(null);
|
||||
binding.rvMessages.setAdapter(messageAdapter);
|
||||
setConversationActive(false, null);
|
||||
}
|
||||
@@ -285,9 +286,14 @@ public class ChatFragment extends Fragment implements ChatAdapter.OnChatClickLis
|
||||
});
|
||||
|
||||
viewModel.getMessageList().observe(getViewLifecycleOwner(), list -> {
|
||||
int prevSize = messageList.size();
|
||||
messageList.clear();
|
||||
messageList.addAll(list);
|
||||
messageAdapter.notifyDataSetChanged();
|
||||
if (prevSize > 0 && list.size() == prevSize + 1) {
|
||||
messageAdapter.notifyItemInserted(list.size() - 1);
|
||||
} else {
|
||||
messageAdapter.notifyDataSetChanged();
|
||||
}
|
||||
scrollToBottom();
|
||||
});
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@ import android.widget.*;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import com.example.petstoremobile.api.auth.TokenManager;
|
||||
import com.example.petstoremobile.databinding.FragmentAnalyticsBinding;
|
||||
import com.example.petstoremobile.utils.SpinnerUtils;
|
||||
import com.example.petstoremobile.utils.UIUtils;
|
||||
import com.example.petstoremobile.viewmodels.AnalyticsViewModel;
|
||||
import dagger.hilt.android.AndroidEntryPoint;
|
||||
import javax.inject.Inject;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.*;
|
||||
@@ -19,6 +21,9 @@ import java.util.*;
|
||||
@AndroidEntryPoint
|
||||
public class AnalyticsFragment extends Fragment {
|
||||
|
||||
@Inject
|
||||
TokenManager tokenManager;
|
||||
|
||||
private FragmentAnalyticsBinding binding;
|
||||
private AnalyticsViewModel viewModel;
|
||||
private boolean filtersExpanded = false;
|
||||
@@ -33,6 +38,7 @@ public class AnalyticsFragment extends Fragment {
|
||||
viewModel = new ViewModelProvider(this).get(AnalyticsViewModel.class);
|
||||
|
||||
setupFilterPanel();
|
||||
setupViewModeToggle();
|
||||
observeViewModel();
|
||||
viewModel.loadAnalytics();
|
||||
|
||||
@@ -42,6 +48,39 @@ public class AnalyticsFragment extends Fragment {
|
||||
return binding.getRoot();
|
||||
}
|
||||
|
||||
private static final int COLOR_SELECTED = 0xFF4ECDC4;
|
||||
private static final int COLOR_UNSELECTED = 0xFFCBD5E1;
|
||||
|
||||
private void setupViewModeToggle() {
|
||||
updateViewModeButtonStyles(viewModel.getViewMode());
|
||||
|
||||
binding.btnMyAnalytics.setOnClickListener(v -> {
|
||||
viewModel.setViewMode("mine");
|
||||
updateViewModeButtonStyles("mine");
|
||||
updateStoreFilterVisibility("mine");
|
||||
});
|
||||
|
||||
binding.btnStoreAnalytics.setOnClickListener(v -> {
|
||||
viewModel.setViewMode("store");
|
||||
updateViewModeButtonStyles("store");
|
||||
updateStoreFilterVisibility("store");
|
||||
});
|
||||
}
|
||||
|
||||
private void updateViewModeButtonStyles(String mode) {
|
||||
binding.btnMyAnalytics.setBackgroundTintList(
|
||||
android.content.res.ColorStateList.valueOf(mode.equals("mine") ? COLOR_SELECTED : COLOR_UNSELECTED));
|
||||
binding.btnStoreAnalytics.setBackgroundTintList(
|
||||
android.content.res.ColorStateList.valueOf(mode.equals("store") ? COLOR_SELECTED : COLOR_UNSELECTED));
|
||||
}
|
||||
|
||||
private void updateStoreFilterVisibility(String mode) {
|
||||
boolean isAdmin = "ADMIN".equalsIgnoreCase(tokenManager.getRole());
|
||||
int vis = (isAdmin && mode.equals("store")) ? View.VISIBLE : View.GONE;
|
||||
binding.tvStoreFilterLabel.setVisibility(vis);
|
||||
binding.spinnerFilterStore.setVisibility(vis);
|
||||
}
|
||||
|
||||
// Filter Panel
|
||||
|
||||
private void setupFilterPanel() {
|
||||
@@ -96,6 +135,9 @@ public class AnalyticsFragment extends Fragment {
|
||||
int topNPos = binding.spinnerTopN.getSelectedItemPosition();
|
||||
filter.topN = (topNPos >= 0 && topNPos < TOP_N_VALUES.length) ? TOP_N_VALUES[topNPos] : 5;
|
||||
|
||||
Object store = binding.spinnerFilterStore.getSelectedItem();
|
||||
viewModel.setStoreFilter(store != null ? store.toString() : "All Stores");
|
||||
|
||||
updateFilterSummary();
|
||||
viewModel.applyFilter(filter);
|
||||
}
|
||||
@@ -104,8 +146,8 @@ public class AnalyticsFragment extends Fragment {
|
||||
binding.etFilterStartDate.setText("");
|
||||
binding.etFilterEndDate.setText("");
|
||||
binding.spinnerTopN.setSelection(0);
|
||||
// Reset payment method to "All"
|
||||
SpinnerUtils.setSelectionByValue(binding.spinnerFilterPayment, "All");
|
||||
SpinnerUtils.setSelectionByValue(binding.spinnerFilterStore, "All Stores");
|
||||
updateFilterSummary();
|
||||
viewModel.resetFilter();
|
||||
}
|
||||
@@ -162,6 +204,16 @@ public class AnalyticsFragment extends Fragment {
|
||||
methods.toArray(new String[0]));
|
||||
SpinnerUtils.setSelectionByValue(binding.spinnerFilterPayment, currentSelection);
|
||||
});
|
||||
|
||||
viewModel.getAvailableStores().observe(getViewLifecycleOwner(), stores -> {
|
||||
if (stores == null || stores.isEmpty()) return;
|
||||
String currentSelection = binding.spinnerFilterStore.getSelectedItem() != null
|
||||
? binding.spinnerFilterStore.getSelectedItem().toString() : "All Stores";
|
||||
SpinnerUtils.setupStringSpinner(requireContext(), binding.spinnerFilterStore,
|
||||
stores.toArray(new String[0]));
|
||||
SpinnerUtils.setSelectionByValue(binding.spinnerFilterStore, currentSelection);
|
||||
updateStoreFilterVisibility(viewModel.getViewMode());
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -224,17 +276,22 @@ public class AnalyticsFragment extends Fragment {
|
||||
}
|
||||
|
||||
// Employee Performance
|
||||
binding.llEmployeePerformance.removeAllViews();
|
||||
if (data.employeePerformance != null && !data.employeePerformance.isEmpty()) {
|
||||
BigDecimal maxEmp = data.employeePerformance.get(0).getValue();
|
||||
if (maxEmp.compareTo(BigDecimal.ZERO) == 0) maxEmp = BigDecimal.ONE;
|
||||
for (Map.Entry<String, BigDecimal> e : data.employeePerformance) {
|
||||
addBarRow(binding.llEmployeePerformance, e.getKey(),
|
||||
"$" + e.getValue().setScale(2, RoundingMode.HALF_UP),
|
||||
e.getValue().floatValue() / maxEmp.floatValue(), "#1a759f");
|
||||
boolean showEmployeeSection = viewModel.getViewMode().equals("store");
|
||||
View empParent = (View) binding.llEmployeePerformance.getParent();
|
||||
if (empParent != null) empParent.setVisibility(showEmployeeSection ? View.VISIBLE : View.GONE);
|
||||
if (showEmployeeSection) {
|
||||
binding.llEmployeePerformance.removeAllViews();
|
||||
if (data.employeePerformance != null && !data.employeePerformance.isEmpty()) {
|
||||
BigDecimal maxEmp = data.employeePerformance.get(0).getValue();
|
||||
if (maxEmp.compareTo(BigDecimal.ZERO) == 0) maxEmp = BigDecimal.ONE;
|
||||
for (Map.Entry<String, BigDecimal> e : data.employeePerformance) {
|
||||
addBarRow(binding.llEmployeePerformance, e.getKey(),
|
||||
"$" + e.getValue().setScale(2, RoundingMode.HALF_UP),
|
||||
e.getValue().floatValue() / maxEmp.floatValue(), "#1a759f");
|
||||
}
|
||||
} else {
|
||||
addEmptyRow(binding.llEmployeePerformance, "No data");
|
||||
}
|
||||
} else {
|
||||
addEmptyRow(binding.llEmployeePerformance, "No data");
|
||||
}
|
||||
|
||||
// Daily Revenue
|
||||
|
||||
@@ -111,6 +111,7 @@ public class SaleDetailFragment extends Fragment {
|
||||
binding.llLoyaltyPoints.setVisibility(View.GONE);
|
||||
binding.cbUseLoyaltyPoints.setChecked(false);
|
||||
}
|
||||
updateTotal();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -420,6 +421,15 @@ public class SaleDetailFragment extends Fragment {
|
||||
}
|
||||
|
||||
binding.tvSaleDetailTotal.setText("Total: $" + String.format(Locale.getDefault(), "%.2f", total));
|
||||
|
||||
CustomerDTO customer = viewModel.getSelectedCustomerData().getValue();
|
||||
if (customer != null && !viewModel.isViewOnly()) {
|
||||
int pointsToEarn = total.max(BigDecimal.ZERO).intValue();
|
||||
binding.tvPointsToEarn.setText("+" + pointsToEarn + " pts");
|
||||
binding.llPointsToEarn.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
binding.llPointsToEarn.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveSale() {
|
||||
|
||||
@@ -100,13 +100,12 @@ public class InputValidator {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Checks if the phone number is valid in (XXX) XXX-XXXX format
|
||||
// Checks if the phone number is valid: XXX-XXX-XXXX, (XXX) XXX-XXXX, or XXXXXXXXXX
|
||||
public static boolean isValidPhone(EditText field) {
|
||||
String phone = field.getText().toString().trim();
|
||||
// Matches (XXX) XXX-XXXX format
|
||||
String pattern = "^\\(\\d{3}\\) \\d{3}-\\d{4}$";
|
||||
String pattern = "^(\\(\\d{3}\\) \\d{3}-\\d{4}|\\d{3}-\\d{3}-\\d{4}|\\d{10})$";
|
||||
if (phone.isEmpty() || !phone.matches(pattern)) {
|
||||
field.setError("Enter a valid phone number: (XXX) XXX-XXXX");
|
||||
field.setError("Enter a valid phone number");
|
||||
field.requestFocus();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
|
||||
import com.example.petstoremobile.api.auth.TokenManager;
|
||||
import com.example.petstoremobile.dtos.SaleDTO;
|
||||
import com.example.petstoremobile.repositories.SaleRepository;
|
||||
import com.example.petstoremobile.utils.Resource;
|
||||
@@ -21,6 +22,7 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
@@ -29,24 +31,30 @@ import dagger.hilt.android.lifecycle.HiltViewModel;
|
||||
@HiltViewModel
|
||||
public class AnalyticsViewModel extends ViewModel {
|
||||
private final SaleRepository saleRepository;
|
||||
private final TokenManager tokenManager;
|
||||
|
||||
private final MutableLiveData<AnalyticsData> analyticsData = new MutableLiveData<>();
|
||||
private final MutableLiveData<Boolean> isLoading = new MutableLiveData<>(false);
|
||||
private final MutableLiveData<String> errorMessage = new MutableLiveData<>();
|
||||
private final MutableLiveData<List<String>> availablePaymentMethods = new MutableLiveData<>(new ArrayList<>());
|
||||
private final MutableLiveData<List<String>> availableStores = new MutableLiveData<>(new ArrayList<>());
|
||||
|
||||
private List<SaleDTO> cachedSales = new ArrayList<>();
|
||||
private FilterState currentFilter = new FilterState();
|
||||
private String viewMode = "store";
|
||||
private String storeFilter = "All Stores";
|
||||
|
||||
@Inject
|
||||
public AnalyticsViewModel(SaleRepository saleRepository) {
|
||||
public AnalyticsViewModel(SaleRepository saleRepository, TokenManager tokenManager) {
|
||||
this.saleRepository = saleRepository;
|
||||
this.tokenManager = tokenManager;
|
||||
}
|
||||
|
||||
public LiveData<AnalyticsData> getAnalyticsData() { return analyticsData; }
|
||||
public LiveData<Boolean> getIsLoading() { return isLoading; }
|
||||
public LiveData<String> getErrorMessage() { return errorMessage; }
|
||||
public LiveData<List<String>> getAvailablePaymentMethods() { return availablePaymentMethods; }
|
||||
public LiveData<List<String>> getAvailableStores() { return availableStores; }
|
||||
|
||||
public void loadAnalytics() {
|
||||
isLoading.setValue(true);
|
||||
@@ -56,6 +64,7 @@ public class AnalyticsViewModel extends ViewModel {
|
||||
if (resource.status == Resource.Status.SUCCESS && resource.data != null) {
|
||||
cachedSales = resource.data.getContent();
|
||||
derivePaymentMethods();
|
||||
deriveStores();
|
||||
applyCurrentFilter();
|
||||
isLoading.setValue(false);
|
||||
} else if (resource.status == Resource.Status.ERROR) {
|
||||
@@ -73,14 +82,61 @@ public class AnalyticsViewModel extends ViewModel {
|
||||
|
||||
public void resetFilter() {
|
||||
currentFilter = new FilterState();
|
||||
storeFilter = "All Stores";
|
||||
applyCurrentFilter();
|
||||
}
|
||||
|
||||
public void setViewMode(String mode) {
|
||||
viewMode = mode;
|
||||
applyCurrentFilter();
|
||||
}
|
||||
|
||||
public String getViewMode() {
|
||||
return viewMode;
|
||||
}
|
||||
|
||||
public void setStoreFilter(String store) {
|
||||
storeFilter = (store != null && !store.isEmpty()) ? store : "All Stores";
|
||||
applyCurrentFilter();
|
||||
}
|
||||
|
||||
public String getStoreFilter() {
|
||||
return storeFilter;
|
||||
}
|
||||
|
||||
private void applyCurrentFilter() {
|
||||
List<SaleDTO> filtered = filterSales(cachedSales, currentFilter);
|
||||
List<SaleDTO> salesForMode;
|
||||
if (viewMode.equals("mine")) {
|
||||
String currentUser = tokenManager.getUsername();
|
||||
salesForMode = cachedSales.stream()
|
||||
.filter(s -> currentUser != null && currentUser.equalsIgnoreCase(s.getEmployeeName() != null ? s.getEmployeeName() : ""))
|
||||
.collect(Collectors.toList());
|
||||
} else {
|
||||
salesForMode = cachedSales;
|
||||
}
|
||||
if (!storeFilter.equals("All Stores") && !storeFilter.isEmpty()) {
|
||||
final String sf = storeFilter;
|
||||
salesForMode = salesForMode.stream()
|
||||
.filter(s -> sf.equalsIgnoreCase(s.getStoreName() != null ? s.getStoreName() : ""))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
List<SaleDTO> filtered = filterSales(salesForMode, currentFilter);
|
||||
computeAnalytics(filtered, currentFilter);
|
||||
}
|
||||
|
||||
private void deriveStores() {
|
||||
java.util.Set<String> stores = new java.util.TreeSet<>();
|
||||
for (SaleDTO s : cachedSales) {
|
||||
if (s.getStoreName() != null && !s.getStoreName().isEmpty()) {
|
||||
stores.add(s.getStoreName());
|
||||
}
|
||||
}
|
||||
List<String> result = new ArrayList<>();
|
||||
result.add("All Stores");
|
||||
result.addAll(stores);
|
||||
availableStores.setValue(result);
|
||||
}
|
||||
|
||||
private void derivePaymentMethods() {
|
||||
java.util.Set<String> methods = new java.util.TreeSet<>();
|
||||
for (SaleDTO s : cachedSales) {
|
||||
|
||||
@@ -45,6 +45,40 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/llViewModeToggle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="4dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnMyAnalytics"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="36dp"
|
||||
android:layout_weight="1"
|
||||
android:text="My Analytics"
|
||||
android:textSize="12sp"
|
||||
android:backgroundTint="#CBD5E1"
|
||||
android:textColor="@color/white"
|
||||
android:layout_marginEnd="4dp"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnStoreAnalytics"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="36dp"
|
||||
android:layout_weight="1"
|
||||
android:text="Store Analytics"
|
||||
android:textSize="12sp"
|
||||
android:backgroundTint="@color/primary_medium"
|
||||
android:textColor="@color/white"
|
||||
android:layout_marginStart="4dp"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
@@ -102,6 +136,23 @@
|
||||
android:layout_marginTop="12dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvStoreFilterLabel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Store"
|
||||
android:textColor="@color/text_light"
|
||||
android:textSize="11sp"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:visibility="gone"/>
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/spinnerFilterStore"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:visibility="gone"/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
||||
@@ -444,6 +444,30 @@
|
||||
android:textColor="@color/accent_coral"
|
||||
android:layout_gravity="end"
|
||||
android:layout_marginTop="12dp"/>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/llPointsToEarn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_gravity="end"
|
||||
android:layout_marginTop="4dp"
|
||||
android:visibility="gone">
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Points to earn: "
|
||||
android:textColor="#4ECDC4"
|
||||
android:textSize="13sp"/>
|
||||
<TextView
|
||||
android:id="@+id/tvPointsToEarn"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="+0 pts"
|
||||
android:textColor="#4ECDC4"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold"/>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.petshop.backend.controller;
|
||||
|
||||
import com.petshop.backend.dto.adoption.AdoptionRequest;
|
||||
import com.petshop.backend.dto.adoption.AdoptionResponse;
|
||||
import com.petshop.backend.dto.adoption.CustomerAdoptionRequest;
|
||||
import com.petshop.backend.dto.common.BulkDeleteRequest;
|
||||
import com.petshop.backend.entity.User;
|
||||
import com.petshop.backend.repository.UserRepository;
|
||||
@@ -81,6 +82,33 @@ public class AdoptionController {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(adoptionService.createAdoption(request));
|
||||
}
|
||||
|
||||
@PostMapping("/request")
|
||||
@PreAuthorize("hasAnyRole('CUSTOMER', 'ADMIN')")
|
||||
public ResponseEntity<AdoptionResponse> requestAdoption(@Valid @RequestBody CustomerAdoptionRequest request) {
|
||||
User user = AuthenticationHelper.getAuthenticatedUser(userRepository);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(
|
||||
adoptionService.requestAdoption(user.getId(), request.getPetId(), request.getEmployeeId(), request.getSourceStoreId(), request.getAdoptionDate())
|
||||
);
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}/cancel")
|
||||
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
|
||||
public ResponseEntity<AdoptionResponse> cancelAdoption(@PathVariable Long id) {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
String role = authentication.getAuthorities().stream()
|
||||
.findFirst()
|
||||
.map(authority -> authority.getAuthority().replace("ROLE_", ""))
|
||||
.orElse(null);
|
||||
|
||||
Long customerId = null;
|
||||
if ("CUSTOMER".equals(role)) {
|
||||
User user = AuthenticationHelper.getAuthenticatedUser(userRepository);
|
||||
customerId = user.getId();
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(adoptionService.cancelAdoption(id, customerId));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
|
||||
public ResponseEntity<AdoptionResponse> updateAdoption(
|
||||
|
||||
@@ -98,6 +98,24 @@ public class AppointmentController {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(appointmentService.createAppointment(request));
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}/cancel")
|
||||
@PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
|
||||
public ResponseEntity<AppointmentResponse> cancelAppointment(@PathVariable Long id) {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
String role = authentication.getAuthorities().stream()
|
||||
.findFirst()
|
||||
.map(authority -> authority.getAuthority().replace("ROLE_", ""))
|
||||
.orElse(null);
|
||||
|
||||
Long customerId = null;
|
||||
if ("CUSTOMER".equals(role)) {
|
||||
User user = AuthenticationHelper.getAuthenticatedUser(userRepository);
|
||||
customerId = user.getId();
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(appointmentService.cancelAppointment(id, customerId));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
|
||||
public ResponseEntity<AppointmentResponse> updateAppointment(
|
||||
|
||||
@@ -73,7 +73,8 @@ public class AuthController {
|
||||
public ResponseEntity<?> register(@Valid @RequestBody RegisterRequest request) {
|
||||
String username = trimToNull(request.getUsername());
|
||||
String email = trimToNull(request.getEmail());
|
||||
NameParts nameParts = splitFullName(request.getFullName());
|
||||
String firstName = trimToNull(request.getFirstName());
|
||||
String lastName = trimToNull(request.getLastName());
|
||||
String phone = normalizePhone(request.getPhone());
|
||||
|
||||
if (userRepository.findByUsername(username).isPresent()) {
|
||||
@@ -101,9 +102,9 @@ public class AuthController {
|
||||
user.setUsername(username);
|
||||
user.setPassword(passwordEncoder.encode(request.getPassword()));
|
||||
user.setEmail(email);
|
||||
user.setFirstName(nameParts.firstName());
|
||||
user.setLastName(nameParts.lastName());
|
||||
user.setFullName(nameParts.fullName());
|
||||
user.setFirstName(firstName);
|
||||
user.setLastName(lastName);
|
||||
user.setFullName(joinFullName(firstName, lastName));
|
||||
user.setPhone(phone);
|
||||
user.setRole(User.Role.CUSTOMER);
|
||||
user.setActive(true);
|
||||
@@ -208,11 +209,16 @@ public class AuthController {
|
||||
user.setEmail(email);
|
||||
}
|
||||
|
||||
if (request.getFullName() != null) {
|
||||
NameParts nameParts = splitFullName(request.getFullName());
|
||||
user.setFirstName(nameParts.firstName());
|
||||
user.setLastName(nameParts.lastName());
|
||||
user.setFullName(nameParts.fullName());
|
||||
String firstName = trimToNull(request.getFirstName());
|
||||
if (firstName != null) {
|
||||
user.setFirstName(firstName);
|
||||
}
|
||||
String lastName = trimToNull(request.getLastName());
|
||||
if (lastName != null) {
|
||||
user.setLastName(lastName);
|
||||
}
|
||||
if (firstName != null || lastName != null) {
|
||||
user.setFullName(joinFullName(user.getFirstName(), user.getLastName()));
|
||||
}
|
||||
|
||||
if (request.getPhone() != null) {
|
||||
@@ -252,6 +258,8 @@ public class AuthController {
|
||||
return new UserInfoResponse(
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getFirstName(),
|
||||
user.getLastName(),
|
||||
user.getEmail(),
|
||||
fullName,
|
||||
user.getPhone(),
|
||||
|
||||
@@ -38,8 +38,8 @@ public class MyPetController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<MyPetResponse>> getMyPets() {
|
||||
return ResponseEntity.ok(petService.getMyPets(currentUserId()));
|
||||
public ResponseEntity<List<MyPetResponse>> getMyPets(@RequestParam(required = false) String status) {
|
||||
return ResponseEntity.ok(petService.getMyPets(currentUserId(), status));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
|
||||
@@ -23,7 +23,6 @@ public class StoreController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<Page<StoreResponse>> getAllStores(
|
||||
@RequestParam(required = false) String q,
|
||||
Pageable pageable) {
|
||||
@@ -31,7 +30,6 @@ public class StoreController {
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<StoreResponse> getStoreById(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(storeService.getStoreById(id));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.petshop.backend.dto.adoption;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public class CustomerAdoptionRequest {
|
||||
|
||||
@NotNull(message = "Pet ID is required")
|
||||
private Long petId;
|
||||
|
||||
private Long employeeId;
|
||||
|
||||
private Long sourceStoreId;
|
||||
|
||||
@NotNull(message = "Appointment date is required")
|
||||
private LocalDate adoptionDate;
|
||||
|
||||
public Long getPetId() {
|
||||
return petId;
|
||||
}
|
||||
|
||||
public void setPetId(Long petId) {
|
||||
this.petId = petId;
|
||||
}
|
||||
|
||||
public Long getEmployeeId() {
|
||||
return employeeId;
|
||||
}
|
||||
|
||||
public void setEmployeeId(Long employeeId) {
|
||||
this.employeeId = employeeId;
|
||||
}
|
||||
|
||||
public Long getSourceStoreId() {
|
||||
return sourceStoreId;
|
||||
}
|
||||
|
||||
public void setSourceStoreId(Long sourceStoreId) {
|
||||
this.sourceStoreId = sourceStoreId;
|
||||
}
|
||||
|
||||
public LocalDate getAdoptionDate() {
|
||||
return adoptionDate;
|
||||
}
|
||||
|
||||
public void setAdoptionDate(LocalDate adoptionDate) {
|
||||
this.adoptionDate = adoptionDate;
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,11 @@ public class ProfileUpdateRequest {
|
||||
@Email(message = "Email must be valid")
|
||||
private String email;
|
||||
|
||||
@Size(max = 100, message = "Full name must not exceed 100 characters")
|
||||
private String fullName;
|
||||
@Size(max = 50, message = "First name must not exceed 50 characters")
|
||||
private String firstName;
|
||||
|
||||
@Size(max = 50, message = "Last name must not exceed 50 characters")
|
||||
private String lastName;
|
||||
|
||||
@Size(max = 20, message = "Phone must not exceed 20 characters")
|
||||
private String phone;
|
||||
@@ -36,12 +39,20 @@ public class ProfileUpdateRequest {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public String getFullName() {
|
||||
return fullName;
|
||||
public String getFirstName() {
|
||||
return firstName;
|
||||
}
|
||||
|
||||
public void setFullName(String fullName) {
|
||||
this.fullName = fullName;
|
||||
public void setFirstName(String firstName) {
|
||||
this.firstName = firstName;
|
||||
}
|
||||
|
||||
public String getLastName() {
|
||||
return lastName;
|
||||
}
|
||||
|
||||
public void setLastName(String lastName) {
|
||||
this.lastName = lastName;
|
||||
}
|
||||
|
||||
public String getPhone() {
|
||||
@@ -67,14 +78,15 @@ public class ProfileUpdateRequest {
|
||||
ProfileUpdateRequest that = (ProfileUpdateRequest) o;
|
||||
return Objects.equals(username, that.username) &&
|
||||
Objects.equals(email, that.email) &&
|
||||
Objects.equals(fullName, that.fullName) &&
|
||||
Objects.equals(firstName, that.firstName) &&
|
||||
Objects.equals(lastName, that.lastName) &&
|
||||
Objects.equals(phone, that.phone) &&
|
||||
Objects.equals(password, that.password);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(username, email, fullName, phone, password);
|
||||
return Objects.hash(username, email, firstName, lastName, phone, password);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -82,7 +94,8 @@ public class ProfileUpdateRequest {
|
||||
return "ProfileUpdateRequest{" +
|
||||
"username='" + username + '\'' +
|
||||
", email='" + email + '\'' +
|
||||
", fullName='" + fullName + '\'' +
|
||||
", firstName='" + firstName + '\'' +
|
||||
", lastName='" + lastName + '\'' +
|
||||
", phone='" + phone + '\'' +
|
||||
", password='" + password + '\'' +
|
||||
'}';
|
||||
|
||||
@@ -18,9 +18,13 @@ public class RegisterRequest {
|
||||
@Email(message = "Email must be valid")
|
||||
private String email;
|
||||
|
||||
@NotBlank(message = "Full name is required")
|
||||
@Size(max = 100, message = "Full name must not exceed 100 characters")
|
||||
private String fullName;
|
||||
@NotBlank(message = "First name is required")
|
||||
@Size(max = 50, message = "First name must not exceed 50 characters")
|
||||
private String firstName;
|
||||
|
||||
@NotBlank(message = "Last name is required")
|
||||
@Size(max = 50, message = "Last name must not exceed 50 characters")
|
||||
private String lastName;
|
||||
|
||||
@NotBlank(message = "Phone is required")
|
||||
@Size(max = 20, message = "Phone must not exceed 20 characters")
|
||||
@@ -50,12 +54,20 @@ public class RegisterRequest {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public String getFullName() {
|
||||
return fullName;
|
||||
public String getFirstName() {
|
||||
return firstName;
|
||||
}
|
||||
|
||||
public void setFullName(String fullName) {
|
||||
this.fullName = fullName;
|
||||
public void setFirstName(String firstName) {
|
||||
this.firstName = firstName;
|
||||
}
|
||||
|
||||
public String getLastName() {
|
||||
return lastName;
|
||||
}
|
||||
|
||||
public void setLastName(String lastName) {
|
||||
this.lastName = lastName;
|
||||
}
|
||||
|
||||
public String getPhone() {
|
||||
@@ -74,13 +86,14 @@ public class RegisterRequest {
|
||||
return Objects.equals(username, that.username) &&
|
||||
Objects.equals(password, that.password) &&
|
||||
Objects.equals(email, that.email) &&
|
||||
Objects.equals(fullName, that.fullName) &&
|
||||
Objects.equals(firstName, that.firstName) &&
|
||||
Objects.equals(lastName, that.lastName) &&
|
||||
Objects.equals(phone, that.phone);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(username, password, email, fullName, phone);
|
||||
return Objects.hash(username, password, email, firstName, lastName, phone);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -89,7 +102,8 @@ public class RegisterRequest {
|
||||
"username='" + username + '\'' +
|
||||
", password='" + password + '\'' +
|
||||
", email='" + email + '\'' +
|
||||
", fullName='" + fullName + '\'' +
|
||||
", firstName='" + firstName + '\'' +
|
||||
", lastName='" + lastName + '\'' +
|
||||
", phone='" + phone + '\'' +
|
||||
'}';
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import java.util.Objects;
|
||||
public class UserInfoResponse {
|
||||
private Long id;
|
||||
private String username;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private String email;
|
||||
private String fullName;
|
||||
private String phone;
|
||||
@@ -17,9 +19,11 @@ public class UserInfoResponse {
|
||||
public UserInfoResponse() {
|
||||
}
|
||||
|
||||
public UserInfoResponse(Long id, String username, String email, String fullName, String phone, String avatarUrl, String role, Long customerId, Long storeId, String storeName) {
|
||||
public UserInfoResponse(Long id, String username, String firstName, String lastName, String email, String fullName, String phone, String avatarUrl, String role, Long customerId, Long storeId, String storeName) {
|
||||
this.id = id;
|
||||
this.username = username;
|
||||
this.firstName = firstName;
|
||||
this.lastName = lastName;
|
||||
this.email = email;
|
||||
this.fullName = fullName;
|
||||
this.phone = phone;
|
||||
@@ -46,6 +50,22 @@ public class UserInfoResponse {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getFirstName() {
|
||||
return firstName;
|
||||
}
|
||||
|
||||
public void setFirstName(String firstName) {
|
||||
this.firstName = firstName;
|
||||
}
|
||||
|
||||
public String getLastName() {
|
||||
return lastName;
|
||||
}
|
||||
|
||||
public void setLastName(String lastName) {
|
||||
this.lastName = lastName;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.petshop.backend.dto.pet;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
@@ -16,6 +18,10 @@ public class MyPetRequest {
|
||||
@Size(max = 50, message = "Breed must not exceed 50 characters")
|
||||
private String breed;
|
||||
|
||||
@Min(value = 0, message = "Age must be 0 or greater")
|
||||
@Max(value = 100, message = "Age must not exceed 100")
|
||||
private Integer petAge;
|
||||
|
||||
public String getPetName() {
|
||||
return petName;
|
||||
}
|
||||
@@ -39,4 +45,12 @@ public class MyPetRequest {
|
||||
public void setBreed(String breed) {
|
||||
this.breed = breed;
|
||||
}
|
||||
|
||||
public Integer getPetAge() {
|
||||
return petAge;
|
||||
}
|
||||
|
||||
public void setPetAge(Integer petAge) {
|
||||
this.petAge = petAge;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,17 +6,21 @@ public class MyPetResponse {
|
||||
private String petName;
|
||||
private String species;
|
||||
private String breed;
|
||||
private Integer petAge;
|
||||
private String imageUrl;
|
||||
private String petStatus;
|
||||
|
||||
public MyPetResponse() {
|
||||
}
|
||||
|
||||
public MyPetResponse(Long customerPetId, String petName, String species, String breed, String imageUrl) {
|
||||
public MyPetResponse(Long customerPetId, String petName, String species, String breed, Integer petAge, String imageUrl, String petStatus) {
|
||||
this.customerPetId = customerPetId;
|
||||
this.petName = petName;
|
||||
this.species = species;
|
||||
this.breed = breed;
|
||||
this.petAge = petAge;
|
||||
this.imageUrl = imageUrl;
|
||||
this.petStatus = petStatus;
|
||||
}
|
||||
|
||||
public Long getCustomerPetId() {
|
||||
@@ -51,6 +55,14 @@ public class MyPetResponse {
|
||||
this.breed = breed;
|
||||
}
|
||||
|
||||
public Integer getPetAge() {
|
||||
return petAge;
|
||||
}
|
||||
|
||||
public void setPetAge(Integer petAge) {
|
||||
this.petAge = petAge;
|
||||
}
|
||||
|
||||
public String getImageUrl() {
|
||||
return imageUrl;
|
||||
}
|
||||
@@ -58,4 +70,12 @@ public class MyPetResponse {
|
||||
public void setImageUrl(String imageUrl) {
|
||||
this.imageUrl = imageUrl;
|
||||
}
|
||||
|
||||
public String getPetStatus() {
|
||||
return petStatus;
|
||||
}
|
||||
|
||||
public void setPetStatus(String petStatus) {
|
||||
this.petStatus = petStatus;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,5 +51,7 @@ public interface AppointmentRepository extends JpaRepository<Appointment, Long>
|
||||
@Query("SELECT a FROM Appointment a WHERE (a.appointmentDate < :currentDate OR (a.appointmentDate = :currentDate AND a.appointmentTime < :currentTime)) AND LOWER(a.appointmentStatus) = 'booked'")
|
||||
List<Appointment> findPastBookedAppointments(@Param("currentDate") LocalDate currentDate, @Param("currentTime") LocalTime currentTime);
|
||||
|
||||
List<Appointment> findByPet_Id(Long petId);
|
||||
|
||||
List<Appointment> findByAppointmentDateAndAppointmentStatusIgnoreCase(LocalDate date, String status);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ public class SecurityConfig {
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/products/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/services/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/categories/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/stores/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/dropdowns/pet-species").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/dropdowns/pet-breeds").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/dropdowns/stores").permitAll()
|
||||
@@ -101,7 +102,7 @@ public class SecurityConfig {
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
config.setAllowedOriginPatterns(List.of("http://localhost:*", "http://127.0.0.1:*"));
|
||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||
config.setAllowedHeaders(List.of("*"));
|
||||
config.setAllowCredentials(true);
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
|
||||
@@ -158,6 +158,57 @@ public class AdoptionService {
|
||||
return mapToResponse(adoption);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdoptionResponse requestAdoption(Long customerId, Long petId, Long employeeId, Long sourceStoreId, LocalDate adoptionDate) {
|
||||
Pet pet = petRepository.findById(petId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Pet not found"));
|
||||
|
||||
// Verify the pet is actually located at the claimed store
|
||||
if (pet.getStore() == null || !pet.getStore().getStoreId().equals(sourceStoreId)) {
|
||||
throw new IllegalArgumentException("The specified pet is not located at the selected store.");
|
||||
}
|
||||
|
||||
// Verify the pet is available for adoption
|
||||
validatePetAvailability(pet, null, null);
|
||||
|
||||
User customer = userRepository.findById(customerId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Customer not found"));
|
||||
User employee = resolveAdoptionEmployee(employeeId);
|
||||
StoreLocation sourceStore = storeRepository.findById(sourceStoreId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Store not found"));
|
||||
|
||||
Adoption adoption = new Adoption();
|
||||
adoption.setPet(pet);
|
||||
adoption.setCustomer(customer);
|
||||
adoption.setEmployee(employee);
|
||||
adoption.setSourceStore(sourceStore);
|
||||
adoption.setAdoptionDate(adoptionDate);
|
||||
adoption.setAdoptionStatus(ADOPTION_STATUS_PENDING);
|
||||
|
||||
adoption = adoptionRepository.save(adoption);
|
||||
syncPetStatus(pet, ADOPTION_STATUS_PENDING, adoption.getAdoptionId(), customer);
|
||||
return mapToResponse(adoption);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AdoptionResponse cancelAdoption(Long adoptionId, Long requestingCustomerId) {
|
||||
Adoption adoption = adoptionRepository.findById(adoptionId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Adoption not found with id: " + adoptionId));
|
||||
|
||||
if (requestingCustomerId != null && !adoption.getCustomer().getId().equals(requestingCustomerId)) {
|
||||
throw new ResourceNotFoundException("Adoption not found");
|
||||
}
|
||||
|
||||
if (!ADOPTION_STATUS_PENDING.equalsIgnoreCase(adoption.getAdoptionStatus())) {
|
||||
throw new IllegalArgumentException("Only pending adoptions can be cancelled");
|
||||
}
|
||||
|
||||
adoption.setAdoptionStatus(ADOPTION_STATUS_CANCELLED);
|
||||
adoption = adoptionRepository.save(adoption);
|
||||
syncPetStatus(adoption.getPet(), ADOPTION_STATUS_CANCELLED, adoption.getAdoptionId(), adoption.getCustomer());
|
||||
return mapToResponse(adoption);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteAdoption(Long id) {
|
||||
Adoption adoption = adoptionRepository.findById(id)
|
||||
|
||||
@@ -109,6 +109,22 @@ public class AppointmentService {
|
||||
Pet pet = request.getPetId() != null ? fetchPet(request.getPetId()) : null;
|
||||
User employee = resolveAppointmentEmployee(request.getEmployeeId(), store.getStoreId());
|
||||
|
||||
// Customers must supply a pet that is Adopted and owned by them
|
||||
if (User.Role.CUSTOMER.equals(authenticatedUser.getRole())) {
|
||||
if (pet == null) {
|
||||
throw new IllegalArgumentException("A pet must be selected for your appointment");
|
||||
}
|
||||
if (pet.getOwner() == null || !pet.getOwner().getId().equals(authenticatedUser.getId())) {
|
||||
throw new IllegalArgumentException("The selected pet does not belong to your account");
|
||||
}
|
||||
String petStatus = pet.getPetStatus();
|
||||
if (!"Owned".equalsIgnoreCase(petStatus) && !"Adopted".equalsIgnoreCase(petStatus)) {
|
||||
throw new IllegalArgumentException("Only your own pets can be booked for appointments");
|
||||
}
|
||||
}
|
||||
|
||||
validateSpeciesServiceCompatibility(pet, service);
|
||||
|
||||
validateStoreAccess(store.getStoreId(), authenticatedUser);
|
||||
validatePetServiceCompatibility(pet, service);
|
||||
validateAvailability(employee, service, request.getAppointmentDate(), request.getAppointmentTime(), null);
|
||||
@@ -167,6 +183,23 @@ public class AppointmentService {
|
||||
return mapToResponse(appointment);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AppointmentResponse cancelAppointment(Long appointmentId, Long requestingCustomerId) {
|
||||
Appointment appointment = appointmentRepository.findById(appointmentId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Appointment not found with id: " + appointmentId));
|
||||
|
||||
if (requestingCustomerId != null && !appointment.getCustomer().getId().equals(requestingCustomerId)) {
|
||||
throw new ResourceNotFoundException("Appointment not found");
|
||||
}
|
||||
|
||||
if (!"Booked".equalsIgnoreCase(appointment.getAppointmentStatus())) {
|
||||
throw new IllegalArgumentException("Only booked appointments can be cancelled");
|
||||
}
|
||||
|
||||
appointment.setAppointmentStatus("Cancelled");
|
||||
return mapToResponse(appointmentRepository.save(appointment));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteAppointment(Long id) {
|
||||
if (!appointmentRepository.existsById(id)) {
|
||||
@@ -350,6 +383,32 @@ public class AppointmentService {
|
||||
return true;
|
||||
}
|
||||
|
||||
private void validateSpeciesServiceCompatibility(Pet pet, com.petshop.backend.entity.Service service) {
|
||||
if (pet == null || service == null) return;
|
||||
String species = pet.getPetSpecies();
|
||||
if (species == null) return;
|
||||
String serviceName = service.getServiceName().toLowerCase();
|
||||
|
||||
switch (species.toLowerCase()) {
|
||||
case "bird":
|
||||
if (!serviceName.contains("wing clipping") && !serviceName.contains("beak and nail")) {
|
||||
throw new IllegalArgumentException(
|
||||
"Service '" + service.getServiceName() + "' is not available for birds. " +
|
||||
"Allowed services: Wing Clipping, Beak and Nail Care.");
|
||||
}
|
||||
break;
|
||||
case "fish":
|
||||
if (!serviceName.contains("aquarium health")) {
|
||||
throw new IllegalArgumentException(
|
||||
"Service '" + service.getServiceName() + "' is not available for fish. " +
|
||||
"Allowed service: Aquarium Health Check.");
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void validateStoreAccess(Long requestedStoreId, User user) {
|
||||
if (user.getRole() != User.Role.STAFF) {
|
||||
return;
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.petshop.backend.entity.User;
|
||||
import com.petshop.backend.exception.ResourceNotFoundException;
|
||||
import com.petshop.backend.security.AppPrincipal;
|
||||
import com.petshop.backend.repository.AdoptionRepository;
|
||||
import com.petshop.backend.repository.AppointmentRepository;
|
||||
import com.petshop.backend.repository.PetRepository;
|
||||
import com.petshop.backend.repository.StoreRepository;
|
||||
import com.petshop.backend.repository.UserRepository;
|
||||
@@ -35,13 +36,15 @@ public class PetService {
|
||||
|
||||
private final PetRepository petRepository;
|
||||
private final AdoptionRepository adoptionRepository;
|
||||
private final AppointmentRepository appointmentRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final StoreRepository storeRepository;
|
||||
private final CatalogImageStorageService catalogImageStorageService;
|
||||
|
||||
public PetService(PetRepository petRepository, AdoptionRepository adoptionRepository, UserRepository userRepository, StoreRepository storeRepository, CatalogImageStorageService catalogImageStorageService) {
|
||||
public PetService(PetRepository petRepository, AdoptionRepository adoptionRepository, AppointmentRepository appointmentRepository, UserRepository userRepository, StoreRepository storeRepository, CatalogImageStorageService catalogImageStorageService) {
|
||||
this.petRepository = petRepository;
|
||||
this.adoptionRepository = adoptionRepository;
|
||||
this.appointmentRepository = appointmentRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.storeRepository = storeRepository;
|
||||
this.catalogImageStorageService = catalogImageStorageService;
|
||||
@@ -87,8 +90,9 @@ public class PetService {
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<MyPetResponse> getMyPets(Long ownerUserId) {
|
||||
public List<MyPetResponse> getMyPets(Long ownerUserId, String status) {
|
||||
return petRepository.findAllByOwner_IdOrderByPetNameAsc(ownerUserId).stream()
|
||||
.filter(p -> status == null || status.isBlank() || status.equalsIgnoreCase(p.getPetStatus()))
|
||||
.map(this::mapToMyPetResponse)
|
||||
.toList();
|
||||
}
|
||||
@@ -117,6 +121,18 @@ public class PetService {
|
||||
@Transactional
|
||||
public void deleteMyPet(Long ownerUserId, Long petId) {
|
||||
Pet pet = findOwnedPet(ownerUserId, petId);
|
||||
List<com.petshop.backend.entity.Appointment> linkedAppointments = appointmentRepository.findByPet_Id(petId);
|
||||
boolean hasBooked = linkedAppointments.stream()
|
||||
.anyMatch(a -> "Booked".equalsIgnoreCase(a.getAppointmentStatus()));
|
||||
if (hasBooked) {
|
||||
throw new IllegalArgumentException(
|
||||
"Your pet has a booked appointment. Please cancel the appointment before removing your pet from our database.");
|
||||
}
|
||||
// Nullify the pet reference on non-booked appointments to avoid FK constraint violations
|
||||
for (com.petshop.backend.entity.Appointment appt : linkedAppointments) {
|
||||
appt.setPet(null);
|
||||
}
|
||||
appointmentRepository.saveAll(linkedAppointments);
|
||||
deleteStoredImageIfPresent(pet.getImageUrl());
|
||||
petRepository.delete(pet);
|
||||
}
|
||||
@@ -341,7 +357,9 @@ public class PetService {
|
||||
pet.getPetName(),
|
||||
pet.getPetSpecies(),
|
||||
pet.getPetBreed(),
|
||||
pet.getImageUrl() != null && !pet.getImageUrl().isBlank() ? "/api/v1/pets/" + pet.getPetId() + "/image" : null
|
||||
pet.getPetAge(),
|
||||
pet.getImageUrl() != null && !pet.getImageUrl().isBlank() ? "/api/v1/pets/" + pet.getPetId() + "/image" : null,
|
||||
pet.getPetStatus()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -349,7 +367,7 @@ public class PetService {
|
||||
pet.setPetName(request.getPetName().trim());
|
||||
pet.setPetSpecies(request.getSpecies().trim());
|
||||
pet.setPetBreed(normalizeOptional(request.getBreed()));
|
||||
pet.setPetAge(null);
|
||||
pet.setPetAge(request.getPetAge());
|
||||
pet.setPetPrice(null);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ public class MessageResponse {
|
||||
private String content;
|
||||
private LocalDateTime timestamp;
|
||||
private Boolean isRead;
|
||||
private String attachmentName;
|
||||
private String attachmentUrl;
|
||||
|
||||
public MessageResponse() {
|
||||
}
|
||||
@@ -87,4 +89,20 @@ public class MessageResponse {
|
||||
public void setIsRead(Boolean isRead) {
|
||||
this.isRead = isRead;
|
||||
}
|
||||
|
||||
public String getAttachmentName() {
|
||||
return attachmentName;
|
||||
}
|
||||
|
||||
public void setAttachmentName(String attachmentName) {
|
||||
this.attachmentName = attachmentName;
|
||||
}
|
||||
|
||||
public String getAttachmentUrl() {
|
||||
return attachmentUrl;
|
||||
}
|
||||
|
||||
public void setAttachmentUrl(String attachmentUrl) {
|
||||
this.attachmentUrl = attachmentUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import org.example.petshopdesktop.api.dto.auth.AvatarUploadResponse;
|
||||
import org.example.petshopdesktop.api.dto.auth.UserInfoResponse;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class AuthApi {
|
||||
private static final AuthApi INSTANCE = new AuthApi();
|
||||
@@ -33,4 +35,10 @@ public class AuthApi {
|
||||
public void deleteAvatar() throws Exception {
|
||||
apiClient.delete("/api/v1/auth/me/avatar");
|
||||
}
|
||||
|
||||
public void forgotPassword(String usernameOrEmail) throws Exception {
|
||||
Map<String, String> body = new HashMap<>();
|
||||
body.put("usernameOrEmail", usernameOrEmail);
|
||||
apiClient.post("/api/v1/auth/forgot-password", body, Object.class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ import javafx.scene.control.DatePicker;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Tab;
|
||||
import javafx.scene.control.TabPane;
|
||||
import javafx.scene.control.ToggleButton;
|
||||
import javafx.scene.control.ToggleGroup;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.example.petshopdesktop.api.dto.analytics.DailySales;
|
||||
import org.example.petshopdesktop.api.dto.analytics.DashboardResponse;
|
||||
@@ -98,6 +101,20 @@ public class AnalyticsController {
|
||||
@FXML
|
||||
private ComboBox<String> cbTopN;
|
||||
|
||||
@FXML
|
||||
private ComboBox<String> cbStoreFilter;
|
||||
|
||||
@FXML
|
||||
private HBox hbViewToggle;
|
||||
|
||||
@FXML
|
||||
private ToggleButton tbnMyAnalytics;
|
||||
|
||||
@FXML
|
||||
private ToggleButton tbnStoreAnalytics;
|
||||
|
||||
private String viewMode = "store";
|
||||
|
||||
private List<SaleResponse> cachedSales = new ArrayList<>();
|
||||
private FilterState currentFilter = new FilterState();
|
||||
|
||||
@@ -124,8 +141,29 @@ public class AnalyticsController {
|
||||
cbPaymentFilter.setItems(FXCollections.observableArrayList("All"));
|
||||
cbPaymentFilter.getSelectionModel().selectFirst();
|
||||
|
||||
cbStoreFilter.setItems(FXCollections.observableArrayList("All Stores"));
|
||||
cbStoreFilter.getSelectionModel().selectFirst();
|
||||
|
||||
lblFilterSummary.setText("All time");
|
||||
|
||||
ToggleGroup tgViewMode = new ToggleGroup();
|
||||
tbnMyAnalytics.setToggleGroup(tgViewMode);
|
||||
tbnStoreAnalytics.setToggleGroup(tgViewMode);
|
||||
tbnStoreAnalytics.setSelected(true);
|
||||
tgViewMode.selectedToggleProperty().addListener((obs, oldVal, newVal) -> {
|
||||
if (newVal == null) {
|
||||
(viewMode.equals("mine") ? tbnMyAnalytics : tbnStoreAnalytics).setSelected(true);
|
||||
return;
|
||||
}
|
||||
viewMode = (newVal == tbnMyAnalytics) ? "mine" : "store";
|
||||
updateViewModeStyles();
|
||||
updateStoreFilterVisibility();
|
||||
applyCurrentFilter();
|
||||
});
|
||||
|
||||
hbViewToggle.setVisible(true);
|
||||
hbViewToggle.setManaged(true);
|
||||
|
||||
loadAnalyticsData();
|
||||
}
|
||||
|
||||
@@ -182,6 +220,8 @@ public class AnalyticsController {
|
||||
Platform.runLater(() -> {
|
||||
cachedSales = sales;
|
||||
derivePaymentMethods();
|
||||
deriveStores();
|
||||
updateStoreFilterVisibility();
|
||||
applyCurrentFilter();
|
||||
btnRefresh.setDisable(false);
|
||||
});
|
||||
@@ -196,9 +236,36 @@ public class AnalyticsController {
|
||||
}).start();
|
||||
}
|
||||
|
||||
private void updateViewModeStyles() {
|
||||
String selectedStyle = "-fx-background-color: #4ECDC4; -fx-text-fill: white; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;";
|
||||
String unselectedStyle = "-fx-background-color: #e2e8f0; -fx-text-fill: #475569; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;";
|
||||
if (viewMode.equals("mine")) {
|
||||
tbnMyAnalytics.setStyle(selectedStyle + " -fx-background-radius: 6 0 0 6;");
|
||||
tbnStoreAnalytics.setStyle(unselectedStyle + " -fx-background-radius: 0 6 6 0;");
|
||||
} else {
|
||||
tbnMyAnalytics.setStyle(unselectedStyle + " -fx-background-radius: 6 0 0 6;");
|
||||
tbnStoreAnalytics.setStyle(selectedStyle + " -fx-background-radius: 0 6 6 0;");
|
||||
}
|
||||
}
|
||||
|
||||
private void applyCurrentFilter() {
|
||||
try {
|
||||
List<SaleResponse> filtered = filterSales(cachedSales, currentFilter);
|
||||
List<SaleResponse> salesForMode;
|
||||
if (viewMode.equals("mine")) {
|
||||
String myName = UserSession.getInstance().getEmployeeName();
|
||||
salesForMode = cachedSales.stream()
|
||||
.filter(s -> myName != null && myName.equalsIgnoreCase(s.getEmployeeName() != null ? s.getEmployeeName() : ""))
|
||||
.collect(Collectors.toList());
|
||||
} else {
|
||||
salesForMode = cachedSales;
|
||||
}
|
||||
String storeFilter = currentFilter.storeFilter;
|
||||
if (!storeFilter.equals("All Stores") && !storeFilter.isBlank()) {
|
||||
salesForMode = salesForMode.stream()
|
||||
.filter(s -> storeFilter.equalsIgnoreCase(s.getStoreName() != null ? s.getStoreName() : ""))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
List<SaleResponse> filtered = filterSales(salesForMode, currentFilter);
|
||||
String start = currentFilter.startDate.isEmpty() ? LocalDate.now().minusDays(6).toString() : currentFilter.startDate;
|
||||
String end = currentFilter.endDate.isEmpty() ? LocalDate.now().toString() : currentFilter.endDate;
|
||||
|
||||
@@ -256,6 +323,31 @@ public class AnalyticsController {
|
||||
}
|
||||
}
|
||||
|
||||
private void deriveStores() {
|
||||
Set<String> stores = new TreeSet<>();
|
||||
for (SaleResponse s : cachedSales) {
|
||||
if (s.getStoreName() != null && !s.getStoreName().isBlank()) {
|
||||
stores.add(s.getStoreName());
|
||||
}
|
||||
}
|
||||
List<String> items = new ArrayList<>();
|
||||
items.add("All Stores");
|
||||
items.addAll(stores);
|
||||
String current = cbStoreFilter.getValue();
|
||||
cbStoreFilter.setItems(FXCollections.observableArrayList(items));
|
||||
if (current != null && items.contains(current)) {
|
||||
cbStoreFilter.setValue(current);
|
||||
} else {
|
||||
cbStoreFilter.getSelectionModel().selectFirst();
|
||||
}
|
||||
}
|
||||
|
||||
private void updateStoreFilterVisibility() {
|
||||
boolean show = UserSession.getInstance().isAdmin() && viewMode.equals("store");
|
||||
cbStoreFilter.setVisible(show);
|
||||
cbStoreFilter.setManaged(show);
|
||||
}
|
||||
|
||||
private void updateFilterSummary() {
|
||||
String start = currentFilter.startDate;
|
||||
String end = currentFilter.endDate;
|
||||
@@ -392,11 +484,12 @@ public class AnalyticsController {
|
||||
}
|
||||
|
||||
private void applyRoleVisibility(boolean isAdmin) {
|
||||
chartEmployeePerformance.setVisible(isAdmin);
|
||||
chartEmployeePerformance.setManaged(isAdmin);
|
||||
boolean showEmpChart = isAdmin && viewMode.equals("store");
|
||||
chartEmployeePerformance.setVisible(showEmpChart);
|
||||
chartEmployeePerformance.setManaged(showEmpChart);
|
||||
if (chartEmployeePerformance.getParent() != null) {
|
||||
chartEmployeePerformance.getParent().setVisible(isAdmin);
|
||||
chartEmployeePerformance.getParent().setManaged(isAdmin);
|
||||
chartEmployeePerformance.getParent().setVisible(showEmpChart);
|
||||
chartEmployeePerformance.getParent().setManaged(showEmpChart);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,6 +705,7 @@ public class AnalyticsController {
|
||||
dpEndDate.setValue(null);
|
||||
cbPaymentFilter.getSelectionModel().selectFirst();
|
||||
cbTopN.getSelectionModel().selectFirst();
|
||||
cbStoreFilter.getSelectionModel().selectFirst();
|
||||
currentFilter = new FilterState();
|
||||
applyCurrentFilter();
|
||||
}
|
||||
@@ -630,6 +724,8 @@ public class AnalyticsController {
|
||||
currentFilter.paymentMethod = pm != null ? pm : "All";
|
||||
int topNPos = cbTopN.getSelectionModel().getSelectedIndex();
|
||||
currentFilter.topN = (topNPos >= 0 && topNPos < TOP_N_VALUES.length) ? TOP_N_VALUES[topNPos] : 5;
|
||||
String sf = cbStoreFilter.getValue();
|
||||
currentFilter.storeFilter = (sf != null && !sf.isBlank()) ? sf : "All Stores";
|
||||
applyCurrentFilter();
|
||||
}
|
||||
|
||||
@@ -638,5 +734,6 @@ public class AnalyticsController {
|
||||
String endDate = "";
|
||||
String paymentMethod = "All";
|
||||
int topN = 5;
|
||||
String storeFilter = "All Stores";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,17 @@ import javafx.collections.transformation.FilteredList;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.stage.Modality;
|
||||
import javafx.stage.Stage;
|
||||
import org.example.petshopdesktop.api.dto.user.UserResponse;
|
||||
import org.example.petshopdesktop.api.endpoints.CustomerApi;
|
||||
import org.example.petshopdesktop.util.ActivityLogger;
|
||||
import org.example.petshopdesktop.util.DesktopImageSupport;
|
||||
import org.example.petshopdesktop.util.TableViewSupport;
|
||||
|
||||
import java.util.Comparator;
|
||||
@@ -25,6 +29,9 @@ public class CustomerAccountsController {
|
||||
@FXML
|
||||
private TableView<UserResponse> tvCustomers;
|
||||
|
||||
@FXML
|
||||
private TableColumn<UserResponse, String> colCustomerAvatar;
|
||||
|
||||
@FXML
|
||||
private TableColumn<UserResponse, String> colCustomerUsername;
|
||||
|
||||
@@ -69,6 +76,13 @@ public class CustomerAccountsController {
|
||||
|
||||
@FXML
|
||||
public void initialize() {
|
||||
colCustomerAvatar.setCellValueFactory(data -> {
|
||||
Long id = data.getValue().getId();
|
||||
if (id == null) return new javafx.beans.property.SimpleStringProperty("");
|
||||
return new javafx.beans.property.SimpleStringProperty("/api/v1/users/" + id + "/avatar/file");
|
||||
});
|
||||
configureAvatarColumn(colCustomerAvatar);
|
||||
|
||||
colCustomerUsername.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(data.getValue().getUsername()));
|
||||
colCustomerName.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(data.getValue().getFullName()));
|
||||
colCustomerEmail.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(data.getValue().getEmail()));
|
||||
@@ -94,6 +108,27 @@ public class CustomerAccountsController {
|
||||
refresh();
|
||||
}
|
||||
|
||||
private void configureAvatarColumn(TableColumn<UserResponse, String> column) {
|
||||
column.setCellFactory(col -> new TableCell<>() {
|
||||
private final ImageView imageView = new ImageView();
|
||||
private final StackPane container = new StackPane(imageView);
|
||||
{
|
||||
container.setAlignment(Pos.CENTER);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateItem(String item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
if (empty || item == null || item.isBlank()) {
|
||||
setGraphic(null);
|
||||
return;
|
||||
}
|
||||
DesktopImageSupport.loadImageInto(imageView, item, 48, 48);
|
||||
setGraphic(container);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@FXML
|
||||
void btnRefreshClicked(ActionEvent event) {
|
||||
txtSearchCustomer.clear();
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package org.example.petshopdesktop.controllers;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.PasswordField;
|
||||
import javafx.scene.control.TextInputDialog;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.stage.Stage;
|
||||
@@ -14,6 +17,7 @@ import org.example.petshopdesktop.api.ApiClient;
|
||||
import org.example.petshopdesktop.api.dto.auth.LoginRequest;
|
||||
import org.example.petshopdesktop.api.dto.auth.LoginResponse;
|
||||
import org.example.petshopdesktop.api.dto.auth.UserInfoResponse;
|
||||
import org.example.petshopdesktop.api.endpoints.AuthApi;
|
||||
import org.example.petshopdesktop.auth.Role;
|
||||
import org.example.petshopdesktop.auth.UserSession;
|
||||
import org.example.petshopdesktop.ui.SvgWebViewFactory;
|
||||
@@ -105,6 +109,42 @@ public class LoginController {
|
||||
}
|
||||
}
|
||||
|
||||
@FXML
|
||||
void lnkForgotPasswordClicked(ActionEvent event) {
|
||||
TextInputDialog dialog = new TextInputDialog();
|
||||
dialog.setTitle("Forgot Password");
|
||||
dialog.setHeaderText("Reset your password");
|
||||
dialog.setContentText("Enter your username or email:");
|
||||
|
||||
dialog.showAndWait().ifPresent(input -> {
|
||||
if (input.trim().isEmpty()) return;
|
||||
new Thread(() -> {
|
||||
try {
|
||||
AuthApi.getInstance().forgotPassword(input.trim());
|
||||
Platform.runLater(() -> {
|
||||
Alert alert = new Alert(Alert.AlertType.INFORMATION);
|
||||
alert.setTitle("Reset Link Sent");
|
||||
alert.setHeaderText(null);
|
||||
alert.setContentText("If this account exists, a password reset link has been sent to the associated email.");
|
||||
alert.showAndWait();
|
||||
});
|
||||
} catch (Exception e) {
|
||||
ActivityLogger.getInstance().logException(
|
||||
"LoginController.lnkForgotPasswordClicked",
|
||||
e,
|
||||
"Forgot password request for: " + input.trim());
|
||||
Platform.runLater(() -> {
|
||||
Alert alert = new Alert(Alert.AlertType.ERROR);
|
||||
alert.setTitle("Error");
|
||||
alert.setHeaderText(null);
|
||||
alert.setContentText("Could not send reset link. Please try again.");
|
||||
alert.showAndWait();
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
});
|
||||
}
|
||||
|
||||
private void openMainLayout() {
|
||||
try {
|
||||
FXMLLoader loader = new FXMLLoader(
|
||||
|
||||
@@ -199,6 +199,12 @@ public class SaleController {
|
||||
@FXML
|
||||
private Label lblLoyaltyDiscount;
|
||||
|
||||
@FXML
|
||||
private HBox hbPointsToEarn;
|
||||
|
||||
@FXML
|
||||
private Label lblPointsToEarn;
|
||||
|
||||
private final ObservableList<SaleCartItem> cartItems = FXCollections.observableArrayList();
|
||||
private final ObservableList<SaleLineItem> saleItems = FXCollections.observableArrayList();
|
||||
private FilteredList<SaleLineItem> filteredSales;
|
||||
@@ -389,12 +395,15 @@ public class SaleController {
|
||||
|
||||
task.setOnSucceeded(event -> {
|
||||
selectedCustomerData = task.getValue();
|
||||
if (selectedCustomerData != null && selectedCustomerData.getLoyaltyPoints() != null && selectedCustomerData.getLoyaltyPoints() >= 20) {
|
||||
lblLoyaltyPoints.setText(selectedCustomerData.getLoyaltyPoints() + " pts available");
|
||||
if (selectedCustomerData != null) {
|
||||
int pts = selectedCustomerData.getLoyaltyPoints() != null ? selectedCustomerData.getLoyaltyPoints() : 0;
|
||||
lblLoyaltyPoints.setText(pts + " pts available");
|
||||
lblLoyaltyPoints.setVisible(true);
|
||||
lblLoyaltyPoints.setManaged(true);
|
||||
chkUseLoyaltyPoints.setVisible(true);
|
||||
chkUseLoyaltyPoints.setManaged(true);
|
||||
boolean canRedeem = pts >= 20;
|
||||
chkUseLoyaltyPoints.setVisible(canRedeem);
|
||||
chkUseLoyaltyPoints.setManaged(canRedeem);
|
||||
if (!canRedeem) chkUseLoyaltyPoints.setSelected(false);
|
||||
} else {
|
||||
lblLoyaltyPoints.setVisible(false);
|
||||
lblLoyaltyPoints.setManaged(false);
|
||||
@@ -701,6 +710,8 @@ public class SaleController {
|
||||
hbCouponDiscount.setManaged(false);
|
||||
hbLoyaltyDiscount.setVisible(false);
|
||||
hbLoyaltyDiscount.setManaged(false);
|
||||
hbPointsToEarn.setVisible(false);
|
||||
hbPointsToEarn.setManaged(false);
|
||||
cbCustomer.setValue(null);
|
||||
selectedCustomerData = null;
|
||||
lblLoyaltyPoints.setVisible(false);
|
||||
@@ -875,6 +886,16 @@ public class SaleController {
|
||||
}
|
||||
|
||||
lblCartTotal.setText(currency.format(Math.max(0, total.doubleValue())));
|
||||
|
||||
if (selectedCustomerData != null) {
|
||||
int pointsToEarn = (int) Math.max(0, total.doubleValue());
|
||||
lblPointsToEarn.setText("+" + pointsToEarn + " pts");
|
||||
hbPointsToEarn.setVisible(true);
|
||||
hbPointsToEarn.setManaged(true);
|
||||
} else {
|
||||
hbPointsToEarn.setVisible(false);
|
||||
hbPointsToEarn.setManaged(false);
|
||||
}
|
||||
}
|
||||
|
||||
private BigDecimal calculateCouponDiscount(BigDecimal subtotal) {
|
||||
|
||||
@@ -7,8 +7,11 @@ import javafx.collections.transformation.FilteredList;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.Modality;
|
||||
import javafx.stage.Stage;
|
||||
@@ -16,6 +19,7 @@ import org.example.petshopdesktop.api.dto.employee.EmployeeResponse;
|
||||
import org.example.petshopdesktop.api.endpoints.EmployeeApi;
|
||||
import org.example.petshopdesktop.auth.UserSession;
|
||||
import org.example.petshopdesktop.util.ActivityLogger;
|
||||
import org.example.petshopdesktop.util.DesktopImageSupport;
|
||||
import org.example.petshopdesktop.util.TableViewSupport;
|
||||
|
||||
import java.util.Comparator;
|
||||
@@ -26,6 +30,7 @@ public class StaffAccountsController {
|
||||
|
||||
@FXML private VBox staffSection;
|
||||
@FXML private TableView<EmployeeResponse> tvStaff;
|
||||
@FXML private TableColumn<EmployeeResponse, String> colStaffAvatar;
|
||||
@FXML private TableColumn<EmployeeResponse, String> colUsername;
|
||||
@FXML private TableColumn<EmployeeResponse, String> colName;
|
||||
@FXML private TableColumn<EmployeeResponse, String> colEmail;
|
||||
@@ -47,6 +52,13 @@ public class StaffAccountsController {
|
||||
|
||||
@FXML
|
||||
public void initialize() {
|
||||
colStaffAvatar.setCellValueFactory(data -> {
|
||||
Long id = data.getValue().getId();
|
||||
if (id == null) return new javafx.beans.property.SimpleStringProperty("");
|
||||
return new javafx.beans.property.SimpleStringProperty("/api/v1/users/" + id + "/avatar/file");
|
||||
});
|
||||
configureAvatarColumn(colStaffAvatar);
|
||||
|
||||
colUsername.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(data.getValue().getUsername()));
|
||||
colName.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(data.getValue().getFullName()));
|
||||
colEmail.setCellValueFactory(data -> new javafx.beans.property.SimpleStringProperty(data.getValue().getEmail()));
|
||||
@@ -81,6 +93,27 @@ public class StaffAccountsController {
|
||||
refresh();
|
||||
}
|
||||
|
||||
private void configureAvatarColumn(TableColumn<EmployeeResponse, String> column) {
|
||||
column.setCellFactory(col -> new TableCell<>() {
|
||||
private final ImageView imageView = new ImageView();
|
||||
private final StackPane container = new StackPane(imageView);
|
||||
{
|
||||
container.setAlignment(Pos.CENTER);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateItem(String item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
if (empty || item == null || item.isBlank()) {
|
||||
setGraphic(null);
|
||||
return;
|
||||
}
|
||||
DesktopImageSupport.loadImageInto(imageView, item, 48, 48);
|
||||
setGraphic(container);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@FXML
|
||||
void btnRefreshClicked(ActionEvent event) {
|
||||
txtSearch.clear();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
<?import javafx.geometry.Insets?>
|
||||
<?import javafx.scene.control.Button?>
|
||||
<?import javafx.scene.control.Hyperlink?>
|
||||
<?import javafx.scene.control.Label?>
|
||||
<?import javafx.scene.control.PasswordField?>
|
||||
<?import javafx.scene.control.TextField?>
|
||||
@@ -83,6 +84,15 @@
|
||||
<Insets top="8.0" />
|
||||
</VBox.margin>
|
||||
</Button>
|
||||
|
||||
<Hyperlink maxWidth="Infinity" mnemonicParsing="false" onAction="#lnkForgotPasswordClicked"
|
||||
text="Forgot Password?"
|
||||
style="-fx-text-fill: #91a4b7; -fx-border-color: transparent; -fx-cursor: hand;"
|
||||
alignment="CENTER_RIGHT">
|
||||
<font>
|
||||
<Font size="12.0" />
|
||||
</font>
|
||||
</Hyperlink>
|
||||
</children>
|
||||
</VBox>
|
||||
</children>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<?import javafx.scene.chart.PieChart?>
|
||||
<?import javafx.scene.control.Button?>
|
||||
<?import javafx.scene.control.ComboBox?>
|
||||
<?import javafx.scene.control.ToggleButton?>
|
||||
<?import javafx.scene.control.DatePicker?>
|
||||
<?import javafx.scene.control.Label?>
|
||||
<?import javafx.scene.control.Tab?>
|
||||
@@ -30,6 +31,16 @@
|
||||
</font>
|
||||
</Label>
|
||||
<Region HBox.hgrow="ALWAYS" />
|
||||
<HBox fx:id="hbViewToggle" spacing="0.0" alignment="CENTER" visible="false" managed="false">
|
||||
<ToggleButton fx:id="tbnMyAnalytics" text="My Analytics" style="-fx-background-color: #e2e8f0; -fx-text-fill: #475569; -fx-background-radius: 6 0 0 6; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;">
|
||||
<font><Font size="12.0" /></font>
|
||||
<padding><Insets bottom="6.0" left="14.0" right="14.0" top="6.0" /></padding>
|
||||
</ToggleButton>
|
||||
<ToggleButton fx:id="tbnStoreAnalytics" text="Store Analytics" style="-fx-background-color: #4ECDC4; -fx-text-fill: white; -fx-background-radius: 0 6 6 0; -fx-cursor: hand; -fx-focus-color: transparent; -fx-faint-focus-color: transparent;">
|
||||
<font><Font size="12.0" /></font>
|
||||
<padding><Insets bottom="6.0" left="14.0" right="14.0" top="6.0" /></padding>
|
||||
</ToggleButton>
|
||||
</HBox>
|
||||
<Button fx:id="btnRefresh" onAction="#handleRefresh" style="-fx-background-color: #4ECDC4; -fx-text-fill: white; -fx-background-radius: 5; -fx-cursor: hand;" text="Refresh">
|
||||
<font>
|
||||
<Font size="13.0" />
|
||||
@@ -91,6 +102,7 @@
|
||||
</FlowPane>
|
||||
<HBox alignment="CENTER_LEFT" spacing="8.0">
|
||||
<children>
|
||||
<ComboBox fx:id="cbStoreFilter" prefWidth="145.0" promptText="All Stores" visible="false" managed="false" />
|
||||
<ComboBox fx:id="cbPaymentFilter" prefWidth="145.0" promptText="All Payments" />
|
||||
<ComboBox fx:id="cbTopN" prefWidth="110.0" />
|
||||
<Region HBox.hgrow="ALWAYS" />
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
|
||||
<TableView fx:id="tvCustomers" style="-fx-background-color: white; -fx-background-radius: 12;" VBox.vgrow="ALWAYS">
|
||||
<columns>
|
||||
<TableColumn fx:id="colCustomerAvatar" prefWidth="70.0" text="Avatar" sortable="false" />
|
||||
<TableColumn fx:id="colCustomerUsername" prefWidth="130.0" text="Username" />
|
||||
<TableColumn fx:id="colCustomerName" prefWidth="160.0" text="Name" />
|
||||
<TableColumn fx:id="colCustomerEmail" prefWidth="200.0" text="Email" />
|
||||
|
||||
@@ -173,6 +173,12 @@
|
||||
<Label fx:id="lblCartTotal" text="" textFill="#2c3e50"><font><Font name="System Bold" size="16.0" /></font></Label>
|
||||
</children>
|
||||
</HBox>
|
||||
<HBox fx:id="hbPointsToEarn" spacing="8.0" alignment="CENTER_LEFT" visible="false" managed="false">
|
||||
<children>
|
||||
<Label text="Points to earn:" textFill="#4ECDC4"><font><Font size="12.0" /></font></Label>
|
||||
<Label fx:id="lblPointsToEarn" text="+0 pts" textFill="#4ECDC4"><font><Font size="12.0" /></font></Label>
|
||||
</children>
|
||||
</HBox>
|
||||
</children>
|
||||
</VBox>
|
||||
<FlowPane hgap="8.0" prefWrapLength="220.0" vgap="8.0">
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
|
||||
<TableView fx:id="tvStaff" style="-fx-background-color: white; -fx-background-radius: 12;" VBox.vgrow="ALWAYS">
|
||||
<columns>
|
||||
<TableColumn fx:id="colStaffAvatar" prefWidth="70.0" text="Avatar" sortable="false" />
|
||||
<TableColumn fx:id="colUsername" prefWidth="140.0" text="Username" />
|
||||
<TableColumn fx:id="colName" prefWidth="170.0" text="Name" />
|
||||
<TableColumn fx:id="colEmail" prefWidth="210.0" text="Email" />
|
||||
|
||||
@@ -44,6 +44,8 @@ export default function PetDetailPage() {
|
||||
petStatus={pet.petStatus}
|
||||
petPrice={pet.petPrice}
|
||||
imageUrl={pet.imageUrl}
|
||||
storeId={pet.storeId}
|
||||
storeName={pet.storeName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,34 @@ import { useAuth } from "@/context/AuthContext";
|
||||
|
||||
const API_BASE = "";
|
||||
|
||||
const SPECIES_BREEDS = {
|
||||
Dog: ["Beagle", "Boxer", "Bulldog", "Chihuahua", "Dachshund", "German Shepherd", "Golden Retriever", "Labrador Retriever", "Poodle", "Rottweiler", "Shih Tzu", "Siberian Husky", "Yorkshire Terrier", "Mixed / Other"],
|
||||
Cat: ["Abyssinian", "Bengal", "British Shorthair", "Maine Coon", "Persian", "Ragdoll", "Scottish Fold", "Siamese", "Sphynx", "Mixed / Other"],
|
||||
Bird: ["Canary", "Cockatiel", "Cockatoo", "Finch", "Lovebird", "Macaw", "Parakeet", "Parrot", "Other"],
|
||||
Rabbit: ["Dutch", "Flemish Giant", "Holland Lop", "Lionhead", "Mini Rex", "Other"],
|
||||
Hamster: ["Dwarf", "Roborovski", "Syrian", "Other"],
|
||||
"Guinea Pig": ["Abyssinian", "American", "Peruvian", "Teddy", "Other"],
|
||||
Reptile: ["Ball Python", "Bearded Dragon", "Blue-tongued Skink", "Corn Snake", "Leopard Gecko", "Other"],
|
||||
Fish: ["Angelfish", "Betta", "Cichlid", "Clownfish", "Goldfish", "Guppy", "Tetra", "Other"],
|
||||
Other: ["Other"],
|
||||
};
|
||||
|
||||
// Explicit allowlists for species with restricted service availability.
|
||||
// Species not listed here may use all services.
|
||||
const SPECIES_SERVICE_ALLOWLIST = {
|
||||
Bird: ["wing clipping", "beak and nail"],
|
||||
Fish: ["aquarium health"],
|
||||
};
|
||||
|
||||
function getAvailableServices(services, species) {
|
||||
if (!species) return services;
|
||||
const allowlist = SPECIES_SERVICE_ALLOWLIST[species];
|
||||
if (!allowlist) return services;
|
||||
return services.filter((s) =>
|
||||
allowlist.some((kw) => s.serviceName.toLowerCase().includes(kw))
|
||||
);
|
||||
}
|
||||
|
||||
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December",
|
||||
];
|
||||
@@ -254,26 +282,32 @@ function AddPetModal({ token, onClose, onAdded }) {
|
||||
</label>
|
||||
<label className="appt-label">
|
||||
Species
|
||||
<input
|
||||
className="appt-input"
|
||||
type="text"
|
||||
<select
|
||||
className="appt-select"
|
||||
value={species}
|
||||
onChange={(e) => setSpecies(e.target.value)}
|
||||
onChange={(e) => { setSpecies(e.target.value); setBreed(""); }}
|
||||
required
|
||||
maxLength={50}
|
||||
placeholder="e.g. Dog, Cat, Bird"
|
||||
/>
|
||||
>
|
||||
<option value="">Select a species...</option>
|
||||
{Object.keys(SPECIES_BREEDS).map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="appt-label">
|
||||
Breed (optional)
|
||||
<input
|
||||
className="appt-input"
|
||||
type="text"
|
||||
Breed
|
||||
<select
|
||||
className="appt-select"
|
||||
value={breed}
|
||||
onChange={(e) => setBreed(e.target.value)}
|
||||
maxLength={50}
|
||||
placeholder="e.g. Golden Retriever"
|
||||
/>
|
||||
required
|
||||
disabled={!species}
|
||||
>
|
||||
<option value="">{species ? "Select a breed..." : "Select a species first"}</option>
|
||||
{(SPECIES_BREEDS[species] || []).map((b) => (
|
||||
<option key={b} value={b}>{b}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="profile-pet-form-actions">
|
||||
<button type="submit" className="appt-submit-btn" disabled={submitting}>
|
||||
@@ -294,8 +328,23 @@ function AppointmentsPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const preselectedPetId = searchParams.get("petId");
|
||||
|
||||
// Adoption mode — set when arriving from a pet detail page
|
||||
const adoptionMode = searchParams.get("adoptionMode") === "true";
|
||||
const adoptionPetId = searchParams.get("petId");
|
||||
const adoptionPetName = searchParams.get("petName") || "";
|
||||
const adoptionPetSpecies = searchParams.get("petSpecies") || "";
|
||||
const adoptionPetBreed = searchParams.get("petBreed") || "";
|
||||
const adoptionStoreId = searchParams.get("storeId") || "";
|
||||
const adoptionStoreName = searchParams.get("storeName") || "";
|
||||
|
||||
const didPreselectRef = useRef(false);
|
||||
|
||||
// Adoption-mode URL verification
|
||||
const [adoptionVerified, setAdoptionVerified] = useState(!adoptionMode);
|
||||
const [adoptionVerifyError, setAdoptionVerifyError] = useState(null);
|
||||
const [adoptionVerifyLoading, setAdoptionVerifyLoading] = useState(adoptionMode);
|
||||
|
||||
const [stores, setStores] = useState([]);
|
||||
const [employees, setEmployees] = useState([]);
|
||||
const [services, setServices] = useState([]);
|
||||
@@ -318,7 +367,11 @@ function AppointmentsPage() {
|
||||
const [appointments, setAppointments] = useState([]);
|
||||
const [loadingAppointments, setLoadingAppointments] = useState(false);
|
||||
|
||||
const [adoptions, setAdoptions] = useState([]);
|
||||
const [loadingAdoptions, setLoadingAdoptions] = useState(false);
|
||||
|
||||
const [showAddPetModal, setShowAddPetModal] = useState(false);
|
||||
const [cancellingId, setCancellingId] = useState(null);
|
||||
|
||||
const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
|
||||
@@ -330,6 +383,28 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
|
||||
}, [authLoading, user, router, preselectedPetId]);
|
||||
|
||||
// Verify the pet from the URL is real, available, and at the stated store
|
||||
useEffect(() => {
|
||||
if (!adoptionMode || !adoptionPetId) return;
|
||||
setAdoptionVerifyLoading(true);
|
||||
fetch(`${API_BASE}/api/v1/pets/${adoptionPetId}`)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error("Pet not found. This link may be invalid.");
|
||||
return r.json();
|
||||
})
|
||||
.then((pet) => {
|
||||
if (pet.petStatus?.toLowerCase() !== "available") {
|
||||
throw new Error(`${pet.petName || "This pet"} is no longer available for adoption (status: ${pet.petStatus}).`);
|
||||
}
|
||||
if (adoptionStoreId && String(pet.storeId) !== String(adoptionStoreId)) {
|
||||
throw new Error("Store mismatch: this pet is not located at the specified store.");
|
||||
}
|
||||
setAdoptionVerified(true);
|
||||
})
|
||||
.catch((err) => setAdoptionVerifyError(err.message))
|
||||
.finally(() => setAdoptionVerifyLoading(false));
|
||||
}, [adoptionMode, adoptionPetId, adoptionStoreId]);
|
||||
|
||||
const loadCustomerPets = useCallback(() => {
|
||||
if (!token || !canBookAppointments) return;
|
||||
fetch(`${API_BASE}/api/v1/my-pets`, {
|
||||
@@ -366,9 +441,24 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
}, [token, loadCustomerPets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (didPreselectRef.current) {
|
||||
if (didPreselectRef.current) return;
|
||||
|
||||
if (adoptionMode) {
|
||||
// Need both the store (so employees load) and a serviceId (so availability slots load)
|
||||
if (adoptionStoreId && services.length > 0) {
|
||||
setStoreId(adoptionStoreId);
|
||||
// Prefer a service named "adopt", fall back to the first available service
|
||||
const adoptionSvc =
|
||||
services.find((s) => s.serviceName.toLowerCase().includes("adopt")) ||
|
||||
services[0];
|
||||
if (adoptionSvc) {
|
||||
setServiceId(String(adoptionSvc.serviceId));
|
||||
didPreselectRef.current = true;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!preselectedPetId || services.length === 0 || allPets.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -382,16 +472,13 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
}
|
||||
setSelectedPetIds([Number(preselectedPetId)]);
|
||||
didPreselectRef.current = true;
|
||||
}, [preselectedPetId, services, allPets]);
|
||||
}, [adoptionMode, adoptionStoreId, preselectedPetId, services, allPets]);
|
||||
|
||||
const loadAppointments = useCallback(() => {
|
||||
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
if (!token) return;
|
||||
setLoadingAppointments(true);
|
||||
fetch(`${API_BASE}/api/v1/appointments?size=50&sort=appointmentDate,desc`, {
|
||||
headers: {Authorization: `Bearer ${token}`},
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => setAppointments(data.content ?? []))
|
||||
@@ -399,9 +486,62 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
.finally(() => setLoadingAppointments(false));
|
||||
}, [token]);
|
||||
|
||||
const loadAdoptions = useCallback(() => {
|
||||
if (!token) return;
|
||||
setLoadingAdoptions(true);
|
||||
fetch(`${API_BASE}/api/v1/adoptions?size=50&sort=adoptionDate,desc`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => setAdoptions(data.content ?? []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoadingAdoptions(false));
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAppointments();
|
||||
}, [loadAppointments]);
|
||||
if (adoptionMode) loadAdoptions();
|
||||
else loadAppointments();
|
||||
}, [adoptionMode, loadAppointments, loadAdoptions]);
|
||||
|
||||
async function handleCancelAppointment(appointmentId) {
|
||||
if (!confirm("Cancel this appointment?")) return;
|
||||
setCancellingId(appointmentId);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/appointments/${appointmentId}/cancel`, {
|
||||
method: "PATCH",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
throw new Error(data?.message || `Failed to cancel appointment (${res.status})`);
|
||||
}
|
||||
loadAppointments();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setCancellingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancelAdoption(adoptionId) {
|
||||
if (!confirm("Cancel this adoption request?")) return;
|
||||
setCancellingId(adoptionId);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/adoptions/${adoptionId}/cancel`, {
|
||||
method: "PATCH",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
throw new Error(data?.message || `Failed to cancel adoption (${res.status})`);
|
||||
}
|
||||
loadAdoptions();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setCancellingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !storeId) {
|
||||
@@ -453,30 +593,30 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
.finally(() => setLoadingSlots(false));
|
||||
}, [storeId, serviceId, appointmentDate]);
|
||||
|
||||
const selectedService = services.find((s) => s.serviceId === Number(serviceId));
|
||||
const isAdoptionService = selectedService ? selectedService.serviceName.toLowerCase().includes("adopt") : false;
|
||||
const isCustomerPetService = !!selectedService && !isAdoptionService;
|
||||
|
||||
const adoptablePets = allPets.filter(
|
||||
(p) => p.petStatus && p.petStatus.toLowerCase() === "available"
|
||||
const eligiblePets = customerPets.filter(
|
||||
(p) => p.petStatus === "Owned" || p.petStatus === "Adopted"
|
||||
);
|
||||
|
||||
const selectedService = services.find((s) => s.serviceId === Number(serviceId));
|
||||
const selectedPet = !adoptionMode
|
||||
? (eligiblePets.find((p) => p.customerPetId === selectedPetIds[0]) || null)
|
||||
: null;
|
||||
const availableServices = getAvailableServices(services, selectedPet?.species);
|
||||
|
||||
function handleServiceChange(newServiceId) {
|
||||
setServiceId(newServiceId);
|
||||
setSelectedPetIds([]);
|
||||
}
|
||||
|
||||
function togglePet(petId) {
|
||||
if (isAdoptionService) {
|
||||
setSelectedPetIds((prev) =>
|
||||
prev.includes(petId) ? [] : [petId]
|
||||
);
|
||||
}
|
||||
|
||||
else {
|
||||
setSelectedPetIds((prev) =>
|
||||
prev.includes(petId) ? prev.filter((id) => id !== petId) : [...prev, petId]
|
||||
);
|
||||
function handlePetSelect(petId) {
|
||||
const newPet = eligiblePets.find((p) => p.customerPetId === petId);
|
||||
setSelectedPetIds([petId]);
|
||||
if (serviceId && newPet) {
|
||||
const newAvailable = getAvailableServices(services, newPet.species);
|
||||
if (!newAvailable.some((s) => String(s.serviceId) === String(serviceId))) {
|
||||
setServiceId("");
|
||||
setAppointmentTime("");
|
||||
setAvailableSlots([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,8 +635,9 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
return d.toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
const formValid =
|
||||
storeId && serviceId && appointmentDate && appointmentTime && selectedPetIds.length > 0;
|
||||
const formValid = adoptionMode
|
||||
? Boolean(employeeId && appointmentDate && adoptionVerified)
|
||||
: storeId && serviceId && appointmentDate && appointmentTime && selectedPetIds.length > 0;
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
@@ -505,19 +646,59 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
|
||||
if (!canBookAppointments) {
|
||||
setError("Only customer accounts can book appointments from the web app.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedPetIds.length === 0) {
|
||||
setError(isAdoptionService ? "Please select a pet to adopt." : "Please select at least one pet.");
|
||||
|
||||
if (!adoptionMode && selectedPetIds.length === 0) {
|
||||
setError("Please select a pet for your appointment.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!adoptionMode && selectedPet && selectedPet.petStatus !== "Owned" && selectedPet.petStatus !== "Adopted") {
|
||||
setError("The selected pet is no longer eligible for appointments. Please refresh the page.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!adoptionMode && selectedPet && serviceId) {
|
||||
const chosenService = services.find((s) => String(s.serviceId) === String(serviceId));
|
||||
if (chosenService && getAvailableServices([chosenService], selectedPet.species).length === 0) {
|
||||
setError(`"${chosenService.serviceName}" is not available for ${selectedPet.species}s. Please select a valid service.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
if (adoptionMode) {
|
||||
// Submit an adoption request directly to the adoption table
|
||||
const body = {
|
||||
petId: Number(adoptionPetId),
|
||||
employeeId: employeeId ? Number(employeeId) : undefined,
|
||||
sourceStoreId: adoptionStoreId ? Number(adoptionStoreId) : undefined,
|
||||
adoptionDate: appointmentDate,
|
||||
};
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/v1/adoptions/request`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
throw new Error(data?.message || data?.error || `Request failed (${res.status})`);
|
||||
}
|
||||
|
||||
setSuccess(`Adoption request submitted! ${adoptionPetName} is now marked as Pending. We'll be in touch soon.`);
|
||||
setEmployeeId("");
|
||||
loadAdoptions();
|
||||
return;
|
||||
}
|
||||
|
||||
const body = {
|
||||
customerId: user.customerId || user.id,
|
||||
storeId: Number(storeId),
|
||||
@@ -577,12 +758,6 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const petsToShow = isAdoptionService ? adoptablePets : isCustomerPetService ? customerPets : [];
|
||||
const petSectionLabel = isAdoptionService ? "Select a Pet to Adopt" : "Select Pet(s)";
|
||||
const noPetsMessage = isAdoptionService
|
||||
? "No pets are currently available for adoption."
|
||||
: "No pets found on your profile.";
|
||||
|
||||
return (
|
||||
<main className="appt-page">
|
||||
{showAddPetModal && (
|
||||
@@ -594,50 +769,112 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
)}
|
||||
|
||||
<section className="appt-hero">
|
||||
<h1 className="appt-hero-title">Schedule an Appointment</h1>
|
||||
<p className="appt-hero-subtitle">Book a service for your pet or schedule a pet adoption visit</p>
|
||||
<h1 className="appt-hero-title">{adoptionMode ? "Schedule an Adoption" : "Schedule an Appointment"}</h1>
|
||||
<p className="appt-hero-subtitle">{adoptionMode ? "Schedule a pet adoption visit" : "Book a service for your pet."}</p>
|
||||
<div className="title-decoration"></div>
|
||||
</section>
|
||||
|
||||
<section className="appt-content">
|
||||
{canBookAppointments ? (
|
||||
<form className="appt-form" onSubmit={handleSubmit}>
|
||||
<h2 className="appt-form-title">New Appointment</h2>
|
||||
<h2 className="appt-form-title">{adoptionMode ? "New Adoption" : "New Appointment"}</h2>
|
||||
|
||||
{error && <div className="appt-error">{error}</div>}
|
||||
{success && <div className="appt-success">{success}</div>}
|
||||
|
||||
{adoptionMode && adoptionVerifyLoading && (
|
||||
<p className="appt-loading">Verifying pet details…</p>
|
||||
)}
|
||||
{adoptionMode && adoptionVerifyError && (
|
||||
<div className="appt-error">{adoptionVerifyError}</div>
|
||||
)}
|
||||
|
||||
{(!adoptionMode || adoptionVerified) && (<>
|
||||
|
||||
{/* ADOPTION MODE: locked pet + store */}
|
||||
{adoptionMode && (
|
||||
<label className="appt-label">
|
||||
Pet
|
||||
<div className="appt-locked-field">
|
||||
{[adoptionPetName, adoptionPetSpecies, adoptionPetBreed].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* STEP 1 (non-adoption): select a pet first */}
|
||||
{!adoptionMode && (
|
||||
<div className="appt-label">
|
||||
<span>Select Your Pet</span>
|
||||
{eligiblePets.length === 0 ? (
|
||||
<p className="appt-no-slots">You have no adopted pets available for appointments.</p>
|
||||
) : (
|
||||
<div className="appt-pets-grid">
|
||||
{eligiblePets.map((p) => (
|
||||
<label
|
||||
key={p.customerPetId}
|
||||
className={`appt-pet-chip ${selectedPetIds.includes(p.customerPetId) ? "appt-pet-chip--selected" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="customerPet"
|
||||
checked={selectedPetIds.includes(p.customerPetId)}
|
||||
onChange={() => handlePetSelect(p.customerPetId)}
|
||||
className="appt-pet-checkbox"
|
||||
/>
|
||||
{p.petName}
|
||||
<span className="appt-pet-chip-species">({p.species})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remaining fields — shown after pet selected (or always in adoption mode) */}
|
||||
{(adoptionMode || selectedPetIds.length > 0) && (<>
|
||||
|
||||
<label className="appt-label">
|
||||
Store Location
|
||||
<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>
|
||||
{adoptionMode ? (
|
||||
<div className="appt-locked-field">{adoptionStoreName || "Pet's store"}</div>
|
||||
) : (
|
||||
<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>
|
||||
{!adoptionMode && (
|
||||
<label className="appt-label">
|
||||
Service
|
||||
{availableServices.length === 0 ? (
|
||||
<p className="appt-no-slots">
|
||||
No services are available for {selectedPet?.species || "this pet"}.
|
||||
</p>
|
||||
) : (
|
||||
<select
|
||||
className="appt-select"
|
||||
value={serviceId}
|
||||
onChange={(e) => handleServiceChange(e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="">Select a service...</option>
|
||||
{availableServices.map((s) => (
|
||||
<option key={s.serviceId} value={s.serviceId}>
|
||||
{s.serviceName} — ${Number(s.servicePrice).toFixed(2)} ({s.serviceDuration} min)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{employees.length > 0 && (
|
||||
<label className="appt-label">
|
||||
@@ -654,7 +891,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
</label>
|
||||
)}
|
||||
|
||||
{selectedService && (
|
||||
{!adoptionMode && selectedService && (
|
||||
<div className="appt-service-info">
|
||||
<p>{selectedService.serviceDesc}</p>
|
||||
</div>
|
||||
@@ -669,7 +906,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
/>
|
||||
</div>
|
||||
|
||||
{storeId && serviceId && appointmentDate && (
|
||||
{!adoptionMode && storeId && serviceId && appointmentDate && (
|
||||
<div className="appt-label">
|
||||
<span>Available Time Slots</span>
|
||||
{loadingSlots ? (
|
||||
@@ -693,83 +930,62 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
</div>
|
||||
)}
|
||||
|
||||
{serviceId && (
|
||||
<div className="appt-label">
|
||||
<span>{petSectionLabel}</span>
|
||||
{isCustomerPetService && (
|
||||
<button
|
||||
type="button"
|
||||
className="appt-add-pet-btn"
|
||||
onClick={() => setShowAddPetModal(true)}
|
||||
>
|
||||
+ Add New Pet
|
||||
</button>
|
||||
)}
|
||||
{petsToShow.length === 0 ? (
|
||||
<p className="appt-no-slots">{noPetsMessage}</p>
|
||||
) : isAdoptionService ? (
|
||||
<div className="appt-adopt-grid">
|
||||
{petsToShow.map((p) => (
|
||||
<label
|
||||
key={p.petId}
|
||||
className={`appt-adopt-card ${selectedPetIds.includes(p.petId) ? "appt-adopt-card--selected" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="adoptionPet"
|
||||
value={p.petId}
|
||||
checked={selectedPetIds.includes(p.petId)}
|
||||
onChange={() => togglePet(p.petId)}
|
||||
className="appt-adopt-radio"
|
||||
/>
|
||||
{p.imageUrl ? (
|
||||
<img src={p.imageUrl} alt={p.petName} className="appt-adopt-img" />
|
||||
) : (
|
||||
<div className="appt-adopt-img-placeholder">🐾</div>
|
||||
)}
|
||||
<div className="appt-adopt-info">
|
||||
<span className="appt-adopt-name">{p.petName}</span>
|
||||
<span className="appt-adopt-detail">{p.petSpecies} · {p.petBreed}</span>
|
||||
<span className="appt-adopt-detail">Age: {p.petAge}</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="appt-pets-grid">
|
||||
{petsToShow.map((p) => (
|
||||
<label
|
||||
key={p.customerPetId}
|
||||
className={`appt-pet-chip ${selectedPetIds.includes(p.customerPetId) ? "appt-pet-chip--selected" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPetIds.includes(p.customerPetId)}
|
||||
onChange={() => togglePet(p.customerPetId)}
|
||||
className="appt-pet-checkbox"
|
||||
/>
|
||||
{p.petName}
|
||||
<span className="appt-pet-chip-species">({p.species})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="appt-submit-btn"
|
||||
disabled={!formValid || submitting}
|
||||
>
|
||||
{submitting ? "Booking..." : isAdoptionService ? "Schedule Adoption Visit" : "Book Appointment"}
|
||||
{submitting ? "Booking..." : adoptionMode ? "Schedule Adoption" : "Book Appointment"}
|
||||
</button>
|
||||
|
||||
{success && <div className="appt-success">{success}</div>}
|
||||
|
||||
</>)}
|
||||
|
||||
</>)}
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
<div className="appt-history">
|
||||
<h2 className="appt-form-title">{canBookAppointments ? "Your Appointments" : "Appointments"}</h2>
|
||||
{loadingAppointments ? (
|
||||
<h2 className="appt-form-title">
|
||||
{adoptionMode ? "Your Adoptions" : canBookAppointments ? "Your Appointments" : "Appointments"}
|
||||
</h2>
|
||||
{adoptionMode ? (
|
||||
loadingAdoptions ? (
|
||||
<p className="appt-loading">Loading adoptions...</p>
|
||||
) : adoptions.length === 0 ? (
|
||||
<p className="appt-empty">No adoption appointments yet.</p>
|
||||
) : (
|
||||
<div className="appt-list">
|
||||
{adoptions.map((a) => (
|
||||
<div key={a.adoptionId} className="appt-card">
|
||||
<div className="appt-card-header">
|
||||
<span className="appt-card-service">{a.petName}</span>
|
||||
<span className={`appt-card-status appt-card-status--${a.adoptionStatus?.toLowerCase()}`}>
|
||||
{a.adoptionStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="appt-card-details">
|
||||
<span>{a.sourceStoreName}</span>
|
||||
<span>{a.adoptionDate}</span>
|
||||
</div>
|
||||
{a.adoptionStatus?.toLowerCase() === "pending" && (
|
||||
<div className="appt-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="appt-cancel-btn"
|
||||
disabled={cancellingId === a.adoptionId}
|
||||
onClick={() => handleCancelAdoption(a.adoptionId)}
|
||||
>
|
||||
{cancellingId === a.adoptionId ? "Cancelling..." : "Cancel"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : loadingAppointments ? (
|
||||
<p className="appt-loading">Loading appointments...</p>
|
||||
) : appointments.length === 0 ? (
|
||||
<p className="appt-empty">No appointments yet.</p>
|
||||
@@ -797,6 +1013,18 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
Pets: {a.customerPetNames.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{a.appointmentStatus?.toLowerCase() === "booked" && (
|
||||
<div className="appt-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="appt-cancel-btn"
|
||||
disabled={cancellingId === a.appointmentId}
|
||||
onClick={() => handleCancelAppointment(a.appointmentId)}
|
||||
>
|
||||
{cancellingId === a.appointmentId ? "Cancelling..." : "Cancel"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,31 +1,24 @@
|
||||
const LOCATIONS = [
|
||||
{
|
||||
name: "Downtown Branch",
|
||||
address: "123 Main St",
|
||||
phone: "(123) 456-7890",
|
||||
email: "downtown@petshop.com",
|
||||
},
|
||||
{
|
||||
name: "North Branch",
|
||||
address: "456 North Ave",
|
||||
phone: "(987) 654-3210",
|
||||
email: "north@petshop.com",
|
||||
},
|
||||
{
|
||||
name: "West Side Store",
|
||||
address: "789 West Blvd",
|
||||
phone: "(555) 123-4567",
|
||||
email: "westside@petshop.com",
|
||||
},
|
||||
];
|
||||
"use client";
|
||||
|
||||
const PERSONNEL = [
|
||||
{ name: "John Doe", role: "Store Manager" },
|
||||
{ name: "Sara Smith", role: "Staff" },
|
||||
{ name: "Michael Johnson", role: "Grooming Team" },
|
||||
];
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export default function ContactPage() {
|
||||
const [locations, setLocations] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams({ page: "0", size: "100", sort: "storeName,asc" });
|
||||
fetch(`/api/v1/stores?${params}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => setLocations(data.content ?? []))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="info-page">
|
||||
<section className="info-hero">
|
||||
@@ -44,28 +37,40 @@ export default function ContactPage() {
|
||||
|
||||
<div className="info-card">
|
||||
<h2>Store Locations</h2>
|
||||
<div className="info-card-grid">
|
||||
{LOCATIONS.map((location) => (
|
||||
<article key={location.name} className="info-mini-card">
|
||||
<h3>{location.name}</h3>
|
||||
<p>{location.address}</p>
|
||||
<p>{location.phone}</p>
|
||||
<p>{location.email}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="info-card">
|
||||
<h2>Store Personnel</h2>
|
||||
<div className="info-card-grid">
|
||||
{PERSONNEL.map((person) => (
|
||||
<article key={person.name} className="info-mini-card">
|
||||
<h3>{person.name}</h3>
|
||||
<p>{person.role}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{loading && <p>Loading locations...</p>}
|
||||
|
||||
{error && <p style={{ color: "red" }}>Failed to load locations: {error}</p>}
|
||||
|
||||
{!loading && !error && locations.length === 0 && (
|
||||
<p>No store locations found.</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && locations.length > 0 && (
|
||||
<div className="info-card-grid">
|
||||
{locations.map((location) => (
|
||||
<article key={location.storeId} className="info-mini-card location-card">
|
||||
<div className="location-card-image-wrapper">
|
||||
<img
|
||||
src={location.imageUrl || "/images/pet-placeholder.png"}
|
||||
alt={location.storeName}
|
||||
className="location-card-image"
|
||||
onError={(e) => {
|
||||
e.currentTarget.onerror = null;
|
||||
e.currentTarget.src = "/images/pet-placeholder.png";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="location-card-body">
|
||||
<h3>{location.storeName}</h3>
|
||||
<p>{location.address}</p>
|
||||
<p>{location.phone}</p>
|
||||
<p>{location.email}</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 176 KiB |
@@ -38,7 +38,7 @@ body {
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
min-height: 70px;
|
||||
border-radius: 0px 0px 10px 10px;
|
||||
/* border-radius: 0px 0px 10px 10px; */
|
||||
}
|
||||
|
||||
/* Add padding to body to account for fixed header */
|
||||
@@ -62,11 +62,9 @@ body {
|
||||
.nav-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
gap: 1.25rem;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Indivdual Link Styles */
|
||||
@@ -758,6 +756,39 @@ body {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.location-card {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.location-card-image-wrapper {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.location-card-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.location-card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.location-card-body h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.location-card-body p {
|
||||
margin: 0.25rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.products-hero {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem 3rem;
|
||||
@@ -1356,6 +1387,17 @@ body {
|
||||
box-shadow: 0 0 0 3px rgba(255, 165, 0, 0.2);
|
||||
}
|
||||
|
||||
.appt-locked-field {
|
||||
padding: 0.6rem 0.85rem;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
background: #f5f5f5;
|
||||
color: #555;
|
||||
font-weight: 600;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.appt-service-info {
|
||||
background: #fff8f0;
|
||||
border: 1px solid #ffd180;
|
||||
@@ -1616,6 +1658,11 @@ body {
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.appt-card-status--pending {
|
||||
background: #fff8e1;
|
||||
color: #f57f17;
|
||||
}
|
||||
|
||||
.appt-card-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1629,6 +1676,34 @@ body {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.appt-card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.appt-cancel-btn {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.85rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e53935;
|
||||
background: transparent;
|
||||
color: #e53935;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.appt-cancel-btn:hover:not(:disabled) {
|
||||
background: #e53935;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.appt-cancel-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Adoption Pet Selection */
|
||||
|
||||
.appt-adopt-grid {
|
||||
@@ -2573,7 +2648,24 @@ body {
|
||||
|
||||
/* Mobile / Responsive */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* Compact nav at mid-range widths before collapsing to hamburger */
|
||||
@media (min-width: 1101px) and (max-width: 1350px) {
|
||||
.nav-links {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
}
|
||||
|
||||
.nav-auth {
|
||||
gap: 0.35rem;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.navbar {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
@@ -2629,7 +2721,7 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (max-width: 1100px) {
|
||||
/* Show hamburger bar, hide desktop nav */
|
||||
.nav-mobile-bar {
|
||||
display: flex;
|
||||
|
||||
@@ -6,6 +6,18 @@ import { useAuth } from "@/context/AuthContext";
|
||||
|
||||
const API_BASE = "";
|
||||
|
||||
const SPECIES_BREEDS = {
|
||||
Dog: ["Beagle", "Boxer", "Bulldog", "Chihuahua", "Dachshund", "German Shepherd", "Golden Retriever", "Labrador Retriever", "Poodle", "Rottweiler", "Shih Tzu", "Siberian Husky", "Yorkshire Terrier", "Mixed / Other"],
|
||||
Cat: ["Abyssinian", "Bengal", "British Shorthair", "Maine Coon", "Persian", "Ragdoll", "Scottish Fold", "Siamese", "Sphynx", "Mixed / Other"],
|
||||
Bird: ["Canary", "Cockatiel", "Cockatoo", "Finch", "Lovebird", "Macaw", "Parakeet", "Parrot", "Other"],
|
||||
Rabbit: ["Dutch", "Flemish Giant", "Holland Lop", "Lionhead", "Mini Rex", "Other"],
|
||||
Hamster: ["Dwarf", "Roborovski", "Syrian", "Other"],
|
||||
"Guinea Pig": ["Abyssinian", "American", "Peruvian", "Teddy", "Other"],
|
||||
Reptile: ["Ball Python", "Bearded Dragon", "Blue-tongued Skink", "Corn Snake", "Leopard Gecko", "Other"],
|
||||
Fish: ["Angelfish", "Betta", "Cichlid", "Clownfish", "Goldfish", "Guppy", "Tetra", "Other"],
|
||||
Other: ["Other"],
|
||||
};
|
||||
|
||||
export default function ProfilePage() {
|
||||
const {user, token, loading, logout, refreshUser} = useAuth();
|
||||
const router = useRouter();
|
||||
@@ -18,9 +30,11 @@ export default function ProfilePage() {
|
||||
const [petName, setPetName] = useState("");
|
||||
const [species, setSpecies] = useState("");
|
||||
const [breed, setBreed] = useState("");
|
||||
const [petAge, setPetAge] = useState("1");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [petError, setPetError] = useState(null);
|
||||
const [profileForm, setProfileForm] = useState({ fullName: "", email: "", phone: "" });
|
||||
const [avatarObjectUrl, setAvatarObjectUrl] = useState(null);
|
||||
const [profileForm, setProfileForm] = useState({ firstName: "", lastName: "", email: "", phone: "", password: "", confirmPassword: "" });
|
||||
const [profileSubmitting, setProfileSubmitting] = useState(false);
|
||||
const [profileError, setProfileError] = useState(null);
|
||||
const [profileSuccess, setProfileSuccess] = useState(null);
|
||||
@@ -42,9 +56,12 @@ export default function ProfilePage() {
|
||||
|
||||
useEffect(() => {
|
||||
setProfileForm({
|
||||
fullName: user?.fullName || "",
|
||||
firstName: user?.firstName || "",
|
||||
lastName: user?.lastName || "",
|
||||
email: user?.email || "",
|
||||
phone: user?.phone || "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
}, [user]);
|
||||
|
||||
@@ -53,7 +70,7 @@ export default function ProfilePage() {
|
||||
setLoadingPets(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/v1/my-pets`, {
|
||||
const response = await fetch(`${API_BASE}/api/v1/my-pets?status=Owned`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
@@ -64,25 +81,21 @@ export default function ProfilePage() {
|
||||
const petData = await response.json();
|
||||
clearPetImageObjectUrls();
|
||||
|
||||
const petsWithResolvedImages = await Promise.all(
|
||||
(Array.isArray(petData) ? petData : []).map(async (pet) => {
|
||||
if (!pet.imageUrl) {
|
||||
return pet;
|
||||
}
|
||||
const ownedPets = Array.isArray(petData) ? petData : [];
|
||||
|
||||
const petsWithResolvedImages = await Promise.all(
|
||||
ownedPets.map(async (pet) => {
|
||||
if (!pet.imageUrl) return pet;
|
||||
try {
|
||||
const imageResponse = await fetch(`${API_BASE}${pet.imageUrl}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!imageResponse.ok) {
|
||||
return { ...pet, imageUrl: null };
|
||||
}
|
||||
if (!imageResponse.ok) return { ...pet, imageUrl: null };
|
||||
|
||||
const blob = await imageResponse.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
petImageObjectUrlsRef.current.push(objectUrl);
|
||||
|
||||
return { ...pet, imageUrl: objectUrl };
|
||||
} catch {
|
||||
return { ...pet, imageUrl: null };
|
||||
@@ -108,11 +121,37 @@ export default function ProfilePage() {
|
||||
}, [clearPetImageObjectUrls]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
|
||||
if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
|
||||
loadPets();
|
||||
}
|
||||
}, [user, loadPets]);
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl = null;
|
||||
|
||||
if (user?.avatarUrl && token) {
|
||||
fetch(`${API_BASE}${user.avatarUrl}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((res) => (res.ok ? res.blob() : null))
|
||||
.then((blob) => {
|
||||
if (blob) {
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setAvatarObjectUrl(objectUrl);
|
||||
} else {
|
||||
setAvatarObjectUrl(null);
|
||||
}
|
||||
})
|
||||
.catch(() => setAvatarObjectUrl(null));
|
||||
} else {
|
||||
setAvatarObjectUrl(null);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [user?.avatarUrl, token]);
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
router.push("/");
|
||||
@@ -120,10 +159,26 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
|
||||
|
||||
async function handleProfileSubmit(e) {
|
||||
e.preventDefault();
|
||||
setProfileSubmitting(true);
|
||||
setProfileError(null);
|
||||
|
||||
if (profileForm.password && profileForm.password !== profileForm.confirmPassword) {
|
||||
setProfileError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
|
||||
setProfileSubmitting(true);
|
||||
setProfileSuccess(null);
|
||||
|
||||
const payload = {
|
||||
firstName: profileForm.firstName,
|
||||
lastName: profileForm.lastName,
|
||||
email: profileForm.email,
|
||||
phone: profileForm.phone,
|
||||
};
|
||||
if (profileForm.password) {
|
||||
payload.password = profileForm.password;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/auth/me`, {
|
||||
method: "PUT",
|
||||
@@ -131,7 +186,7 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(profileForm),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => null);
|
||||
@@ -140,6 +195,7 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
|
||||
}
|
||||
|
||||
await refreshUser();
|
||||
setProfileForm((prev) => ({ ...prev, password: "", confirmPassword: "" }));
|
||||
setProfileSuccess("Profile updated successfully.");
|
||||
}
|
||||
|
||||
@@ -222,6 +278,7 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
|
||||
setPetName("");
|
||||
setSpecies("");
|
||||
setBreed("");
|
||||
setPetAge("1");
|
||||
setPetError(null);
|
||||
setShowForm(true);
|
||||
}
|
||||
@@ -231,6 +288,7 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
|
||||
setPetName(pet.petName);
|
||||
setSpecies(pet.species);
|
||||
setBreed(pet.breed || "");
|
||||
setPetAge(pet.petAge != null ? String(pet.petAge) : "1");
|
||||
setPetError(null);
|
||||
setShowForm(true);
|
||||
}
|
||||
@@ -257,7 +315,7 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ petName, species, breed: breed || null }),
|
||||
body: JSON.stringify({ petName, species, breed: breed || null, petAge: Number(petAge) }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -284,14 +342,19 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/v1/my-pets/${id}`, {
|
||||
const res = await fetch(`${API_BASE}/api/v1/my-pets/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
throw new Error(data?.message || `Failed to remove pet (${res.status})`);
|
||||
}
|
||||
loadPets();
|
||||
}
|
||||
|
||||
catch {
|
||||
catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,12 +387,14 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
|
||||
return <main className="auth-page"><p className="profile-loading">Loading…</p></main>;
|
||||
}
|
||||
|
||||
const displayName = [user.firstName, user.lastName].filter(Boolean).join(" ") || user.username;
|
||||
|
||||
const fields = [
|
||||
{label: "Full Name", value: user.fullName},
|
||||
{label: "First Name", value: user.firstName || "N/A"},
|
||||
{label: "Last Name", value: user.lastName || "N/A"},
|
||||
{label: "Username", value: user.username},
|
||||
{label: "Email", value: user.email},
|
||||
{label: "Phone", value: user.phone || "—"},
|
||||
{label: "Role", value: user.role},
|
||||
{label: "Phone", value: user.phone || "N/A"},
|
||||
...(user.storeName ? [{ label: "Store", value: user.storeName }] : []),
|
||||
];
|
||||
|
||||
@@ -337,14 +402,14 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
|
||||
<main className="profile-page-layout">
|
||||
<div className="profile-card">
|
||||
<div className="profile-avatar-circle">
|
||||
{user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt={user.fullName || user.username} className="profile-avatar-image" />
|
||||
{avatarObjectUrl ? (
|
||||
<img src={avatarObjectUrl} alt={displayName} className="profile-avatar-image" />
|
||||
) : (
|
||||
(user.fullName || user.username).charAt(0).toUpperCase()
|
||||
displayName.charAt(0).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="profile-name">{user.fullName || user.username}</h1>
|
||||
<h1 className="profile-name">{displayName}</h1>
|
||||
<span className="profile-role-badge">{user.role}</span>
|
||||
|
||||
<dl className="profile-fields">
|
||||
@@ -361,13 +426,23 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
|
||||
{profileError && <div className="appt-error">{profileError}</div>}
|
||||
{profileSuccess && <div className="appt-success">{profileSuccess}</div>}
|
||||
<label className="appt-label">
|
||||
Full Name
|
||||
First Name
|
||||
<input
|
||||
className="appt-input"
|
||||
type="text"
|
||||
value={profileForm.fullName}
|
||||
onChange={(e) => setProfileForm((current) => ({ ...current, fullName: e.target.value }))}
|
||||
maxLength={100}
|
||||
value={profileForm.firstName}
|
||||
onChange={(e) => setProfileForm((current) => ({ ...current, firstName: e.target.value }))}
|
||||
maxLength={50}
|
||||
/>
|
||||
</label>
|
||||
<label className="appt-label">
|
||||
Last Name
|
||||
<input
|
||||
className="appt-input"
|
||||
type="text"
|
||||
value={profileForm.lastName}
|
||||
onChange={(e) => setProfileForm((current) => ({ ...current, lastName: e.target.value }))}
|
||||
maxLength={50}
|
||||
/>
|
||||
</label>
|
||||
<label className="appt-label">
|
||||
@@ -390,6 +465,29 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
|
||||
maxLength={20}
|
||||
/>
|
||||
</label>
|
||||
<label className="appt-label">
|
||||
New Password
|
||||
<input
|
||||
className="appt-input"
|
||||
type="password"
|
||||
value={profileForm.password}
|
||||
onChange={(e) => setProfileForm((current) => ({ ...current, password: e.target.value }))}
|
||||
minLength={6}
|
||||
autoComplete="new-password"
|
||||
placeholder="Leave blank to keep current"
|
||||
/>
|
||||
</label>
|
||||
<label className="appt-label">
|
||||
Confirm New Password
|
||||
<input
|
||||
className="appt-input"
|
||||
type="password"
|
||||
value={profileForm.confirmPassword}
|
||||
onChange={(e) => setProfileForm((current) => ({ ...current, confirmPassword: e.target.value }))}
|
||||
autoComplete="new-password"
|
||||
placeholder="Leave blank to keep current"
|
||||
/>
|
||||
</label>
|
||||
<div className="profile-avatar-actions">
|
||||
<label className="profile-avatar-upload-btn">
|
||||
<input
|
||||
@@ -445,26 +543,45 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
|
||||
</label>
|
||||
<label className="appt-label">
|
||||
Species
|
||||
<input
|
||||
className="appt-input"
|
||||
type="text"
|
||||
<select
|
||||
className="appt-select"
|
||||
value={species}
|
||||
onChange={(e) => setSpecies(e.target.value)}
|
||||
onChange={(e) => { setSpecies(e.target.value); setBreed(""); }}
|
||||
required
|
||||
maxLength={50}
|
||||
placeholder="e.g. Dog, Cat, Bird"
|
||||
/>
|
||||
>
|
||||
<option value="">Select a species...</option>
|
||||
{Object.keys(SPECIES_BREEDS).map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="appt-label">
|
||||
Breed (optional)
|
||||
<input
|
||||
className="appt-input"
|
||||
type="text"
|
||||
Breed
|
||||
<select
|
||||
className="appt-select"
|
||||
value={breed}
|
||||
onChange={(e) => setBreed(e.target.value)}
|
||||
maxLength={50}
|
||||
placeholder="e.g. Golden Retriever"
|
||||
/>
|
||||
required
|
||||
disabled={!species}
|
||||
>
|
||||
<option value="">{species ? "Select a breed..." : "Select a species first"}</option>
|
||||
{(SPECIES_BREEDS[species] || []).map((b) => (
|
||||
<option key={b} value={b}>{b}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="appt-label">
|
||||
Age (years)
|
||||
<select
|
||||
className="appt-select"
|
||||
value={petAge}
|
||||
onChange={(e) => setPetAge(e.target.value)}
|
||||
required
|
||||
>
|
||||
{Array.from({ length: 20 }, (_, i) => i + 1).map((n) => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="profile-pet-form-actions">
|
||||
<button type="submit" className="appt-submit-btn" disabled={submitting}>
|
||||
@@ -512,13 +629,14 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
|
||||
<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>}
|
||||
{pet.petAge != null && <span className="profile-pet-card-detail">Age: {pet.petAge === 0 ? "< 1 yr" : `${pet.petAge} yr${pet.petAge !== 1 ? "s" : ""}`}</span>}
|
||||
</div>
|
||||
<div className="profile-pet-card-actions">
|
||||
<button type="button" className="profile-pet-edit-btn" onClick={() => openEditForm(pet)}>Edit</button>
|
||||
<button type="button" className="profile-pet-delete-btn" onClick={() => handleDeletePet(pet.customerPetId)}>Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="profile-pet-edit-btn" onClick={() => openEditForm(pet)}>Edit</button>
|
||||
<button type="button" className="profile-pet-delete-btn" onClick={() => handleDeletePet(pet.customerPetId)}>Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -22,12 +22,14 @@ function RegisterPage() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [form, setForm] = useState({
|
||||
fullName: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
username: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
password: "",
|
||||
confirmPassword: "",});
|
||||
confirmPassword: "",
|
||||
});
|
||||
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -47,7 +49,9 @@ function RegisterPage() {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await register({fullName: form.fullName,
|
||||
await register({
|
||||
firstName: form.firstName,
|
||||
lastName: form.lastName,
|
||||
username: form.username,
|
||||
email: form.email,
|
||||
phone: form.phone,
|
||||
@@ -74,12 +78,24 @@ function RegisterPage() {
|
||||
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<label className="auth-label">
|
||||
Full Name
|
||||
First Name
|
||||
<input
|
||||
className="auth-input"
|
||||
type="text"
|
||||
name="fullName"
|
||||
value={form.fullName}
|
||||
name="firstName"
|
||||
value={form.firstName}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="auth-label">
|
||||
Last Name
|
||||
<input
|
||||
className="auth-input"
|
||||
type="text"
|
||||
name="lastName"
|
||||
value={form.lastName}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
|
||||
@@ -43,7 +43,6 @@ export default function Footer() {
|
||||
<ul className="footer-links footer-contact">
|
||||
<li>(403) 123-4567</li>
|
||||
<li>support@leonspetstore.com</li>
|
||||
<li>123 Street Street, Calgary, Alberta, Canada</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { getStatusClass } from "@/components/petUtils";
|
||||
|
||||
export default function PetProfile({ petId, petName, petSpecies, petBreed, petAge, petStatus, petPrice, imageUrl }) {
|
||||
export default function PetProfile({ petId, petName, petSpecies, petBreed, petAge, petStatus, petPrice, imageUrl, storeId, storeName }) {
|
||||
return (
|
||||
<div className="pet-detail-card">
|
||||
<div className="pet-detail-image-wrapper">
|
||||
@@ -53,7 +53,10 @@ export default function PetProfile({ petId, petName, petSpecies, petBreed, petAg
|
||||
<p className="pet-detail-cta-text">
|
||||
Interested in adopting {petName}? Visit us in store or schedule an appointment.
|
||||
</p>
|
||||
<Link href={`/appointments?petId=${petId}`} className="pet-detail-cta-btn">
|
||||
<Link
|
||||
href={`/appointments?adoptionMode=true&petId=${petId}&petName=${encodeURIComponent(petName || "")}&petSpecies=${encodeURIComponent(petSpecies || "")}&petBreed=${encodeURIComponent(petBreed || "")}${storeId ? `&storeId=${storeId}` : ""}${storeName ? `&storeName=${encodeURIComponent(storeName)}` : ""}`}
|
||||
className="pet-detail-cta-btn"
|
||||
>
|
||||
Schedule an Appointment
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -79,15 +79,21 @@ export function AuthProvider({ children }) {
|
||||
return userInfo;
|
||||
}, [refreshUser]);
|
||||
|
||||
const register = useCallback(async ({ username, password, email, fullName, phone }) => {
|
||||
const register = useCallback(async ({ username, password, email, firstName, lastName, phone }) => {
|
||||
const res = await fetch("/api/v1/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password, email, fullName, phone }),
|
||||
body: JSON.stringify({ username, password, email, firstName, lastName, phone }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
if (data.errors && typeof data.errors === "object") {
|
||||
const fieldErrors = Object.entries(data.errors)
|
||||
.map(([field, msg]) => `${field}: ${msg}`)
|
||||
.join(", ");
|
||||
throw new Error(fieldErrors || data.message || "Registration failed");
|
||||
}
|
||||
throw new Error(data.message || "Registration failed");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user