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) => ( +
+ preview + + {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 }); + } +}