essential forum code

This commit is contained in:
jackiettran
2025-11-11 16:55:00 -05:00
parent 4a4eee86a7
commit 825389228d
29 changed files with 2557 additions and 2861 deletions

View File

@@ -22,10 +22,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 ForumPosts from './pages/ForumPosts';
import ForumPostDetail from './pages/ForumPostDetail';
import CreateForumPost from './pages/CreateForumPost';
import MyPosts from './pages/MyPosts';
import EarningsDashboard from './pages/EarningsDashboard';
import FAQ from './pages/FAQ';
import PrivateRoute from './components/PrivateRoute';
@@ -158,21 +158,21 @@ const AppContent: React.FC = () => {
</PrivateRoute>
}
/>
<Route path="/item-requests" element={<ItemRequests />} />
<Route path="/item-requests/:id" element={<ItemRequestDetail />} />
<Route path="/forum" element={<ForumPosts />} />
<Route path="/forum/:id" element={<ForumPostDetail />} />
<Route
path="/create-item-request"
path="/forum/create"
element={
<PrivateRoute>
<CreateItemRequest />
<CreateForumPost />
</PrivateRoute>
}
/>
<Route
path="/my-requests"
path="/my-posts"
element={
<PrivateRoute>
<MyRequests />
<MyPosts />
</PrivateRoute>
}
/>

View File

@@ -0,0 +1,33 @@
import React from 'react';
interface CategoryBadgeProps {
category: "item_request" | "technical_support" | "community_resources" | "general_discussion";
}
const CategoryBadge: React.FC<CategoryBadgeProps> = ({ category }) => {
const getCategoryConfig = (cat: string) => {
switch (cat) {
case 'item_request':
return { label: 'Item Request', color: 'primary', icon: 'bi-box-seam' };
case 'technical_support':
return { label: 'Technical Support', color: 'warning', icon: 'bi-tools' };
case 'community_resources':
return { label: 'Community Resources', color: 'info', icon: 'bi-people' };
case 'general_discussion':
return { label: 'General Discussion', color: 'secondary', icon: 'bi-chat-dots' };
default:
return { label: 'General', color: 'secondary', icon: 'bi-chat' };
}
};
const config = getCategoryConfig(category);
return (
<span className={`badge bg-${config.color}`}>
<i className={`bi ${config.icon} me-1`}></i>
{config.label}
</span>
);
};
export default CategoryBadge;

View File

@@ -0,0 +1,86 @@
import React, { useState } from 'react';
interface CommentFormProps {
onSubmit: (content: string) => Promise<void>;
onCancel?: () => void;
placeholder?: string;
buttonText?: string;
isReply?: boolean;
}
const CommentForm: React.FC<CommentFormProps> = ({
onSubmit,
onCancel,
placeholder = 'Write your comment...',
buttonText = 'Post Comment',
isReply = false,
}) => {
const [content, setContent] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!content.trim()) {
setError('Comment cannot be empty');
return;
}
setIsSubmitting(true);
setError('');
try {
await onSubmit(content);
setContent('');
} catch (err: any) {
setError(err.message || 'Failed to post comment');
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className={isReply ? 'ms-4' : ''}>
<div className="mb-2">
<textarea
className={`form-control ${error ? 'is-invalid' : ''}`}
rows={isReply ? 2 : 3}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={placeholder}
disabled={isSubmitting}
/>
{error && <div className="invalid-feedback">{error}</div>}
</div>
<div className="d-flex gap-2">
<button
type="submit"
className={`btn btn-${isReply ? 'sm btn-outline-' : ''}primary`}
disabled={isSubmitting || !content.trim()}
>
{isSubmitting ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
Posting...
</>
) : (
buttonText
)}
</button>
{onCancel && (
<button
type="button"
className={`btn btn-${isReply ? 'sm btn-outline-' : ''}secondary`}
onClick={onCancel}
disabled={isSubmitting}
>
Cancel
</button>
)}
</div>
</form>
);
};
export default CommentForm;

