started dockerfiles and itemrequest

This commit is contained in:
jackiettran
2025-08-18 12:24:46 -04:00
parent 209d8f5fbf
commit 99eae4774e
18 changed files with 2080 additions and 1 deletions

34
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM nginx:alpine
COPY --from=builder /app/build /usr/share/nginx/html
RUN echo 'server { \
listen 80; \
location / { \
root /usr/share/nginx/html; \
index index.html index.htm; \
try_files $uri $uri/ /index.html; \
} \
error_page 500 502 503 504 /50x.html; \
location = /50x.html { \
root /usr/share/nginx/html; \
} \
}' > /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -18,6 +18,10 @@ import Profile from './pages/Profile';
import PublicProfile from './pages/PublicProfile';
import Messages from './pages/Messages';
import MessageDetail from './pages/MessageDetail';
import ItemRequests from './pages/ItemRequests';
import ItemRequestDetail from './pages/ItemRequestDetail';
import CreateItemRequest from './pages/CreateItemRequest';
import MyRequests from './pages/MyRequests';
import PrivateRoute from './components/PrivateRoute';
import './App.css';
@@ -99,6 +103,24 @@ function App() {
<MessageDetail />
</PrivateRoute>
}
/>
<Route path="/item-requests" element={<ItemRequests />} />
<Route path="/item-requests/:id" element={<ItemRequestDetail />} />
<Route
path="/create-item-request"
element={
<PrivateRoute>
<CreateItemRequest />
</PrivateRoute>
}
/>
<Route
path="/my-requests"
element={
<PrivateRoute>
<MyRequests />
</PrivateRoute>
}
/>
</Routes>
</main>

View File

