diff --git a/android/app/src/main/java/com/example/petstoremobile/adapters/MessageAdapter.java b/android/app/src/main/java/com/example/petstoremobile/adapters/MessageAdapter.java index 0c3a51a0..eb9d9c19 100644 --- a/android/app/src/main/java/com/example/petstoremobile/adapters/MessageAdapter.java +++ b/android/app/src/main/java/com/example/petstoremobile/adapters/MessageAdapter.java @@ -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 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 { + 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(); }); diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/AnalyticsFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/AnalyticsFragment.java index b8656b60..89aeb098 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/AnalyticsFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/AnalyticsFragment.java @@ -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 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 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 diff --git a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/SaleDetailFragment.java b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/SaleDetailFragment.java index 8a4dff16..4963b272 100644 --- a/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/SaleDetailFragment.java +++ b/android/app/src/main/java/com/example/petstoremobile/fragments/listfragments/detailfragments/SaleDetailFragment.java @@ -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() { diff --git a/android/app/src/main/java/com/example/petstoremobile/utils/InputValidator.java b/android/app/src/main/java/com/example/petstoremobile/utils/InputValidator.java index 0d45ad12..9ad2d5f4 100644 --- a/android/app/src/main/java/com/example/petstoremobile/utils/InputValidator.java +++ b/android/app/src/main/java/com/example/petstoremobile/utils/InputValidator.java @@ -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; } diff --git a/android/app/src/main/java/com/example/petstoremobile/viewmodels/AnalyticsViewModel.java b/android/app/src/main/java/com/example/petstoremobile/viewmodels/AnalyticsViewModel.java index 721dad5f..4a9b94d9 100644 --- a/android/app/src/main/java/com/example/petstoremobile/viewmodels/AnalyticsViewModel.java +++ b/android/app/src/main/java/com/example/petstoremobile/viewmodels/AnalyticsViewModel.java @@ -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 = new MutableLiveData<>(); private final MutableLiveData isLoading = new MutableLiveData<>(false); private final MutableLiveData errorMessage = new MutableLiveData<>(); private final MutableLiveData> availablePaymentMethods = new MutableLiveData<>(new ArrayList<>()); + private final MutableLiveData> availableStores = new MutableLiveData<>(new ArrayList<>()); private List 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 getAnalyticsData() { return analyticsData; } public LiveData getIsLoading() { return isLoading; } public LiveData getErrorMessage() { return errorMessage; } public LiveData> getAvailablePaymentMethods() { return availablePaymentMethods; } + public LiveData> 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 filtered = filterSales(cachedSales, currentFilter); + List 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 filtered = filterSales(salesForMode, currentFilter); computeAnalytics(filtered, currentFilter); } + private void deriveStores() { + java.util.Set stores = new java.util.TreeSet<>(); + for (SaleDTO s : cachedSales) { + if (s.getStoreName() != null && !s.getStoreName().isEmpty()) { + stores.add(s.getStoreName()); + } + } + List result = new ArrayList<>(); + result.add("All Stores"); + result.addAll(stores); + availableStores.setValue(result); + } + private void derivePaymentMethods() { java.util.Set methods = new java.util.TreeSet<>(); for (SaleDTO s : cachedSales) { diff --git a/android/app/src/main/res/layout/fragment_analytics.xml b/android/app/src/main/res/layout/fragment_analytics.xml index 19a7a51b..9e59b7d7 100644 --- a/android/app/src/main/res/layout/fragment_analytics.xml +++ b/android/app/src/main/res/layout/fragment_analytics.xml @@ -45,6 +45,40 @@ + + + + + + + + + diff --git a/desktop/src/main/resources/org/example/petshopdesktop/modelviews/analytics-view.fxml b/desktop/src/main/resources/org/example/petshopdesktop/modelviews/analytics-view.fxml index 9aeb4fe9..4f86758a 100644 --- a/desktop/src/main/resources/org/example/petshopdesktop/modelviews/analytics-view.fxml +++ b/desktop/src/main/resources/org/example/petshopdesktop/modelviews/analytics-view.fxml @@ -8,6 +8,7 @@ + @@ -30,6 +31,16 @@ + + + + + + + + + + - )} - {petsToShow.length === 0 ? ( -

{noPetsMessage}

- ) : isAdoptionService ? ( -
- {petsToShow.map((p) => ( - - ))} -
- ) : ( -
- {petsToShow.map((p) => ( - - ))} -
- )} - - )} - + + {success &&
{success}
} + + )} + + )} ) : null}
-