View File

@@ -0,0 +1,227 @@
import React, { useState } from 'react';
import { ForumComment } from '../types';
import CommentForm from './CommentForm';
interface CommentThreadProps {
comment: ForumComment;
onReply: (commentId: string, content: string) => Promise<void>;
onEdit?: (commentId: string, content: string) => Promise<void>;
onDelete?: (commentId: string) => Promise<void>;
currentUserId?: string;
depth?: number;
}
const CommentThread: React.FC<CommentThreadProps> = ({
comment,
onReply,
onEdit,
onDelete,
currentUserId,
depth = 0,
}) => {
const [showReplyForm, setShowReplyForm] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [editContent, setEditContent] = useState(comment.content);
const [isCollapsed, setIsCollapsed] = useState(false);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMinutes = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMinutes < 1) {
return 'Just now';
} else if (diffMinutes < 60) {
return `${diffMinutes}m ago`;
} else if (diffHours < 24) {
return `${diffHours}h ago`;
} else if (diffDays < 7) {
return `${diffDays}d ago`;
} else {
return date.toLocaleDateString();
}
};
const handleReply = async (content: string) => {
await onReply(comment.id, content);
setShowReplyForm(false);
};
const handleEdit = async () => {
if (onEdit && editContent.trim() !== comment.content) {
await onEdit(comment.id, editContent);
setIsEditing(false);
} else {
setIsEditing(false);
}
};
const handleDelete = async () => {
if (onDelete && window.confirm('Are you sure you want to delete this comment?')) {
await onDelete(comment.id);
}
};
const isAuthor = currentUserId === comment.authorId;
const maxDepth = 5;
const canNest = depth < maxDepth;
if (comment.isDeleted) {
return (
<div className={`comment-deleted ${depth > 0 ? 'ms-4' : ''} mb-3`}>
<div className="card bg-light">
<div className="card-body py-2">
<small className="text-muted fst-italic">
[Comment deleted]
</small>
</div>
</div>
{comment.replies && comment.replies.length > 0 && (
<div className="replies mt-2">
{comment.replies.map((reply) => (
<CommentThread
key={reply.id}
comment={reply}
onReply={onReply}
onEdit={onEdit}
onDelete={onDelete}
currentUserId={currentUserId}
depth={depth + 1}
/>
))}
</div>
)}
</div>
);
}
return (
<div className={`comment ${depth > 0 ? 'ms-4' : ''} mb-3`}>
<div className="card">
<div className="card-body">
<div className="d-flex justify-content-between align-items-start mb-2">
<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: '32px', height: '32px', fontSize: '14px' }}>
{comment.author?.firstName?.charAt(0) || '?'}
</div>
<div>
<strong className="d-block">
{comment.author?.firstName || 'Unknown'} {comment.author?.lastName || ''}
</strong>
<small className="text-muted">
{formatDate(comment.createdAt)}
{comment.updatedAt !== comment.createdAt && ' (edited)'}
</small>
</div>
</div>
{comment.replies && comment.replies.length > 0 && (
<button
className="btn btn-sm btn-link text-decoration-none"
onClick={() => setIsCollapsed(!isCollapsed)}
>
<i className={`bi bi-chevron-${isCollapsed ? 'down' : 'up'}`}></i>
</button>
)}
</div>
{isEditing ? (
<div className="mb-2">
<textarea
className="form-control mb-2"
rows={3}
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
/>
<div className="d-flex gap-2">
<button
className="btn btn-sm btn-primary"
onClick={handleEdit}
disabled={!editContent.trim()}
>
Save
</button>
<button
className="btn btn-sm btn-secondary"
onClick={() => {
setIsEditing(false);
setEditContent(comment.content);
}}
>
Cancel
</button>
</div>
</div>
) : (
<p className="card-text mb-2" style={{ whiteSpace: 'pre-wrap' }}>
{comment.content}
</p>
)}
<div className="d-flex gap-2">
{!isEditing && canNest && (
<button
className="btn btn-sm btn-link text-decoration-none p-0"
onClick={() => setShowReplyForm(!showReplyForm)}
>
<i className="bi bi-reply me-1"></i>
Reply
</button>
)}
{isAuthor && onEdit && !isEditing && (
<button
className="btn btn-sm btn-link text-decoration-none p-0"
onClick={() => setIsEditing(true)}
>
<i className="bi bi-pencil me-1"></i>
Edit
</button>
)}
{isAuthor && onDelete && !isEditing && (
<button
className="btn btn-sm btn-link text-danger text-decoration-none p-0"
onClick={handleDelete}
>
<i className="bi bi-trash me-1"></i>
Delete
</button>
)}
</div>
</div>
</div>
{showReplyForm && (
<div className="mt-2">
<CommentForm
onSubmit={handleReply}
onCancel={() => setShowReplyForm(false)}
placeholder="Write your reply..."
buttonText="Post Reply"
isReply={true}
/>
</div>
)}
{!isCollapsed && comment.replies && comment.replies.length > 0 && (
<div className="replies mt-2">
{comment.replies.map((reply) => (
<CommentThread
key={reply.id}
comment={reply}
onReply={onReply}
onEdit={onEdit}
onDelete={onDelete}
currentUserId={currentUserId}
depth={depth + 1}
/>
))}
</div>
)}
</div>
);
};
export default CommentThread;