@@ -0,0 +1,111 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ItemRequest } from '../types';
interface ItemRequestCardProps {
request: ItemRequest;
showActions?: boolean;
}
const ItemRequestCard: React.FC<ItemRequestCardProps> = ({ request, showActions = true }) => {
const formatDate = (dateString?: string) => {
if (!dateString) return 'Flexible';
const date = new Date(dateString);
return date.toLocaleDateString();
};
const getLocationString = () => {
const parts = [];
if (request.city) parts.push(request.city);
if (request.state) parts.push(request.state);
return parts.join(', ') || 'Location not specified';
};
const getStatusColor = (status: string) => {
switch (status) {
case 'open':
return 'success';
case 'fulfilled':
return 'primary';
case 'closed':
return 'secondary';
default:
return 'secondary';
}
};
return (
<div className="card h-100 shadow-sm hover-shadow">
<div className="card-body">
<div className="d-flex justify-content-between align-items-start mb-2">
<h5 className="card-title text-truncate flex-grow-1 me-2">{request.title}</h5>
<span className={`badge bg-${getStatusColor(request.status)}`}>
{request.status.charAt(0).toUpperCase() + request.status.slice(1)}
</span>
</div>
<p className="card-text text-muted small mb-2">
{request.description.length > 100
? `${request.description.substring(0, 100)}...`
: request.description
}
</p>
<div className="mb-2">
<small className="text-muted">
<i className="bi bi-geo-alt me-1"></i>
{getLocationString()}
</small>
</div>
<div className="mb-2">
<small className="text-muted">
<i className="bi bi-person me-1"></i>
Requested by {request.requester?.firstName || 'Unknown'}
</small>
</div>
{(request.maxPricePerDay || request.maxPricePerHour) && (
<div className="mb-2">
<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="mb-3">
<small className="text-muted">
<i className="bi bi-calendar me-1"></i>
Dates: {formatDate(request.preferredStartDate)} - {formatDate(request.preferredEndDate)}
{request.isFlexibleDates && ' (Flexible)'}
</small>
</div>
<div className="d-flex justify-content-between align-items-center">
<small className="text-muted">
{request.responseCount || 0} response{request.responseCount !== 1 ? 's' : ''}
</small>
<small className="text-muted">
{new Date(request.createdAt).toLocaleDateString()}
</small>
</div>
{showActions && (
<div className="d-grid gap-2 mt-3">
<Link
to={`/item-requests/${request.id}`}
className="btn btn-outline-primary btn-sm"
>
View Details
</Link>
</div>
)}
</div>
</div>
);
};
export default ItemRequestCard;

View File

@@ -88,6 +88,11 @@ const Navbar: React.FC = () => {
<i className="bi bi-list-ul me-2"></i>My Listings
</Link>
</li>
<li>
<Link className="dropdown-item" to="/my-requests">
<i className="bi bi-clipboard-check me-2"></i>My Requests
</Link>
</li>
<li>
<Link className="dropdown-item" to="/messages">
<i className="bi bi-envelope me-2"></i>Messages

View File

@@ -0,0 +1,257 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { itemRequestAPI, itemAPI } from '../services/api';
import { ItemRequest, Item } from '../types';
interface RequestResponseModalProps {
show: boolean;
onHide: () => void;
request: ItemRequest | null;
onResponseSubmitted: () => void;
}
const RequestResponseModal: React.FC<RequestResponseModalProps> = ({
show,
onHide,
request,
onResponseSubmitted
}) => {
const { user } = useAuth();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [userItems, setUserItems] = useState<Item[]>([]);
const [formData, setFormData] = useState({
message: '',
offerPricePerHour: '',
offerPricePerDay: '',
availableStartDate: '',
availableEndDate: '',
existingItemId: '',
contactInfo: ''
});
useEffect(() => {
if (show && user) {
fetchUserItems();
resetForm();
}
}, [show, user]);
const fetchUserItems = async () => {
try {
const response = await itemAPI.getItems({ owner: user?.id });
setUserItems(response.data.items || []);
} catch (err) {
console.error('Failed to fetch user items:', err);
}
};
const resetForm = () => {
setFormData({
message: '',
offerPricePerHour: '',
offerPricePerDay: '',
availableStartDate: '',
availableEndDate: '',
existingItemId: '',
contactInfo: ''
});
setError(null);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!request || !user) return;
setLoading(true);
setError(null);
try {
const responseData = {
...formData,
offerPricePerHour: formData.offerPricePerHour ? parseFloat(formData.offerPricePerHour) : null,
offerPricePerDay: formData.offerPricePerDay ? parseFloat(formData.offerPricePerDay) : null,
existingItemId: formData.existingItemId || null,
availableStartDate: formData.availableStartDate || null,
availableEndDate: formData.availableEndDate || null
};
await itemRequestAPI.respondToRequest(request.id, responseData);
onResponseSubmitted();
onHide();
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to submit response');
} finally {
setLoading(false);
}
};
if (!request) return null;
return (
<div className={`modal fade ${show ? 'show d-block' : ''}`} tabIndex={-1} style={{ backgroundColor: show ? 'rgba(0,0,0,0.5)' : 'transparent' }}>
<div className="modal-dialog modal-lg">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Respond to Request</h5>
<button type="button" className="btn-close" onClick={onHide}></button>
</div>
<div className="modal-body">
<div className="mb-3 p-3 bg-light rounded">
<h6>{request.title}</h6>
<p className="text-muted small mb-0">{request.description}</p>
</div>
{error && (
<div className="alert alert-danger" role="alert">
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="mb-3">
<label htmlFor="message" className="form-label">Your Message *</label>
<textarea
className="form-control"
id="message"
name="message"
rows={4}
value={formData.message}
onChange={handleChange}
placeholder="Explain how you can help, availability, condition of the item, etc."
required
/>
</div>
{userItems.length > 0 && (
<div className="mb-3">
<label htmlFor="existingItemId" className="form-label">Do you have an existing listing for this item?</label>
<select
className="form-select"
id="existingItemId"
name="existingItemId"
value={formData.existingItemId}
onChange={handleChange}
>
<option value="">No existing listing</option>
{userItems.map((item) => (
<option key={item.id} value={item.id}>
{item.name} - ${item.pricePerDay}/day
</option>
))}
</select>
<div className="form-text">
If you have an existing listing that matches this request, select it here.
</div>
</div>
)}
<div className="row mb-3">
<div className="col-md-6">
<label htmlFor="offerPricePerDay" className="form-label">Your Price per Day</label>
<div className="input-group">
<span className="input-group-text">$</span>
<input
type="number"
className="form-control"
id="offerPricePerDay"
name="offerPricePerDay"
value={formData.offerPricePerDay}
onChange={handleChange}
step="0.01"
min="0"
placeholder="0.00"
/>
</div>
</div>
<div className="col-md-6">
<label htmlFor="offerPricePerHour" className="form-label">Your Price per Hour</label>
<div className="input-group">
<span className="input-group-text">$</span>
<input
type="number"
className="form-control"
id="offerPricePerHour"
name="offerPricePerHour"
value={formData.offerPricePerHour}
onChange={handleChange}
step="0.01"
min="0"
placeholder="0.00"
/>
</div>
</div>
</div>
<div className="row mb-3">
<div className="col-md-6">
<label htmlFor="availableStartDate" className="form-label">Available From</label>
<input
type="date"
className="form-control"
id="availableStartDate"
name="availableStartDate"
value={formData.availableStartDate}
onChange={handleChange}
min={new Date().toISOString().split('T')[0]}
/>
</div>
<div className="col-md-6">
<label htmlFor="availableEndDate" className="form-label">Available Until</label>
<input
type="date"
className="form-control"
id="availableEndDate"
name="availableEndDate"
value={formData.availableEndDate}
onChange={handleChange}
min={formData.availableStartDate || new Date().toISOString().split('T')[0]}
/>
</div>
</div>
<div className="mb-3">
<label htmlFor="contactInfo" className="form-label">Contact Information</label>
<input
type="text"
className="form-control"
id="contactInfo"
name="contactInfo"
value={formData.contactInfo}
onChange={handleChange}
placeholder="Phone number, email, or preferred contact method"
/>
<div className="form-text">
How should the requester contact you if they're interested?
</div>
</div>
</form>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={onHide}>
Cancel
</button>
<button
type="button"
className="btn btn-primary"
onClick={handleSubmit}
disabled={loading || !formData.message.trim()}
>
{loading ? 'Submitting...' : 'Submit Response'}
</button>
</div>
</div>
</div>
</div>
);
};
export default RequestResponseModal;

View 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;

View File

@@ -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' }}>

View 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;

View 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;

View 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;

View File

@@ -80,4 +80,16 @@ export const messageAPI = {
getUnreadCount: () => api.get("/messages/unread/count"),
};
export const itemRequestAPI = {
getItemRequests: (params?: any) => api.get("/item-requests", { params }),
getItemRequest: (id: string) => api.get(`/item-requests/${id}`),
createItemRequest: (data: any) => api.post("/item-requests", data),
updateItemRequest: (id: string, data: any) => api.put(`/item-requests/${id}`, data),
deleteItemRequest: (id: string) => api.delete(`/item-requests/${id}`),
getMyRequests: () => api.get("/item-requests/my-requests"),
respondToRequest: (id: string, data: any) => api.post(`/item-requests/${id}/responses`, data),
updateResponseStatus: (responseId: string, status: string) =>
api.put(`/item-requests/responses/${responseId}/status`, { status }),
};
export default api;

View File

@@ -97,3 +97,48 @@ export interface Rental {
createdAt: string;
updatedAt: string;
}
export interface ItemRequest {
id: string;
title: string;
description: string;
address1?: string;
address2?: string;
city?: string;
state?: string;
zipCode?: string;
country?: string;
latitude?: number;
longitude?: number;
maxPricePerHour?: number;
maxPricePerDay?: number;
preferredStartDate?: string;
preferredEndDate?: string;
isFlexibleDates: boolean;
status: "open" | "fulfilled" | "closed";
requesterId: string;
requester?: User;
responseCount: number;
responses?: ItemRequestResponse[];
createdAt: string;
updatedAt: string;
}
export interface ItemRequestResponse {
id: string;
itemRequestId: string;
responderId: string;
message: string;
offerPricePerHour?: number;
offerPricePerDay?: number;
availableStartDate?: string;
availableEndDate?: string;
existingItemId?: string;
status: "pending" | "accepted" | "declined" | "expired";
contactInfo?: string;
responder?: User;
existingItem?: Item;
itemRequest?: ItemRequest;
createdAt: string;
updatedAt: string;
}