essential forum code
This commit is contained in:
310
frontend/src/pages/CreateForumPost.tsx
Normal file
310
frontend/src/pages/CreateForumPost.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { forumAPI } from "../services/api";
|
||||
import TagInput from "../components/TagInput";
|
||||
|
||||
const CreateForumPost: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: "",
|
||||
content: "",
|
||||
category: "general_discussion" as
|
||||
| "item_request"
|
||||
| "technical_support"
|
||||
| "community_resources"
|
||||
| "general_discussion",
|
||||
tags: [] as string[],
|
||||
});
|
||||
|
||||
const categories = [
|
||||
{
|
||||
value: "item_request",
|
||||
label: "Item Request",
|
||||
description: "Looking to rent a specific item",
|
||||
},
|
||||
{
|
||||
value: "technical_support",
|
||||
label: "Technical Support",
|
||||
description: "Get help with using the platform",
|
||||
},
|
||||
{
|
||||
value: "community_resources",
|
||||
label: "Community Resources",
|
||||
description: "Share tips, guides, and resources",
|
||||
},
|
||||
{
|
||||
value: "general_discussion",
|
||||
label: "General Discussion",
|
||||
description: "Open-ended conversations",
|
||||
},
|
||||
];
|
||||
|
||||
const handleInputChange = (
|
||||
e: React.ChangeEvent<
|
||||
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||
>
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleTagsChange = (tags: string[]) => {
|
||||
setFormData((prev) => ({ ...prev, tags }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
// Validation
|
||||
if (!formData.title.trim()) {
|
||||
setError("Title is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.title.length < 10) {
|
||||
setError("Title must be at least 10 characters long");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.content.trim()) {
|
||||
setError("Content is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.content.length < 20) {
|
||||
setError("Content must be at least 20 characters long");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const response = await forumAPI.createPost(formData);
|
||||
navigate(`/forum/${response.data.id}`);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || "Failed to create post");
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="alert alert-warning" role="alert">
|
||||
<i className="bi bi-exclamation-triangle me-2"></i>
|
||||
You must be logged in to create a post.
|
||||
</div>
|
||||
<Link to="/forum" className="btn btn-secondary">
|
||||
<i className="bi bi-arrow-left me-2"></i>
|
||||
Back to Forum
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<nav aria-label="breadcrumb" className="mb-3">
|
||||
<ol className="breadcrumb">
|
||||
<li className="breadcrumb-item">
|
||||
<Link to="/forum">Forum</Link>
|
||||
</li>
|
||||
<li className="breadcrumb-item active" aria-current="page">
|
||||
Create Post
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-lg-8 mx-auto">
|
||||
{/* Guidelines Card */}
|
||||
<div className="card mb-3">
|
||||
<div className="card-header">
|
||||
<h6 className="mb-0">
|
||||
<i className="bi bi-info-circle me-2"></i>
|
||||
Community Guidelines
|
||||
</h6>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<ul className="mb-0">
|
||||
<li>Be respectful and courteous to others</li>
|
||||
<li>Stay on topic and relevant to the category</li>
|
||||
<li>No spam, advertising, or self-promotion</li>
|
||||
<li>Search before posting to avoid duplicates</li>
|
||||
<li>Use clear, descriptive titles</li>
|
||||
<li>Provide helpful and constructive feedback</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="mb-0">Create New Post</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Title */}
|
||||
<div className="mb-3">
|
||||
<label htmlFor="title" className="form-label">
|
||||
Title <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="title"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter a descriptive title..."
|
||||
maxLength={200}
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
/>
|
||||
<div className="form-text">
|
||||
{formData.title.length}/200 characters (minimum 10)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div className="mb-3">
|
||||
<label htmlFor="category" className="form-label">
|
||||
Category <span className="text-danger">*</span>
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="category"
|
||||
name="category"
|
||||
value={formData.category}
|
||||
onChange={handleInputChange}
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.value} value={cat.value}>
|
||||
{cat.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="form-text">
|
||||
Choose the category that best fits your post
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mb-3">
|
||||
<label htmlFor="content" className="form-label">
|
||||
Content <span className="text-danger">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
id="content"
|
||||
name="content"
|
||||
rows={10}
|
||||
value={formData.content}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Write your post content here..."
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
/>
|
||||
<div className="form-text">
|
||||
{formData.content.length} characters (minimum 20)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="mb-4">
|
||||
<label className="form-label">
|
||||
Tags <span className="text-muted">(optional)</span>
|
||||
</label>
|
||||
<TagInput
|
||||
selectedTags={formData.tags}
|
||||
onChange={handleTagsChange}
|
||||
placeholder="Add tags to help others find your post..."
|
||||
/>
|
||||
<div className="form-text">
|
||||
Add up to 5 relevant tags. Press Enter after each tag.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category-specific guidelines */}
|
||||
{formData.category === "item_request" && (
|
||||
<div className="alert alert-info mb-3">
|
||||
<strong>Item Request Tips:</strong>
|
||||
<ul className="mb-0 mt-2">
|
||||
<li>Be specific about what you're looking for</li>
|
||||
<li>Include your general location</li>
|
||||
<li>Specify when you need the item</li>
|
||||
<li>Mention your budget range if applicable</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.category === "technical_support" && (
|
||||
<div className="alert alert-info mb-3">
|
||||
<strong>Technical Support Tips:</strong>
|
||||
<ul className="mb-0 mt-2">
|
||||
<li>Describe the issue you're experiencing</li>
|
||||
<li>Include steps to reproduce the problem</li>
|
||||
<li>Mention your device/browser if relevant</li>
|
||||
<li>Include any error messages you see</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit buttons */}
|
||||
<div className="d-flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
!formData.title.trim() ||
|
||||
!formData.content.trim()
|
||||
}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner-border spinner-border-sm me-2"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-send me-2"></i>
|
||||
Create Post
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<Link
|
||||
to="/forum"
|
||||
className={`btn btn-secondary ${
|
||||
isSubmitting ? "disabled" : ""
|
||||
}`}
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateForumPost;
|
||||
@@ -1,333 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { itemRequestAPI } from "../services/api";
|
||||
|
||||
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 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 htmlFor="address1" className="form-label">Address</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="address1"
|
||||
name="address1"
|
||||
value={formData.address1}
|
||||
onChange={handleChange}
|
||||
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().toLocaleDateString()}
|
||||
/>
|
||||
</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().toLocaleDateString()
|
||||
}
|
||||
/>
|
||||
</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;
|
||||
397
frontend/src/pages/ForumPostDetail.tsx
Normal file
397
frontend/src/pages/ForumPostDetail.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { forumAPI } from '../services/api';
|
||||
import { ForumPost, ForumComment } from '../types';
|
||||
import CategoryBadge from '../components/CategoryBadge';
|
||||
import PostStatusBadge from '../components/PostStatusBadge';
|
||||
import CommentThread from '../components/CommentThread';
|
||||
import CommentForm from '../components/CommentForm';
|
||||
import AuthButton from '../components/AuthButton';
|
||||
|
||||
const ForumPostDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [post, setPost] = useState<ForumPost | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchPost();
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const fetchPost = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await forumAPI.getPost(id!);
|
||||
setPost(response.data);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to fetch post');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddComment = async (content: string) => {
|
||||
if (!user) {
|
||||
alert('Please log in to comment');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await forumAPI.createComment(id!, { content });
|
||||
await fetchPost(); // Refresh to get new comment
|
||||
} catch (err: any) {
|
||||
throw new Error(err.response?.data?.error || 'Failed to post comment');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReply = async (parentCommentId: string, content: string) => {
|
||||
if (!user) {
|
||||
alert('Please log in to reply');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await forumAPI.createComment(id!, { content, parentCommentId });
|
||||
await fetchPost(); // Refresh to get new reply
|
||||
} catch (err: any) {
|
||||
throw new Error(err.response?.data?.error || 'Failed to post reply');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditComment = async (commentId: string, content: string) => {
|
||||
try {
|
||||
await forumAPI.updateComment(commentId, { content });
|
||||
await fetchPost(); // Refresh to get updated comment
|
||||
} catch (err: any) {
|
||||
throw new Error(err.response?.data?.error || 'Failed to update comment');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteComment = async (commentId: string) => {
|
||||
try {
|
||||
await forumAPI.deleteComment(commentId);
|
||||
await fetchPost(); // Refresh to remove deleted comment
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.error || 'Failed to delete comment');
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (newStatus: string) => {
|
||||
try {
|
||||
setActionLoading(true);
|
||||
await forumAPI.updatePostStatus(id!, newStatus);
|
||||
await fetchPost();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.error || 'Failed to update status');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePost = async () => {
|
||||
if (!window.confirm('Are you sure you want to delete this post? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setActionLoading(true);
|
||||
await forumAPI.deletePost(id!);
|
||||
navigate('/forum');
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.error || 'Failed to delete post');
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="text-center py-5">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !post) {
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error || 'Post not found'}
|
||||
</div>
|
||||
<Link to="/forum" className="btn btn-secondary">
|
||||
<i className="bi bi-arrow-left me-2"></i>
|
||||
Back to Forum
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isAuthor = user?.id === post.authorId;
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<nav aria-label="breadcrumb" className="mb-3">
|
||||
<ol className="breadcrumb">
|
||||
<li className="breadcrumb-item">
|
||||
<Link to="/forum">Forum</Link>
|
||||
</li>
|
||||
<li className="breadcrumb-item active" aria-current="page">
|
||||
{post.title}
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-lg-8">
|
||||
{/* Post Content */}
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<div className="d-flex justify-content-between align-items-start mb-3">
|
||||
<div className="flex-grow-1">
|
||||
{post.isPinned && (
|
||||
<span className="badge bg-danger me-2">
|
||||
<i className="bi bi-pin-angle-fill me-1"></i>
|
||||
Pinned
|
||||
</span>
|
||||
)}
|
||||
<h1 className="h3 mb-2">{post.title}</h1>
|
||||
<div className="d-flex gap-2 mb-2">
|
||||
<CategoryBadge category={post.category} />
|
||||
<PostStatusBadge status={post.status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className="mb-3">
|
||||
{post.tags.map((tag) => (
|
||||
<Link
|
||||
key={tag.id}
|
||||
to={`/forum?tag=${tag.tagName}`}
|
||||
className="badge bg-light text-dark me-1 mb-1 text-decoration-none"
|
||||
>
|
||||
#{tag.tagName}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="d-flex align-items-center">
|
||||
<div className="avatar bg-primary text-white rounded-circle d-flex align-items-center justify-content-center me-2"
|
||||
style={{ width: '40px', height: '40px' }}>
|
||||
{post.author?.firstName?.charAt(0) || '?'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>
|
||||
{post.author?.firstName || 'Unknown'} {post.author?.lastName || ''}
|
||||
</strong>
|
||||
<br />
|
||||
<small className="text-muted">
|
||||
Posted {formatDate(post.createdAt)}
|
||||
{post.updatedAt !== post.createdAt && ' (edited)'}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div className="post-content mb-3" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{post.content}
|
||||
</div>
|
||||
|
||||
<div className="d-flex gap-3 text-muted small">
|
||||
<span>
|
||||
<i className="bi bi-chat me-1"></i>
|
||||
{post.commentCount || 0} comments
|
||||
</span>
|
||||
<span>
|
||||
<i className="bi bi-eye me-1"></i>
|
||||
{post.viewCount || 0} views
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isAuthor && (
|
||||
<>
|
||||
<hr />
|
||||
<div className="d-flex gap-2 flex-wrap">
|
||||
{post.status === 'open' && (
|
||||
<button
|
||||
className="btn btn-sm btn-success"
|
||||
onClick={() => handleStatusChange('solved')}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<i className="bi bi-check-circle me-1"></i>
|
||||
Mark as Solved
|
||||
</button>
|
||||
)}
|
||||
{post.status !== 'closed' && (
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => handleStatusChange('closed')}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<i className="bi bi-x-circle me-1"></i>
|
||||
Close Post
|
||||
</button>
|
||||
)}
|
||||
{post.status === 'closed' && (
|
||||
<button
|
||||
className="btn btn-sm btn-success"
|
||||
onClick={() => handleStatusChange('open')}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<i className="bi bi-arrow-counterclockwise me-1"></i>
|
||||
Reopen Post
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
to={`/forum/${post.id}/edit`}
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
>
|
||||
<i className="bi bi-pencil me-1"></i>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
onClick={handleDeletePost}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<i className="bi bi-trash me-1"></i>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comments Section */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h5 className="mb-0">
|
||||
<i className="bi bi-chat-dots me-2"></i>
|
||||
Comments ({post.commentCount || 0})
|
||||
</h5>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{user ? (
|
||||
<div className="mb-4">
|
||||
<h6>Add a comment</h6>
|
||||
<CommentForm
|
||||
onSubmit={handleAddComment}
|
||||
placeholder="Share your thoughts..."
|
||||
buttonText="Post Comment"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="alert alert-info mb-4">
|
||||
<i className="bi bi-info-circle me-2"></i>
|
||||
<AuthButton mode="login" className="alert-link" asLink>Log in</AuthButton> to join the discussion.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<hr />
|
||||
|
||||
{post.comments && post.comments.length > 0 ? (
|
||||
<div className="comments-list">
|
||||
{post.comments.map((comment: ForumComment) => (
|
||||
<CommentThread
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
onReply={handleReply}
|
||||
onEdit={handleEditComment}
|
||||
onDelete={handleDeleteComment}
|
||||
currentUserId={user?.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 text-muted">
|
||||
<i className="bi bi-chat display-4 d-block mb-2"></i>
|
||||
<p>No comments yet. Be the first to comment!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="col-lg-4">
|
||||
<div className="card mb-3">
|
||||
<div className="card-header">
|
||||
<h6 className="mb-0">About this post</h6>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="mb-2">
|
||||
<small className="text-muted">Category:</small>
|
||||
<div>
|
||||
<CategoryBadge category={post.category} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<small className="text-muted">Status:</small>
|
||||
<div>
|
||||
<PostStatusBadge status={post.status} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<small className="text-muted">Created:</small>
|
||||
<div>{formatDate(post.createdAt)}</div>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<small className="text-muted">Last updated:</small>
|
||||
<div>{formatDate(post.updatedAt)}</div>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<small className="text-muted">Author:</small>
|
||||
<div>
|
||||
<Link to={`/users/${post.authorId}`}>
|
||||
{post.author?.firstName || 'Unknown'} {post.author?.lastName || ''}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h6 className="mb-0">Actions</h6>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="d-grid gap-2">
|
||||
<Link to="/forum" className="btn btn-outline-secondary btn-sm">
|
||||
<i className="bi bi-arrow-left me-2"></i>
|
||||
Back to Forum
|
||||
</Link>
|
||||
{user && (
|
||||
<Link to="/forum/create" className="btn btn-outline-primary btn-sm">
|
||||
<i className="bi bi-plus-circle me-2"></i>
|
||||
Create New Post
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForumPostDetail;
|
||||
@@ -1,43 +1,61 @@
|
||||
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';
|
||||
import { forumAPI } from '../services/api';
|
||||
import { ForumPost } from '../types';
|
||||
import ForumPostCard from '../components/ForumPostCard';
|
||||
import AuthButton from '../components/AuthButton';
|
||||
|
||||
const ItemRequests: React.FC = () => {
|
||||
const ForumPosts: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const [requests, setRequests] = useState<ItemRequest[]>([]);
|
||||
const [posts, setPosts] = useState<ForumPost[]>([]);
|
||||
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 [totalPosts, setTotalPosts] = useState(0);
|
||||
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
status: 'open'
|
||||
category: '',
|
||||
tag: '',
|
||||
status: '',
|
||||
sort: 'recent'
|
||||
});
|
||||
|
||||
const categories = [
|
||||
{ value: '', label: 'All Categories' },
|
||||
{ value: 'item_request', label: 'Item Requests' },
|
||||
{ value: 'technical_support', label: 'Technical Support' },
|
||||
{ value: 'community_resources', label: 'Community Resources' },
|
||||
{ value: 'general_discussion', label: 'General Discussion' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
fetchRequests();
|
||||
fetchPosts();
|
||||
}, [currentPage, filters]);
|
||||
|
||||
const fetchRequests = async () => {
|
||||
const fetchPosts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await itemRequestAPI.getItemRequests({
|
||||
const params: any = {
|
||||
page: currentPage,
|
||||
limit: 20,
|
||||
...filters
|
||||
});
|
||||
|
||||
setRequests(response.data.requests);
|
||||
sort: filters.sort
|
||||
};
|
||||
|
||||
if (filters.search) params.search = filters.search;
|
||||
if (filters.category) params.category = filters.category;
|
||||
if (filters.tag) params.tag = filters.tag;
|
||||
if (filters.status) params.status = filters.status;
|
||||
|
||||
const response = await forumAPI.getPosts(params);
|
||||
|
||||
setPosts(response.data.posts);
|
||||
setTotalPages(response.data.totalPages);
|
||||
setTotalRequests(response.data.totalRequests);
|
||||
setTotalPosts(response.data.totalPosts);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to fetch item requests');
|
||||
setError(err.response?.data?.error || 'Failed to fetch forum posts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -51,36 +69,56 @@ const ItemRequests: React.FC = () => {
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
fetchRequests();
|
||||
fetchPosts();
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
const handleCategoryClick = (category: string) => {
|
||||
setFilters(prev => ({ ...prev, category }));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
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>
|
||||
<h1>Community Forum</h1>
|
||||
<p className="text-muted">Discuss, share, and connect with the community</p>
|
||||
</div>
|
||||
{user && (
|
||||
<Link to="/create-item-request" className="btn btn-primary">
|
||||
<Link to="/forum/create" className="btn btn-primary">
|
||||
<i className="bi bi-plus-circle me-2"></i>
|
||||
Create Request
|
||||
Create Post
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category Tabs */}
|
||||
<ul className="nav nav-tabs mb-4">
|
||||
{categories.map((cat) => (
|
||||
<li key={cat.value} className="nav-item">
|
||||
<button
|
||||
className={`nav-link ${filters.category === cat.value ? 'active' : ''}`}
|
||||
onClick={() => handleCategoryClick(cat.value)}
|
||||
>
|
||||
{cat.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="row mb-4">
|
||||
<div className="col-md-8">
|
||||
<div className="col-md-6">
|
||||
<form onSubmit={handleSearch}>
|
||||
<div className="input-group">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Search item requests..."
|
||||
placeholder="Search posts..."
|
||||
name="search"
|
||||
value={filters.search}
|
||||
onChange={handleFilterChange}
|
||||
@@ -91,16 +129,29 @@ const ItemRequests: React.FC = () => {
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<div className="col-md-3">
|
||||
<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>
|
||||
<option value="">All Status</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="solved">Solved</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<select
|
||||
className="form-select"
|
||||
name="sort"
|
||||
value={filters.sort}
|
||||
onChange={handleFilterChange}
|
||||
>
|
||||
<option value="recent">Most Recent</option>
|
||||
<option value="comments">Most Commented</option>
|
||||
<option value="views">Most Viewed</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,32 +172,32 @@ const ItemRequests: React.FC = () => {
|
||||
<>
|
||||
<div className="d-flex justify-content-between align-items-center mb-3">
|
||||
<p className="text-muted mb-0">
|
||||
Showing {requests.length} of {totalRequests} requests
|
||||
Showing {posts.length} of {totalPosts} posts
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{requests.length === 0 ? (
|
||||
{posts.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>
|
||||
<h3 className="mt-3">No posts found</h3>
|
||||
<p className="text-muted">
|
||||
{filters.search
|
||||
{filters.search || filters.category || filters.tag
|
||||
? "Try adjusting your search terms or filters."
|
||||
: "Be the first to create an item request!"
|
||||
: "Be the first to start a discussion!"
|
||||
}
|
||||
</p>
|
||||
{user && !filters.search && (
|
||||
<Link to="/create-item-request" className="btn btn-primary">
|
||||
Create First Request
|
||||
{user && !filters.search && !filters.category && (
|
||||
<Link to="/forum/create" className="btn btn-primary">
|
||||
Create First Post
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="row g-4">
|
||||
{requests.map((request) => (
|
||||
<div key={request.id} className="col-md-6 col-lg-4">
|
||||
<ItemRequestCard request={request} />
|
||||
{posts.map((post) => (
|
||||
<div key={post.id} className="col-md-6 col-lg-4">
|
||||
<ForumPostCard post={post} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -163,11 +214,11 @@ const ItemRequests: React.FC = () => {
|
||||
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
|
||||
@@ -179,7 +230,7 @@ const ItemRequests: React.FC = () => {
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
<li className={`page-item ${currentPage === totalPages ? 'disabled' : ''}`}>
|
||||
<button
|
||||
className="page-link"
|
||||
@@ -201,7 +252,7 @@ const ItemRequests: React.FC = () => {
|
||||
<div className="mt-4">
|
||||
<div className="alert alert-info" role="alert">
|
||||
<i className="bi bi-info-circle me-2"></i>
|
||||
<AuthButton mode="login" className="alert-link" asLink>Log in</AuthButton> to create your own item requests or respond to existing ones.
|
||||
<AuthButton mode="login" className="alert-link" asLink>Log in</AuthButton> to create posts and join the discussion.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -209,4 +260,4 @@ const ItemRequests: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ItemRequests;
|
||||
export default ForumPosts;
|
||||
@@ -1,363 +0,0 @@
|
||||
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;
|
||||
274
frontend/src/pages/MyPosts.tsx
Normal file
274
frontend/src/pages/MyPosts.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { forumAPI } from '../services/api';
|
||||
import { ForumPost } from '../types';
|
||||
import CategoryBadge from '../components/CategoryBadge';
|
||||
import PostStatusBadge from '../components/PostStatusBadge';
|
||||
|
||||
const MyPosts: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const [posts, setPosts] = useState<ForumPost[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchMyPosts();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const fetchMyPosts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await forumAPI.getMyPosts();
|
||||
setPosts(response.data);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Failed to fetch your posts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (postId: string, newStatus: string) => {
|
||||
try {
|
||||
setActionLoading(postId);
|
||||
await forumAPI.updatePostStatus(postId, newStatus);
|
||||
await fetchMyPosts(); // Refresh list
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.error || 'Failed to update status');
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (postId: string, postTitle: string) => {
|
||||
if (!window.confirm(`Are you sure you want to delete "${postTitle}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setActionLoading(postId);
|
||||
await forumAPI.deletePost(postId);
|
||||
await fetchMyPosts(); // Refresh list
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.error || 'Failed to delete post');
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffHours < 1) {
|
||||
return 'Just now';
|
||||
} else if (diffHours < 24) {
|
||||
return `${diffHours}h ago`;
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays}d ago`;
|
||||
} else {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="alert alert-warning" role="alert">
|
||||
<i className="bi bi-exclamation-triangle me-2"></i>
|
||||
You must be logged in to view your posts.
|
||||
</div>
|
||||
<Link to="/forum" className="btn btn-secondary">
|
||||
<i className="bi bi-arrow-left me-2"></i>
|
||||
Back to Forum
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="text-center py-5">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mt-4">
|
||||
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1>My Posts</h1>
|
||||
<p className="text-muted">Manage your forum posts and discussions</p>
|
||||
</div>
|
||||
<Link to="/forum/create" className="btn btn-primary">
|
||||
<i className="bi bi-plus-circle me-2"></i>
|
||||
Create Post
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<div className="text-center py-5">
|
||||
<i className="bi bi-inbox display-1 text-muted"></i>
|
||||
<h3 className="mt-3">No posts yet</h3>
|
||||
<p className="text-muted mb-4">
|
||||
Start a conversation by creating your first post!
|
||||
</p>
|
||||
<Link to="/forum/create" className="btn btn-primary">
|
||||
<i className="bi bi-plus-circle me-2"></i>
|
||||
Create Your First Post
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<p className="text-muted">
|
||||
You have {posts.length} post{posts.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="list-group">
|
||||
{posts.map((post) => (
|
||||
<div key={post.id} className="list-group-item">
|
||||
<div className="row align-items-center">
|
||||
<div className="col-lg-7">
|
||||
<div className="d-flex align-items-start mb-2">
|
||||
{post.isPinned && (
|
||||
<span className="badge bg-danger me-2">
|
||||
<i className="bi bi-pin-angle-fill"></i>
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-grow-1">
|
||||
<h5 className="mb-1">
|
||||
<Link to={`/forum/${post.id}`} className="text-decoration-none">
|
||||
{post.title}
|
||||
</Link>
|
||||
</h5>
|
||||
<div className="d-flex gap-2 mb-2">
|
||||
<CategoryBadge category={post.category} />
|
||||
<PostStatusBadge status={post.status} />
|
||||
</div>
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className="mb-2">
|
||||
{post.tags.slice(0, 3).map((tag) => (
|
||||
<span key={tag.id} className="badge bg-light text-dark me-1">
|
||||
#{tag.tagName}
|
||||
</span>
|
||||
))}
|
||||
{post.tags.length > 3 && (
|
||||
<span className="badge bg-light text-dark">
|
||||
+{post.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="d-flex gap-3 text-muted small">
|
||||
<span>
|
||||
<i className="bi bi-chat me-1"></i>
|
||||
{post.commentCount || 0} comments
|
||||
</span>
|
||||
<span>
|
||||
<i className="bi bi-eye me-1"></i>
|
||||
{post.viewCount || 0} views
|
||||
</span>
|
||||
<span>
|
||||
<i className="bi bi-clock me-1"></i>
|
||||
{formatDate(post.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-lg-5">
|
||||
<div className="d-flex gap-2 flex-wrap justify-content-lg-end">
|
||||
<Link
|
||||
to={`/forum/${post.id}`}
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
>
|
||||
<i className="bi bi-eye me-1"></i>
|
||||
View
|
||||
</Link>
|
||||
|
||||
{post.status === 'open' && (
|
||||
<button
|
||||
className="btn btn-sm btn-outline-success"
|
||||
onClick={() => handleStatusChange(post.id, 'solved')}
|
||||
disabled={actionLoading === post.id}
|
||||
>
|
||||
<i className="bi bi-check-circle me-1"></i>
|
||||
Mark Solved
|
||||
</button>
|
||||
)}
|
||||
|
||||
{post.status !== 'closed' && (
|
||||
<button
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
onClick={() => handleStatusChange(post.id, 'closed')}
|
||||
disabled={actionLoading === post.id}
|
||||
>
|
||||
<i className="bi bi-x-circle me-1"></i>
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
|
||||
{post.status === 'closed' && (
|
||||
<button
|
||||
className="btn btn-sm btn-outline-success"
|
||||
onClick={() => handleStatusChange(post.id, 'open')}
|
||||
disabled={actionLoading === post.id}
|
||||
>
|
||||
<i className="bi bi-arrow-counterclockwise me-1"></i>
|
||||
Reopen
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
onClick={() => handleDelete(post.id, post.title)}
|
||||
disabled={actionLoading === post.id}
|
||||
>
|
||||
{actionLoading === post.id ? (
|
||||
<span className="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi bi-trash me-1"></i>
|
||||
Delete
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<Link to="/forum" className="btn btn-outline-secondary">
|
||||
<i className="bi bi-arrow-left me-2"></i>
|
||||
Back to Forum
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyPosts;
|
||||
@@ -1,264 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } 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, openAuthModal } = useAuth();
|
||||
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 {
|
||||
openAuthModal('login');
|
||||
}
|
||||
}, [user, openAuthModal]);
|
||||
|
||||
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