rental price calculation bug, sticky pricing cards on mobile, bigger font app wide, removed delivery options from frontened, searching by location with zipcode works when there's multiple zipcodes in the area,

This commit is contained in:
jackiettran
2025-12-30 00:20:15 -05:00
parent 7dd3aff0f8
commit 546c881701
12 changed files with 254 additions and 222 deletions

View File

@@ -33,8 +33,8 @@ class RentalDurationCalculator {
// Calculate base amount based on duration (tiered pricing) // Calculate base amount based on duration (tiered pricing)
let totalAmount; let totalAmount;
if (item.pricePerHour && diffHours <= 24) { if (item.pricePerHour && diffHours < 24) {
// Use hourly rate for rentals <= 24 hours // Use hourly rate for rentals under 24 hours
totalAmount = diffHours * Number(item.pricePerHour); totalAmount = diffHours * Number(item.pricePerHour);
} else if (diffDays <= 7 && item.pricePerDay) { } else if (diffDays <= 7 && item.pricePerDay) {
// Use daily rate for rentals <= 7 days // Use daily rate for rentals <= 7 days

View File

@@ -55,3 +55,60 @@ main {
justify-content: space-between; justify-content: space-between;
} }
} }
/* Mobile Sticky Bottom Bar for Item Detail */
.mobile-sticky-bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
padding: 12px 16px;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
z-index: 1000;
border-top: 1px solid #e0e0e0;
}
/* Mobile-specific styles */
@media (max-width: 767.98px) {
/* Make sticky card non-sticky on mobile */
.sticky-pricing-card {
position: static !important;
margin-bottom: 80px;
}
}
/* Pricing card input sizing - applies to all screen sizes */
.sticky-pricing-card .form-label {
font-size: 1rem;
}
.sticky-pricing-card .input-group-lg .form-control,
.sticky-pricing-card .input-group-lg .form-select {
font-size: 1rem;
padding: 12px 16px;
min-height: 48px;
}
/* Style the date input specifically */
.sticky-pricing-card input[type="date"] {
font-size: 1rem;
}
/* Make the calendar icon larger on webkit browsers */
.sticky-pricing-card input[type="date"]::-webkit-calendar-picker-indicator {
width: 20px;
height: 20px;
cursor: pointer;
}
/* Time select dropdown */
.sticky-pricing-card .time-select {
font-size: 1rem;
}
/* Style options in the time dropdown */
.sticky-pricing-card .time-select option {
font-size: 1rem;
padding: 8px;
}

View File

