diff --git a/frontend/publish-wizard/package-lock.json b/frontend/publish-wizard/package-lock.json
index c915e6c..2cbf0f7 100644
--- a/frontend/publish-wizard/package-lock.json
+++ b/frontend/publish-wizard/package-lock.json
@@ -14,6 +14,7 @@
"lucide-react": "^0.561.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
+ "react-dropzone": "^14.3.8",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.9"
},
@@ -1781,6 +1782,15 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
+ "node_modules/attr-accept": {
+ "version": "2.2.5",
+ "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
+ "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/autoprefixer": {
"version": "10.4.23",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
@@ -2430,6 +2440,18 @@
"node": ">=16.0.0"
}
},
+ "node_modules/file-selector": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
+ "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.7.0"
+ },
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -2791,7 +2813,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -2901,6 +2922,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -3018,6 +3051,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -3167,6 +3209,17 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -3204,6 +3257,29 @@
"react": "^19.2.3"
}
},
+ "node_modules/react-dropzone": {
+ "version": "14.3.8",
+ "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz",
+ "integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==",
+ "license": "MIT",
+ "dependencies": {
+ "attr-accept": "^2.2.4",
+ "file-selector": "^2.1.0",
+ "prop-types": "^15.8.1"
+ },
+ "engines": {
+ "node": ">= 10.13"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8 || 18.0.0"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
diff --git a/frontend/publish-wizard/package.json b/frontend/publish-wizard/package.json
index 67c212f..80255ea 100644
--- a/frontend/publish-wizard/package.json
+++ b/frontend/publish-wizard/package.json
@@ -16,6 +16,7 @@
"lucide-react": "^0.561.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
+ "react-dropzone": "^14.3.8",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.9"
},
diff --git a/frontend/publish-wizard/src/App.tsx b/frontend/publish-wizard/src/App.tsx
index e86c97c..817e5d1 100644
--- a/frontend/publish-wizard/src/App.tsx
+++ b/frontend/publish-wizard/src/App.tsx
@@ -3,6 +3,7 @@ import { useWizardStore } from './store/wizardStore';
import CategorySelection from './pages/Steps/CategorySelection';
import OperationSelection from './pages/Steps/OperationSelection';
import AttributeForm from './pages/Steps/AttributeForm';
+import PhotoUploadStep from './pages/Steps/PhotoUploadStep';
import SummaryStep from './pages/Steps/SummaryStep';
import { wizardService } from './services/wizardService';
import type { AttributeDefinition } from './types';
@@ -41,7 +42,7 @@ function App() {
{step === 1 && }
{step === 2 && }
{step === 3 && }
- {step === 4 &&
Paso 4: Fotos (Coming Soon) -
}
+ {step === 4 && }
{step === 5 && }
diff --git a/frontend/publish-wizard/src/pages/Steps/PhotoUploadStep.tsx b/frontend/publish-wizard/src/pages/Steps/PhotoUploadStep.tsx
new file mode 100644
index 0000000..68ab0dd
--- /dev/null
+++ b/frontend/publish-wizard/src/pages/Steps/PhotoUploadStep.tsx
@@ -0,0 +1,84 @@
+import { useCallback } from 'react';
+import { useDropzone } from 'react-dropzone';
+import { StepWrapper } from '../../components/StepWrapper';
+import { useWizardStore } from '../../store/wizardStore';
+import { Upload, X } from 'lucide-react';
+
+export default function PhotoUploadStep() {
+ const { setStep } = useWizardStore();
+
+ return (
+
+ Fotos del Aviso
+ Muestra lo mejor de tu producto. Primera foto es la portada.
+
+
+
+
+
+
+
+ );
+}
+
+function DropzoneArea() {
+ const { addPhoto, removePhoto, photos } = useWizardStore();
+
+ const onDrop = useCallback((acceptedFiles: File[]) => {
+ acceptedFiles.forEach(file => {
+ addPhoto(file);
+ });
+ }, [addPhoto]);
+
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
+ onDrop,
+ accept: { 'image/*': [] },
+ maxFiles: 10
+ });
+
+ return (
+
+
+
+
+
+
Arrastra fotos aquĆ, o click para seleccionar
+
Soporta JPG, PNG, WEBP
+
+
+
+ {/* Previews */}
+
+ {photos.map((file: File, index: number) => (
+
+
})
+
+ {index === 0 && (
+
+ Portada
+
+ )}
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/publish-wizard/src/pages/Steps/SummaryStep.tsx b/frontend/publish-wizard/src/pages/Steps/SummaryStep.tsx
index c5bbf28..398d055 100644
--- a/frontend/publish-wizard/src/pages/Steps/SummaryStep.tsx
+++ b/frontend/publish-wizard/src/pages/Steps/SummaryStep.tsx
@@ -37,6 +37,15 @@ export default function SummaryStep({ definitions }: { definitions: AttributeDef
};
const result = await wizardService.createListing(payload);
+
+ // Upload Images
+ const { photos } = useWizardStore.getState();
+ if (photos.length > 0) {
+ for (const photo of photos) {
+ await wizardService.uploadImage(result.id, photo);
+ }
+ }
+
setCreatedId(result.id);
} catch (error) {
console.error(error);
diff --git a/frontend/publish-wizard/src/services/wizardService.ts b/frontend/publish-wizard/src/services/wizardService.ts
index 8459e68..425d047 100644
--- a/frontend/publish-wizard/src/services/wizardService.ts
+++ b/frontend/publish-wizard/src/services/wizardService.ts
@@ -20,5 +20,14 @@ export const wizardService = {
createListing: async (data: any): Promise<{ id: number }> => {
const response = await api.post<{ id: number }>('/listings', data);
return response.data;
+ },
+
+ uploadImage: async (listingId: number, file: File): Promise<{ url: string }> => {
+ const formData = new FormData();
+ formData.append('file', file);
+ const response = await api.post<{ url: string }>(`/images/upload/${listingId}`, formData, {
+ headers: { 'Content-Type': 'multipart/form-data' }
+ });
+ return response.data;
}
};
diff --git a/frontend/publish-wizard/src/store/wizardStore.ts b/frontend/publish-wizard/src/store/wizardStore.ts
index 97ae685..c50718d 100644
--- a/frontend/publish-wizard/src/store/wizardStore.ts
+++ b/frontend/publish-wizard/src/store/wizardStore.ts
@@ -6,10 +6,13 @@ interface WizardState {
selectedCategory: Category | null;
selectedOperation: Operation | null;
attributes: Record;
+ photos: File[]; // Added
setStep: (step: number) => void;
setCategory: (category: Category) => void;
setOperation: (operation: Operation) => void;
setAttribute: (key: string, value: any) => void;
+ addPhoto: (file: File) => void; // Added
+ removePhoto: (index: number) => void; // Added
reset: () => void;
}
@@ -18,11 +21,14 @@ export const useWizardStore = create((set) => ({
selectedCategory: null,
selectedOperation: null,
attributes: {},
+ photos: [],
setStep: (step) => set({ step }),
setCategory: (category) => set({ selectedCategory: category, step: 2 }), // Auto advance
setOperation: (operation) => set({ selectedOperation: operation, step: 3 }), // Auto advance
setAttribute: (key, value) => set((state) => ({
attributes: { ...state.attributes, [key]: value }
})),
- reset: () => set({ step: 1, selectedCategory: null, selectedOperation: null, attributes: {} }),
+ addPhoto: (file) => set((state) => ({ photos: [...state.photos, file] })),
+ removePhoto: (index) => set((state) => ({ photos: state.photos.filter((_, i) => i !== index) })),
+ reset: () => set({ step: 1, selectedCategory: null, selectedOperation: null, attributes: {}, photos: [] }),
}));
diff --git a/src/SIGCM.API/Controllers/ImagesController.cs b/src/SIGCM.API/Controllers/ImagesController.cs
new file mode 100644
index 0000000..b9e4d50
--- /dev/null
+++ b/src/SIGCM.API/Controllers/ImagesController.cs
@@ -0,0 +1,56 @@
+using Microsoft.AspNetCore.Mvc;
+using SIGCM.Domain.Entities;
+using SIGCM.Domain.Interfaces;
+
+namespace SIGCM.API.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public class ImagesController : ControllerBase
+{
+ private readonly IImageRepository _repository;
+ private readonly IWebHostEnvironment _env;
+
+ public ImagesController(IImageRepository repository, IWebHostEnvironment env)
+ {
+ _repository = repository;
+ _env = env;
+ }
+
+ [HttpPost("upload/{listingId}")]
+ public async Task Upload(int listingId, IFormFile file)
+ {
+ if (file == null || file.Length == 0) return BadRequest("File is empty");
+
+ // Basic validation
+ var allowedExtensions = new[] { ".jpg", ".jpeg", ".png", ".webp" };
+ var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
+ if (!allowedExtensions.Contains(ext)) return BadRequest("Invalid file type");
+
+ // Ensure directory exists
+ var uploadDir = Path.Combine(_env.WebRootPath, "uploads", "listings", listingId.ToString());
+ Directory.CreateDirectory(uploadDir);
+
+ // Save file
+ var fileName = $"{Guid.NewGuid()}{ext}";
+ var filePath = Path.Combine(uploadDir, fileName);
+
+ using (var stream = new FileStream(filePath, FileMode.Create))
+ {
+ await file.CopyToAsync(stream);
+ }
+
+ // Save metadata
+ var relativeUrl = $"/uploads/listings/{listingId}/{fileName}";
+ var image = new ListingImage
+ {
+ ListingId = listingId,
+ Url = relativeUrl,
+ IsMainInfo = false, // Logic to set first as main could be added here
+ DisplayOrder = 0
+ };
+ await _repository.AddAsync(image);
+
+ return Ok(new { Url = relativeUrl });
+ }
+}
diff --git a/src/SIGCM.API/Program.cs b/src/SIGCM.API/Program.cs
index ef5c8b9..3b6c6b0 100644
--- a/src/SIGCM.API/Program.cs
+++ b/src/SIGCM.API/Program.cs
@@ -31,6 +31,7 @@ if (app.Environment.IsDevelopment())
}
app.UseHttpsRedirection();
+app.UseStaticFiles(); // Enable static files for images
app.UseCors("AllowFrontend");
diff --git a/src/SIGCM.Domain/Entities/ListingImage.cs b/src/SIGCM.Domain/Entities/ListingImage.cs
new file mode 100644
index 0000000..bac3f9b
--- /dev/null
+++ b/src/SIGCM.Domain/Entities/ListingImage.cs
@@ -0,0 +1,10 @@
+namespace SIGCM.Domain.Entities;
+
+public class ListingImage
+{
+ public int Id { get; set; }
+ public int ListingId { get; set; }
+ public required string Url { get; set; }
+ public bool IsMainInfo { get; set; }
+ public int DisplayOrder { get; set; }
+}
diff --git a/src/SIGCM.Domain/Interfaces/IImageRepository.cs b/src/SIGCM.Domain/Interfaces/IImageRepository.cs
new file mode 100644
index 0000000..2510ed3
--- /dev/null
+++ b/src/SIGCM.Domain/Interfaces/IImageRepository.cs
@@ -0,0 +1,9 @@
+using SIGCM.Domain.Entities;
+
+namespace SIGCM.Domain.Interfaces;
+
+public interface IImageRepository
+{
+ Task AddAsync(ListingImage image);
+ Task> GetByListingIdAsync(int listingId);
+}
diff --git a/src/SIGCM.Infrastructure/Data/DbInitializer.cs b/src/SIGCM.Infrastructure/Data/DbInitializer.cs
index b28cbcb..4679c4e 100644
--- a/src/SIGCM.Infrastructure/Data/DbInitializer.cs
+++ b/src/SIGCM.Infrastructure/Data/DbInitializer.cs
@@ -154,6 +154,18 @@ BEGIN
FOREIGN KEY (AttributeDefinitionId) REFERENCES AttributeDefinitions(Id) ON DELETE NO ACTION
);
END
+
+IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'ListingImages')
+BEGIN
+ CREATE TABLE ListingImages (
+ Id INT IDENTITY(1,1) PRIMARY KEY,
+ ListingId INT NOT NULL,
+ Url NVARCHAR(500) NOT NULL,
+ IsMainInfo BIT DEFAULT 0,
+ DisplayOrder INT DEFAULT 0,
+ FOREIGN KEY (ListingId) REFERENCES Listings(Id) ON DELETE CASCADE
+ );
+END
";
await connection.ExecuteAsync(schemaSql);
}
diff --git a/src/SIGCM.Infrastructure/DependencyInjection.cs b/src/SIGCM.Infrastructure/DependencyInjection.cs
index 9dbe5e1..b80daa1 100644
--- a/src/SIGCM.Infrastructure/DependencyInjection.cs
+++ b/src/SIGCM.Infrastructure/DependencyInjection.cs
@@ -19,6 +19,7 @@ public static class DependencyInjection
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
return services;
}
}
diff --git a/src/SIGCM.Infrastructure/Repositories/ImageRepository.cs b/src/SIGCM.Infrastructure/Repositories/ImageRepository.cs
new file mode 100644
index 0000000..a7f727d
--- /dev/null
+++ b/src/SIGCM.Infrastructure/Repositories/ImageRepository.cs
@@ -0,0 +1,33 @@
+using Dapper;
+using SIGCM.Domain.Entities;
+using SIGCM.Domain.Interfaces;
+using SIGCM.Infrastructure.Data;
+
+namespace SIGCM.Infrastructure.Repositories;
+
+public class ImageRepository : IImageRepository
+{
+ private readonly IDbConnectionFactory _connectionFactory;
+
+ public ImageRepository(IDbConnectionFactory connectionFactory)
+ {
+ _connectionFactory = connectionFactory;
+ }
+
+ public async Task AddAsync(ListingImage image)
+ {
+ using var conn = _connectionFactory.CreateConnection();
+ var sql = @"
+ INSERT INTO ListingImages (ListingId, Url, IsMainInfo, DisplayOrder)
+ VALUES (@ListingId, @Url, @IsMainInfo, @DisplayOrder)";
+ await conn.ExecuteAsync(sql, image);
+ }
+
+ public async Task> GetByListingIdAsync(int listingId)
+ {
+ using var conn = _connectionFactory.CreateConnection();
+ return await conn.QueryAsync(
+ "SELECT * FROM ListingImages WHERE ListingId = @ListingId ORDER BY DisplayOrder",
+ new { ListingId = listingId });
+ }
+}