Feat: Cambios Varios 2
This commit is contained in:
134
frontend/public-web/src/pages/Steps/AttributeForm.tsx
Normal file
134
frontend/public-web/src/pages/Steps/AttributeForm.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { wizardService } from '../../services/wizardService';
|
||||
import { useWizardStore } from '../../store/wizardStore';
|
||||
import type { AttributeDefinition } from '../../types';
|
||||
import { StepWrapper } from '../../components/StepWrapper';
|
||||
import { ChevronRight, Info, Tag, PenTool, DollarSign } from 'lucide-react';
|
||||
|
||||
export default function AttributeForm() {
|
||||
const { selectedCategory, selectedOperation, attributes, setAttribute, setStep } = useWizardStore();
|
||||
const [definitions, setDefinitions] = useState<AttributeDefinition[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// CAMBIO: Verificamos específicamente que exista el ID para evitar el GET /undefined
|
||||
if (selectedCategory?.id) {
|
||||
wizardService.getAttributes(selectedCategory.id)
|
||||
.then(setDefinitions)
|
||||
.catch(err => console.error("Error al obtener atributos:", err));
|
||||
}
|
||||
}, [selectedCategory?.id]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setStep(4);
|
||||
};
|
||||
|
||||
return (
|
||||
<StepWrapper>
|
||||
{/* Breadcrumbs */}
|
||||
<div className="flex items-center gap-3 mb-8 px-1">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-slate-400">
|
||||
<button onClick={() => setStep(1)} className="hover:text-blue-600 transition-colors">
|
||||
{selectedCategory?.name}
|
||||
</button>
|
||||
<ChevronRight size={10} className="opacity-30" />
|
||||
<button onClick={() => setStep(2)} className="hover:text-blue-600 transition-colors">
|
||||
{selectedOperation?.name}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-10">
|
||||
<h2 className="text-4xl font-black text-slate-900 tracking-tighter uppercase leading-none">
|
||||
Características <br />
|
||||
<span className="text-blue-600">del Anuncio</span>
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm mt-3 font-medium">Completa los datos técnicos para mejorar la visibilidad.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* GRUPO: INFORMACIÓN BÁSICA */}
|
||||
<div className="space-y-6 bg-white p-8 rounded-[2.5rem] border border-slate-100 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-blue-600 mb-2">
|
||||
<PenTool size={16} />
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em]">Información General</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest ml-1">Título del Aviso</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="w-full p-4 bg-slate-50 border-2 border-slate-100 rounded-2xl outline-none focus:border-blue-500 focus:bg-white transition-all font-bold text-slate-700 text-sm"
|
||||
onChange={(e) => setAttribute('title', e.target.value)}
|
||||
value={attributes['title'] || ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest ml-1">Precio de Venta</label>
|
||||
<div className="relative flex items-center">
|
||||
{/* Icono de moneda separado del texto */}
|
||||
<div className="absolute left-4 text-slate-400 font-black text-lg pointer-events-none">
|
||||
<DollarSign size={18} />
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
/* Aumentamos pl-12 para evitar superposición */
|
||||
className="w-full p-4 pl-12 bg-slate-50 border-2 border-slate-100 rounded-2xl outline-none focus:border-blue-500 focus:bg-white transition-all font-mono font-black text-slate-800 text-lg"
|
||||
onChange={(e) => setAttribute('price', e.target.value)}
|
||||
value={attributes['price'] || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GRUPO: ESPECIFICACIONES TÉCNICAS */}
|
||||
{definitions.length > 0 && (
|
||||
<div className="space-y-6 bg-slate-50/50 p-8 rounded-[2.5rem] border-2 border-slate-100">
|
||||
<div className="flex items-center gap-2 text-slate-500 mb-2">
|
||||
<Tag size={16} />
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em]">Ficha Técnica</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{definitions.map(def => (
|
||||
<div key={def.id} className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest ml-1">
|
||||
{def.name} {def.required && <span className="text-rose-500">*</span>}
|
||||
</label>
|
||||
|
||||
<input
|
||||
type={def.dataType === 'number' ? 'number' : 'text'}
|
||||
required={def.required}
|
||||
placeholder={`Ingrese ${def.name.toLowerCase()}...`}
|
||||
className="w-full p-4 bg-white border-2 border-slate-100 rounded-2xl outline-none focus:border-blue-500 transition-all font-bold text-slate-700 text-sm placeholder:text-slate-200 shadow-sm"
|
||||
value={attributes[def.name.toLowerCase()] || ''}
|
||||
onChange={(e) => setAttribute(def.name.toLowerCase(), e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6 bg-blue-50 rounded-3xl flex gap-4 border border-blue-100 items-start">
|
||||
<Info className="text-blue-500 shrink-0" size={20} />
|
||||
<p className="text-[10px] text-blue-700 font-bold leading-relaxed uppercase opacity-80">
|
||||
Los datos cargados aquí sirven para filtrar las búsquedas en el portal digital.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* BOTÓN CON ALTO CONTRASTE */}
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-slate-950 hover:bg-black text-white font-black uppercase text-xs tracking-[0.2em] py-6 rounded-[2rem] shadow-2xl shadow-slate-300 transition-all mt-6 active:scale-[0.98] flex items-center justify-center gap-3 group"
|
||||
>
|
||||
Continuar al Editor de Texto
|
||||
<ChevronRight size={18} className="group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</form>
|
||||
</StepWrapper>
|
||||
);
|
||||
}
|
||||
63
frontend/public-web/src/pages/Steps/CategorySelection.tsx
Normal file
63
frontend/public-web/src/pages/Steps/CategorySelection.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { wizardService } from '../../services/wizardService';
|
||||
import { useWizardStore } from '../../store/wizardStore';
|
||||
import type { Category } from '../../types'; // ID: type-import-fix
|
||||
import { StepWrapper } from '../../components/StepWrapper';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
export default function CategoryParams() {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [navStack, setNavStack] = useState<Category[]>([]); // For hierarchical navigation
|
||||
const setCategory = useWizardStore(state => state.setCategory);
|
||||
|
||||
useEffect(() => {
|
||||
wizardService.getCategories().then(setCategories);
|
||||
}, []);
|
||||
|
||||
// Filter categories dependent on current parent
|
||||
const currentParentId = navStack.length > 0 ? navStack[navStack.length - 1].id : null;
|
||||
const displayedCategories = categories.filter(c => c.parentId === currentParentId);
|
||||
|
||||
const handleSelect = (category: Category) => {
|
||||
const hasChildren = categories.some(c => c.parentId === category.id);
|
||||
if (hasChildren) {
|
||||
setNavStack([...navStack, category]);
|
||||
} else {
|
||||
setCategory(category);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (navStack.length > 0) {
|
||||
setNavStack(navStack.slice(0, -1));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StepWrapper>
|
||||
<h2 className="text-2xl font-bold mb-6 text-brand-900">¿Qué deseas publicar?</h2>
|
||||
|
||||
{navStack.length > 0 && (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="mb-4 text-sm text-brand-600 hover:text-brand-800 flex items-center gap-1"
|
||||
>
|
||||
← Volver a {navStack.length > 1 ? navStack[navStack.length - 2].name : 'Inicio'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3">
|
||||
{displayedCategories.map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => handleSelect(cat)}
|
||||
className="group flex items-center justify-between p-4 bg-white border border-slate-200 rounded-lg shadow-sm hover:border-brand-500 hover:ring-1 hover:ring-brand-500 transition-all text-left"
|
||||
>
|
||||
<span className="font-medium text-slate-700 group-hover:text-brand-700">{cat.name}</span>
|
||||
<ChevronRight className="text-slate-400 group-hover:text-brand-500" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</StepWrapper>
|
||||
);
|
||||
}
|
||||
42
frontend/public-web/src/pages/Steps/OperationSelection.tsx
Normal file
42
frontend/public-web/src/pages/Steps/OperationSelection.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { wizardService } from '../../services/wizardService';
|
||||
import { useWizardStore } from '../../store/wizardStore';
|
||||
import type { Operation } from '../../types';
|
||||
import { StepWrapper } from '../../components/StepWrapper';
|
||||
|
||||
export default function OperationSelection() {
|
||||
const { selectedCategory, setOperation, setStep } = useWizardStore();
|
||||
const [operations, setOperations] = useState<Operation[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCategory) {
|
||||
wizardService.getOperations(selectedCategory.id).then(setOperations);
|
||||
}
|
||||
}, [selectedCategory]);
|
||||
|
||||
return (
|
||||
<StepWrapper>
|
||||
<button onClick={() => setStep(1)} className="mb-4 text-brand-600 font-medium text-sm">Cambiar Categoría</button>
|
||||
<h2 className="text-2xl font-bold mb-2 text-brand-900">Tipo de Operación</h2>
|
||||
<p className="text-slate-500 mb-6">Seleccionaste: <span className="font-semibold text-brand-700">{selectedCategory?.name}</span></p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{operations.length === 0 ? (
|
||||
<div className="col-span-2 text-center py-8 bg-white rounded border border-yellow-200 bg-yellow-50 text-yellow-800">
|
||||
No hay operaciones disponibles para este rubro.
|
||||
</div>
|
||||
) : (
|
||||
operations.map(op => (
|
||||
<button
|
||||
key={op.id}
|
||||
onClick={() => setOperation(op)}
|
||||
className="p-6 bg-white border border-slate-200 rounded-xl hover:border-brand-500 hover:bg-brand-50 hover:text-brand-700 transition font-semibold text-lg shadow-sm"
|
||||
>
|
||||
{op.name}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</StepWrapper>
|
||||
);
|
||||
}
|
||||
107
frontend/public-web/src/pages/Steps/PhotoUploadStep.tsx
Normal file
107
frontend/public-web/src/pages/Steps/PhotoUploadStep.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { StepWrapper } from '../../components/StepWrapper';
|
||||
import { useWizardStore } from '../../store/wizardStore';
|
||||
import { Upload, Plus, Trash2, ArrowLeft, ChevronRight } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function PhotoUploadStep() {
|
||||
const { setStep, photos, existingImages, removePhoto, removeExistingImage, addPhoto } = useWizardStore();
|
||||
const baseUrl = import.meta.env.VITE_BASE_URL;
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
acceptedFiles.forEach(file => addPhoto(file));
|
||||
}, [addPhoto]);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: { 'image/*': [] },
|
||||
maxFiles: 10
|
||||
});
|
||||
|
||||
return (
|
||||
<StepWrapper>
|
||||
<div className="mb-10 text-center">
|
||||
<h2 className="text-4xl font-black text-slate-900 tracking-tighter uppercase leading-none">
|
||||
Galería de <br />
|
||||
<span className="text-blue-600">Imágenes</span>
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm mt-3 font-medium">Gestiona las fotos de tu aviso. La primera será la portada.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-10">
|
||||
|
||||
{/* GRILLA DE FOTOS (EXISTENTES + NUEVAS) */}
|
||||
{(existingImages.length > 0 || photos.length > 0) && (
|
||||
<section className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
|
||||
{/* Renderizado de Fotos Heredadas */}
|
||||
{existingImages.map((img, idx) => (
|
||||
<div key={`old-${idx}`} className="group relative aspect-square bg-slate-100 rounded-[2rem] overflow-hidden border-2 border-slate-100 shadow-sm transition-all hover:shadow-xl">
|
||||
<img src={`${baseUrl}${img.url}`} className="w-full h-full object-cover" alt="Original" />
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<button onClick={() => removeExistingImage(idx)} className="bg-rose-500 text-white p-3 rounded-2xl hover:bg-rose-600 transition-colors shadow-lg">
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
{idx === 0 && (
|
||||
<div className="absolute top-3 left-3 bg-blue-600 text-white text-[8px] font-black uppercase px-2 py-1 rounded-lg shadow-lg">Portada</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Renderizado de Fotos Nuevas (Files) */}
|
||||
{photos.map((file, idx) => (
|
||||
<div key={`new-${idx}`} className="group relative aspect-square bg-blue-50 rounded-[2rem] overflow-hidden border-2 border-blue-200 shadow-sm transition-all hover:shadow-xl">
|
||||
<img src={URL.createObjectURL(file)} className="w-full h-full object-cover" alt="Nueva" />
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<button onClick={() => removePhoto(idx)} className="bg-rose-500 text-white p-3 rounded-2xl shadow-lg">
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
{existingImages.length === 0 && idx === 0 && (
|
||||
<div className="absolute top-3 left-3 bg-blue-600 text-white text-[8px] font-black uppercase px-2 py-1 rounded-lg shadow-lg">Portada</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* BOTÓN "AÑADIR MÁS" dentro de la grilla si ya hay fotos */}
|
||||
<div {...getRootProps()} className="aspect-square bg-slate-50 border-2 border-dashed border-slate-200 rounded-[2rem] flex flex-col items-center justify-center cursor-pointer hover:bg-white hover:border-blue-400 transition-all group">
|
||||
<input {...getInputProps()} />
|
||||
<div className="p-3 bg-white rounded-2xl shadow-sm text-slate-400 group-hover:text-blue-500 group-hover:shadow-blue-100 transition-all">
|
||||
<Plus size={24} />
|
||||
</div>
|
||||
<span className="text-[9px] font-black text-slate-400 uppercase mt-2 group-hover:text-blue-600">Añadir más</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ÁREA DE CARGA INICIAL (Solo si está todo vacío) */}
|
||||
{existingImages.length === 0 && photos.length === 0 && (
|
||||
<section {...getRootProps()} className={clsx(
|
||||
"p-16 border-4 border-dashed rounded-[3rem] text-center transition-all cursor-pointer group",
|
||||
isDragActive ? "border-blue-500 bg-blue-50" : "border-slate-100 bg-white hover:border-slate-200 hover:bg-slate-50"
|
||||
)}>
|
||||
<input {...getInputProps()} />
|
||||
<div className="w-20 h-20 bg-blue-50 text-blue-600 rounded-[2rem] flex items-center justify-center mx-auto mb-6 group-hover:scale-110 transition-transform">
|
||||
<Upload size={32} />
|
||||
</div>
|
||||
<h3 className="text-xl font-black text-slate-900 uppercase tracking-tighter mb-2">Sube tus fotografías</h3>
|
||||
<p className="text-slate-400 text-xs font-medium max-w-xs mx-auto leading-relaxed">
|
||||
Arrastra tus fotos aquí o haz clic para buscarlas. Soporta JPG, PNG y WebP.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4 pt-6">
|
||||
<button onClick={() => setStep(4)} className="flex-1 py-5 bg-slate-100 text-slate-500 font-black uppercase text-xs rounded-2xl hover:bg-slate-200 transition-all flex items-center justify-center gap-3">
|
||||
<ArrowLeft size={16} /> Volver
|
||||
</button>
|
||||
<button onClick={() => setStep(6)} className="flex-[2] py-5 bg-blue-600 text-white font-black uppercase text-xs rounded-2xl shadow-xl shadow-blue-200 hover:bg-blue-700 transition-all flex items-center justify-center gap-3">
|
||||
Continuar al Resumen <ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</StepWrapper>
|
||||
);
|
||||
}
|
||||
291
frontend/public-web/src/pages/Steps/SummaryStep.tsx
Normal file
291
frontend/public-web/src/pages/Steps/SummaryStep.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import { useState } from 'react';
|
||||
import { useWizardStore } from '../../store/wizardStore';
|
||||
import { wizardService } from '../../services/wizardService';
|
||||
import { StepWrapper } from '../../components/StepWrapper';
|
||||
import type { AttributeDefinition } from '../../types';
|
||||
import {
|
||||
CreditCard,
|
||||
Wallet,
|
||||
ChevronRight,
|
||||
ArrowLeft,
|
||||
Tag,
|
||||
FileText,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
import { initMercadoPago, Wallet as MPWallet } from '@mercadopago/sdk-react';
|
||||
import clsx from 'clsx';
|
||||
import api from '../../services/api';
|
||||
|
||||
// Se inicializa con la Public Key desde env
|
||||
initMercadoPago(import.meta.env.VITE_MP_PUBLIC_KEY);
|
||||
|
||||
export default function SummaryStep({ definitions }: { definitions: AttributeDefinition[] }) {
|
||||
const { selectedCategory, selectedOperation, attributes, photos, setStep, reset } = useWizardStore();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [preferenceId, setPreferenceId] = useState<string | null>(null);
|
||||
const [createdId, setCreatedId] = useState<number | null>(null);
|
||||
const [paymentMethod, setPaymentMethod] = useState<'mercadopago' | 'stripe'>('mercadopago');
|
||||
|
||||
const adFee = parseFloat(attributes['adFee'] || "0");
|
||||
const listingPrice = parseFloat(attributes['price'] || "0");
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!selectedCategory || !selectedOperation) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const attributePayload: Record<number, string> = {};
|
||||
const { existingImages } = useWizardStore.getState();
|
||||
|
||||
definitions.forEach(def => {
|
||||
const val = attributes[def.name.toLowerCase()] || attributes[def.name];
|
||||
if (val) {
|
||||
attributePayload[def.id] = val.toString();
|
||||
}
|
||||
});
|
||||
|
||||
const payload = {
|
||||
categoryId: selectedCategory.id,
|
||||
operationId: selectedOperation.id,
|
||||
title: attributes['title'],
|
||||
description: attributes['description'],
|
||||
price: parseFloat(attributes['price'] || "0"),
|
||||
adFee: adFee,
|
||||
currency: 'ARS',
|
||||
status: 'Pending',
|
||||
origin: 'Web',
|
||||
attributes: attributePayload,
|
||||
imagesToClone: existingImages.map(img => img.url),
|
||||
printText: attributes['description'],
|
||||
printDaysCount: parseInt(attributes['days'] || "3"),
|
||||
isBold: false,
|
||||
isFrame: false,
|
||||
printFontSize: 'normal',
|
||||
printAlignment: 'left'
|
||||
};
|
||||
|
||||
const result = await wizardService.createListing(payload);
|
||||
setCreatedId(result.id);
|
||||
|
||||
if (photos.length > 0) {
|
||||
for (const photo of photos) {
|
||||
await wizardService.uploadImage(result.id, photo);
|
||||
}
|
||||
}
|
||||
|
||||
const resp = await fetch(`${import.meta.env.VITE_API_URL}/payments/create-preference/${result.id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(adFee)
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.id) {
|
||||
setPreferenceId(data.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Error al publicar');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const paymentStatus = urlParams.get('status');
|
||||
|
||||
if (paymentStatus === 'approved') {
|
||||
return (
|
||||
<StepWrapper>
|
||||
<div className="text-center py-20 bg-white rounded-[3rem] shadow-2xl p-12 border border-emerald-100">
|
||||
<div className="w-24 h-24 bg-blue-50 text-blue-500 rounded-full flex items-center justify-center mx-auto mb-8 shadow-inner">
|
||||
<Clock size={64} strokeWidth={1.5} className="animate-pulse" />
|
||||
</div>
|
||||
<h2 className="text-4xl font-black mb-4 text-slate-900 tracking-tighter uppercase">¡Pago Recibido!</h2>
|
||||
<p className="text-slate-500 text-lg mb-10 font-medium max-w-md mx-auto">
|
||||
Tu aviso ha sido enviado a nuestro equipo de **Moderación**. Una vez aprobado, se publicará automáticamente en el portal y en la edición impresa.
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 max-w-xs mx-auto">
|
||||
<button
|
||||
onClick={() => { reset(); window.location.href = '/profile'; }}
|
||||
className="bg-slate-900 text-white px-12 py-5 rounded-2xl font-black uppercase text-xs tracking-[0.2em] hover:bg-black transition-all shadow-xl shadow-slate-200"
|
||||
>
|
||||
Ver estado en mi Perfil
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</StepWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (preferenceId) {
|
||||
return (
|
||||
<StepWrapper>
|
||||
<div className="text-center py-20 bg-white rounded-[3rem] shadow-2xl p-12 border border-blue-100">
|
||||
<div className="w-24 h-24 bg-blue-50 text-blue-600 rounded-full flex items-center justify-center mx-auto mb-8">
|
||||
<Wallet size={56} strokeWidth={1.5} />
|
||||
</div>
|
||||
<h2 className="text-3xl font-black mb-4 text-slate-900 tracking-tighter uppercase">Finalizar Publicación</h2>
|
||||
|
||||
{/* LÓGICA DE SIMULACIÓN */}
|
||||
{preferenceId === "MOCK_PREFERENCE_ID_12345" ? (
|
||||
<div className="space-y-6">
|
||||
<div className="p-4 bg-amber-50 border border-amber-100 rounded-2xl text-amber-700 text-xs font-bold uppercase">
|
||||
Modo Simulación Activo: Token de MP no configurado.
|
||||
</div>
|
||||
<p className="text-slate-500 text-sm mb-8 font-medium max-w-sm mx-auto">
|
||||
Estás en el entorno de desarrollo. Haz clic abajo para simular un pago aprobado y finalizar el proceso.
|
||||
</p>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
// Enviamos un objeto en lugar del número solo
|
||||
await api.post(`/payments/confirm-simulation/${createdId}`, {
|
||||
amount: adFee
|
||||
});
|
||||
|
||||
// Si la petición es exitosa, procedemos a la pantalla de éxito
|
||||
window.location.href = window.location.pathname + '?status=approved';
|
||||
} catch (error) {
|
||||
console.error("Error en la simulación:", error);
|
||||
alert("No se pudo confirmar el pago simulado.");
|
||||
}
|
||||
}}
|
||||
className="w-full py-5 bg-emerald-500 hover:bg-emerald-600 text-white rounded-2xl font-black uppercase text-xs tracking-widest shadow-xl shadow-emerald-200 transition-all"
|
||||
>
|
||||
Simular Pago Aprobado
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// LÓGICA REAL
|
||||
<>
|
||||
<p className="text-slate-500 text-lg mb-10 font-medium max-w-sm mx-auto leading-tight">
|
||||
Haz clic abajo para completar el pago de <strong>${adFee.toLocaleString()}</strong> de forma segura con Mercado Pago.
|
||||
</p>
|
||||
<div className="max-w-xs mx-auto scale-110">
|
||||
<MPWallet initialization={{ preferenceId }} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</StepWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StepWrapper>
|
||||
<div className="mb-10 text-center">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-slate-400 mb-4 block">Paso Final</span>
|
||||
<h2 className="text-4xl font-black text-slate-900 tracking-tighter uppercase leading-none">
|
||||
Resumen de <br />
|
||||
<span className="text-blue-600">tu publicación</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="max-w-2xl mx-auto space-y-8">
|
||||
|
||||
{/* RESUMEN DEL AVISO */}
|
||||
<section className="bg-white p-8 rounded-[2.5rem] shadow-2xl shadow-slate-200/50 border border-slate-100">
|
||||
<div className="flex justify-between items-start mb-8 pb-6 border-b border-slate-50">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-slate-50 rounded-2xl text-slate-400"><FileText size={20} /></div>
|
||||
<div>
|
||||
<h3 className="font-black text-slate-900 uppercase text-sm tracking-tight">{attributes['title']}</h3>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{selectedCategory?.name} / {selectedOperation?.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-[9px] font-black text-slate-400 uppercase block mb-1">Precio Producto</span>
|
||||
<p className="text-xl font-black text-slate-900">${listingPrice.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-10 gap-y-4 mb-4">
|
||||
{definitions.map(def => {
|
||||
const val = attributes[def.name.toLowerCase()] || attributes[def.name];
|
||||
return val && (
|
||||
<div key={def.id} className="flex justify-between items-center group">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase group-hover:text-slate-600 transition-colors">{def.name}</span>
|
||||
<span className="text-xs font-black text-slate-800 uppercase">{val}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* --- BLOQUE DE COSTO (TOTAL A PAGAR) --- */}
|
||||
<section className="bg-blue-600 rounded-[2.5rem] p-8 text-white shadow-2xl shadow-blue-200 relative overflow-hidden">
|
||||
<div className="flex justify-between items-center relative z-10">
|
||||
<div>
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.3em] opacity-60">Total a Pagar</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-xl font-black">$</span>
|
||||
<span className="text-5xl font-black font-mono tracking-tighter">
|
||||
{adFee.toLocaleString('es-AR', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/10 p-4 rounded-[1.5rem] border border-white/20 backdrop-blur-sm">
|
||||
<Tag size={24} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* MÉTODO DE PAGO */}
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest ml-4">Selecciona Método de Pago</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label className={clsx(
|
||||
"flex items-center gap-4 p-6 rounded-3xl border-2 cursor-pointer transition-all",
|
||||
paymentMethod === 'mercadopago' ? "border-blue-600 bg-blue-50 shadow-lg shadow-blue-100" : "border-slate-100 bg-white hover:border-blue-200"
|
||||
)}>
|
||||
<input type="radio" className="hidden" checked={paymentMethod === 'mercadopago'} onChange={() => setPaymentMethod('mercadopago')} />
|
||||
<div className={clsx("p-3 rounded-2xl", paymentMethod === 'mercadopago' ? "bg-blue-600 text-white" : "bg-slate-100 text-slate-400")}>
|
||||
<Wallet size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-black text-slate-900 uppercase text-xs">Mercado Pago</p>
|
||||
<p className="text-[10px] text-slate-400 font-bold uppercase">QR, Débito, Crédito</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className={clsx(
|
||||
"flex items-center gap-4 p-6 rounded-3xl border-2 cursor-pointer transition-all",
|
||||
paymentMethod === 'stripe' ? "border-slate-900 bg-slate-50 shadow-lg shadow-slate-100" : "border-slate-100 bg-white hover:border-slate-200"
|
||||
)}>
|
||||
<input type="radio" className="hidden" checked={paymentMethod === 'stripe'} onChange={() => setPaymentMethod('stripe')} />
|
||||
<div className={clsx("p-3 rounded-2xl", paymentMethod === 'stripe' ? "bg-slate-900 text-white" : "bg-slate-100 text-slate-400")}>
|
||||
<CreditCard size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-black text-slate-900 uppercase text-xs">Tarjeta Directa</p>
|
||||
<p className="text-[10px] text-slate-400 font-bold uppercase">Visa o MasterCard</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ACCIÓN PRINCIPAL */}
|
||||
<div className="flex flex-col md:flex-row gap-4 pt-6">
|
||||
<button
|
||||
onClick={() => setStep(5)}
|
||||
className="flex-1 py-5 bg-slate-100 text-slate-500 font-black uppercase text-xs tracking-widest rounded-2xl hover:bg-slate-200 transition-all flex items-center justify-center gap-3"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ArrowLeft size={16} /> Volver
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePublish}
|
||||
disabled={isSubmitting}
|
||||
className="flex-[2] py-5 bg-blue-600 text-white font-black uppercase text-xs tracking-[0.2em] rounded-2xl shadow-xl shadow-blue-500/20 hover:bg-blue-700 disabled:opacity-50 transition-all flex items-center justify-center gap-3 group"
|
||||
>
|
||||
<span className="text-white">
|
||||
{isSubmitting ? 'Procesando...' : 'Pagar y Publicar Aviso'}
|
||||
</span>
|
||||
<ChevronRight size={18} className="text-blue-300 group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</StepWrapper>
|
||||
);
|
||||
}
|
||||
233
frontend/public-web/src/pages/Steps/TextEditorStep.tsx
Normal file
233
frontend/public-web/src/pages/Steps/TextEditorStep.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useWizardStore } from '../../store/wizardStore';
|
||||
import { StepWrapper } from '../../components/StepWrapper';
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import { Eye, FileText, Calendar, ChevronRight, ArrowLeft } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import api from '../../services/api';
|
||||
|
||||
interface PricingResult {
|
||||
totalPrice: number;
|
||||
baseCost: number;
|
||||
extraCost: number;
|
||||
wordCount: number;
|
||||
specialCharCount: number;
|
||||
details: string;
|
||||
}
|
||||
|
||||
export default function TextEditorStep() {
|
||||
const { selectedCategory, attributes, setAttribute, setStep } = useWizardStore();
|
||||
const [text, setText] = useState(attributes['description'] || '');
|
||||
const [days, setDays] = useState(parseInt(attributes['days'] as string) || 3);
|
||||
const [pricing, setPricing] = useState<PricingResult>({
|
||||
totalPrice: 0,
|
||||
baseCost: 0,
|
||||
extraCost: 0,
|
||||
wordCount: 0,
|
||||
specialCharCount: 0,
|
||||
details: ''
|
||||
});
|
||||
const [loadingPrice, setLoadingPrice] = useState(false);
|
||||
|
||||
const debouncedText = useDebounce(text, 800);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCategory || debouncedText.length === 0) {
|
||||
setPricing({ totalPrice: 0, baseCost: 0, extraCost: 0, wordCount: 0, specialCharCount: 0, details: '' });
|
||||
return;
|
||||
}
|
||||
|
||||
const calculatePrice = async () => {
|
||||
setLoadingPrice(true);
|
||||
try {
|
||||
const response = await api.post('/pricing/calculate', {
|
||||
categoryId: selectedCategory.id,
|
||||
text: debouncedText,
|
||||
days: days,
|
||||
isBold: false,
|
||||
isFrame: false,
|
||||
startDate: new Date().toISOString()
|
||||
});
|
||||
setPricing(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error al calcular precio:', error);
|
||||
} finally {
|
||||
setLoadingPrice(false);
|
||||
}
|
||||
};
|
||||
|
||||
calculatePrice();
|
||||
}, [debouncedText, days, selectedCategory]);
|
||||
|
||||
const handleContinue = () => {
|
||||
if (text.trim().length === 0) {
|
||||
alert('⚠️ Debes escribir el texto del aviso');
|
||||
return;
|
||||
}
|
||||
setAttribute('description', text);
|
||||
setAttribute('days', days.toString());
|
||||
setAttribute('adFee', pricing.totalPrice.toString());
|
||||
setStep(5); // Siguiente paso
|
||||
};
|
||||
|
||||
return (
|
||||
<StepWrapper>
|
||||
{/* Header & Breadcrumb */}
|
||||
<div className="mb-10 text-center">
|
||||
<div className="flex justify-center items-center gap-2 text-[10px] font-black uppercase tracking-[0.3em] text-slate-400 mb-4">
|
||||
<span>{selectedCategory?.name}</span>
|
||||
<ChevronRight size={12} />
|
||||
<span className="text-blue-600">Redacción</span>
|
||||
</div>
|
||||
<h2 className="text-4xl font-black text-slate-900 tracking-tighter uppercase leading-none">
|
||||
Escribe tu <br />
|
||||
<span className="text-blue-600">Aviso Clasificado</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="max-w-3xl mx-auto space-y-10">
|
||||
|
||||
{/* BLOQUE 1: EL EDITOR */}
|
||||
<section className="bg-white p-8 rounded-[2.5rem] shadow-2xl shadow-slate-200/50 border border-slate-100">
|
||||
<div className="flex items-center gap-3 mb-6 text-slate-900">
|
||||
<div className="p-2 bg-blue-50 rounded-xl text-blue-600">
|
||||
<FileText size={20} />
|
||||
</div>
|
||||
<h3 className="font-black uppercase tracking-widest text-xs">Contenido del Aviso</h3>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
className="w-full h-56 p-6 bg-slate-50 border-2 border-slate-100 rounded-[2rem] outline-none focus:border-blue-500 focus:bg-white transition-all font-mono text-lg leading-relaxed text-slate-700 placeholder:text-slate-300"
|
||||
placeholder="Comienza a escribir aquí..."
|
||||
/>
|
||||
|
||||
<div className="flex justify-between items-center mt-6 px-2">
|
||||
<div className="flex gap-6">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Palabras</span>
|
||||
<span className={clsx("text-xl font-black", pricing.wordCount > 0 ? "text-blue-600" : "text-slate-300")}>
|
||||
{pricing.wordCount.toString().padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
{pricing.specialCharCount > 0 && (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Signos</span>
|
||||
<span className="text-xl font-black text-orange-500">
|
||||
{pricing.specialCharCount.toString().padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{loadingPrice && (
|
||||
<div className="flex items-center gap-2 text-[10px] font-black text-slate-400 uppercase animate-pulse">
|
||||
Sincronizando tarifa...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* BLOQUE 2: CONFIGURACIÓN DE DÍAS */}
|
||||
<section className="bg-white p-8 rounded-[2.5rem] shadow-xl border border-slate-100 flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-slate-900 rounded-2xl text-white">
|
||||
<Calendar size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-black uppercase text-xs text-slate-900">Duración de la oferta</h4>
|
||||
<p className="text-slate-400 text-[10px] font-bold uppercase tracking-wider">¿Cuántos días se publicará?</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center bg-slate-100 rounded-2xl p-1 w-full md:w-48">
|
||||
<button onClick={() => setDays(Math.max(1, days - 1))} className="flex-1 py-3 font-black text-xl text-slate-400 hover:text-slate-900">-</button>
|
||||
<input
|
||||
type="number"
|
||||
value={days}
|
||||
onChange={(e) => setDays(parseInt(e.target.value) || 1)}
|
||||
className="w-16 text-center bg-transparent border-none outline-none font-black text-xl text-blue-600"
|
||||
/>
|
||||
<button onClick={() => setDays(days + 1)} className="flex-1 py-3 font-black text-xl text-slate-400 hover:text-slate-900">+</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* BLOQUE 3: PREVISUALIZACIÓN */}
|
||||
<section className="bg-[#fffef5] p-8 rounded-[2.5rem] border-2 border-yellow-100 shadow-inner relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-8 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
<Eye size={120} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6 text-yellow-700 relative z-10">
|
||||
<Eye size={18} />
|
||||
<h3 className="font-black uppercase tracking-[0.2em] text-[10px]">Vista Previa Real</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-[1.8rem] border border-yellow-200 shadow-sm min-h-[10rem] relative z-10">
|
||||
{text ? (
|
||||
<p className="text-slate-800 text-lg font-medium leading-relaxed italic uppercase">
|
||||
{text}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-slate-300 font-bold text-center py-10 uppercase tracking-widest text-xs">
|
||||
El texto aparecerá aquí...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* BLOQUE 4: COSTO FINAL */}
|
||||
<section className="bg-slate-900 rounded-[3rem] p-10 text-white shadow-2xl relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-blue-600/10 rounded-full blur-[80px] -mr-32 -mt-32"></div>
|
||||
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-8 relative z-10">
|
||||
<div className="text-center md:text-left">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-blue-400 mb-2 block">Total a Pagar</span>
|
||||
<div className="flex items-baseline justify-center md:justify-start gap-2">
|
||||
<span className="text-2xl font-black text-blue-500">$</span>
|
||||
<span className="text-6xl font-black tracking-tighter font-mono">
|
||||
{pricing.totalPrice.toLocaleString('es-AR', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-64 space-y-3 bg-white/5 p-6 rounded-3xl border border-white/10 backdrop-blur-sm">
|
||||
<div className="flex justify-between text-[10px] font-black uppercase">
|
||||
<span className="text-slate-500">Tarifa base</span>
|
||||
<span className="text-white">${pricing.baseCost.toLocaleString()}</span>
|
||||
</div>
|
||||
{pricing.extraCost > 0 && (
|
||||
<div className="flex justify-between text-[10px] font-black uppercase text-orange-400">
|
||||
<span>Recargo texto</span>
|
||||
<span>+${pricing.extraCost.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="h-px bg-white/10 my-2"></div>
|
||||
<p className="text-[9px] text-slate-500 font-bold leading-tight italic">
|
||||
* {pricing.details || "Calculando tasas e impuestos..."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* BOTONES DE NAVEGACIÓN */}
|
||||
<div className="flex flex-col md:flex-row gap-4 pt-6">
|
||||
<button
|
||||
onClick={() => setStep(3)}
|
||||
className="flex-1 py-5 bg-slate-100 text-slate-500 font-black uppercase text-xs tracking-widest rounded-2xl hover:bg-slate-200 transition-all flex items-center justify-center gap-3"
|
||||
>
|
||||
<ArrowLeft size={16} /> Volver
|
||||
</button>
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
disabled={text.trim().length === 0 || loadingPrice}
|
||||
className="flex-[2] py-5 bg-blue-600 text-white font-black uppercase text-xs tracking-[0.2em] rounded-2xl shadow-xl shadow-blue-500/20 hover:bg-blue-700 disabled:opacity-50 transition-all flex items-center justify-center gap-3 group"
|
||||
>
|
||||
Continuar a Fotos
|
||||
<ChevronRight size={18} className="group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</StepWrapper>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user