Image is required for creating an item, required fields actually required, Available After and Available Before defaults changed, delete confirmation modal for deleting an item

This commit is contained in:
jackiettran
2025-12-29 19:26:37 -05:00
parent ac1e22f194
commit 7dd3aff0f8
10 changed files with 287 additions and 89 deletions

View File

@@ -95,11 +95,11 @@ const Item = sequelize.define("Item", {
}, },
availableAfter: { availableAfter: {
type: DataTypes.STRING, type: DataTypes.STRING,
defaultValue: "09:00", defaultValue: "00:00",
}, },
availableBefore: { availableBefore: {
type: DataTypes.STRING, type: DataTypes.STRING,
defaultValue: "17:00", defaultValue: "23:00",
}, },
specifyTimesPerDay: { specifyTimesPerDay: {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
@@ -108,13 +108,13 @@ const Item = sequelize.define("Item", {
weeklyTimes: { weeklyTimes: {
type: DataTypes.JSONB, type: DataTypes.JSONB,
defaultValue: { defaultValue: {
sunday: { availableAfter: "09:00", availableBefore: "17:00" }, sunday: { availableAfter: "00:00", availableBefore: "23:00" },
monday: { availableAfter: "09:00", availableBefore: "17:00" }, monday: { availableAfter: "00:00", availableBefore: "23:00" },
tuesday: { availableAfter: "09:00", availableBefore: "17:00" }, tuesday: { availableAfter: "00:00", availableBefore: "23:00" },
wednesday: { availableAfter: "09:00", availableBefore: "17:00" }, wednesday: { availableAfter: "00:00", availableBefore: "23:00" },
thursday: { availableAfter: "09:00", availableBefore: "17:00" }, thursday: { availableAfter: "00:00", availableBefore: "23:00" },
friday: { availableAfter: "09:00", availableBefore: "17:00" }, friday: { availableAfter: "00:00", availableBefore: "23:00" },
saturday: { availableAfter: "09:00", availableBefore: "17:00" }, saturday: { availableAfter: "00:00", availableBefore: "23:00" },
}, },
}, },
ownerId: { ownerId: {

View File

@@ -89,11 +89,11 @@ const User = sequelize.define(
}, },
defaultAvailableAfter: { defaultAvailableAfter: {
type: DataTypes.STRING, type: DataTypes.STRING,
defaultValue: "09:00", defaultValue: "00:00",
}, },
defaultAvailableBefore: { defaultAvailableBefore: {
type: DataTypes.STRING, type: DataTypes.STRING,
defaultValue: "17:00", defaultValue: "23:00",
}, },
defaultSpecifyTimesPerDay: { defaultSpecifyTimesPerDay: {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
@@ -102,13 +102,13 @@ const User = sequelize.define(
defaultWeeklyTimes: { defaultWeeklyTimes: {
type: DataTypes.JSONB, type: DataTypes.JSONB,
defaultValue: { defaultValue: {
sunday: { availableAfter: "09:00", availableBefore: "17:00" }, sunday: { availableAfter: "00:00", availableBefore: "23:00" },
monday: { availableAfter: "09:00", availableBefore: "17:00" }, monday: { availableAfter: "00:00", availableBefore: "23:00" },
tuesday: { availableAfter: "09:00", availableBefore: "17:00" }, tuesday: { availableAfter: "00:00", availableBefore: "23:00" },
wednesday: { availableAfter: "09:00", availableBefore: "17:00" }, wednesday: { availableAfter: "00:00", availableBefore: "23:00" },
thursday: { availableAfter: "09:00", availableBefore: "17:00" }, thursday: { availableAfter: "00:00", availableBefore: "23:00" },
friday: { availableAfter: "09:00", availableBefore: "17:00" }, friday: { availableAfter: "00:00", availableBefore: "23:00" },
saturday: { availableAfter: "09:00", availableBefore: "17:00" }, saturday: { availableAfter: "00:00", availableBefore: "23:00" },
}, },
}, },
stripeConnectedAccountId: { stripeConnectedAccountId: {

View File

@@ -328,12 +328,37 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res, next)
// Extract only allowed fields (prevents mass assignment) // Extract only allowed fields (prevents mass assignment)
const allowedData = extractAllowedFields(req.body); const allowedData = extractAllowedFields(req.body);
// Validate imageFilenames if provided // Validate imageFilenames - at least one image is required
if (allowedData.imageFilenames) {
const imageFilenames = Array.isArray(allowedData.imageFilenames) const imageFilenames = Array.isArray(allowedData.imageFilenames)
? allowedData.imageFilenames ? allowedData.imageFilenames
: []; : [];
if (imageFilenames.length === 0) {
return res.status(400).json({
error: "At least one image is required to create a listing"
});
}
// Validate required fields
if (!allowedData.name || !allowedData.name.trim()) {
return res.status(400).json({ error: "Item name is required" });
}
if (!allowedData.address1 || !allowedData.address1.trim()) {
return res.status(400).json({ error: "Address is required" });
}
if (!allowedData.city || !allowedData.city.trim()) {
return res.status(400).json({ error: "City is required" });
}
if (!allowedData.state || !allowedData.state.trim()) {
return res.status(400).json({ error: "State is required" });
}
if (!allowedData.zipCode || !allowedData.zipCode.trim()) {
return res.status(400).json({ error: "ZIP code is required" });
}
if (!allowedData.replacementCost || Number(allowedData.replacementCost) <= 0) {
return res.status(400).json({ error: "Replacement cost is required" });
}
const keyValidation = validateS3Keys(imageFilenames, 'items', { maxKeys: IMAGE_LIMITS.items }); const keyValidation = validateS3Keys(imageFilenames, 'items', { maxKeys: IMAGE_LIMITS.items });
if (!keyValidation.valid) { if (!keyValidation.valid) {
return res.status(400).json({ return res.status(400).json({
@@ -342,7 +367,6 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res, next)
}); });
} }
allowedData.imageFilenames = imageFilenames; allowedData.imageFilenames = imageFilenames;
}
const item = await Item.create({ const item = await Item.create({
...allowedData, ...allowedData,
@@ -428,6 +452,13 @@ router.put("/:id", authenticateToken, async (req, res, next) => {
? allowedData.imageFilenames ? allowedData.imageFilenames
: []; : [];
// Require at least one image
if (imageFilenames.length === 0) {
return res.status(400).json({
error: "At least one image is required for a listing"
});
}
const keyValidation = validateS3Keys(imageFilenames, 'items', { maxKeys: IMAGE_LIMITS.items }); const keyValidation = validateS3Keys(imageFilenames, 'items', { maxKeys: IMAGE_LIMITS.items });
if (!keyValidation.valid) { if (!keyValidation.valid) {
return res.status(400).json({ return res.status(400).json({
@@ -438,6 +469,26 @@ router.put("/:id", authenticateToken, async (req, res, next) => {
allowedData.imageFilenames = imageFilenames; allowedData.imageFilenames = imageFilenames;
} }
// Validate required fields if they are being updated
if (allowedData.name !== undefined && (!allowedData.name || !allowedData.name.trim())) {
return res.status(400).json({ error: "Item name is required" });
}
if (allowedData.address1 !== undefined && (!allowedData.address1 || !allowedData.address1.trim())) {
return res.status(400).json({ error: "Address is required" });
}
if (allowedData.city !== undefined && (!allowedData.city || !allowedData.city.trim())) {
return res.status(400).json({ error: "City is required" });
}
if (allowedData.state !== undefined && (!allowedData.state || !allowedData.state.trim())) {
return res.status(400).json({ error: "State is required" });
}
if (allowedData.zipCode !== undefined && (!allowedData.zipCode || !allowedData.zipCode.trim())) {
return res.status(400).json({ error: "ZIP code is required" });
}
if (allowedData.replacementCost !== undefined && (!allowedData.replacementCost || Number(allowedData.replacementCost) <= 0)) {
return res.status(400).json({ error: "Replacement cost is required" });
}
await item.update(allowedData); await item.update(allowedData);
const updatedItem = await Item.findByPk(item.id, { const updatedItem = await Item.findByPk(item.id, {

View File

@@ -60,7 +60,7 @@ const AvailabilitySettings: React.FC<AvailabilitySettingsProps> = ({
<div className="row mb-3"> <div className="row mb-3">
<div className="col-md-6"> <div className="col-md-6">
<label htmlFor="generalAvailableAfter" className="form-label"> <label htmlFor="generalAvailableAfter" className="form-label">
Available After * Available After
</label> </label>
<select <select
className="form-select" className="form-select"
@@ -69,7 +69,6 @@ const AvailabilitySettings: React.FC<AvailabilitySettingsProps> = ({
value={data.generalAvailableAfter} value={data.generalAvailableAfter}
onChange={handleGeneralChange} onChange={handleGeneralChange}
disabled={data.specifyTimesPerDay} disabled={data.specifyTimesPerDay}
required
> >
{generateTimeOptions().map((option) => ( {generateTimeOptions().map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>
@@ -80,7 +79,7 @@ const AvailabilitySettings: React.FC<AvailabilitySettingsProps> = ({
</div> </div>
<div className="col-md-6"> <div className="col-md-6">
<label htmlFor="generalAvailableBefore" className="form-label"> <label htmlFor="generalAvailableBefore" className="form-label">
Available Before * Available Before
</label> </label>
<select <select
className="form-select" className="form-select"
@@ -89,7 +88,6 @@ const AvailabilitySettings: React.FC<AvailabilitySettingsProps> = ({
value={data.generalAvailableBefore} value={data.generalAvailableBefore}
onChange={handleGeneralChange} onChange={handleGeneralChange}
disabled={data.specifyTimesPerDay} disabled={data.specifyTimesPerDay}
required
> >
{generateTimeOptions().map((option) => ( {generateTimeOptions().map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>

View File

@@ -24,7 +24,7 @@ const FeedbackButton: React.FC = () => {
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
z-index: 1000; z-index: 1000;
background-color: #0d6efd; background-color: #14B8A6;
color: white; color: white;
border: none; border: none;
border-radius: 8px 0 0 8px; border-radius: 8px 0 0 8px;
@@ -40,13 +40,13 @@ const FeedbackButton: React.FC = () => {
} }
.feedback-tab:hover { .feedback-tab:hover {
background-color: #0b5ed7; background-color: #0d9488;
padding-right: 14px; padding-right: 14px;
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.2); box-shadow: -4px 0 12px rgba(0, 0, 0, 0.2);
} }
.feedback-tab:active { .feedback-tab:active {
background-color: #0a58ca; background-color: #0f766e;
} }
.feedback-tab-text { .feedback-tab-text {

View File

@@ -23,7 +23,7 @@ const ImageUpload: React.FC<ImageUploadProps> = ({
<div className="card-body"> <div className="card-body">
<div className="mb-3"> <div className="mb-3">
<label className="form-label mb-0"> <label className="form-label mb-0">
Upload Images (Max {maxImages}) Upload Images (Max {maxImages}) *
</label> </label>
<div className="form-text mb-2"> <div className="form-text mb-2">
Have pictures of everything that's included Have pictures of everything that's included
@@ -50,7 +50,7 @@ const ImageUpload: React.FC<ImageUploadProps> = ({
style={{ style={{
width: "100%", width: "100%",
height: "150px", height: "150px",
objectFit: "cover", objectFit: "contain",
}} }}
/> />
<button <button

View File

@@ -67,13 +67,11 @@ const PricingForm: React.FC<PricingFormProps> = ({
"Set multiple pricing tiers for flexible rental rates." "Set multiple pricing tiers for flexible rental rates."
) : ( ) : (
<> <>
Set your pricing rate. You can use Advanced Pricing for multiple Community Rentals charges a 10% Community Upkeep Fee to help keep
pricing tiers. Community Rentals charges a 10% Community Upkeep us running.{" "}
Fee to help keep us running.{" "}
<Link to="/faq" target="_blank"> <Link to="/faq" target="_blank">
Calculate what you can earn here. Calculate what you can earn here
</Link> </Link>
.
</> </>
)} )}
</p> </p>

View File

@@ -67,17 +67,17 @@ const CreateItem: React.FC = () => {
state: "", state: "",
zipCode: "", zipCode: "",
country: "US", country: "US",
generalAvailableAfter: "09:00", generalAvailableAfter: "00:00",
generalAvailableBefore: "17:00", generalAvailableBefore: "23:00",
specifyTimesPerDay: false, specifyTimesPerDay: false,
weeklyTimes: { weeklyTimes: {
sunday: { availableAfter: "09:00", availableBefore: "17:00" }, sunday: { availableAfter: "00:00", availableBefore: "23:00" },
monday: { availableAfter: "09:00", availableBefore: "17:00" }, monday: { availableAfter: "00:00", availableBefore: "23:00" },
tuesday: { availableAfter: "09:00", availableBefore: "17:00" }, tuesday: { availableAfter: "00:00", availableBefore: "23:00" },
wednesday: { availableAfter: "09:00", availableBefore: "17:00" }, wednesday: { availableAfter: "00:00", availableBefore: "23:00" },
thursday: { availableAfter: "09:00", availableBefore: "17:00" }, thursday: { availableAfter: "00:00", availableBefore: "23:00" },
friday: { availableAfter: "09:00", availableBefore: "17:00" }, friday: { availableAfter: "00:00", availableBefore: "23:00" },
saturday: { availableAfter: "09:00", availableBefore: "17:00" }, saturday: { availableAfter: "00:00", availableBefore: "23:00" },
}, },
}); });
const [imageFiles, setImageFiles] = useState<File[]>([]); const [imageFiles, setImageFiles] = useState<File[]>([]);
@@ -163,6 +163,48 @@ const CreateItem: React.FC = () => {
return; return;
} }
if (imageFiles.length === 0) {
setError("At least one image is required to create 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;
}
setLoading(true); setLoading(true);
setError(""); setError("");
@@ -393,6 +435,7 @@ const CreateItem: React.FC = () => {
const newImageFiles = [...imageFiles, ...files]; const newImageFiles = [...imageFiles, ...files];
setImageFiles(newImageFiles); setImageFiles(newImageFiles);
setError(""); // Clear any previous error
// Create previews // Create previews
files.forEach((file) => { files.forEach((file) => {
@@ -456,7 +499,8 @@ const CreateItem: React.FC = () => {
</div> </div>
)} )}
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit} noValidate>
<div id="image-upload-section">
<ImageUpload <ImageUpload
imageFiles={imageFiles} imageFiles={imageFiles}
imagePreviews={imagePreviews} imagePreviews={imagePreviews}
@@ -464,6 +508,7 @@ const CreateItem: React.FC = () => {
onRemoveImage={removeImage} onRemoveImage={removeImage}
error={error} error={error}
/> />
</div>
<ItemInformation <ItemInformation
name={formData.name} name={formData.name}

View File

@@ -90,17 +90,17 @@ const EditItem: React.FC = () => {
zipCode: "", zipCode: "",
country: "US", country: "US",
rules: "", rules: "",
generalAvailableAfter: "09:00", generalAvailableAfter: "00:00",
generalAvailableBefore: "17:00", generalAvailableBefore: "23:00",
specifyTimesPerDay: false, specifyTimesPerDay: false,
weeklyTimes: { weeklyTimes: {
sunday: { availableAfter: "09:00", availableBefore: "17:00" }, sunday: { availableAfter: "00:00", availableBefore: "23:00" },
monday: { availableAfter: "09:00", availableBefore: "17:00" }, monday: { availableAfter: "00:00", availableBefore: "23:00" },
tuesday: { availableAfter: "09:00", availableBefore: "17:00" }, tuesday: { availableAfter: "00:00", availableBefore: "23:00" },
wednesday: { availableAfter: "09:00", availableBefore: "17:00" }, wednesday: { availableAfter: "00:00", availableBefore: "23:00" },
thursday: { availableAfter: "09:00", availableBefore: "17:00" }, thursday: { availableAfter: "00:00", availableBefore: "23:00" },
friday: { availableAfter: "09:00", availableBefore: "17:00" }, friday: { availableAfter: "00:00", availableBefore: "23:00" },
saturday: { availableAfter: "09:00", availableBefore: "17:00" }, saturday: { availableAfter: "00:00", availableBefore: "23:00" },
}, },
}); });
@@ -151,17 +151,17 @@ const EditItem: React.FC = () => {
latitude: item.latitude, latitude: item.latitude,
longitude: item.longitude, longitude: item.longitude,
rules: item.rules || "", rules: item.rules || "",
generalAvailableAfter: item.availableAfter || "09:00", generalAvailableAfter: item.availableAfter || "00:00",
generalAvailableBefore: item.availableBefore || "17:00", generalAvailableBefore: item.availableBefore || "23:00",
specifyTimesPerDay: item.specifyTimesPerDay || false, specifyTimesPerDay: item.specifyTimesPerDay || false,
weeklyTimes: item.weeklyTimes || { weeklyTimes: item.weeklyTimes || {
sunday: { availableAfter: "09:00", availableBefore: "17:00" }, sunday: { availableAfter: "00:00", availableBefore: "23:00" },
monday: { availableAfter: "09:00", availableBefore: "17:00" }, monday: { availableAfter: "00:00", availableBefore: "23:00" },
tuesday: { availableAfter: "09:00", availableBefore: "17:00" }, tuesday: { availableAfter: "00:00", availableBefore: "23:00" },
wednesday: { availableAfter: "09:00", availableBefore: "17:00" }, wednesday: { availableAfter: "00:00", availableBefore: "23:00" },
thursday: { availableAfter: "09:00", availableBefore: "17:00" }, thursday: { availableAfter: "00:00", availableBefore: "23:00" },
friday: { availableAfter: "09:00", availableBefore: "17:00" }, friday: { availableAfter: "00:00", availableBefore: "23:00" },
saturday: { availableAfter: "09:00", availableBefore: "17:00" }, saturday: { availableAfter: "00:00", availableBefore: "23:00" },
}, },
}); });
@@ -259,6 +259,51 @@ const EditItem: React.FC = () => {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(null); 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); setSubmitting(true);
// Try to geocode the address before submitting // Try to geocode the address before submitting
@@ -358,6 +403,7 @@ const EditItem: React.FC = () => {
const newImageFiles = [...imageFiles, ...files]; const newImageFiles = [...imageFiles, ...files];
setImageFiles(newImageFiles); setImageFiles(newImageFiles);
setError(null); // Clear any previous error
// Create previews // Create previews
files.forEach((file) => { files.forEach((file) => {
@@ -492,7 +538,8 @@ const EditItem: React.FC = () => {
</div> </div>
)} )}
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit} noValidate>
<div id="image-upload-section">
<ImageUpload <ImageUpload
imageFiles={imageFiles} imageFiles={imageFiles}
imagePreviews={imagePreviews} imagePreviews={imagePreviews}
@@ -500,6 +547,7 @@ const EditItem: React.FC = () => {
onRemoveImage={removeImage} onRemoveImage={removeImage}
error={error || ""} error={error || ""}
/> />
</div>
<ItemInformation <ItemInformation
name={formData.name} name={formData.name}

View File

@@ -71,6 +71,8 @@ const Owning: React.FC = () => {
useState<ConditionCheck | null>(null); useState<ConditionCheck | null>(null);
const [showReturnStatusModal, setShowReturnStatusModal] = useState(false); const [showReturnStatusModal, setShowReturnStatusModal] = useState(false);
const [rentalForReturn, setRentalForReturn] = useState<Rental | null>(null); const [rentalForReturn, setRentalForReturn] = useState<Rental | null>(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [itemToDelete, setItemToDelete] = useState<Item | null>(null);
useEffect(() => { useEffect(() => {
fetchListings(); fetchListings();
@@ -107,15 +109,23 @@ const Owning: React.FC = () => {
} }
}; };
const handleDelete = async (itemId: string) => { const handleDeleteClick = (item: Item) => {
if (!window.confirm("Are you sure you want to delete this listing?")) setItemToDelete(item);
return; setShowDeleteModal(true);
};
const handleDeleteConfirm = async () => {
if (!itemToDelete) return;
try { try {
await api.delete(`/items/${itemId}`); await api.delete(`/items/${itemToDelete.id}`);
setListings(listings.filter((item) => item.id !== itemId)); setListings(listings.filter((item) => item.id !== itemToDelete.id));
setShowDeleteModal(false);
setItemToDelete(null);
} catch (err: any) { } catch (err: any) {
alert("Failed to delete listing"); setError("Failed to delete listing");
setShowDeleteModal(false);
setItemToDelete(null);
} }
}; };
@@ -713,7 +723,7 @@ const Owning: React.FC = () => {
{item.isAvailable ? "Mark Unavailable" : "Mark Available"} {item.isAvailable ? "Mark Unavailable" : "Mark Available"}
</button> </button>
<button <button
onClick={() => handleDelete(item.id)} onClick={() => handleDeleteClick(item)}
className="btn btn-sm btn-outline-danger" className="btn btn-sm btn-outline-danger"
> >
Delete Delete
@@ -803,6 +813,54 @@ const Owning: React.FC = () => {
}} }}
conditionCheck={selectedConditionCheck} conditionCheck={selectedConditionCheck}
/> />
{/* Delete Confirmation Modal */}
{showDeleteModal && (
<div
className="modal fade show d-block"
tabIndex={-1}
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
>
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Delete Listing</h5>
<button
type="button"
className="btn-close"
onClick={() => {
setShowDeleteModal(false);
setItemToDelete(null);
}}
></button>
</div>
<div className="modal-body">
<p>Are you sure you want to delete {itemToDelete?.name}?</p>
<p>This action cannot be undone.</p>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={() => {
setShowDeleteModal(false);
setItemToDelete(null);
}}
>
Cancel
</button>
<button
type="button"
className="btn btn-danger"
onClick={handleDeleteConfirm}
>
Delete
</button>
</div>
</div>
</div>
</div>
)}
</div> </div>
); );
}; };