View File

@@ -0,0 +1,115 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ForumPost } from '../types';
import CategoryBadge from './CategoryBadge';
import PostStatusBadge from './PostStatusBadge';
interface ForumPostCardProps {
post: ForumPost;
showActions?: boolean;
}
const ForumPostCard: React.FC<ForumPostCardProps> = ({ post, showActions = true }) => {
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();
}
};
// Strip HTML tags for preview
const getTextPreview = (html: string, maxLength: number = 150) => {
const text = html.replace(/<[^>]*>/g, '');
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
};
return (
<div className="card h-100 shadow-sm hover-shadow">
<div className="card-body">
{post.isPinned && (
<div className="mb-2">
<span className="badge bg-danger">
<i className="bi bi-pin-angle-fill me-1"></i>
Pinned
</span>
</div>
)}
<div className="d-flex justify-content-between align-items-start mb-2">
<h5 className="card-title text-truncate flex-grow-1 me-2">{post.title}</h5>
<PostStatusBadge status={post.status} />
</div>
<div className="mb-2">
<CategoryBadge category={post.category} />
</div>
<p className="card-text text-muted small mb-2">
{getTextPreview(post.content)}
</p>
{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 mb-1">
#{tag.tagName}
</span>
))}
{post.tags.length > 3 && (
<span className="badge bg-light text-dark">
+{post.tags.length - 3}
</span>
)}
</div>
)}
<div className="mb-2">
<small className="text-muted">
<i className="bi bi-person me-1"></i>
{post.author?.firstName || 'Unknown'} {post.author?.lastName || ''}
</small>
</div>
<div className="d-flex justify-content-between align-items-center">
<div className="d-flex gap-3">
<small className="text-muted">
<i className="bi bi-chat me-1"></i>
{post.commentCount || 0}
</small>
<small className="text-muted">
<i className="bi bi-eye me-1"></i>
{post.viewCount || 0}
</small>
</div>
<small className="text-muted">
{formatDate(post.updatedAt)}
</small>
</div>
{showActions && (
<div className="d-grid gap-2 mt-3">
<Link
to={`/forum/${post.id}`}
className="btn btn-outline-primary btn-sm"
>
View Post
</Link>
</div>
)}
</div>
</div>
);
};
export default ForumPostCard;

View File

@@ -1,111 +0,0 @@
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

