This commit is contained in:
jackiettran
2025-09-02 16:15:09 -04:00
parent b52104c3fa
commit b59fc07fc3
23 changed files with 1080 additions and 417 deletions

View File

@@ -1,152 +1,141 @@
const { DataTypes } = require('sequelize'); const { DataTypes } = require("sequelize");
const sequelize = require('../config/database'); const sequelize = require("../config/database");
const Rental = sequelize.define('Rental', { const Rental = sequelize.define("Rental", {
id: { id: {
type: DataTypes.UUID, type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
primaryKey: true primaryKey: true,
}, },
itemId: { itemId: {
type: DataTypes.UUID, type: DataTypes.UUID,
allowNull: false, allowNull: false,
references: { references: {
model: 'Items', model: "Items",
key: 'id' key: "id",
} },
}, },
renterId: { renterId: {
type: DataTypes.UUID, type: DataTypes.UUID,
allowNull: false, allowNull: false,
references: { references: {
model: 'Users', model: "Users",
key: 'id' key: "id",
} },
}, },
ownerId: { ownerId: {
type: DataTypes.UUID, type: DataTypes.UUID,
allowNull: false, allowNull: false,
references: { references: {
model: 'Users', model: "Users",
key: 'id' key: "id",
} },
}, },
startDate: { startDateTime: {
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: false allowNull: false,
}, },
endDate: { endDateTime: {
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: false allowNull: false,
},
startTime: {
type: DataTypes.STRING
},
endTime: {
type: DataTypes.STRING
}, },
totalAmount: { totalAmount: {
type: DataTypes.DECIMAL(10, 2), type: DataTypes.DECIMAL(10, 2),
allowNull: false allowNull: false,
}, },
baseRentalAmount: { baseRentalAmount: {
type: DataTypes.DECIMAL(10, 2), type: DataTypes.DECIMAL(10, 2),
allowNull: false allowNull: false,
}, },
platformFee: { platformFee: {
type: DataTypes.DECIMAL(10, 2), type: DataTypes.DECIMAL(10, 2),
allowNull: false allowNull: false,
}, },
processingFee: { processingFee: {
type: DataTypes.DECIMAL(10, 2), type: DataTypes.DECIMAL(10, 2),
allowNull: false allowNull: false,
}, },
payoutAmount: { payoutAmount: {
type: DataTypes.DECIMAL(10, 2), type: DataTypes.DECIMAL(10, 2),
allowNull: false allowNull: false,
}, },
status: { status: {
type: DataTypes.ENUM('pending', 'confirmed', 'active', 'completed', 'cancelled'), type: DataTypes.ENUM(
defaultValue: 'pending' "pending",
"confirmed",
"active",
"completed",
"cancelled"
),
defaultValue: "pending",
}, },
paymentStatus: { paymentStatus: {
type: DataTypes.ENUM('pending', 'paid', 'refunded'), type: DataTypes.ENUM("pending", "paid", "refunded"),
defaultValue: 'pending' defaultValue: "pending",
}, },
payoutStatus: { payoutStatus: {
type: DataTypes.ENUM('pending', 'processing', 'completed', 'failed'), type: DataTypes.ENUM("pending", "processing", "completed", "failed"),
defaultValue: 'pending' defaultValue: "pending",
}, },
payoutProcessedAt: { payoutProcessedAt: {
type: DataTypes.DATE type: DataTypes.DATE,
}, },
stripeTransferId: { stripeTransferId: {
type: DataTypes.STRING type: DataTypes.STRING,
}, },
deliveryMethod: { deliveryMethod: {
type: DataTypes.ENUM('pickup', 'delivery'), type: DataTypes.ENUM("pickup", "delivery"),
defaultValue: 'pickup' defaultValue: "pickup",
}, },
deliveryAddress: { deliveryAddress: {
type: DataTypes.TEXT type: DataTypes.TEXT,
}, },
notes: { notes: {
type: DataTypes.TEXT type: DataTypes.TEXT,
}, },
// Renter's review of the item (existing fields renamed for clarity) // Renter's review of the item (existing fields renamed for clarity)
itemRating: { itemRating: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
validate: { validate: {
min: 1, min: 1,
max: 5 max: 5,
} },
}, },
itemReview: { itemReview: {
type: DataTypes.TEXT type: DataTypes.TEXT,
}, },
itemReviewSubmittedAt: { itemReviewSubmittedAt: {
type: DataTypes.DATE type: DataTypes.DATE,
}, },
itemReviewVisible: { itemReviewVisible: {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
defaultValue: false defaultValue: false,
}, },
// Owner's review of the renter // Owner's review of the renter
renterRating: { renterRating: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
validate: { validate: {
min: 1, min: 1,
max: 5 max: 5,
} },
}, },
renterReview: { renterReview: {
type: DataTypes.TEXT type: DataTypes.TEXT,
}, },
renterReviewSubmittedAt: { renterReviewSubmittedAt: {
type: DataTypes.DATE type: DataTypes.DATE,
}, },
renterReviewVisible: { renterReviewVisible: {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
defaultValue: false defaultValue: false,
}, },
// Private messages (always visible to recipient) // Private messages (always visible to recipient)
itemPrivateMessage: { itemPrivateMessage: {
type: DataTypes.TEXT type: DataTypes.TEXT,
}, },
renterPrivateMessage: { renterPrivateMessage: {
type: DataTypes.TEXT type: DataTypes.TEXT,
}, },
// Legacy fields for backwards compatibility
rating: {
type: DataTypes.INTEGER,
validate: {
min: 1,
max: 5
}
},
review: {
type: DataTypes.TEXT
}
}); });
module.exports = Rental; module.exports = Rental;

View File

