product page

This commit is contained in:
María
2025-08-18 13:07:36 +02:00
parent 51f49a2b7d
commit f33da4af80
7 changed files with 942 additions and 7 deletions

View File

@@ -0,0 +1,445 @@
<template>
<div class="productcard_container">
<div class="row productcard_container-basic">
<div class="image_container col-md-5">
<img :src="getProductImg(product)" class="image" alt="" />
</div>
<div class="info_container col-md-5">
<h2 variant="primary">
{{ product?.name }}
</h2>
<span v-if="product?.price" class="price">{{
`${product?.price}`
}}</span>
<span v-else class="price">Precio a consultar</span>
<span v-if="Number(product?.shipping_cost)"
>| {{ `Gastos de envío ${product?.shipping_cost}` }}</span
>
<span v-else>| Sin gastos de envío</span>
<span v-if="product?.stock">| {{ `Stock ${product?.stock}` }} </span>
<p
v-if="product?.description"
class="description"
v-html="sanitize(product?.description)"
></p>
<span v-if="product?.shipping_terms">{{ product?.shipping_terms }}</span>
<BCollapse visible accordion="my-accordion">
<div class="tags_container">
<NuxtLink
v-for="n in product?.tags"
:key="n"
:to="tagRoute(n)"
class="tag_container"
>
<img
class="tag_img"
alt="tag image"
src="@/assets/img/latienda-tag.svg"
/>
<span>{{ n }}</span>
</NuxtLink>
</div>
<div class="smlogos_container">
<p class="share-text">Comparte:</p>
<div class="smlogo_container">
<a @click="shareFacebook">
<img
alt="facebook logo"
class="smlogo_img"
src="@/assets/img/latienda-smlogo-facebook.svg"
/>
</a>
</div>
<a :href="shareTwitter()">
<div class="smlogo_container">
<img
alt="twitter logo"
class="smlogo_img"
src="@/assets/img/latienda-smlogo-twitter.svg"
/>
</div>
</a>
<a
:href="shareWhatsApp()"
data-action="share/whatsapp/share"
target="_blank"
title="latiendacoop"
>
<div class="smlogo_container">
<img
alt="whatsapp logo"
class="smlogo_img"
src="@/assets/img/latienda-smlogo-whatsapp.svg"
/>
</div>
</a>
</div>
<div class="coop_info">
<NuxtLink :to="`/c/${company?.id}`">
<h2>{{ company?.company_name }}</h2>
</NuxtLink>
<p class="description">{{ company?.description }}</p>
<a href="#">{{ company?.web_link }}</a>
</div>
</BCollapse>
</div>
<div class="col-md-2 button_container-detail" align="center">
<button class="button_buy-simple" @click="buyIntent">
<img
class="button_cart_img"
alt="cart"
src="@/assets/img/latienda-carrito.svg"
/>
</button>
<div
v-if="product?.discount && product?.discount > 0"
class="discount-tag"
>
{{ `Descuento ${product?.discount}%` }}
</div>
</div>
</div>
<div class="related_products">
<h2 v-if="related">Productos relacionados</h2>
<h2 v-else>Otros productos</h2>
<ProductsRelated :related-products="relatedProducts" />
</div>
<ProductModal v-if="modal" :product="product" @close-modal="closeModal" />
</div>
</template>
<script>
import DOMPurify from 'dompurify'
import socialShare from '~/utils/socialShare'
export default {
props: {
product: {
type: Object,
default: () => ({}),
},
company: {
type: Object,
default: () => ({}),
},
relatedProducts: {
type: Array,
default: () => [],
},
related: {
type: Boolean,
default: false,
},
},
data() {
return {
modal: true,
productUrl: null,
geolocation: null,
}
},
mounted() {
this.productUrl = window.location.href
this.sendLog('view')
},
methods: {
getProductImg(product) {
if (product && product.image)
return product.image
return `@/assets/img/latienda-product-default.svg`
},
tagRoute(tag) {
return `/busqueda?tags=${tag}`
},
openUrl(url) {
window.open(url)
},
closeModal(value) {
this.modal = false
if (value === 200) {
this.$bvToast.toast(`Email enviado correctamente`, {
title: 'latienda.coop',
autoHideDelay: 5000,
appendToast: true,
})
} else if (value) {
this.$bvToast.toast(`Se ha producido un error en el envío`, {
title: 'latienda.coop',
autoHideDelay: 5000,
appendToast: true,
variant: 'danger',
})
}
},
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 { data } = await this.$axios.get(
'https://api.ipify.org?format=json'
)
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
await this.$api.post(`/stats/me/`, object)
} catch {}
},
shareFacebook() {
const url = socialShare.facebook(this.productUrl)
window.open(url, '_blank')
},
shareTwitter() {
return socialShare.twitter(this.productUrl)
},
shareWhatsApp() {
return socialShare.whatsApp(this.productUrl)
},
sanitize(dirtyHtml) {
return DOMPurify.sanitize(dirtyHtml, {
ALLOWED_TAGS: [
'b',
'i',
'em',
'strong',
'a',
'p',
'br',
'ul',
'li',
'ol',
'span',
'h1',
'h2',
'h3',
],
ALLOWED_ATTR: ['href', 'target', 'rel', 'class'],
})
},
},
}
</script>
<style lang="scss" scoped>
.productcard_container {
border: 3px solid $color-grey-nav;
border-radius: 5px;
margin-bottom: 12px;
}
.productcard_container-basic {
padding: 25px 20px;
}
.image_container {
height: 100%;
overflow: hidden;
margin: auto;
}
.image {
width: 100%;
height: 100%;
object-fit: cover;
}
.info_container {
h2 {
outline: none;
font-weight: medium;
color: $color-navy;
font-size: $m;
}
.price {
font-weight: bold;
color: $color-navy;
font-size: $m;
}
span {
color: $color-greytext;
}
.description {
margin-top: 8px;
font-family: $font-secondary;
font-size: $s;
color: $color-greytext;
}
}
.button_container {
display: flex;
flex-direction: column;
font-size: $m;
span {
margin-top: 15px;
text-align: center;
font-weight: medium;
font-size: $m;
color: $color-navy;
}
.button_buy {
border: 3px solid $color-orange;
border-radius: 5px;
background-color: $color-light;
&:hover {
box-shadow: 0 4px 16px rgba(99, 99, 99, 0.2);
transition: all 0.2s ease;
}
span {
color: $color-orange;
font-weight: $bold;
display: inline-block;
margin-bottom: 15px;
}
}
}
.button_buy-simple {
border: 3px solid $color-orange;
border-radius: 8px;
background-color: $color-light;
padding: 10px 20px;
&:hover {
box-shadow: 0 4px 16px rgba(99, 99, 99, 0.2);
transition: all 0.2s ease;
}
.button_cart_img {
margin-right: 0;
}
}
.tag_container {
margin: 25px 6px 0 0;
border: 2px solid $color-greylayout;
border-radius: 5px;
padding: 6px 10px;
display: inline-block;
font-family: $font-secondary;
font-size: $xs;
color: $color-greytext;
.tag_img {
width: 18px;
}
}
.share-text {
color: $color-navy;
font-size: $m;
margin-top: 2rem;
padding-bottom: 0.2em;
}
.smlogo_container {
cursor: pointer;
display: inline-block;
margin-bottom: 0.5rem;
.smlogo_img {
cursor: pointer;
width: 40px;
fill: $color-greylayout;
}
img:hover {
transform: scale(1.1);
transition: all 0.2s ease;
}
}
.button_cart_img {
width: 20px;
margin-right: 10px;
}
.coop_info {
margin-top: 25px;
p,
a {
margin-top: 8px;
font-family: $font-secondary;
font-size: $s;
color: $color-greytext;
}
a {
text-decoration: underline;
}
}
.related_products {
background-color: $color-lighter-green;
text-align: center;
padding: 0 15px;
h2 {
margin: 35px auto;
font-weight: medium;
color: $color-navy;
font-size: $m;
display: inline-block;
}
}
.discount-tag {
margin: 5px;
border: none;
background-color: $color-green;
border-radius: 5px;
padding: 6px 10px;
display: inline-block;
font-family: $font-secondary;
font-size: $xs;
color: $color-greytext;
}
.content > h2,
h3,
p {
margin: 0;
}
</style>

