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",
"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",

View File

@@ -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"
},

View File

@@ -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 && <CategorySelection />}
{step === 2 && <OperationSelection />}
{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} />}
</main>
</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);
// 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);

View File

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

View File

@@ -6,10 +6,13 @@ interface WizardState {
selectedCategory: Category | null;
selectedOperation: Operation | null;
attributes: Record<string, any>;
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<WizardState>((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: [] }),
}));