Feat: Cambios Varios 2

This commit is contained in:
2026-01-05 10:30:04 -03:00
parent 8bc1308bc5
commit 0fa77e4a98
184 changed files with 11098 additions and 6348 deletions

2
frontend/public-web/.env Normal file
View File

@@ -0,0 +1,2 @@
VITE_API_URL=http://localhost:5176/api
VITE_BASE_URL=http://localhost:5176

View File

@@ -8,12 +8,16 @@
"name": "public-web",
"version": "0.0.0",
"dependencies": {
"@mercadopago/sdk-react": "^1.0.6",
"@tailwindcss/postcss": "^4.1.18",
"axios": "^1.13.2",
"clsx": "^2.1.1",
"framer-motion": "^12.23.26",
"lucide-react": "^0.561.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-dropzone": "^14.3.8",
"react-helmet-async": "^2.0.5",
"react-router-dom": "^7.11.0",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.9"
@@ -1026,6 +1030,25 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mercadopago/sdk-js": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/@mercadopago/sdk-js/-/sdk-js-0.0.3.tgz",
"integrity": "sha512-kO48DNLHdfFAp3on12nuKdqNlEmw1x3+nM6wLd04BdWOXoFcAhkNMQV3AyUIanXdO/bB/dENakdacLT29297EQ==",
"license": "Apache-2.0"
},
"node_modules/@mercadopago/sdk-react": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@mercadopago/sdk-react/-/sdk-react-1.0.6.tgz",
"integrity": "sha512-tfKvU6LceJKh1kuYh2HAWXozmfBcfvgQfroL3e2A583RfStF0C9kcr0SacdDAkz89XwZc7QCzVh4HBh/8QRuvA==",
"license": "Apache-2.0",
"dependencies": {
"@mercadopago/sdk-js": "^0.0.3"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.53",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
@@ -1670,7 +1693,7 @@
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -2045,6 +2068,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",
@@ -2303,7 +2335,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/debug": {
@@ -2729,6 +2761,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",
@@ -2817,6 +2861,33 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "12.23.26",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz",
"integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.23.23",
"motion-utils": "^12.23.6",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3035,6 +3106,15 @@
"node": ">=0.8.19"
}
},
"node_modules/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -3078,7 +3158,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": {
@@ -3437,6 +3516,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",
@@ -3508,6 +3599,21 @@
"node": "*"
}
},
"node_modules/motion-dom": {
"version": "12.23.23",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.23.6"
}
},
"node_modules/motion-utils": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3547,6 +3653,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",
@@ -3694,6 +3809,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",
@@ -3731,6 +3857,49 @@
"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-fast-compare": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==",
"license": "MIT"
},
"node_modules/react-helmet-async": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-2.0.5.tgz",
"integrity": "sha512-rYUYHeus+i27MvFE+Jaa4WsyBKGkL6qVgbJvSBoX8mbsWoABJXdEO0bZyi0F6i+4f0NuIb8AvqPMj3iXFHkMwg==",
"license": "Apache-2.0",
"dependencies": {
"invariant": "^2.2.4",
"react-fast-compare": "^3.2.2",
"shallowequal": "^1.1.0"
},
"peerDependencies": {
"react": "^16.6.0 || ^17.0.0 || ^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",
@@ -3853,6 +4022,12 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shallowequal": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -3970,6 +4145,12 @@
"typescript": ">=4.8.4"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@@ -10,12 +10,16 @@
"preview": "vite preview"
},
"dependencies": {
"@mercadopago/sdk-react": "^1.0.6",
"@tailwindcss/postcss": "^4.1.18",
"axios": "^1.13.2",
"clsx": "^2.1.1",
"framer-motion": "^12.23.26",
"lucide-react": "^0.561.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-dropzone": "^14.3.8",
"react-helmet-async": "^2.0.5",
"react-router-dom": "^7.11.0",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.9"

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

View File

@@ -1,38 +1,224 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { useState } from 'react';
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
import { usePublicAuthStore } from './store/publicAuthStore';
import { motion, AnimatePresence } from 'framer-motion';
// Páginas e Interfaz
import HomePage from './pages/HomePage';
import ListingDetailPage from './pages/ListingDetailPage';
import LoginPage from './pages/LoginPage';
import ProfilePage from './pages/ProfilePage';
import PublishPage from './pages/PublishPage';
import PublishFeedback from './pages/PublishFeedback';
// Componentes de Seguridad y Navegación
import { ProtectedRoute } from './components/ProtectedRoute';
import {
User as UserIcon,
LogIn,
ChevronDown,
LogOut,
PlusCircle,
Mail,
MapPin,
Phone
} from 'lucide-react';
// ICONOS DE REDES SOCIALES (SVG PUROS)
const SocialIcons = {
Facebook: () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978 1.62 0 3.341.252 3.341.252v3.389h-1.835c-1.906 0-2.456 1.159-2.456 2.439v1.478h3.82l-.611 3.667h-3.21v7.98H9.101z" /></svg>
),
Instagram: () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="2" width="20" height="20" rx="5" ry="5"></rect><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"></path><line x1="17.5" y1="6.5" x2="17.51" y2="6.5"></line></svg>
),
X: () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932 6.064-6.932zm-1.292 19.49h2.039L6.486 3.24H4.298L17.609 20.643z" /></svg>
),
Youtube: () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" /></svg>
)
};
function App() {
const { user, logout } = usePublicAuthStore();
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const handleLogout = () => {
logout();
setIsUserMenuOpen(false);
};
return (
<BrowserRouter>
<div className="flex flex-col min-h-screen">
<header className="bg-white border-b border-gray-100 py-4 px-6 sticky top-0 z-50">
<div className="max-w-6xl mx-auto flex items-center justify-between">
<a href="/" className="font-bold text-2xl text-primary-600">SIG-CM</a>
<nav className="hidden md:flex gap-6 text-sm font-medium text-gray-600">
<a href="/" className="hover:text-primary-600">Inicio</a>
<a href="#" className="hover:text-primary-600">Categorías</a>
<div className="flex flex-col min-h-screen font-sans bg-[#f8fafc]">
{/* HEADER */}
<header className="bg-white/80 backdrop-blur-md border-b border-slate-100 py-4 px-6 sticky top-0 z-50">
<div className="max-w-7xl mx-auto flex items-center justify-between">
<Link to="/" className="font-black text-3xl text-slate-900 tracking-tighter flex items-center gap-2">
<span className="bg-blue-600 w-2 h-7 rounded-full"></span>
SIG-CM
</Link>
<nav className="hidden md:flex gap-10 text-[10px] font-black uppercase tracking-[0.2em] text-slate-400">
<Link to="/" className="hover:text-blue-600 transition-colors">Inicio</Link>
<a href="/#categories" className="hover:text-blue-600 transition-colors">Categorías</a>
<a href="#" className="hover:text-blue-600 transition-colors">Soporte</a>
</nav>
<div>
<a
href="http://localhost:5177"
className="bg-primary-600 text-white px-5 py-2 rounded-full font-medium hover:bg-primary-700 transition"
target='_blank'
<div className="flex items-center gap-4">
{user ? (
<div className="relative">
<button
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
className="flex items-center gap-3 bg-slate-50 pl-4 pr-3 py-2 rounded-2xl hover:bg-slate-100 transition-all border border-slate-100 group"
>
<div className="w-8 h-8 bg-blue-600 rounded-xl flex items-center justify-center text-white shadow-lg shadow-blue-200">
<UserIcon size={16} />
</div>
<span className="hidden sm:block text-xs font-black text-slate-900 uppercase tracking-widest">
{user.username}
</span>
<ChevronDown size={14} className={`text-slate-400 transition-transform duration-300 ${isUserMenuOpen ? 'rotate-180' : ''}`} />
</button>
<AnimatePresence>
{isUserMenuOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setIsUserMenuOpen(false)}></div>
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="absolute right-0 mt-3 w-64 bg-white rounded-[2rem] shadow-2xl border border-slate-100 py-4 z-20 overflow-hidden"
>
<div className="px-6 py-3 border-b border-slate-50 mb-2">
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">Cuenta vinculada</p>
<p className="text-sm font-bold text-slate-900 truncate">{user.username}</p>
</div>
<Link
to="/profile"
onClick={() => setIsUserMenuOpen(false)}
className="flex items-center gap-3 px-6 py-4 hover:bg-blue-50 text-slate-600 hover:text-blue-600 transition-colors group"
>
<div className="p-2 bg-slate-50 group-hover:bg-blue-100 rounded-xl transition-colors">
<UserIcon size={18} className="group-hover:text-blue-600" />
</div>
<span className="text-xs font-black uppercase tracking-widest">Mi Perfil</span>
</Link>
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-6 py-4 mt-2 hover:bg-rose-50 text-rose-500 border-t border-slate-50 transition-colors group"
>
<div className="p-2 bg-slate-50 group-hover:bg-rose-100 rounded-xl transition-colors">
<LogOut size={18} />
</div>
<span className="text-xs font-black uppercase tracking-widest">Cerrar Sesión</span>
</button>
</motion.div>
</>
)}
</AnimatePresence>
</div>
) : (
<Link to="/login" className="flex items-center gap-2 text-xs font-black uppercase tracking-widest text-slate-900 hover:text-blue-600 transition-all px-4">
<LogIn size={18} /> Entrar
</Link>
)}
<Link
to="/publicar"
className="bg-slate-900 text-white px-8 py-3 rounded-2xl font-black uppercase tracking-widest text-[10px] shadow-xl shadow-slate-200 hover:bg-black hover:-translate-y-0.5 transition-all flex items-center gap-2"
>
Publicar Aviso
</a>
<PlusCircle size={14} className="text-blue-400" />
Publicar
</Link>
</div>
</div>
</header>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/listing/:id" element={<ListingDetailPage />} />
</Routes>
{/* CONTENIDO PRINCIPAL */}
<main className="flex-1">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/listing/:id" element={<ListingDetailPage />} />
<Route path="/login" element={<LoginPage />} />
<Route element={<ProtectedRoute />}>
<Route path="/profile" element={<ProfilePage />} />
<Route path="/publicar" element={<PublishPage />} />
<Route path="/publicar/exito" element={<PublishFeedback />} />
<Route path="/publicar/error" element={<PublishFeedback />} />
</Route>
</Routes>
</main>
<footer className="bg-gray-900 text-gray-400 py-12 mt-auto">
<div className="max-w-6xl mx-auto px-4 text-center">
<p>&copy; 2024 SIG-CM Clasificados Multicanal.</p>
{/* FOOTER PREMIUM REFINADO */}
<footer className="bg-[#0f172a] text-slate-400 pt-24 pb-12">
<div className="max-w-7xl mx-auto px-8">
<div className="grid grid-cols-1 md:grid-cols-12 gap-16 mb-20">
{/* Columna 1: Branding y Propuesta */}
<div className="md:col-span-5 space-y-8">
<Link to="/" className="font-black text-3xl text-white tracking-tighter flex items-center gap-2">
<span className="bg-blue-600 w-2 h-7 rounded-full"></span>
SIG-CM
</Link>
<p className="text-sm leading-relaxed max-w-sm opacity-60 font-medium">
La plataforma de clasificados líder en la región. Seguridad, trayectoria y efectividad garantizada por el <strong>Diario El Día</strong> de La Plata.
</p>
<div className="space-y-4 pt-4">
<div className="flex items-center gap-4 text-xs font-bold uppercase tracking-widest text-slate-500">
<div className="w-10 h-10 rounded-2xl bg-white/5 flex items-center justify-center text-blue-500"><MapPin size={18} /></div>
Diagonal 80 No. 817, La Plata
</div>
<div className="flex items-center gap-4 text-xs font-bold uppercase tracking-widest text-slate-500">
<div className="w-10 h-10 rounded-2xl bg-white/5 flex items-center justify-center text-blue-500"><Phone size={18} /></div>
(0221) 412-0101
</div>
</div>
</div>
{/* Columna 2: Navegación Rápida */}
<div className="md:col-span-3 space-y-8">
<h4 className="text-[10px] font-black uppercase tracking-[0.3em] text-white opacity-40">Plataforma</h4>
<ul className="space-y-5">
<li><Link to="/" className="text-sm font-bold hover:text-white transition-colors">Centro de Ayuda</Link></li>
<li><Link to="/" className="text-sm font-bold hover:text-white transition-colors">Preguntas Frecuentes</Link></li>
<li><Link to="/" className="text-sm font-bold hover:text-white transition-colors">Términos del Servicio</Link></li>
<li><Link to="/" className="text-sm font-bold hover:text-white transition-colors">Política de Privacidad</Link></li>
</ul>
</div>
{/* Columna 3: Comunidad y Redes */}
<div className="md:col-span-4 space-y-8 text-left md:text-right">
<h4 className="text-[10px] font-black uppercase tracking-[0.3em] text-white opacity-40">Síguenos en Redes</h4>
<div className="flex justify-start md:justify-end gap-3">
<SocialButton href="https://facebook.com" color="hover:bg-[#1877F2]"><SocialIcons.Facebook /></SocialButton>
<SocialButton href="https://instagram.com" color="hover:bg-gradient-to-tr from-[#F58529] via-[#DD2A7B] to-[#8134AF]"><SocialIcons.Instagram /></SocialButton>
<SocialButton href="https://x.com" color="hover:bg-black"><SocialIcons.X /></SocialButton>
<SocialButton href="https://youtube.com" color="hover:bg-[#FF0000]"><SocialIcons.Youtube /></SocialButton>
</div>
<div className="pt-8">
<div className="inline-flex items-center gap-3 px-5 py-3 rounded-2xl bg-white/5 border border-white/10">
<Mail size={16} className="text-blue-500" />
<span className="text-[11px] font-black uppercase tracking-widest text-slate-300">soporte@eldia.com</span>
</div>
</div>
</div>
</div>
{/* Créditos Finales */}
<div className="pt-12 border-t border-white/5 flex flex-col md:flex-row justify-between items-center gap-6">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-600">
&copy; 2026 SIG-CM Clasificados. Todos los derechos reservados.
</p>
<div className="flex items-center gap-6">
<span className="text-[9px] font-black uppercase tracking-widest text-slate-700">Diseñado para Diario El Día</span>
<div className="w-8 h-8 rounded-lg bg-white/5 flex items-center justify-center grayscale opacity-30 hover:grayscale-0 hover:opacity-100 transition-all cursor-pointer">
<div className="w-4 h-4 bg-white rounded-full"></div>
</div>
</div>
</div>
</div>
</footer>
</div>
@@ -40,4 +226,18 @@ function App() {
);
}
// COMPONENTE AUXILIAR PARA BOTONES DE REDES
function SocialButton({ children, href, color }: { children: React.ReactNode, href: string, color: string }) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={`w-12 h-12 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center text-white transition-all duration-300 hover:-translate-y-1 hover:shadow-xl hover:shadow-blue-500/10 ${color}`}
>
{children}
</a>
);
}
export default App;