310
components/ProductModal.vue Normal file
View File

@@ -0,0 +1,310 @@
<template>
<transition name="modal">
<div v-if="product" class="mask">
<div class="wrapper" @click.self="handleEmit">
<div class="container">
<div class="element header">
<h2>Interesado en comprar el producto</h2>
<img src="@/assets/img/latienda-lineapuntos-2.svg" alt="" />
</div>
<div class="modal-body">
<form @submit.prevent="sendForm">
<div>
<BFormGroup class="element">
<BFormInput
v-model="form.email"
required
class="input"
size="lg"
placeholder="Email"
/>
</BFormGroup>
</div>
<div>
<BFormGroup class="element">
<BFormInput
v-model="form.telephone"
required
class="input"
size="lg"
placeholder="Teléfono"
/>
</BFormGroup>
</div>
<div v-if="!isAuthenticated" class="element">
<BButton
class="input"
size="lg"
variant="outline-primary w-100"
@click="redirectToLogin"
>Login</BButton
>
</div>
<div class="element coop">
<!-- <BCard
img-src="https://placekitten.com/1000/300"
img-alt="Card image"
img-left
>
<BCardText>
<h3>Cooperativa</h3>
<p>Dirección</p>
</BCardText>
</BCard> -->
<div class="content">
<img :src="getImgUrl(product.company.logo)" alt="" />
<div class="text">
<h3>{{ product?.company?.company_name }}</h3>
<p>{{ product?.company?.address }}</p>
<br />
</div>
</div>
</div>
<div class="element coop">
<!-- <BCard
img-src="https://placekitten.com/1000/300"
img-alt="Card image"
img-left
>
<BCardText>
<h3>Cooperativa</h3>
<p>Dirección</p>
</BCardText>
</BCard> -->
<div class="content">
<img :src="getImgUrl(product.image)" alt="" />
<div class="text">
<h3>{{ product?.name }}</h3>
<p>{{ product?.price }} </p>
<p class="text-muted">
{{ product?.shipping_cost || 'Sin gastos de envío' }}
</p>
</div>
</div>
</div>
<div class="element">
<BFormTextarea
id="textarea-no-resize"
v-model="form.comment"
class="input"
placeholder="Comentarios"
rows="3"
no-resize
/>
</div>
<div>
<BButton type="submit" class="enviar-button">
<v-progress-circular
v-if="loading"
:size="15"
:width="2"
indeterminate
/>
<span v-else>Enviar</span>
</BButton>
</div>
</form>
</div>
</div>
</div>
</div>
</transition>
</template>
<script>
import { useAuthStore } from '@/stores/auth'
export default {
props: {
product: { type: Object, default: () => ({}) },
},
emits: ['closeModal'],
setup() {
const authStore = useAuthStore()
return {
authStore
}
},
data() {
return {
isAuthenticated: true,
form: {
email: undefined,
telephone: undefined,
company: undefined,
product: undefined,
comment: '',
},
loading: false,
}
},
mounted() {
this.isAuthenticated = this.authStore.isAuthenticated
if (this.isAuthenticated) {
const email = this.authStore.email
this.form.email = email
}
},
methods: {
async sendForm() {
this.form.company = this.product.company.id
this.form.product = this.product.id
this.loading = true
let response
let status
const config = useRuntimeConfig()
try { //TODO: review if its working
response = await $fetch(`/purchase_email/`, {
baseURL: config.public.baseURL,
method: 'POST',
body: { ...this.form }
})
status = response.status
} catch {
status = 500
}
this.loading = false
this.$emit('closeModal', status)
},
getImgUrl(image) {
if (image) return image
return `@/assets/img/latienda-product-default.svg`
},
redirectToLogin() {
this.$router.push({
name: 'login',
})
},
handleEmit() {
this.$emit('closeModal')
},
},
}
</script>
<style lang="css" scoped>
.mask {
position: fixed;
z-index: 9998;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: table;
transition: opacity 0.3s ease;
}
.wrapper {
display: table-cell;
vertical-align: middle;
}
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 500px;
margin: 0px auto;
padding: 20px 40px;
background-color: #fff;
border-radius: 7px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.33);
transition: all 0.3s ease;
}
.element {
margin-bottom: 10px;
}
.header {
display: flex;
flex-direction: column;
align-items: center;
}
.header > h2 {
margin-bottom: 20px;
text-align: center;
font-size: 1.125rem;
font-weight: 500;
color: #374493;
}
.header > img {
width: 50px;
}
.modal-body {
width: 300px;
}
.input {
font-size: 0.75rem;
margin: 0;
}
.input-group-text {
width: 48px;
border-right: none;
background-color: #ffffff;
}
.coop {
border: 1px lightgray solid;
border-radius: 5px;
}
.coop > .content {
display: flex;
flex-direction: row;
align-items: flex-end;
margin: 5px 10px;
}
.coop > .content > img {
width: 80px;
height: 80px;
object-fit: cover;
margin-right: 5px;
}
.coop > .content > .text > h3,
p {
font-size: 0.75rem;
margin-bottom: 0.5rem;
}
.enviar-button {
width: 100%;
background: #fd6871;
border-color: #fd6871;
color: white;
}
/*
* The following styles are auto-applied to elements with
* transition="modal" when their visibility is toggled
* by Vue.js.
*
* You can easily play with the modal transition by editing
* these styles.
*/
.modal-enter {
opacity: 0;
}
.modal-leave-active {
opacity: 0;
}
.modal-enter .modal-container,
.modal-leave-active .modal-container {
-webkit-transform: scale(1.1);
transform: scale(0.8);
}
</style>

