Files
rentall-app/frontend/src/pages/EditItem.tsx

635 lines
21 KiB
TypeScript

import React, { useState, useEffect, useRef } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { Item, Rental, Address } from "../types";
import { useAuth } from "../contexts/AuthContext";
import { itemAPI, rentalAPI, addressAPI, userAPI } from "../services/api";
import { uploadImagesWithVariants, getImageUrl } from "../services/uploadService";
import AvailabilitySettings from "../components/AvailabilitySettings";
import ImageUpload from "../components/ImageUpload";
import ItemInformation from "../components/ItemInformation";
import LocationForm from "../components/LocationForm";
import PricingForm from "../components/PricingForm";
import RulesForm from "../components/RulesForm";
import { IMAGE_LIMITS } from "../config/imageLimits";
interface ItemFormData {
name: string;
description: string;
pricePerHour?: number | string;
pricePerDay?: number | string;
pricePerWeek?: number | string;
pricePerMonth?: number | string;
replacementCost: number | string;
address1: string;
address2: string;
city: string;
state: string;
zipCode: string;
country: string;
latitude?: number;
longitude?: number;
rules?: 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 = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { user } = useAuth();
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [imageFiles, setImageFiles] = useState<File[]>([]);
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
const [existingImageKeys, setExistingImageKeys] = useState<string[]>([]); // S3 keys for existing images
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<{ latitude: number; longitude: number } | null>) | null
>(null);
const [formData, setFormData] = useState<ItemFormData>({
name: "",
description: "",
pricePerHour: "",
pricePerDay: "",
replacementCost: "",
address1: "",
address2: "",
city: "",
state: "",
zipCode: "",
country: "US",
rules: "",
generalAvailableAfter: "00:00",
generalAvailableBefore: "23:00",
specifyTimesPerDay: false,
weeklyTimes: {
sunday: { availableAfter: "00:00", availableBefore: "23:00" },
monday: { availableAfter: "00:00", availableBefore: "23:00" },
tuesday: { availableAfter: "00:00", availableBefore: "23:00" },
wednesday: { availableAfter: "00:00", availableBefore: "23:00" },
thursday: { availableAfter: "00:00", availableBefore: "23:00" },
friday: { availableAfter: "00:00", availableBefore: "23:00" },
saturday: { availableAfter: "00:00", availableBefore: "23: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!);
const item: Item = response.data;
if (item.ownerId !== user?.id) {
setError("You are not authorized to edit this item");
return;
}
// Convert item data to form data format
setFormData({
name: item.name,
description: item.description,
pricePerHour: item.pricePerHour || "",
pricePerDay: item.pricePerDay || "",
pricePerWeek: item.pricePerWeek || "",
pricePerMonth: item.pricePerMonth || "",
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 || "",
generalAvailableAfter: item.availableAfter || "00:00",
generalAvailableBefore: item.availableBefore || "23:00",
specifyTimesPerDay: item.specifyTimesPerDay || false,
weeklyTimes: item.weeklyTimes || {
sunday: { availableAfter: "00:00", availableBefore: "23:00" },
monday: { availableAfter: "00:00", availableBefore: "23:00" },
tuesday: { availableAfter: "00:00", availableBefore: "23:00" },
wednesday: { availableAfter: "00:00", availableBefore: "23:00" },
thursday: { availableAfter: "00:00", availableBefore: "23:00" },
friday: { availableAfter: "00:00", availableBefore: "23:00" },
saturday: { availableAfter: "00:00", availableBefore: "23:00" },
},
});
// Set existing images - store S3 keys and generate preview URLs
if (item.imageFilenames && item.imageFilenames.length > 0) {
setExistingImageKeys(item.imageFilenames);
// Generate preview URLs from S3 keys (use thumbnail for previews)
setImagePreviews(item.imageFilenames.map((key: string) => getImageUrl(key, 'thumbnail')));
}
// 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 {
setLoading(false);
}
};
const fetchAcceptedRentals = async () => {
try {
const response = await rentalAPI.getListings();
const rentals: Rental[] = response.data;
// Filter for accepted rentals for this specific item
const itemRentals = rentals.filter(
(rental) =>
rental.itemId === id &&
["confirmed", "active"].includes(rental.status)
);
setAcceptedRentals(itemRentals);
} catch (err) {
console.error("Error fetching rentals:", err);
}
};
const handleChange = (
e: React.ChangeEvent<
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
>
) => {
const { name, value, type } = e.target;
if (type === "checkbox") {
const checked = (e.target as HTMLInputElement).checked;
setFormData((prev) => ({ ...prev, [name]: checked }));
} else if (type === "number") {
setFormData((prev) => ({
...prev,
[name]: value === "" ? "" : parseFloat(value) || 0,
}));
} else {
setFormData((prev) => ({ ...prev, [name]: value }));
}
};
const handleCoordinatesChange = (latitude: number, longitude: number) => {
setFormData((prev) => ({
...prev,
latitude,
longitude,
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// Check total images (existing + new)
const totalImages = existingImageKeys.length + imageFiles.length;
if (totalImages === 0) {
setError("At least one image is required for a listing");
document.getElementById("image-upload-section")?.scrollIntoView({ behavior: "smooth", block: "center" });
return;
}
if (!formData.name.trim()) {
setError("Item name is required");
document.getElementById("name")?.focus();
return;
}
if (!formData.address1.trim()) {
setError("Address is required");
document.getElementById("address1")?.focus();
return;
}
if (!formData.city.trim()) {
setError("City is required");
document.getElementById("city")?.focus();
return;
}
if (!formData.state.trim()) {
setError("State is required");
document.getElementById("state")?.focus();
return;
}
if (!formData.zipCode.trim()) {
setError("ZIP code is required");
document.getElementById("zipCode")?.focus();
return;
}
if (!formData.replacementCost || Number(formData.replacementCost) <= 0) {
setError("Replacement cost is required");
document.getElementById("replacementCost")?.focus();
return;
}
setSubmitting(true);
// Try to geocode the address before submitting
let geocodedCoordinates = null;
if (geocodeLocationRef.current) {
try {
geocodedCoordinates = await geocodeLocationRef.current();
} catch (error) {
console.warn(
"Geocoding failed, updating item without coordinates:",
error
);
}
} else {
console.warn("No geocoding function available");
}
try {
// Upload new images to S3 and get their keys (with resizing)
let newImageKeys: string[] = [];
if (imageFiles.length > 0) {
const uploadResults = await uploadImagesWithVariants("item", imageFiles);
newImageKeys = uploadResults.map((result) => result.baseKey);
}
// Combine existing S3 keys with newly uploaded keys
const allImageKeys = [...existingImageKeys, ...newImageKeys];
const updatePayload = {
...formData,
// Use geocoded coordinates if available, otherwise fall back to formData
latitude: geocodedCoordinates?.latitude ?? formData.latitude,
longitude: geocodedCoordinates?.longitude ?? formData.longitude,
pricePerDay: formData.pricePerDay
? parseFloat(formData.pricePerDay.toString())
: undefined,
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,
availableAfter: formData.generalAvailableAfter,
availableBefore: formData.generalAvailableBefore,
specifyTimesPerDay: formData.specifyTimesPerDay,
weeklyTimes: formData.weeklyTimes,
imageFilenames: allImageKeys,
};
await itemAPI.updateItem(id!, updatePayload);
// 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}`);
}, 1500);
} catch (err: any) {
setError(err.response?.data?.message || err.message || "Failed to update item");
} finally {
setSubmitting(false);
}
};
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (imagePreviews.length + files.length > IMAGE_LIMITS.items) {
setError(`You can upload a maximum of ${IMAGE_LIMITS.items} images`);
return;
}
const newImageFiles = [...imageFiles, ...files];
setImageFiles(newImageFiles);
setError(null); // Clear any previous error
// Create previews
files.forEach((file) => {
const reader = new FileReader();
reader.onloadend = () => {
setImagePreviews((prev) => [...prev, reader.result as string]);
};
reader.readAsDataURL(file);
});
};
const removeImage = (index: number) => {
// Check if removing an existing image or a new upload
if (index < existingImageKeys.length) {
// Removing an existing S3 image
setExistingImageKeys((prev) => prev.filter((_, i) => i !== index));
} else {
// Removing a new upload - adjust index for the imageFiles array
const newFileIndex = index - existingImageKeys.length;
setImageFiles((prev) => prev.filter((_, i) => i !== newFileIndex));
}
// Always update previews
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,
},
},
}));
};
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">
<div className="text-center">
<div className="spinner-border" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
</div>
);
}
if (error && error.includes("authorized")) {
return (
<div className="container mt-5">
<div className="alert alert-danger" role="alert">
{error}
</div>
</div>
);
}
return (
<div className="container mt-4">
<div className="row justify-content-center">
<div className="col-md-8">
<h1>Edit Listing</h1>
{error && (
<div className="alert alert-danger" role="alert">
{error}
</div>
)}
{success && (
<div className="alert alert-success" role="alert">
Item updated successfully! Redirecting...
</div>
)}
<form onSubmit={handleSubmit} noValidate>
<div id="image-upload-section">
<ImageUpload
imageFiles={imageFiles}
imagePreviews={imagePreviews}
onImageChange={handleImageChange}
onRemoveImage={removeImage}
error={error || ""}
/>
</div>
<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}
onCoordinatesChange={handleCoordinatesChange}
onGeocodeRef={(geocodeFunction) => {
geocodeLocationRef.current = geocodeFunction;
}}
/>
<PricingForm
pricePerHour={formData.pricePerHour || ""}
pricePerDay={formData.pricePerDay || ""}
pricePerWeek={formData.pricePerWeek || ""}
pricePerMonth={formData.pricePerMonth || ""}
replacementCost={formData.replacementCost}
selectedPricingUnit={selectedPricingUnit}
showAdvancedPricing={showAdvancedPricing}
enabledTiers={enabledPricingTiers}
onChange={handleChange}
onPricingUnitChange={handlePricingUnitChange}
onToggleAdvancedPricing={handleToggleAdvancedPricing}
onTierToggle={handleTierToggle}
/>
<div className="card mb-4">
<div className="card-body">
<AvailabilitySettings
data={{
generalAvailableAfter: formData.generalAvailableAfter,
generalAvailableBefore: formData.generalAvailableBefore,
specifyTimesPerDay: formData.specifyTimesPerDay,
weeklyTimes: formData.weeklyTimes,
}}
onChange={(field, value) => {
setFormData((prev) => ({ ...prev, [field]: value }));
}}
onWeeklyTimeChange={handleWeeklyTimeChange}
/>
</div>
</div>
<RulesForm
rules={formData.rules || ""}
onChange={handleChange}
/>
<div className="d-grid gap-2 mb-5">
<button
type="submit"
className="btn btn-primary"
disabled={submitting}
>
{submitting ? "Updating..." : "Update Listing"}
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => navigate(-1)}
>
Back
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default EditItem;