essential forum code
This commit is contained in:
44
backend/models/ForumComment.js
Normal file
44
backend/models/ForumComment.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const sequelize = require('../config/database');
|
||||||
|
|
||||||
|
const ForumComment = sequelize.define('ForumComment', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
postId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'ForumPosts',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
authorId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'Users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
parentCommentId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'ForumComments',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isDeleted: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = ForumComment;
|
||||||
49
backend/models/ForumPost.js
Normal file
49
backend/models/ForumPost.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const sequelize = require('../config/database');
|
||||||
|
|
||||||
|
const ForumPost = sequelize.define('ForumPost', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
authorId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'Users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
type: DataTypes.ENUM('item_request', 'technical_support', 'community_resources', 'general_discussion'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'general_discussion'
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM('open', 'solved', 'closed'),
|
||||||
|
defaultValue: 'open'
|
||||||
|
},
|
||||||
|
viewCount: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
commentCount: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
isPinned: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = ForumPost;
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
const { DataTypes } = require('sequelize');
|
|
||||||
const sequelize = require('../config/database');
|
|
||||||
|
|
||||||
const ItemRequest = sequelize.define('ItemRequest', {
|
|
||||||
id: {
|
|
||||||
type: DataTypes.UUID,
|
|
||||||
defaultValue: DataTypes.UUIDV4,
|
|
||||||
primaryKey: true
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
type: DataTypes.STRING,
|
|
||||||
allowNull: false
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
allowNull: false
|
|
||||||
},
|
|
||||||
address1: {
|
|
||||||
type: DataTypes.STRING
|
|
||||||
},
|
|
||||||
address2: {
|
|
||||||
type: DataTypes.STRING
|
|
||||||
},
|
|
||||||
city: {
|
|
||||||
type: DataTypes.STRING
|
|
||||||
},
|
|
||||||
state: {
|
|
||||||
type: DataTypes.STRING
|
|
||||||
},
|
|
||||||
zipCode: {
|
|
||||||
type: DataTypes.STRING
|
|
||||||
},
|
|
||||||
country: {
|
|
||||||
type: DataTypes.STRING
|
|
||||||
},
|
|
||||||
latitude: {
|
|
||||||
type: DataTypes.DECIMAL(10, 8)
|
|
||||||
},
|
|
||||||
longitude: {
|
|
||||||
type: DataTypes.DECIMAL(11, 8)
|
|
||||||
},
|
|
||||||
maxPricePerHour: {
|
|
||||||
type: DataTypes.DECIMAL(10, 2)
|
|
||||||
},
|
|
||||||
maxPricePerDay: {
|
|
||||||
type: DataTypes.DECIMAL(10, 2)
|
|
||||||
},
|
|
||||||
preferredStartDate: {
|
|
||||||
type: DataTypes.DATE
|
|
||||||
},
|
|
||||||
preferredEndDate: {
|
|
||||||
type: DataTypes.DATE
|
|
||||||
},
|
|
||||||
isFlexibleDates: {
|
|
||||||
type: DataTypes.BOOLEAN,
|
|
||||||
defaultValue: true
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
type: DataTypes.ENUM('open', 'fulfilled', 'closed'),
|
|
||||||
defaultValue: 'open'
|
|
||||||
},
|
|
||||||
requesterId: {
|
|
||||||
type: DataTypes.UUID,
|
|
||||||
allowNull: false,
|
|
||||||
references: {
|
|
||||||
model: 'Users',
|
|
||||||
key: 'id'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
responseCount: {
|
|
||||||
type: DataTypes.INTEGER,
|
|
||||||
defaultValue: 0
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = ItemRequest;
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
const { DataTypes } = require('sequelize');
|
|
||||||
const sequelize = require('../config/database');
|
|
||||||
|
|
||||||
const ItemRequestResponse = sequelize.define('ItemRequestResponse', {
|
|
||||||
id: {
|
|
||||||
type: DataTypes.UUID,
|
|
||||||
defaultValue: DataTypes.UUIDV4,
|
|
||||||
primaryKey: true
|
|
||||||
},
|
|
||||||
itemRequestId: {
|
|
||||||
type: DataTypes.UUID,
|
|
||||||
allowNull: false,
|
|
||||||
references: {
|
|
||||||
model: 'ItemRequests',
|
|
||||||
key: 'id'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
responderId: {
|
|
||||||
type: DataTypes.UUID,
|
|
||||||
allowNull: false,
|
|
||||||
references: {
|
|
||||||
model: 'Users',
|
|
||||||
key: 'id'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
message: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
allowNull: false
|
|
||||||
},
|
|
||||||
offerPricePerHour: {
|
|
||||||
type: DataTypes.DECIMAL(10, 2)
|
|
||||||
},
|
|
||||||
offerPricePerDay: {
|
|
||||||
type: DataTypes.DECIMAL(10, 2)
|
|
||||||
},
|
|
||||||
offerPricePerWeek: {
|
|
||||||
type: DataTypes.DECIMAL(10, 2)
|
|
||||||
},
|
|
||||||
offerPricePerMonth: {
|
|
||||||
type: DataTypes.DECIMAL(10, 2)
|
|
||||||
},
|
|
||||||
availableStartDate: {
|
|
||||||
type: DataTypes.DATE
|
|
||||||
},
|
|
||||||
availableEndDate: {
|
|
||||||
type: DataTypes.DATE
|
|
||||||
},
|
|
||||||
existingItemId: {
|
|
||||||
type: DataTypes.UUID,
|
|
||||||
allowNull: true,
|
|
||||||
references: {
|
|
||||||
model: 'Items',
|
|
||||||
key: 'id'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
type: DataTypes.ENUM('pending', 'accepted', 'declined', 'expired'),
|
|
||||||
defaultValue: 'pending'
|
|
||||||
},
|
|
||||||
contactInfo: {
|
|
||||||
type: DataTypes.STRING
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = ItemRequestResponse;
|
|
||||||
24
backend/models/PostTag.js
Normal file
24
backend/models/PostTag.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const sequelize = require('../config/database');
|
||||||
|
|
||||||
|
const PostTag = sequelize.define('PostTag', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
postId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'ForumPosts',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tagName: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = PostTag;
|
||||||
@@ -3,8 +3,9 @@ const User = require("./User");
|
|||||||
const Item = require("./Item");
|
const Item = require("./Item");
|
||||||
const Rental = require("./Rental");
|
const Rental = require("./Rental");
|
||||||
const Message = require("./Message");
|
const Message = require("./Message");
|
||||||
const ItemRequest = require("./ItemRequest");
|
const ForumPost = require("./ForumPost");
|
||||||
const ItemRequestResponse = require("./ItemRequestResponse");
|
const ForumComment = require("./ForumComment");
|
||||||
|
const PostTag = require("./PostTag");
|
||||||
const UserAddress = require("./UserAddress");
|
const UserAddress = require("./UserAddress");
|
||||||
const ConditionCheck = require("./ConditionCheck");
|
const ConditionCheck = require("./ConditionCheck");
|
||||||
const AlphaInvitation = require("./AlphaInvitation");
|
const AlphaInvitation = require("./AlphaInvitation");
|
||||||
@@ -31,29 +32,22 @@ Message.belongsTo(Message, {
|
|||||||
foreignKey: "parentMessageId",
|
foreignKey: "parentMessageId",
|
||||||
});
|
});
|
||||||
|
|
||||||
User.hasMany(ItemRequest, { as: "itemRequests", foreignKey: "requesterId" });
|
// Forum associations
|
||||||
ItemRequest.belongsTo(User, { as: "requester", foreignKey: "requesterId" });
|
User.hasMany(ForumPost, { as: "forumPosts", foreignKey: "authorId" });
|
||||||
|
ForumPost.belongsTo(User, { as: "author", foreignKey: "authorId" });
|
||||||
|
|
||||||
User.hasMany(ItemRequestResponse, {
|
User.hasMany(ForumComment, { as: "forumComments", foreignKey: "authorId" });
|
||||||
as: "itemRequestResponses",
|
ForumComment.belongsTo(User, { as: "author", foreignKey: "authorId" });
|
||||||
foreignKey: "responderId",
|
|
||||||
});
|
ForumPost.hasMany(ForumComment, { as: "comments", foreignKey: "postId" });
|
||||||
ItemRequest.hasMany(ItemRequestResponse, {
|
ForumComment.belongsTo(ForumPost, { as: "post", foreignKey: "postId" });
|
||||||
as: "responses",
|
|
||||||
foreignKey: "itemRequestId",
|
// Self-referential association for nested comments
|
||||||
});
|
ForumComment.hasMany(ForumComment, { as: "replies", foreignKey: "parentCommentId" });
|
||||||
ItemRequestResponse.belongsTo(User, {
|
ForumComment.belongsTo(ForumComment, { as: "parentComment", foreignKey: "parentCommentId" });
|
||||||
as: "responder",
|
|
||||||
foreignKey: "responderId",
|
ForumPost.hasMany(PostTag, { as: "tags", foreignKey: "postId" });
|
||||||
});
|
PostTag.belongsTo(ForumPost, { as: "post", foreignKey: "postId" });
|
||||||
ItemRequestResponse.belongsTo(ItemRequest, {
|
|
||||||
as: "itemRequest",
|
|
||||||
foreignKey: "itemRequestId",
|
|
||||||
});
|
|
||||||
ItemRequestResponse.belongsTo(Item, {
|
|
||||||
as: "existingItem",
|
|
||||||
foreignKey: "existingItemId",
|
|
||||||
});
|
|
||||||
|
|
||||||
User.hasMany(UserAddress, { as: "addresses", foreignKey: "userId" });
|
User.hasMany(UserAddress, { as: "addresses", foreignKey: "userId" });
|
||||||
UserAddress.belongsTo(User, { as: "user", foreignKey: "userId" });
|
UserAddress.belongsTo(User, { as: "user", foreignKey: "userId" });
|
||||||
@@ -93,8 +87,9 @@ module.exports = {
|
|||||||
Item,
|
Item,
|
||||||
Rental,
|
Rental,
|
||||||
Message,
|
Message,
|
||||||
ItemRequest,
|
ForumPost,
|
||||||
ItemRequestResponse,
|
ForumComment,
|
||||||
|
PostTag,
|
||||||
UserAddress,
|
UserAddress,
|
||||||
ConditionCheck,
|
ConditionCheck,
|
||||||
AlphaInvitation,
|
AlphaInvitation,
|
||||||
|
|||||||
641
backend/routes/forum.js
Normal file
641
backend/routes/forum.js
Normal file
@@ -0,0 +1,641 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
const { ForumPost, ForumComment, PostTag, User } = require('../models');
|
||||||
|
const { authenticateToken } = require('../middleware/auth');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Helper function to build nested comment tree
|
||||||
|
const buildCommentTree = (comments) => {
|
||||||
|
const commentMap = {};
|
||||||
|
const rootComments = [];
|
||||||
|
|
||||||
|
// Create a map of all comments
|
||||||
|
comments.forEach(comment => {
|
||||||
|
commentMap[comment.id] = { ...comment.toJSON(), replies: [] };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build the tree structure
|
||||||
|
comments.forEach(comment => {
|
||||||
|
if (comment.parentCommentId && commentMap[comment.parentCommentId]) {
|
||||||
|
commentMap[comment.parentCommentId].replies.push(commentMap[comment.id]);
|
||||||
|
} else if (!comment.parentCommentId) {
|
||||||
|
rootComments.push(commentMap[comment.id]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return rootComments;
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET /api/forum/posts - Browse all posts
|
||||||
|
router.get('/posts', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
search,
|
||||||
|
category,
|
||||||
|
tag,
|
||||||
|
status,
|
||||||
|
page = 1,
|
||||||
|
limit = 20,
|
||||||
|
sort = 'recent'
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const where = {};
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
where.category = category;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where[Op.or] = [
|
||||||
|
{ title: { [Op.iLike]: `%${search}%` } },
|
||||||
|
{ content: { [Op.iLike]: `%${search}%` } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
// Determine sort order
|
||||||
|
let order;
|
||||||
|
switch (sort) {
|
||||||
|
case 'comments':
|
||||||
|
order = [['commentCount', 'DESC'], ['createdAt', 'DESC']];
|
||||||
|
break;
|
||||||
|
case 'views':
|
||||||
|
order = [['viewCount', 'DESC'], ['createdAt', 'DESC']];
|
||||||
|
break;
|
||||||
|
case 'recent':
|
||||||
|
default:
|
||||||
|
order = [['isPinned', 'DESC'], ['updatedAt', 'DESC']];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const include = [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'author',
|
||||||
|
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: PostTag,
|
||||||
|
as: 'tags',
|
||||||
|
attributes: ['tagName']
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Filter by tag if provided
|
||||||
|
if (tag) {
|
||||||
|
include[1].where = { tagName: tag };
|
||||||
|
include[1].required = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { count, rows } = await ForumPost.findAndCountAll({
|
||||||
|
where,
|
||||||
|
include,
|
||||||
|
limit: parseInt(limit),
|
||||||
|
offset: parseInt(offset),
|
||||||
|
order,
|
||||||
|
distinct: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.info("Forum posts fetched", {
|
||||||
|
search,
|
||||||
|
category,
|
||||||
|
tag,
|
||||||
|
status,
|
||||||
|
postsCount: count,
|
||||||
|
page: parseInt(page),
|
||||||
|
limit: parseInt(limit)
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
posts: rows,
|
||||||
|
totalPages: Math.ceil(count / limit),
|
||||||
|
currentPage: parseInt(page),
|
||||||
|
totalPosts: count
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error("Forum posts fetch failed", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
query: req.query
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/forum/posts/:id - Get single post with all comments
|
||||||
|
router.get('/posts/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const post = await ForumPost.findByPk(req.params.id, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'author',
|
||||||
|
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: PostTag,
|
||||||
|
as: 'tags',
|
||||||
|
attributes: ['tagName']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: ForumComment,
|
||||||
|
as: 'comments',
|
||||||
|
where: { isDeleted: false },
|
||||||
|
required: false,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'author',
|
||||||
|
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'ASC']]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return res.status(404).json({ error: 'Post not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment view count
|
||||||
|
await post.increment('viewCount');
|
||||||
|
|
||||||
|
// Build nested comment tree
|
||||||
|
const postData = post.toJSON();
|
||||||
|
postData.comments = buildCommentTree(post.comments);
|
||||||
|
postData.viewCount += 1; // Reflect the increment
|
||||||
|
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.info("Forum post fetched", {
|
||||||
|
postId: req.params.id,
|
||||||
|
authorId: post.authorId
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(postData);
|
||||||
|
} catch (error) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error("Forum post fetch failed", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
postId: req.params.id
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/forum/posts - Create new post
|
||||||
|
router.post('/posts', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { title, content, category, tags } = req.body;
|
||||||
|
|
||||||
|
const post = await ForumPost.create({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
category,
|
||||||
|
authorId: req.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create tags if provided
|
||||||
|
if (tags && Array.isArray(tags) && tags.length > 0) {
|
||||||
|
const tagPromises = tags.map(tagName =>
|
||||||
|
PostTag.create({
|
||||||
|
postId: post.id,
|
||||||
|
tagName: tagName.toLowerCase().trim()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.all(tagPromises);
|
||||||
|
}
|
||||||
|
|
||||||
|
const postWithDetails = await ForumPost.findByPk(post.id, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'author',
|
||||||
|
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: PostTag,
|
||||||
|
as: 'tags',
|
||||||
|
attributes: ['tagName']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.info("Forum post created", {
|
||||||
|
postId: post.id,
|
||||||
|
authorId: req.user.id,
|
||||||
|
category,
|
||||||
|
title
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(postWithDetails);
|
||||||
|
} catch (error) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error("Forum post creation failed", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
authorId: req.user.id,
|
||||||
|
postData: logger.sanitize(req.body)
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/forum/posts/:id - Update post
|
||||||
|
router.put('/posts/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const post = await ForumPost.findByPk(req.params.id);
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return res.status(404).json({ error: 'Post not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (post.authorId !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, content, category, tags } = req.body;
|
||||||
|
|
||||||
|
await post.update({ title, content, category });
|
||||||
|
|
||||||
|
// Update tags if provided
|
||||||
|
if (tags !== undefined) {
|
||||||
|
// Delete existing tags
|
||||||
|
await PostTag.destroy({ where: { postId: post.id } });
|
||||||
|
|
||||||
|
// Create new tags
|
||||||
|
if (Array.isArray(tags) && tags.length > 0) {
|
||||||
|
const tagPromises = tags.map(tagName =>
|
||||||
|
PostTag.create({
|
||||||
|
postId: post.id,
|
||||||
|
tagName: tagName.toLowerCase().trim()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.all(tagPromises);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedPost = await ForumPost.findByPk(post.id, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'author',
|
||||||
|
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: PostTag,
|
||||||
|
as: 'tags',
|
||||||
|
attributes: ['tagName']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.info("Forum post updated", {
|
||||||
|
postId: req.params.id,
|
||||||
|
authorId: req.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(updatedPost);
|
||||||
|
} catch (error) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error("Forum post update failed", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
postId: req.params.id,
|
||||||
|
authorId: req.user.id
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/forum/posts/:id - Delete post
|
||||||
|
router.delete('/posts/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const post = await ForumPost.findByPk(req.params.id);
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return res.status(404).json({ error: 'Post not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (post.authorId !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete associated tags and comments
|
||||||
|
await PostTag.destroy({ where: { postId: post.id } });
|
||||||
|
await ForumComment.destroy({ where: { postId: post.id } });
|
||||||
|
await post.destroy();
|
||||||
|
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.info("Forum post deleted", {
|
||||||
|
postId: req.params.id,
|
||||||
|
authorId: req.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error("Forum post deletion failed", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
postId: req.params.id,
|
||||||
|
authorId: req.user.id
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/forum/posts/:id/status - Update post status
|
||||||
|
router.patch('/posts/:id/status', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { status } = req.body;
|
||||||
|
const post = await ForumPost.findByPk(req.params.id);
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return res.status(404).json({ error: 'Post not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (post.authorId !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: 'Only the author can update post status' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['open', 'solved', 'closed'].includes(status)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid status value' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await post.update({ status });
|
||||||
|
|
||||||
|
const updatedPost = await ForumPost.findByPk(post.id, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'author',
|
||||||
|
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: PostTag,
|
||||||
|
as: 'tags',
|
||||||
|
attributes: ['tagName']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.info("Forum post status updated", {
|
||||||
|
postId: req.params.id,
|
||||||
|
newStatus: status,
|
||||||
|
authorId: req.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(updatedPost);
|
||||||
|
} catch (error) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error("Forum post status update failed", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
postId: req.params.id,
|
||||||
|
authorId: req.user.id
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/forum/posts/:id/comments - Add comment/reply
|
||||||
|
router.post('/posts/:id/comments', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { content, parentCommentId } = req.body;
|
||||||
|
const post = await ForumPost.findByPk(req.params.id);
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return res.status(404).json({ error: 'Post not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate parent comment if provided
|
||||||
|
if (parentCommentId) {
|
||||||
|
const parentComment = await ForumComment.findByPk(parentCommentId);
|
||||||
|
if (!parentComment || parentComment.postId !== post.id) {
|
||||||
|
return res.status(400).json({ error: 'Invalid parent comment' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment = await ForumComment.create({
|
||||||
|
postId: req.params.id,
|
||||||
|
authorId: req.user.id,
|
||||||
|
content,
|
||||||
|
parentCommentId: parentCommentId || null
|
||||||
|
});
|
||||||
|
|
||||||
|
// Increment comment count
|
||||||
|
await post.increment('commentCount');
|
||||||
|
|
||||||
|
const commentWithDetails = await ForumComment.findByPk(comment.id, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'author',
|
||||||
|
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.info("Forum comment created", {
|
||||||
|
postId: req.params.id,
|
||||||
|
commentId: comment.id,
|
||||||
|
authorId: req.user.id,
|
||||||
|
isReply: !!parentCommentId
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(commentWithDetails);
|
||||||
|
} catch (error) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error("Forum comment creation failed", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
postId: req.params.id,
|
||||||
|
authorId: req.user.id
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/forum/comments/:id - Edit comment
|
||||||
|
router.put('/comments/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { content } = req.body;
|
||||||
|
const comment = await ForumComment.findByPk(req.params.id);
|
||||||
|
|
||||||
|
if (!comment) {
|
||||||
|
return res.status(404).json({ error: 'Comment not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (comment.authorId !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (comment.isDeleted) {
|
||||||
|
return res.status(400).json({ error: 'Cannot edit deleted comment' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await comment.update({ content });
|
||||||
|
|
||||||
|
const updatedComment = await ForumComment.findByPk(comment.id, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'author',
|
||||||
|
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.info("Forum comment updated", {
|
||||||
|
commentId: req.params.id,
|
||||||
|
authorId: req.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(updatedComment);
|
||||||
|
} catch (error) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error("Forum comment update failed", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
commentId: req.params.id,
|
||||||
|
authorId: req.user.id
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/forum/comments/:id - Soft delete comment
|
||||||
|
router.delete('/comments/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const comment = await ForumComment.findByPk(req.params.id);
|
||||||
|
|
||||||
|
if (!comment) {
|
||||||
|
return res.status(404).json({ error: 'Comment not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (comment.authorId !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
await comment.update({ isDeleted: true, content: '[deleted]' });
|
||||||
|
|
||||||
|
// Decrement comment count
|
||||||
|
const post = await ForumPost.findByPk(comment.postId);
|
||||||
|
if (post && post.commentCount > 0) {
|
||||||
|
await post.decrement('commentCount');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.info("Forum comment deleted", {
|
||||||
|
commentId: req.params.id,
|
||||||
|
authorId: req.user.id,
|
||||||
|
postId: comment.postId
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error("Forum comment deletion failed", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
commentId: req.params.id,
|
||||||
|
authorId: req.user.id
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/forum/my-posts - Get user's posts
|
||||||
|
router.get('/my-posts', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const posts = await ForumPost.findAll({
|
||||||
|
where: { authorId: req.user.id },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: 'author',
|
||||||
|
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: PostTag,
|
||||||
|
as: 'tags',
|
||||||
|
attributes: ['tagName']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.info("User forum posts fetched", {
|
||||||
|
userId: req.user.id,
|
||||||
|
postsCount: posts.length
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(posts);
|
||||||
|
} catch (error) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error("User forum posts fetch failed", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
userId: req.user.id
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/forum/tags - Get all unique tags for autocomplete
|
||||||
|
router.get('/tags', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { search } = req.query;
|
||||||
|
|
||||||
|
const where = {};
|
||||||
|
if (search) {
|
||||||
|
where.tagName = { [Op.iLike]: `%${search}%` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = await PostTag.findAll({
|
||||||
|
where,
|
||||||
|
attributes: [
|
||||||
|
'tagName',
|
||||||
|
[require('sequelize').fn('COUNT', require('sequelize').col('tagName')), 'count']
|
||||||
|
],
|
||||||
|
group: ['tagName'],
|
||||||
|
order: [[require('sequelize').fn('COUNT', require('sequelize').col('tagName')), 'DESC']],
|
||||||
|
limit: 50
|
||||||
|
});
|
||||||
|
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.info("Tags fetched", {
|
||||||
|
search,
|
||||||
|
tagsCount: tags.length
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(tags);
|
||||||
|
} catch (error) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error("Tags fetch failed", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
query: req.query
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -1,396 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const { Op } = require('sequelize');
|
|
||||||
const { ItemRequest, ItemRequestResponse, User, Item } = require('../models');
|
|
||||||
const { authenticateToken } = require('../middleware/auth');
|
|
||||||
const logger = require('../utils/logger');
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const {
|
|
||||||
search,
|
|
||||||
status = 'open',
|
|
||||||
page = 1,
|
|
||||||
limit = 20
|
|
||||||
} = req.query;
|
|
||||||
|
|
||||||
const where = { status };
|
|
||||||
|
|
||||||
if (search) {
|
|
||||||
where[Op.or] = [
|
|
||||||
{ title: { [Op.iLike]: `%${search}%` } },
|
|
||||||
{ description: { [Op.iLike]: `%${search}%` } }
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const offset = (page - 1) * limit;
|
|
||||||
|
|
||||||
const { count, rows } = await ItemRequest.findAndCountAll({
|
|
||||||
where,
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: User,
|
|
||||||
as: 'requester',
|
|
||||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
|
||||||
}
|
|
||||||
],
|
|
||||||
limit: parseInt(limit),
|
|
||||||
offset: parseInt(offset),
|
|
||||||
order: [['createdAt', 'DESC']]
|
|
||||||
});
|
|
||||||
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
|
||||||
reqLogger.info("Item requests fetched", {
|
|
||||||
search,
|
|
||||||
status,
|
|
||||||
requestsCount: count,
|
|
||||||
page: parseInt(page),
|
|
||||||
limit: parseInt(limit)
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
requests: rows,
|
|
||||||
totalPages: Math.ceil(count / limit),
|
|
||||||
currentPage: parseInt(page),
|
|
||||||
totalRequests: count
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
|
||||||
reqLogger.error("Item requests fetch failed", {
|
|
||||||
error: error.message,
|
|
||||||
stack: error.stack,
|
|
||||||
query: req.query
|
|
||||||
});
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/my-requests', authenticateToken, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const requests = await ItemRequest.findAll({
|
|
||||||
where: { requesterId: req.user.id },
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: User,
|
|
||||||
as: 'requester',
|
|
||||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: ItemRequestResponse,
|
|
||||||
as: 'responses',
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: User,
|
|
||||||
as: 'responder',
|
|
||||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: Item,
|
|
||||||
as: 'existingItem'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
order: [['createdAt', 'DESC']]
|
|
||||||
});
|
|
||||||
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
|
||||||
reqLogger.info("User item requests fetched", {
|
|
||||||
userId: req.user.id,
|
|
||||||
requestsCount: requests.length
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(requests);
|
|
||||||
} catch (error) {
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
|
||||||
reqLogger.error("User item requests fetch failed", {
|
|
||||||
error: error.message,
|
|
||||||
stack: error.stack,
|
|
||||||
userId: req.user.id
|
|
||||||
});
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/:id', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const request = await ItemRequest.findByPk(req.params.id, {
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: User,
|
|
||||||
as: 'requester',
|
|
||||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: ItemRequestResponse,
|
|
||||||
as: 'responses',
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: User,
|
|
||||||
as: 'responder',
|
|
||||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: Item,
|
|
||||||
as: 'existingItem'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!request) {
|
|
||||||
return res.status(404).json({ error: 'Item request not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
|
||||||
reqLogger.info("Item request fetched", {
|
|
||||||
requestId: req.params.id,
|
|
||||||
requesterId: request.requesterId
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(request);
|
|
||||||
} catch (error) {
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
|
||||||
reqLogger.error("Item request fetch failed", {
|
|
||||||
error: error.message,
|
|
||||||
stack: error.stack,
|
|
||||||
requestId: req.params.id
|
|
||||||
});
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/', authenticateToken, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const request = await ItemRequest.create({
|
|
||||||
...req.body,
|
|
||||||
requesterId: req.user.id
|
|
||||||
});
|
|
||||||
|
|
||||||
const requestWithRequester = await ItemRequest.findByPk(request.id, {
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: User,
|
|
||||||
as: 'requester',
|
|
||||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
|
||||||
reqLogger.info("Item request created", {
|
|
||||||
requestId: request.id,
|
|
||||||
requesterId: req.user.id,
|
|
||||||
title: req.body.title
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(201).json(requestWithRequester);
|
|
||||||
} catch (error) {
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
|
||||||
reqLogger.error("Item request creation failed", {
|
|
||||||
error: error.message,
|
|
||||||
stack: error.stack,
|
|
||||||
requesterId: req.user.id,
|
|
||||||
requestData: logger.sanitize(req.body)
|
|
||||||
});
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.put('/:id', authenticateToken, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const request = await ItemRequest.findByPk(req.params.id);
|
|
||||||
|
|
||||||
if (!request) {
|
|
||||||
return res.status(404).json({ error: 'Item request not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.requesterId !== req.user.id) {
|
|
||||||
return res.status(403).json({ error: 'Unauthorized' });
|
|
||||||
}
|
|
||||||
|
|
||||||
await request.update(req.body);
|
|
||||||
|
|
||||||
const updatedRequest = await ItemRequest.findByPk(request.id, {
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: User,
|
|
||||||
as: 'requester',
|
|
||||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
|
||||||
reqLogger.info("Item request updated", {
|
|
||||||
requestId: req.params.id,
|
|
||||||
requesterId: req.user.id
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(updatedRequest);
|
|
||||||
} catch (error) {
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
|
||||||
reqLogger.error("Item request update failed", {
|
|
||||||
error: error.message,
|
|
||||||
stack: error.stack,
|
|
||||||
requestId: req.params.id,
|
|
||||||
requesterId: req.user.id
|
|
||||||
});
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete('/:id', authenticateToken, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const request = await ItemRequest.findByPk(req.params.id);
|
|
||||||
|
|
||||||
if (!request) {
|
|
||||||
return res.status(404).json({ error: 'Item request not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.requesterId !== req.user.id) {
|
|
||||||
return res.status(403).json({ error: 'Unauthorized' });
|
|
||||||
}
|
|
||||||
|
|
||||||
await request.destroy();
|
|
||||||
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
|
||||||
reqLogger.info("Item request deleted", {
|
|
||||||
requestId: req.params.id,
|
|
||||||
requesterId: req.user.id
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(204).send();
|
|
||||||
} catch (error) {
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
|
||||||
reqLogger.error("Item request deletion failed", {
|
|
||||||
error: error.message,
|
|
||||||
stack: error.stack,
|
|
||||||
requestId: req.params.id,
|
|
||||||
requesterId: req.user.id
|
|
||||||
});
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/:id/responses', authenticateToken, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const request = await ItemRequest.findByPk(req.params.id);
|
|
||||||
|
|
||||||
if (!request) {
|
|
||||||
return res.status(404).json({ error: 'Item request not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.requesterId === req.user.id) {
|
|
||||||
return res.status(400).json({ error: 'Cannot respond to your own request' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.status !== 'open') {
|
|
||||||
return res.status(400).json({ error: 'Cannot respond to closed request' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await ItemRequestResponse.create({
|
|
||||||
...req.body,
|
|
||||||
itemRequestId: req.params.id,
|
|
||||||
responderId: req.user.id
|
|
||||||
});
|
|
||||||
|
|
||||||
await request.increment('responseCount');
|
|
||||||
|
|
||||||
const responseWithDetails = await ItemRequestResponse.findByPk(response.id, {
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: User,
|
|
||||||
as: 'responder',
|
|
||||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: Item,
|
|
||||||
as: 'existingItem'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
|
||||||
reqLogger.info("Item request response created", {
|
|
||||||
requestId: req.params.id,
|
|
||||||
responseId: response.id,
|
|
||||||
responderId: req.user.id
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(201).json(responseWithDetails);
|
|
||||||
} catch (error) {
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
|
||||||
reqLogger.error("Item request response creation failed", {
|
|
||||||
error: error.message,
|
|
||||||
stack: error.stack,
|
|
||||||
requestId: req.params.id,
|
|
||||||
responderId: req.user.id
|
|
||||||
});
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.put('/responses/:responseId/status', authenticateToken, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { status } = req.body;
|
|
||||||
const response = await ItemRequestResponse.findByPk(req.params.responseId, {
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: ItemRequest,
|
|
||||||
as: 'itemRequest'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response) {
|
|
||||||
return res.status(404).json({ error: 'Response not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.itemRequest.requesterId !== req.user.id) {
|
|
||||||
return res.status(403).json({ error: 'Only the requester can update response status' });
|
|
||||||
}
|
|
||||||
|
|
||||||
await response.update({ status });
|
|
||||||
|
|
||||||
if (status === 'accepted') {
|
|
||||||
await response.itemRequest.update({ status: 'fulfilled' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedResponse = await ItemRequestResponse.findByPk(response.id, {
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: User,
|
|
||||||
as: 'responder',
|
|
||||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: Item,
|
|
||||||
as: 'existingItem'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
|
||||||
reqLogger.info("Item request response status updated", {
|
|
||||||
responseId: req.params.responseId,
|
|
||||||
newStatus: status,
|
|
||||||
requesterId: req.user.id,
|
|
||||||
requestFulfilled: status === 'accepted'
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(updatedResponse);
|
|
||||||
} catch (error) {
|
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
|
||||||
reqLogger.error("Item request response status update failed", {
|
|
||||||
error: error.message,
|
|
||||||
stack: error.stack,
|
|
||||||
responseId: req.params.responseId,
|
|
||||||
requesterId: req.user.id
|
|
||||||
});
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@@ -23,7 +23,7 @@ const userRoutes = require("./routes/users");
|
|||||||
const itemRoutes = require("./routes/items");
|
const itemRoutes = require("./routes/items");
|
||||||
const rentalRoutes = require("./routes/rentals");
|
const rentalRoutes = require("./routes/rentals");
|
||||||
const messageRoutes = require("./routes/messages");
|
const messageRoutes = require("./routes/messages");
|
||||||
const itemRequestRoutes = require("./routes/itemRequests");
|
const forumRoutes = require("./routes/forum");
|
||||||
const stripeRoutes = require("./routes/stripe");
|
const stripeRoutes = require("./routes/stripe");
|
||||||
const mapsRoutes = require("./routes/maps");
|
const mapsRoutes = require("./routes/maps");
|
||||||
const conditionCheckRoutes = require("./routes/conditionChecks");
|
const conditionCheckRoutes = require("./routes/conditionChecks");
|
||||||
@@ -148,7 +148,7 @@ app.use("/api/users", requireAlphaAccess, userRoutes);
|
|||||||
app.use("/api/items", requireAlphaAccess, itemRoutes);
|
app.use("/api/items", requireAlphaAccess, itemRoutes);
|
||||||
app.use("/api/rentals", requireAlphaAccess, rentalRoutes);
|
app.use("/api/rentals", requireAlphaAccess, rentalRoutes);
|
||||||
app.use("/api/messages", requireAlphaAccess, messageRoutes);
|
app.use("/api/messages", requireAlphaAccess, messageRoutes);
|
||||||
app.use("/api/item-requests", requireAlphaAccess, itemRequestRoutes);
|
app.use("/api/forum", requireAlphaAccess, forumRoutes);
|
||||||
app.use("/api/stripe", requireAlphaAccess, stripeRoutes);
|
app.use("/api/stripe", requireAlphaAccess, stripeRoutes);
|
||||||
app.use("/api/maps", requireAlphaAccess, mapsRoutes);
|
app.use("/api/maps", requireAlphaAccess, mapsRoutes);
|
||||||
app.use("/api/condition-checks", requireAlphaAccess, conditionCheckRoutes);
|
app.use("/api/condition-checks", requireAlphaAccess, conditionCheckRoutes);
|
||||||
|
|||||||
@@ -1,823 +0,0 @@
|
|||||||
const request = require('supertest');
|
|
||||||
const express = require('express');
|
|
||||||
const itemRequestsRouter = require('../../../routes/itemRequests');
|
|
||||||
|
|
||||||
// Mock all dependencies
|
|
||||||
jest.mock('../../../models', () => ({
|
|
||||||
ItemRequest: {
|
|
||||||
findAndCountAll: jest.fn(),
|
|
||||||
findAll: jest.fn(),
|
|
||||||
findByPk: jest.fn(),
|
|
||||||
create: jest.fn(),
|
|
||||||
},
|
|
||||||
ItemRequestResponse: {
|
|
||||||
findByPk: jest.fn(),
|
|
||||||
create: jest.fn(),
|
|
||||||
},
|
|
||||||
User: jest.fn(),
|
|
||||||
Item: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('../../../middleware/auth', () => ({
|
|
||||||
authenticateToken: jest.fn((req, res, next) => {
|
|
||||||
req.user = { id: 1 };
|
|
||||||
next();
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('sequelize', () => ({
|
|
||||||
Op: {
|
|
||||||
or: Symbol('or'),
|
|
||||||
iLike: Symbol('iLike'),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { ItemRequest, ItemRequestResponse, User, Item } = require('../../../models');
|
|
||||||
|
|
||||||
// Create express app with the router
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use('/item-requests', itemRequestsRouter);
|
|
||||||
|
|
||||||
// Mock models
|
|
||||||
const mockItemRequestFindAndCountAll = ItemRequest.findAndCountAll;
|
|
||||||
const mockItemRequestFindAll = ItemRequest.findAll;
|
|
||||||
const mockItemRequestFindByPk = ItemRequest.findByPk;
|
|
||||||
const mockItemRequestCreate = ItemRequest.create;
|
|
||||||
const mockItemRequestResponseFindByPk = ItemRequestResponse.findByPk;
|
|
||||||
const mockItemRequestResponseCreate = ItemRequestResponse.create;
|
|
||||||
|
|
||||||
describe('ItemRequests Routes', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GET /', () => {
|
|
||||||
it('should get item requests with default pagination and status', async () => {
|
|
||||||
const mockRequestsData = {
|
|
||||||
count: 25,
|
|
||||||
rows: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: 'Need a Camera',
|
|
||||||
description: 'Looking for a DSLR camera for weekend photography',
|
|
||||||
status: 'open',
|
|
||||||
requesterId: 2,
|
|
||||||
createdAt: '2024-01-15T10:00:00.000Z',
|
|
||||||
requester: {
|
|
||||||
id: 2,
|
|
||||||
username: 'jane_doe',
|
|
||||||
firstName: 'Jane',
|
|
||||||
lastName: 'Doe'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: 'Power Drill Needed',
|
|
||||||
description: 'Need a drill for home improvement project',
|
|
||||||
status: 'open',
|
|
||||||
requesterId: 3,
|
|
||||||
createdAt: '2024-01-14T10:00:00.000Z',
|
|
||||||
requester: {
|
|
||||||
id: 3,
|
|
||||||
username: 'bob_smith',
|
|
||||||
firstName: 'Bob',
|
|
||||||
lastName: 'Smith'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
mockItemRequestFindAndCountAll.mockResolvedValue(mockRequestsData);
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.get('/item-requests');
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body).toEqual({
|
|
||||||
requests: mockRequestsData.rows,
|
|
||||||
totalPages: 2,
|
|
||||||
currentPage: 1,
|
|
||||||
totalRequests: 25
|
|
||||||
});
|
|
||||||
expect(mockItemRequestFindAndCountAll).toHaveBeenCalledWith({
|
|
||||||
where: { status: 'open' },
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: User,
|
|
||||||
as: 'requester',
|
|
||||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
|
||||||
}
|
|
||||||
],
|
|
||||||
limit: 20,
|
|
||||||
offset: 0,
|
|
||||||
order: [['createdAt', 'DESC']]
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should filter requests with search query', async () => {
|
|
||||||
const mockSearchResults = {
|
|
||||||
count: 5,
|
|
||||||
rows: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: 'Need a Camera',
|
|
||||||
description: 'Looking for a DSLR camera',
|
|
||||||
status: 'open'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
mockItemRequestFindAndCountAll.mockResolvedValue(mockSearchResults);
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.get('/item-requests?search=camera&page=1&limit=10');
|
|
||||||
|
|
||||||
const { Op } = require('sequelize');
|
|
||||||
expect(mockItemRequestFindAndCountAll).toHaveBeenCalledWith({
|
|
||||||
where: {
|
|
||||||
status: 'open',
|
|
||||||
[Op.or]: [
|
|
||||||
{ title: { [Op.iLike]: '%camera%' } },
|
|
||||||
{ description: { [Op.iLike]: '%camera%' } }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
include: expect.any(Array),
|
|
||||||
limit: 10,
|
|
||||||
offset: 0,
|
|
||||||
order: [['createdAt', 'DESC']]
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle custom pagination', async () => {
|
|
||||||
const mockData = { count: 50, rows: [] };
|
|
||||||
mockItemRequestFindAndCountAll.mockResolvedValue(mockData);
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.get('/item-requests?page=3&limit=5');
|
|
||||||
|
|
||||||
expect(mockItemRequestFindAndCountAll).toHaveBeenCalledWith({
|
|
||||||
where: { status: 'open' },
|
|
||||||
include: expect.any(Array),
|
|
||||||
limit: 5,
|
|
||||||
offset: 10, // (3-1) * 5
|
|
||||||
order: [['createdAt', 'DESC']]
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should filter by custom status', async () => {
|
|
||||||
const mockData = { count: 10, rows: [] };
|
|
||||||
mockItemRequestFindAndCountAll.mockResolvedValue(mockData);
|
|
||||||
|
|
||||||
await request(app)
|
|
||||||
.get('/item-requests?status=fulfilled');
|
|
||||||
|
|
||||||
expect(mockItemRequestFindAndCountAll).toHaveBeenCalledWith({
|
|
||||||
where: { status: 'fulfilled' },
|
|
||||||
include: expect.any(Array),
|
|
||||||
limit: 20,
|
|
||||||
offset: 0,
|
|
||||||
order: [['createdAt', 'DESC']]
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle database errors', async () => {
|
|
||||||
mockItemRequestFindAndCountAll.mockRejectedValue(new Error('Database error'));
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.get('/item-requests');
|
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
|
||||||
expect(response.body).toEqual({ error: 'Database error' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GET /my-requests', () => {
|
|
||||||
it('should get user\'s own requests with responses', async () => {
|
|
||||||
const mockRequests = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: 'My Camera Request',
|
|
||||||
description: 'Need a camera',
|
|
||||||
status: 'open',
|
|
||||||
requesterId: 1,
|
|
||||||
requester: {
|
|
||||||
id: 1,
|
|
||||||
username: 'john_doe',
|
|
||||||
firstName: 'John',
|
|
||||||
lastName: 'Doe'
|
|
||||||
},
|
|
||||||
responses: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
message: 'I have a Canon DSLR available',
|
|
||||||
responder: {
|
|
||||||
id: 2,
|
|
||||||
username: 'jane_doe',
|
|
||||||
firstName: 'Jane',
|
|
||||||
lastName: 'Doe'
|
|
||||||
},
|
|
||||||
existingItem: {
|
|
||||||
id: 5,
|
|
||||||
name: 'Canon EOS 5D',
|
|
||||||
description: 'Professional DSLR camera'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
mockItemRequestFindAll.mockResolvedValue(mockRequests);
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.get('/item-requests/my-requests');
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body).toEqual(mockRequests);
|
|
||||||
expect(mockItemRequestFindAll).toHaveBeenCalledWith({
|
|
||||||
where: { requesterId: 1 },
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: User,
|
|
||||||
as: 'requester',
|
|
||||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: ItemRequestResponse,
|
|
||||||
as: 'responses',
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: User,
|
|
||||||
as: 'responder',
|
|
||||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: Item,
|
|
||||||
as: 'existingItem'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
order: [['createdAt', 'DESC']]
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle database errors', async () => {
|
|
||||||
mockItemRequestFindAll.mockRejectedValue(new Error('Database error'));
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.get('/item-requests/my-requests');
|
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
|
||||||
expect(response.body).toEqual({ error: 'Database error' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GET /:id', () => {
|
|
||||||
it('should get specific request with responses', async () => {
|
|
||||||
const mockRequest = {
|
|
||||||
id: 1,
|
|
||||||
title: 'Camera Request',
|
|
||||||
description: 'Need a DSLR camera',
|
|
||||||
status: 'open',
|
|
||||||
requesterId: 2,
|
|
||||||
requester: {
|
|
||||||
id: 2,
|
|
||||||
username: 'jane_doe',
|
|
||||||
firstName: 'Jane',
|
|
||||||
lastName: 'Doe'
|
|
||||||
},
|
|
||||||
responses: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
message: 'I have a Canon DSLR',
|
|
||||||
responder: {
|
|
||||||
id: 1,
|
|
||||||
username: 'john_doe',
|
|
||||||
firstName: 'John',
|
|
||||||
lastName: 'Doe'
|
|
||||||
},
|
|
||||||
existingItem: null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
mockItemRequestFindByPk.mockResolvedValue(mockRequest);
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.get('/item-requests/1');
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body).toEqual(mockRequest);
|
|
||||||
expect(mockItemRequestFindByPk).toHaveBeenCalledWith('1', {
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: User,
|
|
||||||
as: 'requester',
|
|
||||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: ItemRequestResponse,
|
|
||||||
as: 'responses',
|
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: User,
|
|
||||||
as: 'responder',
|
|
||||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: Item,
|
|
||||||
as: 'existingItem'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 for non-existent request', async () => {
|
|
||||||
mockItemRequestFindByPk.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.get('/item-requests/999');
|
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
expect(response.body).toEqual({ error: 'Item request not found' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle database errors', async () => {
|
|
||||||
mockItemRequestFindByPk.mockRejectedValue(new Error('Database error'));
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.get('/item-requests/1');
|
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
|
||||||
expect(response.body).toEqual({ error: 'Database error' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /', () => {
|
|
||||||
it('should create a new item request', async () => {
|
|
||||||
const requestData = {
|
|
||||||
title: 'Need a Drill',
|
|
||||||
description: 'Looking for a power drill for weekend project',
|
|
||||||
category: 'tools',
|
|
||||||
budget: 50,
|
|
||||||
location: 'New York'
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockCreatedRequest = {
|
|
||||||
id: 3,
|
|
||||||
...requestData,
|
|
||||||
requesterId: 1,
|
|
||||||
status: 'open'
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockRequestWithRequester = {
|
|
||||||
...mockCreatedRequest,
|
|
||||||
requester: {
|
|
||||||
id: 1,
|
|
||||||
username: 'john_doe',
|
|
||||||
firstName: 'John',
|
|
||||||
lastName: 'Doe'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
mockItemRequestCreate.mockResolvedValue(mockCreatedRequest);
|
|
||||||
mockItemRequestFindByPk.mockResolvedValue(mockRequestWithRequester);
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.post('/item-requests')
|
|
||||||
.send(requestData);
|
|
||||||
|
|
||||||
expect(response.status).toBe(201);
|
|
||||||
expect(response.body).toEqual(mockRequestWithRequester);
|
|
||||||
expect(mockItemRequestCreate).toHaveBeenCalledWith({
|
|
||||||
...requestData,
|
|
||||||
requesterId: 1
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle database errors during creation', async () => {
|
|
||||||
mockItemRequestCreate.mockRejectedValue(new Error('Database error'));
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.post('/item-requests')
|
|
||||||
.send({
|
|
||||||
title: 'Test Request',
|
|
||||||
description: 'Test description'
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
|
||||||
expect(response.body).toEqual({ error: 'Database error' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PUT /:id', () => {
|
|
||||||
const mockRequest = {
|
|
||||||
id: 1,
|
|
||||||
title: 'Original Title',
|
|
||||||
requesterId: 1,
|
|
||||||
update: jest.fn()
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockItemRequestFindByPk.mockResolvedValue(mockRequest);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update item request for owner', async () => {
|
|
||||||
const updateData = {
|
|
||||||
title: 'Updated Title',
|
|
||||||
description: 'Updated description'
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockUpdatedRequest = {
|
|
||||||
...mockRequest,
|
|
||||||
...updateData,
|
|
||||||
requester: {
|
|
||||||
id: 1,
|
|
||||||
username: 'john_doe',
|
|
||||||
firstName: 'John',
|
|
||||||
lastName: 'Doe'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
mockRequest.update.mockResolvedValue();
|
|
||||||
mockItemRequestFindByPk
|
|
||||||
.mockResolvedValueOnce(mockRequest)
|
|
||||||
.mockResolvedValueOnce(mockUpdatedRequest);
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.put('/item-requests/1')
|
|
||||||
.send(updateData);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body).toEqual({
|
|
||||||
id: 1,
|
|
||||||
title: 'Updated Title',
|
|
||||||
description: 'Updated description',
|
|
||||||
requesterId: 1,
|
|
||||||
requester: {
|
|
||||||
id: 1,
|
|
||||||
username: 'john_doe',
|
|
||||||
firstName: 'John',
|
|
||||||
lastName: 'Doe'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(mockRequest.update).toHaveBeenCalledWith(updateData);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 for non-existent request', async () => {
|
|
||||||
mockItemRequestFindByPk.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.put('/item-requests/999')
|
|
||||||
.send({ title: 'Updated' });
|
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
expect(response.body).toEqual({ error: 'Item request not found' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 403 for unauthorized user', async () => {
|
|
||||||
const unauthorizedRequest = { ...mockRequest, requesterId: 2 };
|
|
||||||
mockItemRequestFindByPk.mockResolvedValue(unauthorizedRequest);
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.put('/item-requests/1')
|
|
||||||
.send({ title: 'Updated' });
|
|
||||||
|
|
||||||
expect(response.status).toBe(403);
|
|
||||||
expect(response.body).toEqual({ error: 'Unauthorized' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle database errors', async () => {
|
|
||||||
mockItemRequestFindByPk.mockRejectedValue(new Error('Database error'));
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.put('/item-requests/1')
|
|
||||||
.send({ title: 'Updated' });
|
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
|
||||||
expect(response.body).toEqual({ error: 'Database error' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DELETE /:id', () => {
|
|
||||||
const mockRequest = {
|
|
||||||
id: 1,
|
|
||||||
requesterId: 1,
|
|
||||||
destroy: jest.fn()
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockItemRequestFindByPk.mockResolvedValue(mockRequest);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should delete item request for owner', async () => {
|
|
||||||
mockRequest.destroy.mockResolvedValue();
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.delete('/item-requests/1');
|
|
||||||
|
|
||||||
expect(response.status).toBe(204);
|
|
||||||
expect(mockRequest.destroy).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 for non-existent request', async () => {
|
|
||||||
mockItemRequestFindByPk.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.delete('/item-requests/999');
|
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
expect(response.body).toEqual({ error: 'Item request not found' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 403 for unauthorized user', async () => {
|
|
||||||
const unauthorizedRequest = { ...mockRequest, requesterId: 2 };
|
|
||||||
mockItemRequestFindByPk.mockResolvedValue(unauthorizedRequest);
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.delete('/item-requests/1');
|
|
||||||
|
|
||||||
expect(response.status).toBe(403);
|
|
||||||
expect(response.body).toEqual({ error: 'Unauthorized' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle database errors', async () => {
|
|
||||||
mockItemRequestFindByPk.mockRejectedValue(new Error('Database error'));
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.delete('/item-requests/1');
|
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
|
||||||
expect(response.body).toEqual({ error: 'Database error' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /:id/responses', () => {
|
|
||||||
const mockRequest = {
|
|
||||||
id: 1,
|
|
||||||
requesterId: 2,
|
|
||||||
status: 'open',
|
|
||||||
increment: jest.fn()
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockResponseData = {
|
|
||||||
message: 'I have a drill you can borrow',
|
|
||||||
price: 25,
|
|
||||||
existingItemId: 5
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockCreatedResponse = {
|
|
||||||
id: 1,
|
|
||||||
...mockResponseData,
|
|
||||||
itemRequestId: 1,
|
|
||||||
responderId: 1
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockResponseWithDetails = {
|
|
||||||
...mockCreatedResponse,
|
|
||||||
responder: {
|
|
||||||
id: 1,
|
|
||||||
username: 'john_doe',
|
|
||||||
firstName: 'John',
|
|
||||||
lastName: 'Doe'
|
|
||||||
},
|
|
||||||
existingItem: {
|
|
||||||
id: 5,
|
|
||||||
name: 'Power Drill',
|
|
||||||
description: 'Cordless power drill'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockItemRequestFindByPk.mockResolvedValue(mockRequest);
|
|
||||||
mockItemRequestResponseCreate.mockResolvedValue(mockCreatedResponse);
|
|
||||||
mockItemRequestResponseFindByPk.mockResolvedValue(mockResponseWithDetails);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create a response to item request', async () => {
|
|
||||||
mockRequest.increment.mockResolvedValue();
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.post('/item-requests/1/responses')
|
|
||||||
.send(mockResponseData);
|
|
||||||
|
|
||||||
expect(response.status).toBe(201);
|
|
||||||
expect(response.body).toEqual(mockResponseWithDetails);
|
|
||||||
expect(mockItemRequestResponseCreate).toHaveBeenCalledWith({
|
|
||||||
...mockResponseData,
|
|
||||||
itemRequestId: '1',
|
|
||||||
responderId: 1
|
|
||||||
});
|
|
||||||
expect(mockRequest.increment).toHaveBeenCalledWith('responseCount');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 for non-existent request', async () => {
|
|
||||||
mockItemRequestFindByPk.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.post('/item-requests/999/responses')
|
|
||||||
.send(mockResponseData);
|
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
expect(response.body).toEqual({ error: 'Item request not found' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should prevent responding to own request', async () => {
|
|
||||||
const ownRequest = { ...mockRequest, requesterId: 1 };
|
|
||||||
mockItemRequestFindByPk.mockResolvedValue(ownRequest);
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.post('/item-requests/1/responses')
|
|
||||||
.send(mockResponseData);
|
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
expect(response.body).toEqual({ error: 'Cannot respond to your own request' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should prevent responding to closed request', async () => {
|
|
||||||
const closedRequest = { ...mockRequest, status: 'fulfilled' };
|
|
||||||
mockItemRequestFindByPk.mockResolvedValue(closedRequest);
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.post('/item-requests/1/responses')
|
|
||||||
.send(mockResponseData);
|
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
expect(response.body).toEqual({ error: 'Cannot respond to closed request' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle database errors', async () => {
|
|
||||||
mockItemRequestResponseCreate.mockRejectedValue(new Error('Database error'));
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.post('/item-requests/1/responses')
|
|
||||||
.send(mockResponseData);
|
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
|
||||||
expect(response.body).toEqual({ error: 'Database error' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PUT /responses/:responseId/status', () => {
|
|
||||||
const mockResponse = {
|
|
||||||
id: 1,
|
|
||||||
status: 'pending',
|
|
||||||
itemRequest: {
|
|
||||||
id: 1,
|
|
||||||
requesterId: 1
|
|
||||||
},
|
|
||||||
update: jest.fn()
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockItemRequestResponseFindByPk.mockResolvedValue(mockResponse);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update response status to accepted and fulfill request', async () => {
|
|
||||||
const updatedResponse = {
|
|
||||||
...mockResponse,
|
|
||||||
status: 'accepted',
|
|
||||||
responder: {
|
|
||||||
id: 2,
|
|
||||||
username: 'jane_doe',
|
|
||||||
firstName: 'Jane',
|
|
||||||
lastName: 'Doe'
|
|
||||||
},
|
|
||||||
existingItem: null
|
|
||||||
};
|
|
||||||
|
|
||||||
mockResponse.update.mockResolvedValue();
|
|
||||||
mockResponse.itemRequest.update = jest.fn().mockResolvedValue();
|
|
||||||
mockItemRequestResponseFindByPk
|
|
||||||
.mockResolvedValueOnce(mockResponse)
|
|
||||||
.mockResolvedValueOnce(updatedResponse);
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.put('/item-requests/responses/1/status')
|
|
||||||
.send({ status: 'accepted' });
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body).toEqual({
|
|
||||||
id: 1,
|
|
||||||
status: 'accepted',
|
|
||||||
itemRequest: {
|
|
||||||
id: 1,
|
|
||||||
requesterId: 1
|
|
||||||
},
|
|
||||||
responder: {
|
|
||||||
id: 2,
|
|
||||||
username: 'jane_doe',
|
|
||||||
firstName: 'Jane',
|
|
||||||
lastName: 'Doe'
|
|
||||||
},
|
|
||||||
existingItem: null
|
|
||||||
});
|
|
||||||
expect(mockResponse.update).toHaveBeenCalledWith({ status: 'accepted' });
|
|
||||||
expect(mockResponse.itemRequest.update).toHaveBeenCalledWith({ status: 'fulfilled' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update response status without fulfilling request', async () => {
|
|
||||||
const updatedResponse = { ...mockResponse, status: 'declined' };
|
|
||||||
mockResponse.update.mockResolvedValue();
|
|
||||||
mockItemRequestResponseFindByPk
|
|
||||||
.mockResolvedValueOnce(mockResponse)
|
|
||||||
.mockResolvedValueOnce(updatedResponse);
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.put('/item-requests/responses/1/status')
|
|
||||||
.send({ status: 'declined' });
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(mockResponse.update).toHaveBeenCalledWith({ status: 'declined' });
|
|
||||||
expect(mockResponse.itemRequest.update).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 for non-existent response', async () => {
|
|
||||||
mockItemRequestResponseFindByPk.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.put('/item-requests/responses/999/status')
|
|
||||||
.send({ status: 'accepted' });
|
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
expect(response.body).toEqual({ error: 'Response not found' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 403 for unauthorized user', async () => {
|
|
||||||
const unauthorizedResponse = {
|
|
||||||
...mockResponse,
|
|
||||||
itemRequest: { ...mockResponse.itemRequest, requesterId: 2 }
|
|
||||||
};
|
|
||||||
mockItemRequestResponseFindByPk.mockResolvedValue(unauthorizedResponse);
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.put('/item-requests/responses/1/status')
|
|
||||||
.send({ status: 'accepted' });
|
|
||||||
|
|
||||||
expect(response.status).toBe(403);
|
|
||||||
expect(response.body).toEqual({ error: 'Only the requester can update response status' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle database errors', async () => {
|
|
||||||
mockItemRequestResponseFindByPk.mockRejectedValue(new Error('Database error'));
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.put('/item-requests/responses/1/status')
|
|
||||||
.send({ status: 'accepted' });
|
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
|
||||||
expect(response.body).toEqual({ error: 'Database error' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Edge cases', () => {
|
|
||||||
it('should handle empty search results', async () => {
|
|
||||||
mockItemRequestFindAndCountAll.mockResolvedValue({ count: 0, rows: [] });
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.get('/item-requests?search=nonexistent');
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body.requests).toEqual([]);
|
|
||||||
expect(response.body.totalRequests).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle zero page calculation', async () => {
|
|
||||||
mockItemRequestFindAndCountAll.mockResolvedValue({ count: 0, rows: [] });
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.get('/item-requests');
|
|
||||||
|
|
||||||
expect(response.body.totalPages).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle request without optional fields', async () => {
|
|
||||||
const minimalRequest = {
|
|
||||||
title: 'Basic Request',
|
|
||||||
description: 'Simple description'
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockCreated = { id: 1, ...minimalRequest, requesterId: 1 };
|
|
||||||
const mockWithRequester = {
|
|
||||||
...mockCreated,
|
|
||||||
requester: { id: 1, username: 'test' }
|
|
||||||
};
|
|
||||||
|
|
||||||
mockItemRequestCreate.mockResolvedValue(mockCreated);
|
|
||||||
mockItemRequestFindByPk.mockResolvedValue(mockWithRequester);
|
|
||||||
|
|
||||||
const response = await request(app)
|
|
||||||
.post('/item-requests')
|
|
||||||
.send(minimalRequest);
|
|
||||||
|
|
||||||
expect(response.status).toBe(201);
|
|
||||||
expect(mockItemRequestCreate).toHaveBeenCalledWith({
|
|
||||||
...minimalRequest,
|
|
||||||
requesterId: 1
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -22,10 +22,10 @@ import Profile from './pages/Profile';
|
|||||||
import PublicProfile from './pages/PublicProfile';
|
import PublicProfile from './pages/PublicProfile';
|
||||||
import Messages from './pages/Messages';
|
import Messages from './pages/Messages';
|
||||||
import MessageDetail from './pages/MessageDetail';
|
import MessageDetail from './pages/MessageDetail';
|
||||||
import ItemRequests from './pages/ItemRequests';
|
import ForumPosts from './pages/ForumPosts';
|
||||||
import ItemRequestDetail from './pages/ItemRequestDetail';
|
import ForumPostDetail from './pages/ForumPostDetail';
|
||||||
import CreateItemRequest from './pages/CreateItemRequest';
|
import CreateForumPost from './pages/CreateForumPost';
|
||||||
import MyRequests from './pages/MyRequests';
|
import MyPosts from './pages/MyPosts';
|
||||||
import EarningsDashboard from './pages/EarningsDashboard';
|
import EarningsDashboard from './pages/EarningsDashboard';
|
||||||
import FAQ from './pages/FAQ';
|
import FAQ from './pages/FAQ';
|
||||||
import PrivateRoute from './components/PrivateRoute';
|
import PrivateRoute from './components/PrivateRoute';
|
||||||
@@ -158,21 +158,21 @@ const AppContent: React.FC = () => {
|
|||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/item-requests" element={<ItemRequests />} />
|
<Route path="/forum" element={<ForumPosts />} />
|
||||||
<Route path="/item-requests/:id" element={<ItemRequestDetail />} />
|
<Route path="/forum/:id" element={<ForumPostDetail />} />
|
||||||
<Route
|
<Route
|
||||||
path="/create-item-request"
|
path="/forum/create"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<CreateItemRequest />
|
<CreateForumPost />
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/my-requests"
|
path="/my-posts"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<MyRequests />
|
<MyPosts />
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
33
frontend/src/components/CategoryBadge.tsx
Normal file
33
frontend/src/components/CategoryBadge.tsx
Normal 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;
|
||||||
86
frontend/src/components/CommentForm.tsx
Normal file
86
frontend/src/components/CommentForm.tsx
Normal 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;
|
||||||
227
frontend/src/components/CommentThread.tsx
Normal file
227
frontend/src/components/CommentThread.tsx
Normal 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;
|
||||||
115
frontend/src/components/ForumPostCard.tsx
Normal file
115
frontend/src/components/ForumPostCard.tsx
Normal 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;
|
||||||
@@ -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;
|
|
||||||
@@ -253,9 +253,9 @@ const Navbar: React.FC = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link className="dropdown-item" to="/my-requests">
|
<Link className="dropdown-item" to="/forum">
|
||||||
<i className="bi bi-clipboard-check me-2"></i>
|
<i className="bi bi-chat-dots me-2"></i>
|
||||||
Looking For
|
Forum
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
31
frontend/src/components/PostStatusBadge.tsx
Normal file
31
frontend/src/components/PostStatusBadge.tsx
Normal 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;
|
||||||
@@ -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;
|
|
||||||
154
frontend/src/components/TagInput.tsx
Normal file
154
frontend/src/components/TagInput.tsx
Normal 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;
|
||||||
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 React, { useState, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { itemRequestAPI } from '../services/api';
|
import { forumAPI } from '../services/api';
|
||||||
import { ItemRequest } from '../types';
|
import { ForumPost } from '../types';
|
||||||
import ItemRequestCard from '../components/ItemRequestCard';
|
import ForumPostCard from '../components/ForumPostCard';
|
||||||
import AuthButton from '../components/AuthButton';
|
import AuthButton from '../components/AuthButton';
|
||||||
|
|
||||||
const ItemRequests: React.FC = () => {
|
const ForumPosts: React.FC = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [requests, setRequests] = useState<ItemRequest[]>([]);
|
const [posts, setPosts] = useState<ForumPost[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [totalRequests, setTotalRequests] = useState(0);
|
const [totalPosts, setTotalPosts] = useState(0);
|
||||||
|
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
search: '',
|
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(() => {
|
useEffect(() => {
|
||||||
fetchRequests();
|
fetchPosts();
|
||||||
}, [currentPage, filters]);
|
}, [currentPage, filters]);
|
||||||
|
|
||||||
const fetchRequests = async () => {
|
const fetchPosts = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await itemRequestAPI.getItemRequests({
|
const params: any = {
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
...filters
|
sort: filters.sort
|
||||||
});
|
};
|
||||||
|
|
||||||
setRequests(response.data.requests);
|
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);
|
setTotalPages(response.data.totalPages);
|
||||||
setTotalRequests(response.data.totalRequests);
|
setTotalPosts(response.data.totalPosts);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.error || 'Failed to fetch item requests');
|
setError(err.response?.data?.error || 'Failed to fetch forum posts');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -51,36 +69,56 @@ const ItemRequests: React.FC = () => {
|
|||||||
|
|
||||||
const handleSearch = (e: React.FormEvent) => {
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
fetchRequests();
|
fetchPosts();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePageChange = (page: number) => {
|
const handlePageChange = (page: number) => {
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCategoryClick = (category: string) => {
|
||||||
|
setFilters(prev => ({ ...prev, category }));
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mt-4">
|
<div className="container mt-4">
|
||||||
<div className="d-flex justify-content-between align-items-center mb-4">
|
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h1>Item Requests</h1>
|
<h1>Community Forum</h1>
|
||||||
<p className="text-muted">Help others by fulfilling their item requests</p>
|
<p className="text-muted">Discuss, share, and connect with the community</p>
|
||||||
</div>
|
</div>
|
||||||
{user && (
|
{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>
|
<i className="bi bi-plus-circle me-2"></i>
|
||||||
Create Request
|
Create Post
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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="row mb-4">
|
||||||
<div className="col-md-8">
|
<div className="col-md-6">
|
||||||
<form onSubmit={handleSearch}>
|
<form onSubmit={handleSearch}>
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
placeholder="Search item requests..."
|
placeholder="Search posts..."
|
||||||
name="search"
|
name="search"
|
||||||
value={filters.search}
|
value={filters.search}
|
||||||
onChange={handleFilterChange}
|
onChange={handleFilterChange}
|
||||||
@@ -91,16 +129,29 @@ const ItemRequests: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-4">
|
<div className="col-md-3">
|
||||||
<select
|
<select
|
||||||
className="form-select"
|
className="form-select"
|
||||||
name="status"
|
name="status"
|
||||||
value={filters.status}
|
value={filters.status}
|
||||||
onChange={handleFilterChange}
|
onChange={handleFilterChange}
|
||||||
>
|
>
|
||||||
<option value="open">Open Requests</option>
|
<option value="">All Status</option>
|
||||||
<option value="fulfilled">Fulfilled Requests</option>
|
<option value="open">Open</option>
|
||||||
<option value="closed">Closed Requests</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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,32 +172,32 @@ const ItemRequests: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<div className="d-flex justify-content-between align-items-center mb-3">
|
<div className="d-flex justify-content-between align-items-center mb-3">
|
||||||
<p className="text-muted mb-0">
|
<p className="text-muted mb-0">
|
||||||
Showing {requests.length} of {totalRequests} requests
|
Showing {posts.length} of {totalPosts} posts
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{requests.length === 0 ? (
|
{posts.length === 0 ? (
|
||||||
<div className="text-center py-5">
|
<div className="text-center py-5">
|
||||||
<i className="bi bi-inbox display-1 text-muted"></i>
|
<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">
|
<p className="text-muted">
|
||||||
{filters.search
|
{filters.search || filters.category || filters.tag
|
||||||
? "Try adjusting your search terms or filters."
|
? "Try adjusting your search terms or filters."
|
||||||
: "Be the first to create an item request!"
|
: "Be the first to start a discussion!"
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
{user && !filters.search && (
|
{user && !filters.search && !filters.category && (
|
||||||
<Link to="/create-item-request" className="btn btn-primary">
|
<Link to="/forum/create" className="btn btn-primary">
|
||||||
Create First Request
|
Create First Post
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="row g-4">
|
<div className="row g-4">
|
||||||
{requests.map((request) => (
|
{posts.map((post) => (
|
||||||
<div key={request.id} className="col-md-6 col-lg-4">
|
<div key={post.id} className="col-md-6 col-lg-4">
|
||||||
<ItemRequestCard request={request} />
|
<ForumPostCard post={post} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -201,7 +252,7 @@ const ItemRequests: React.FC = () => {
|
|||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<div className="alert alert-info" role="alert">
|
<div className="alert alert-info" role="alert">
|
||||||
<i className="bi bi-info-circle me-2"></i>
|
<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>
|
||||||
</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;
|
|
||||||
@@ -254,18 +254,22 @@ export const messageAPI = {
|
|||||||
getUnreadCount: () => api.get("/messages/unread/count"),
|
getUnreadCount: () => api.get("/messages/unread/count"),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const itemRequestAPI = {
|
export const forumAPI = {
|
||||||
getItemRequests: (params?: any) => api.get("/item-requests", { params }),
|
getPosts: (params?: any) => api.get("/forum/posts", { params }),
|
||||||
getItemRequest: (id: string) => api.get(`/item-requests/${id}`),
|
getPost: (id: string) => api.get(`/forum/posts/${id}`),
|
||||||
createItemRequest: (data: any) => api.post("/item-requests", data),
|
createPost: (data: any) => api.post("/forum/posts", data),
|
||||||
updateItemRequest: (id: string, data: any) =>
|
updatePost: (id: string, data: any) => api.put(`/forum/posts/${id}`, data),
|
||||||
api.put(`/item-requests/${id}`, data),
|
deletePost: (id: string) => api.delete(`/forum/posts/${id}`),
|
||||||
deleteItemRequest: (id: string) => api.delete(`/item-requests/${id}`),
|
updatePostStatus: (id: string, status: string) =>
|
||||||
getMyRequests: () => api.get("/item-requests/my-requests"),
|
api.patch(`/forum/posts/${id}/status`, { status }),
|
||||||
respondToRequest: (id: string, data: any) =>
|
getMyPosts: () => api.get("/forum/my-posts"),
|
||||||
api.post(`/item-requests/${id}/responses`, data),
|
getTags: (params?: any) => api.get("/forum/tags", { params }),
|
||||||
updateResponseStatus: (responseId: string, status: string) =>
|
createComment: (postId: string, data: any) =>
|
||||||
api.put(`/item-requests/responses/${responseId}/status`, { status }),
|
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 = {
|
export const stripeAPI = {
|
||||||
|
|||||||
@@ -255,55 +255,42 @@ export interface ConditionCheckTimeline {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ItemRequest {
|
export interface ForumPost {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
content: string;
|
||||||
address1?: string;
|
authorId: string;
|
||||||
address2?: string;
|
category: "item_request" | "technical_support" | "community_resources" | "general_discussion";
|
||||||
city?: string;
|
status: "open" | "solved" | "closed";
|
||||||
state?: string;
|
viewCount: number;
|
||||||
zipCode?: string;
|
commentCount: number;
|
||||||
country?: string;
|
isPinned: boolean;
|
||||||
latitude?: number;
|
author?: User;
|
||||||
longitude?: number;
|
tags?: PostTag[];
|
||||||
maxPricePerHour?: number;
|
comments?: ForumComment[];
|
||||||
maxPricePerDay?: number;
|
|
||||||
maxPricePerWeek?: number;
|
|
||||||
maxPricePerMonth?: number;
|
|
||||||
preferredStartDate?: string;
|
|
||||||
preferredEndDate?: string;
|
|
||||||
isFlexibleDates: boolean;
|
|
||||||
status: "open" | "fulfilled" | "closed";
|
|
||||||
requesterId: string;
|
|
||||||
requester?: User;
|
|
||||||
responseCount: number;
|
|
||||||
responses?: ItemRequestResponse[];
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ItemRequestResponse {
|
export interface ForumComment {
|
||||||
id: string;
|
id: string;
|
||||||
itemRequestId: string;
|
postId: string;
|
||||||
responderId: string;
|
authorId: string;
|
||||||
message: string;
|
content: string;
|
||||||
offerPricePerHour?: number;
|
parentCommentId?: string;
|
||||||
offerPricePerDay?: number;
|
isDeleted: boolean;
|
||||||
offerPricePerWeek?: number;
|
author?: User;
|
||||||
offerPricePerMonth?: number;
|
replies?: ForumComment[];
|
||||||
availableStartDate?: string;
|
|
||||||
availableEndDate?: string;
|
|
||||||
existingItemId?: string;
|
|
||||||
status: "pending" | "accepted" | "declined" | "expired";
|
|
||||||
contactInfo?: string;
|
|
||||||
responder?: User;
|
|
||||||
existingItem?: Item;
|
|
||||||
itemRequest?: ItemRequest;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PostTag {
|
||||||
|
id: string;
|
||||||
|
postId: string;
|
||||||
|
tagName: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RefundPreview {
|
export interface RefundPreview {
|
||||||
canCancel: boolean;
|
canCancel: boolean;
|
||||||
cancelledBy: "renter" | "owner";
|
cancelledBy: "renter" | "owner";
|
||||||
|
|||||||
Reference in New Issue
Block a user