Files
consumocuidado/components/ProductCard.vue
2025-10-03 15:19:23 +02:00

332 lines
7.4 KiB
Vue

<template>
<div class="c-card">
<div class="image-container">
<img v-if="product.image" class="image" :src="product.image" alt="" />
<img v-else class="image" :src="defaultImage" alt="" />
</div>
<div class="details-container">
<p>{{ product.name }}</p>
<p class="company">{{ product.company.company_name }}</p>
<p v-if="product.price" class="price">{{ product.price }}</p>
</div>
<div class="links-btns">
<NuxtLink :to="`/productos/${product.id}`" class="div-action show-link">
<img class="div-action-img" src="@/assets/img/eye.svg" />
VER
</NuxtLink>
<NuxtLink v-if="product.url" :to="product.url" class="div-action buy-link">
<img class="div-action-img" src="@/assets/img/shopping-cart.svg" />
COMPRAR
</NuxtLink>
</div>
</div>
</template>
<script>
import DOMPurify from 'dompurify'
import { mapState } from 'pinia'
import socialShare from '~/utils/socialShare'
import defaultImage from '@/assets/img/producto-default.png'
export default {
props: {
product: {
type: Object,
default: () => ({}),
},
},
data() {
return {
expanded: false,
modal: false,
productUrl: null,
relatedProducts: null,
active: false,
modalText: '',
modalColor: 'info',
defaultImage: defaultImage,
}
},
computed: {
...mapState(useAuthStore, ['access']),
sanitizedDescription() {
return DOMPurify.sanitize(this.product.description, {
ALLOWED_TAGS: ['p'],
ALLOWED_ATTR: [],
})
}
},
mounted() {
this.productUrl = window.location.origin + `/productos/${this.product.id}`
this.getRelatedProducts()
},
methods: {
async onOpen() {
this.expanded = true
await this.getRelatedProducts()
await this.sendLog('view')
},
onClose() {
this.expanded = false
},
tagRoute(tag) {
return `/busqueda?tags=${tag}`
},
async getRelatedProducts() {
try {
const config = useRuntimeConfig()
const data = await $fetch(`/products/${this.product.id}/related/`, {
baseURL: config.public.baseURL,
method: 'GET',
headers: {
Authorization: '/',
},
})
this.relatedProducts = data
} catch {
this.relatedProducts = null
}
},
openUrl(url) {
window.open(url)
},
buyIntent() {
this.sendLog('shop')
return this.product.url
? this.openUrl(this.product.url)
: (this.modal = true)
},
async getPosition() {
const geoLocation = () =>
new Promise((resolve, reject) =>
navigator.geolocation.getCurrentPosition(
(posData) => {
resolve(posData)
},
(error) => {
reject(error)
}
)
)
try {
const position = await geoLocation()
const geo = {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
}
return geo
} catch {
const geo = null
return geo
}
},
async sendLog(action) {
const geo = await this.getPosition()
try {
const config = useRuntimeConfig()
const accessToken = this.access
const data = await $fetch('https://api.ipify.org?format=json', {
baseURL: config.public.baseURL,
method: 'GET'
})
const ip = data.ip
const object = {
action: action,
action_object: {
model: 'product',
id: this.product.id,
},
}
if (ip) object.ip = ip
if (geo) object.geo = geo
//TODO: review problems with 406 error backend
await $fetch(`/stats/me/`, {
baseURL: config.public.baseURL,
method: 'POST',
body: object,
headers: {
Authorization: `Bearer ${accessToken}`
}
})
} catch (error) {
console.error('Error sending log:', error)
}
},
closeModal(value) {
console.log(value)
this.modal = false
this.active = true
if (value === 200 || value === 201) {
this.modalText = 'Actualizado correctamente'
this.modalColor = 'success'
} else if (value) {
this.modalText = 'Se ha producido un error en el envío'
this.modalColor = 'danger'
}
},
shareFacebook() {
const url = socialShare.facebook(this.productUrl)
window.open(url, '_blank')
},
shareTwitter() {
return socialShare.twitter(this.productUrl)
},
shareWhatsApp() {
return socialShare.whatsApp(this.productUrl)
},
},
}
</script>
<style lang="scss" scoped>
.image-container {
width: 100%;
height: 11rem;
flex-shrink: 0;
align-self: stretch;
border-radius: 24px 24px 0 0;
@include mobile {
max-height: 10rem;
max-width: 10rem;
}
.image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 24px 24px 0 0;
}
}
.c-card {
width: 237px;
height: 316px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: stretch;
background-color: #FDFCFB;
border: 5px solid white;
border-radius: 24px;
box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.15);
text-decoration: none;
position: relative; // necesario para controlar hijos absolutos
overflow: hidden;
p {
text-align: center;
display: -webkit-box;
--webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
width: 100%;
font-weight: $medium;
font-size: $m;
}
// detalles visibles por defecto
.details-container {
opacity: 1;
visibility: visible;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
height: 100%;
p {
margin: 0;
}
.company {
font-weight: $regular;
font-size: $s;
margin: 0;
}
.price {
font-weight: $bold;
font-size: $m;
margin-top: auto;
margin-bottom: 2px;
}
}
// botones ocultos por defecto
.links-btns {
opacity: 0;
visibility: hidden;
position: absolute;
bottom: 1rem;
left: 0;
width: 100%;
display: flex;
gap: 0.5rem;
transition: all 0.3s ease;
}
// en hover se intercambian
&:hover {
.details-container {
opacity: 0;
visibility: hidden;
transform: translateY(10px); // opcional efecto
}
.links-btns {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
}
}
.links-btns {
display: flex;
flex-direction: column;
padding: 0 0.5rem;
.div-action {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-weight: $bold;
font-size: $s;
text-decoration: none;
&.show-link {
border: 1px solid $color-button;
padding: 0.25rem 0.5rem;
border-radius: 12px;
background-color: white;
&:hover {
background-color: $color-button;
color: white;
}
}
&.buy-link {
color: white;
border: 1px solid $color-button;
padding: 0.25rem 0.5rem;
border-radius: 12px;
background-color: $color-button;
&:hover {
background-color: white;
color: $color-button;
}
}
.div-action-img {
width: 1rem;
height: 1rem;
}
}
}
</style>