@@ -253,9 +253,9 @@ const Navbar: React.FC = () => {
</Link>
</li>
<li>
<Link className="dropdown-item" to="/my-requests">
<i className="bi bi-clipboard-check me-2"></i>
Looking For
<Link className="dropdown-item" to="/forum">
<i className="bi bi-chat-dots me-2"></i>
Forum
</Link>
</li>
<li>

View File

@@ -0,0 +1,31 @@
import React from 'react';
interface PostStatusBadgeProps {
status: "open" | "solved" | "closed";
}
const PostStatusBadge: React.FC<PostStatusBadgeProps> = ({ status }) => {
const getStatusConfig = (stat: string) => {
switch (stat) {
case 'open':
return { label: 'Open', color: 'success', icon: 'bi-circle' };
case 'solved':
return { label: 'Solved', color: 'info', icon: 'bi-check-circle' };
case 'closed':
return { label: 'Closed', color: 'secondary', icon: 'bi-x-circle' };
default:
return { label: status, color: 'secondary', icon: 'bi-circle' };
}
};
const config = getStatusConfig(status);
return (
<span className={`badge bg-${config.color}`}>
<i className={`bi ${config.icon} me-1`}></i>
{config.label}
</span>
);
};
export default PostStatusBadge;

View File

@@ -1,295 +0,0 @@
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().toLocaleDateString()}
/>
</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().toLocaleDateString()
}
/>
</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,154 @@
import React, { useState, useEffect, useRef } from 'react';
import { forumAPI } from '../services/api';
interface TagInputProps {
selectedTags: string[];
onChange: (tags: string[]) => void;
placeholder?: string;
}
const TagInput: React.FC<TagInputProps> = ({
selectedTags,
onChange,
placeholder = 'Add tags...',
}) => {
const [inputValue, setInputValue] = useState('');
const [suggestions, setSuggestions] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [activeSuggestion, setActiveSuggestion] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const suggestionsRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const fetchSuggestions = async () => {
if (inputValue.length >= 2) {
try {
const response = await forumAPI.getTags({ search: inputValue });
const tags = response.data.map((tag: any) => tag.tagName);
setSuggestions(tags.filter((tag: string) => !selectedTags.includes(tag)));
setShowSuggestions(true);
} catch (error) {
console.error('Failed to fetch tag suggestions:', error);
}
} else {
setSuggestions([]);
setShowSuggestions(false);
}
};
const debounceTimer = setTimeout(fetchSuggestions, 300);
return () => clearTimeout(debounceTimer);
}, [inputValue, selectedTags]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
suggestionsRef.current &&
!suggestionsRef.current.contains(event.target as Node) &&
inputRef.current &&
!inputRef.current.contains(event.target as Node)
) {
setShowSuggestions(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const addTag = (tag: string) => {
const normalizedTag = tag.toLowerCase().trim();
if (normalizedTag && !selectedTags.includes(normalizedTag)) {
onChange([...selectedTags, normalizedTag]);
setInputValue('');
setSuggestions([]);
setShowSuggestions(false);
setActiveSuggestion(0);
}
};
const removeTag = (tagToRemove: string) => {
onChange(selectedTags.filter((tag) => tag !== tagToRemove));
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
if (showSuggestions && suggestions.length > 0 && activeSuggestion < suggestions.length) {
addTag(suggestions[activeSuggestion]);
} else if (inputValue.trim()) {
addTag(inputValue);
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveSuggestion((prev) => Math.min(prev + 1, suggestions.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveSuggestion((prev) => Math.max(prev - 1, 0));
} else if (e.key === 'Escape') {
setShowSuggestions(false);
} else if (e.key === 'Backspace' && !inputValue && selectedTags.length > 0) {
removeTag(selectedTags[selectedTags.length - 1]);
}
};
return (
<div className="tag-input-container">
<div className="form-control d-flex flex-wrap gap-1 p-2" style={{ minHeight: '45px' }}>
{selectedTags.map((tag) => (
<span key={tag} className="badge bg-primary d-flex align-items-center gap-1">
#{tag}
<button
type="button"
className="btn-close btn-close-white"
style={{ fontSize: '0.6rem' }}
onClick={() => removeTag(tag)}
aria-label="Remove tag"
></button>
</span>
))}
<input
ref={inputRef}
type="text"
className="border-0 flex-grow-1"
style={{ outline: 'none', minWidth: '150px' }}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => {
if (suggestions.length > 0) setShowSuggestions(true);
}}
placeholder={selectedTags.length === 0 ? placeholder : ''}
/>
</div>
{showSuggestions && suggestions.length > 0 && (
<div
ref={suggestionsRef}
className="list-group position-absolute w-100 shadow-sm"
style={{ zIndex: 1000, maxHeight: '200px', overflowY: 'auto' }}
>
{suggestions.map((suggestion, index) => (
<button
key={suggestion}
type="button"
className={`list-group-item list-group-item-action ${
index === activeSuggestion ? 'active' : ''
}`}
onClick={() => addTag(suggestion)}
onMouseEnter={() => setActiveSuggestion(index)}
>
#{suggestion}
</button>
))}
</div>
)}
<small className="form-text text-muted">
Press Enter to add a tag. Use letters, numbers, and hyphens only.
</small>
</div>
);
};
export default TagInput;

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

@@ -254,18 +254,22 @@ 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 const forumAPI = {
getPosts: (params?: any) => api.get("/forum/posts", { params }),
getPost: (id: string) => api.get(`/forum/posts/${id}`),
createPost: (data: any) => api.post("/forum/posts", data),
updatePost: (id: string, data: any) => api.put(`/forum/posts/${id}`, data),
deletePost: (id: string) => api.delete(`/forum/posts/${id}`),
updatePostStatus: (id: string, status: string) =>
api.patch(`/forum/posts/${id}/status`, { status }),
getMyPosts: () => api.get("/forum/my-posts"),
getTags: (params?: any) => api.get("/forum/tags", { params }),
createComment: (postId: string, data: any) =>
api.post(`/forum/posts/${postId}/comments`, data),
updateComment: (commentId: string, data: any) =>
api.put(`/forum/comments/${commentId}`, data),
deleteComment: (commentId: string) =>
api.delete(`/forum/comments/${commentId}`),
};
export const stripeAPI = {

View File

@@ -255,55 +255,42 @@ export interface ConditionCheckTimeline {
};
}
export interface ItemRequest {
export interface ForumPost {
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;
maxPricePerWeek?: number;
maxPricePerMonth?: number;
preferredStartDate?: string;
preferredEndDate?: string;
isFlexibleDates: boolean;
status: "open" | "fulfilled" | "closed";
requesterId: string;
requester?: User;
responseCount: number;
responses?: ItemRequestResponse[];
content: string;
authorId: string;
category: "item_request" | "technical_support" | "community_resources" | "general_discussion";
status: "open" | "solved" | "closed";
viewCount: number;
commentCount: number;
isPinned: boolean;
author?: User;
tags?: PostTag[];
comments?: ForumComment[];
createdAt: string;
updatedAt: string;
}
export interface ItemRequestResponse {
export interface ForumComment {
id: string;
itemRequestId: string;
responderId: string;
message: string;
offerPricePerHour?: number;
offerPricePerDay?: number;
offerPricePerWeek?: number;
offerPricePerMonth?: number;
availableStartDate?: string;
availableEndDate?: string;
existingItemId?: string;
status: "pending" | "accepted" | "declined" | "expired";
contactInfo?: string;
responder?: User;
existingItem?: Item;
itemRequest?: ItemRequest;
postId: string;
authorId: string;
content: string;
parentCommentId?: string;
isDeleted: boolean;
author?: User;
replies?: ForumComment[];
createdAt: string;
updatedAt: string;
}
export interface PostTag {
id: string;
postId: string;
tagName: string;
}
export interface RefundPreview {
canCancel: boolean;
cancelledBy: "renter" | "owner";