pricing tiers
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
62
backend/utils/rentalDurationCalculator.js
Normal file
62
backend/utils/rentalDurationCalculator.js
Normal 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;
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 *
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user