made components that create and edit item can share, started item detail changes, listings provide more views

This commit is contained in:
jackiettran
2025-08-20 17:06:47 -04:00
parent ddd27a59f9
commit b624841350
13 changed files with 1008 additions and 982 deletions

View File

@@ -1,36 +1,47 @@
import React, { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { Item, Rental } from "../types";
import { Item, Rental, Address } from "../types";
import { useAuth } from "../contexts/AuthContext";
import { itemAPI, rentalAPI } from "../services/api";
import AvailabilityCalendar from "../components/AvailabilityCalendar";
import AddressAutocomplete from "../components/AddressAutocomplete";
import { itemAPI, rentalAPI, addressAPI, userAPI } from "../services/api";
import AvailabilitySettings from "../components/AvailabilitySettings";
import ImageUpload from "../components/ImageUpload";
import ItemInformation from "../components/ItemInformation";
import LocationForm from "../components/LocationForm";
import DeliveryOptions from "../components/DeliveryOptions";
import PricingForm from "../components/PricingForm";
import RulesForm from "../components/RulesForm";
interface ItemFormData {
name: string;
description: string;
pickUpAvailable: boolean;
localDeliveryAvailable: boolean;
localDeliveryRadius?: number;
shippingAvailable: boolean;
inPlaceUseAvailable: boolean;
pricePerHour?: number | string;
pricePerDay?: number | string;
replacementCost: number | string;
location: string;
address1: string;
address2: string;
city: string;
state: string;
zipCode: string;
country: string;
latitude?: number;
longitude?: number;
rules?: string;
minimumRentalDays: number;
needsTraining: boolean;
availability: boolean;
unavailablePeriods?: Array<{
id: string;
startDate: Date;
endDate: Date;
startTime?: string;
endTime?: string;
}>;
generalAvailableAfter: string;
generalAvailableBefore: string;
specifyTimesPerDay: boolean;
weeklyTimes: {
sunday: { availableAfter: string; availableBefore: string };
monday: { availableAfter: string; availableBefore: string };
tuesday: { availableAfter: string; availableBefore: string };
wednesday: { availableAfter: string; availableBefore: string };
thursday: { availableAfter: string; availableBefore: string };
friday: { availableAfter: string; availableBefore: string };
saturday: { availableAfter: string; availableBefore: string };
};
}
const EditItem: React.FC = () => {
@@ -44,29 +55,57 @@ const EditItem: React.FC = () => {
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 [formData, setFormData] = useState<ItemFormData>({
name: "",
description: "",
pickUpAvailable: false,
localDeliveryAvailable: false,
shippingAvailable: false,
inPlaceUseAvailable: false,
pricePerHour: undefined,
pricePerDay: undefined,
replacementCost: 0,
location: "",
pricePerHour: "",
pricePerDay: "",
replacementCost: "",
address1: "",
address2: "",
city: "",
state: "",
zipCode: "",
country: "US",
rules: "",
minimumRentalDays: 1,
needsTraining: false,
availability: true,
unavailablePeriods: [],
generalAvailableAfter: "09:00",
generalAvailableBefore: "17:00",
specifyTimesPerDay: false,
weeklyTimes: {
sunday: { availableAfter: "09:00", availableBefore: "17:00" },
monday: { availableAfter: "09:00", availableBefore: "17:00" },
tuesday: { availableAfter: "09:00", availableBefore: "17:00" },
wednesday: { availableAfter: "09:00", availableBefore: "17:00" },
thursday: { availableAfter: "09:00", availableBefore: "17:00" },
friday: { availableAfter: "09:00", availableBefore: "17:00" },
saturday: { availableAfter: "09:00", availableBefore: "17:00" },
},
});
useEffect(() => {
fetchItem();
fetchAcceptedRentals();
fetchUserAddresses();
}, [id]);
const fetchUserAddresses = async () => {
try {
const response = await addressAPI.getAddresses();
setUserAddresses(response.data);
} catch (error) {
console.error("Error fetching addresses:", error);
} finally {
setAddressesLoading(false);
}
};
const fetchItem = async () => {
try {
const response = await itemAPI.getItem(id!);
@@ -89,21 +128,33 @@ const EditItem: React.FC = () => {
name: item.name,
description: item.description,
pickUpAvailable: item.pickUpAvailable || false,
localDeliveryAvailable: item.localDeliveryAvailable || false,
localDeliveryRadius: item.localDeliveryRadius || 25,
shippingAvailable: item.shippingAvailable || false,
inPlaceUseAvailable: item.inPlaceUseAvailable || false,
pricePerHour: item.pricePerHour,
pricePerDay: item.pricePerDay,
replacementCost: item.replacementCost,
location: item.location,
pricePerHour: item.pricePerHour || "",
pricePerDay: item.pricePerDay || "",
replacementCost: item.replacementCost || "",
address1: item.address1 || "",
address2: item.address2 || "",
city: item.city || "",
state: item.state || "",
zipCode: item.zipCode || "",
country: item.country || "US",
latitude: item.latitude,
longitude: item.longitude,
rules: item.rules || "",
minimumRentalDays: item.minimumRentalDays,
needsTraining: item.needsTraining || false,
availability: item.availability,
unavailablePeriods: item.unavailablePeriods || [],
generalAvailableAfter: item.availableAfter || "09:00",
generalAvailableBefore: item.availableBefore || "17:00",
specifyTimesPerDay: item.specifyTimesPerDay || false,
weeklyTimes: item.weeklyTimes || {
sunday: { availableAfter: "09:00", availableBefore: "17:00" },
monday: { availableAfter: "09:00", availableBefore: "17:00" },
tuesday: { availableAfter: "09:00", availableBefore: "17:00" },
wednesday: { availableAfter: "09:00", availableBefore: "17:00" },
thursday: { availableAfter: "09:00", availableBefore: "17:00" },
friday: { availableAfter: "09:00", availableBefore: "17:00" },
saturday: { availableAfter: "09:00", availableBefore: "17:00" },
},
});
// Set existing images as previews
@@ -163,12 +214,43 @@ const EditItem: React.FC = () => {
await itemAPI.updateItem(id!, {
...formData,
pricePerDay: formData.pricePerDay ? parseFloat(formData.pricePerDay.toString()) : undefined,
pricePerHour: formData.pricePerHour ? parseFloat(formData.pricePerHour.toString()) : undefined,
replacementCost: formData.replacementCost ? parseFloat(formData.replacementCost.toString()) : 0,
pricePerDay: formData.pricePerDay
? parseFloat(formData.pricePerDay.toString())
: undefined,
pricePerHour: formData.pricePerHour
? parseFloat(formData.pricePerHour.toString())
: undefined,
replacementCost: formData.replacementCost
? parseFloat(formData.replacementCost.toString())
: 0,
availableAfter: formData.generalAvailableAfter,
availableBefore: formData.generalAvailableBefore,
specifyTimesPerDay: formData.specifyTimesPerDay,
weeklyTimes: formData.weeklyTimes,
images: imageUrls,
});
// Check if user has other items - only save to user profile if no other items
try {
const userItemsResponse = await itemAPI.getItems({ owner: user?.id });
const userItems = userItemsResponse.data.items || [];
const hasOtherItems =
userItems.filter((item: Item) => item.id !== id).length > 0;
if (!hasOtherItems) {
// Save to user profile - this is still their primary/only item
await userAPI.updateAvailability({
generalAvailableAfter: formData.generalAvailableAfter,
generalAvailableBefore: formData.generalAvailableBefore,
specifyTimesPerDay: formData.specifyTimesPerDay,
weeklyTimes: formData.weeklyTimes,
});
}
} catch (availabilityError) {
console.error("Failed to save availability:", availabilityError);
// Don't fail item update if availability save fails
}
setSuccess(true);
setTimeout(() => {
navigate(`/items/${id}`);
@@ -204,6 +286,60 @@ const EditItem: React.FC = () => {
setImagePreviews((prev) => prev.filter((_, i) => i !== index));
};
const handleAddressSelect = (addressId: string) => {
if (addressId === "new") {
setFormData((prev) => ({
...prev,
address1: "",
address2: "",
city: "",
state: "",
zipCode: "",
country: "US",
}));
setSelectedAddressId("");
} else {
const selectedAddress = userAddresses.find(
(addr) => addr.id === addressId
);
if (selectedAddress) {
setFormData((prev) => ({
...prev,
address1: selectedAddress.address1,
address2: selectedAddress.address2 || "",
city: selectedAddress.city,
state: selectedAddress.state,
zipCode: selectedAddress.zipCode,
country: selectedAddress.country,
latitude: selectedAddress.latitude,
longitude: selectedAddress.longitude,
}));
setSelectedAddressId(addressId);
}
}
};
const formatAddressDisplay = (address: Address) => {
return `${address.address1}, ${address.city}, ${address.state} ${address.zipCode}`;
};
const handleWeeklyTimeChange = (
day: string,
field: "availableAfter" | "availableBefore",
value: string
) => {
setFormData((prev) => ({
...prev,
weeklyTimes: {
...prev.weeklyTimes,
[day]: {
...prev.weeklyTimes[day as keyof typeof prev.weeklyTimes],
[field]: value,
},
},
}));
};
if (loading) {
return (
<div className="container mt-5">
@@ -245,386 +381,79 @@ const EditItem: React.FC = () => {
)}
<form onSubmit={handleSubmit}>
{/* Images Card */}
<ImageUpload
imageFiles={imageFiles}
imagePreviews={imagePreviews}
onImageChange={handleImageChange}
onRemoveImage={removeImage}
error={error || ""}
/>
<ItemInformation
name={formData.name}
description={formData.description}
onChange={handleChange}
/>
<LocationForm
data={{
address1: formData.address1,
address2: formData.address2,
city: formData.city,
state: formData.state,
zipCode: formData.zipCode,
country: formData.country,
latitude: formData.latitude,
longitude: formData.longitude,
}}
userAddresses={userAddresses}
selectedAddressId={selectedAddressId}
addressesLoading={addressesLoading}
onChange={handleChange}
onAddressSelect={handleAddressSelect}
formatAddressDisplay={formatAddressDisplay}
/>
<DeliveryOptions
pickUpAvailable={formData.pickUpAvailable}
inPlaceUseAvailable={formData.inPlaceUseAvailable}
onChange={handleChange}
/>
<PricingForm
priceType={priceType}
pricePerHour={formData.pricePerHour || ""}
pricePerDay={formData.pricePerDay || ""}
replacementCost={formData.replacementCost}
minimumRentalDays={formData.minimumRentalDays}
onPriceTypeChange={setPriceType}
onChange={handleChange}
/>
<div className="card mb-4">
<div className="card-body">
<div className="mb-3">
<label className="form-label">Images (Max 5)</label>
<input
type="file"
className="form-control"
onChange={handleImageChange}
accept="image/*"
multiple
disabled={imagePreviews.length >= 5}
/>
<div className="form-text">
Have pictures of everything that's included
</div>
</div>
{imagePreviews.length > 0 && (
<div className="row mt-3">
{imagePreviews.map((preview, index) => (
<div key={index} className="col-6 col-md-4 col-lg-3 mb-3">
<div className="position-relative">
<img
src={preview}
alt={`Preview ${index + 1}`}
className="img-fluid rounded"
style={{
width: "100%",
height: "150px",
objectFit: "cover",
}}
/>
<button
type="button"
className="btn btn-sm btn-danger position-absolute top-0 end-0 m-1"
onClick={() => removeImage(index)}
>
<i className="bi bi-x"></i>
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Basic Information Card */}
<div className="card mb-4">
<div className="card-body">
<div className="mb-3">
<label htmlFor="name" className="form-label">
Item Name *
</label>
<input
type="text"
className="form-control"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
required
/>
</div>
<div className="mb-3">
<label htmlFor="description" className="form-label">
Description *
</label>
<textarea
className="form-control"
id="description"
name="description"
rows={4}
value={formData.description}
onChange={handleChange}
required
/>
</div>
</div>
</div>
{/* Location Card */}
<div className="card mb-4">
<div className="card-body">
<div className="mb-3">
<label htmlFor="location" className="form-label">
Address *
</label>
<AddressAutocomplete
id="location"
name="location"
value={formData.location}
onChange={(value, lat, lon) => {
setFormData((prev) => ({
...prev,
location: value,
latitude: lat,
longitude: lon,
}));
}}
placeholder="Enter address"
required
/>
</div>
</div>
</div>
{/* Delivery & Availability Options Card */}
<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={formData.pickUpAvailable}
onChange={handleChange}
/>
<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="localDeliveryAvailable"
name="localDeliveryAvailable"
checked={formData.localDeliveryAvailable}
onChange={handleChange}
/>
<label
className="form-check-label d-flex align-items-center"
htmlFor="localDeliveryAvailable"
>
<div>
Local Delivery
{formData.localDeliveryAvailable && (
<span className="ms-2">
(Delivery Radius:
<input
type="number"
className="form-control form-control-sm d-inline-block mx-1"
id="localDeliveryRadius"
name="localDeliveryRadius"
value={formData.localDeliveryRadius || ""}
onChange={handleChange}
onClick={(e) => e.stopPropagation()}
placeholder="25"
min="1"
max="100"
style={{ width: "60px" }}
/>
miles)
</span>
)}
<div className="small text-muted">
You deliver and then pick-up the item when the rental
period ends
</div>
</div>
</label>
</div>
<div className="form-check">
<input
type="checkbox"
className="form-check-input"
id="shippingAvailable"
name="shippingAvailable"
checked={formData.shippingAvailable}
onChange={handleChange}
/>
<label
className="form-check-label"
htmlFor="shippingAvailable"
>
Shipping
</label>
</div>
<div className="form-check">
<input
type="checkbox"
className="form-check-input"
id="inPlaceUseAvailable"
name="inPlaceUseAvailable"
checked={formData.inPlaceUseAvailable}
onChange={handleChange}
/>
<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>
{/* Pricing Card */}
<div className="card mb-4">
<div className="card-body">
<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">
<select
className="form-select"
value={priceType}
onChange={(e) =>
setPriceType(e.target.value as "hour" | "day")
}
>
<option value="hour">Hour</option>
<option value="day">Day</option>
</select>
</div>
<div className="col">
<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"
? formData.pricePerHour || ""
: formData.pricePerDay || ""
}
onChange={handleChange}
step="0.01"
min="0"
placeholder="0.00"
/>
</div>
</div>
</div>
</div>
<div className="mb-3">
<label htmlFor="minimumRentalDays" className="form-label">
Minimum Rental {priceType === "hour" ? "Hours" : "Days"}
</label>
<input
type="number"
className="form-control"
id="minimumRentalDays"
name="minimumRentalDays"
value={formData.minimumRentalDays}
onChange={handleChange}
min="1"
/>
</div>
<div className="mb-3">
<label htmlFor="replacementCost" className="form-label mb-0">
Replacement Cost *
</label>
<div className="form-text mb-2">
The cost to replace the item if lost
</div>
<div className="input-group">
<span className="input-group-text">$</span>
<input
type="number"
className="form-control"
id="replacementCost"
name="replacementCost"
value={formData.replacementCost}
onChange={handleChange}
step="0.01"
min="0"
placeholder="0"
required
/>
</div>
</div>
</div>
</div>
{/* Availability Schedule Card */}
<div className="card mb-4">
<div className="card-body">
<p className="text-muted">
Select dates when the item is NOT available for rent. Dates
with accepted rentals are shown in purple.
</p>
<AvailabilityCalendar
unavailablePeriods={[
...(formData.unavailablePeriods || []),
...acceptedRentals.map((rental) => ({
id: `rental-${rental.id}`,
startDate: new Date(rental.startDate),
endDate: new Date(rental.endDate),
isAcceptedRental: true,
})),
]}
onPeriodsChange={(periods) => {
// Filter out accepted rental periods when saving
const userPeriods = periods.filter(
(p) => !p.isAcceptedRental
);
setFormData((prev) => ({
...prev,
unavailablePeriods: userPeriods,
}));
<AvailabilitySettings
data={{
generalAvailableAfter: formData.generalAvailableAfter,
generalAvailableBefore: formData.generalAvailableBefore,
specifyTimesPerDay: formData.specifyTimesPerDay,
weeklyTimes: formData.weeklyTimes,
}}
mode="owner"
/>
<div className="mt-3 form-check">
<input
type="checkbox"
className="form-check-input"
id="availability"
name="availability"
checked={formData.availability}
onChange={handleChange}
/>
<label className="form-check-label" htmlFor="availability">
Available for rent
</label>
</div>
</div>
</div>
{/* Rules & Guidelines Card */}
<div className="card mb-4">
<div className="card-body">
<div className="form-check mb-3">
<input
type="checkbox"
className="form-check-input"
id="needsTraining"
name="needsTraining"
checked={formData.needsTraining}
onChange={handleChange}
/>
<label className="form-check-label" htmlFor="needsTraining">
Requires in-person training before rental
</label>
</div>
<label htmlFor="rules" className="form-label">
Additional Rules
</label>
<textarea
className="form-control"
id="rules"
name="rules"
rows={3}
value={formData.rules || ""}
onChange={handleChange}
placeholder="Any specific rules for renting this item"
onChange={(field, value) => {
setFormData((prev) => ({ ...prev, [field]: value }));
}}
onWeeklyTimeChange={handleWeeklyTimeChange}
/>
</div>
</div>
<div className="d-grid gap-2">
<RulesForm
needsTraining={formData.needsTraining}
rules={formData.rules || ""}
onChange={handleChange}
/>
<div className="d-grid gap-2 mb-5">
<button
type="submit"
className="btn btn-primary"