363 lines
13 KiB
TypeScript
363 lines
13 KiB
TypeScript
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';
|
|
import AuthButton from '../components/AuthButton';
|
|
|
|
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">
|
|
<AuthButton mode="login" className="btn btn-outline-primary">
|
|
Log in to Respond
|
|
</AuthButton>
|
|
</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; |