Move image from URL option from validate step to add images step
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useState, useRef, useEffect } from "react";
|
||||
import { useRsi } from "../../hooks/useRsi";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, Upload, Trash2, AlertCircle, GripVertical, Maximize2, X } from "lucide-react";
|
||||
import { Loader2, Upload, Trash2, AlertCircle, GripVertical, Maximize2, X, Link2 } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { toast } from "sonner";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -37,6 +37,11 @@ import {
|
||||
DialogContent,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
type Props<T extends string = string> = {
|
||||
data: any[];
|
||||
@@ -70,7 +75,6 @@ export const ImageUploadStep = <T extends string>({
|
||||
}: Props<T>) => {
|
||||
const { translations } = useRsi<T>();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [productImages, setProductImages] = useState<ProductImageSortable[]>([]);
|
||||
const fileInputRefs = useRef<{ [key: number]: HTMLInputElement | null }>({});
|
||||
const [unassignedImages, setUnassignedImages] = useState<UnassignedImage[]>([]);
|
||||
const [processingBulk, setProcessingBulk] = useState(false);
|
||||
@@ -78,6 +82,49 @@ export const ImageUploadStep = <T extends string>({
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [activeImage, setActiveImage] = useState<ProductImageSortable | null>(null);
|
||||
|
||||
// Add state for URL input
|
||||
const [urlInputs, setUrlInputs] = useState<{ [key: number]: string }>({});
|
||||
const [processingUrls, setProcessingUrls] = useState<{ [key: number]: boolean }>({});
|
||||
|
||||
// Initialize product images from data
|
||||
const [productImages, setProductImages] = useState<ProductImageSortable[]>(() => {
|
||||
// Convert existing product_images to ProductImageSortable objects
|
||||
const initialImages: ProductImageSortable[] = [];
|
||||
|
||||
data.forEach((product, productIndex) => {
|
||||
if (product.product_images) {
|
||||
let imageUrls: string[] = [];
|
||||
|
||||
// Handle different formats of product_images
|
||||
if (typeof product.product_images === 'string') {
|
||||
// Split by comma if it's a string
|
||||
imageUrls = product.product_images.split(',').filter(Boolean);
|
||||
} else if (Array.isArray(product.product_images)) {
|
||||
// Use the array directly
|
||||
imageUrls = product.product_images.filter(Boolean);
|
||||
} else if (product.product_images) {
|
||||
// Handle case where it might be a single value
|
||||
imageUrls = [String(product.product_images)];
|
||||
}
|
||||
|
||||
// Create ProductImageSortable objects for each URL
|
||||
imageUrls.forEach((url, i) => {
|
||||
if (url && url.trim()) {
|
||||
initialImages.push({
|
||||
id: `image-${productIndex}-initial-${i}`,
|
||||
productIndex,
|
||||
imageUrl: url.trim(),
|
||||
loading: false,
|
||||
fileName: `Image ${i + 1}`
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return initialImages;
|
||||
});
|
||||
|
||||
// Set up sensors for drag and drop with enhanced configuration
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@@ -245,23 +292,52 @@ export const ImageUploadStep = <T extends string>({
|
||||
// Get the current product
|
||||
const product = newData[productIndex];
|
||||
|
||||
// Get current image URLs
|
||||
let currentUrls = product.image_url ?
|
||||
(typeof product.image_url === 'string' ? product.image_url.split(',') : product.image_url)
|
||||
: [];
|
||||
// We need to update product_images array directly instead of the image_url field
|
||||
if (!product.product_images) {
|
||||
product.product_images = [];
|
||||
} else if (typeof product.product_images === 'string') {
|
||||
// Handle case where it might be a comma-separated string
|
||||
product.product_images = product.product_images.split(',').filter(Boolean);
|
||||
}
|
||||
|
||||
// Filter out all instances of the URL we're removing
|
||||
currentUrls = currentUrls.filter((url: string) => url && url !== imageUrl);
|
||||
// Filter out the image URL we're removing
|
||||
if (Array.isArray(product.product_images)) {
|
||||
product.product_images = product.product_images.filter((url: string) => url && url !== imageUrl);
|
||||
}
|
||||
|
||||
// Update the product
|
||||
product.image_url = currentUrls.join(',');
|
||||
|
||||
// This is important - actually update the data reference in the parent component
|
||||
// by passing the newData back to onSubmit, which will update the parent state
|
||||
return newData;
|
||||
};
|
||||
|
||||
// Handle drag end event to reorder or reassign images
|
||||
// Function to add an image URL to a product
|
||||
const addImageToProduct = (productIndex: number, imageUrl: string) => {
|
||||
// Create a copy of the data
|
||||
const newData = [...data];
|
||||
|
||||
// Get the current product
|
||||
const product = newData[productIndex];
|
||||
|
||||
// Initialize product_images array if it doesn't exist
|
||||
if (!product.product_images) {
|
||||
product.product_images = [];
|
||||
} else if (typeof product.product_images === 'string') {
|
||||
// Handle case where it might be a comma-separated string
|
||||
product.product_images = product.product_images.split(',').filter(Boolean);
|
||||
}
|
||||
|
||||
// Ensure it's an array
|
||||
if (!Array.isArray(product.product_images)) {
|
||||
product.product_images = [product.product_images].filter(Boolean);
|
||||
}
|
||||
|
||||
// Only add if the URL doesn't already exist
|
||||
if (!product.product_images.includes(imageUrl)) {
|
||||
product.product_images.push(imageUrl);
|
||||
}
|
||||
|
||||
return newData;
|
||||
};
|
||||
|
||||
// Update handleDragEnd to work with the updated product data structure
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
@@ -358,7 +434,7 @@ export const ImageUploadStep = <T extends string>({
|
||||
targetImagesAfter: filteredItems.filter(item => item.productIndex === targetProductIndex).length
|
||||
});
|
||||
|
||||
// Update both products' image_url fields - creating new objects to ensure state updates
|
||||
// Update both products' image data fields
|
||||
let updatedData = [...data]; // Start with a fresh copy
|
||||
|
||||
// First remove from source
|
||||
@@ -406,7 +482,7 @@ export const ImageUploadStep = <T extends string>({
|
||||
newItems.push(...newFilteredItems);
|
||||
|
||||
// Update the product data with the new image order
|
||||
|
||||
// Since we're just reordering, the URLs don't change, but their order might matter
|
||||
return newItems;
|
||||
}
|
||||
|
||||
@@ -427,7 +503,7 @@ export const ImageUploadStep = <T extends string>({
|
||||
newItems.push(...newFilteredItems);
|
||||
|
||||
// Update the product data with the new image order
|
||||
|
||||
// The order might matter for display purposes
|
||||
return newItems;
|
||||
});
|
||||
}
|
||||
@@ -437,45 +513,7 @@ export const ImageUploadStep = <T extends string>({
|
||||
setActiveImage(null);
|
||||
};
|
||||
|
||||
// Function to add an image URL to a product
|
||||
const addImageToProduct = (productIndex: number, imageUrl: string) => {
|
||||
// Create a copy of the data
|
||||
const newData = [...data];
|
||||
|
||||
// Get the current product
|
||||
const product = newData[productIndex];
|
||||
|
||||
// Get the current image URLs or initialize as empty array
|
||||
let currentUrls = product.image_url ?
|
||||
(typeof product.image_url === 'string' ? product.image_url.split(',') : product.image_url)
|
||||
: [];
|
||||
|
||||
// If it's not an array, convert to array
|
||||
if (!Array.isArray(currentUrls)) {
|
||||
currentUrls = [currentUrls];
|
||||
}
|
||||
|
||||
// Filter out empty values and make sure the URL doesn't already exist
|
||||
currentUrls = currentUrls.filter((url: string) => url);
|
||||
|
||||
// Only add if the URL doesn't already exist
|
||||
if (!currentUrls.includes(imageUrl)) {
|
||||
// Add the new URL
|
||||
currentUrls.push(imageUrl);
|
||||
}
|
||||
|
||||
// Update the product
|
||||
product.image_url = currentUrls.join(',');
|
||||
|
||||
// Update the data
|
||||
newData[productIndex] = product;
|
||||
|
||||
return newData;
|
||||
};
|
||||
|
||||
// Function to update product data with the new image order
|
||||
|
||||
// Function to handle image upload
|
||||
// Function to handle image upload - update product data
|
||||
const handleImageUpload = async (files: FileList | File[], productIndex: number) => {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
@@ -523,6 +561,7 @@ export const ImageUploadStep = <T extends string>({
|
||||
);
|
||||
|
||||
// Update the product data with the new image URL
|
||||
addImageToProduct(productIndex, result.imageUrl);
|
||||
|
||||
toast.success(`Image uploaded for ${data[productIndex].name || `Product #${productIndex + 1}`}`);
|
||||
} catch (error) {
|
||||
@@ -719,7 +758,7 @@ export const ImageUploadStep = <T extends string>({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Function to remove an image
|
||||
// Function to remove an image - update to work with product_images
|
||||
const removeImage = async (imageIndex: number) => {
|
||||
const image = productImages[imageIndex];
|
||||
if (!image) return;
|
||||
@@ -749,6 +788,7 @@ export const ImageUploadStep = <T extends string>({
|
||||
setProductImages(prev => prev.filter((_, idx) => idx !== imageIndex));
|
||||
|
||||
// Remove the image URL from the product data
|
||||
removeImageFromProduct(image.productIndex, image.imageUrl);
|
||||
|
||||
toast.success('Image removed successfully');
|
||||
} catch (error) {
|
||||
@@ -761,14 +801,30 @@ export const ImageUploadStep = <T extends string>({
|
||||
const handleSubmit = useCallback(async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit(data, file);
|
||||
// First, we need to ensure product_images is properly formatted for each product
|
||||
const updatedData = [...data].map((product, index) => {
|
||||
// Get all images for this product
|
||||
const images = productImages
|
||||
.filter(img => img.productIndex === index)
|
||||
.map(img => img.imageUrl)
|
||||
.filter(Boolean);
|
||||
|
||||
// Update the product with the formatted image URLs
|
||||
return {
|
||||
...product,
|
||||
// Store as comma-separated string to ensure compatibility
|
||||
product_images: images.join(',')
|
||||
};
|
||||
});
|
||||
|
||||
await onSubmit(updatedData, file);
|
||||
} catch (error) {
|
||||
console.error('Submit error:', error);
|
||||
toast.error(`Failed to submit: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [data, file, onSubmit]);
|
||||
}, [data, file, onSubmit, productImages]);
|
||||
|
||||
// Function to ensure URLs are properly formatted with absolute paths
|
||||
const getFullImageUrl = (url: string): string => {
|
||||
@@ -1173,13 +1229,140 @@ export const ImageUploadStep = <T extends string>({
|
||||
);
|
||||
};
|
||||
|
||||
// Handle adding an image from a URL
|
||||
const handleAddImageFromUrl = async (productIndex: number, url: string) => {
|
||||
if (!url || !url.trim()) {
|
||||
toast.error("Please enter a valid URL");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Set processing state
|
||||
setProcessingUrls(prev => ({ ...prev, [productIndex]: true }));
|
||||
|
||||
// Create a unique ID for this image
|
||||
const imageId = `image-${productIndex}-url-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
// Add a placeholder for this image while it's being processed
|
||||
const newImage: ProductImageSortable = {
|
||||
id: imageId,
|
||||
productIndex,
|
||||
imageUrl: url, // Use the URL directly initially
|
||||
loading: true,
|
||||
fileName: "From URL"
|
||||
};
|
||||
|
||||
setProductImages(prev => [...prev, newImage]);
|
||||
|
||||
// Call the API to validate and potentially process the URL
|
||||
const response = await fetch(`${config.apiUrl}/import/add-image-from-url`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: url,
|
||||
productIndex: productIndex,
|
||||
upc: data[productIndex].upc || '',
|
||||
supplier_no: data[productIndex].supplier_no || ''
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to add image from URL');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Update the image URL in our state
|
||||
setProductImages(prev =>
|
||||
prev.map(img =>
|
||||
img.id === imageId
|
||||
? { ...img, imageUrl: result.imageUrl || url, loading: false }
|
||||
: img
|
||||
)
|
||||
);
|
||||
|
||||
// Update the product data with the new image URL
|
||||
addImageToProduct(productIndex, result.imageUrl || url);
|
||||
|
||||
// Clear the URL input field on success
|
||||
setUrlInputs(prev => ({ ...prev, [productIndex]: '' }));
|
||||
|
||||
toast.success(`Image added from URL for ${data[productIndex].name || `Product #${productIndex + 1}`}`);
|
||||
} catch (error) {
|
||||
console.error('Add image from URL error:', error);
|
||||
|
||||
// Remove the failed image from our state
|
||||
setProductImages(prev =>
|
||||
prev.filter(img =>
|
||||
!(img.loading && img.productIndex === productIndex && img.fileName === "From URL")
|
||||
)
|
||||
);
|
||||
|
||||
toast.error(`Failed to add image from URL: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setProcessingUrls(prev => ({ ...prev, [productIndex]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Update the URL input value
|
||||
const updateUrlInput = (productIndex: number, value: string) => {
|
||||
setUrlInputs(prev => ({ ...prev, [productIndex]: value }));
|
||||
};
|
||||
|
||||
// Add a URL input component
|
||||
const ImageUrlInput = ({ productIndex }: { productIndex: number }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 flex gap-1 items-center text-xs whitespace-nowrap"
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
Add from URL
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-sm">Add image from URL</h4>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Enter image URL"
|
||||
value={urlInputs[productIndex] || ''}
|
||||
onChange={(e) => updateUrlInput(productIndex, e.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={processingUrls[productIndex] || !urlInputs[productIndex]}
|
||||
onClick={() => {
|
||||
handleAddImageFromUrl(productIndex, urlInputs[productIndex] || '');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
{processingUrls[productIndex] ?
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> :
|
||||
"Add"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-9.5rem)] overflow-hidden">
|
||||
{/* Header - fixed at top */}
|
||||
<div className="px-8 py-6 bg-background shrink-0">
|
||||
<h2 className="text-2xl font-semibold">Add Product Images</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Upload images for each product. Drag images to reorder them or move them between products.
|
||||
Upload images for each product or add them from URLs. Drag images to reorder them or move them between products.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1235,16 +1418,23 @@ export const ImageUploadStep = <T extends string>({
|
||||
>
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col lg:flex-row lg:items-baseline gap-0.5 lg:gap-4">
|
||||
<h3 className="text-base font-medium">{product.name || `Product #${index + 1}`}</h3>
|
||||
<div className="text-xs lg:text-sm text-muted-foreground">
|
||||
<span className="font-medium">UPC:</span> {product.upc || 'N/A'} |
|
||||
<span className="font-medium"> Supplier #:</span> {product.supplier_no || 'N/A'}
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-2">
|
||||
<div className="flex flex-col lg:flex-row lg:items-baseline gap-0.5 lg:gap-4">
|
||||
<h3 className="text-base font-medium">{product.name || `Product #${index + 1}`}</h3>
|
||||
<div className="text-xs lg:text-sm text-muted-foreground">
|
||||
<span className="font-medium">UPC:</span> {product.upc || 'N/A'} |
|
||||
<span className="font-medium"> Supplier #:</span> {product.supplier_no || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<ImageUrlInput productIndex={index} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<ImageDropzone productIndex={index} />
|
||||
<div className="flex flex-col sm:flex-row items-start gap-2">
|
||||
<div className="flex flex-row gap-2 items-start">
|
||||
<ImageDropzone productIndex={index} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={getProductContainerClasses(index)}
|
||||
|
||||
@@ -84,13 +84,6 @@ const BASE_IMPORT_FIELDS = [
|
||||
{ rule: "unique", errorMessage: "Must be unique", level: "error" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Image URL",
|
||||
key: "image_url",
|
||||
description: "Product image URL(s)",
|
||||
fieldType: { type: "multi-input" },
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
label: "MSRP",
|
||||
key: "msrp",
|
||||
|
||||
Reference in New Issue
Block a user