started dockerfiles and itemrequest
This commit is contained in:
303
frontend/src/pages/CreateItemRequest.tsx
Normal file
303
frontend/src/pages/CreateItemRequest.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { itemRequestAPI } from '../services/api';
|
||||
import AddressAutocomplete from '../components/AddressAutocomplete';
|
||||
|
||||
const CreateItemRequest: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
address1: '',
|
||||
address2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zipCode: '',
|
||||
country: 'US',
|
||||
latitude: undefined as number | undefined,
|
||||
longitude: undefined as number | undefined,
|
||||
maxPricePerHour: '',
|
||||
maxPricePerDay: '',
|
||||
preferredStartDate: '',
|
||||
preferredEndDate: '',
|
||||
isFlexibleDates: true
|
||||
});
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
if (type === 'checkbox') {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
setFormData(prev => ({ ...prev, [name]: checked }));
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddressChange = (value: string, lat?: number, lon?: number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
address1: value,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
city: prev.city,
|
||||
state: prev.state,
|
||||
zipCode: prev.zipCode,
|
||||
country: prev.country
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const requestData = {
|
||||
...formData,
|
||||
maxPricePerHour: formData.maxPricePerHour ? parseFloat(formData.maxPricePerHour) : null,
|
||||
maxPricePerDay: formData.maxPricePerDay ? parseFloat(formData.maxPricePerDay) : null,
|
||||
preferredStartDate: formData.preferredStartDate || null,
|
||||
preferredEndDate: formData.preferredEndDate || null
|
||||
};
|
||||
|
||||
await itemRequestAPI.createItemRequest(requestData);
|
||||
navigate('/my-requests');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to create item request');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="alert alert-warning" role="alert">
|
||||
Please log in to create item requests.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-8">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2 className="mb-0">Request an Item</h2>
|
||||
<p className="text-muted mb-0">Can't find what you need? Request it and let others know!</p>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-3">
|
||||
<label htmlFor="title" className="form-label">What are you looking for? *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="title"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
placeholder="e.g., Power drill, Camera lens, Camping tent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="description" className="form-label">Description *</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
id="description"
|
||||
name="description"
|
||||
rows={4}
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
placeholder="Describe what you need it for, any specific requirements, condition preferences, etc."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="row mb-3">
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="maxPricePerDay" className="form-label">Max Price per Day</label>
|
||||
<div className="input-group">
|
||||
<span className="input-group-text">$</span>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
id="maxPricePerDay"
|
||||
name="maxPricePerDay"
|
||||
value={formData.maxPricePerDay}
|
||||
onChange={handleChange}
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="maxPricePerHour" className="form-label">Max Price per Hour</label>
|
||||
<div className="input-group">
|
||||
<span className="input-group-text">$</span>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
id="maxPricePerHour"
|
||||
name="maxPricePerHour"
|
||||
value={formData.maxPricePerHour}
|
||||
onChange={handleChange}
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Address</label>
|
||||
<AddressAutocomplete
|
||||
value={formData.address1}
|
||||
onChange={handleAddressChange}
|
||||
placeholder="Enter your address or area"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="row mb-3">
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="address2" className="form-label">Apartment, suite, etc.</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="address2"
|
||||
name="address2"
|
||||
value={formData.address2}
|
||||
onChange={handleChange}
|
||||
placeholder="Apt 2B, Suite 100, etc."
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="city" className="form-label">City</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="city"
|
||||
name="city"
|
||||
value={formData.city}
|
||||
onChange={handleChange}
|
||||
placeholder="City"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row mb-3">
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="state" className="form-label">State</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="state"
|
||||
name="state"
|
||||
value={formData.state}
|
||||
onChange={handleChange}
|
||||
placeholder="State"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="zipCode" className="form-label">ZIP Code</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="zipCode"
|
||||
name="zipCode"
|
||||
value={formData.zipCode}
|
||||
onChange={handleChange}
|
||||
placeholder="12345"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="form-check">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="isFlexibleDates"
|
||||
name="isFlexibleDates"
|
||||
checked={formData.isFlexibleDates}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="isFlexibleDates">
|
||||
I'm flexible with dates
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!formData.isFlexibleDates && (
|
||||
<div className="row mb-3">
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="preferredStartDate" className="form-label">Preferred Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="form-control"
|
||||
id="preferredStartDate"
|
||||
name="preferredStartDate"
|
||||
value={formData.preferredStartDate}
|
||||
onChange={handleChange}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="preferredEndDate" className="form-label">Preferred End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="form-control"
|
||||
id="preferredEndDate"
|
||||
name="preferredEndDate"
|
||||
value={formData.preferredEndDate}
|
||||
onChange={handleChange}
|
||||
min={formData.preferredStartDate || new Date().toISOString().split('T')[0]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="d-grid gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Creating Request...' : 'Create Request'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateItemRequest;
|
||||
@@ -133,6 +133,7 @@ const Home: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* How It Works - For Renters */}
|
||||
<div className="py-5">
|
||||
<div className="container-fluid" style={{ maxWidth: '1800px' }}>
|
||||
|
||||
362
frontend/src/pages/ItemRequestDetail.tsx
Normal file
362
frontend/src/pages/ItemRequestDetail.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { itemRequestAPI } from '../services/api';
|
||||
import { ItemRequest, ItemRequestResponse } from '../types';
|
||||
import RequestResponseModal from '../components/RequestResponseModal';
|
||||
|
||||
const ItemRequestDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [request, setRequest] = useState<ItemRequest | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showResponseModal, setShowResponseModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchRequest();
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const fetchRequest = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await itemRequestAPI.getItemRequest(id!);
|
||||
setRequest(response.data);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to fetch request details');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResponseSubmitted = () => {
|
||||
fetchRequest();
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'open':
|
||||
return 'success';
|
||||
case 'fulfilled':
|
||||
return 'primary';
|
||||
case 'closed':
|
||||
return 'secondary';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
const getResponseStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'warning';
|
||||
case 'accepted':
|
||||
return 'success';
|
||||
case 'declined':
|
||||
return 'danger';
|
||||
case 'expired':
|
||||
return 'secondary';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return 'Not specified';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const getLocationString = () => {
|
||||
if (!request) return '';
|
||||
const parts = [];
|
||||
if (request.city) parts.push(request.city);
|
||||
if (request.state) parts.push(request.state);
|
||||
return parts.join(', ') || 'Location not specified';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="text-center">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !request) {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error || 'Request not found'}
|
||||
</div>
|
||||
<button className="btn btn-secondary" onClick={() => navigate(-1)}>
|
||||
Go Back
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isOwner = user?.id === request.requesterId;
|
||||
const canRespond = user && !isOwner && request.status === 'open';
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="row">
|
||||
<div className="col-lg-8">
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div className="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h1 className="card-title">{request.title}</h1>
|
||||
<p className="text-muted mb-2">
|
||||
Requested by {request.requester?.firstName || 'Unknown'} {request.requester?.lastName || ''}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`badge bg-${getStatusColor(request.status)} fs-6`}>
|
||||
{request.status.charAt(0).toUpperCase() + request.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h5>Description</h5>
|
||||
<p className="card-text">{request.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="row mb-4">
|
||||
<div className="col-md-6">
|
||||
<h6><i className="bi bi-geo-alt me-2"></i>Location</h6>
|
||||
<p className="text-muted">{getLocationString()}</p>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<h6><i className="bi bi-calendar me-2"></i>Timeline</h6>
|
||||
<p className="text-muted">
|
||||
{request.isFlexibleDates ? (
|
||||
'Flexible dates'
|
||||
) : (
|
||||
`${formatDate(request.preferredStartDate)} - ${formatDate(request.preferredEndDate)}`
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(request.maxPricePerDay || request.maxPricePerHour) && (
|
||||
<div className="mb-4">
|
||||
<h6><i className="bi bi-currency-dollar me-2"></i>Budget</h6>
|
||||
<div className="text-muted">
|
||||
{request.maxPricePerDay && <div>Up to ${request.maxPricePerDay} per day</div>}
|
||||
{request.maxPricePerHour && <div>Up to ${request.maxPricePerHour} per hour</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<small className="text-muted">
|
||||
Created on {new Date(request.createdAt).toLocaleDateString()} •
|
||||
{request.responseCount || 0} response{request.responseCount !== 1 ? 's' : ''}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{canRespond && (
|
||||
<div className="d-grid">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowResponseModal(true)}
|
||||
>
|
||||
<i className="bi bi-reply me-2"></i>
|
||||
Respond to Request
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOwner && (
|
||||
<div className="d-flex gap-2">
|
||||
<Link to={`/my-requests`} className="btn btn-outline-primary">
|
||||
<i className="bi bi-arrow-left me-2"></i>
|
||||
Back to My Requests
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{request.responses && request.responses.length > 0 && (
|
||||
<div className="card mt-4">
|
||||
<div className="card-body">
|
||||
<h5 className="mb-4">Responses ({request.responses.length})</h5>
|
||||
|
||||
{request.responses.map((response: ItemRequestResponse) => (
|
||||
<div key={response.id} className="border-bottom pb-4 mb-4 last:border-bottom-0 last:pb-0 last:mb-0">
|
||||
<div className="d-flex justify-content-between align-items-start mb-3">
|
||||
<div className="d-flex align-items-center">
|
||||
<div className="me-3">
|
||||
<div className="bg-light rounded-circle d-flex align-items-center justify-content-center" style={{ width: '40px', height: '40px' }}>
|
||||
<i className="bi bi-person"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{response.responder?.firstName || 'Unknown'} {response.responder?.lastName || ''}</strong>
|
||||
<br />
|
||||
<small className="text-muted">
|
||||
{new Date(response.createdAt).toLocaleDateString()} at {new Date(response.createdAt).toLocaleTimeString()}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`badge bg-${getResponseStatusColor(response.status)}`}>
|
||||
{response.status.charAt(0).toUpperCase() + response.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="ms-5">
|
||||
<p className="mb-3">{response.message}</p>
|
||||
|
||||
{(response.offerPricePerDay || response.offerPricePerHour) && (
|
||||
<div className="mb-2">
|
||||
<strong>Offered Price:</strong>
|
||||
<div className="text-muted">
|
||||
{response.offerPricePerDay && <div>${response.offerPricePerDay} per day</div>}
|
||||
{response.offerPricePerHour && <div>${response.offerPricePerHour} per hour</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(response.availableStartDate || response.availableEndDate) && (
|
||||
<div className="mb-2">
|
||||
<strong>Availability:</strong>
|
||||
<div className="text-muted">
|
||||
{formatDate(response.availableStartDate)} - {formatDate(response.availableEndDate)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{response.contactInfo && (
|
||||
<div className="mb-2">
|
||||
<strong>Contact:</strong>
|
||||
<span className="text-muted ms-2">{response.contactInfo}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{response.existingItem && (
|
||||
<div className="mb-2">
|
||||
<strong>Related Item:</strong>
|
||||
<Link to={`/items/${response.existingItem.id}`} className="ms-2 text-decoration-none">
|
||||
{response.existingItem.name}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-lg-4">
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<h6>Request Summary</h6>
|
||||
|
||||
<div className="mb-3">
|
||||
<strong>Status:</strong>
|
||||
<span className={`badge bg-${getStatusColor(request.status)} ms-2`}>
|
||||
{request.status.charAt(0).toUpperCase() + request.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<strong>Requested by:</strong>
|
||||
<div className="mt-1">
|
||||
<Link to={`/users/${request.requester?.id}`} className="text-decoration-none">
|
||||
{request.requester?.firstName || 'Unknown'} {request.requester?.lastName || ''}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(request.maxPricePerDay || request.maxPricePerHour) && (
|
||||
<div className="mb-3">
|
||||
<strong>Budget Range:</strong>
|
||||
<div className="text-muted mt-1">
|
||||
{request.maxPricePerDay && <div>≤ ${request.maxPricePerDay}/day</div>}
|
||||
{request.maxPricePerHour && <div>≤ ${request.maxPricePerHour}/hour</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3">
|
||||
<strong>Timeline:</strong>
|
||||
<div className="text-muted mt-1">
|
||||
{request.isFlexibleDates ? (
|
||||
'Flexible dates'
|
||||
) : (
|
||||
<div>
|
||||
<div>From: {formatDate(request.preferredStartDate)}</div>
|
||||
<div>To: {formatDate(request.preferredEndDate)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<strong>Location:</strong>
|
||||
<div className="text-muted mt-1">{getLocationString()}</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<strong>Responses:</strong>
|
||||
<div className="text-muted mt-1">{request.responseCount || 0} received</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div className="d-grid gap-2">
|
||||
{canRespond ? (
|
||||
<button
|
||||
className="btn btn-success"
|
||||
onClick={() => setShowResponseModal(true)}
|
||||
>
|
||||
<i className="bi bi-reply me-2"></i>
|
||||
Respond to Request
|
||||
</button>
|
||||
) : user && !isOwner ? (
|
||||
<div className="text-muted text-center">
|
||||
<small>This request is {request.status}</small>
|
||||
</div>
|
||||
) : !user ? (
|
||||
<div className="text-center">
|
||||
<Link to="/login" className="btn btn-outline-primary">
|
||||
Log in to Respond
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<i className="bi bi-arrow-left me-2"></i>
|
||||
Go Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RequestResponseModal
|
||||
show={showResponseModal}
|
||||
onHide={() => setShowResponseModal(false)}
|
||||
request={request}
|
||||
onResponseSubmitted={handleResponseSubmitted}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ItemRequestDetail;
|
||||
211
frontend/src/pages/ItemRequests.tsx
Normal file
211
frontend/src/pages/ItemRequests.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { itemRequestAPI } from '../services/api';
|
||||
import { ItemRequest } from '../types';
|
||||
import ItemRequestCard from '../components/ItemRequestCard';
|
||||
|
||||
const ItemRequests: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const [requests, setRequests] = useState<ItemRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalRequests, setTotalRequests] = useState(0);
|
||||
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
status: 'open'
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchRequests();
|
||||
}, [currentPage, filters]);
|
||||
|
||||
const fetchRequests = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await itemRequestAPI.getItemRequests({
|
||||
page: currentPage,
|
||||
limit: 20,
|
||||
...filters
|
||||
});
|
||||
|
||||
setRequests(response.data.requests);
|
||||
setTotalPages(response.data.totalPages);
|
||||
setTotalRequests(response.data.totalRequests);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to fetch item requests');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFilters(prev => ({ ...prev, [name]: value }));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
fetchRequests();
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1>Item Requests</h1>
|
||||
<p className="text-muted">Help others by fulfilling their item requests</p>
|
||||
</div>
|
||||
{user && (
|
||||
<Link to="/create-item-request" className="btn btn-primary">
|
||||
<i className="bi bi-plus-circle me-2"></i>
|
||||
Create Request
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="row mb-4">
|
||||
<div className="col-md-8">
|
||||
<form onSubmit={handleSearch}>
|
||||
<div className="input-group">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Search item requests..."
|
||||
name="search"
|
||||
value={filters.search}
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
<button className="btn btn-outline-secondary" type="submit">
|
||||
<i className="bi bi-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<select
|
||||
className="form-select"
|
||||
name="status"
|
||||
value={filters.status}
|
||||
onChange={handleFilterChange}
|
||||
>
|
||||
<option value="open">Open Requests</option>
|
||||
<option value="fulfilled">Fulfilled Requests</option>
|
||||
<option value="closed">Closed Requests</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="d-flex justify-content-between align-items-center mb-3">
|
||||
<p className="text-muted mb-0">
|
||||
Showing {requests.length} of {totalRequests} requests
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{requests.length === 0 ? (
|
||||
<div className="text-center py-5">
|
||||
<i className="bi bi-inbox display-1 text-muted"></i>
|
||||
<h3 className="mt-3">No requests found</h3>
|
||||
<p className="text-muted">
|
||||
{filters.search
|
||||
? "Try adjusting your search terms or filters."
|
||||
: "Be the first to create an item request!"
|
||||
}
|
||||
</p>
|
||||
{user && !filters.search && (
|
||||
<Link to="/create-item-request" className="btn btn-primary">
|
||||
Create First Request
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="row g-4">
|
||||
{requests.map((request) => (
|
||||
<div key={request.id} className="col-md-6 col-lg-4">
|
||||
<ItemRequestCard request={request} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<nav className="mt-4" aria-label="Page navigation">
|
||||
<ul className="pagination justify-content-center">
|
||||
<li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}>
|
||||
<button
|
||||
className="page-link"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
</li>
|
||||
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
const page = i + Math.max(1, currentPage - 2);
|
||||
if (page > totalPages) return null;
|
||||
|
||||
return (
|
||||
<li key={page} className={`page-item ${currentPage === page ? 'active' : ''}`}>
|
||||
<button
|
||||
className="page-link"
|
||||
onClick={() => handlePageChange(page)}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
<li className={`page-item ${currentPage === totalPages ? 'disabled' : ''}`}>
|
||||
<button
|
||||
className="page-link"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!user && (
|
||||
<div className="mt-4">
|
||||
<div className="alert alert-info" role="alert">
|
||||
<i className="bi bi-info-circle me-2"></i>
|
||||
<Link to="/login" className="alert-link">Log in</Link> to create your own item requests or respond to existing ones.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ItemRequests;
|
||||
265
frontend/src/pages/MyRequests.tsx
Normal file
265
frontend/src/pages/MyRequests.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { itemRequestAPI } from '../services/api';
|
||||
import { ItemRequest, ItemRequestResponse } from '../types';
|
||||
import ConfirmationModal from '../components/ConfirmationModal';
|
||||
|
||||
const MyRequests: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [requests, setRequests] = useState<ItemRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteModal, setDeleteModal] = useState<{ show: boolean; requestId: string | null }>({
|
||||
show: false,
|
||||
requestId: null
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchMyRequests();
|
||||
} else {
|
||||
navigate('/login');
|
||||
}
|
||||
}, [user, navigate]);
|
||||
|
||||
const fetchMyRequests = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await itemRequestAPI.getMyRequests();
|
||||
setRequests(response.data);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to fetch your requests');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteModal.requestId) return;
|
||||
|
||||
try {
|
||||
await itemRequestAPI.deleteItemRequest(deleteModal.requestId);
|
||||
setRequests(prev => prev.filter(req => req.id !== deleteModal.requestId));
|
||||
setDeleteModal({ show: false, requestId: null });
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to delete request');
|
||||
}
|
||||
};
|
||||
|
||||
const handleResponseStatusUpdate = async (responseId: string, status: string) => {
|
||||
try {
|
||||
await itemRequestAPI.updateResponseStatus(responseId, status);
|
||||
fetchMyRequests();
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to update response status');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'open':
|
||||
return 'success';
|
||||
case 'fulfilled':
|
||||
return 'primary';
|
||||
case 'closed':
|
||||
return 'secondary';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
const getResponseStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'warning';
|
||||
case 'accepted':
|
||||
return 'success';
|
||||
case 'declined':
|
||||
return 'danger';
|
||||
case 'expired':
|
||||
return 'secondary';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1>My Item Requests</h1>
|
||||
<p className="text-muted">Manage your item requests and view responses</p>
|
||||
</div>
|
||||
<Link to="/create-item-request" className="btn btn-primary">
|
||||
<i className="bi bi-plus-circle me-2"></i>
|
||||
Create New Request
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : requests.length === 0 ? (
|
||||
<div className="text-center py-5">
|
||||
<i className="bi bi-clipboard-x display-1 text-muted"></i>
|
||||
<h3 className="mt-3">No requests yet</h3>
|
||||
<p className="text-muted">Create your first item request to get started!</p>
|
||||
<Link to="/create-item-request" className="btn btn-primary">
|
||||
Create Request
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="row g-4">
|
||||
{requests.map((request) => (
|
||||
<div key={request.id} className="col-12">
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div className="d-flex justify-content-between align-items-start mb-3">
|
||||
<div className="flex-grow-1">
|
||||
<h5 className="card-title">{request.title}</h5>
|
||||
<p className="card-text text-muted mb-2">
|
||||
{request.description.length > 200
|
||||
? `${request.description.substring(0, 200)}...`
|
||||
: request.description
|
||||
}
|
||||
</p>
|
||||
<small className="text-muted">
|
||||
Created on {new Date(request.createdAt).toLocaleDateString()}
|
||||
</small>
|
||||
</div>
|
||||
<div className="text-end">
|
||||
<span className={`badge bg-${getStatusColor(request.status)} mb-2`}>
|
||||
{request.status.charAt(0).toUpperCase() + request.status.slice(1)}
|
||||
</span>
|
||||
<div>
|
||||
<small className="text-muted">
|
||||
{request.responseCount || 0} response{request.responseCount !== 1 ? 's' : ''}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row mb-3">
|
||||
<div className="col-md-6">
|
||||
{(request.maxPricePerDay || request.maxPricePerHour) && (
|
||||
<small className="text-muted">
|
||||
<i className="bi bi-currency-dollar me-1"></i>
|
||||
Budget:
|
||||
{request.maxPricePerDay && ` $${request.maxPricePerDay}/day`}
|
||||
{request.maxPricePerHour && ` $${request.maxPricePerHour}/hour`}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<small className="text-muted">
|
||||
<i className="bi bi-calendar me-1"></i>
|
||||
Dates: {request.isFlexibleDates ? 'Flexible' :
|
||||
`${request.preferredStartDate ? new Date(request.preferredStartDate).toLocaleDateString() : 'TBD'} - ${request.preferredEndDate ? new Date(request.preferredEndDate).toLocaleDateString() : 'TBD'}`}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{request.responses && request.responses.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<h6>Responses:</h6>
|
||||
{request.responses.map((response: ItemRequestResponse) => (
|
||||
<div key={response.id} className="border-start ps-3 mb-3">
|
||||
<div className="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<strong>{response.responder?.firstName || 'Unknown'}</strong>
|
||||
<small className="text-muted ms-2">
|
||||
{new Date(response.createdAt).toLocaleDateString()}
|
||||
</small>
|
||||
</div>
|
||||
<span className={`badge bg-${getResponseStatusColor(response.status)}`}>
|
||||
{response.status.charAt(0).toUpperCase() + response.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mb-2">{response.message}</p>
|
||||
{(response.offerPricePerDay || response.offerPricePerHour) && (
|
||||
<p className="mb-2 text-muted small">
|
||||
Offered price:
|
||||
{response.offerPricePerDay && ` $${response.offerPricePerDay}/day`}
|
||||
{response.offerPricePerHour && ` $${response.offerPricePerHour}/hour`}
|
||||
</p>
|
||||
)}
|
||||
{response.contactInfo && (
|
||||
<p className="mb-2 text-muted small">
|
||||
Contact: {response.contactInfo}
|
||||
</p>
|
||||
)}
|
||||
{response.status === 'pending' && (
|
||||
<div className="btn-group btn-group-sm">
|
||||
<button
|
||||
className="btn btn-outline-success"
|
||||
onClick={() => handleResponseStatusUpdate(response.id, 'accepted')}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-danger"
|
||||
onClick={() => handleResponseStatusUpdate(response.id, 'declined')}
|
||||
>
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="d-flex gap-2 flex-wrap">
|
||||
<Link
|
||||
to={`/item-requests/${request.id}`}
|
||||
className="btn btn-outline-primary btn-sm"
|
||||
>
|
||||
<i className="bi bi-eye me-1"></i>
|
||||
View Details
|
||||
</Link>
|
||||
{request.status === 'open' && (
|
||||
<button
|
||||
className="btn btn-outline-danger btn-sm"
|
||||
onClick={() => setDeleteModal({ show: true, requestId: request.id })}
|
||||
>
|
||||
<i className="bi bi-trash me-1"></i>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmationModal
|
||||
show={deleteModal.show}
|
||||
title="Delete Item Request"
|
||||
message="Are you sure you want to delete this item request? This action cannot be undone."
|
||||
onConfirm={handleDelete}
|
||||
onClose={() => setDeleteModal({ show: false, requestId: null })}
|
||||
confirmText="Delete"
|
||||
confirmButtonClass="btn-danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyRequests;
|
||||
Reference in New Issue
Block a user