View File

@@ -0,0 +1,66 @@
// src/components/LazyImage.tsx
import { useLazyImage } from '../hooks/useLazyImage';
import clsx from 'clsx';
interface LazyImageProps {
src: string;
alt: string;
className?: string;
placeholder?: string;
fallbackSrc?: string;
}
export default function LazyImage({
src,
alt,
className = '',
placeholder = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300"%3E%3Crect fill="%23f0f0f0" width="400" height="300"/%3E%3C/svg%3E',
fallbackSrc
}: LazyImageProps) {
const {
imgRef,
imageSrc,
isLoading,
isError,
handleLoad,
handleError
} = useLazyImage({ src, placeholder });
const isRealImage = imageSrc && !imageSrc.startsWith('data:');
return (
<div className={clsx("relative w-full h-full overflow-hidden", className)}>
{isLoading && (
<div className="absolute inset-0 bg-slate-200 animate-pulse z-10" />
)}
{isError ? (
<div className="absolute inset-0 flex items-center justify-center bg-slate-100 text-slate-400 text-[10px] p-2 text-center">
{fallbackSrc ? (
<img src={fallbackSrc} alt={alt} className="w-full h-full object-cover" />
) : (
<span>Imagen no disponible</span>
)}
</div>
) : (
<picture className="w-full h-full">
{/* Solo renderizamos el source si es una imagen real del servidor */}
{isRealImage && <source srcSet={imageSrc} type="image/webp" />}
<img
ref={imgRef}
src={imageSrc}
alt={alt}
className={clsx(
"w-full h-full object-cover transition-opacity duration-500 block",
isLoading ? 'opacity-0' : 'opacity-100'
)}
onLoad={handleLoad}
onError={handleError}
loading="lazy"
/>
</picture>
)}
</div>
);
}

View File

@@ -1,42 +1,95 @@
import type { Listing } from '../types';
import { MapPin } from 'lucide-react';
import { Calendar, ExternalLink, ArrowRight } from 'lucide-react';
import { Link } from 'react-router-dom';
import LazyImage from './LazyImage';
import { motion } from 'framer-motion';
import clsx from 'clsx';
export default function ListingCard({ listing }: { listing: Listing }) {
const baseUrl = import.meta.env.VITE_BASE_URL;
// 1. Definimos la ruta de la imagen por defecto (desde la raíz de public)
const placeholder = '/sin_imagen.png';
// Lógica de imagen: Si viene del backend, la usamos. Si no, placeholder local o remoto.
const imageUrl = listing.mainImageUrl
? `${baseUrl}${listing.mainImageUrl}`
: 'https://placehold.co/400x300?text=Sin+Foto'; // placehold.co es más rápido y fiable que via.placeholder
// 2. Lógica de selección de URL
const imageUrl = listing.mainImageUrl
? `${baseUrl}${listing.mainImageUrl}`
: placeholder;
const jpgFallback = imageUrl.replace('.webp', '.jpg');
return (
<Link to={`/listing/${listing.id}`} className="block h-full">
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition group h-full flex flex-col">
<div className="aspect-[4/3] bg-gray-100 relative overflow-hidden">
<img
<Link to={`/listing/${listing.id}`} className="group relative block h-full">
<div className="flex h-full flex-col overflow-hidden rounded-2xl bg-white border border-slate-100 shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-xl hover:shadow-slate-200/60">
{/* Image Container */}
<div className="relative aspect-[4/3] w-full overflow-hidden bg-slate-100">
<LazyImage
src={imageUrl}
fallbackSrc={jpgFallback}
alt={listing.title}
className="w-full h-full object-cover group-hover:scale-105 transition duration-500"
onError={(e) => {
// Fallback si la imagen falla al cargar
e.currentTarget.src = 'https://placehold.co/400x300?text=Error+Carga';
}}
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-110"
/>
<div className="absolute top-3 right-3 bg-white/90 backdrop-blur px-2 py-1 rounded text-xs font-bold uppercase tracking-wide">
Ver
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/40 via-transparent to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100"></div>
{listing.overlayStatus && (
<div className="absolute inset-0 z-20 bg-black/40 backdrop-blur-[3px] flex items-center justify-center p-4">
<motion.div
initial={{ scale: 0.5, opacity: 0, rotate: -10 }}
animate={{ scale: 1, opacity: 1, rotate: listing.overlayStatus === 'Vendido' ? -5 : 5 }}
className={clsx(
"px-6 py-2.5 rounded-xl text-white font-black uppercase tracking-[0.2em] text-sm border-2 shadow-[0_20px_50px_rgba(0,0,0,0.3)]",
listing.overlayStatus === 'Vendido' && "bg-rose-600 border-rose-400",
listing.overlayStatus === 'Reservado' && "bg-amber-500 border-amber-300",
listing.overlayStatus === 'Alquilado' && "bg-blue-600 border-blue-400"
)}
>
{listing.overlayStatus}
</motion.div>
</div>
)}
{/* Badge ID */}
<div className="absolute top-4 left-4">
<span className="inline-flex items-center rounded-lg bg-white/95 backdrop-blur-md px-2.5 py-1 text-[10px] font-black uppercase tracking-wider text-slate-900 shadow-sm">
#{listing.id.toString().padStart(6, '0')}
</span>
</div>
<div className="absolute bottom-4 right-4 translate-y-2 opacity-0 transition-all duration-300 group-hover:translate-y-0 group-hover:opacity-100">
<div className="rounded-full bg-blue-600 p-2.5 text-white shadow-lg">
<ExternalLink size={16} />
</div>
</div>
</div>
<div className="p-4 flex flex-col flex-grow">
<div className="text-2xl font-bold text-gray-900 mb-1">
{listing.currency} {listing.price.toLocaleString()}
{/* Content */}
<div className="flex flex-1 flex-col p-5">
<div className="mb-2.5 flex items-center gap-2">
<span className="inline-block rounded-md bg-blue-50 px-2 py-0.5 text-[10px] font-bold text-blue-600 uppercase tracking-tighter">
{listing.categoryName || 'Clasificado'}
</span>
<div className="h-1 w-1 rounded-full bg-slate-200"></div>
<span className="flex items-center gap-1 text-[10px] font-bold text-slate-400">
<Calendar size={12} className="text-slate-300" />
{new Date(listing.createdAt).toLocaleDateString()}
</span>
</div>
<h3 className="font-medium text-gray-800 mb-2 line-clamp-2 group-hover:text-primary-600 transition">
<h3 className="mb-4 line-clamp-2 text-lg font-extrabold text-slate-800 leading-tight group-hover:text-blue-600 transition-colors uppercase">
{listing.title}
</h3>
<div className="mt-auto pt-4 border-t border-gray-100 flex items-center text-sm text-gray-500">
<MapPin size={16} className="mr-1" />
<span>Ver detalles</span>
<div className="mt-auto flex items-end justify-between border-t border-slate-50 pt-4">
<div className="flex flex-col">
<span className="text-[10px] font-black uppercase text-slate-300 tracking-widest leading-none">Importe</span>
<div className="text-2xl font-black text-slate-900 leading-none mt-1.5 flex items-baseline">
<span className="text-sm font-bold text-blue-500 mr-1">{listing.currency}</span>
{listing.price.toLocaleString('es-AR')}
</div>
</div>
<button className="flex items-center gap-1.5 text-[10px] font-black uppercase tracking-widest text-slate-400 group-hover:text-blue-600 transition-all">
Detalle
<ArrowRight size={14} className="group-hover:translate-x-1 transition-transform" />
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,14 @@
import { Navigate, Outlet } from 'react-router-dom';
import { usePublicAuthStore } from '../store/publicAuthStore';
export const ProtectedRoute = () => {
const user = usePublicAuthStore((state) => state.user);
const token = localStorage.getItem('public_token');
// Si no hay usuario o no hay token, al login
if (!user || !token) {
return <Navigate to="/login" replace />;
}
return <Outlet />;
};

View File

@@ -0,0 +1,72 @@
import { Helmet } from 'react-helmet-async';
interface SEOProps {
title: string;
description: string;
keywords?: string[];
image?: string;
url?: string;
type?: 'website' | 'article' | 'product';
author?: string;
publishedTime?: string;
modifiedTime?: string;
}
export default function SEO({
title,
description,
keywords = [],
image = '/og-default.jpg',
url = window.location.href,
type = 'website',
author,
publishedTime,
modifiedTime
}: SEOProps) {
const siteName = 'Diario El Día - Clasificados';
const fullTitle = `${title} | ${siteName}`;
return (
<Helmet>
{/* Primary Meta Tags */}
<title>{fullTitle}</title>
<meta name="title" content={fullTitle} />
<meta name="description" content={description} />
{keywords.length > 0 && <meta name="keywords" content={keywords.join(', ')} />}
{author && <meta name="author" content={author} />}
{/* Open Graph / Facebook */}
<meta property="og:type" content={type} />
<meta property="og:url" content={url} />
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta property="og:image" content={image} />
<meta property="og:site_name" content={siteName} />
<meta property="og:locale" content="es_AR" />
{/* Twitter */}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content={url} />
<meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={image} />
{/* Article specific */}
{type === 'article' && publishedTime && (
<meta property="article:published_time" content={publishedTime} />
)}
{type === 'article' && modifiedTime && (
<meta property="article:modified_time" content={modifiedTime} />
)}
{type === 'article' && author && (
<meta property="article:author" content={author} />
)}
{/* Additional SEO */}
<meta name="robots" content="index, follow" />
<meta name="googlebot" content="index, follow" />
<meta name="theme-color" content="#0066CC" />
<link rel="canonical" href={url} />
</Helmet>
);
}

View File

@@ -0,0 +1,138 @@
import { Helmet } from 'react-helmet-async';
interface ListingSchemaProps {
listing: {
id: number;
title: string;
description: string;
price: number;
currency?: string;
category: string;
images?: string[];
seller?: {
name: string;
telephone?: string;
};
location?: string;
datePosted?: string;
};
}
// Schema.org markup para avisos clasificados (Product/Offer)
export function ListingSchema({ listing }: ListingSchemaProps) {
const schema = {
'@context': 'https://schema.org',
'@type': 'Product',
name: listing.title,
description: listing.description,
category: listing.category,
image: listing.images || [],
offers: {
'@type': 'Offer',
price: listing.price,
priceCurrency: listing.currency || 'ARS',
availability: 'https://schema.org/InStock',
url: `${window.location.origin}/avisos/${listing.id}`,
seller: listing.seller ? {
'@type': 'Organization',
name: listing.seller.name,
telephone: listing.seller.telephone
} : undefined
},
...(listing.datePosted && { datePublished: listing.datePosted }),
...(listing.location && {
availableAtOrFrom: {
'@type': 'Place',
address: {
'@type': 'PostalAddress',
addressLocality: listing.location
}
}
})
};
return (
<Helmet>
<script type="application/ld+json">
{JSON.stringify(schema)}
</script>
</Helmet>
);
}
// Schema.org para la organización (footer del sitio)
export function OrganizationSchema() {
const schema = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Diario El Día',
url: window.location.origin,
logo: `${window.location.origin}/logo.png`,
sameAs: [
'https://www.facebook.com/diarioeldia',
'https://twitter.com/diarioeldia',
'https://www.instagram.com/diarioeldia'
],
contactPoint: {
'@type': 'ContactPoint',
contactType: 'customer service',
telephone: '+54-11-1234-5678',
availableLanguage: 'Spanish'
}
};
return (
<Helmet>
<script type="application/ld+json">
{JSON.stringify(schema)}
</script>
</Helmet>
);
}
// Schema.org para breadcrumbs de navegación
export function BreadcrumbSchema({ items }: { items: Array<{ name: string; url: string }> }) {
const schema = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: item.url
}))
};
return (
<Helmet>
<script type="application/ld+json">
{JSON.stringify(schema)}
</script>
</Helmet>
);
}
// Schema.org para formulario de búsqueda
export function WebsiteSearchSchema() {
const schema = {
'@context': 'https://schema.org',
'@type': 'WebSite',
url: window.location.origin,
potentialAction: {
'@type': 'SearchAction',
target: {
'@type': 'EntryPoint',
urlTemplate: `${window.location.origin}/buscar?q={search_term_string}`
},
'query-input': 'required name=search_term_string'
}
};
return (
<Helmet>
<script type="application/ld+json">
{JSON.stringify(schema)}
</script>
</Helmet>
);
}

