pricing tiers

This commit is contained in:
jackiettran
2025-11-06 15:54:27 -05:00
parent 9c258177ae
commit 3dca6c803a
14 changed files with 508 additions and 154 deletions

View File

@@ -48,6 +48,12 @@ const Item = sequelize.define("Item", {
pricePerDay: { pricePerDay: {
type: DataTypes.DECIMAL(10, 2), type: DataTypes.DECIMAL(10, 2),
}, },
pricePerWeek: {
type: DataTypes.DECIMAL(10, 2),
},
pricePerMonth: {
type: DataTypes.DECIMAL(10, 2),
},
replacementCost: { replacementCost: {
type: DataTypes.DECIMAL(10, 2), type: DataTypes.DECIMAL(10, 2),
allowNull: false, allowNull: false,

View File

@@ -33,6 +33,12 @@ const ItemRequestResponse = sequelize.define('ItemRequestResponse', {
offerPricePerDay: { offerPricePerDay: {
type: DataTypes.DECIMAL(10, 2) type: DataTypes.DECIMAL(10, 2)
}, },
offerPricePerWeek: {
type: DataTypes.DECIMAL(10, 2)
},
offerPricePerMonth: {
type: DataTypes.DECIMAL(10, 2)
},
availableStartDate: { availableStartDate: {
type: DataTypes.DATE type: DataTypes.DATE
}, },

View File

@@ -3,6 +3,7 @@ const { Op } = require("sequelize");
const { Rental, Item, User } = require("../models"); // Import from models/index.js to get models with associations const { Rental, Item, User } = require("../models"); // Import from models/index.js to get models with associations
const { authenticateToken, requireVerifiedEmail } = require("../middleware/auth"); const { authenticateToken, requireVerifiedEmail } = require("../middleware/auth");
const FeeCalculator = require("../utils/feeCalculator"); const FeeCalculator = require("../utils/feeCalculator");
const RentalDurationCalculator = require("../utils/rentalDurationCalculator");
const RefundService = require("../services/refundService"); const RefundService = require("../services/refundService");
const LateReturnService = require("../services/lateReturnService"); const LateReturnService = require("../services/lateReturnService");
const DamageAssessmentService = require("../services/damageAssessmentService"); const DamageAssessmentService = require("../services/damageAssessmentService");
@@ -201,19 +202,12 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
rentalStartDateTime = new Date(startDateTime); rentalStartDateTime = new Date(startDateTime);
rentalEndDateTime = new Date(endDateTime); rentalEndDateTime = new Date(endDateTime);
// Calculate rental duration // Calculate rental cost using duration calculator
const diffMs = rentalEndDateTime.getTime() - rentalStartDateTime.getTime(); totalAmount = RentalDurationCalculator.calculateRentalCost(
const diffHours = Math.ceil(diffMs / (1000 * 60 * 60)); rentalStartDateTime,
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); rentalEndDateTime,
item
// Calculate base amount based on duration );
if (item.pricePerHour && diffHours <= 24) {
totalAmount = diffHours * Number(item.pricePerHour);
} else if (item.pricePerDay) {
totalAmount = diffDays * Number(item.pricePerDay);
} else {
totalAmount = 0;
}
// Check for overlapping rentals using datetime ranges // Check for overlapping rentals using datetime ranges
const overlappingRental = await Rental.findOne({ const overlappingRental = await Rental.findOne({
@@ -884,6 +878,60 @@ router.post("/calculate-fees", authenticateToken, async (req, res) => {
} }
}); });
// Preview rental cost calculation (no rental creation)
router.post("/cost-preview", authenticateToken, async (req, res) => {
try {
const { itemId, startDateTime, endDateTime } = req.body;
// Validate inputs
if (!itemId || !startDateTime || !endDateTime) {
return res.status(400).json({
error: "itemId, startDateTime, and endDateTime are required",
});
}
// Fetch item
const item = await Item.findByPk(itemId);
if (!item) {
return res.status(404).json({ error: "Item not found" });
}
// Parse datetimes
const rentalStartDateTime = new Date(startDateTime);
const rentalEndDateTime = new Date(endDateTime);
// Validate date range
if (rentalEndDateTime <= rentalStartDateTime) {
return res.status(400).json({
error: "End date/time must be after start date/time",
});
}
// Calculate rental cost using duration calculator
const totalAmount = RentalDurationCalculator.calculateRentalCost(
rentalStartDateTime,
rentalEndDateTime,
item
);
// Calculate fees
const fees = FeeCalculator.calculateRentalFees(totalAmount);
res.json({
baseAmount: totalAmount,
fees,
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error calculating rental cost preview", {
error: error.message,
stack: error.stack,
userId: req.user.id,
});
res.status(500).json({ error: "Failed to calculate rental cost preview" });
}
});
// Get earnings status for owner's rentals // Get earnings status for owner's rentals
router.get("/earnings/status", authenticateToken, async (req, res) => { router.get("/earnings/status", authenticateToken, async (req, res) => {
try { try {

View File

@@ -25,33 +25,27 @@ class LateReturnService {
let lateFee = 0; let lateFee = 0;
let pricingType = "daily"; let pricingType = "daily";
// Check if item has hourly or daily pricing
if (rental.item?.pricePerHour && rental.item.pricePerHour > 0) {
// Hourly pricing - charge per hour late
lateFee = hoursLate * parseFloat(rental.item.pricePerHour);
pricingType = "hourly";
} else if (rental.item?.pricePerDay && rental.item.pricePerDay > 0) {
// Daily pricing - charge per day late (rounded up)
const billableDays = Math.ceil(hoursLate / 24); const billableDays = Math.ceil(hoursLate / 24);
// Calculate late fees per day, deriving daily rate from available pricing tiers
if (rental.item?.pricePerDay && rental.item.pricePerDay > 0) {
// Daily pricing - charge per day late
lateFee = billableDays * parseFloat(rental.item.pricePerDay); lateFee = billableDays * parseFloat(rental.item.pricePerDay);
pricingType = "daily"; } else if (rental.item?.pricePerWeek && rental.item.pricePerWeek > 0) {
// Weekly pricing - derive daily rate and charge per day late
const dailyRate = parseFloat(rental.item.pricePerWeek) / 7;
lateFee = billableDays * dailyRate;
} else if (rental.item?.pricePerMonth && rental.item.pricePerMonth > 0) {
// Monthly pricing - derive daily rate and charge per day late
const dailyRate = parseFloat(rental.item.pricePerMonth) / 30;
lateFee = billableDays * dailyRate;
} else if (rental.item?.pricePerHour && rental.item.pricePerHour > 0) {
// Hourly pricing - derive daily rate and charge per day late
const dailyRate = parseFloat(rental.item.pricePerHour) * 24;
lateFee = billableDays * dailyRate;
} else { } else {
// Free borrows: determine pricing type based on rental duration // Free borrows - charge $10 per day late
const rentalStart = new Date(rental.startDateTime);
const rentalEnd = new Date(rental.endDateTime);
const rentalDurationHours = (rentalEnd - rentalStart) / (1000 * 60 * 60);
if (rentalDurationHours <= 24) {
// Hourly rental - charge $10 per hour late
lateFee = hoursLate * 10.0;
pricingType = "hourly";
} else {
// Daily rental - charge $10 per day late
const billableDays = Math.ceil(hoursLate / 24);
lateFee = billableDays * 10.0; lateFee = billableDays * 10.0;
pricingType = "daily";
}
} }
return { return {

View File

@@ -0,0 +1,62 @@
class RentalDurationCalculator {
/**
* Calculate rental cost based on duration and item pricing tiers
* @param {Date|string} startDateTime - Rental start date/time
* @param {Date|string} endDateTime - Rental end date/time
* @param {Object} item - Item object with pricing information
* @returns {number} Total rental cost
*/
static calculateRentalCost(startDateTime, endDateTime, item) {
const rentalStartDateTime = new Date(startDateTime);
const 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));
const diffWeeks = Math.ceil(diffDays / 7);
// Calculate difference in calendar months
let diffMonths = (rentalEndDateTime.getFullYear() - rentalStartDateTime.getFullYear()) * 12;
diffMonths += rentalEndDateTime.getMonth() - rentalStartDateTime.getMonth();
// Add 1 if we're past the start day/time in the end month
if (rentalEndDateTime.getDate() > rentalStartDateTime.getDate()) {
diffMonths += 1;
} else if (rentalEndDateTime.getDate() === rentalStartDateTime.getDate() &&
rentalEndDateTime.getTime() > rentalStartDateTime.getTime()) {
diffMonths += 1;
}
diffMonths = Math.max(1, diffMonths);
// Calculate base amount based on duration (tiered pricing)
let totalAmount;
if (item.pricePerHour && diffHours <= 24) {
// Use hourly rate for rentals <= 24 hours
totalAmount = diffHours * Number(item.pricePerHour);
} else if (diffDays <= 7 && item.pricePerDay) {
// Use daily rate for rentals <= 7 days
totalAmount = diffDays * Number(item.pricePerDay);
} else if (diffMonths <= 1 && item.pricePerWeek) {
// Use weekly rate for rentals <= 1 calendar month
totalAmount = diffWeeks * Number(item.pricePerWeek);
} else if (diffMonths > 1 && item.pricePerMonth) {
// Use monthly rate for rentals > 1 calendar month
totalAmount = diffMonths * Number(item.pricePerMonth);
} else if (item.pricePerWeek) {
// Fallback to weekly rate if monthly not available
totalAmount = diffWeeks * Number(item.pricePerWeek);
} else if (item.pricePerDay) {
// Fallback to daily rate
totalAmount = diffDays * Number(item.pricePerDay);
} else {
totalAmount = 0;
}
return totalAmount;
}
}
module.exports = RentalDurationCalculator;

View File

@@ -14,16 +14,28 @@ const ItemCard: React.FC<ItemCardProps> = ({
const isCompact = variant === 'compact'; const isCompact = variant === 'compact';
const getPriceDisplay = () => { const getPriceDisplay = () => {
if (item.pricePerDay !== undefined) { // Collect all available pricing tiers
return Number(item.pricePerDay) === 0 const pricingTiers: string[] = [];
? "Free to Borrow"
: `$${Math.floor(Number(item.pricePerDay))}/Day`; if (item.pricePerHour && Number(item.pricePerHour) > 0) {
} else if (item.pricePerHour !== undefined) { pricingTiers.push(`$${Math.floor(Number(item.pricePerHour))}/hr`);
return Number(item.pricePerHour) === 0
? "Free to Borrow"
: `$${Math.floor(Number(item.pricePerHour))}/Hour`;
} }
return null; if (item.pricePerDay && Number(item.pricePerDay) > 0) {
pricingTiers.push(`$${Math.floor(Number(item.pricePerDay))}/day`);
}
if (item.pricePerWeek && Number(item.pricePerWeek) > 0) {
pricingTiers.push(`$${Math.floor(Number(item.pricePerWeek))}/wk`);
}
if (item.pricePerMonth && Number(item.pricePerMonth) > 0) {
pricingTiers.push(`$${Math.floor(Number(item.pricePerMonth))}/mo`);
}
if (pricingTiers.length === 0) {
return "Free to Borrow";
}
// Display up to 2 pricing tiers separated by bullet point
return pricingTiers.slice(0, 2).join(" • ");
}; };
const getLocationDisplay = () => { const getLocationDisplay = () => {

View File

@@ -1,51 +1,105 @@
import React from 'react'; import React from "react";
interface PricingFormProps { interface PricingFormProps {
priceType: "hour" | "day";
pricePerHour: number | string; pricePerHour: number | string;
pricePerDay: number | string; pricePerDay: number | string;
pricePerWeek: number | string;
pricePerMonth: number | string;
replacementCost: number | string; replacementCost: number | string;
minimumRentalDays: number; selectedPricingUnit: string;
onPriceTypeChange: (type: "hour" | "day") => void; showAdvancedPricing: boolean;
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => void; enabledTiers: {
hour: boolean;
day: boolean;
week: boolean;
month: boolean;
};
onChange: (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => void;
onPricingUnitChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
onToggleAdvancedPricing: () => void;
onTierToggle: (tier: string) => void;
} }
const PricingForm: React.FC<PricingFormProps> = ({ const PricingForm: React.FC<PricingFormProps> = ({
priceType,
pricePerHour, pricePerHour,
pricePerDay, pricePerDay,
pricePerWeek,
pricePerMonth,
replacementCost, replacementCost,
minimumRentalDays, selectedPricingUnit,
onPriceTypeChange, showAdvancedPricing,
onChange enabledTiers,
onChange,
onPricingUnitChange,
onToggleAdvancedPricing,
onTierToggle,
}) => { }) => {
// Map pricing unit to field name and label
const pricingUnitMap: Record<
string,
{ field: string; label: string; value: number | string }
> = {
hour: { field: "pricePerHour", label: "Hour", value: pricePerHour },
day: { field: "pricePerDay", label: "Day", value: pricePerDay },
week: { field: "pricePerWeek", label: "Week", value: pricePerWeek },
month: { field: "pricePerMonth", label: "Month", value: pricePerMonth },
};
// When advanced pricing is on, show ALL 4 options
// When advanced pricing is off, show only the 3 non-selected options
const advancedPricingOptions = showAdvancedPricing
? Object.entries(pricingUnitMap)
: Object.entries(pricingUnitMap).filter(
([key]) => key !== selectedPricingUnit
);
const selectedUnit = pricingUnitMap[selectedPricingUnit];
return ( return (
<div className="card mb-4"> <div className="card mb-4">
<div className="card-body"> <div className="card-body">
<h5 className="card-title">Pricing</h5>
<p className="text-muted small mb-3">
{showAdvancedPricing
? "Set multiple pricing tiers for flexible rental rates."
: "Set your primary pricing rate, or use Advanced Pricing for multiple tiers."}
</p>
{/* Pricing Unit Dropdown - Only show when advanced pricing is OFF */}
{!showAdvancedPricing && (
<>
<div className="mb-3"> <div className="mb-3">
<div className="row align-items-center"> <label htmlFor="pricingUnit" className="form-label">
<div className="col-auto"> Pricing Unit
<label className="col-form-label">Price per</label> </label>
</div>
<div className="col-auto">
<select <select
className="form-select" className="form-select"
value={priceType} id="pricingUnit"
onChange={(e) => onPriceTypeChange(e.target.value as "hour" | "day")} value={selectedPricingUnit}
onChange={onPricingUnitChange}
> >
<option value="hour">Hour</option> <option value="hour">Hour</option>
<option value="day">Day</option> <option value="day">Day</option>
<option value="week">Week</option>
<option value="month">Month</option>
</select> </select>
</div> </div>
<div className="col">
{/* Primary Price Input */}
<div className="mb-3">
<label htmlFor={selectedUnit.field} className="form-label mb-0">
Price per {selectedUnit.label}
</label>
<div className="input-group"> <div className="input-group">
<span className="input-group-text">$</span> <span className="input-group-text">$</span>
<input <input
type="number" type="number"
className="form-control" className="form-control"
id={priceType === "hour" ? "pricePerHour" : "pricePerDay"} id={selectedUnit.field}
name={priceType === "hour" ? "pricePerHour" : "pricePerDay"} name={selectedUnit.field}
value={priceType === "hour" ? pricePerHour : pricePerDay} value={selectedUnit.value}
onChange={onChange} onChange={onChange}
step="0.01" step="0.01"
min="0" min="0"
@@ -53,24 +107,63 @@ const PricingForm: React.FC<PricingFormProps> = ({
/> />
</div> </div>
</div> </div>
</div> </>
)}
{/* Advanced Pricing Toggle */}
<div className="mb-3">
<button
type="button"
className="btn btn-link p-0 text-decoration-none"
onClick={onToggleAdvancedPricing}
>
{showAdvancedPricing ? "▼" : "▶"} Advanced Pricing
</button>
</div> </div>
<div className="mb-3"> {/* Advanced Pricing Section */}
<label htmlFor="minimumRentalDays" className="form-label"> {showAdvancedPricing && (
Minimum Rental {priceType === "hour" ? "Hours" : "Days"} <div className="border rounded p-3 mb-3 bg-light">
<p className="text-muted small mb-3">
Set multiple pricing tiers. Check the boxes for the tiers you want to use.
</p>
{advancedPricingOptions.map(([key, { field, label, value }]) => (
<div className="mb-3" key={key}>
<div className="form-check mb-2">
<input
className="form-check-input"
type="checkbox"
id={`enable-${key}`}
checked={enabledTiers[key as keyof typeof enabledTiers]}
onChange={() => onTierToggle(key)}
/>
<label className="form-check-label" htmlFor={`enable-${key}`}>
Price per {label}
</label> </label>
</div>
<div className="input-group">
<span className="input-group-text">$</span>
<input <input
type="number" type="number"
className="form-control" className="form-control"
id="minimumRentalDays" id={field}
name="minimumRentalDays" name={field}
value={minimumRentalDays} value={value}
onChange={onChange} onChange={onChange}
min="1" step="0.01"
min="0"
placeholder="0.00"
disabled={!enabledTiers[key as keyof typeof enabledTiers]}
/> />
</div> </div>
</div>
))}
</div>
)}
<hr className="my-4" />
{/* Replacement Cost */}
<div className="mb-3"> <div className="mb-3">
<label htmlFor="replacementCost" className="form-label mb-0"> <label htmlFor="replacementCost" className="form-label mb-0">
Replacement Cost * Replacement Cost *

View File

@@ -18,6 +18,8 @@ interface ItemFormData {
inPlaceUseAvailable: boolean; inPlaceUseAvailable: boolean;
pricePerHour?: number | string; pricePerHour?: number | string;
pricePerDay?: number | string; pricePerDay?: number | string;
pricePerWeek?: number | string;
pricePerMonth?: number | string;
replacementCost: number | string; replacementCost: number | string;
address1: string; address1: string;
address2: string; address2: string;
@@ -28,7 +30,6 @@ interface ItemFormData {
latitude?: number; latitude?: number;
longitude?: number; longitude?: number;
rules?: string; rules?: string;
minimumRentalDays: number;
needsTraining: boolean; needsTraining: boolean;
generalAvailableAfter: string; generalAvailableAfter: string;
generalAvailableBefore: string; generalAvailableBefore: string;
@@ -62,7 +63,6 @@ const CreateItem: React.FC = () => {
state: "", state: "",
zipCode: "", zipCode: "",
country: "US", country: "US",
minimumRentalDays: 1,
needsTraining: false, needsTraining: false,
generalAvailableAfter: "09:00", generalAvailableAfter: "09:00",
generalAvailableBefore: "17:00", generalAvailableBefore: "17:00",
@@ -79,10 +79,17 @@ const CreateItem: React.FC = () => {
}); });
const [imageFiles, setImageFiles] = useState<File[]>([]); const [imageFiles, setImageFiles] = useState<File[]>([]);
const [imagePreviews, setImagePreviews] = useState<string[]>([]); const [imagePreviews, setImagePreviews] = useState<string[]>([]);
const [priceType, setPriceType] = useState<"hour" | "day">("day");
const [userAddresses, setUserAddresses] = useState<Address[]>([]); const [userAddresses, setUserAddresses] = useState<Address[]>([]);
const [selectedAddressId, setSelectedAddressId] = useState<string>(""); const [selectedAddressId, setSelectedAddressId] = useState<string>("");
const [addressesLoading, setAddressesLoading] = useState(true); const [addressesLoading, setAddressesLoading] = useState(true);
const [selectedPricingUnit, setSelectedPricingUnit] = useState<string>("day");
const [showAdvancedPricing, setShowAdvancedPricing] = useState<boolean>(false);
const [enabledPricingTiers, setEnabledPricingTiers] = useState({
hour: false,
day: false,
week: false,
month: false,
});
// Reference to LocationForm geocoding function // Reference to LocationForm geocoding function
const geocodeLocationRef = useRef<(() => Promise<boolean>) | null>(null); const geocodeLocationRef = useRef<(() => Promise<boolean>) | null>(null);
@@ -187,6 +194,12 @@ const CreateItem: React.FC = () => {
pricePerHour: formData.pricePerHour pricePerHour: formData.pricePerHour
? parseFloat(formData.pricePerHour.toString()) ? parseFloat(formData.pricePerHour.toString())
: undefined, : undefined,
pricePerWeek: formData.pricePerWeek
? parseFloat(formData.pricePerWeek.toString())
: undefined,
pricePerMonth: formData.pricePerMonth
? parseFloat(formData.pricePerMonth.toString())
: undefined,
replacementCost: formData.replacementCost replacementCost: formData.replacementCost
? parseFloat(formData.replacementCost.toString()) ? parseFloat(formData.replacementCost.toString())
: 0, : 0,
@@ -355,6 +368,21 @@ const CreateItem: React.FC = () => {
setImagePreviews((prev) => prev.filter((_, i) => i !== index)); setImagePreviews((prev) => prev.filter((_, i) => i !== index));
}; };
const handlePricingUnitChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setSelectedPricingUnit(e.target.value);
};
const handleToggleAdvancedPricing = () => {
setShowAdvancedPricing((prev) => !prev);
};
const handleTierToggle = (tier: string) => {
setEnabledPricingTiers((prev) => ({
...prev,
[tier]: !prev[tier as keyof typeof prev],
}));
};
return ( return (
<div className="container mt-4"> <div className="container mt-4">
<div className="row justify-content-center"> <div className="row justify-content-center">
@@ -430,13 +458,18 @@ const CreateItem: React.FC = () => {
</div> </div>
<PricingForm <PricingForm
priceType={priceType}
pricePerHour={formData.pricePerHour || ""} pricePerHour={formData.pricePerHour || ""}
pricePerDay={formData.pricePerDay || ""} pricePerDay={formData.pricePerDay || ""}
pricePerWeek={formData.pricePerWeek || ""}
pricePerMonth={formData.pricePerMonth || ""}
replacementCost={formData.replacementCost} replacementCost={formData.replacementCost}
minimumRentalDays={formData.minimumRentalDays} selectedPricingUnit={selectedPricingUnit}
onPriceTypeChange={setPriceType} showAdvancedPricing={showAdvancedPricing}
enabledTiers={enabledPricingTiers}
onChange={handleChange} onChange={handleChange}
onPricingUnitChange={handlePricingUnitChange}
onToggleAdvancedPricing={handleToggleAdvancedPricing}
onTierToggle={handleTierToggle}
/> />
<RulesForm <RulesForm

View File

@@ -18,6 +18,8 @@ interface ItemFormData {
inPlaceUseAvailable: boolean; inPlaceUseAvailable: boolean;
pricePerHour?: number | string; pricePerHour?: number | string;
pricePerDay?: number | string; pricePerDay?: number | string;
pricePerWeek?: number | string;
pricePerMonth?: number | string;
replacementCost: number | string; replacementCost: number | string;
address1: string; address1: string;
address2: string; address2: string;
@@ -28,7 +30,6 @@ interface ItemFormData {
latitude?: number; latitude?: number;
longitude?: number; longitude?: number;
rules?: string; rules?: string;
minimumRentalDays: number;
needsTraining: boolean; needsTraining: boolean;
generalAvailableAfter: string; generalAvailableAfter: string;
generalAvailableBefore: string; generalAvailableBefore: string;
@@ -53,11 +54,18 @@ const EditItem: React.FC = () => {
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [imageFiles, setImageFiles] = useState<File[]>([]); const [imageFiles, setImageFiles] = useState<File[]>([]);
const [imagePreviews, setImagePreviews] = useState<string[]>([]); const [imagePreviews, setImagePreviews] = useState<string[]>([]);
const [priceType, setPriceType] = useState<"hour" | "day">("day");
const [acceptedRentals, setAcceptedRentals] = useState<Rental[]>([]); const [acceptedRentals, setAcceptedRentals] = useState<Rental[]>([]);
const [userAddresses, setUserAddresses] = useState<Address[]>([]); const [userAddresses, setUserAddresses] = useState<Address[]>([]);
const [selectedAddressId, setSelectedAddressId] = useState<string>(""); const [selectedAddressId, setSelectedAddressId] = useState<string>("");
const [addressesLoading, setAddressesLoading] = useState(true); const [addressesLoading, setAddressesLoading] = useState(true);
const [selectedPricingUnit, setSelectedPricingUnit] = useState<string>("day");
const [showAdvancedPricing, setShowAdvancedPricing] = useState<boolean>(false);
const [enabledPricingTiers, setEnabledPricingTiers] = useState({
hour: false,
day: false,
week: false,
month: false,
});
// Reference to LocationForm geocoding function // Reference to LocationForm geocoding function
const geocodeLocationRef = useRef<(() => Promise<boolean>) | null>(null); const geocodeLocationRef = useRef<(() => Promise<boolean>) | null>(null);
@@ -76,7 +84,6 @@ const EditItem: React.FC = () => {
zipCode: "", zipCode: "",
country: "US", country: "US",
rules: "", rules: "",
minimumRentalDays: 1,
needsTraining: false, needsTraining: false,
generalAvailableAfter: "09:00", generalAvailableAfter: "09:00",
generalAvailableBefore: "17:00", generalAvailableBefore: "17:00",
@@ -119,13 +126,6 @@ const EditItem: React.FC = () => {
return; return;
} }
// Set the price type based on available pricing
if (item.pricePerHour) {
setPriceType("hour");
} else if (item.pricePerDay) {
setPriceType("day");
}
// Convert item data to form data format // Convert item data to form data format
setFormData({ setFormData({
name: item.name, name: item.name,
@@ -134,6 +134,8 @@ const EditItem: React.FC = () => {
inPlaceUseAvailable: item.inPlaceUseAvailable || false, inPlaceUseAvailable: item.inPlaceUseAvailable || false,
pricePerHour: item.pricePerHour || "", pricePerHour: item.pricePerHour || "",
pricePerDay: item.pricePerDay || "", pricePerDay: item.pricePerDay || "",
pricePerWeek: item.pricePerWeek || "",
pricePerMonth: item.pricePerMonth || "",
replacementCost: item.replacementCost || "", replacementCost: item.replacementCost || "",
address1: item.address1 || "", address1: item.address1 || "",
address2: item.address2 || "", address2: item.address2 || "",
@@ -144,7 +146,6 @@ const EditItem: React.FC = () => {
latitude: item.latitude, latitude: item.latitude,
longitude: item.longitude, longitude: item.longitude,
rules: item.rules || "", rules: item.rules || "",
minimumRentalDays: item.minimumRentalDays,
needsTraining: item.needsTraining || false, needsTraining: item.needsTraining || false,
generalAvailableAfter: item.availableAfter || "09:00", generalAvailableAfter: item.availableAfter || "09:00",
generalAvailableBefore: item.availableBefore || "17:00", generalAvailableBefore: item.availableBefore || "17:00",
@@ -164,6 +165,40 @@ const EditItem: React.FC = () => {
if (item.images && item.images.length > 0) { if (item.images && item.images.length > 0) {
setImagePreviews(item.images); setImagePreviews(item.images);
} }
// Determine which pricing unit to select based on existing data
// Priority: hour -> day -> week -> month (first one with a value)
if (item.pricePerHour) {
setSelectedPricingUnit("hour");
} else if (item.pricePerDay) {
setSelectedPricingUnit("day");
} else if (item.pricePerWeek) {
setSelectedPricingUnit("week");
} else if (item.pricePerMonth) {
setSelectedPricingUnit("month");
} else {
setSelectedPricingUnit("day"); // Default to day if no pricing is set
}
// Set enabled tiers based on which prices are populated
setEnabledPricingTiers({
hour: !!(item.pricePerHour && Number(item.pricePerHour) > 0),
day: !!(item.pricePerDay && Number(item.pricePerDay) > 0),
week: !!(item.pricePerWeek && Number(item.pricePerWeek) > 0),
month: !!(item.pricePerMonth && Number(item.pricePerMonth) > 0),
});
// Auto-expand advanced section if multiple pricing tiers are set
const pricingTiersSet = [
item.pricePerHour,
item.pricePerDay,
item.pricePerWeek,
item.pricePerMonth,
].filter((price) => price && Number(price) > 0).length;
if (pricingTiersSet > 1) {
setShowAdvancedPricing(true);
}
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.message || "Failed to fetch item"); setError(err.response?.data?.message || "Failed to fetch item");
} finally { } finally {
@@ -240,6 +275,12 @@ const EditItem: React.FC = () => {
pricePerHour: formData.pricePerHour pricePerHour: formData.pricePerHour
? parseFloat(formData.pricePerHour.toString()) ? parseFloat(formData.pricePerHour.toString())
: undefined, : undefined,
pricePerWeek: formData.pricePerWeek
? parseFloat(formData.pricePerWeek.toString())
: undefined,
pricePerMonth: formData.pricePerMonth
? parseFloat(formData.pricePerMonth.toString())
: undefined,
replacementCost: formData.replacementCost replacementCost: formData.replacementCost
? parseFloat(formData.replacementCost.toString()) ? parseFloat(formData.replacementCost.toString())
: 0, : 0,
@@ -360,6 +401,21 @@ const EditItem: React.FC = () => {
})); }));
}; };
const handlePricingUnitChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setSelectedPricingUnit(e.target.value);
};
const handleToggleAdvancedPricing = () => {
setShowAdvancedPricing((prev) => !prev);
};
const handleTierToggle = (tier: string) => {
setEnabledPricingTiers((prev) => ({
...prev,
[tier]: !prev[tier as keyof typeof prev],
}));
};
if (loading) { if (loading) {
return ( return (
<div className="container mt-5"> <div className="container mt-5">
@@ -445,13 +501,18 @@ const EditItem: React.FC = () => {
/> />
<PricingForm <PricingForm
priceType={priceType}
pricePerHour={formData.pricePerHour || ""} pricePerHour={formData.pricePerHour || ""}
pricePerDay={formData.pricePerDay || ""} pricePerDay={formData.pricePerDay || ""}
pricePerWeek={formData.pricePerWeek || ""}
pricePerMonth={formData.pricePerMonth || ""}
replacementCost={formData.replacementCost} replacementCost={formData.replacementCost}
minimumRentalDays={formData.minimumRentalDays} selectedPricingUnit={selectedPricingUnit}
onPriceTypeChange={setPriceType} showAdvancedPricing={showAdvancedPricing}
enabledTiers={enabledPricingTiers}
onChange={handleChange} onChange={handleChange}
onPricingUnitChange={handlePricingUnitChange}
onToggleAdvancedPricing={handleToggleAdvancedPricing}
onTierToggle={handleTierToggle}
/> />
<div className="card mb-4"> <div className="card mb-4">

View File

@@ -22,6 +22,8 @@ const ItemDetail: React.FC = () => {
endTime: "12:00", endTime: "12:00",
}); });
const [totalCost, setTotalCost] = useState(0); const [totalCost, setTotalCost] = useState(0);
const [costLoading, setCostLoading] = useState(false);
const [costError, setCostError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
fetchItem(); fetchItem();
@@ -83,31 +85,37 @@ const ItemDetail: React.FC = () => {
})); }));
}; };
const calculateTotalCost = () => { const calculateTotalCost = async () => {
if (!item || !rentalDates.startDate || !rentalDates.endDate) { if (!item || !rentalDates.startDate || !rentalDates.endDate) {
setTotalCost(0); setTotalCost(0);
return; return;
} }
try {
setCostLoading(true);
setCostError(null);
const startDateTime = new Date( const startDateTime = new Date(
`${rentalDates.startDate}T${rentalDates.startTime}` `${rentalDates.startDate}T${rentalDates.startTime}`
); ).toISOString();
const endDateTime = new Date( const endDateTime = new Date(
`${rentalDates.endDate}T${rentalDates.endTime}` `${rentalDates.endDate}T${rentalDates.endTime}`
); ).toISOString();
const diffMs = endDateTime.getTime() - startDateTime.getTime(); const response = await rentalAPI.getRentalCostPreview({
const diffHours = Math.ceil(diffMs / (1000 * 60 * 60)); itemId: item.id,
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); startDateTime,
endDateTime,
});
let cost = 0; setTotalCost(response.data.baseAmount);
if (item.pricePerHour && diffHours <= 24) { } catch (err: any) {
cost = diffHours * Number(item.pricePerHour); setCostError(err.response?.data?.error || "Failed to calculate cost");
} else if (item.pricePerDay) { setTotalCost(0);
cost = diffDays * Number(item.pricePerDay); } finally {
setCostLoading(false);
} }
setTotalCost(cost);
}; };
const generateTimeOptions = (item: Item | null, selectedDate: string) => { const generateTimeOptions = (item: Item | null, selectedDate: string) => {
@@ -458,7 +466,7 @@ const ItemDetail: React.FC = () => {
)} )}
{item.pricePerMonth !== undefined && {item.pricePerMonth !== undefined &&
Number(item.pricePerMonth) > 0 && ( Number(item.pricePerMonth) > 0 && (
<div className="mb-4"> <div className="mb-2">
<h4> <h4>
${Math.floor(Number(item.pricePerMonth))}/Month ${Math.floor(Number(item.pricePerMonth))}/Month
</h4> </h4>
@@ -571,11 +579,17 @@ const ItemDetail: React.FC = () => {
</div> </div>
</div> </div>
{rentalDates.startDate && {rentalDates.startDate && rentalDates.endDate && (
rentalDates.endDate &&
totalCost > 0 && (
<div className="mb-3 p-2 bg-light rounded text-center"> <div className="mb-3 p-2 bg-light rounded text-center">
{costLoading ? (
<div className="spinner-border spinner-border-sm" role="status">
<span className="visually-hidden">Calculating...</span>
</div>
) : costError ? (
<small className="text-danger">{costError}</small>
) : totalCost > 0 ? (
<strong>Total: ${totalCost}</strong> <strong>Total: ${totalCost}</strong>
) : null}
</div> </div>
)} )}
</div> </div>

View File

@@ -305,7 +305,7 @@ const Owning: React.FC = () => {
<div className="mb-5"> <div className="mb-5">
<h4 className="mb-3"> <h4 className="mb-3">
<i className="bi bi-calendar-check me-2"></i> <i className="bi bi-calendar-check me-2"></i>
Rental Requests Rentals
</h4> </h4>
<div className="row"> <div className="row">
{allOwnerRentals.map((rental) => ( {allOwnerRentals.map((rental) => (

View File

@@ -27,6 +27,8 @@ const RentItem: React.FC = () => {
}); });
const [totalCost, setTotalCost] = useState(0); const [totalCost, setTotalCost] = useState(0);
const [costLoading, setCostLoading] = useState(false);
const [costError, setCostError] = useState<string | null>(null);
const [completed, setCompleted] = useState(false); const [completed, setCompleted] = useState(false);
const convertToUTC = (dateString: string, timeString: string): string => { const convertToUTC = (dateString: string, timeString: string): string => {
@@ -62,31 +64,37 @@ const RentItem: React.FC = () => {
return `${hour12}:${minute} ${period}`; return `${hour12}:${minute} ${period}`;
}; };
const calculateTotalCost = () => { const calculateTotalCost = async () => {
if (!item || !manualSelection.startDate || !manualSelection.endDate) { if (!item || !manualSelection.startDate || !manualSelection.endDate) {
setTotalCost(0); setTotalCost(0);
return; return;
} }
try {
setCostLoading(true);
setCostError(null);
const startDateTime = new Date( const startDateTime = new Date(
`${manualSelection.startDate}T${manualSelection.startTime}` `${manualSelection.startDate}T${manualSelection.startTime}`
); ).toISOString();
const endDateTime = new Date( const endDateTime = new Date(
`${manualSelection.endDate}T${manualSelection.endTime}` `${manualSelection.endDate}T${manualSelection.endTime}`
); ).toISOString();
const diffMs = endDateTime.getTime() - startDateTime.getTime(); const response = await rentalAPI.getRentalCostPreview({
const diffHours = Math.ceil(diffMs / (1000 * 60 * 60)); itemId: item.id,
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); startDateTime,
endDateTime,
});
let cost = 0; setTotalCost(response.data.baseAmount);
if (item.pricePerHour && diffHours <= 24) { } catch (err: any) {
cost = diffHours * Number(item.pricePerHour); setCostError(err.response?.data?.error || "Failed to calculate cost");
} else if (item.pricePerDay) { setTotalCost(0);
cost = diffDays * Number(item.pricePerDay); } finally {
setCostLoading(false);
} }
setTotalCost(cost);
}; };
useEffect(() => { useEffect(() => {
@@ -366,15 +374,23 @@ const RentItem: React.FC = () => {
)} )}
{/* Total Cost */} {/* Total Cost */}
{totalCost > 0 && (
<> <>
<hr /> <hr />
<div className="d-flex justify-content-between"> <div className="d-flex justify-content-between">
<strong>Total:</strong> <strong>Total:</strong>
{costLoading ? (
<div className="spinner-border spinner-border-sm" role="status">
<span className="visually-hidden">Calculating...</span>
</div>
) : costError ? (
<small className="text-danger">Error</small>
) : totalCost > 0 ? (
<strong>${totalCost}</strong> <strong>${totalCost}</strong>
) : (
<strong>$0</strong>
)}
</div> </div>
</> </>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -229,6 +229,11 @@ export const rentalAPI = {
) => api.post(`/rentals/${id}/mark-return`, data), ) => api.post(`/rentals/${id}/mark-return`, data),
reportDamage: (id: string, data: any) => reportDamage: (id: string, data: any) =>
api.post(`/rentals/${id}/report-damage`, data), api.post(`/rentals/${id}/report-damage`, data),
getRentalCostPreview: (data: {
itemId: string;
startDateTime: string;
endDateTime: string;
}) => api.post("/rentals/cost-preview", data),
}; };
export const messageAPI = { export const messageAPI = {

View File

@@ -254,6 +254,8 @@ export interface ItemRequest {
longitude?: number; longitude?: number;
maxPricePerHour?: number; maxPricePerHour?: number;
maxPricePerDay?: number; maxPricePerDay?: number;
maxPricePerWeek?: number;
maxPricePerMonth?: number;
preferredStartDate?: string; preferredStartDate?: string;
preferredEndDate?: string; preferredEndDate?: string;
isFlexibleDates: boolean; isFlexibleDates: boolean;
@@ -273,6 +275,8 @@ export interface ItemRequestResponse {
message: string; message: string;
offerPricePerHour?: number; offerPricePerHour?: number;
offerPricePerDay?: number; offerPricePerDay?: number;
offerPricePerWeek?: number;
offerPricePerMonth?: number;
availableStartDate?: string; availableStartDate?: string;
availableEndDate?: string; availableEndDate?: string;
existingItemId?: string; existingItemId?: string;