Fase 3: Soporte de Imágenes en Wizard y Backend (Upload Local)

This commit is contained in:
2025-12-17 13:56:47 -03:00
parent 1b88394b00
commit 8f535f3a6e
14 changed files with 311 additions and 3 deletions

View File

@@ -14,6 +14,7 @@
"lucide-react": "^0.561.0", "lucide-react": "^0.561.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-dropzone": "^14.3.8",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
@@ -1781,6 +1782,15 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT" "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": { "node_modules/autoprefixer": {
"version": "10.4.23", "version": "10.4.23",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
@@ -2430,6 +2440,18 @@
"node": ">=16.0.0" "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": { "node_modules/find-up": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -2791,7 +2813,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
@@ -2901,6 +2922,18 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -3018,6 +3051,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -3167,6 +3209,17 @@
"node": ">= 0.8.0" "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": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -3204,6 +3257,29 @@
"react": "^19.2.3" "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": { "node_modules/react-refresh": {
"version": "0.18.0", "version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",

View File

@@ -16,6 +16,7 @@
"lucide-react": "^0.561.0", "lucide-react": "^0.561.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-dropzone": "^14.3.8",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },

View File

@@ -3,6 +3,7 @@ import { useWizardStore } from './store/wizardStore';
import CategorySelection from './pages/Steps/CategorySelection'; import CategorySelection from './pages/Steps/CategorySelection';
import OperationSelection from './pages/Steps/OperationSelection'; import OperationSelection from './pages/Steps/OperationSelection';
import AttributeForm from './pages/Steps/AttributeForm'; import AttributeForm from './pages/Steps/AttributeForm';
import PhotoUploadStep from './pages/Steps/PhotoUploadStep';
import SummaryStep from './pages/Steps/SummaryStep'; import SummaryStep from './pages/Steps/SummaryStep';
import { wizardService } from './services/wizardService'; import { wizardService } from './services/wizardService';
import type { AttributeDefinition } from './types'; import type { AttributeDefinition } from './types';
@@ -41,7 +42,7 @@ function App() {
{step === 1 && <CategorySelection />} {step === 1 && <CategorySelection />}
{step === 2 && <OperationSelection />} {step === 2 && <OperationSelection />}
{step === 3 && <AttributeForm />} {step === 3 && <AttributeForm />}
{step === 4 && <div className="text-center py-20">Paso 4: Fotos (Coming Soon) - <button onClick={() => useWizardStore.getState().setStep(5)} className="text-blue-500 underline">Saltar</button></div>} {step === 4 && <PhotoUploadStep />}
{step === 5 && <SummaryStep definitions={definitions} />} {step === 5 && <SummaryStep definitions={definitions} />}
</main> </main>
</div> </div>

View File

@@ -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 (
<StepWrapper>
<h2 className="text-2xl font-bold mb-2 text-brand-900">Fotos del Aviso</h2>
<p className="text-slate-500 mb-6">Muestra lo mejor de tu producto. Primera foto es la portada.</p>
<DropzoneArea />
<div className="mt-8 flex justify-end">
<button
onClick={() => setStep(5)}
className="bg-brand-600 text-white px-6 py-3 rounded-lg font-bold hover:bg-brand-700"
>
Continuar
</button>
</div>
</StepWrapper>
);
}
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 (
<div>
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-colors
${isDragActive ? 'border-brand-500 bg-brand-50' : 'border-slate-300 hover:border-brand-400'}`}
>
<input {...getInputProps()} />
<div className="flex flex-col items-center gap-2 text-slate-500">
<Upload size={40} className="text-slate-400" />
<p>Arrastra fotos aquí, o click para seleccionar</p>
<p className="text-xs">Soporta JPG, PNG, WEBP</p>
</div>
</div>
{/* Previews */}
<div className="grid grid-cols-3 sm:grid-cols-4 gap-4 mt-6">
{photos.map((file: File, index: number) => (
<div key={index} className="relative aspect-square bg-slate-100 rounded-lg overflow-hidden border border-slate-200 group">
<img
src={URL.createObjectURL(file)}
alt="preview"
className="w-full h-full object-cover"
/>
<button
onClick={() => removePhoto(index)}
className="absolute top-1 right-1 bg-black/50 text-white p-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
<X size={14} />
</button>
{index === 0 && (
<div className="absolute bottom-0 left-0 right-0 bg-brand-600/80 text-white text-xs py-1 text-center">
Portada
</div>
)}
</div>
))}
</div>
</div>
);
}

View File

@@ -37,6 +37,15 @@ export default function SummaryStep({ definitions }: { definitions: AttributeDef
}; };
const result = await wizardService.createListing(payload); 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); setCreatedId(result.id);
} catch (error) { } catch (error) {
console.error(error); console.error(error);

View File

@@ -20,5 +20,14 @@ export const wizardService = {
createListing: async (data: any): Promise<{ id: number }> => { createListing: async (data: any): Promise<{ id: number }> => {
const response = await api.post<{ id: number }>('/listings', data); const response = await api.post<{ id: number }>('/listings', data);
return response.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;
} }
}; };

View File

@@ -6,10 +6,13 @@ interface WizardState {
selectedCategory: Category | null; selectedCategory: Category | null;
selectedOperation: Operation | null; selectedOperation: Operation | null;
attributes: Record<string, any>; attributes: Record<string, any>;
photos: File[]; // Added
setStep: (step: number) => void; setStep: (step: number) => void;
setCategory: (category: Category) => void; setCategory: (category: Category) => void;
setOperation: (operation: Operation) => void; setOperation: (operation: Operation) => void;
setAttribute: (key: string, value: any) => void; setAttribute: (key: string, value: any) => void;
addPhoto: (file: File) => void; // Added
removePhoto: (index: number) => void; // Added
reset: () => void; reset: () => void;
} }
@@ -18,11 +21,14 @@ export const useWizardStore = create<WizardState>((set) => ({
selectedCategory: null, selectedCategory: null,
selectedOperation: null, selectedOperation: null,
attributes: {}, attributes: {},
photos: [],
setStep: (step) => set({ step }), setStep: (step) => set({ step }),
setCategory: (category) => set({ selectedCategory: category, step: 2 }), // Auto advance setCategory: (category) => set({ selectedCategory: category, step: 2 }), // Auto advance
setOperation: (operation) => set({ selectedOperation: operation, step: 3 }), // Auto advance setOperation: (operation) => set({ selectedOperation: operation, step: 3 }), // Auto advance
setAttribute: (key, value) => set((state) => ({ setAttribute: (key, value) => set((state) => ({
attributes: { ...state.attributes, [key]: value } 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: [] }),
})); }));

View File

@@ -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<IActionResult> 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 });
}
}

View File

@@ -31,6 +31,7 @@ if (app.Environment.IsDevelopment())
} }
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseStaticFiles(); // Enable static files for images
app.UseCors("AllowFrontend"); app.UseCors("AllowFrontend");

View File

@@ -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; }
}

View File

@@ -0,0 +1,9 @@
using SIGCM.Domain.Entities;
namespace SIGCM.Domain.Interfaces;
public interface IImageRepository
{
Task AddAsync(ListingImage image);
Task<IEnumerable<ListingImage>> GetByListingIdAsync(int listingId);
}

View File

@@ -154,6 +154,18 @@ BEGIN
FOREIGN KEY (AttributeDefinitionId) REFERENCES AttributeDefinitions(Id) ON DELETE NO ACTION FOREIGN KEY (AttributeDefinitionId) REFERENCES AttributeDefinitions(Id) ON DELETE NO ACTION
); );
END 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); await connection.ExecuteAsync(schemaSql);
} }

View File

@@ -19,6 +19,7 @@ public static class DependencyInjection
services.AddScoped<IAuthService, Services.AuthService>(); services.AddScoped<IAuthService, Services.AuthService>();
services.AddScoped<IAttributeDefinitionRepository, AttributeDefinitionRepository>(); services.AddScoped<IAttributeDefinitionRepository, AttributeDefinitionRepository>();
services.AddScoped<IListingRepository, ListingRepository>(); services.AddScoped<IListingRepository, ListingRepository>();
services.AddScoped<IImageRepository, ImageRepository>();
return services; return services;
} }
} }

View File

@@ -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<IEnumerable<ListingImage>> GetByListingIdAsync(int listingId)
{
using var conn = _connectionFactory.CreateConnection();
return await conn.QueryAsync<ListingImage>(
"SELECT * FROM ListingImages WHERE ListingId = @ListingId ORDER BY DisplayOrder",
new { ListingId = listingId });
}
}