View File

@@ -16,19 +16,19 @@ export default function SearchBar({ onSearch }: SearchBarProps) {
};
return (
<form onSubmit={handleSearch} className="w-full max-w-2xl relative">
<form onSubmit={handleSearch} className="w-full relative flex items-center">
<input
type="text"
placeholder="¿Qué estás buscando? (Ej: Departamento, Fiat 600...)"
className="w-full py-4 pl-6 pr-14 rounded-full border border-gray-200 shadow-lg text-gray-800 text-lg focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all placeholder:text-gray-400"
className="w-full py-4 pl-8 pr-16 rounded-[1.8rem] bg-slate-900/40 border-none text-white text-lg focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all placeholder:text-slate-500 font-medium"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button
type="submit"
className="absolute right-2 top-2 p-2 bg-primary-600 text-white rounded-full hover:bg-primary-700 transition flex items-center justify-center w-10 h-10"
className="absolute right-2 w-12 h-12 bg-blue-600 text-white rounded-full hover:bg-blue-500 transition-all flex items-center justify-center shadow-lg shadow-blue-600/20 active:scale-90"
>
<Search size={20} />
<Search size={20} strokeWidth={3} />
</button>
</form>
);

View File

@@ -0,0 +1,15 @@
import { motion } from 'framer-motion';
export const StepWrapper = ({ children }: { children: React.ReactNode }) => {
return (
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
className="w-full max-w-2xl mx-auto"
>
{children}
</motion.div>
);
};

View File

@@ -0,0 +1,18 @@
import { useState, useEffect } from 'react';
// Hook para debounce (evita llamadas excesivas al escribir)
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}

View File

@@ -0,0 +1,66 @@
import { useState, useEffect, useRef } from 'react';
interface UseLazyImageProps {
src: string;
placeholder?: string;
threshold?: number;
}
// Hook personalizado para lazy loading de imágenes
export function useLazyImage({ src, placeholder = '', threshold = 0.25 }: UseLazyImageProps) {
const [imageSrc, setImageSrc] = useState(placeholder);
const [isLoading, setIsLoading] = useState(true);
const [isError, setIsError] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => {
let observer: IntersectionObserver;
let didCancel = false;
const currentImg = imgRef.current;
if (currentImg && imageSrc === placeholder) {
// Crear observer que detecta cuando la imagen entra en viewport
observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (!didCancel && entry.isIntersecting) {
setIsLoading(true);
setImageSrc(src);
}
});
},
{
threshold,
rootMargin: '50px' // Empezar a cargar 50px antes de que sea visible
}
);
observer.observe(currentImg);
}
return () => {
didCancel = true;
if (observer && currentImg) {
observer.unobserve(currentImg);
}
};
}, [src, imageSrc, placeholder, threshold]);
const handleLoad = () => {
setIsLoading(false);
};
const handleError = () => {
setIsLoading(false);
setIsError(true);
};
return {
imgRef,
imageSrc,
isLoading,
isError,
handleLoad,
handleError
};
}

View File

@@ -1,10 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { HelmetProvider } from 'react-helmet-async'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<HelmetProvider>
<App />
</HelmetProvider>
</StrictMode>,
)
)

View File