View File

@@ -0,0 +1,86 @@
<template>
<div class="row related_products-cards">
<NuxtLink
v-for="(product, key) in relatedProducts"
:key="`related-${key}`"
:to="`/productos/${product.id}`"
class="col mx-2 related_product-card"
>
<img
v-if="product.image"
:src="product.image"
alt=""
class="related_product-image"
/>
<img
v-else
class="image-default"
src="@/assets/img/latienda-product-default.svg"
alt=""
/>
<h3>{{ product.name }}</h3>
<span v-if="product.price" class="price">{{ `${product.price}` }}</span>
</NuxtLink>
</div>
</template>
<script>
export default {
props: {
relatedProducts: {
type: Array,
default: () => [],
},
},
}
</script>
<style lang="scss" scoped>
.related_products {
background-color: $color-lighter-green;
text-align: center;
padding: 0 15px;
img {
margin-top: 12px;
}
.image-default {
object-fit: cover;
}
.star {
width: 12px;
margin-right: 1px;
}
.related_product-card {
border: 3px solid $color-grey-nav;
border-radius: 5px;
margin-bottom: 35px;
.related_product-image {
width: 100px;
height: 100px;
object-fit: cover;
}
h3 {
font-weight: $regular;
color: $color-navy;
font-size: $m;
display: inline-block;
margin-bottom: 0;
}
span {
font-weight: $regular;
color: $color-navy;
font-size: $m;
display: block;
font-weight: bolder;
margin-bottom: 5px;
}
}
}
</style>

