635 lines
21 KiB
TypeScript
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;
|