@@ -1,60 +0,0 @@
import React from 'react';
interface DeliveryOptionsProps {
pickUpAvailable: boolean;
inPlaceUseAvailable: boolean;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
const DeliveryOptions: React.FC<DeliveryOptionsProps> = ({
pickUpAvailable,
inPlaceUseAvailable,
onChange
}) => {
return (
<div className="card mb-4">
<div className="card-body">
<label className="form-label">
How will renters receive this item?
</label>
<div className="form-check">
<input
type="checkbox"
className="form-check-input"
id="pickUpAvailable"
name="pickUpAvailable"
checked={pickUpAvailable}
onChange={onChange}
/>
<label className="form-check-label" htmlFor="pickUpAvailable">
Pick-Up/Drop-off
<div className="small text-muted">
You and the renter coordinate pick-up and drop-off
</div>
</label>
</div>
<div className="form-check">
<input
type="checkbox"
className="form-check-input"
id="inPlaceUseAvailable"
name="inPlaceUseAvailable"
checked={inPlaceUseAvailable}
onChange={onChange}
/>
<label
className="form-check-label"
htmlFor="inPlaceUseAvailable"
>
In-Place Use
<div className="small text-muted">
They use at your location
</div>
</label>
</div>
</div>
</div>
);
};
export default DeliveryOptions;

View File

@@ -1,11 +1,10 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { mapsAPI } from "../services/api";
interface LocationPromptModalProps { interface LocationPromptModalProps {
show: boolean; show: boolean;
onClose: () => void; onClose: () => void;
onLocationSelect: ( onLocationSelect: (location: { lat: number; lng: number }) => void;
location: { lat: number; lng: number } | { city?: string; zipCode?: string }
) => void;
} }
const LocationPromptModal: React.FC<LocationPromptModalProps> = ({ const LocationPromptModal: React.FC<LocationPromptModalProps> = ({
@@ -53,15 +52,31 @@ const LocationPromptModal: React.FC<LocationPromptModalProps> = ({
} }
}; };
const handleManualSubmit = () => { const handleManualSubmit = async () => {
const trimmed = manualLocation.trim(); const trimmed = manualLocation.trim();
if (!trimmed) return; if (!trimmed) return;
// Check if it looks like a ZIP code setLoading(true);
if (/^\d{5}(-\d{4})?$/.test(trimmed)) { setError(null);
onLocationSelect({ zipCode: trimmed });
} else { try {
onLocationSelect({ city: trimmed }); // Geocode the input (works for both ZIP codes and city names)
const response = await mapsAPI.geocode({
address: trimmed,
componentRestrictions: { country: "US" },
});
const { latitude, longitude } = response.data;
if (latitude && longitude) {
onLocationSelect({ lat: latitude, lng: longitude });
} else {
setError("Could not find that location. Please try a different city or ZIP code.");
}
} catch (err: any) {
setError("Could not find that location. Please try a different city or ZIP code.");
} finally {
setLoading(false);
} }
}; };

View File

@@ -1,3 +1,7 @@
html {
font-size: 20px;
}
body { body {
margin: 0; margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',

View File

@@ -48,7 +48,6 @@ export const mockRental = {
totalAmount: 25, totalAmount: 25,
status: 'pending' as const, status: 'pending' as const,
paymentStatus: 'pending' as const, paymentStatus: 'pending' as const,
deliveryMethod: 'pickup' as const,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };

View File

@@ -7,7 +7,6 @@ import AvailabilitySettings from "../components/AvailabilitySettings";
import ImageUpload from "../components/ImageUpload"; import ImageUpload from "../components/ImageUpload";
import ItemInformation from "../components/ItemInformation"; import ItemInformation from "../components/ItemInformation";
import LocationForm from "../components/LocationForm"; import LocationForm from "../components/LocationForm";
import DeliveryOptions from "../components/DeliveryOptions";
import PricingForm from "../components/PricingForm"; import PricingForm from "../components/PricingForm";
import RulesForm from "../components/RulesForm"; import RulesForm from "../components/RulesForm";
import VerificationCodeModal from "../components/VerificationCodeModal"; import VerificationCodeModal from "../components/VerificationCodeModal";
@@ -17,8 +16,6 @@ import { IMAGE_LIMITS } from "../config/imageLimits";
interface ItemFormData { interface ItemFormData {
name: string; name: string;
description: string; description: string;
pickUpAvailable: boolean;
inPlaceUseAvailable: boolean;
pricePerHour?: number | string; pricePerHour?: number | string;
pricePerDay?: number | string; pricePerDay?: number | string;
pricePerWeek?: number | string; pricePerWeek?: number | string;
@@ -57,8 +54,6 @@ const CreateItem: React.FC = () => {
const [formData, setFormData] = useState<ItemFormData>({ const [formData, setFormData] = useState<ItemFormData>({
name: "", name: "",
description: "", description: "",
pickUpAvailable: false,
inPlaceUseAvailable: false,
pricePerDay: "", pricePerDay: "",
replacementCost: "", replacementCost: "",
address1: "", address1: "",
@@ -539,12 +534,6 @@ const CreateItem: React.FC = () => {
}} }}
/> />
<DeliveryOptions
pickUpAvailable={formData.pickUpAvailable}
inPlaceUseAvailable={formData.inPlaceUseAvailable}
onChange={handleChange}
/>
{/* Availability Card */} {/* Availability Card */}
<div className="card mb-4"> <div className="card mb-4">
<div className="card-body"> <div className="card-body">

View File

@@ -8,7 +8,6 @@ import AvailabilitySettings from "../components/AvailabilitySettings";
import ImageUpload from "../components/ImageUpload"; import ImageUpload from "../components/ImageUpload";
import ItemInformation from "../components/ItemInformation"; import ItemInformation from "../components/ItemInformation";
import LocationForm from "../components/LocationForm"; import LocationForm from "../components/LocationForm";
import DeliveryOptions from "../components/DeliveryOptions";
import PricingForm from "../components/PricingForm"; import PricingForm from "../components/PricingForm";
import RulesForm from "../components/RulesForm"; import RulesForm from "../components/RulesForm";
import { IMAGE_LIMITS } from "../config/imageLimits"; import { IMAGE_LIMITS } from "../config/imageLimits";
@@ -16,8 +15,6 @@ import { IMAGE_LIMITS } from "../config/imageLimits";
interface ItemFormData { interface ItemFormData {
name: string; name: string;
description: string; description: string;
pickUpAvailable: boolean;
inPlaceUseAvailable: boolean;
pricePerHour?: number | string; pricePerHour?: number | string;
pricePerDay?: number | string; pricePerDay?: number | string;
pricePerWeek?: number | string; pricePerWeek?: number | string;
@@ -78,8 +75,6 @@ const EditItem: React.FC = () => {
const [formData, setFormData] = useState<ItemFormData>({ const [formData, setFormData] = useState<ItemFormData>({
name: "", name: "",
description: "", description: "",
pickUpAvailable: false,
inPlaceUseAvailable: false,
pricePerHour: "", pricePerHour: "",
pricePerDay: "", pricePerDay: "",
replacementCost: "", replacementCost: "",
@@ -135,8 +130,6 @@ const EditItem: React.FC = () => {
setFormData({ setFormData({
name: item.name, name: item.name,
description: item.description, description: item.description,
pickUpAvailable: item.pickUpAvailable || false,
inPlaceUseAvailable: item.inPlaceUseAvailable || false,
pricePerHour: item.pricePerHour || "", pricePerHour: item.pricePerHour || "",
pricePerDay: item.pricePerDay || "", pricePerDay: item.pricePerDay || "",
pricePerWeek: item.pricePerWeek || "", pricePerWeek: item.pricePerWeek || "",
@@ -578,12 +571,6 @@ const EditItem: React.FC = () => {
}} }}
/> />
<DeliveryOptions
pickUpAvailable={formData.pickUpAvailable}
inPlaceUseAvailable={formData.inPlaceUseAvailable}
onChange={handleChange}
/>
<PricingForm <PricingForm
pricePerHour={formData.pricePerHour || ""} pricePerHour={formData.pricePerHour || ""}
pricePerDay={formData.pricePerDay || ""} pricePerDay={formData.pricePerDay || ""}

View File

@@ -531,6 +531,7 @@ const ItemDetail: React.FC = () => {
<div className="col-md-4"> <div className="col-md-4">
<div <div
className="card sticky-pricing-card" className="card sticky-pricing-card"
id="pricing-card"
style={{ style={{
position: "sticky", position: "sticky",
top: "20px", top: "20px",
@@ -610,9 +611,9 @@ const ItemDetail: React.FC = () => {
<> <>
<hr /> <hr />
<div className="text-start"> <div className="text-start">
<div className="mb-2"> <div className="mb-3">
<label className="form-label small mb-1">Start</label> <label className="form-label fw-medium mb-2">Start</label>
<div className="input-group input-group-sm"> <div className="input-group input-group-lg">
<input <input
type="date" type="date"
className="form-control" className="form-control"
@@ -627,7 +628,7 @@ const ItemDetail: React.FC = () => {
style={{ flex: "1 1 50%" }} style={{ flex: "1 1 50%" }}
/> />
<select <select
className="form-select" className="form-select time-select"
value={rentalDates.startTime} value={rentalDates.startTime}
onChange={(e) => onChange={(e) =>
handleDateTimeChange( handleDateTimeChange(
@@ -661,8 +662,8 @@ const ItemDetail: React.FC = () => {
</div> </div>
<div className="mb-3"> <div className="mb-3">
<label className="form-label small mb-1">End</label> <label className="form-label fw-medium mb-2">End</label>
<div className="input-group input-group-sm"> <div className="input-group input-group-lg">
<input <input
type="date" type="date"
className="form-control" className="form-control"
@@ -677,7 +678,7 @@ const ItemDetail: React.FC = () => {
style={{ flex: "1 1 50%" }} style={{ flex: "1 1 50%" }}
/> />
<select <select
className="form-select" className="form-select time-select"
value={rentalDates.endTime} value={rentalDates.endTime}
onChange={(e) => onChange={(e) =>
handleDateTimeChange("endTime", e.target.value) handleDateTimeChange("endTime", e.target.value)
@@ -787,6 +788,62 @@ const ItemDetail: React.FC = () => {
reasonPlaceholder="Enter reason for deletion (e.g., policy violation, inappropriate content)" reasonPlaceholder="Enter reason for deletion (e.g., policy violation, inappropriate content)"
reasonRequired={true} reasonRequired={true}
/> />
{/* Mobile Sticky Bottom Bar */}
{!isOwner && item.isAvailable && (
<div className="mobile-sticky-bottom-bar d-md-none">
<div className="d-flex align-items-center justify-content-between">
<div className="price-display">
{(() => {
const prices = [
{ value: Number(item.pricePerHour), label: "/hour" },
{ value: Number(item.pricePerDay), label: "/day" },
{ value: Number(item.pricePerWeek), label: "/week" },
{ value: Number(item.pricePerMonth), label: "/month" },
].filter((p) => p.value > 0);
if (prices.length === 0) {
return <span className="fw-bold">Free to Borrow</span>;
}
const lowestPrice = prices[0];
return (
<span>
<span className="fw-bold fs-5">
${Math.floor(lowestPrice.value)}
</span>
<span className="text-muted">{lowestPrice.label}</span>
</span>
);
})()}
</div>
{isAlreadyRenting ? (
<button
className="btn btn-success"
disabled
style={{ opacity: 0.8 }}
>
Renting
</button>
) : (
<button
className="btn btn-primary btn-lg"
onClick={() => {
const pricingCard = document.getElementById("pricing-card");
if (pricingCard) {
pricingCard.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
}}
>
Check Availability
</button>
)}
</div>
</div>
)}
</div> </div>
); );
}; };

View File

@@ -32,6 +32,7 @@ const ItemList: React.FC = () => {
if (locationCheckDone.current) return; if (locationCheckDone.current) return;
const hasLocation = searchParams.has("lat") || searchParams.has("city") || searchParams.has("zipCode"); const hasLocation = searchParams.has("lat") || searchParams.has("city") || searchParams.has("zipCode");
const hasSearchTerm = searchParams.has("search");
if (!hasLocation) { if (!hasLocation) {
// Check user's saved address for lat/lng // Check user's saved address for lat/lng
@@ -46,10 +47,13 @@ const ItemList: React.FC = () => {
params.set("radius", "25"); params.set("radius", "25");
locationCheckDone.current = true; locationCheckDone.current = true;
navigate(`/items?${params.toString()}`, { replace: true }); navigate(`/items?${params.toString()}`, { replace: true });
} else { } else if (!hasSearchTerm) {
// No saved address with coordinates - show location prompt // No saved address and no search term - show location prompt
locationCheckDone.current = true; locationCheckDone.current = true;
setShowLocationPrompt(true); setShowLocationPrompt(true);
} else {
// Has search term but no location - just show results without location filter
locationCheckDone.current = true;
} }
} else { } else {
locationCheckDone.current = true; locationCheckDone.current = true;
@@ -72,26 +76,15 @@ const ItemList: React.FC = () => {
}); });
}, [searchParams]); }, [searchParams]);
const handleLocationSelect = ( const handleLocationSelect = (location: { lat: number; lng: number }) => {
location: { lat: number; lng: number } | { city?: string; zipCode?: string }
) => {
const params = new URLSearchParams(searchParams); const params = new URLSearchParams(searchParams);
if ("lat" in location) { params.set("lat", location.lat.toString());
params.set("lat", location.lat.toString()); params.set("lng", location.lng.toString());
params.set("lng", location.lng.toString()); params.set("radius", "25");
params.set("radius", "25"); // Remove city/zipCode since we're using coordinates
// Remove city/zipCode if using coordinates params.delete("city");
params.delete("city"); params.delete("zipCode");
params.delete("zipCode");
} else {
if (location.city) params.set("city", location.city);
if (location.zipCode) params.set("zipCode", location.zipCode);
// Remove lat/lng if using city/zip
params.delete("lat");
params.delete("lng");
params.delete("radius");
}
navigate(`/items?${params.toString()}`, { replace: true }); navigate(`/items?${params.toString()}`, { replace: true });
setShowLocationPrompt(false); setShowLocationPrompt(false);

View File

@@ -19,8 +19,6 @@ const RentItem: React.FC = () => {
const [pendingSubmit, setPendingSubmit] = useState(false); const [pendingSubmit, setPendingSubmit] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
deliveryMethod: "pickup" as "pickup" | "delivery",
deliveryAddress: "",
intendedUse: "", intendedUse: "",
}); });
@@ -146,8 +144,6 @@ const RentItem: React.FC = () => {
itemId: id, itemId: id,
startDateTime, startDateTime,
endDateTime, endDateTime,
deliveryMethod: formData.deliveryMethod,
deliveryAddress: formData.deliveryAddress,
intendedUse: formData.intendedUse || undefined, intendedUse: formData.intendedUse || undefined,
totalAmount: totalCost, totalAmount: totalCost,
}; };
@@ -230,9 +226,9 @@ const RentItem: React.FC = () => {
} }
return ( return (
<div className="container mt-4"> <div className="container mt-5">
<div className="row"> <div className="row justify-content-center">
<div className="col-md-8"> <div className="col-md-10">
<h1>Renting {item.name}</h1> <h1>Renting {item.name}</h1>
{/* Email Verification Warning Banner */} {/* Email Verification Warning Banner */}
@@ -259,6 +255,93 @@ const RentItem: React.FC = () => {
)} )}
<div className="row"> <div className="row">
{/* Pricing Card - appears first on mobile, right side on desktop */}
<div className="col-md-4 order-first order-md-last mb-4 mb-md-0">
<div className="card">
<div className="card-body">
{item.imageFilenames && item.imageFilenames[0] && (
<img
src={getPublicImageUrl(item.imageFilenames[0])}
alt={item.name}
className="img-fluid rounded mb-3"
style={{
width: "100%",
height: "150px",
objectFit: "contain",
backgroundColor: "#f8f9fa",
}}
/>
)}
<h6>{item.name}</h6>
<p className="text-muted small">
{item.city && item.state
? `${item.city}, ${item.state}`
: ""}
</p>
<hr />
{/* Pricing */}
<div className="mb-3 text-center">
{totalCost === 0 ? (
<h6>Free to Borrow</h6>
) : (
<>
{item.pricePerHour && Number(item.pricePerHour) > 0 && (
<h6>${Math.floor(Number(item.pricePerHour))}/Hour</h6>
)}
{item.pricePerDay && Number(item.pricePerDay) > 0 && (
<h6>${Math.floor(Number(item.pricePerDay))}/Day</h6>
)}
</>
)}
</div>
{/* Selected Dates */}
{manualSelection.startDate && manualSelection.endDate && (
<div className="mb-3">
<div className="small mb-1">
<strong>Check-in:</strong>{" "}
{formatDate(manualSelection.startDate)} at{" "}
{formatTime(manualSelection.startTime)}
</div>
<div className="small">
<strong>Check-out:</strong>{" "}
{formatDate(manualSelection.endDate)} at{" "}
{formatTime(manualSelection.endTime)}
</div>
</div>
)}
{/* Total Cost */}
<>
<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>
{/* Form - appears second on mobile, left side on desktop */}
<div className="col-md-8"> <div className="col-md-8">
{completed ? ( {completed ? (
<div className="card mb-4"> <div className="card mb-4">
@@ -377,91 +460,6 @@ const RentItem: React.FC = () => {
</div> </div>
)} )}
</div> </div>
<div className="col-md-4">
<div className="card">
<div className="card-body">
{item.imageFilenames && item.imageFilenames[0] && (
<img
src={getPublicImageUrl(item.imageFilenames[0])}
alt={item.name}
className="img-fluid rounded mb-3"
style={{
width: "100%",
height: "150px",
objectFit: "contain",
backgroundColor: "#f8f9fa",
}}
/>
)}
<h6>{item.name}</h6>
<p className="text-muted small">
{item.city && item.state
? `${item.city}, ${item.state}`
: ""}
</p>
<hr />
{/* Pricing */}
<div className="mb-3 text-center">
{totalCost === 0 ? (
<h6>Free to Borrow</h6>
) : (
<>
{item.pricePerHour && Number(item.pricePerHour) > 0 && (
<h6>${Math.floor(Number(item.pricePerHour))}/Hour</h6>
)}
{item.pricePerDay && Number(item.pricePerDay) > 0 && (
<h6>${Math.floor(Number(item.pricePerDay))}/Day</h6>
)}
</>
)}
</div>
{/* Selected Dates */}
{manualSelection.startDate && manualSelection.endDate && (
<div className="mb-3">
<div className="small mb-1">
<strong>Check-in:</strong>{" "}
{formatDate(manualSelection.startDate)} at{" "}
{formatTime(manualSelection.startTime)}
</div>
<div className="small">
<strong>Check-out:</strong>{" "}
{formatDate(manualSelection.endDate)} at{" "}
{formatTime(manualSelection.endTime)}
</div>
</div>
)}
{/* Total Cost */}
<>
<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>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -66,11 +66,6 @@ export interface Item {
id: string; id: string;
name: string; name: string;
description: string; description: string;
pickUpAvailable?: boolean;
localDeliveryAvailable?: boolean;
localDeliveryRadius?: number;
shippingAvailable?: boolean;
inPlaceUseAvailable?: boolean;
pricePerHour?: number; pricePerHour?: number;
pricePerDay?: number; pricePerDay?: number;
pricePerWeek?: number; pricePerWeek?: number;
@@ -147,8 +142,6 @@ export interface Rental {
payoutStatus?: "pending" | "completed" | "failed" | null; payoutStatus?: "pending" | "completed" | "failed" | null;
payoutProcessedAt?: string; payoutProcessedAt?: string;
stripeTransferId?: string; stripeTransferId?: string;
deliveryMethod: "pickup" | "delivery";
deliveryAddress?: string;
intendedUse?: string; intendedUse?: string;
rating?: number; rating?: number;
review?: string; review?: string;