diff --git a/src/App.tsx b/src/App.tsx index 0c9889f..dd35f77 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,11 +32,11 @@ function App() { {/* Public routes */} } />} /> - } />} /> } />} /> } />} /> {/* Protected routes */} + } />} />} /> } />} />} /> } />} />} /> } />} />} /> diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx index eb0784d..864b2f2 100644 --- a/src/components/ProtectedRoute.tsx +++ b/src/components/ProtectedRoute.tsx @@ -1,17 +1,16 @@ import React from "react"; -// import { Navigate } from "react-router"; import type { ReactNode } from "react"; +import { Navigate } from "react-router"; interface ProtectedRouteProps { element?: ReactNode; } export const ProtectedRoute: React.FC = ({ element }) => { - // const storedUser = localStorage.getItem("user"); - // - // if (!storedUser) { - // return ; - // } - // + const storedUser = localStorage.getItem("user"); + + if (!storedUser) { + return ; + } return <>{element}; }; diff --git a/src/pages/ExplorePage.tsx b/src/pages/ExplorePage.tsx index e011265..0cf2d2d 100644 --- a/src/pages/ExplorePage.tsx +++ b/src/pages/ExplorePage.tsx @@ -1,10 +1,30 @@ import { useEffect, useState } from "react"; import { MapContainer, TileLayer, Marker, Popup, Tooltip } from "react-leaflet"; import "leaflet/dist/leaflet.css"; -import { Input, Spin } from "antd"; +import { Input, Radio, Spin, Image } from "antd"; +import { InventoryService } from "../services/InventoryService"; +import { useNavigate } from "react-router"; +import L from "leaflet"; + +// Fix Leaflet marker icons in production +const defaultIcon = L.icon({ + iconUrl: + "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png", + shadowUrl: + "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png", + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41], +}); + +L.Marker.prototype.setIcon(defaultIcon); export function ExplorePage() { const [position, setPosition] = useState<[number, number] | null>(null); + const [menu, setMenu] = useState("map"); + const [items, setItems] = useState([]); + const navigate = useNavigate(); useEffect(() => { if (!navigator.geolocation) { @@ -19,30 +39,152 @@ export function ExplorePage() { (err) => { console.error(err); alert("Unable to retrieve your location"); - }, + } ); + + // eslint-disable-next-line react-hooks/immutability + getItems(); }, []); - if (!position) return
; + const getItems = async () => { + try { + const { data } = await InventoryService.getAvailableItems(); + setItems(data); + } catch (error) { + console.log("ERROR:", error); + } + }; + + if (!position) + return ( +
+ +
+ ); return (
-

Explore

-
- +

+ Explore +

+
+
- - + setMenu(e.target.value)} + defaultValue={menu} + optionType="button" + buttonStyle="solid" /> - - Your current location - - You are here - - - +
+ {menu === "map" ? ( + + + + + You are here + + + {items.map((item) => { + if (!item.location.latitude || !item.location.longitude) + return null; + const itemPosition: [number, number] = [ + item.location.latitude, + item.location.longitude, + ]; + return ( + + // navigate(`/inventory/${item.id}`), + // }} + > + +
+

+ {item.name} +

+

+ {item.description} +

+ +
+
+ + + {item.name} + + +
+ ); + })} +
+ ) : ( +
+ {items.map((item, index) => ( +
navigate(`/inventory/${item.id}`)} + > +
+ basic image 0 + ? item.imageUrls[0] + : undefined + } + preview={false} + fallback="https://plus.unsplash.com/premium_vector-1731660866636-d6ee4dcd6806?q=80&w=1480&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" + /> +
+
+

+ {item.name} +

+

+ {item.description} +

+
+
+ {item.quantity} +
+
+ ))} +
+ )}
); } diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index c7dabe3..c33df7e 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -6,7 +6,7 @@ export function HomePage() {
-

FairFree — Fair. Free. For Everyone.

+

FairFree — Fair and Free For Everyone.

A community-driven marketplace for sharing items and services with fairness and trust. Discover listings near you, give away what you no longer need, and connect with neighbors. diff --git a/src/pages/InventoryPage.tsx b/src/pages/InventoryPage.tsx index 8a063a3..6fcdf06 100644 --- a/src/pages/InventoryPage.tsx +++ b/src/pages/InventoryPage.tsx @@ -20,7 +20,8 @@ export function InventoryPage() { }, []); return ( -

+
+ {/* Header */}
@@ -29,16 +30,13 @@ export function InventoryPage() {
-
-
    -
  • Items
  • -
  • Description
  • -
  • Quantity
  • -
+ + {/* Scrollable content */} +
{items.map((item, index) => (
navigate(`/inventory/${item.id}`)} >
@@ -59,9 +57,9 @@ export function InventoryPage() {
))}
-
+
- diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index bbacae5..2064c55 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -50,7 +50,13 @@ export function LoginPage() { - or Sign up + + Don't have an account?{" "} + Sign Up + + + By clicking continue, you agree to our Terms of Service and Privacy Policy +
); diff --git a/src/pages/NotificationPage.tsx b/src/pages/NotificationPage.tsx index 5e239ba..b5973de 100644 --- a/src/pages/NotificationPage.tsx +++ b/src/pages/NotificationPage.tsx @@ -1,5 +1,7 @@ -import { List, Avatar, Divider, Spin } from "antd"; +import { List, Avatar, Divider, Spin, Button, Tooltip } from "antd"; +import { ArrowRightOutlined } from "@ant-design/icons"; import { useEffect, useState } from "react"; +import { useNavigate } from "react-router"; import { NotificationService } from "../services/NotificationService"; interface Notification { @@ -37,9 +39,16 @@ export function NotificationPage() { const [loading, setLoading] = useState(true); const [selectedType, setSelectedType] = useState("ALL"); + const navigate = useNavigate(); + const handleOnItemSelect = (itemId: number) => { - // navigate(`/items/${itemId}`); - alert(`(TODO) Navigate to item details page with ID: ${itemId}`); + // navigate to item details + navigate(`/inventory/${itemId}`); + }; + + const handleOpenIcon = (e: React.MouseEvent, itemId: number) => { + e.stopPropagation(); + handleOnItemSelect(itemId); }; useEffect(() => { @@ -122,6 +131,7 @@ export function NotificationPage() { px-4 py-1 rounded-full font-medium transition-colors duration-200 whitespace-nowrap + cursor-pointer ${isSelected ? "bg-blue-500 text-white shadow-md" : "bg-gray-200 text-gray-700 hover:bg-gray-300"} `}> {labels[type]} @@ -153,7 +163,18 @@ export function NotificationPage() { onMouseLeave={(e) => { if (item.isRead) e.currentTarget.style.backgroundColor = "white"; }} -> + actions={[ + +
} title={ -
+
{ - const { id } = useParams<{ id: string }>(); - const [item, setItem] = useState(null); - const [loading, setLoading] = useState(false); - - useEffect(() => { - if (!id) return; - (async () => { - if (loading) return; - setLoading(true); - try { - const { data } = await InventoryService.getItem(id); - setItem(data); - } catch (error) { - console.log("ERROR:", error); - } - })(); - }); - - if (!item) - return ( -
-
-
-
window.history.back()}> - -
-

Show item

-
-
- -
- -
- ); + const { id } = useParams<{ id: string }>(); + const [item, setItem] = useState(null); + const [_, setLoading] = useState(false); + + const [error, setError] = useState(""); + const [showErrorDetails, setShowErrorDetails] = useState(false); + const navigate = useNavigate(); + + const loggedInUser = JSON.parse(localStorage.getItem("user") || "{}"); + + const fetchItem = async () => { + if (!id) return; + setLoading(true); + try { + const { data } = await InventoryService.getItem(id); + console.log("Fetched item data:", data); + setItem(data); + } + catch (error) { + console.log("ERROR:", error); + setError("Failed to fetch item details."); + } + finally { + setLoading(false); + } + }; - const imageSrc = item.imageUrls && item.imageUrls.length > 0 ? item.imageUrls[0] : undefined; - const expiryText = item.neverExpires ? "Never expires" : item.expiresAfterDays ? `Expires after ${item.expiresAfterDays} days` : "No expiry info"; + useEffect(() => { + fetchItem(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + if (!item) return ( -
-
-
-
window.history.back()}> - -
-

Show item

-
+
+
+
+
window.history.back()}> + +
+

Show item

+
+
+ +
+ +
+ ); + + const expiryText = item.neverExpires + ? "Never expires" + : item.expiresAfterDays + ? `Expires after ${item.expiresAfterDays} days` + : "No expiry info"; + + const handleClaim = async () => { + if (!item?.id) return; + setError(""); + try { + await InventoryService.claimItem(item.id); + fetchItem(); + } + catch (error) { + console.log("Claim error:", error); + setError("Failed to claim the item"); + } + }; + const isOwner = item.owner?.id === loggedInUser.id; + + // Find a pending claim for the logged-in user (only if they are NOT the owner) + const myClaim = !isOwner + ? item.claims?.find(c => c.userId === loggedInUser.id && c.status === "PENDING") + : undefined; + + const handleCancel = async () => { + if (!myClaim) return; + setError(""); + try { + await InventoryService.cancelClaim(myClaim.id); + fetchItem(); + } + catch (error) { + console.log("Cancel claim error:", error); + setError("Failed to cancel the claim."); + } + }; + + const handleApprove = async (claimId: number) => { + setError(""); + try { + await InventoryService.approveClaim(claimId); + fetchItem(); + } + catch (error) { + console.log("Approve claim error:", error); + setError("Failed to approve the claim."); + } + }; + + const handleDelete = async () => { + if (!item?.id) return; + setError(""); + try { + await InventoryService.deleteItem(item.id); + navigate('/inventory'); + } + catch (error) { + console.log("Delete error:", error); + setError("Failed to delete the item."); + } + }; + + return ( +
+
+
+
window.history.back()}> + +
+

Details of the selected item.

+
+
+ +
+ + {item.name} + + } + extra={ + isOwner && ( + + ) + } + > + + +
+ {item.imageUrls && item.imageUrls.length > 0 ? ( +
+
+ {item.imageUrls.map((imageUrl, index) => ( +
+ {`${item.name} +
+ ))} +
+
+ ) : ( +
+ 🖼️ + No image available
- + )} + + 📦 Quantity: {item.quantity ?? 0} + ⏰ {expiryText} +
- - {item.name} - - } + + + + + {item.description || "-"} + + {item.location ? ( + + {/* Address line */} +
+ 🏠 + {item.location.address} +
+ + {/* City, State, Country */} +
+ 📍 + + {[item.location.city, item.location.state, item.location.country].filter(Boolean).join(", ")} + +
+ + {/* See on Map link */} + {item.location.latitude != null && item.location.longitude != null && ( + - ); + )} +
+ ) : ( + "-" + )} +
+ + {item.owner && ( + + + + 👤 Owner / Donator + {item.owner.fullName} + {item.owner.email} + + Contact the owner for any inquiries regarding this donation + + + + + )} + { item.receiver && ( + + + + ✓ Allocated Recipient + {item.receiver.fullName} + {item.receiver.email} + + ⚠ This item has already been donated and is not available + + + + + )} + + + {item.claims ? item.claims.length : 0} + {item.claims && item.claims.length > 0 && ( + + {item.claims.filter(c => c.status === "PENDING").length} total pending claims + + )} + + + {error && ( + + +
+
+
+ } + style={{ marginTop: 4 }} + /> + + )} + + {/* Error Details Modal - Only shows when info icon is clicked */} + {showErrorDetails && ( + setShowErrorDetails(false)} + closable={true} + footer={[ + , + ]} + > +
+ Possible reasons: +
+ {[ + "✓ You have already claimed this item", + "⏰ Your session has expired, please log in again", + "📋 Your previous claim was cancelled - you cannot claim it again", + "✓ Someone else has already received this item", + "📦 The item is no longer available", + "⏰ This item has expired", + "🌐 Network connectivity issue", + "🔒 You don't have permission to claim this item", + ].map((reason: string, idx: number) => ( +
+ {reason} +
+ ))} +
+
+ + 💬 If you believe this is an error, please contact the item owner or support team. + +
+
+
+ )} + + + {/* Claim button, */} + {!isOwner && item.status !== "DONATED" && !myClaim && ( + + )} + {/* Cancel claim button */} + {myClaim && ( + + )} + {/* Approve buttons for owner */} + {isOwner && (item.claims ?? []).filter((c) => c.status === "PENDING").length > 0 && ( +
+ Pending claims to approve: +
+ {(item.claims ?? []).filter((c) => c.status === "PENDING").map((claim) => ( + +
+ {claim.userName} + {claim.message && {claim.message}} +
+ +
+ ))} +
+
+ )} +
+
+ + + + + + {/* Error Details Modal - Only shows when info icon is clicked */} + {showErrorDetails && ( + setShowErrorDetails(false)} + closable={true} + footer={[ + , + ]} + > +
+ Possible reasons: +
+ {[ + "1. You have already claimed this item", + "2. Your previous claim was cancelled - you cannot claim it again", + "3. Someone else has already received this item", + "4. The item is no longer available", + "5. This item has expired", + "6. Network connectivity issue", + "7. You don't have permission to claim this item", + ].map((reason: string, idx: number) => ( +
+ {reason} +
+ ))} +
+
+ + 💬 If you believe this is an error, please contact the item owner or support team. + +
+
+
+ )} +
+ ); }; export default ProductShowPage; diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index e06f12c..f89f781 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -1,5 +1,5 @@ import { Avatar, Button, Divider } from "antd"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import { useNavigate } from "react-router"; export function ProfilePage() { @@ -9,12 +9,6 @@ export function ProfilePage() { return stored ? JSON.parse(stored) : null; }); - useEffect(() => { - if (!user) { - navigate("/signin"); - } - }, [user, navigate]); - const handleSignOut = () => { localStorage.removeItem("user"); localStorage.removeItem("auth_token"); @@ -42,42 +36,30 @@ export function ProfilePage() { {user?.email}
- -
-
-
- {/* placeholder */}0 -
-
- Donations -
-
-
-
- {/* placeholder */}0 +
+
+
+ {/* placeholder */}0 +
+
+ Donations +
-
- Claims +
+
+ {/* placeholder */}0 +
+
+ Claims +
-
-
- {/* placeholder */}0 -
-
- Friends -
-
-
-
diff --git a/src/pages/RegisterPage.tsx b/src/pages/RegisterPage.tsx index d5bd615..c4a732a 100644 --- a/src/pages/RegisterPage.tsx +++ b/src/pages/RegisterPage.tsx @@ -2,7 +2,7 @@ import { Button, Form, Input, message } from "antd"; import { NavLink } from "react-router"; import { AuthService } from "../services/AuthService"; import { useNavigate } from "react-router"; -import { useState } from "react"; +import { useEffect, useState } from "react"; type RegisterType = { email?: string; @@ -14,6 +14,15 @@ export function RegisterPage() { const navigate = useNavigate(); const [loading, setLoading] = useState(false); + useEffect(() => { + const stored = localStorage.getItem("auth_token"); + if (stored) { + navigate("/profile"); + return; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const onSubmit = async (values: RegisterType) => { try { if (loading) return; @@ -48,10 +57,14 @@ export function RegisterPage() { + + Already have an account?{" "} + Sign in + By clicking continue, you agree to our Terms of Service and Privacy Policy diff --git a/src/services/InventoryService.ts b/src/services/InventoryService.ts index d68e73c..e688845 100644 --- a/src/services/InventoryService.ts +++ b/src/services/InventoryService.ts @@ -10,6 +10,12 @@ export const InventoryService = { headers: data instanceof FormData ? { "Content-Type": "multipart/form-data" } : {}, }); }, + getAvailableItems: (): Promise> => { + return service({ + url: "/api/v1/items/available", + method: "get", + }); + }, getMyItems: (): Promise> => { return service({ url: "/api/v1/items/my-items", @@ -22,4 +28,28 @@ export const InventoryService = { method: "get", }); }, + claimItem: (itemId: number): Promise> => { + return service({ + url: `/api/v1/items/${itemId}/claim`, + method: "post", + }); + }, + cancelClaim: (claimId: number): Promise> => { + return service({ + url: `/api/v1/items/cancel/claims/${claimId}`, + method: "post", + }); + }, + approveClaim: (claimId: number): Promise> => { + return service({ + url: `/api/v1/items/approve/claims/${claimId}`, + method: "post", + }); + }, + deleteItem: (itemId: number): Promise> => { + return service({ + url: `/api/v1/items/${itemId}`, + method: "delete", + }); + }, };