{canBookAppointments ? "Your Appointments" : "Appointments"}

- {loadingAppointments ? ( +

+ {adoptionMode ? "Your Adoptions" : canBookAppointments ? "Your Appointments" : "Appointments"} +

+ {adoptionMode ? ( + loadingAdoptions ? ( +

Loading adoptions...

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

No adoption appointments yet.

+ ) : ( +
+ {adoptions.map((a) => ( +
+
+ {a.petName} + + {a.adoptionStatus} + +
+
+ {a.sourceStoreName} + {a.adoptionDate} +
+ {a.adoptionStatus?.toLowerCase() === "pending" && ( +
+ +
+ )} +
+ ))} +
+ ) + ) : loadingAppointments ? (

Loading appointments...

) : appointments.length === 0 ? (

No appointments yet.

@@ -797,6 +1013,18 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN"; Pets: {a.customerPetNames.join(", ")}
)} + {a.appointmentStatus?.toLowerCase() === "booked" && ( +
+ +
+ )} ))} diff --git a/web/app/contact/page.js b/web/app/contact/page.js index 85fba175..19295ebd 100644 --- a/web/app/contact/page.js +++ b/web/app/contact/page.js @@ -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 (
@@ -44,28 +37,40 @@ export default function ContactPage() {

Store Locations

-
- {LOCATIONS.map((location) => ( -
-

{location.name}

-

{location.address}

-

{location.phone}

-

{location.email}

-
- ))} -
-
-
-

Store Personnel

-
- {PERSONNEL.map((person) => ( -
-

{person.name}

-

{person.role}

-
- ))} -
+ {loading &&

Loading locations...

} + + {error &&

Failed to load locations: {error}

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

No store locations found.

+ )} + + {!loading && !error && locations.length > 0 && ( +
+ {locations.map((location) => ( +
+
+ {location.storeName} { + e.currentTarget.onerror = null; + e.currentTarget.src = "/images/pet-placeholder.png"; + }} + /> +
+
+

{location.storeName}

+

{location.address}

+

{location.phone}

+

{location.email}

+
+
+ ))} +
+ )}
diff --git a/web/app/favicon.ico b/web/app/favicon.ico index 718d6fea..5e11bd61 100644 Binary files a/web/app/favicon.ico and b/web/app/favicon.ico differ diff --git a/web/app/globals.css b/web/app/globals.css index dd607b01..2dbbe14a 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -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; diff --git a/web/app/profile/page.js b/web/app/profile/page.js index cf2f0d2d..ddb23d62 100644 --- a/web/app/profile/page.js +++ b/web/app/profile/page.js @@ -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

Loading…

; } + 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") {
- {user.avatarUrl ? ( - {user.fullName + {avatarObjectUrl ? ( + {displayName} ) : ( - (user.fullName || user.username).charAt(0).toUpperCase() + displayName.charAt(0).toUpperCase() )}
-

{user.fullName || user.username}

+

{displayName}

{user.role}
@@ -361,13 +426,23 @@ if (user?.role === "CUSTOMER" || user?.role === "ADMIN") { {profileError &&
{profileError}
} {profileSuccess &&
{profileSuccess}
} + + +
- ))} + + +
+ + ))} )} diff --git a/web/app/register/page.js b/web/app/register/page.js index 17f49672..3b3ba59b 100644 --- a/web/app/register/page.js +++ b/web/app/register/page.js @@ -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() {
+ +