@@ -98,13 +98,12 @@ router.post("/", authenticateToken, async (req, res) => {
try { try {
const { const {
itemId, itemId,
startDate, startDateTime,
endDate, endDateTime,
startTime,
endTime,
deliveryMethod, deliveryMethod,
deliveryAddress, deliveryAddress,
notes, notes,
paymentStatus,
} = req.body; } = req.body;
const item = await Item.findByPk(itemId); const item = await Item.findByPk(itemId);
@@ -116,16 +115,57 @@ router.post("/", authenticateToken, async (req, res) => {
return res.status(400).json({ error: "Item is not available" }); return res.status(400).json({ error: "Item is not available" });
} }
let rentalStartDateTime, rentalEndDateTime, baseRentalAmount;
// New UTC datetime format
rentalStartDateTime = new Date(startDateTime);
rentalEndDateTime = new Date(endDateTime);
// Calculate rental duration
const diffMs = rentalEndDateTime.getTime() - rentalStartDateTime.getTime();
const diffHours = Math.ceil(diffMs / (1000 * 60 * 60));
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
// Calculate base amount based on duration
if (item.pricePerHour && diffHours <= 24) {
baseRentalAmount = diffHours * Number(item.pricePerHour);
} else if (item.pricePerDay) {
baseRentalAmount = diffDays * Number(item.pricePerDay);
} else {
baseRentalAmount = 0;
}
// Check for overlapping rentals using datetime ranges
const overlappingRental = await Rental.findOne({ const overlappingRental = await Rental.findOne({
where: { where: {
itemId, itemId,
status: { [Op.in]: ["confirmed", "active"] }, status: { [Op.in]: ["confirmed", "active"] },
[Op.or]: [ [Op.or]: [
{ {
startDate: { [Op.between]: [startDate, endDate] }, [Op.and]: [
}, { startDateTime: { [Op.not]: null } },
{ { endDateTime: { [Op.not]: null } },
endDate: { [Op.between]: [startDate, endDate] }, {
[Op.or]: [
{
startDateTime: {
[Op.between]: [rentalStartDateTime, rentalEndDateTime],
},
},
{
endDateTime: {
[Op.between]: [rentalStartDateTime, rentalEndDateTime],
},
},
{
[Op.and]: [
{ startDateTime: { [Op.lte]: rentalStartDateTime } },
{ endDateTime: { [Op.gte]: rentalEndDateTime } },
],
},
],
},
],
}, },
], ],
}, },
@@ -137,11 +177,6 @@ router.post("/", authenticateToken, async (req, res) => {
.json({ error: "Item is already booked for these dates" }); .json({ error: "Item is already booked for these dates" });
} }
const rentalDays = Math.ceil(
(new Date(endDate) - new Date(startDate)) / (1000 * 60 * 60 * 24)
);
const baseRentalAmount = rentalDays * (item.pricePerDay || 0);
// Calculate fees using FeeCalculator // Calculate fees using FeeCalculator
const fees = FeeCalculator.calculateRentalFees(baseRentalAmount); const fees = FeeCalculator.calculateRentalFees(baseRentalAmount);
@@ -149,15 +184,14 @@ router.post("/", authenticateToken, async (req, res) => {
itemId, itemId,
renterId: req.user.id, renterId: req.user.id,
ownerId: item.ownerId, ownerId: item.ownerId,
startDate, startDateTime: rentalStartDateTime,
endDate, endDateTime: rentalEndDateTime,
startTime,
endTime,
totalAmount: fees.totalChargedAmount, totalAmount: fees.totalChargedAmount,
baseRentalAmount: fees.baseRentalAmount, baseRentalAmount: fees.baseRentalAmount,
platformFee: fees.platformFee, platformFee: fees.platformFee,
processingFee: fees.processingFee, processingFee: fees.processingFee,
payoutAmount: fees.payoutAmount, payoutAmount: fees.payoutAmount,
paymentStatus: paymentStatus || "pending",
deliveryMethod, deliveryMethod,
deliveryAddress, deliveryAddress,
notes, notes,
@@ -356,34 +390,6 @@ router.post("/:id/mark-completed", authenticateToken, async (req, res) => {
} }
}); });
// Legacy review endpoint (for backward compatibility)
router.post("/:id/review", authenticateToken, async (req, res) => {
try {
const { rating, review } = req.body;
const rental = await Rental.findByPk(req.params.id);
if (!rental) {
return res.status(404).json({ error: "Rental not found" });
}
if (rental.renterId !== req.user.id) {
return res.status(403).json({ error: "Only renters can leave reviews" });
}
if (rental.status !== "completed") {
return res
.status(400)
.json({ error: "Can only review completed rentals" });
}
await rental.update({ rating, review });
res.json(rental);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Calculate fees for rental pricing display // Calculate fees for rental pricing display
router.post("/calculate-fees", authenticateToken, async (req, res) => { router.post("/calculate-fees", authenticateToken, async (req, res) => {
try { try {
@@ -406,8 +412,8 @@ router.post("/calculate-fees", authenticateToken, async (req, res) => {
} }
}); });
// Get payout status for owner's rentals // Get earnings status for owner's rentals
router.get("/payouts/status", authenticateToken, async (req, res) => { router.get("/earnings/status", authenticateToken, async (req, res) => {
try { try {
const ownerRentals = await Rental.findAll({ const ownerRentals = await Rental.findAll({
where: { where: {
@@ -429,7 +435,7 @@ router.get("/payouts/status", authenticateToken, async (req, res) => {
res.json(ownerRentals); res.json(ownerRentals);
} catch (error) { } catch (error) {
console.error("Error getting payout status:", error); console.error("Error getting earnings status:", error);
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
}); });

View File

@@ -1,12 +1,10 @@
const express = require("express"); const express = require("express");
const { authenticateToken } = require("../middleware/auth"); const { authenticateToken } = require("../middleware/auth");
const { User } = require("../models"); const { User, Item } = require("../models");
const { Rental, Item } = require("../models");
const StripeService = require("../services/stripeService"); const StripeService = require("../services/stripeService");
const router = express.Router(); const router = express.Router();
const platformFee = 0.1;
router.post("/create-checkout-session", async (req, res) => { router.post("/create-checkout-session", authenticateToken, async (req, res) => {
try { try {
const { itemName, total, return_url, rentalData } = req.body; const { itemName, total, return_url, rentalData } = req.body;
@@ -20,18 +18,37 @@ router.post("/create-checkout-session", async (req, res) => {
return res.status(400).json({ error: "No return_url found" }); return res.status(400).json({ error: "No return_url found" });
} }
// Validate rental data and user authorization
if (rentalData && rentalData.itemId) {
const item = await Item.findByPk(rentalData.itemId);
if (!item) {
return res.status(404).json({ error: "Item not found" });
}
if (!item.availability) {
return res
.status(400)
.json({ error: "Item is not available for rent" });
}
// Check if user is trying to rent their own item
if (item.ownerId === req.user.id) {
return res.status(400).json({ error: "You cannot rent your own item" });
}
}
// Prepare metadata - Stripe metadata keys must be strings // Prepare metadata - Stripe metadata keys must be strings
const metadata = rentalData const metadata = rentalData
? { ? {
itemId: rentalData.itemId, itemId: rentalData.itemId,
startDate: rentalData.startDate, renterId: req.user.id.toString(), // Add authenticated user ID
endDate: rentalData.endDate, startDateTime: rentalData.startDateTime,
startTime: rentalData.startTime, endDateTime: rentalData.endDateTime,
endTime: rentalData.endTime,
totalAmount: rentalData.totalAmount.toString(), totalAmount: rentalData.totalAmount.toString(),
deliveryMethod: rentalData.deliveryMethod, deliveryMethod: rentalData.deliveryMethod,
} }
: {}; : { renterId: req.user.id.toString() };
const session = await StripeService.createCheckoutSession({ const session = await StripeService.createCheckoutSession({
item_name: itemName, item_name: itemName,

View File

@@ -59,6 +59,8 @@ class PayoutService {
ownerId: rental.ownerId, ownerId: rental.ownerId,
baseAmount: rental.baseRentalAmount.toString(), baseAmount: rental.baseRentalAmount.toString(),
platformFee: rental.platformFee.toString(), platformFee: rental.platformFee.toString(),
startDateTime: rental.startDateTime.toISOString(),
endDateTime: rental.endDateTime.toISOString(),
}, },
}); });

View File

@@ -48,7 +48,7 @@ class StripeService {
static async createConnectedAccount({ email, country = "US" }) { static async createConnectedAccount({ email, country = "US" }) {
try { try {
const account = await stripe.accounts.create({ const account = await stripe.accounts.create({
type: "standard", type: "express",
email, email,
country, country,
capabilities: { capabilities: {

View File

@@ -22,6 +22,7 @@ import ItemRequests from './pages/ItemRequests';
import ItemRequestDetail from './pages/ItemRequestDetail'; import ItemRequestDetail from './pages/ItemRequestDetail';
import CreateItemRequest from './pages/CreateItemRequest'; import CreateItemRequest from './pages/CreateItemRequest';
import MyRequests from './pages/MyRequests'; import MyRequests from './pages/MyRequests';
import EarningsDashboard from './pages/EarningsDashboard';
import CheckoutReturn from './components/CheckoutReturn'; import CheckoutReturn from './components/CheckoutReturn';
import PrivateRoute from './components/PrivateRoute'; import PrivateRoute from './components/PrivateRoute';
import './App.css'; import './App.css';
@@ -123,6 +124,14 @@ function App() {
</PrivateRoute> </PrivateRoute>
} }
/> />
<Route
path="/earnings"
element={
<PrivateRoute>
<EarningsDashboard />
</PrivateRoute>
}
/>
<Route <Route
path="/checkout/return" path="/checkout/return"
element={ element={

View File

@@ -38,12 +38,11 @@ const CheckoutReturn: React.FC = () => {
// Convert metadata back to proper types // Convert metadata back to proper types
const rentalData = { const rentalData = {
itemId: metadata.itemId, itemId: metadata.itemId,
startDate: metadata.startDate, startDateTime: metadata.startDateTime,
endDate: metadata.endDate, endDateTime: metadata.endDateTime,
startTime: metadata.startTime,
endTime: metadata.endTime,
totalAmount: parseFloat(metadata.totalAmount), totalAmount: parseFloat(metadata.totalAmount),
deliveryMethod: metadata.deliveryMethod, deliveryMethod: metadata.deliveryMethod,
paymentStatus: "paid", // Set since payment already succeeded
}; };
const response = await rentalAPI.createRental(rentalData); const response = await rentalAPI.createRental(rentalData);

View File

@@ -0,0 +1,76 @@
import React from "react";
interface EarningsStatusProps {
hasStripeAccount: boolean;
onSetupClick: () => void;
}
const EarningsStatus: React.FC<EarningsStatusProps> = ({
hasStripeAccount,
onSetupClick,
}) => {
// No Stripe account exists
if (!hasStripeAccount) {
return (
<div className="text-center">
<div className="mb-3">
<i
className="bi bi-exclamation-circle text-warning"
style={{ fontSize: "2.5rem" }}
></i>
</div>
<h6>Earnings Not Set Up</h6>
<p className="text-muted small mb-3">
Set up earnings to automatically receive payments when rentals are
completed.
</p>
<button className="btn btn-primary" onClick={onSetupClick}>
Set Up Earnings
</button>
</div>
);
}
// Account exists and is set up
return (
<div className="text-center">
<div className="mb-3">
<i
className="bi bi-check-circle text-success"
style={{ fontSize: "2.5rem" }}
></i>
</div>
<h6 className="text-success">Earnings Active</h6>
<p className="text-muted small mb-3">
Your earnings are set up and working. You'll receive payments
automatically.
</p>
<div className="small text-start">
<div className="d-flex justify-content-between mb-1">
<span>Earnings Enabled:</span>
<span className="text-success">
<i className="bi bi-check"></i> Yes
</span>
</div>
<div className="d-flex justify-content-between">
<span>Status:</span>
<span className="text-success">
<i className="bi bi-check"></i> Active
</span>
</div>
</div>
<hr />
<button
className="btn btn-outline-primary btn-sm"
onClick={() => window.open("https://dashboard.stripe.com", "_blank")}
>
<i className="bi bi-box-arrow-up-right"></i> Stripe Dashboard
</button>
</div>
);
};
export default EarningsStatus;

View File

@@ -162,6 +162,12 @@ const Navbar: React.FC = () => {
Looking For Looking For
</Link> </Link>
</li> </li>
<li>
<Link className="dropdown-item" to="/earnings">
<i className="bi bi-cash-coin me-2"></i>
Earnings
</Link>
</li>
<li> <li>
<Link className="dropdown-item" to="/messages"> <Link className="dropdown-item" to="/messages">
<i className="bi bi-envelope me-2"></i>Messages <i className="bi bi-envelope me-2"></i>Messages

View File

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

View File

@@ -17,23 +17,9 @@ const ReviewDetailsModal: React.FC<ReviewDetailsModalProps> = ({
}) => { }) => {
if (!show) return null; if (!show) return null;
const formatDateTime = (dateString: string, timeString?: string) => { const formatDateTime = (dateString: string) => {
const date = new Date(dateString).toLocaleDateString(); const date = new Date(dateString).toLocaleDateString();
const formattedTime = timeString return date;
? (() => {
try {
const [hour, minute] = timeString.split(":");
const hourNum = parseInt(hour);
const hour12 =
hourNum === 0 ? 12 : hourNum > 12 ? hourNum - 12 : hourNum;
const period = hourNum < 12 ? "AM" : "PM";
return `${hour12}:${minute} ${period}`;
} catch {
return "";
}
})()
: "";
return formattedTime ? `${date} at ${formattedTime}` : date;
}; };
const isRenter = userType === "renter"; const isRenter = userType === "renter";
@@ -60,8 +46,8 @@ const ReviewDetailsModal: React.FC<ReviewDetailsModalProps> = ({
<div className="mb-4 text-center"> <div className="mb-4 text-center">
<h6 className="mb-1">{rental.item.name}</h6> <h6 className="mb-1">{rental.item.name}</h6>
<small className="text-muted"> <small className="text-muted">
{formatDateTime(rental.startDate, rental.startTime)} to{" "} {formatDateTime(rental.startDateTime)} to{" "}
{formatDateTime(rental.endDate, rental.endTime)} {formatDateTime(rental.endDateTime)}
</small> </small>
</div> </div>
)} )}

View File

@@ -40,12 +40,14 @@ const ReviewItemModal: React.FC<ReviewItemModalProps> = ({
setRating(5); setRating(5);
setReview(""); setReview("");
setPrivateMessage(""); setPrivateMessage("");
// Show success modal with appropriate message // Show success modal with appropriate message
if (response.data.reviewVisible) { if (response.data.reviewVisible) {
setSuccessMessage("Review published successfully!"); setSuccessMessage("Review published successfully!");
} else { } else {
setSuccessMessage("Review submitted! It will be published when both parties have reviewed or after 10 minutes."); setSuccessMessage(
"Review submitted! It will be published when both parties have reviewed or after 10 minutes."
);
} }
setShowSuccessModal(true); setShowSuccessModal(true);
} catch (err: any) { } catch (err: any) {
@@ -114,8 +116,8 @@ const ReviewItemModal: React.FC<ReviewItemModalProps> = ({
</h6> </h6>
<p className="mb-1 text-muted small">{rental.item.name}</p> <p className="mb-1 text-muted small">{rental.item.name}</p>
<small className="text-muted"> <small className="text-muted">
{new Date(rental.startDate).toLocaleDateString()} to{" "} {new Date(rental.startDateTime).toLocaleDateString()} to{" "}
{new Date(rental.endDate).toLocaleDateString()} {new Date(rental.endDateTime).toLocaleDateString()}
</small> </small>
</div> </div>
)} )}
@@ -210,7 +212,7 @@ const ReviewItemModal: React.FC<ReviewItemModalProps> = ({
</form> </form>
</div> </div>
</div> </div>
<SuccessModal <SuccessModal
show={showSuccessModal} show={showSuccessModal}
onClose={handleSuccessModalClose} onClose={handleSuccessModalClose}

View File

@@ -40,12 +40,14 @@ const ReviewRenterModal: React.FC<ReviewRenterModalProps> = ({
setRating(5); setRating(5);
setReview(""); setReview("");
setPrivateMessage(""); setPrivateMessage("");
// Show success modal with appropriate message // Show success modal with appropriate message
if (response.data.reviewVisible) { if (response.data.reviewVisible) {
setSuccessMessage("Review published successfully!"); setSuccessMessage("Review published successfully!");
} else { } else {
setSuccessMessage("Review submitted! It will be published when both parties have reviewed or after 10 minutes."); setSuccessMessage(
"Review submitted! It will be published when both parties have reviewed or after 10 minutes."
);
} }
setShowSuccessModal(true); setShowSuccessModal(true);
} catch (err: any) { } catch (err: any) {
@@ -114,8 +116,8 @@ const ReviewRenterModal: React.FC<ReviewRenterModalProps> = ({
</h6> </h6>
<p className="mb-1 text-muted small">{rental.item.name}</p> <p className="mb-1 text-muted small">{rental.item.name}</p>
<small className="text-muted"> <small className="text-muted">
{new Date(rental.startDate).toLocaleDateString()} to{" "} {new Date(rental.startDateTime).toLocaleDateString()} to{" "}
{new Date(rental.endDate).toLocaleDateString()} {new Date(rental.endDateTime).toLocaleDateString()}
</small> </small>
</div> </div>
)} )}
@@ -210,7 +212,7 @@ const ReviewRenterModal: React.FC<ReviewRenterModalProps> = ({
</form> </form>
</div> </div>
</div> </div>
<SuccessModal <SuccessModal
show={showSuccessModal} show={showSuccessModal}
onClose={handleSuccessModalClose} onClose={handleSuccessModalClose}

View File

@@ -0,0 +1,211 @@
import React, { useState } from "react";
import { stripeAPI } from "../services/api";
interface StripeConnectOnboardingProps {
onComplete: () => void;
onCancel: () => void;
}
const StripeConnectOnboarding: React.FC<StripeConnectOnboardingProps> = ({
onComplete,
onCancel,
}) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [step, setStep] = useState<"start" | "creating" | "redirecting">(
"start"
);
const createStripeAccount = async () => {
setLoading(true);
setError(null);
setStep("creating");
try {
// First, create the Stripe Connected Account
const accountResponse = await stripeAPI.createConnectedAccount();
setStep("redirecting");
// Generate onboarding link
const refreshUrl = `${window.location.origin}/earnings?refresh=true`;
const returnUrl = `${window.location.origin}/earnings?setup=complete`;
const linkResponse = await stripeAPI.createAccountLink({
refreshUrl,
returnUrl,
});
const { url } = linkResponse.data;
// Redirect to Stripe onboarding
window.location.href = url;
} catch (err: any) {
setError(
err.response?.data?.error || err.message || "Failed to set up earnings"
);
setStep("start");
setLoading(false);
}
};
const handleStartSetup = () => {
createStripeAccount();
};
return (
<div
className="modal fade show d-block"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
tabIndex={-1}
>
<div className="modal-dialog modal-lg">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Set Up Earnings</h5>
<button
type="button"
className="btn-close"
onClick={onCancel}
disabled={loading}
></button>
</div>
<div className="modal-body">
{step === "start" && (
<>
<div className="text-center mb-4">
<div className="text-primary mb-3">
<i
className="bi bi-cash-coin"
style={{ fontSize: "3rem" }}
></i>
</div>
<h4>Start Receiving Earnings</h4>
<p className="text-muted">
Set up your earnings account to automatically receive
payments
</p>
</div>
<div className="row text-center mb-4">
<div className="col-md-4">
<div className="mb-3">
<i
className="bi bi-shield-check text-success"
style={{ fontSize: "2rem" }}
></i>
</div>
<h6>Secure</h6>
<small className="text-muted">
Powered by Stripe, trusted by millions
</small>
</div>
<div className="col-md-4">
<div className="mb-3">
<i
className="bi bi-clock text-primary"
style={{ fontSize: "2rem" }}
></i>
</div>
<h6>Automatic</h6>
<small className="text-muted">
Earnings are processed automatically
</small>
</div>
<div className="col-md-4">
<div className="mb-3">
<i
className="bi bi-bank text-info"
style={{ fontSize: "2rem" }}
></i>
</div>
<h6>Direct Deposit</h6>
<small className="text-muted">
Funds go directly to your bank
</small>
</div>
</div>
<div className="alert alert-info">
<h6>
<i className="bi bi-info-circle"></i> What to expect:
</h6>
<ul className="mb-0">
<li>
You'll be redirected to Stripe to verify your identity
</li>
<li>Provide bank account details for deposits</li>
<li>The setup process takes about 5 minutes</li>
<li>Start earning immediately after setup</li>
</ul>
</div>
{error && (
<div className="alert alert-danger">
<i className="bi bi-exclamation-triangle"></i> {error}
</div>
)}
</>
)}
{step === "creating" && (
<div className="text-center">
<div className="spinner-border text-primary mb-3" role="status">
<span className="visually-hidden">Loading...</span>
</div>
<h5>Creating your earnings account...</h5>
<p className="text-muted">
Please wait while we set up your account
</p>
</div>
)}
{step === "redirecting" && (
<div className="text-center">
<div className="spinner-border text-success mb-3" role="status">
<span className="visually-hidden">Loading...</span>
</div>
<h5>Redirecting to Stripe...</h5>
<p className="text-muted">
You'll be redirected to complete the setup process. This may
take a moment.
</p>
</div>
)}
</div>
<div className="modal-footer">
{step === "start" && (
<>
<button
type="button"
className="btn btn-secondary"
onClick={onCancel}
disabled={loading}
>
Cancel
</button>
<button
type="button"
className="btn btn-primary"
onClick={handleStartSetup}
disabled={loading}
>
Set Up Earnings
</button>
</>
)}
{(step === "creating" || step === "redirecting") && (
<div className="w-100 text-center">
<small className="text-muted">
Please don't close this window...
</small>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default StripeConnectOnboarding;

View File

@@ -1,45 +1,49 @@
import React, { useState } from 'react'; import React, { useState } from "react";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from "../contexts/AuthContext";
import { itemRequestAPI } from '../services/api'; import { itemRequestAPI } from "../services/api";
import AddressAutocomplete from '../components/AddressAutocomplete'; import AddressAutocomplete from "../components/AddressAutocomplete";
const CreateItemRequest: React.FC = () => { const CreateItemRequest: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
title: '', title: "",
description: '', description: "",
address1: '', address1: "",
address2: '', address2: "",
city: '', city: "",
state: '', state: "",
zipCode: '', zipCode: "",
country: 'US', country: "US",
latitude: undefined as number | undefined, latitude: undefined as number | undefined,
longitude: undefined as number | undefined, longitude: undefined as number | undefined,
maxPricePerHour: '', maxPricePerHour: "",
maxPricePerDay: '', maxPricePerDay: "",
preferredStartDate: '', preferredStartDate: "",
preferredEndDate: '', preferredEndDate: "",
isFlexibleDates: true isFlexibleDates: true,
}); });
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => { const handleChange = (
e: React.ChangeEvent<
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
>
) => {
const { name, value, type } = e.target; const { name, value, type } = e.target;
if (type === 'checkbox') { if (type === "checkbox") {
const checked = (e.target as HTMLInputElement).checked; const checked = (e.target as HTMLInputElement).checked;
setFormData(prev => ({ ...prev, [name]: checked })); setFormData((prev) => ({ ...prev, [name]: checked }));
} else { } else {
setFormData(prev => ({ ...prev, [name]: value })); setFormData((prev) => ({ ...prev, [name]: value }));
} }
}; };
const handleAddressChange = (value: string, lat?: number, lon?: number) => { const handleAddressChange = (value: string, lat?: number, lon?: number) => {
setFormData(prev => ({ setFormData((prev) => ({
...prev, ...prev,
address1: value, address1: value,
latitude: lat, latitude: lat,
@@ -47,7 +51,7 @@ const CreateItemRequest: React.FC = () => {
city: prev.city, city: prev.city,
state: prev.state, state: prev.state,
zipCode: prev.zipCode, zipCode: prev.zipCode,
country: prev.country country: prev.country,
})); }));
}; };
@@ -61,16 +65,20 @@ const CreateItemRequest: React.FC = () => {
try { try {
const requestData = { const requestData = {
...formData, ...formData,
maxPricePerHour: formData.maxPricePerHour ? parseFloat(formData.maxPricePerHour) : null, maxPricePerHour: formData.maxPricePerHour
maxPricePerDay: formData.maxPricePerDay ? parseFloat(formData.maxPricePerDay) : null, ? parseFloat(formData.maxPricePerHour)
: null,
maxPricePerDay: formData.maxPricePerDay
? parseFloat(formData.maxPricePerDay)
: null,
preferredStartDate: formData.preferredStartDate || null, preferredStartDate: formData.preferredStartDate || null,
preferredEndDate: formData.preferredEndDate || null preferredEndDate: formData.preferredEndDate || null,
}; };
await itemRequestAPI.createItemRequest(requestData); await itemRequestAPI.createItemRequest(requestData);
navigate('/my-requests'); navigate("/my-requests");
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.error || 'Failed to create item request'); setError(err.response?.data?.error || "Failed to create item request");
setLoading(false); setLoading(false);
} }
}; };
@@ -92,7 +100,9 @@ const CreateItemRequest: React.FC = () => {
<div className="card"> <div className="card">
<div className="card-header"> <div className="card-header">
<h2 className="mb-0">Request an Item</h2> <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> <p className="text-muted mb-0">
Can't find what you need? Request it and let others know!
</p>
</div> </div>
<div className="card-body"> <div className="card-body">
{error && ( {error && (
@@ -103,7 +113,9 @@ const CreateItemRequest: React.FC = () => {
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="mb-3"> <div className="mb-3">
<label htmlFor="title" className="form-label">What are you looking for? *</label> <label htmlFor="title" className="form-label">
What are you looking for? *
</label>
<input <input
type="text" type="text"
className="form-control" className="form-control"
@@ -117,7 +129,9 @@ const CreateItemRequest: React.FC = () => {
</div> </div>
<div className="mb-3"> <div className="mb-3">
<label htmlFor="description" className="form-label">Description *</label> <label htmlFor="description" className="form-label">
Description *
</label>
<textarea <textarea
className="form-control" className="form-control"
id="description" id="description"
@@ -132,7 +146,9 @@ const CreateItemRequest: React.FC = () => {
<div className="row mb-3"> <div className="row mb-3">
<div className="col-md-6"> <div className="col-md-6">
<label htmlFor="maxPricePerDay" className="form-label">Max Price per Day</label> <label htmlFor="maxPricePerDay" className="form-label">
Max Price per Day
</label>
<div className="input-group"> <div className="input-group">
<span className="input-group-text">$</span> <span className="input-group-text">$</span>
<input <input
@@ -149,7 +165,9 @@ const CreateItemRequest: React.FC = () => {
</div> </div>
</div> </div>
<div className="col-md-6"> <div className="col-md-6">
<label htmlFor="maxPricePerHour" className="form-label">Max Price per Hour</label> <label htmlFor="maxPricePerHour" className="form-label">
Max Price per Hour
</label>
<div className="input-group"> <div className="input-group">
<span className="input-group-text">$</span> <span className="input-group-text">$</span>
<input <input
@@ -178,7 +196,9 @@ const CreateItemRequest: React.FC = () => {
<div className="row mb-3"> <div className="row mb-3">
<div className="col-md-6"> <div className="col-md-6">
<label htmlFor="address2" className="form-label">Apartment, suite, etc.</label> <label htmlFor="address2" className="form-label">
Apartment, suite, etc.
</label>
<input <input
type="text" type="text"
className="form-control" className="form-control"
@@ -190,7 +210,9 @@ const CreateItemRequest: React.FC = () => {
/> />
</div> </div>
<div className="col-md-6"> <div className="col-md-6">
<label htmlFor="city" className="form-label">City</label> <label htmlFor="city" className="form-label">
City
</label>
<input <input
type="text" type="text"
className="form-control" className="form-control"
@@ -205,7 +227,9 @@ const CreateItemRequest: React.FC = () => {
<div className="row mb-3"> <div className="row mb-3">
<div className="col-md-6"> <div className="col-md-6">
<label htmlFor="state" className="form-label">State</label> <label htmlFor="state" className="form-label">
State
</label>
<input <input
type="text" type="text"
className="form-control" className="form-control"
@@ -217,7 +241,9 @@ const CreateItemRequest: React.FC = () => {
/> />
</div> </div>
<div className="col-md-6"> <div className="col-md-6">
<label htmlFor="zipCode" className="form-label">ZIP Code</label> <label htmlFor="zipCode" className="form-label">
ZIP Code
</label>
<input <input
type="text" type="text"
className="form-control" className="form-control"
@@ -240,7 +266,10 @@ const CreateItemRequest: React.FC = () => {
checked={formData.isFlexibleDates} checked={formData.isFlexibleDates}
onChange={handleChange} onChange={handleChange}
/> />
<label className="form-check-label" htmlFor="isFlexibleDates"> <label
className="form-check-label"
htmlFor="isFlexibleDates"
>
I'm flexible with dates I'm flexible with dates
</label> </label>
</div> </div>
@@ -249,7 +278,12 @@ const CreateItemRequest: React.FC = () => {
{!formData.isFlexibleDates && ( {!formData.isFlexibleDates && (
<div className="row mb-3"> <div className="row mb-3">
<div className="col-md-6"> <div className="col-md-6">
<label htmlFor="preferredStartDate" className="form-label">Preferred Start Date</label> <label
htmlFor="preferredStartDate"
className="form-label"
>
Preferred Start Date
</label>
<input <input
type="date" type="date"
className="form-control" className="form-control"
@@ -257,11 +291,13 @@ const CreateItemRequest: React.FC = () => {
name="preferredStartDate" name="preferredStartDate"
value={formData.preferredStartDate} value={formData.preferredStartDate}
onChange={handleChange} onChange={handleChange}
min={new Date().toISOString().split('T')[0]} min={new Date().toLocaleDateString()}
/> />
</div> </div>
<div className="col-md-6"> <div className="col-md-6">
<label htmlFor="preferredEndDate" className="form-label">Preferred End Date</label> <label htmlFor="preferredEndDate" className="form-label">
Preferred End Date
</label>
<input <input
type="date" type="date"
className="form-control" className="form-control"
@@ -269,7 +305,10 @@ const CreateItemRequest: React.FC = () => {
name="preferredEndDate" name="preferredEndDate"
value={formData.preferredEndDate} value={formData.preferredEndDate}
onChange={handleChange} onChange={handleChange}
min={formData.preferredStartDate || new Date().toISOString().split('T')[0]} min={
formData.preferredStartDate ||
new Date().toLocaleDateString()
}
/> />
</div> </div>
</div> </div>
@@ -281,7 +320,7 @@ const CreateItemRequest: React.FC = () => {
className="btn btn-primary" className="btn btn-primary"
disabled={loading} disabled={loading}
> >
{loading ? 'Creating Request...' : 'Create Request'} {loading ? "Creating Request..." : "Create Request"}
</button> </button>
<button <button
type="button" type="button"
@@ -300,4 +339,4 @@ const CreateItemRequest: React.FC = () => {
); );
}; };
export default CreateItemRequest; export default CreateItemRequest;

View File

@@ -0,0 +1,288 @@
import React, { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { rentalAPI, userAPI } from "../services/api";
import { Rental, User } from "../types";
import StripeConnectOnboarding from "../components/StripeConnectOnboarding";
import EarningsStatus from "../components/EarningsStatus";
interface EarningsData {
totalEarnings: number;
pendingEarnings: number;
completedEarnings: number;
rentalsWithEarnings: Rental[];
}
const EarningsDashboard: React.FC = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [earningsData, setEarningsData] = useState<EarningsData | null>(null);
const [userProfile, setUserProfile] = useState<User | null>(null);
const [showOnboarding, setShowOnboarding] = useState(false);
useEffect(() => {
fetchUserProfile();
fetchEarningsData();
}, []);
const fetchUserProfile = async () => {
try {
const response = await userAPI.getProfile();
setUserProfile(response.data);
} catch (err) {
console.error("Failed to fetch user profile:", err);
}
};
const fetchEarningsData = async () => {
try {
// Get completed rentals where user is the owner
const response = await rentalAPI.getMyListings();
const rentals = response.data || [];
// Filter for completed rentals with earnings data
const completedRentals = rentals.filter(
(rental: Rental) => rental.status === "completed" && rental.payoutAmount
);
// Calculate earnings - convert string values to numbers
const totalEarnings = completedRentals.reduce(
(sum: number, rental: Rental) =>
sum + parseFloat(rental.payoutAmount?.toString() || "0"),
0
);
const pendingEarnings = completedRentals
.filter((rental: Rental) => rental.payoutStatus === "pending")
.reduce(
(sum: number, rental: Rental) =>
sum + parseFloat(rental.payoutAmount?.toString() || "0"),
0
);
const completedEarnings = completedRentals
.filter((rental: Rental) => rental.payoutStatus === "completed")
.reduce(
(sum: number, rental: Rental) =>
sum + parseFloat(rental.payoutAmount?.toString() || "0"),
0
);
setEarningsData({
totalEarnings,
pendingEarnings,
completedEarnings,
rentalsWithEarnings: completedRentals,
});
} catch (err: any) {
setError(err.response?.data?.message || "Failed to fetch earnings data");
} finally {
setLoading(false);
}
};
const handleSetupComplete = () => {
setShowOnboarding(false);
fetchUserProfile(); // Refresh user profile after setup
fetchEarningsData();
};
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>
);
}
const hasStripeAccount = !!userProfile?.stripeConnectedAccountId;
return (
<div className="container mt-4">
<div className="row">
<div className="col-12">
<h1>My Earnings</h1>
<p className="text-muted">
Manage your rental earnings and payment setup
</p>
</div>
</div>
{error && (
<div className="alert alert-danger" role="alert">
{error}
</div>
)}
<div className="row">
<div className="col-md-8">
{/* Earnings Overview */}
<div className="card mb-4">
<div className="card-header">
<h5 className="mb-0">Earnings Overview</h5>
</div>
<div className="card-body">
<div className="row text-center">
<div className="col-md-4">
<h3 className="text-primary">
${(earningsData?.totalEarnings || 0).toFixed(2)}
</h3>
<p className="text-muted">Total Earnings</p>
</div>
<div className="col-md-4">
<h3 className="text-warning">
${(earningsData?.pendingEarnings || 0).toFixed(2)}
</h3>
<p className="text-muted">Pending Earnings</p>
</div>
<div className="col-md-4">
<h3 className="text-success">
${(earningsData?.completedEarnings || 0).toFixed(2)}
</h3>
<p className="text-muted">Paid Out</p>
</div>
</div>
</div>
</div>
{/* Earnings History */}
<div className="card mb-4">
<div className="card-header">
<h5 className="mb-0">Earnings History</h5>
</div>
<div className="card-body">
{earningsData?.rentalsWithEarnings.length === 0 ? (
<p className="text-muted text-center">
No completed rentals yet
</p>
) : (
<div className="table-responsive">
<table className="table">
<thead>
<tr>
<th>Item</th>
<th>Rental Period</th>
<th>Total Amount</th>
<th>Your Earnings</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{earningsData?.rentalsWithEarnings.map((rental) => (
<tr key={rental.id}>
<td>
<Link to={`/items/${rental.itemId}`}>
{rental.item?.name || "Item"}
</Link>
</td>
<td>
<small>
{new Date(rental.startDateTime).toLocaleString()}
<br />
to
<br />
{new Date(rental.endDateTime).toLocaleString()}
</small>
</td>
<td>
$
{parseFloat(
rental.totalAmount?.toString() || "0"
).toFixed(2)}
</td>
<td className="text-success">
<strong>
$
{parseFloat(
rental.payoutAmount?.toString() || "0"
).toFixed(2)}
</strong>
</td>
<td>
<span
className={`badge ${
rental.payoutStatus === "completed"
? "bg-success"
: rental.payoutStatus === "processing"
? "bg-warning"
: "bg-secondary"
}`}
>
{rental.payoutStatus === "completed"
? "Paid"
: rental.payoutStatus === "processing"
? "Processing"
: "Pending"}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
{/* Sidebar */}
<div className="col-md-4">
{/* Earnings Setup Status */}
<div className="card mb-4">
<div className="card-header">
<h5 className="mb-0">Earnings Setup</h5>
</div>
<div className="card-body">
<EarningsStatus
hasStripeAccount={hasStripeAccount}
onSetupClick={() => setShowOnboarding(true)}
/>
</div>
</div>
{/* Quick Stats */}
<div className="card">
<div className="card-header">
<h5 className="mb-0">Quick Stats</h5>
</div>
<div className="card-body">
<div className="d-flex justify-content-between mb-2">
<span>Completed Rentals:</span>
<strong>{earningsData?.rentalsWithEarnings.length || 0}</strong>
</div>
<div className="d-flex justify-content-between mb-2">
<span>Average Earning:</span>
<strong>
$
{earningsData?.rentalsWithEarnings.length
? (
(earningsData.totalEarnings || 0) /
earningsData.rentalsWithEarnings.length
).toFixed(2)
: "0.00"}
</strong>
</div>
<div className="d-flex justify-content-between">
<span>Platform Fee:</span>
<strong>20%</strong>
</div>
</div>
</div>
</div>
</div>
{/* Stripe Connect Onboarding Modal */}
{showOnboarding && (
<StripeConnectOnboarding
onComplete={handleSetupComplete}
onCancel={() => setShowOnboarding(false)}
/>
)}
</div>
);
};
export default EarningsDashboard;

View File

@@ -488,7 +488,7 @@ const ItemDetail: React.FC = () => {
e.target.value e.target.value
) )
} }
min={new Date().toISOString().split("T")[0]} min={new Date().toLocaleDateString()}
style={{ flex: "1 1 50%" }} style={{ flex: "1 1 50%" }}
/> />
<select <select
@@ -537,7 +537,7 @@ const ItemDetail: React.FC = () => {
} }
min={ min={
rentalDates.startDate || rentalDates.startDate ||
new Date().toISOString().split("T")[0] new Date().toLocaleDateString()
} }
style={{ flex: "1 1 50%" }} style={{ flex: "1 1 50%" }}
/> />

View File

@@ -22,10 +22,9 @@ const MyListings: React.FC = () => {
}; };
// Helper function to format date and time together // Helper function to format date and time together
const formatDateTime = (dateString: string, timeString?: string) => { const formatDateTime = (dateTimeString: string) => {
const date = new Date(dateString).toLocaleDateString(); const date = new Date(dateTimeString).toLocaleDateString();
const formattedTime = formatTime(timeString); return date;
return formattedTime ? `${date} at ${formattedTime}` : date;
}; };
const { user } = useAuth(); const { user } = useAuth();
@@ -98,7 +97,6 @@ const MyListings: React.FC = () => {
const fetchOwnerRentals = async () => { const fetchOwnerRentals = async () => {
try { try {
const response = await rentalAPI.getMyListings(); const response = await rentalAPI.getMyListings();
console.log("Owner rentals data from backend:", response.data);
setOwnerRentals(response.data); setOwnerRentals(response.data);
} catch (err: any) { } catch (err: any) {
console.error("Failed to fetch owner rentals:", err); console.error("Failed to fetch owner rentals:", err);
@@ -128,7 +126,6 @@ const MyListings: React.FC = () => {
const handleCompleteClick = async (rental: Rental) => { const handleCompleteClick = async (rental: Rental) => {
try { try {
console.log("Marking rental as completed:", rental.id);
await rentalAPI.markAsCompleted(rental.id); await rentalAPI.markAsCompleted(rental.id);
setSelectedRentalForReview(rental); setSelectedRentalForReview(rental);
@@ -237,10 +234,8 @@ const MyListings: React.FC = () => {
<p className="mb-1 text-dark small"> <p className="mb-1 text-dark small">
<strong>Period:</strong> <strong>Period:</strong>
<br /> <br />
{formatDateTime( {formatDateTime(rental.startDateTime)} -{" "}
rental.startDate, {formatDateTime(rental.endDateTime)}
rental.startTime
)} - {formatDateTime(rental.endDate, rental.endTime)}
</p> </p>
<p className="mb-1 text-dark small"> <p className="mb-1 text-dark small">

View File

@@ -21,13 +21,6 @@ const MyRentals: React.FC = () => {
} }
}; };
// Helper function to format date and time together
const formatDateTime = (dateString: string, timeString?: string) => {
const date = new Date(dateString).toLocaleDateString();
const formattedTime = formatTime(timeString);
return formattedTime ? `${date} at ${formattedTime}` : date;
};
const { user } = useAuth(); const { user } = useAuth();
const [rentals, setRentals] = useState<Rental[]>([]); const [rentals, setRentals] = useState<Rental[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -171,19 +164,16 @@ const MyRentals: React.FC = () => {
{rental.status.charAt(0).toUpperCase() + {rental.status.charAt(0).toUpperCase() +
rental.status.slice(1)} rental.status.slice(1)}
</span> </span>
{rental.paymentStatus === "paid" && (
<span className="badge bg-success ms-2">Paid</span>
)}
</div> </div>
<p className="mb-1 text-dark"> <p className="mb-1 text-dark">
<strong>Rental Period:</strong> <strong>Rental Period:</strong>
<br /> <br />
<strong>Start:</strong>{" "} <strong>Start:</strong>{" "}
{formatDateTime(rental.startDate, rental.startTime)} {new Date(rental.startDateTime).toLocaleString()}
<br /> <br />
<strong>End:</strong>{" "} <strong>End:</strong>{" "}
{formatDateTime(rental.endDate, rental.endTime)} {new Date(rental.endDateTime).toLocaleString()}
</p> </p>
<p className="mb-1 text-dark"> <p className="mb-1 text-dark">

View File

@@ -176,10 +176,9 @@ const Profile: React.FC = () => {
} }
}; };
const formatDateTime = (dateString: string, timeString?: string) => { const formatDateTime = (dateTimeString: string) => {
const date = new Date(dateString).toLocaleDateString(); const date = new Date(dateTimeString).toLocaleDateString();
const formattedTime = formatTime(timeString); return date;
return formattedTime ? `${date} at ${formattedTime}` : date;
}; };
const fetchRentalHistory = async () => { const fetchRentalHistory = async () => {
@@ -853,16 +852,10 @@ const Profile: React.FC = () => {
<strong>Period:</strong> <strong>Period:</strong>
<br /> <br />
<strong>Start:</strong>{" "} <strong>Start:</strong>{" "}
{formatDateTime( {formatDateTime(rental.startDateTime)}
rental.startDate,
rental.startTime
)}
<br /> <br />
<strong>End:</strong>{" "} <strong>End:</strong>{" "}
{formatDateTime( {formatDateTime(rental.endDateTime)}
rental.endDate,
rental.endTime
)}
</p> </p>
<p className="mb-1 small"> <p className="mb-1 small">
@@ -994,15 +987,8 @@ const Profile: React.FC = () => {
<p className="mb-1 small"> <p className="mb-1 small">
<strong>Period:</strong> <strong>Period:</strong>
<br /> <br />
{formatDateTime( {formatDateTime(rental.startDateTime)} -{" "}
rental.startDate, {formatDateTime(rental.endDateTime)}
rental.startTime
)}{" "}
-{" "}
{formatDateTime(
rental.endDate,
rental.endTime
)}
</p> </p>
<p className="mb-1 small"> <p className="mb-1 small">

View File

@@ -28,9 +28,24 @@ const RentItem: React.FC = () => {
const [totalCost, setTotalCost] = useState(0); const [totalCost, setTotalCost] = useState(0);
const convertToUTC = (dateString: string, timeString: string): string => {
if (!dateString || !timeString) {
throw new Error("Date and time are required");
}
// Create date in user's local timezone
const localDateTime = new Date(`${dateString}T${timeString}`);
// Return UTC ISO string
return localDateTime.toISOString();
};
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
if (!dateString) return ""; if (!dateString) return "";
return new Date(dateString).toLocaleDateString(); // Use safe date parsing to avoid timezone issues
const [year, month, day] = dateString.split("-");
const date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
return date.toLocaleDateString();
}; };
const formatTime = (timeString: string) => { const formatTime = (timeString: string) => {
@@ -167,10 +182,14 @@ const RentItem: React.FC = () => {
itemName={item.name} itemName={item.name}
rentalData={{ rentalData={{
itemId: item.id, itemId: item.id,
startDate: manualSelection.startDate, startDateTime: convertToUTC(
endDate: manualSelection.endDate, manualSelection.startDate,
startTime: manualSelection.startTime, manualSelection.startTime
endTime: manualSelection.endTime, ),
endDateTime: convertToUTC(
manualSelection.endDate,
manualSelection.endTime
),
totalAmount: totalCost, totalAmount: totalCost,
deliveryMethod: "pickup", deliveryMethod: "pickup",
}} }}

View File

@@ -84,7 +84,6 @@ export const rentalAPI = {
api.post(`/rentals/${id}/review-renter`, data), api.post(`/rentals/${id}/review-renter`, data),
reviewItem: (id: string, data: any) => reviewItem: (id: string, data: any) =>
api.post(`/rentals/${id}/review-item`, data), api.post(`/rentals/${id}/review-item`, data),
addReview: (id: string, data: any) => api.post(`/rentals/${id}/review`, data), // Legacy
}; };
export const messageAPI = { export const messageAPI = {
@@ -119,14 +118,10 @@ export const stripeAPI = {
}) => api.post("/stripe/create-checkout-session", data), }) => api.post("/stripe/create-checkout-session", data),
getCheckoutSession: (sessionId: string) => getCheckoutSession: (sessionId: string) =>
api.get(`/stripe/checkout-session/${sessionId}`), api.get(`/stripe/checkout-session/${sessionId}`),
// createConnectedAccount: () => createConnectedAccount: () => api.post("/stripe/accounts"),
// api.post("/stripe/accounts"), createAccountLink: (data: { refreshUrl: string; returnUrl: string }) =>
// createAccountLink: (data: { refreshUrl: string; returnUrl: string }) => api.post("/stripe/account-links", data),
// api.post("/stripe/account-links", data), getAccountStatus: () => api.get("/stripe/account-status"),
// getAccountStatus: () =>
// api.get("/stripe/account-status"),
// createPaymentIntent: (data: { rentalId: string; amount: number }) =>
// api.post("/stripe/payment-intents", data),
}; };
export default api; export default api;

View File

@@ -29,6 +29,7 @@ export interface User {
country?: string; country?: string;
profileImage?: string; profileImage?: string;
isVerified: boolean; isVerified: boolean;
stripeConnectedAccountId?: string;
addresses?: Address[]; addresses?: Address[];
} }
@@ -101,13 +102,20 @@ export interface Rental {
itemId: string; itemId: string;
renterId: string; renterId: string;
ownerId: string; ownerId: string;
startDate: string; startDateTime: string;
endDate: string; endDateTime: string;
startTime?: string;
endTime?: string;
totalAmount: number; totalAmount: number;
// Fee tracking fields
baseRentalAmount?: number;
platformFee?: number;
processingFee?: number;
payoutAmount?: number;
status: "pending" | "confirmed" | "active" | "completed" | "cancelled"; status: "pending" | "confirmed" | "active" | "completed" | "cancelled";
paymentStatus: "pending" | "paid" | "refunded"; paymentStatus: "pending" | "paid" | "refunded";
// Payout status tracking
payoutStatus?: "pending" | "processing" | "completed" | "failed";
payoutProcessedAt?: string;
stripeTransferId?: string;
deliveryMethod: "pickup" | "delivery"; deliveryMethod: "pickup" | "delivery";
deliveryAddress?: string; deliveryAddress?: string;
notes?: string; notes?: string;