payouts
This commit is contained in:
@@ -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;
|
||||||
@@ -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 } },
|
||||||
|
{
|
||||||
|
[Op.or]: [
|
||||||
|
{
|
||||||
|
startDateTime: {
|
||||||
|
[Op.between]: [rentalStartDateTime, rentalEndDateTime],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
endDate: { [Op.between]: [startDate, endDate] },
|
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 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
76
frontend/src/components/EarningsStatus.tsx
Normal file
76
frontend/src/components/EarningsStatus.tsx
Normal 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;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,7 +14,7 @@ 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);
|
||||||
@@ -22,13 +22,13 @@ const RequestResponseModal: React.FC<RequestResponseModalProps> = ({
|
|||||||
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,12 +103,20 @@ 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">
|
||||||
@@ -117,7 +133,9 @@ const RequestResponseModal: React.FC<RequestResponseModalProps> = ({
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label htmlFor="message" className="form-label">Your Message *</label>
|
<label htmlFor="message" className="form-label">
|
||||||
|
Your Message *
|
||||||
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="form-control"
|
className="form-control"
|
||||||
id="message"
|
id="message"
|
||||||
@@ -132,7 +150,9 @@ const RequestResponseModal: React.FC<RequestResponseModalProps> = ({
|
|||||||
|
|
||||||
{userItems.length > 0 && (
|
{userItems.length > 0 && (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label htmlFor="existingItemId" className="form-label">Do you have an existing listing for this item?</label>
|
<label htmlFor="existingItemId" className="form-label">
|
||||||
|
Do you have an existing listing for this item?
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
className="form-select"
|
className="form-select"
|
||||||
id="existingItemId"
|
id="existingItemId"
|
||||||
@@ -148,14 +168,17 @@ const RequestResponseModal: React.FC<RequestResponseModalProps> = ({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<div className="form-text">
|
<div className="form-text">
|
||||||
If you have an existing listing that matches this request, select it here.
|
If you have an existing listing that matches this request,
|
||||||
|
select it here.
|
||||||
</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="offerPricePerDay" className="form-label">Your Price per Day</label>
|
<label htmlFor="offerPricePerDay" className="form-label">
|
||||||
|
Your 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
|
||||||
@@ -172,7 +195,9 @@ const RequestResponseModal: React.FC<RequestResponseModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-6">
|
<div className="col-md-6">
|
||||||
<label htmlFor="offerPricePerHour" className="form-label">Your Price per Hour</label>
|
<label htmlFor="offerPricePerHour" className="form-label">
|
||||||
|
Your 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
|
||||||
@@ -192,7 +217,9 @@ const RequestResponseModal: React.FC<RequestResponseModalProps> = ({
|
|||||||
|
|
||||||
<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">
|
||||||
|
Available From
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
@@ -200,11 +227,13 @@ const RequestResponseModal: React.FC<RequestResponseModalProps> = ({
|
|||||||
name="availableStartDate"
|
name="availableStartDate"
|
||||||
value={formData.availableStartDate}
|
value={formData.availableStartDate}
|
||||||
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="availableEndDate" className="form-label">Available Until</label>
|
<label htmlFor="availableEndDate" className="form-label">
|
||||||
|
Available Until
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
@@ -212,13 +241,18 @@ const RequestResponseModal: React.FC<RequestResponseModalProps> = ({
|
|||||||
name="availableEndDate"
|
name="availableEndDate"
|
||||||
value={formData.availableEndDate}
|
value={formData.availableEndDate}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
min={formData.availableStartDate || new Date().toISOString().split('T')[0]}
|
min={
|
||||||
|
formData.availableStartDate ||
|
||||||
|
new Date().toLocaleDateString()
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
||||||
|
Contact Information
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
@@ -236,7 +270,11 @@ const RequestResponseModal: React.FC<RequestResponseModalProps> = ({
|
|||||||
</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
|
||||||
@@ -245,7 +283,7 @@ const RequestResponseModal: React.FC<RequestResponseModalProps> = ({
|
|||||||
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ const ReviewItemModal: React.FC<ReviewItemModalProps> = ({
|
|||||||
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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ const ReviewRenterModal: React.FC<ReviewRenterModalProps> = ({
|
|||||||
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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
211
frontend/src/components/StripeConnectOnboarding.tsx
Normal file
211
frontend/src/components/StripeConnectOnboarding.tsx
Normal 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;
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
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();
|
||||||
@@ -11,35 +11,39 @@ const CreateItemRequest: React.FC = () => {
|
|||||||
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"
|
||||||
|
|||||||
288
frontend/src/pages/EarningsDashboard.tsx
Normal file
288
frontend/src/pages/EarningsDashboard.tsx
Normal 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;
|
||||||
@@ -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%" }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user