17
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"@nuxt/eslint": "^1.8.0", "@nuxt/eslint": "^1.8.0",
"@nuxtjs/sitemap": "^7.4.3", "@nuxtjs/sitemap": "^7.4.3",
"@pinia/nuxt": "^0.11.2", "@pinia/nuxt": "^0.11.2",
"dompurify": "^3.2.6",
"eslint": "^9.32.0", "eslint": "^9.32.0",
"nuxt": "^3.17.7", "nuxt": "^3.17.7",
"pinia": "^3.0.3", "pinia": "^3.0.3",
@@ -4321,6 +4322,13 @@
"integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/yauzl": { "node_modules/@types/yauzl": {
"version": "2.10.3", "version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
@@ -7027,6 +7035,15 @@
"url": "https://github.com/fb55/domhandler?sponsor=1" "url": "https://github.com/fb55/domhandler?sponsor=1"
} }
}, },
"node_modules/dompurify": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
"integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": { "node_modules/domutils": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",

View File

@@ -15,6 +15,7 @@
"@nuxt/eslint": "^1.8.0", "@nuxt/eslint": "^1.8.0",
"@nuxtjs/sitemap": "^7.4.3", "@nuxtjs/sitemap": "^7.4.3",
"@pinia/nuxt": "^0.11.2", "@pinia/nuxt": "^0.11.2",
"dompurify": "^3.2.6",
"eslint": "^9.32.0", "eslint": "^9.32.0",
"nuxt": "^3.17.7", "nuxt": "^3.17.7",
"pinia": "^3.0.3", "pinia": "^3.0.3",

View File

@@ -31,7 +31,6 @@
<SubmitButton text="Actualizar" image-url="" /> <SubmitButton text="Actualizar" image-url="" />
</div> </div>
</form> </form>
{{ form }}
</BCol> </BCol>
</BRow> </BRow>
</BContainer> </BContainer>

View File

@@ -1,15 +1,92 @@
<template> <template>
<div> <div class="container">
Producto 1 <h1 class="title">Detalles del producto</h1>
<ProductCardDetails
:product="product"
:related="related"
:related-products="relatedProducts"
:company="company"
/>
</div> </div>
</template> </template>
<script> <script>
import { useRoute } from 'vue-router'
export default { export default {
setup() {
definePageMeta({
layout: 'mainbanner'
})
},
data() {
return {
product: null,
company: null,
relatedProducts: null,
related: false,
}
},
async mounted() {
try {
const config = useRuntimeConfig()
const $route = useRoute()
this.product = await $fetch(`/products/${$route.params.id}/`, {
baseURL: config.public.baseURL,
method: 'GET',
})
if (this.product.company) {
this.company = await $fetch(`/companies/${this.product.company.id}/`, {
baseURL: config.public.baseURL,
method: 'GET',
})
}
this.relatedProducts = await $fetch(`/products/${this.product.id}/related/`, {
baseURL: config.public.baseURL,
method: 'GET',
})
this.related = this.relatedProducts.length > 0
//return { product, company, relatedProducts, related }
} catch (error) {
console.error('Error fetching product details:', error)
}
},
//TODO: meta data
// mounted() {
// useHead({
// title: `latienda.coop | ${this.product.id}`,
// meta: [
// {
// hid: 'description',
// name: 'description',
// content: this.product.description,
// },
// { property: 'og:title', content: this.product.id },
// { property: 'og:description', content: this.product.description },
// { property: 'og:image', content: this.product.image },
// { property: 'og:url', content: this.product.url },
// { name: 'twitter:card', content: 'summary_large_image' },
// ],
// })
// },
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.container {
margin-top: 40px;
margin-bottom: 80px;
@include mobile {
margin-top: 80px;
}
}
.title {
margin-bottom: 40px;
font-size: $xl;
color: $color-navy;
}
</style> </style>