338 lines
12 KiB
TypeScript
338 lines
12 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import { Link } from "react-router";
|
|
import { rentalAPI, userAPI, stripeAPI } 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[];
|
|
}
|
|
|
|
interface AccountStatus {
|
|
detailsSubmitted: boolean;
|
|
payoutsEnabled: boolean;
|
|
}
|
|
|
|
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 [accountStatus, setAccountStatus] = useState<AccountStatus | null>(
|
|
null
|
|
);
|
|
const [showOnboarding, setShowOnboarding] = useState(false);
|
|
|
|
useEffect(() => {
|
|
fetchUserProfile();
|
|
fetchEarningsData();
|
|
}, []);
|
|
|
|
const fetchUserProfile = async () => {
|
|
try {
|
|
const response = await userAPI.getProfile();
|
|
setUserProfile(response.data);
|
|
|
|
// If user has a Stripe account, fetch account status
|
|
if (response.data.stripeConnectedAccountId) {
|
|
await fetchAccountStatus();
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to fetch user profile:", err);
|
|
}
|
|
};
|
|
|
|
const fetchAccountStatus = async () => {
|
|
try {
|
|
const response = await stripeAPI.getAccountStatus();
|
|
setAccountStatus({
|
|
detailsSubmitted: response.data.detailsSubmitted,
|
|
payoutsEnabled: response.data.payoutsEnabled,
|
|
});
|
|
} catch (err) {
|
|
console.error("Failed to fetch account status:", err);
|
|
}
|
|
};
|
|
|
|
const fetchEarningsData = async () => {
|
|
try {
|
|
// Get completed rentals where user is the owner
|
|
const response = await rentalAPI.getListings();
|
|
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.bankDepositStatus !== "paid")
|
|
.reduce(
|
|
(sum: number, rental: Rental) =>
|
|
sum + parseFloat(rental.payoutAmount?.toString() || "0"),
|
|
0
|
|
);
|
|
|
|
const completedEarnings = completedRentals
|
|
.filter((rental: Rental) => rental.bankDepositStatus === "paid")
|
|
.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;
|
|
const isOnboardingComplete = accountStatus?.detailsSubmitted ?? false;
|
|
const payoutsEnabled = accountStatus?.payoutsEnabled ?? true;
|
|
|
|
// Don't show setup card until we have account status (if user has a Stripe account)
|
|
// This prevents the setup card from flashing briefly while fetching account status
|
|
const accountStatusLoading = hasStripeAccount && accountStatus === null;
|
|
|
|
// Show setup card if: no account, onboarding incomplete, or payouts disabled
|
|
const showSetupCard =
|
|
!accountStatusLoading &&
|
|
(!hasStripeAccount || !isOnboardingComplete || !payoutsEnabled);
|
|
|
|
return (
|
|
<div className="container mt-4">
|
|
<div className="row">
|
|
<div className="col-12">
|
|
<h1>Earnings</h1>
|
|
<p className="text-muted">
|
|
Manage your rental earnings and payment setup.{" "}
|
|
<Link to="/faq" target="_blank">
|
|
Calculate what you can earn here
|
|
</Link>{" "}
|
|
or <Link to="/faq#earnings">learn how payouts work</Link>.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="alert alert-danger" role="alert">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Earnings Setup - show if not fully set up or payouts disabled */}
|
|
{showSetupCard && (
|
|
<div className="card mb-4">
|
|
<div className="card-header">
|
|
<h5 className="mb-0">Earnings Setup</h5>
|
|
</div>
|
|
<div className="card-body">
|
|
<EarningsStatus
|
|
hasStripeAccount={hasStripeAccount}
|
|
isOnboardingComplete={isOnboardingComplete}
|
|
payoutsEnabled={payoutsEnabled}
|
|
onSetupClick={() => setShowOnboarding(true)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="row">
|
|
<div className="col-12">
|
|
{/* 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>
|
|
{(() => {
|
|
// Determine badge based on bank deposit and payout status
|
|
let badgeClass = "bg-secondary";
|
|
let badgeLabel = "Pending";
|
|
let badgeTooltip =
|
|
"Waiting for rental to complete or Stripe setup.";
|
|
|
|
if (rental.bankDepositStatus === "paid") {
|
|
badgeClass = "bg-success";
|
|
badgeLabel = "Deposited";
|
|
badgeTooltip = rental.bankDepositAt
|
|
? `Deposited to your bank on ${new Date(
|
|
rental.bankDepositAt
|
|
).toLocaleDateString()}`
|
|
: "Funds deposited to your bank account.";
|
|
} else if (
|
|
rental.bankDepositStatus === "failed"
|
|
) {
|
|
badgeClass = "bg-danger";
|
|
badgeLabel = "Deposit Failed";
|
|
badgeTooltip =
|
|
"Bank deposit failed. Please check your Stripe dashboard.";
|
|
} else if (
|
|
rental.bankDepositStatus === "in_transit"
|
|
) {
|
|
badgeClass = "bg-info";
|
|
badgeLabel = "In Transit to Bank";
|
|
badgeTooltip =
|
|
"Funds are on their way to your bank.";
|
|
} else if (rental.payoutStatus === "completed") {
|
|
badgeClass = "bg-info";
|
|
badgeLabel = "Transferred to Stripe";
|
|
badgeTooltip =
|
|
"In your Stripe balance. Bank deposit in 2-7 business days.";
|
|
} else if (rental.payoutStatus === "failed") {
|
|
badgeClass = "bg-danger";
|
|
badgeLabel = "Transfer Failed";
|
|
badgeTooltip =
|
|
"Transfer failed. We'll retry automatically.";
|
|
}
|
|
|
|
return (
|
|
<span
|
|
className={`badge ${badgeClass}`}
|
|
title={badgeTooltip}
|
|
style={{ cursor: "help" }}
|
|
>
|
|
{badgeLabel}
|
|
</span>
|
|
);
|
|
})()}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stripe Connect Onboarding Modal */}
|
|
{showOnboarding && (
|
|
<StripeConnectOnboarding
|
|
onComplete={handleSetupComplete}
|
|
onCancel={() => setShowOnboarding(false)}
|
|
hasExistingAccount={hasStripeAccount}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default EarningsDashboard;
|