@@ -1,21 +1,20 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import SearchBar from '../components/SearchBar';
import ListingCard from '../components/ListingCard';
import { publicService } from '../services/publicService';
import type { Listing, Category } from '../types';
import { Filter, X } from 'lucide-react';
import type { Listing } from '../types';
import { Filter, X, Grid, List as ListIcon, TrendingUp, Search, Zap } from 'lucide-react';
import { processCategoriesForSelect, type FlatCategory } from '../utils/categoryTreeUtils';
import { motion, AnimatePresence } from 'framer-motion';
import SEO from '../components/SEO';
import { WebsiteSearchSchema, OrganizationSchema } from '../components/SchemaMarkup';
export default function HomePage() {
const [listings, setListings] = useState<Listing[]>([]);
// Usamos FlatCategory para el renderizado
const [flatCategories, setFlatCategories] = useState<FlatCategory[]>([]);
const [rawCategories, setRawCategories] = useState<Category[]>([]); // Guardamos raw para los botones del home
const [loading, setLoading] = useState(true);
// Estado de Búsqueda
// Search State
const [searchText, setSearchText] = useState('');
const [selectedCatId, setSelectedCatId] = useState<number | null>(null);
const [dynamicFilters, setDynamicFilters] = useState<Record<string, string>>({});
@@ -32,36 +31,38 @@ export default function HomePage() {
publicService.getCategories()
]);
setListings(latestListings);
setRawCategories(cats);
// Procesamos el árbol para el select
const processed = processCategoriesForSelect(cats);
setFlatCategories(processed);
} catch (e) { console.error(e); }
finally { setLoading(false); }
setFlatCategories(processCategoriesForSelect(cats));
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
const performSearch = async () => {
const performSearch = useCallback(async () => {
setLoading(true);
try {
const response = await import('../services/api').then(m => m.default.post('/listings/search', {
const { default: api } = await import('../services/api');
const response = await api.post('/listings/search', {
query: searchText,
categoryId: selectedCatId,
filters: dynamicFilters
}));
attributes: dynamicFilters
});
setListings(response.data);
} catch (e) { console.error(e); }
finally { setLoading(false); }
};
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}, [searchText, selectedCatId, dynamicFilters]);
useEffect(() => {
if (searchText || selectedCatId || Object.keys(dynamicFilters).length > 0) {
if (selectedCatId || Object.keys(dynamicFilters).length > 0 || searchText) {
performSearch();
}
}, [selectedCatId, dynamicFilters]);
}, [selectedCatId, dynamicFilters, searchText, performSearch]);
const handleSearchText = (q: string) => {
const handleSearchSubmit = (q: string) => {
setSearchText(q);
performSearch();
};
@@ -70,98 +71,289 @@ export default function HomePage() {
setDynamicFilters({});
setSelectedCatId(null);
setSearchText('');
publicService.getLatestListings().then(setListings); // Reset list
}
// Para los botones del Home, solo mostramos los Raíz
const rootCategories = rawCategories.filter(c => !c.parentId);
loadInitialData();
};
return (
<div className="min-h-screen bg-gray-50 pb-20">
{/* Hero */}
<div className="bg-primary-900 text-white py-16 px-4 relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-primary-900 to-gray-900 opacity-90"></div>
<div className="max-w-4xl mx-auto text-center relative z-10">
<h1 className="text-3xl md:text-5xl font-bold mb-6">Encuentra tu próximo objetivo</h1>
<div className="flex justify-center">
<SearchBar onSearch={handleSearchText} />
</div>
<div className="min-h-screen bg-[#f8fafc] font-sans pb-24">
<SEO
title="Los Mejores Clasificados"
description="Encuentra autos, inmuebles y servicios verificados. Publica tu aviso con la confianza del Diario El Día."
/>
<WebsiteSearchSchema />
<OrganizationSchema />
{/* --- HERO SECTION REFINADO --- */}
<div className="relative bg-[#020617] pt-24 pb-36 px-4 overflow-hidden">
{/* Capas de fondo: Orbes de luz y textura */}
<div className="absolute inset-0 z-0">
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[60%] bg-blue-600/10 rounded-full blur-[120px] animate-pulse"></div>
<div className="absolute bottom-[-10%] right-[-5%] w-[30%] h-[50%] bg-indigo-500/10 rounded-full blur-[100px]"></div>
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/carbon-fibre.png')] opacity-[0.03]"></div>
</div>
<div className="max-w-5xl mx-auto text-center relative z-10">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
{/* Badge superior estilizado */}
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-blue-500/5 border border-blue-500/20 mb-8 shadow-2xl">
<Zap size={14} className="text-blue-400 fill-blue-400/20" />
<span className="text-[9px] font-black text-blue-300 uppercase tracking-[0.3em]">
Marketplace Oficial Diario El Día
</span>
</div>
<h1 className="text-5xl md:text-7xl font-black text-white mb-6 tracking-tighter leading-[0.95] uppercase">
Tu próximo <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 via-blue-500 to-indigo-400 drop-shadow-[0_0_25px_rgba(59,130,246,0.3)]">
gran hallazgo
</span>
</h1>
<p className="text-slate-400 text-base md:text-lg max-w-xl mx-auto mb-12 font-medium leading-relaxed opacity-80">
Explora miles de clasificados verificados con la confianza de siempre, ahora en una experiencia digital Premium.
</p>
</motion.div>
{/* Buscador con efecto Glassmorphism */}
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.2, duration: 0.5 }}
className="max-w-2xl mx-auto"
>
<div className="bg-white/5 backdrop-blur-md p-1.5 rounded-[2rem] border border-white/10 shadow-[0_20px_50px_rgba(0,0,0,0.3)]">
<SearchBar onSearch={handleSearchSubmit} />
</div>
{/* Sugerencias rápidas */}
<div className="mt-6 flex flex-wrap justify-center gap-4">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Tendencias:</span>
{['Departamentos', 'Camionetas', 'Motos', 'Servicios'].map((tag) => (
<button
key={tag}
className="text-[10px] font-black text-slate-400 hover:text-blue-400 uppercase tracking-tighter transition-colors"
>
#{tag}
</button>
))}
</div>
</motion.div>
</div>
{/* Degradado hacia la sección blanca de abajo */}
<div className="absolute bottom-0 left-0 w-full h-24 bg-gradient-to-t from-[#f8fafc] to-transparent"></div>
</div>
<div className="max-w-7xl mx-auto px-4 mt-8 flex flex-col lg:flex-row gap-8">
{/* Main Content Layout */}
<div className="max-w-7xl mx-auto px-4 -mt-24 relative z-20">
<div className="flex flex-col lg:flex-row gap-10">
{/* SIDEBAR DE FILTROS */}
<div className="w-full lg:w-64 flex-shrink-0 space-y-6">
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-100">
<div className="flex justify-between items-center mb-4">
<h3 className="font-bold text-gray-800 flex items-center gap-2">
<Filter size={18} /> Filtros
</h3>
{(selectedCatId || Object.keys(dynamicFilters).length > 0) && (
<button onClick={clearFilters} className="text-xs text-red-500 hover:underline flex items-center">
<X size={12} /> Limpiar
</button>
)}
</div>
{/* Filtro Categoría (MEJORADO) */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-600 mb-2">Categoría</label>
<select
className="w-full border border-gray-300 rounded p-2 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
value={selectedCatId || ''}
onChange={(e) => setSelectedCatId(Number(e.target.value) || null)}
>
<option value="">Todas las categorías</option>
{flatCategories.map(cat => (
<option
key={cat.id}
value={cat.id}
className={cat.level === 0 ? "font-bold text-gray-900" : "text-gray-600"}
{/* SIDEBAR: Advanced Filters */}
<aside className="w-full lg:w-80 flex-shrink-0">
<div className="bg-white rounded-[3rem] shadow-2xl shadow-slate-200/40 border border-slate-100 p-10 mt-10 lg:mt-0 sticky top-28">
<div className="flex justify-between items-center mb-10">
<h3 className="text-xs font-black text-slate-900 uppercase tracking-widest flex items-center gap-3">
<div className="p-2 bg-blue-50 rounded-lg text-blue-600">
<Filter size={18} />
</div>
Filtros
</h3>
{(selectedCatId || Object.keys(dynamicFilters).length > 0 || searchText) && (
<button
onClick={clearFilters}
className="text-[9px] font-black text-red-500 hover:bg-red-50 hover:px-3 py-2 rounded-xl transition-all flex items-center gap-1 uppercase tracking-tighter"
>
{cat.label}
</option>
))}
</select>
</div>
<X size={12} /> Limpiar
</button>
)}
</div>
{/* Filtros Dinámicos */}
{selectedCatId && (
<div className="space-y-3 pt-3 border-t">
<p className="text-xs font-bold text-gray-400 uppercase">Atributos</p>
<div className="space-y-10">
{/* Category Selection */}
<div>
<input
type="text"
placeholder="Filtrar por Kilometraje..."
className="w-full border p-2 rounded text-sm"
onChange={(e) => setDynamicFilters({ ...dynamicFilters, 'Kilometraje': e.target.value })}
/>
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-4 ml-1">Rubro Principal</label>
<div className="relative">
<select
className="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4.5 text-sm font-black text-slate-800 focus:ring-4 focus:ring-blue-600/5 focus:border-blue-200 outline-none transition-all appearance-none cursor-pointer"
value={selectedCatId || ''}
onChange={(e) => {
setSelectedCatId(Number(e.target.value) || null);
setDynamicFilters({});
}}
>
<option value="">Todos los rubros</option>
{flatCategories.map(cat => (
<option
key={cat.id}
value={cat.id}
className={cat.level === 0 ? "font-black" : "font-semibold"}
>
{cat.label}
</option>
))}
</select>
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-slate-300">
<Filter size={14} />
</div>
</div>
</div>
{/* Dynamic Attributes */}
<AnimatePresence>
{selectedCatId && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="space-y-6 pt-10 border-t border-slate-50"
>
<p className="flex items-center gap-3 text-[10px] font-black text-blue-600 uppercase tracking-widest">
<TrendingUp size={16} /> Búsqueda avanzada
</p>
<div className="space-y-4">
<div className="relative">
<input
type="text"
placeholder="Ej: Kilometraje, Ambientes..."
className="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 text-sm font-bold text-slate-700 outline-none focus:ring-4 focus:ring-blue-600/5 focus:border-blue-200 transition-all placeholder:text-slate-300"
onKeyDown={(e) => {
if (e.key === 'Enter') {
const val = (e.target as HTMLInputElement).value;
if (val) {
setDynamicFilters({ ...dynamicFilters, [Date.now()]: val });
(e.target as HTMLInputElement).value = '';
}
}
}}
/>
<div className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-300">
<Search size={14} />
</div>
</div>
{Object.keys(dynamicFilters).length > 0 && (
<div className="flex flex-wrap gap-2">
{Object.entries(dynamicFilters).map(([k, v]) => (
<motion.span
key={k}
layout
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="bg-blue-600 text-white px-3 py-1.5 rounded-xl text-[10px] font-black flex items-center gap-2 shadow-lg shadow-blue-200 uppercase tracking-tighter"
>
{v}
<X
size={12}
className="cursor-pointer hover:bg-white/20 rounded"
onClick={() => {
const next = { ...dynamicFilters }; delete next[k]; setDynamicFilters(next);
}}
/>
</motion.span>
))}
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</aside>
{/* MAIN LIST: Content Grid */}
<main className="flex-1 lg:pt-10">
<div className="mb-12 flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<motion.h2
key={selectedCatId || 'root'}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className="text-4xl font-black text-slate-900 tracking-tighter uppercase leading-none"
>
{loading ? 'Sincronizando...' : selectedCatId
? flatCategories.find(c => c.id === selectedCatId)?.name
: 'Últimos Clasificados'}
</motion.h2>
<div className="flex items-center gap-3 mt-3">
<span className="flex items-center gap-1.5 px-3 py-1 bg-white rounded-full border border-slate-100 text-[10px] font-black text-slate-400 uppercase tracking-widest shadow-sm">
<div className="w-1.5 h-1.5 rounded-full bg-green-500"></div>
{listings.length} resultados
</span>
</div>
</div>
)}
</div>
</div>
{/* LISTADO */}
<div className="flex-1">
<h2 className="text-2xl font-bold text-gray-900 mb-6">
{loading ? 'Cargando...' : selectedCatId
? `${listings.length} Resultados en ${flatCategories.find(c => c.id === selectedCatId)?.name}`
: 'Resultados Recientes'}
</h2>
{!loading && listings.length === 0 && (
<div className="text-center text-gray-500 py-20 bg-white rounded-lg border border-dashed border-gray-300">
No se encontraron avisos con esos criterios.
<div className="flex items-center bg-white p-1.5 rounded-2xl shadow-xl shadow-slate-200/50 border border-slate-100">
<button className="p-2.5 bg-slate-900 text-white rounded-xl shadow-lg">
<Grid size={18} />
</button>
<button className="p-2.5 text-slate-300 hover:text-slate-500 rounded-xl">
<ListIcon size={18} />
</button>
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{listings.map(listing => (
<ListingCard key={listing.id} listing={listing} />
))}
</div>
{/* Grid Container */}
<div className="relative min-h-[600px]">
<AnimatePresence mode="popLayout">
{loading ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-10"
>
{[1, 2, 3, 4, 5, 6].map(i => (
<div key={i} className="bg-white rounded-[3rem] h-[420px] animate-pulse border border-slate-50 shadow-sm"></div>
))}
</motion.div>
) : listings.length === 0 ? (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center justify-center py-40 bg-white rounded-[4rem] border border-slate-100 shadow-2xl shadow-slate-200/50 group"
>
<div className="bg-slate-50 w-32 h-32 rounded-full flex items-center justify-center mb-8 border border-white shadow-inner group-hover:scale-110 transition-transform duration-500">
<Search size={48} className="text-slate-200 group-hover:text-blue-200 transition-colors" />
</div>
<h3 className="text-2xl font-black text-slate-900 uppercase tracking-tighter">Sin resultados</h3>
<p className="text-slate-400 font-bold uppercase tracking-widest text-[10px] mt-2 mb-10 max-w-xs text-center opacity-70">
No hay avisos que coincidan con los criterios aplicados actualmente.
</p>
<button
onClick={clearFilters}
className="bg-slate-900 hover:bg-black text-white px-10 py-4 rounded-2xl font-black uppercase text-xs tracking-[0.2em] shadow-2xl shadow-slate-200 active:scale-95 transition-all"
>
Restablecer vista
</button>
</motion.div>
) : (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-10"
>
{listings.map((listing, idx) => (
<motion.div
key={listing.id}
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.05, duration: 0.4 }}
>
<ListingCard listing={listing} />
</motion.div>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</main>
</div>
</div>
</div>

View File

@@ -1,8 +1,15 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useParams, Link } from 'react-router-dom';
import { publicService } from '../services/publicService';
import type { ListingDetail } from '../types';
import { Calendar, Tag, ChevronLeft } from 'lucide-react';
import {
Tag, ChevronLeft, Share2, MessageCircle,
Info, Calendar, Eye, ShieldCheck, Heart
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import LazyImage from '../components/LazyImage';
import SEO from '../components/SEO';
import { ListingSchema } from '../components/SchemaMarkup';
export default function ListingDetailPage() {
const { id } = useParams();
@@ -15,102 +22,189 @@ export default function ListingDetailPage() {
publicService.getListingDetail(parseInt(id))
.then(data => {
setDetail(data);
if (data.images.length > 0) {
setActiveImage(data.images[0].url);
}
if (data.images.length > 0) setActiveImage(data.images[0].url);
})
.catch(err => console.error(err))
.finally(() => setLoading(false));
}
}, [id]);
if (loading) return <div className="min-h-screen flex items-center justify-center">Cargando...</div>;
if (!detail) return <div className="min-h-screen flex items-center justify-center">Aviso no encontrado.</div>;
if (loading) return (
<div className="min-h-screen flex items-center justify-center bg-slate-50">
<div className="w-10 h-10 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
</div>
);
if (!detail) return <div>No encontrado</div>;
const { listing, attributes, images } = detail;
const baseUrl = import.meta.env.VITE_BASE_URL; // Ajustar puerto según backend launchSettings
const baseUrl = import.meta.env.VITE_BASE_URL;
return (
<div className="max-w-6xl mx-auto px-4 py-8">
<a href="/" className="inline-flex items-center text-gray-500 hover:text-primary-600 mb-6">
<ChevronLeft size={20} /> Volver al listado
</a>
<div className="min-h-screen bg-[#f8fafc] font-sans pb-24">
{/* --- SEO Y METADATOS (Invisibles) --- */}
<SEO title={listing.title} description={listing.description} image={activeImage} />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Galería de Fotos */}
<div className="lg:col-span-2 space-y-4">
<div className="aspect-video bg-gray-100 rounded-xl overflow-hidden border border-gray-200">
{activeImage ? (
<img src={`${baseUrl}${activeImage}`} alt={listing.title} className="w-full h-full object-contain" />
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400">Sin imágenes</div>
)}
{/* RE-INTEGRADO: Vital para que Google muestre el precio en las búsquedas */}
<ListingSchema listing={{
id: listing.id,
title: listing.title,
description: listing.description || '',
price: listing.price,
category: listing.categoryName || 'Clasificados',
images: images.map(i => `${baseUrl}${i.url}`)
}} />
{/* --- NAVEGACIÓN SUPERIOR --- */}
<div className="bg-white/80 backdrop-blur-md border-b border-slate-100 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
<Link to="/" className="flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 hover:text-blue-600 transition-colors">
<ChevronLeft size={16} /> Volver al listado
</Link>
<div className="flex gap-2">
<button className="p-2 text-slate-400 hover:text-rose-500 transition-colors"><Heart size={20} /></button>
<button className="p-2 text-slate-400 hover:text-blue-500 transition-colors"><Share2 size={20} /></button>
</div>
{images.length > 1 && (
<div className="flex gap-2 overflow-x-auto pb-2">
{images.map(img => (
<button
key={img.id}
onClick={() => setActiveImage(img.url)}
className={`w-24 h-24 flex-shrink-0 rounded-lg overflow-hidden border-2 transition ${activeImage === img.url ? 'border-primary-600' : 'border-transparent'
}`}
>
<img src={`${baseUrl}${img.url}`} alt="" className="w-full h-full object-cover" />
</button>
))}
</div>
)}
</div>
</div>
{/* Información Principal */}
<div className="space-y-6">
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
<div className="flex justify-between items-start mb-4">
<span className="bg-primary-50 text-primary-700 px-3 py-1 rounded-full text-sm font-medium">
Clasificado #{listing.id}
</span>
<span className="text-gray-400 text-sm flex items-center gap-1">
<Calendar size={14} />
{new Date(listing.createdAt).toLocaleDateString()}
</span>
</div>
<div className="max-w-7xl mx-auto px-6 pt-10">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10">
<h1 className="text-3xl font-bold text-gray-900 mb-2">{listing.title}</h1>
<div className="text-4xl font-bold text-primary-600 mb-6">
{listing.currency} {listing.price.toLocaleString()}
</div>
<button className="w-full bg-green-600 hover:bg-green-700 text-white font-bold py-3 rounded-lg transition mb-4">
Contactar Anunciante
</button>
<button className="w-full bg-white border border-gray-300 text-gray-700 font-bold py-3 rounded-lg hover:bg-gray-50 transition">
Compartir
</button>
</div>
{/* Atributos Dinámicos */}
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Tag size={20} className="text-primary-600" />
Características
</h3>
<div className="grid grid-cols-2 gap-y-4 gap-x-2">
{attributes.map(attr => (
<div key={attr.id} className="border-b border-gray-50 pb-2">
<span className="block text-xs text-gray-500 uppercase tracking-wide">{attr.attributeName}</span>
<span className="font-medium text-gray-800">{attr.value}</span>
{/* --- COLUMNA IZQUIERDA: VISUALES --- */}
<div className="lg:col-span-8 space-y-8">
<div className="bg-white rounded-[2.5rem] p-3 shadow-2xl shadow-slate-200/50 border border-slate-100 relative group">
<div className="aspect-[16/10] rounded-[2rem] overflow-hidden bg-slate-50 relative">
<AnimatePresence mode="wait">
<motion.div key={activeImage} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="w-full h-full">
<LazyImage src={`${baseUrl}${activeImage}`} alt={listing.title} className="w-full h-full object-cover" />
</motion.div>
</AnimatePresence>
<div className="absolute top-6 left-6 bg-white/90 backdrop-blur-md px-4 py-2 rounded-2xl flex items-center gap-2 shadow-xl border border-white/20">
<ShieldCheck size={16} className="text-blue-600" />
<span className="text-[10px] font-black uppercase tracking-widest text-slate-900">Verificado</span>
</div>
))}
{attributes.length === 0 && <p className="text-gray-400 text-sm">Sin características adicionales.</p>}
</div>
{images.length > 1 && (
<div className="flex gap-3 mt-4 px-2 overflow-x-auto pb-2">
{images.map(img => (
<button key={img.id} onClick={() => setActiveImage(img.url)} className={`relative w-16 h-16 rounded-xl overflow-hidden border-2 transition-all flex-shrink-0 ${activeImage === img.url ? 'border-blue-600 shadow-md scale-105' : 'border-transparent opacity-60'}`}>
<img src={`${baseUrl}${img.url}`} className="w-full h-full object-cover" />
</button>
))}
</div>
)}
</div>
<div className="bg-white rounded-[2.5rem] p-10 shadow-xl shadow-slate-200/40 border border-slate-100">
<div className="flex items-center gap-4 mb-6">
<div className="w-1 h-6 bg-blue-600 rounded-full"></div>
<h3 className="text-lg font-black text-slate-900 uppercase tracking-tighter">Reseña del Anunciante</h3>
</div>
<p className="text-slate-500 text-lg leading-relaxed font-medium italic">
"{listing.description || 'Sin descripción detallada.'}"
</p>
</div>
</div>
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
<h3 className="text-lg font-semibold mb-2">Descripción</h3>
<p className="text-gray-600 leading-relaxed whitespace-pre-line">
{listing.description || "Sin descripción proporcionada."}
</p>
</div>
{/* --- COLUMNA DERECHA: INFO Y ACCIÓN (TODO DENTRO DE UNA CAJA STICKY) --- */}
<aside className="lg:col-span-4">
<div className="sticky top-24 space-y-6">
{/* CARD PRINCIPAL DE COMPRA */}
<div className="bg-white rounded-[2.5rem] shadow-2xl shadow-slate-200/60 border border-slate-100 overflow-hidden">
<div className="p-8">
<div className="flex justify-between items-center mb-4">
<span className="text-[9px] font-black text-slate-300 uppercase tracking-widest">ID_#{listing.id.toString().padStart(6, '0')}</span>
<span className="flex items-center gap-1.5 text-emerald-500 font-black text-[9px] uppercase bg-emerald-50 px-2 py-1 rounded-lg">
<div className="w-1 h-1 bg-emerald-500 rounded-full animate-pulse"></div> en stock
</span>
</div>
<h1 className="text-3xl font-black text-slate-900 mb-6 uppercase tracking-tighter leading-tight">
{listing.title}
</h1>
{/* Bloque de Precio Ajustado */}
<div className="mb-8 p-5 bg-slate-50 rounded-3xl border border-slate-100 relative group overflow-hidden">
<Tag
size={30}
className="absolute right-1 top-1 text-slate-200 rotate-12 group-hover:text-blue-100 transition-colors duration-500"
/>
<div className="flex items-center gap-2 mb-1 relative z-10">
<Tag size={12} className="text-blue-500" /> {/* Versión mini funcional */}
<span className="text-[9px] font-black uppercase text-slate-400 tracking-widest block">
Precio Final de Venta
</span>
</div>
<div className="flex items-baseline gap-1.5 flex-wrap relative z-10">
<span className="text-lg font-black text-blue-600">{listing.currency}</span>
<span className="text-4xl font-black text-slate-950 tracking-tighter">
{listing.price.toLocaleString('es-AR')}
</span>
</div>
</div>
<div className="space-y-3">
<button className="w-full py-4 bg-[#00D15D] hover:bg-[#00B851] text-white rounded-2xl font-black uppercase text-[11px] tracking-widest flex items-center justify-center gap-3 shadow-xl shadow-emerald-500/20 transition-all active:scale-95">
<MessageCircle size={18} />
Contactar Vendedor
</button>
<button className="w-full py-4 bg-slate-900 hover:bg-black text-white rounded-2xl font-black uppercase text-[11px] tracking-widest transition-all active:scale-95 shadow-xl shadow-slate-200">
Realizar Oferta
</button>
</div>
{/* Stats integrados */}
<div className="flex justify-between pt-6 mt-6 border-t border-slate-50">
<div className="flex flex-col">
<span className="text-[8px] font-black text-slate-400 uppercase mb-1">Publicado</span>
<span className="text-[11px] font-bold text-slate-600 flex items-center gap-1">
<Calendar size={12} className="text-blue-500" /> {new Date(listing.createdAt).toLocaleDateString()}
</span>
</div>
<div className="flex flex-col items-end">
<span className="text-[8px] font-black text-slate-400 uppercase mb-1">Interés</span>
<span className="text-[11px] font-bold text-slate-600 flex items-center gap-1">
<Eye size={12} className="text-orange-500" /> {listing.viewCount} visitas
</span>
</div>
</div>
</div>
{/* FICHA TÉCNICA INTEGRADA (Para que el sticky no la tape) */}
<div className="bg-slate-50/50 border-t border-slate-100 p-8">
<h4 className="text-[9px] font-black text-slate-400 uppercase tracking-[0.2em] mb-4">
Especificaciones Técnicas
</h4>
<div className="space-y-2">
{attributes.map(attr => (
<div key={attr.id} className="flex justify-between items-center py-1">
<span className="text-[10px] font-bold text-slate-400 uppercase">{attr.attributeName}</span>
<span className="font-black text-slate-800 text-xs tracking-tight">{attr.value}</span>
</div>
))}
</div>
</div>
</div>
{/* CONSEJO DE SEGURIDAD FUERA DE LA CARD PRINCIPAL PERO DENTRO DEL STICKY */}
<div className="p-6 rounded-[2rem] bg-indigo-900 text-white relative overflow-hidden shadow-xl">
<div className="flex gap-4 items-start relative z-10">
<Info size={20} className="text-indigo-400 flex-shrink-0" />
<div>
<h5 className="font-black uppercase text-[9px] tracking-widest text-indigo-200 mb-1">Consejo de Seguridad</h5>
<p className="text-[10px] text-slate-300 leading-snug">
Verifica la identidad del vendedor y no transfieras dinero sin ver el producto.
</p>
</div>
</div>
</div>
</div>
</aside>
</div>
</div>
</div>

View File

@@ -0,0 +1,224 @@
import { useState } from 'react';
import { publicAuthService } from '../services/authService';
import { usePublicAuthStore } from '../store/publicAuthStore';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { Mail, Lock, User, ArrowRight, ShieldCheck } from 'lucide-react';
export default function LoginPage() {
const setUser = usePublicAuthStore(state => state.setUser);
const [isLogin, setIsLogin] = useState(true);
const [formData, setFormData] = useState({ username: '', email: '', password: '' });
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [showMfa, setShowMfa] = useState(false);
const [mfaCode, setMfaCode] = useState('');
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
if (isLogin) {
const res = await publicAuthService.login(formData.username, formData.password);
if (res.success) {
if (res.requiresMfa) {
setShowMfa(true);
} else {
setUser({ username: formData.username });
navigate('/');
}
} else {
setError(res.errorMessage || 'Error al iniciar sesión');
}
} else {
const res = await publicAuthService.register(formData.username, formData.email, formData.password);
if (res.success) {
setIsLogin(true);
setError('Registro exitoso. Por favor inicie sesión.');
} else {
setError(res.errorMessage || 'Error al registrarse');
}
}
} catch {
setError('Error de conexión');
} finally {
setLoading(false);
}
};
if (showMfa) {
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-6 font-display">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="bg-white w-full max-w-md p-10 rounded-[2.5rem] shadow-2xl border border-slate-100"
>
<div className="text-center mb-8">
<div className="bg-blue-600 w-16 h-16 rounded-3xl flex items-center justify-center text-white mx-auto shadow-xl shadow-blue-500/20 mb-6">
<ShieldCheck size={32} />
</div>
<h1 className="text-3xl font-black text-slate-900 tracking-tight">Verificación MFA</h1>
<p className="text-slate-500 font-bold uppercase tracking-widest text-[10px] mt-2">Ingrese el código de su aplicación de autenticación</p>
</div>
<div className="space-y-6">
<input
type="text"
placeholder="000000"
value={mfaCode}
onChange={(e) => setMfaCode(e.target.value)}
className="w-full text-center text-4xl font-black tracking-[0.5em] py-6 bg-slate-50 border-none rounded-3xl focus:ring-4 focus:ring-blue-100 outline-none transition-all"
maxLength={6}
/>
<button
className="w-full bg-slate-900 text-white font-black uppercase tracking-widest py-5 rounded-3xl shadow-xl hover:bg-black transition-all"
onClick={() => navigate('/')} // En un flujo real llamaríamos a verifyMfa
>
Verificar Identidad
</button>
</div>
</motion.div>
</div>
);
}
return (
<div className="min-h-screen bg-[#f8fafc] flex items-center justify-center p-6 font-display">
<div className="w-full max-w-5xl bg-white rounded-[3rem] shadow-2xl overflow-hidden flex flex-col md:flex-row border border-slate-100">
{/* Lado Izquierdo: Visual & Branding */}
<div className="md:w-1/2 bg-slate-900 p-12 flex flex-col justify-between text-white relative overflow-hidden">
<div className="absolute top-0 right-0 w-64 h-64 bg-blue-600/20 rounded-full -mr-32 -mt-32 blur-3xl"></div>
<div className="relative z-10">
<div className="bg-blue-600 w-12 h-12 rounded-2xl flex items-center justify-center mb-8 shadow-lg shadow-blue-500/20">
<ShieldCheck size={24} />
</div>
<h2 className="text-5xl font-black tracking-tighter leading-none mb-6">
Vende y compra con total confianza.
</h2>
<p className="text-slate-400 font-medium text-lg leading-relaxed">
Únete a la comunidad de clasificados más grande de la región. Seguridad verificada y contacto directo.
</p>
</div>
<div className="flex gap-8 relative z-10">
<div className="flex flex-col">
<span className="text-2xl font-black tracking-tight">25k+</span>
<span className="text-[10px] text-slate-500 font-bold uppercase tracking-widest">Avisos activos</span>
</div>
<div className="flex flex-col">
<span className="text-2xl font-black tracking-tight">10k+</span>
<span className="text-[10px] text-slate-500 font-bold uppercase tracking-widest">Usuarios verificados</span>
</div>
</div>
</div>
{/* Lado Derecho: Formulario */}
<div className="md:w-1/2 p-12 lg:p-16">
<div className="flex justify-between items-center mb-12">
<div>
<h1 className="text-3xl font-black text-slate-900 tracking-tight">
{isLogin ? '¡Hola de nuevo!' : 'Crea tu cuenta'}
</h1>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1">
{isLogin ? 'Accede a tu panel personal' : 'Registrarse es gratis y rápido'}
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-4">Nombre de usuario</label>
<div className="relative">
<User className="absolute left-5 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
type="text"
required
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="w-full pl-14 pr-6 py-5 bg-slate-50 border-none rounded-3xl focus:ring-4 focus:ring-blue-100 outline-none transition-all font-medium"
placeholder="ej: juanperez"
/>
</div>
</div>
{!isLogin && (
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-4">Correo Electrónico</label>
<div className="relative">
<Mail className="absolute left-5 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
type="email"
required
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full pl-14 pr-6 py-5 bg-slate-50 border-none rounded-3xl focus:ring-4 focus:ring-blue-100 outline-none transition-all font-medium"
placeholder="tu@email.com"
/>
</div>
</div>
)}
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-4">Contraseña</label>
<div className="relative">
<Lock className="absolute left-5 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
type="password"
required
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full pl-14 pr-6 py-5 bg-slate-50 border-none rounded-3xl focus:ring-4 focus:ring-blue-100 outline-none transition-all font-medium"
placeholder="••••••••"
/>
</div>
</div>
{error && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="p-4 bg-rose-50 rounded-2xl text-rose-600 text-xs font-bold border border-rose-100">
{error}
</motion.div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white font-black uppercase tracking-widest py-5 rounded-3xl shadow-xl shadow-blue-500/30 hover:bg-blue-700 hover:-translate-y-1 active:translate-y-0 transition-all flex items-center justify-center gap-3 group"
>
{loading ? 'Cargando...' : isLogin ? 'Entrar' : 'Registrarme'}
<ArrowRight size={20} className="group-hover:translate-x-1 transition-transform" />
</button>
</form>
{/* Divisor */}
<div className="my-10 flex items-center gap-4 text-slate-300">
<div className="flex-1 h-px bg-slate-100"></div>
<span className="text-[10px] font-black uppercase tracking-widest px-2">O continuar con</span>
<div className="flex-1 h-px bg-slate-100"></div>
</div>
{/* Google Login Placeholder Button */}
<button className="w-full bg-white text-slate-900 border-2 border-slate-100 font-bold py-5 rounded-3xl flex items-center justify-center gap-4 hover:bg-slate-50 transition-all shadow-sm">
<img src="https://www.svgrepo.com/show/475656/google-color.svg" className="w-6 h-6" alt="Google" />
Sign in with Google
</button>
<div className="mt-12 text-center">
<button
onClick={() => setIsLogin(!isLogin)}
className="text-[10px] font-black uppercase tracking-widest text-slate-400 hover:text-blue-600 transition-colors"
>
{isLogin ? '¿No tienes cuenta? Regístrate aquí' : '¿Ya tienes cuenta? Inicia sesión'}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,478 @@
import { useEffect, useState } from 'react';
import { publicAuthService } from '../services/authService';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import type { Listing } from '../types';
import {
User, Package, Settings, ChevronRight,
Clock, Eye, ShieldCheck, QrCode, Lock,
Bell, RefreshCcw
} from 'lucide-react';
import { usePublicAuthStore } from '../store/publicAuthStore';
import api from '../services/api';
import LazyImage from '../components/LazyImage';
import { useWizardStore } from '../store/wizardStore';
import clsx from 'clsx';
interface MfaData {
qrCodeUri: string;
secret: string;
}
type TabType = 'listings' | 'security' | 'settings';
export default function ProfilePage() {
const { user, logout } = usePublicAuthStore();
const [activeTab, setActiveTab] = useState<TabType>('listings');
const [listings, setListings] = useState<Listing[]>([]);
const [loading, setLoading] = useState(true);
const [mfaSetupData, setMfaSetupData] = useState<MfaData | null>(null);
const [mfaCode, setMfaCode] = useState('');
const baseUrl = import.meta.env.VITE_BASE_URL;
const setRepublishData = useWizardStore(state => state.setRepublishData);
const [republishTarget, setRepublishTarget] = useState<Listing | null>(null);
const navigate = useNavigate();
useEffect(() => {
if (!user) {
navigate('/login');
return;
}
loadMyData();
}, [navigate, user]);
const handleConfirmRepublish = async () => {
if (!republishTarget) return;
try {
// Buscamos el detalle técnico (Modelo, KM, Puertas, etc.)
const res = await api.get(`/listings/${republishTarget.id}`);
// Cargamos el Store con toda la data
setRepublishData(res.data);
// Navegamos al Wizard
navigate('/publicar');
} catch (error) {
alert("Error al recuperar datos técnicos del aviso");
} finally {
setRepublishTarget(null);
}
};
const loadMyData = async () => {
try {
const response = await api.get('/listings/my');
setListings(response.data);
} catch (error) {
console.error("Error al cargar datos del perfil", error);
} finally {
setLoading(false);
}
};
const handleSetupMfa = async () => {
try {
const data = await publicAuthService.setupMfa();
setMfaSetupData(data);
} catch (error) {
console.error("Error al configurar MFA", error);
}
};
const handleVerifyAndEnableMfa = async () => {
if (mfaCode.length !== 6) return;
try {
const res = await publicAuthService.verifyMfa(mfaCode);
if (res.success) {
alert("¡MFA activado con éxito!");
setMfaSetupData(null);
setMfaCode('');
}
} catch (error) {
alert("Error al verificar código.");
}
};
const handleUpdateOverlay = async (id: number, status: string | null) => {
try {
await api.patch(`/listings/${id}/overlay`, JSON.stringify(status), {
headers: { 'Content-Type': 'application/json' }
});
loadMyData();
} catch (error) {
alert("Error al actualizar estado");
}
};
if (loading) return (
<div className="min-h-screen flex items-center justify-center bg-slate-50">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
</div>
);
return (
<div className="min-h-screen bg-[#f8fafc] font-sans">
{/* 1. HEADER REFINADO (Más bajo y elegante) */}
<div className="bg-[#0f172a] relative overflow-hidden pt-12 pb-20">
{/* Luces de fondo más tenues */}
<div className="absolute top-0 right-0 w-[400px] h-[400px] bg-blue-500/5 rounded-full blur-[100px]"></div>
<div className="absolute bottom-0 left-1/4 w-[300px] h-[300px] bg-indigo-500/5 rounded-full blur-[80px]"></div>
<div className="max-w-6xl mx-auto px-6 relative z-10">
<div className="flex flex-col md:flex-row items-end gap-8">
{/* Avatar más integrado */}
<div className="relative">
<div className="w-24 h-24 bg-white rounded-[2rem] flex items-center justify-center shadow-2xl border border-white/10 relative z-10">
<User size={40} className="text-slate-900" />
</div>
<div className="absolute z-20 -bottom-2 -right-2 bg-emerald-500 w-8 h-8 rounded-full border-4 border-[#0f172a] flex items-center justify-center text-white shadow-lg">
<ShieldCheck size={14} />
</div>
</div>
<div className="text-center md:text-left flex-1 pb-2">
<div className="flex items-center justify-center md:justify-start gap-4 mb-3">
<h1 className="text-3xl font-black text-white tracking-tighter uppercase leading-none">
{user?.username}
</h1>
<span className="bg-blue-500/10 text-blue-400 px-3 py-1.5 rounded-xl text-[9px] font-black uppercase tracking-[0.2em] border border-blue-500/20">
Verificado
</span>
</div>
<div className="flex items-center justify-center md:justify-start gap-6 text-slate-400">
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest">
<Package size={14} className="text-blue-500" />
{listings.filter(l => l.status === 'Published').length} ACTIVAS
</div>
<div className="w-1 h-1 bg-slate-800 rounded-full"></div>
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest">
<Clock size={14} className="text-slate-600" />
Miembro desde 2025
</div>
</div>
</div>
<div className="flex gap-3 pb-2">
<button className="bg-white/5 hover:bg-white/10 text-white p-3 rounded-2xl border border-white/10 transition-all">
<Bell size={20} />
</button>
<button
onClick={() => { logout(); navigate('/'); }}
className="bg-rose-500/10 hover:bg-rose-500 text-rose-500 hover:text-white px-6 py-3 rounded-2xl border border-rose-500/20 transition-all text-[10px] font-black uppercase tracking-widest"
>
Cerrar Sesión
</button>
</div>
</div>
</div>
</div>
{/* 2. CONTENIDO (Sidebar + Main) */}
<div className="max-w-6xl mx-auto px-6 -mt-8 relative z-20">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
{/* SIDEBAR MÁS ESTILIZADO */}
<aside className="lg:col-span-3">
<div className="bg-white/80 backdrop-blur-xl rounded-[2.5rem] p-3 shadow-2xl shadow-slate-200/50 border border-white sticky top-24">
<nav className="space-y-1">
<SidebarItem
icon={<Package size={18} />}
label="Publicaciones"
active={activeTab === 'listings'}
onClick={() => setActiveTab('listings')}
/>
<SidebarItem
icon={<Lock size={18} />}
label="Seguridad"
active={activeTab === 'security'}
onClick={() => setActiveTab('security')}
/>
<SidebarItem
icon={<Settings size={18} />}
label="Ajustes"
active={activeTab === 'settings'}
onClick={() => setActiveTab('settings')}
/>
</nav>
</div>
</aside>
{/* CONTENIDO DINÁMICO */}
<main className="lg:col-span-9">
<div className="bg-white rounded-[3rem] p-10 shadow-2xl shadow-slate-200/60 border border-slate-100 min-h-[550px]">
<AnimatePresence mode="wait">
{activeTab === 'listings' && (
<motion.div key="listings" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}>
<h2 className="text-2xl font-black text-slate-900 mb-8 uppercase tracking-tighter">Historial de anuncios</h2>
{listings.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 opacity-30">
<Package size={64} className="mb-4" />
<p className="font-black uppercase tracking-widest text-xs">Sin actividad reciente</p>
</div>
) : (
<div className="space-y-6">
{listings.map(item => (
<div key={item.id} className="flex flex-col p-6 bg-slate-50 hover:bg-white hover:shadow-xl rounded-[2.5rem] transition-all border border-transparent hover:border-slate-100 group">
<div className="flex gap-6 items-start">
{/* Contenedor de imagen con tamaño fijo y aspecto cuadrado */}
<div className="w-24 h-24 bg-white rounded-3xl overflow-hidden flex-shrink-0 relative border border-slate-100 shadow-sm">
{item.mainImageUrl ? (
<LazyImage
src={`${baseUrl}${item.mainImageUrl}`}
alt={item.title}
className="w-full h-full"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-slate-200 bg-slate-50">
<Package size={32} />
</div>
)}
</div>
<div className="flex-1 flex flex-col min-h-[96px] justify-between">
<div className="flex justify-between items-start">
<div>
<h4 className="font-black text-slate-900 leading-tight group-hover:text-blue-600 transition-colors uppercase text-sm">
{item.title}
</h4>
<div className="flex gap-4 mt-2">
<span className="text-[10px] font-bold text-slate-400 flex items-center gap-1 uppercase">
<Clock size={12} /> {new Date(item.createdAt).toLocaleDateString()}
</span>
<span className="text-[10px] font-bold text-slate-400 flex items-center gap-1 uppercase">
<Eye size={12} /> {item.viewCount} vistas
</span>
</div>
</div>
<div className="text-right">
<p className="font-black text-slate-900 text-lg">${item.price.toLocaleString()}</p>
{/* BADGE DINÁMICO DE ESTADO */}
<span className={clsx(
"text-[9px] font-black uppercase px-2 py-1 rounded-lg border shadow-sm inline-block mt-1",
// Estilos para Publicado
item.status === 'Published' && "text-emerald-500 bg-emerald-50 border-emerald-100",
// Estilos para Pendiente de Moderación (Naranja)
item.status === 'Pending' && "text-amber-500 bg-amber-50 border-amber-100",
// Estilos para Borrador o Error (Gris)
(item.status === 'Draft' || !item.status) && "text-slate-400 bg-slate-50 border-slate-200"
)}>
{item.status === 'Published' ? 'Publicado' :
item.status === 'Pending' ? 'En Revisión' :
'Borrador'}
</span>
</div>
</div>
</div>
</div>
{/* PANEL DE GESTIÓN */}
<div className="flex items-center justify-between mt-4 pt-4 border-t border-slate-100">
<div className="flex gap-2">
{['Vendido', 'Reservado', 'Alquilado'].map(status => (
<button
key={status}
onClick={() => handleUpdateOverlay(item.id, item.overlayStatus === status ? null : status)}
className={clsx(
"px-3 py-1.5 rounded-xl text-[9px] font-black uppercase transition-all border",
item.overlayStatus === status
? "bg-slate-900 text-white border-slate-900 shadow-lg scale-105"
: "bg-white text-slate-400 border-slate-200 hover:border-slate-400"
)}
>
{status}
</button>
))}
</div>
<button
onClick={() => setRepublishTarget(item)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-xl text-[9px] font-black uppercase shadow-lg shadow-blue-200 hover:bg-blue-700 transition-all active:scale-95"
>
<RefreshCcw size={12} /> Republicar aviso
</button>
</div>
</div>
))}
</div>
)}
</motion.div>
)}
{/* TAB: SEGURIDAD / MFA */}
{activeTab === 'security' && (
<motion.div key="security" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}>
<h2 className="text-2xl font-black text-slate-900 mb-2 uppercase tracking-tighter">Protección de Cuenta</h2>
<p className="text-slate-500 text-sm mb-10">Gestiona la seguridad de tu acceso y la verificación en dos pasos.</p>
<div className="space-y-6">
{/* Card MFA */}
<div className="p-8 rounded-[2rem] bg-slate-50 border border-slate-100 relative overflow-hidden">
<div className="flex flex-col md:flex-row gap-8 items-start relative z-10">
<div className="bg-white p-4 rounded-3xl shadow-sm border border-slate-100">
<QrCode size={40} className="text-blue-600" />
</div>
<div className="flex-1">
<h4 className="font-black text-slate-900 uppercase text-base mb-1">Doble Factor de Autenticación (TOTP)</h4>
<p className="text-slate-500 text-xs leading-relaxed mb-6">Añade una capa extra de seguridad usando Google Authenticator o similares.</p>
{!mfaSetupData ? (
<button
onClick={handleSetupMfa}
className="bg-slate-900 text-white px-8 py-3 rounded-xl font-black uppercase text-[10px] tracking-widest hover:bg-blue-600 transition-all shadow-lg shadow-slate-200"
>
Configurar MFA ahora
</button>
) : (
<div className="bg-white p-6 rounded-3xl border border-blue-100 animate-in fade-in slide-in-from-bottom-2">
<p className="text-[10px] font-black text-blue-600 uppercase mb-4 text-center">1. Escanea este código</p>
<div className="bg-white p-4 rounded-2xl w-fit mx-auto border border-slate-100 mb-6">
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(mfaSetupData.qrCodeUri)}`}
alt="QR"
/>
</div>
<p className="text-[10px] font-black text-slate-400 uppercase mb-3 text-center">2. Introduce el código de 6 dígitos</p>
<div className="flex gap-3 max-w-xs mx-auto">
<input
type="text"
maxLength={6}
value={mfaCode}
onChange={(e) => setMfaCode(e.target.value)}
placeholder="000000"
className="flex-1 bg-slate-50 border-none rounded-xl text-center font-mono font-bold text-xl py-3 focus:ring-2 focus:ring-blue-500 transition-all"
/>
<button
onClick={handleVerifyAndEnableMfa}
className="bg-blue-600 text-white px-6 rounded-xl font-black uppercase text-[10px] hover:bg-blue-700"
>
Activar
</button>
</div>
</div>
)}
</div>
</div>
</div>
{/* Card Password Change */}
<div className="p-8 rounded-[2rem] border border-slate-100 flex items-center justify-between group hover:bg-slate-50 transition-colors cursor-pointer">
<div className="flex items-center gap-6">
<div className="p-3 bg-slate-100 rounded-2xl text-slate-400 group-hover:text-slate-900 group-hover:bg-white transition-all shadow-sm">
<Lock size={20} />
</div>
<div>
<h4 className="font-black text-slate-900 uppercase text-sm">Cambiar Contraseña</h4>
<p className="text-slate-400 text-[10px] font-bold uppercase tracking-widest mt-1">Último cambio: Hace 3 meses</p>
</div>
</div>
<ChevronRight size={20} className="text-slate-300 group-hover:text-slate-900 transition-all" />
</div>
</div>
</motion.div>
)}
{/* TAB: AJUSTES */}
{activeTab === 'settings' && (
<motion.div key="settings" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}>
<h2 className="text-2xl font-black text-slate-900 mb-8 uppercase tracking-tighter">Ajustes de cuenta</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<InputGroup label="Nombre de usuario" value={user?.username || ''} disabled />
<InputGroup label="Email de contacto" value={user?.email || 'admin@sigcm.com'} />
<InputGroup label="Teléfono / WhatsApp" placeholder="+54..." />
<InputGroup label="Ubicación" value="La Plata, Buenos Aires" />
</div>
<div className="mt-10 pt-10 border-t border-slate-50 flex justify-end">
<button className="bg-blue-600 text-white px-10 py-4 rounded-2xl font-black uppercase text-xs tracking-widest shadow-xl shadow-blue-200 hover:bg-blue-700 transition-all">
Guardar cambios
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</main>
</div>
</div >
{/* MODAL DE CONFIRMACIÓN */}
<AnimatePresence>
{republishTarget && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
onClick={() => setRepublishTarget(null)}
className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm"
/>
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0, y: 20 }}
className="relative bg-white p-8 rounded-[2.5rem] shadow-2xl max-w-sm w-full text-center border border-slate-100"
>
<div className="w-16 h-16 bg-blue-50 text-blue-600 rounded-2xl flex items-center justify-center mx-auto mb-6">
<RefreshCcw size={32} />
</div>
<h3 className="text-xl font-black text-slate-900 uppercase tracking-tighter mb-3">Republicar Aviso</h3>
<p className="text-sm text-slate-500 font-medium mb-8 leading-relaxed">
Se creará una nueva publicación basada en <strong>{republishTarget.title}</strong>. Podrás editar los datos antes de realizar el pago.
</p>
<div className="flex gap-3">
<button
onClick={() => setRepublishTarget(null)}
className="flex-1 py-4 px-6 bg-slate-100 text-slate-500 font-black uppercase text-[10px] tracking-widest rounded-2xl hover:bg-slate-200 transition-all"
>
Cancelar
</button>
<button
onClick={handleConfirmRepublish}
className="flex-1 py-4 px-6 bg-blue-600 text-white font-black uppercase text-[10px] tracking-widest rounded-2xl shadow-lg shadow-blue-200 hover:bg-blue-700 transition-all"
>
Confirmar
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</div >
);
}
// COMPONENTES AUXILIARES PARA LIMPIEZA
function SidebarItem({ icon, label, active, onClick }: { icon: any, label: string, active: boolean, onClick: () => void }) {
return (
<button
onClick={onClick}
className={`w-full flex items-center gap-3 px-5 py-4 rounded-[1.5rem] transition-all duration-300 font-bold text-[11px] uppercase tracking-[0.15em] ${active
? 'bg-blue-600 text-white shadow-xl shadow-blue-200'
: 'text-slate-400 hover:text-slate-900 hover:bg-slate-50'
}`}
>
<span className={active ? 'text-white' : 'text-slate-300 group-hover:text-slate-600'}>
{icon}
</span>
{label}
</button>
);
}
function InputGroup({ label, value, disabled, placeholder }: any) {
return (
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">{label}</label>
<input
type="text"
defaultValue={value}
disabled={disabled}
placeholder={placeholder}
className={`w-full px-6 py-4 bg-slate-50 border-none rounded-2xl focus:ring-2 focus:ring-blue-100 outline-none transition-all font-bold text-slate-700 ${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-slate-100'
}`}
/>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { Link, useSearchParams } from 'react-router-dom';
import { CheckCircle2, XCircle, ArrowRight } from 'lucide-react';
import { motion } from 'framer-motion';
export default function PublishFeedback() {
const [searchParams] = useSearchParams();
const status = searchParams.get('status'); // Viene de Mercado Pago
const isSuccess = status === 'approved';
return (
<div className="min-h-[70vh] flex items-center justify-center p-6">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="bg-white p-12 rounded-[3rem] shadow-2xl border border-slate-100 max-w-md w-full text-center"
>
{isSuccess ? (
<>
<div className="bg-emerald-50 w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6 text-emerald-500">
<CheckCircle2 size={48} />
</div>
<h2 className="text-3xl font-black text-slate-900 uppercase tracking-tighter">¡Publicado!</h2>
<p className="text-slate-500 mt-4 font-medium italic">
Tu pago fue aprobado. Tu aviso ya está en proceso de revisión/publicación.
</p>
</>
) : (
<>
<div className="bg-rose-50 w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6 text-rose-500">
<XCircle size={48} />
</div>
<h2 className="text-3xl font-black text-slate-900 uppercase tracking-tighter">Hubo un problema</h2>
<p className="text-slate-500 mt-4 font-medium italic">
No pudimos procesar el pago. Por favor, intenta de nuevo desde tu perfil.
</p>
</>
)}
<div className="mt-10 space-y-4">
<Link
to="/profile"
className="w-full py-4 bg-slate-900 text-white rounded-2xl font-black uppercase text-xs tracking-widest flex items-center justify-center gap-2 hover:bg-black transition-all"
>
Ir a mis publicaciones <ArrowRight size={16} />
</Link>
<Link to="/" className="block text-[10px] font-black uppercase tracking-widest text-slate-400 hover:text-blue-600 transition-colors">
Volver al inicio
</Link>
</div>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react';
import { useWizardStore } from '../store/wizardStore';
import CategorySelection from './Steps/CategorySelection';
import OperationSelection from './Steps/OperationSelection';
import AttributeForm from './Steps/AttributeForm';
import TextEditorStep from './Steps/TextEditorStep';
import PhotoUploadStep from './Steps/PhotoUploadStep';
import SummaryStep from './Steps/SummaryStep';
import { wizardService } from '../services/wizardService';
import type { AttributeDefinition } from '../types';
import SEO from '../components/SEO';
export default function PublishPage() {
const { step, selectedCategory } = useWizardStore();
const [definitions, setDefinitions] = useState<AttributeDefinition[]>([]);
useEffect(() => {
// CAMBIO: Agregamos el guard aquí también
if (selectedCategory?.id) {
wizardService.getAttributes(selectedCategory.id)
.then(setDefinitions)
.catch(err => console.error("Error en PublishPage:", err));
}
}, [selectedCategory?.id]);
return (
<div className="min-h-screen bg-slate-50 text-slate-900 font-sans pb-20">
<SEO
title="Publicar Aviso"
description="Publica tu aviso clasificado en simples pasos."
/>
{/* Header del Wizard interno */}
<div className="bg-white border-b border-slate-200 sticky top-[80px] z-30 shadow-sm">
<div className="max-w-3xl mx-auto px-4 h-14 flex items-center justify-between">
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400">
Paso {step} de 6
</span>
<div className="flex gap-1">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div
key={i}
className={`w-8 h-1.5 rounded-full transition-colors ${i <= step ? 'bg-blue-600' : 'bg-slate-100'}`}
/>
))}
</div>
</div>
</div>
<main className="max-w-3xl mx-auto p-6 mt-8 bg-white rounded-[2.5rem] shadow-xl shadow-slate-200/50 border border-slate-100">
{step === 1 && <CategorySelection />}
{step === 2 && <OperationSelection />}
{step === 3 && <AttributeForm />}
{step === 4 && <TextEditorStep />}
{step === 5 && <PhotoUploadStep />}
{step === 6 && <SummaryStep definitions={definitions} />}
</main>
</div>
);
}

View 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>
);
}

View 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"
>
&larr; 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -1,7 +1,18 @@
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL, // Usa la variable de entorno
baseURL: import.meta.env.VITE_API_URL,
});
api.interceptors.request.use(config => {
const token = localStorage.getItem('public_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
}, error => {
return Promise.reject(error);
});
export default api;

View File

@@ -0,0 +1,67 @@
const API_URL = `${import.meta.env.VITE_API_URL}/auth`;
export const publicAuthService = {
login: async (username: string, password: string) => {
const response = await fetch(`${API_URL}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (response.ok && data.token) {
localStorage.setItem('public_token', data.token);
localStorage.setItem('public_user', JSON.stringify({ username }));
}
return data;
},
register: async (username: string, email: string, password: string) => {
const response = await fetch(`${API_URL}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email, password })
});
return await response.json();
},
googleLogin: async (idToken: string) => {
const response = await fetch(`${API_URL}/google-login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(idToken)
});
const data = await response.json();
if (response.ok && data.token) {
localStorage.setItem('public_token', data.token);
}
return data;
},
setupMfa: async () => {
const token = localStorage.getItem('public_token');
const response = await fetch(`${API_URL}/mfa/setup`, {
headers: { 'Authorization': `Bearer ${token}` }
});
return await response.json();
},
verifyMfa: async (code: string) => {
const token = localStorage.getItem('public_token');
const response = await fetch(`${API_URL}/mfa/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(code)
});
return await response.json();
},
logout: () => {
localStorage.removeItem('public_token');
localStorage.removeItem('public_user');
},
isAuthenticated: () => !!localStorage.getItem('public_token')
};

View File

@@ -0,0 +1,38 @@
import api from './api';
import type {
Category,
Operation,
AttributeDefinition,
CreateListingDto
} from '../types';
export const wizardService = {
getCategories: async (): Promise<Category[]> => {
const response = await api.get<Category[]>('/categories');
return response.data;
},
getOperations: async (categoryId: number): Promise<Operation[]> => {
const response = await api.get<Operation[]>(`/categories/${categoryId}/operations`);
return response.data;
},
getAttributes: async (categoryId: number): Promise<AttributeDefinition[]> => {
const response = await api.get<AttributeDefinition[]>(`/attributedefinitions/category/${categoryId}`);
return response.data;
},
createListing: async (data: CreateListingDto): 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

@@ -0,0 +1,27 @@
import { create } from 'zustand';
interface User {
username: string;
email: string;
}
interface PublicAuthState {
user: User | null;
setUser: (user: User | null) => void;
logout: () => void;
}
export const usePublicAuthStore = create<PublicAuthState>((set) => ({
// Inicializamos con lo que haya en localStorage
user: localStorage.getItem('public_user')
? JSON.parse(localStorage.getItem('public_user')!)
: null,
setUser: (user) => set({ user }),
logout: () => {
localStorage.removeItem('public_token');
localStorage.removeItem('public_user');
set({ user: null });
}
}));

View File

@@ -0,0 +1,117 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import type { Category, Operation, Listing } from '../types';
interface WizardState {
step: number;
selectedCategory: Category | null;
selectedOperation: Operation | null;
attributes: Record<string, string>;
photos: File[];
setStep: (step: number) => void;
setCategory: (category: Category) => void;
setOperation: (operation: Operation) => void;
setAttribute: (key: string, value: string) => void;
addPhoto: (file: File) => void;
removePhoto: (index: number) => void;
setRepublishData: (listing: Listing) => void;
reset: () => void;
existingImages: any[];
removeExistingImage: (index: number) => void;
}
export const useWizardStore = create<WizardState>()(
persist(
(set) => ({
step: 1,
selectedCategory: null,
selectedOperation: null,
attributes: {},
photos: [],
existingImages: [],
setStep: (step) => set({ step }),
setCategory: (category) => set({ selectedCategory: category, step: 2 }),
setOperation: (operation) => set({ selectedOperation: operation, step: 3 }),
setAttribute: (key, value) => set((state) => ({
attributes: { ...state.attributes, [key]: value }
})),
addPhoto: (file) => set((state) => ({ photos: [...state.photos, file] })),
removePhoto: (index) => set((state) => ({ photos: state.photos.filter((_, i) => i !== index) })),
// REPUBLICACIÓN
setRepublishData: (data: any) => {
const listing = data.listing || data.Listing || data;
// Extraemos el ID con soporte para ambos casos
const catId = listing.categoryId ?? listing.CategoryId;
if (!catId) {
console.error("Error crítico: No se encontró CategoryId en:", data);
return;
}
// 1. Mapeamos campos estáticos (siempre en minúsculas para el form)
const mappedAttributes: Record<string, string> = {
title: listing.title || listing.Title || '',
description: listing.description || listing.Description || '',
price: (listing.price || listing.Price || '0').toString(),
};
// 2. Mapeamos atributos dinámicos
const attributesArray = data.attributes || data.Attributes || [];
if (Array.isArray(attributesArray)) {
attributesArray.forEach((attr: any) => {
// Normalizamos la clave del atributo a minúsculas
const rawKey = attr.attributeName || attr.AttributeName;
if (rawKey) {
const key = rawKey.toLowerCase();
mappedAttributes[key] = (attr.value || attr.Value || '').toString();
}
});
}
const imagesArray = data.images || data.Images || [];
set({
step: 3,
selectedCategory: {
id: catId,
name: listing.categoryName || listing.CategoryName || 'Rubro'
} as any,
selectedOperation: {
id: listing.operationId || listing.OperationId,
name: 'Venta'
} as any,
attributes: mappedAttributes,
existingImages: imagesArray,
photos: []
});
},
removeExistingImage: (index: number) => set((state) => ({
existingImages: state.existingImages.filter((_, i) => i !== index)
})),
reset: () => {
localStorage.removeItem('wizard-storage');
set({ step: 1, selectedCategory: null, selectedOperation: null, attributes: {}, photos: [], existingImages: [] });
},
}),
{
name: 'wizard-storage',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
step: state.step,
selectedCategory: state.selectedCategory,
selectedOperation: state.selectedOperation,
attributes: state.attributes,
}),
},
)
);

View File

@@ -1,3 +1,4 @@
// --- Tipos del Portal ---
export interface Listing {
id: number;
title: string;
@@ -8,7 +9,11 @@ export interface Listing {
operationId: number;
createdAt: string;
mainImageUrl?: string;
categoryName?: string;
status: string;
images?: ListingImage[];
viewCount: number;
overlayStatus?: 'Vendido' | 'Alquilado' | 'Reservado' | null;
}
export interface ListingAttribute {
@@ -33,9 +38,43 @@ export interface ListingDetail {
images: ListingImage[];
}
// --- Tipos Agregados del Wizard ---
export interface Category {
id: number;
parentId?: number | null;
name: string;
parentId?: number;
slug: string;
subcategories?: Category[];
}
export interface Operation {
id: number;
name: string;
}
export interface AttributeDefinition {
id: number;
name: string;
dataType: 'text' | 'number' | 'boolean' | 'date';
required: boolean;
}
// DTO para creación
export interface CreateListingDto {
categoryId: number;
operationId: number;
title: string;
description: string;
price: number;
adFee: number;
currency: string;
status: string;
attributes: Record<number, string>;
printText: string;
printDaysCount: number;
isBold?: boolean;
isFrame?: boolean;
printFontSize?: string;
printAlignment?: string;
imagesToClone?: string[];
}

View File

@@ -23,9 +23,8 @@ export const processCategoriesForSelect = (rawCategories: Category[]): FlatCateg
const map = new Map<number, CategoryNode>();
const roots: CategoryNode[] = [];
// 1. Map
// 1. Mapear categorías
rawCategories.forEach(cat => {
// @ts-ignore
map.set(cat.id, { ...cat, children: [] });
});