Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
Omar Sánchez Pizarro
2026-01-20 03:21:50 +01:00
parent 19932854ca
commit 81bf0675ed
32 changed files with 3081 additions and 932 deletions

View File

@@ -0,0 +1,225 @@
<template>
<div class="card hover:shadow-lg transition-shadow">
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4">
<!-- Imagen del artículo -->
<div class="flex-shrink-0 self-center sm:self-start">
<div v-if="article.images && article.images.length > 0" class="w-24 h-24 sm:w-32 sm:h-32 relative">
<img
:src="article.images[0]"
:alt="article.title || 'Sin título'"
class="w-24 h-24 sm:w-32 sm:h-32 object-cover rounded-lg"
@error="($event) => handleImageError($event)"
/>
</div>
<div v-else class="w-24 h-24 sm:w-32 sm:h-32 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<span class="text-gray-400 dark:text-gray-500 text-xs">Sin imagen</span>
</div>
</div>
<!-- Información del artículo -->
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between mb-2">
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2 mb-2">
<span
class="px-2 py-1 text-xs font-semibold rounded flex-shrink-0"
:class="
article.platform === 'wallapop'
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-300'
: 'bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-300'
"
>
{{ article.platform?.toUpperCase() || 'N/A' }}
</span>
<span v-if="article.username" class="px-2 py-1 text-xs font-medium rounded bg-purple-100 dark:bg-purple-900/50 text-purple-800 dark:text-purple-300 flex-shrink-0" title="Usuario">
👤 {{ article.username }}
</span>
<span v-if="article.worker_name" class="px-2 py-1 text-xs font-medium rounded bg-orange-100 dark:bg-orange-900/50 text-orange-800 dark:text-orange-300 flex-shrink-0" title="Worker">
{{ article.worker_name }}
</span>
<span v-if="article.notifiedAt" class="text-xs sm:text-sm text-gray-500 dark:text-gray-400">
{{ formatDate(article.notifiedAt) }}
</span>
<span v-if="article.addedAt && !article.notifiedAt" class="text-xs sm:text-sm text-gray-500 dark:text-gray-400">
Añadido: {{ formatDate(article.addedAt) }}
</span>
</div>
<h3 class="text-base sm:text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1 line-clamp-2" :title="article.title">
{{ article.title || 'Sin título' }}
</h3>
<div v-if="article.price !== null && article.price !== undefined" class="mb-2">
<span class="text-xl font-bold text-primary-600 dark:text-primary-400">
{{ article.price }} {{ article.currency || '€' }}
</span>
</div>
<div class="space-y-1 text-xs sm:text-sm text-gray-600 dark:text-gray-400 mb-2">
<div v-if="article.location" class="flex flex-wrap items-center">
<span class="font-medium">📍 Localidad:</span>
<span class="ml-2">{{ article.location }}</span>
</div>
<div v-if="article.allows_shipping !== null && article.allows_shipping !== undefined" class="flex flex-wrap items-center">
<span class="font-medium">🚚 Envío:</span>
<span class="ml-2">{{ article.allows_shipping ? '✅ Acepta envíos' : '❌ No acepta envíos' }}</span>
</div>
<div v-if="article.modified_at" class="flex flex-wrap items-center">
<span class="font-medium">🕒 Modificado:</span>
<span class="ml-2 break-all">{{ article.modified_at }}</span>
</div>
</div>
<p v-if="article.description" class="text-xs sm:text-sm text-gray-700 dark:text-gray-300 mb-2 overflow-hidden line-clamp-2">
{{ article.description }}
</p>
<div class="flex flex-wrap items-center gap-2 sm:gap-4 mt-3">
<a
v-if="article.url"
:href="article.url"
target="_blank"
rel="noopener noreferrer"
class="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 text-xs sm:text-sm font-medium break-all"
>
🔗 Ver anuncio
</a>
<span class="text-xs text-gray-400 dark:text-gray-500 break-all">
ID: {{ article.id }}
</span>
<button
v-if="showRemoveButton"
@click="$emit('remove', article.platform, article.id)"
class="btn btn-danger text-xs sm:text-sm"
>
Eliminar
</button>
<button
v-if="!showRemoveButton && isAuthenticated && !isAdding"
@click="handleAddFavorite"
class="btn text-xs sm:text-sm flex items-center gap-1"
:class="favoriteStatus ? 'btn-secondary' : 'bg-pink-500 hover:bg-pink-600 text-white border-pink-600'"
:disabled="favoriteStatus"
:title="favoriteStatus ? 'Ya está en favoritos' : 'Añadir a favoritos'"
>
<HeartIconSolid v-if="favoriteStatus" class="w-4 h-4" />
<HeartIcon v-else class="w-4 h-4" />
{{ favoriteStatus ? 'En favoritos' : 'Añadir a favoritos' }}
</button>
<button
v-if="!showRemoveButton && isAuthenticated && isAdding"
disabled
class="btn btn-secondary text-xs sm:text-sm opacity-50 cursor-not-allowed"
>
<span class="inline-block animate-spin mr-1"></span>
Añadiendo...
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits, ref, onMounted, onUnmounted } from 'vue';
import { HeartIcon } from '@heroicons/vue/24/outline';
import { HeartIcon as HeartIconSolid } from '@heroicons/vue/24/solid';
import authService from '../services/auth';
import api from '../services/api';
const props = defineProps({
article: {
type: Object,
required: true,
},
showRemoveButton: {
type: Boolean,
default: false,
},
isFavorite: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['remove', 'added']);
const isAdding = ref(false);
const isAuthenticated = ref(false);
const favoriteStatus = ref(props.isFavorite);
// Verificar autenticación al montar y cuando cambie
function checkAuth() {
isAuthenticated.value = authService.hasCredentials();
}
function formatDate(timestamp) {
if (!timestamp) return 'N/A';
return new Date(timestamp).toLocaleString('es-ES');
}
function handleImageError(event) {
// Si la imagen falla al cargar, reemplazar con placeholder
event.target.onerror = null; // Prevenir bucle infinito
event.target.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgdmlld0JveD0iMCAwIDEyOCAxMjgiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxMjgiIGhlaWdodD0iMTI4IiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik00OCA0OEg4ME04MCA4MEg0OE00OCA0OEw2NCA2NEw4MCA0OE00OCA4MEw2NCA2NE04MCA4MEw2NCA2NEw0OCA4MCIgc3Ryb2tlPSIjOUI5Q0E0IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8L3N2Zz4K';
}
async function handleAddFavorite() {
if (!isAuthenticated.value || favoriteStatus.value || isAdding.value) {
return;
}
if (!props.article.platform || !props.article.id) {
alert('Error: El artículo no tiene platform o id válidos');
return;
}
isAdding.value = true;
try {
// El backend solo necesita platform e id
const favorite = {
platform: props.article.platform,
id: String(props.article.id), // Asegurar que sea string
};
await api.addFavorite(favorite);
favoriteStatus.value = true;
// Emitir evento para que el componente padre pueda actualizar si es necesario
emit('added', props.article.platform, props.article.id);
} catch (error) {
console.error('Error añadiendo a favoritos:', error);
// El interceptor de API ya maneja el error 401 mostrando el modal de login
if (error.response?.status === 404) {
alert('El artículo no se encontró en la base de datos. Asegúrate de que el artículo esté en la lista de notificados.');
} else if (error.response?.status === 400) {
alert('Error: ' + (error.response?.data?.error || 'Datos inválidos'));
} else if (error.response?.status !== 401) {
const errorMessage = error.response?.data?.error || error.message || 'Error desconocido';
alert('Error al añadir a favoritos: ' + errorMessage);
}
} finally {
isAdding.value = false;
}
}
function handleAuthChange() {
checkAuth();
}
onMounted(() => {
checkAuth();
// Escuchar cambios en la autenticación
window.addEventListener('auth-login', handleAuthChange);
window.addEventListener('auth-logout', handleAuthChange);
});
onUnmounted(() => {
window.removeEventListener('auth-login', handleAuthChange);
window.removeEventListener('auth-logout', handleAuthChange);
});
</script>