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: {
type: DataTypes.DECIMAL(10, 2),
},
pricePerWeek: {
type: DataTypes.DECIMAL(10, 2),
},
pricePerMonth: {
type: DataTypes.DECIMAL(10, 2),
},
replacementCost: {
type: DataTypes.DECIMAL(10, 2),
allowNull: false,

View File

@@ -33,6 +33,12 @@ const ItemRequestResponse = sequelize.define('ItemRequestResponse', {
offerPricePerDay: {
type: DataTypes.DECIMAL(10, 2)
},
offerPricePerWeek: {
type: DataTypes.DECIMAL(10, 2)
},
offerPricePerMonth: {
type: DataTypes.DECIMAL(10, 2)
},
availableStartDate: {
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 { authenticateToken, requireVerifiedEmail } = require("../middleware/auth");
const FeeCalculator = require("../utils/feeCalculator");
const RentalDurationCalculator = require("../utils/rentalDurationCalculator");
const RefundService = require("../services/refundService");
const LateReturnService = require("../services/lateReturnService");
const DamageAssessmentService = require("../services/damageAssessmentService");
@@ -201,19 +202,12 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
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) {
totalAmount = diffHours * Number(item.pricePerHour);
} else if (item.pricePerDay) {
totalAmount = diffDays * Number(item.pricePerDay);
} else {
totalAmount = 0;
}
// Calculate rental cost using duration calculator
totalAmount = RentalDurationCalculator.calculateRentalCost(
rentalStartDateTime,
rentalEndDateTime,
item
);
// Check for overlapping rentals using datetime ranges
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
router.get("/earnings/status", authenticateToken, async (req, res) => {
try {

View File

@@ -25,33 +25,27 @@ class LateReturnService {
let lateFee = 0;
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);
// 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);
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 {
// Free borrows: determine pricing type based on rental duration
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);
// Free borrows - charge $10 per day late
lateFee = billableDays * 10.0;
pricingType = "daily";
}
}
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 getPriceDisplay = () => {
if (item.pricePerDay !== undefined) {
return Number(item.pricePerDay) === 0
? "Free to Borrow"
: `$${Math.floor(Number(item.pricePerDay))}/Day`;
} else if (item.pricePerHour !== undefined) {
return Number(item.pricePerHour) === 0
? "Free to Borrow"
: `$${Math.floor(Number(item.pricePerHour))}/Hour`;
// Collect all available pricing tiers
const pricingTiers: string[] = [];
if (item.pricePerHour && Number(item.pricePerHour) > 0) {
pricingTiers.push(`$${Math.floor(Number(item.pricePerHour))}/hr`);
}
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 = () => {

View File

@@ -1,51 +1,105 @@
import React from 'react';
import React from "react";
interface PricingFormProps {
priceType: "hour" | "day";
pricePerHour: number | string;
pricePerDay: number | string;
pricePerWeek: number | string;
pricePerMonth: number | string;
replacementCost: number | string;
minimumRentalDays: number;
onPriceTypeChange: (type: "hour" | "day") => void;
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => void;
selectedPricingUnit: string;
showAdvancedPricing: boolean;
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> = ({
priceType,
pricePerHour,
pricePerDay,
pricePerWeek,
pricePerMonth,
replacementCost,
minimumRentalDays,
onPriceTypeChange,
onChange
selectedPricingUnit,
showAdvancedPricing,
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 (
<div className="card mb-4">
<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="row align-items-center">
<div className="col-auto">
<label className="col-form-label">Price per</label>
</div>
<div className="col-auto">
<label htmlFor="pricingUnit" className="form-label">
Pricing Unit
</label>
<select
className="form-select"
value={priceType}
onChange={(e) => onPriceTypeChange(e.target.value as "hour" | "day")}
id="pricingUnit"
value={selectedPricingUnit}
onChange={onPricingUnitChange}
>
<option value="hour">Hour</option>
<option value="day">Day</option>
<option value="week">Week</option>
<option value="month">Month</option>
</select>
</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">
<span className="input-group-text">$</span>
<input
type="number"
className="form-control"
id={priceType === "hour" ? "pricePerHour" : "pricePerDay"}
name={priceType === "hour" ? "pricePerHour" : "pricePerDay"}
value={priceType === "hour" ? pricePerHour : pricePerDay}
id={selectedUnit.field}
name={selectedUnit.field}
value={selectedUnit.value}
onChange={onChange}
step="0.01"
min="0"
@@ -53,24 +107,63 @@ const PricingForm: React.FC<PricingFormProps> = ({
/>
</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 className="mb-3">
<label htmlFor="minimumRentalDays" className="form-label">
Minimum Rental {priceType === "hour" ? "Hours" : "Days"}
{/* Advanced Pricing Section */}
{showAdvancedPricing && (
<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>
</div>
<div className="input-group">
<span className="input-group-text">$</span>
<input
type="number"
className="form-control"
id="minimumRentalDays"
name="minimumRentalDays"
value={minimumRentalDays}
id={field}
name={field}
value={value}
onChange={onChange}
min="1"
step="0.01"
min="0"
placeholder="0.00"
disabled={!enabledTiers[key as keyof typeof enabledTiers]}
/>
</div>
</div>
))}
</div>
)}
<hr className="my-4" />
{/* Replacement Cost */}
<div className="mb-3">
<label htmlFor="replacementCost" className="form-label mb-0">
Replacement Cost *

View File

@@ -18,6 +18,8 @@ interface ItemFormData {
inPlaceUseAvailable: boolean;
pricePerHour?: number | string;
pricePerDay?: number | string;
pricePerWeek?: number | string;
pricePerMonth?: number | string;
replacementCost: number | string;
address1: string;
address2: string;
@@ -28,7 +30,6 @@ interface ItemFormData {
latitude?: number;
longitude?: number;
rules?: string;
minimumRentalDays: number;
needsTraining: boolean;
generalAvailableAfter: string;
generalAvailableBefore: string;
@@ -62,7 +63,6 @@ const CreateItem: React.FC = () => {
state: "",
zipCode: "",
country: "US",
minimumRentalDays: 1,
needsTraining: false,
generalAvailableAfter: "09:00",
generalAvailableBefore: "17:00",
@@ -79,10 +79,17 @@ const CreateItem: React.FC = () => {
});
const [imageFiles, setImageFiles] = useState<File[]>([]);
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
const [priceType, setPriceType] = useState<"hour" | "day">("day");
const [userAddresses, setUserAddresses] = useState<Address[]>([]);
const [selectedAddressId, setSelectedAddressId] = useState<string>("");
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
const geocodeLocationRef = useRef<(() => Promise<boolean>) | null>(null);
@@ -187,6 +194,12 @@ const CreateItem: React.FC = () => {
pricePerHour: formData.pricePerHour
? parseFloat(formData.pricePerHour.toString())
: undefined,
pricePerWeek: formData.pricePerWeek
? parseFloat(formData.pricePerWeek.toString())
: undefined,
pricePerMonth: formData.pricePerMonth
? parseFloat(formData.pricePerMonth.toString())
: undefined,
replacementCost: formData.replacementCost
? parseFloat(formData.replacementCost.toString())
: 0,
@@ -355,6 +368,21 @@ const CreateItem: React.FC = () => {
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 (
<div className="container mt-4">
<div className="row justify-content-center">
@@ -430,13 +458,18 @@ const CreateItem: React.FC = () => {
</div>
<PricingForm
priceType={priceType}
pricePerHour={formData.pricePerHour || ""}
pricePerDay={formData.pricePerDay || ""}
pricePerWeek={formData.pricePerWeek || ""}
pricePerMonth={formData.pricePerMonth || ""}
replacementCost={formData.replacementCost}
minimumRentalDays={formData.minimumRentalDays}
onPriceTypeChange={setPriceType}
selectedPricingUnit={selectedPricingUnit}
showAdvancedPricing={showAdvancedPricing}
enabledTiers={enabledPricingTiers}
onChange={handleChange}
onPricingUnitChange={handlePricingUnitChange}
onToggleAdvancedPricing={handleToggleAdvancedPricing}
onTierToggle={handleTierToggle}
/>
<RulesForm

View File

@@ -18,6 +18,8 @@ interface ItemFormData {
inPlaceUseAvailable: boolean;
pricePerHour?: number | string;
pricePerDay?: number | string;
pricePerWeek?: number | string;
pricePerMonth?: number | string;
replacementCost: number | string;
address1: string;
address2: string;
@@ -28,7 +30,6 @@ interface ItemFormData {
latitude?: number;
longitude?: number;
rules?: string;
minimumRentalDays: number;
needsTraining: boolean;
generalAvailableAfter: string;
generalAvailableBefore: string;
@@ -53,11 +54,18 @@ const EditItem: React.FC = () => {
const [success, setSuccess] = useState(false);
const [imageFiles, setImageFiles] = useState<File[]>([]);
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
const [priceType, setPriceType] = useState<"hour" | "day">("day");
const [acceptedRentals, setAcceptedRentals] = useState<Rental[]>([]);
const [userAddresses, setUserAddresses] = useState<Address[]>([]);
const [selectedAddressId, setSelectedAddressId] = useState<string>("");
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
const geocodeLocationRef = useRef<(() => Promise<boolean>) | null>(null);
@@ -76,7 +84,6 @@ const EditItem: React.FC = () => {
zipCode: "",
country: "US",
rules: "",
minimumRentalDays: 1,
needsTraining: false,
generalAvailableAfter: "09:00",
generalAvailableBefore: "17:00",
@@ -119,13 +126,6 @@ const EditItem: React.FC = () => {
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
setFormData({
name: item.name,
@@ -134,6 +134,8 @@ const EditItem: React.FC = () => {
inPlaceUseAvailable: item.inPlaceUseAvailable || false,
pricePerHour: item.pricePerHour || "",
pricePerDay: item.pricePerDay || "",
pricePerWeek: item.pricePerWeek || "",
pricePerMonth: item.pricePerMonth || "",
replacementCost: item.replacementCost || "",
address1: item.address1 || "",
address2: item.address2 || "",
@@ -144,7 +146,6 @@ const EditItem: React.FC = () => {
latitude: item.latitude,
longitude: item.longitude,
rules: item.rules || "",
minimumRentalDays: item.minimumRentalDays,
needsTraining: item.needsTraining || false,
generalAvailableAfter: item.availableAfter || "09:00",
generalAvailableBefore: item.availableBefore || "17:00",
@@ -164,6 +165,40 @@ const EditItem: React.FC = () => {
if (item.images && item.images.length > 0) {
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) {
setError(err.response?.data?.message || "Failed to fetch item");
} finally {
@@ -240,6 +275,12 @@ const EditItem: React.FC = () => {
pricePerHour: formData.pricePerHour
? parseFloat(formData.pricePerHour.toString())
: undefined,
pricePerWeek: formData.pricePerWeek
? parseFloat(formData.pricePerWeek.toString())
: undefined,
pricePerMonth: formData.pricePerMonth
? parseFloat(formData.pricePerMonth.toString())
: undefined,
replacementCost: formData.replacementCost
? parseFloat(formData.replacementCost.toString())
: 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) {
return (
<div className="container mt-5">
@@ -445,13 +501,18 @@ const EditItem: React.FC = () => {
/>
<PricingForm
priceType={priceType}
pricePerHour={formData.pricePerHour || ""}
pricePerDay={formData.pricePerDay || ""}
pricePerWeek={formData.pricePerWeek || ""}
pricePerMonth={formData.pricePerMonth || ""}
replacementCost={formData.replacementCost}
minimumRentalDays={formData.minimumRentalDays}
onPriceTypeChange={setPriceType}
selectedPricingUnit={selectedPricingUnit}
showAdvancedPricing={showAdvancedPricing}
enabledTiers={enabledPricingTiers}
onChange={handleChange}
onPricingUnitChange={handlePricingUnitChange}
onToggleAdvancedPricing={handleToggleAdvancedPricing}
onTierToggle={handleTierToggle}
/>
<div className="card mb-4">

View File

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

View File

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

View File

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

View File

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

View File

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