Merge pull request #1 from enredacoop/migration/latienda-v2-to-v3
migration/latienda-v2-to-v3
This commit is contained in:
441
components/CompanyForm.vue
Normal file
441
components/CompanyForm.vue
Normal file
@@ -0,0 +1,441 @@
|
||||
<template>
|
||||
<BModal
|
||||
id="modal-center"
|
||||
v-model="activeModal"
|
||||
centered
|
||||
title="latienda.coop"
|
||||
:ok-variant="modalColor"> {{ modalText }}
|
||||
</BModal>
|
||||
<form class="form" @submit.prevent="submitCompany">
|
||||
<FormHeader title="general" />
|
||||
<p class="help-text">
|
||||
Estos son los datos básicos de la cooperativa. Procura completar el mayor
|
||||
número de campos posible.
|
||||
</p>
|
||||
<fieldset class="fieldset fieldset-general">
|
||||
<div class="cont">
|
||||
<FormInput
|
||||
v-model="form.cif"
|
||||
:value="form.cif ? form.cif : ''"
|
||||
type="text"
|
||||
label-text="C.I.F."
|
||||
@input="form.cif = $event"
|
||||
/>
|
||||
<FormInput
|
||||
v-model="form.company_name"
|
||||
:value="form.company_name ? form.company_name : ''"
|
||||
type="text"
|
||||
label-text="Nombre de la cooperativa"
|
||||
@input="form.company_name = $event"
|
||||
/>
|
||||
</div>
|
||||
<div class="cont">
|
||||
<FormInput
|
||||
v-model="form.short_name"
|
||||
:value="form.short_name ? form.short_name : ''"
|
||||
type="text"
|
||||
label-text="Nombre corto"
|
||||
@input="form.short_name = $event"
|
||||
/>
|
||||
<FormInput
|
||||
v-model="form.web_link"
|
||||
:value="form.web_link ? form.web_link : ''"
|
||||
type="text"
|
||||
label-text="Sitio web"
|
||||
@input="form.web_link = $event"
|
||||
/>
|
||||
<small v-if="!isValid(form.shop_link)" class="error"
|
||||
>La url no es válida</small
|
||||
>
|
||||
</div>
|
||||
<div class="cont">
|
||||
<FormInput
|
||||
v-model="form.email"
|
||||
:value="form.email ? form.email : ''"
|
||||
type="text"
|
||||
label-text="Email"
|
||||
@input="form.email = $event"
|
||||
/>
|
||||
<FormInput
|
||||
v-model="form.phone"
|
||||
:value="form.phone ? form.phone : ''"
|
||||
type="text"
|
||||
label-text="Teléfono"
|
||||
@input="form.phone = $event"
|
||||
/>
|
||||
</div>
|
||||
<div class="cont">
|
||||
<FormInput
|
||||
v-model="form.mobile"
|
||||
:value="form.mobile ? form.mobile : ''"
|
||||
type="text"
|
||||
label-text="Móvil"
|
||||
@input="form.mobile = $event"
|
||||
/>
|
||||
<FormInput
|
||||
v-model="form.other_phone"
|
||||
:value="form.other_phone ? form.other_phone : ''"
|
||||
type="text"
|
||||
label-text="Otro teléfono"
|
||||
@input="form.other_phone = $event"
|
||||
/>
|
||||
</div>
|
||||
<div class="cont-col">
|
||||
<label for="imagen">Logo</label>
|
||||
<ClientOnly>
|
||||
<FormInputImage :image-url="form.logo" @change="handleImage($event)" />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<div class="cont-col">
|
||||
<label for="tags-basic">Palabras clave</label>
|
||||
<BFormTags
|
||||
v-model="form.tags"
|
||||
placeholder="Añade palabras clave"
|
||||
input-id="tags-basic"
|
||||
/>
|
||||
</div>
|
||||
<div class="cont-col">
|
||||
<label for="">Descripción</label>
|
||||
<textarea v-model="form.description" class="textarea" type="text" />
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<FormHeader title="tienda online" />
|
||||
<p class="help-text">
|
||||
Indica si la cooperativa dispone de tienda online. En caso afirmativo,
|
||||
indica su url y la plataforma utilizada en el seleccionable. Si utilizas
|
||||
WooCommerce, puedes indicar su API para sincronizar automáticamente todos
|
||||
los productos.
|
||||
<a href="#">Sigue estos pasos para obtener la API.</a>
|
||||
</p>
|
||||
<fieldset class="fieldset">
|
||||
<div class="cont">
|
||||
<div>
|
||||
<BFormCheckbox
|
||||
v-model="form.shop"
|
||||
label="¿Tiene tienda online?"
|
||||
class="label"
|
||||
:value="form.shop"
|
||||
>Tienda online</BFormCheckbox>
|
||||
</div>
|
||||
<FormInput
|
||||
v-model="form.shop_link"
|
||||
:value="form.shop_link ? form.shop_link : ''"
|
||||
type="text"
|
||||
label-text="Enlace a tienda online"
|
||||
@input="form.shop_link = $event"
|
||||
/>
|
||||
<small v-if="!isValid(form.shop_link)" class="error"
|
||||
>La url no es válida</small
|
||||
>
|
||||
</div>
|
||||
<div class="cont-col">
|
||||
<label for="select-shop">Plataforma</label>
|
||||
<BFormSelect id="select-shop" v-model="form.platform" name="" >
|
||||
<option selected disabled value="">Plataforma</option>
|
||||
<option
|
||||
v-for="(category, key) in categories"
|
||||
:key="key"
|
||||
:value="category.value"
|
||||
>
|
||||
{{ category.text }}
|
||||
</option>
|
||||
</BFormSelect>
|
||||
</div>
|
||||
<div v-if="form.platform === 'WOO_COMMERCE'" class="cont-col">
|
||||
<h3>Configuración de WooCommerce</h3>
|
||||
<div class="cont">
|
||||
<FormInput
|
||||
v-model="form.credentials.secret"
|
||||
:value="form.credentials.secret ? form.credentials.secret : ''"
|
||||
type="text"
|
||||
label-text="API WooCoomerce clave secreta"
|
||||
@input="form.credentials.secret = $event"
|
||||
/>
|
||||
<FormInput
|
||||
v-model="form.credentials.key"
|
||||
:value="form.credentials.key ? form.credentials.key : ''"
|
||||
type="text"
|
||||
label-text="API WooCoomerce clave pública"
|
||||
@input="form.credentials.key = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<FormHeader title="localización" />
|
||||
<p class="help-text">
|
||||
La geolocalización nos ayudará a mostrar los productos a aquellos usuarios
|
||||
que estén cerca de tu posición.
|
||||
</p>
|
||||
<fieldset class="fieldset">
|
||||
<!-- TODO: Arreglar este componente: -->
|
||||
<!-- <GoogleAddress :value="form.address" @added-data="getPlace" /> -->
|
||||
<br />
|
||||
<div class="cont">
|
||||
<FormInput
|
||||
v-model="form.shop_rss_feed"
|
||||
:value="form.shop_rss_feed ? form.shop_rss_feed : ''"
|
||||
type="text"
|
||||
label-text="Enlace RSS"
|
||||
@input="form.shop_rss_feed = $event"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<FormHeader title="términos y condiciones" />
|
||||
<p class="help-text help-text-terms">
|
||||
En estos textos podrás indicar las condiciones de venta o condiciones de
|
||||
envío de la cooperativa. Si no tienes claro qué texto añadir, puedes hacer
|
||||
<a @click="loadText">click aquí para cargar un texto predefinido</a>
|
||||
que podrás editar. Ten en cuenta que al clicar se borrará el contenido de
|
||||
ambos campos.
|
||||
</p>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<div class="cont-col">
|
||||
<label for="">Condiciones de venta</label>
|
||||
<textarea v-model="form.sale_terms" class="textarea" type="text" />
|
||||
<label for="">Condiciones de envío</label>
|
||||
<textarea v-model="form.shipping_terms" class="textarea" type="text" />
|
||||
<FormInput
|
||||
v-model="form.shipping_cost"
|
||||
:value="form.shipping_cost ? form.shipping_cost : ''"
|
||||
type="text"
|
||||
label-text="Gastos de envío"
|
||||
@input="form.shipping_cost = $event"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div align="center">
|
||||
<SubmitButton text="guardar" image-url="" />
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
export default {
|
||||
setup() {
|
||||
const auth = useAuthStore();
|
||||
return {
|
||||
auth,
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
categories: [
|
||||
{ text: 'WooCommerce', value: 'WOO_COMMERCE' },
|
||||
{ text: 'Prestashop', value: 'PRESTASHOP' },
|
||||
{ text: 'Shopify', value: 'SHOPIFY' },
|
||||
],
|
||||
form: {
|
||||
id: null,
|
||||
cif: '',
|
||||
company_name: '',
|
||||
short_name: '',
|
||||
web_link: '',
|
||||
shop: false,
|
||||
shop_link: '',
|
||||
platform: '',
|
||||
email: '',
|
||||
logo: null,
|
||||
city: null,
|
||||
address: '',
|
||||
tags: [],
|
||||
geo: null,
|
||||
phone: '',
|
||||
credentials: { secret: '', key: '' },
|
||||
mobile: '',
|
||||
other_phone: '',
|
||||
description: '',
|
||||
shop_rss_feed: '',
|
||||
sale_terms: '',
|
||||
shipping_cost: '',
|
||||
sync: false,
|
||||
},
|
||||
|
||||
default_text: {
|
||||
sale_terms: `Devoluciones - derecho de desistimiento
|
||||
|
||||
Puedes devolver un pedido comprado a través del vendedor en el plazo de 14 días desde que se efectuó la entrega ejerciendo el derecho de desistimiento. Para cualquier otra duda puede consultar a la cooperativa que le ha vendido el producto.
|
||||
|
||||
Garantía Legal
|
||||
|
||||
Si el producto recibido no concuerda con la descripción o es defectuoso dentro de los 24 meses siguientes a su recepción, tiene derecho de manera gratuita a la reparación, reemplazo o reembolso del producto por parte del vendedor.`,
|
||||
|
||||
shipping_terms: `Salvo que se indique lo contrario los pedidos gestionados por esta cooperativa se envían en un plazo de dos a cinco días desde la recepción del pedido. Se le avisará si su pedido sufre algún retraso o se cancela.`,
|
||||
},
|
||||
|
||||
activeModal: false,
|
||||
modalText: '',
|
||||
modalColor: '',
|
||||
}
|
||||
},
|
||||
|
||||
async mounted() {
|
||||
await this.getCompany()
|
||||
},
|
||||
|
||||
methods: {
|
||||
isValid(url) {
|
||||
if (url) return /^(ftp|http|https):\/\/[^ "]+\.+[^ "]+$/.test(url)
|
||||
else return true
|
||||
},
|
||||
loadText() {
|
||||
this.form.sale_terms = this.default_text.sale_terms
|
||||
this.form.shipping_terms = this.default_text.shipping_terms
|
||||
},
|
||||
getPlace(value) {
|
||||
if (value) {
|
||||
this.form.address = value.address
|
||||
this.form.geo = value.geo
|
||||
this.form.city = value.city
|
||||
}
|
||||
},
|
||||
async getCompany() {
|
||||
const config = useRuntimeConfig()
|
||||
const data = await $fetch('my_company/', {
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.auth.access}`
|
||||
}
|
||||
})
|
||||
if (data) {
|
||||
this.form = data.company
|
||||
if (this.form.credentials === null)
|
||||
this.form.credentials = { secret: '', key: '' }
|
||||
}
|
||||
},
|
||||
async handleImage(e) {
|
||||
this.form.logo = e
|
||||
},
|
||||
|
||||
parseForm() {
|
||||
const formData = new FormData()
|
||||
Object.keys(this.form).forEach((key) => {
|
||||
if ((key === 'geo') | (key === 'tags') | (key === 'credentials'))
|
||||
formData.append(key, JSON.stringify(this.form[key]))
|
||||
else if (key === 'city') formData.append(key, this.form[key])
|
||||
else if (this.form[key] || this.form[key] === '')
|
||||
formData.append(key, this.form[key])
|
||||
})
|
||||
|
||||
if (typeof formData.get('logo') === 'string') {
|
||||
formData.delete('logo')
|
||||
}
|
||||
|
||||
return formData
|
||||
},
|
||||
|
||||
async submitCompany() {
|
||||
const formData = this.parseForm()
|
||||
console.log('Submitting company data:', formData)
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
await $fetch(`/companies/${this.form.id}/`, {
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'PATCH',
|
||||
//TODO: review with Diego. I changed formaData with this.form
|
||||
body: this.form,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.auth.access}`
|
||||
}
|
||||
})
|
||||
this.modalText = 'Actualizado correctamente'
|
||||
this.modalColor = 'success'
|
||||
this.activeModal = true
|
||||
} catch (error) {
|
||||
this.modalText = 'Ha habido un error'
|
||||
this.modalColor = 'danger'
|
||||
this.activeModal = true
|
||||
console.error('Error al actualizar la cooperativa:', error)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.form {
|
||||
@include desktop {
|
||||
width: 40%;
|
||||
}
|
||||
@include tablet {
|
||||
width: 60%;
|
||||
}
|
||||
@include mobile {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.imagenInput {
|
||||
font-size: $s;
|
||||
}
|
||||
|
||||
label, .label {
|
||||
text-align: left;
|
||||
color: $color-navy;
|
||||
font-weight: $bold;
|
||||
font-size: $xs;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
background-color: $color-grey-inputs;
|
||||
border: 1px solid $color-grey-inputs-border;
|
||||
border-radius: 4px;
|
||||
padding: 10px 5px;
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
.fieldset {
|
||||
margin-bottom: 70px;
|
||||
align-items: left;
|
||||
}
|
||||
.cont {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.cont-col {
|
||||
margin: 15px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
h3 {
|
||||
color: $color-navy;
|
||||
font-size: $m;
|
||||
margin: 20px 0 15px 0;
|
||||
}
|
||||
}
|
||||
.help-text {
|
||||
text-align: left;
|
||||
font-size: $xs;
|
||||
text-align: justify;
|
||||
font-weight: $regular;
|
||||
color: $color-greylayout;
|
||||
font-family: $font-secondary;
|
||||
background-color: $color-light;
|
||||
margin-bottom: 20px;
|
||||
margin-top: -10px;
|
||||
|
||||
a {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
color: $color-greytext;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
.error {
|
||||
color: crimson;
|
||||
}
|
||||
</style>
|
||||
237
components/CoopCard.vue
Normal file
237
components/CoopCard.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<div class="productcard_container">
|
||||
<div class="row productcard_container-basic">
|
||||
<div class="image_container col-md-2">
|
||||
<img
|
||||
v-if="coop.logo"
|
||||
class="image"
|
||||
:src="coop.logo"
|
||||
:alt="coop.company_name"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
class="image"
|
||||
src="@/assets/img/latienda-product-default.svg"
|
||||
:alt="coop.company_name"
|
||||
/>
|
||||
</div>
|
||||
<div class="info_container col-md-6" align="left">
|
||||
<NuxtLink :to="`/c/${this.coop.id}`">
|
||||
<h2>{{ coop.company_name }}</h2>
|
||||
</NuxtLink>
|
||||
<p class="description">{{ coop.description }}</p>
|
||||
<div class="tags_container">
|
||||
<NuxtLink
|
||||
:to="tagRoute(n)"
|
||||
class="tag_container"
|
||||
v-for="n in coop.tags"
|
||||
:key="n"
|
||||
>
|
||||
<img class="tag_img" src="@/assets/img/latienda-tag.svg" />
|
||||
<span>{{ n }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 button_container" align="center">
|
||||
<a v-if="coop.shop_link" :href="coop.shop_link" class="button_buy">
|
||||
<img
|
||||
alt="tienda"
|
||||
class="button_cart_img"
|
||||
src="@/assets/img/latienda-tienda.svg"
|
||||
/>
|
||||
<span>Tienda online</span>
|
||||
</a>
|
||||
<a v-if="coop.web_link" :href="coop.web_link" class="button_buy">
|
||||
<img
|
||||
alt="web"
|
||||
class="button_cart_img"
|
||||
src="@/assets/img/latienda-web.svg"
|
||||
/>
|
||||
<span>Página web</span>
|
||||
</a>
|
||||
<div class="smlogos_container">
|
||||
<div class="smlogo_container">
|
||||
<a @click="shareFacebook">
|
||||
<img
|
||||
class="smlogo_img"
|
||||
alt="facebook logo"
|
||||
src="@/assets/img/latienda-smlogo-facebook.svg"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<a @click="shareTwitter">
|
||||
<div class="smlogo_container">
|
||||
<img
|
||||
class="smlogo_img"
|
||||
alt="twitter logo"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import socialShare from '~/utils/socialShare'
|
||||
export default {
|
||||
name: 'CoopCard',
|
||||
props: ['coop'],
|
||||
|
||||
computed: {
|
||||
coopUrl() {
|
||||
return `${window.location.origin}/c/${this.coop.id}`
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
tagRoute(tag) {
|
||||
return `/busqueda?tags=${tag}`
|
||||
},
|
||||
|
||||
shareFacebook() {
|
||||
const url = socialShare.facebook(this.coopUrl)
|
||||
window.open(url, '_blank')
|
||||
},
|
||||
shareTwitter() {
|
||||
const url = socialShare.twitter(this.coopUrl)
|
||||
window.open(url, '_blank')
|
||||
},
|
||||
shareWhatsApp() {
|
||||
return socialShare.whatsApp(this.coopUrl)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.productcard_container {
|
||||
border: 3px #e9e9e9 solid;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
.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: #808080;
|
||||
}
|
||||
.description {
|
||||
margin-top: 8px;
|
||||
font-family: $font-secondary;
|
||||
font-size: $s;
|
||||
color: $color-greytext;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 5;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.button_container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: $s;
|
||||
|
||||
.button_buy {
|
||||
width: 100%;
|
||||
color: $color-orange;
|
||||
font-weight: $bold;
|
||||
padding: 10px 0;
|
||||
margin-bottom: 5px;
|
||||
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 {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.smlogo_container {
|
||||
display: inline-block;
|
||||
margin-top: 15px;
|
||||
|
||||
.smlogo_img {
|
||||
width: 35px;
|
||||
fill: $color-greytext;
|
||||
margin: 2px;
|
||||
}
|
||||
img:hover {
|
||||
transform: scale(1.1);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.button_cart_img {
|
||||
width: 20px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.content > h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
60
components/FacebookSignIn.vue
Normal file
60
components/FacebookSignIn.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div>
|
||||
<button class="button f-signin-btn" @click="logInWithFacebook">
|
||||
Login with Facebook
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
head() {
|
||||
return {
|
||||
script: [
|
||||
{
|
||||
src: 'https://connect.facebook.net/es_ES/sdk.js',
|
||||
defer: true,
|
||||
async: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
window.fbAsyncInit = async function () {
|
||||
await window.FB.init({
|
||||
appId: process.env.facebookId,
|
||||
cookie: true,
|
||||
version: 'v13.0',
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async logInWithFacebook() {
|
||||
await window.FB.login(function (response) {
|
||||
if (response.authResponse) {
|
||||
console.log(response)
|
||||
alert('You are logged in & cookie set!')
|
||||
// Now you can redirect the user or do an AJAX request to
|
||||
// a PHP script that grabs the signed request from the cookie.
|
||||
} else {
|
||||
alert('User cancelled login or did not fully authorize.')
|
||||
}
|
||||
})
|
||||
return false
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scooped>
|
||||
.f-signin-btn {
|
||||
align-self: center;
|
||||
background-color: $color-facebook;
|
||||
color: $color-light;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
text-transform: uppercase;
|
||||
padding: 15px 20px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@
|
||||
>{{ labelText + (required ? '*' : '') }}
|
||||
<input
|
||||
v-model="inputValue"
|
||||
:value="value ? value : inputValue"
|
||||
:required="required"
|
||||
:type="type"
|
||||
:step="step"
|
||||
|
||||
115
components/FormInputImage.vue
Normal file
115
components/FormInputImage.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div class="form-container">
|
||||
<input class="input" type="file" @change="loadImage" />
|
||||
|
||||
<!-- Editor -->
|
||||
<div v-show="edit">
|
||||
<cropper
|
||||
ref="cropperRef"
|
||||
class="cropper"
|
||||
:src="croppieImage"
|
||||
:stencil-props="{ aspectRatio: 1 }"
|
||||
/>
|
||||
<div class="buttons">
|
||||
<button class="filter-button" @click.prevent="crop">Aplicar</button>
|
||||
<button @click.prevent="cancel">Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result -->
|
||||
<div v-show="cropped && !edit">
|
||||
<img :src="cropped" @click.prevent="editImage" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Cropper } from "vue-advanced-cropper";
|
||||
import "vue-advanced-cropper/dist/style.css";
|
||||
|
||||
export default {
|
||||
components: { Cropper },
|
||||
props: {
|
||||
imageUrl: {
|
||||
type: [String, Object],
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: ["change"],
|
||||
data() {
|
||||
return {
|
||||
croppieImage: "",
|
||||
cropped: null,
|
||||
edit: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
if (typeof this.imageUrl === "string") {
|
||||
this.croppieImage = this.imageUrl;
|
||||
this.cropped = this.imageUrl;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
editImage() {
|
||||
this.edit = true;
|
||||
},
|
||||
cancel() {
|
||||
this.edit = false;
|
||||
},
|
||||
loadImage(e) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (evt) => {
|
||||
this.croppieImage = evt.target.result;
|
||||
this.edit = true;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
crop() {
|
||||
const canvas = this.$refs.cropperRef.getResult().canvas;
|
||||
if (canvas) {
|
||||
const base64 = canvas.toDataURL("image/png");
|
||||
this.cropped = base64;
|
||||
|
||||
// Emitir como File
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const fileName =
|
||||
Math.random().toString(36).substring(2, 15) + ".png";
|
||||
const file = new File([blob], fileName, { type: "image/png" });
|
||||
this.$emit("change", file);
|
||||
}
|
||||
}, "image/png");
|
||||
}
|
||||
this.edit = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.form-container {
|
||||
width: 100%;
|
||||
}
|
||||
.cropper {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
}
|
||||
.filter-button {
|
||||
background-color: $color-navy;
|
||||
color: $color-light;
|
||||
font-size: $xs;
|
||||
padding: 0.5em 0.8em;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.input {
|
||||
max-width: 100%;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 1em;
|
||||
}
|
||||
</style>
|
||||
92
components/GoogleSignIn.vue
Normal file
92
components/GoogleSignIn.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<button class="g-signin-btn" id="googleSignIn" type="button">
|
||||
Login with Google
|
||||
</button>
|
||||
<!-- id="google-signin-button"
|
||||
class="g-signin2"
|
||||
data-onsuccess="onGoogleSignIn" -->
|
||||
</template>
|
||||
<script>
|
||||
/* eslint-disable */
|
||||
// function onGoogleSignIn(googleUser) {
|
||||
// // Useful data for your client-side scripts:
|
||||
// var profile = googleUser.getBasicProfile()
|
||||
// console.log('ID: ' + profile.getId()) // Don't send this directly to your server!
|
||||
// console.log('Full Name: ' + profile.getName())
|
||||
// console.log('Given Name: ' + profile.getGivenName())
|
||||
// console.log('Family Name: ' + profile.getFamilyName())
|
||||
// console.log('Image URL: ' + profile.getImageUrl())
|
||||
// console.log('Email: ' + profile.getEmail())
|
||||
|
||||
// // The ID token you need to pass to your backend:
|
||||
// var id_token = googleUser.getAuthResponse().id_token
|
||||
// console.log('ID Token: ' + id_token)
|
||||
// }
|
||||
|
||||
function onLoadGoogleCallback() {
|
||||
gapi.load('auth2', function () {
|
||||
const auth2 = gapi.auth2.init({
|
||||
client_id: process.env.googleId,
|
||||
cookiepolicy: 'single_host_origin',
|
||||
scope: 'profile',
|
||||
})
|
||||
|
||||
const element = document.getElementById('googleSignIn')
|
||||
auth2.attachClickHandler(
|
||||
element,
|
||||
{},
|
||||
function (googleUser) {
|
||||
console.log('Signed in: ' + googleUser.getBasicProfile().getName())
|
||||
},
|
||||
function (error) {
|
||||
console.log('Sign-in error', error)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
head() {
|
||||
return {
|
||||
meta: [
|
||||
{
|
||||
name: 'google-signin-scope',
|
||||
content: 'profile email',
|
||||
},
|
||||
{
|
||||
name: 'google-signin-client_id',
|
||||
content: process.env.googleId,
|
||||
},
|
||||
],
|
||||
script: [
|
||||
{
|
||||
src: 'https://apis.google.com/js/platform.js',
|
||||
defer: true,
|
||||
async: true,
|
||||
},
|
||||
// {
|
||||
// src: 'https://apis.google.com/js/api:client.js',
|
||||
// },
|
||||
],
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
window.onload = onLoadGoogleCallback
|
||||
},
|
||||
|
||||
methods: {},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.g-signin-btn {
|
||||
align-self: center;
|
||||
background-color: $color-google;
|
||||
color: $color-light;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
text-transform: uppercase;
|
||||
padding: 15px 20px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
</style>
|
||||
117
components/LineChart.vue
Normal file
117
components/LineChart.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<!-- <section> -->
|
||||
<div :id="chartId" class="chart-container ">
|
||||
<div v-show="dialog && dialog.dataIndex !== -1" class="chart-dialog">
|
||||
<p>this is a test</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as echarts from 'echarts/core';
|
||||
import { LineChart } from 'echarts/charts';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
// Import the tooltip, title, rectangular coordinate system, dataset and transform components
|
||||
import {
|
||||
DatasetComponent,
|
||||
DataZoomComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
TransformComponent
|
||||
} from 'echarts/components';
|
||||
|
||||
// Features like Universal Transition and Label Layout
|
||||
import { LabelLayout, UniversalTransition } from 'echarts/features';
|
||||
import { SVGRenderer, CanvasRenderer } from 'echarts/renderers';
|
||||
|
||||
// Register the required components
|
||||
echarts.use([
|
||||
DatasetComponent,
|
||||
DataZoomComponent,
|
||||
GridComponent,
|
||||
LabelLayout,
|
||||
LegendComponent,
|
||||
LineChart,
|
||||
SVGRenderer,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
TransformComponent,
|
||||
UniversalTransition,
|
||||
CanvasRenderer
|
||||
]);
|
||||
export default {
|
||||
props: {
|
||||
chartId: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
lineChartData: {
|
||||
type: Object,
|
||||
default: () => { }
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
chart: null,
|
||||
showDataDialog: false,
|
||||
dialog: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
dataIndex: -1,
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
chartContainer() {
|
||||
return document.getElementById(this.chartId)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
lineChartData: {
|
||||
immediate: true,
|
||||
deep: true,
|
||||
handler(newVal) {
|
||||
if (newVal && newVal.xAxis && newVal.series) {
|
||||
this.$nextTick(() => {
|
||||
const el = document.getElementById(this.chartId);
|
||||
if (el && el.clientWidth > 0 && el.clientHeight > 0) {
|
||||
this.initChart();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
initChart() {
|
||||
this.chart = echarts.init(this.chartContainer, {
|
||||
renderer: 'svg',
|
||||
useDirtyRect: false,
|
||||
decalPattern: true,
|
||||
});
|
||||
this.chart.setOption(this.lineChartData)
|
||||
this.chart.resize();
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.chart-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 24rem;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.chart-dialog {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@
|
||||
<header class="header">
|
||||
<div class="container wrapper">
|
||||
<div class="navmenu-container">
|
||||
<NavMenu @logout="logout" />
|
||||
<NavMenu @handle-logout="handleLogout" />
|
||||
</div>
|
||||
<!-- isAdmin: {{ isAdmin }} <br>
|
||||
isAuthenticated: {{ isAuthenticated }} <br> -->
|
||||
@@ -71,7 +71,6 @@
|
||||
import { mapActions } from 'pinia'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
export default {
|
||||
|
||||
setup() {
|
||||
const auth = useAuthStore();
|
||||
return {
|
||||
@@ -89,10 +88,9 @@ export default {
|
||||
return this.auth.getName
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
...mapActions('auth', ['logout']),
|
||||
async logout() {
|
||||
...mapActions(useAuthStore, ['logout']),
|
||||
async handleLogout() {
|
||||
try {
|
||||
await this.logout()
|
||||
this.$router.push('/login')
|
||||
|
||||
@@ -13,12 +13,13 @@
|
||||
to="/editar/productos/importar"
|
||||
>Importar</NuxtLink
|
||||
>
|
||||
<NuxtLink to="/" @click="logout" >Cerrar sesión</NuxtLink>
|
||||
<NuxtLink to="/" @click="handleLogout" >Cerrar sesión</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { mapActions } from 'pinia'
|
||||
export default {
|
||||
setup() {
|
||||
const auth = useAuthStore();
|
||||
@@ -35,16 +36,26 @@ export default {
|
||||
await this.checkIfCoopValidated()
|
||||
},
|
||||
methods: {
|
||||
async logout() {
|
||||
await this.auth.logout()
|
||||
...mapActions(useAuthStore, ['logout']),
|
||||
async handleLogout() {
|
||||
try {
|
||||
await this.logout()
|
||||
this.$router.push('/')
|
||||
} catch (error) {
|
||||
console.error('Error logging out:', error)
|
||||
}
|
||||
},
|
||||
//TODO: check if cooperative is validated is working
|
||||
async checkIfCoopValidated() {
|
||||
const config = useRuntimeConfig()
|
||||
const accessToken = this.auth.access
|
||||
const result = await $fetch('my_company/', {
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
})
|
||||
this.coopIsValidated = result.data.company.is_validated
|
||||
this.coopIsValidated = result.company.is_validated
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<template>
|
||||
<div class="navsearch_container container-fluid">
|
||||
<NuxtLink to="/editar/perfil">Mi perfil</NuxtLink>
|
||||
<NuxtLink to="/" @click="logout" >Cerrar sesión</NuxtLink>
|
||||
<NuxtLink to="/" @click="handleLogout" >Cerrar sesión</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { mapActions } from 'pinia'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const auth = useAuthStore();
|
||||
@@ -15,8 +17,14 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async logout() {
|
||||
await this.auth.logout()
|
||||
...mapActions(useAuthStore, ['logout']),
|
||||
async handleLogout() {
|
||||
try {
|
||||
await this.logout()
|
||||
this.$router.push('/')
|
||||
} catch (error) {
|
||||
console.error('Error logging out:', error)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
<template>
|
||||
<div class="navsearch_container container-fluid">
|
||||
<NuxtLink to="/busqueda"> Todos</NuxtLink>
|
||||
<NuxtLink :to="{ name: 'busqueda', query: { order: 'newest' } }">
|
||||
Últimos productos</NuxtLink
|
||||
>
|
||||
<button @click="searchLastestProducts"> Últimos productos</button>
|
||||
<NuxtLink to="/busqueda"> Más buscados</NuxtLink>
|
||||
<NuxtLink to="/c"> Cooperativas</NuxtLink>
|
||||
<NuxtLink to="/registro"> Regístrate</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script></script>
|
||||
<script>
|
||||
export default {
|
||||
methods: {
|
||||
searchLastestProducts() {
|
||||
return navigateTo({
|
||||
name: 'busqueda',
|
||||
query: { order: 'newest' }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.navsearch_container {
|
||||
@@ -22,7 +31,7 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
a {
|
||||
a, button {
|
||||
font-size: $m;
|
||||
font-weight: $bold;
|
||||
color: $color-navy;
|
||||
@@ -31,7 +40,11 @@
|
||||
a:nth-child(1):after,
|
||||
a:nth-child(2):after,
|
||||
a:nth-child(3):after,
|
||||
a:nth-child(4):after {
|
||||
a:nth-child(4):after,
|
||||
button:nth-child(1):after,
|
||||
button:nth-child(2):after,
|
||||
button:nth-child(3):after,
|
||||
button:nth-child(4):after {
|
||||
color: $color-navy;
|
||||
content: '\22EE';
|
||||
margin: 0.5rem;
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
<template>
|
||||
<div class="nav-menu-container">
|
||||
<img
|
||||
class="burger"
|
||||
src="@/assets/img/latienda-burger-nav.svg"
|
||||
alt=""
|
||||
@click="isMenuOpen = !isMenuOpen"
|
||||
/>
|
||||
<div :class="isMenuOpen ? `shadow` : ''">
|
||||
<transition name="slider" mode="out-in">
|
||||
<div v-if="isMenuOpen" class="nav-menu">
|
||||
<img
|
||||
class="close-icon"
|
||||
src="@/assets/img/latienda-close-nav.svg"
|
||||
alt=""
|
||||
@click="isMenuOpen = !isMenuOpen"
|
||||
/>
|
||||
<nav class="nav">
|
||||
<ul class="section-list">
|
||||
<NuxtLink to="/">
|
||||
<li class="section" @click="isMenuOpen = !isMenuOpen">
|
||||
<img
|
||||
class="section-img"
|
||||
src="@/assets/img/latienda-ubicacion.svg"
|
||||
alt=""
|
||||
/>
|
||||
<span class="section-text">Inicio</span>
|
||||
</li>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/c">
|
||||
<li class="section" @click="isMenuOpen = !isMenuOpen">
|
||||
<img
|
||||
class="section-img"
|
||||
src="@/assets/img/latienda-tienda-nav.svg"
|
||||
alt=""
|
||||
/>
|
||||
<span class="section-text">Cooperativas</span>
|
||||
</li>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/page/info">
|
||||
<li class="section" @click="isMenuOpen = !isMenuOpen">
|
||||
<img
|
||||
class="section-img"
|
||||
src="@/assets/img/latienda-bag.svg"
|
||||
alt=""
|
||||
/>
|
||||
<span class="section-text">Sobre nosotros</span>
|
||||
</li>
|
||||
</NuxtLink>
|
||||
<li class="section" @click="isMenuOpen = !isMenuOpen">
|
||||
<a href="mailto:info@latienda.coop">
|
||||
<img
|
||||
class="section-img"
|
||||
src="@/assets/img/envelope-simple.svg"
|
||||
alt=""
|
||||
/>
|
||||
<span class="section-text">Contacto</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<ul class="login-list">
|
||||
<NuxtLink v-if="!isAuthenticated" to="/login">
|
||||
<li class="section" @click="isMenuOpen = !isMenuOpen">
|
||||
<img
|
||||
class="section-img"
|
||||
src="@/assets/img/latienda-lock.svg"
|
||||
alt=""
|
||||
/>
|
||||
<span class="section-text">Acceder</span>
|
||||
</li>
|
||||
</NuxtLink>
|
||||
<NuxtLink v-if="isManager" to="/editar/perfil">
|
||||
<li class="section" @click="isMenuOpen = !isMenuOpen">
|
||||
<img
|
||||
class="section-img"
|
||||
src="@/assets/img/latienda-user.svg"
|
||||
alt=""
|
||||
/>
|
||||
<span class="section-text">Perfil</span>
|
||||
</li>
|
||||
</NuxtLink>
|
||||
<NuxtLink v-if="isManager" to="/editar/cooperativa">
|
||||
<li class="section" @click="isMenuOpen = !isMenuOpen">
|
||||
<span class="section-text login">Cooperativa</span>
|
||||
</li>
|
||||
</NuxtLink>
|
||||
<NuxtLink v-if="isManager" to="/editar/productos">
|
||||
<li class="section" @click="isMenuOpen = !isMenuOpen">
|
||||
<span class="section-text login">Productos</span>
|
||||
</li>
|
||||
</NuxtLink>
|
||||
<NuxtLink v-if="isManager" to="/editar/productos/importar">
|
||||
<li class="section" @click="isMenuOpen = !isMenuOpen">
|
||||
<span class="section-text login">Importar</span>
|
||||
</li>
|
||||
</NuxtLink>
|
||||
<NuxtLink v-if="isAuthenticated" @click.native="logout" to="/">
|
||||
<li class="section" @click="isMenuOpen = !isMenuOpen">
|
||||
<img
|
||||
class="section-img"
|
||||
src="@/assets/img/latienda-sign-out.svg"
|
||||
alt=""
|
||||
/>
|
||||
<span class="section-text">Cerrar sesión</span>
|
||||
</li>
|
||||
</NuxtLink>
|
||||
</ul>
|
||||
<ul class="link-list">
|
||||
<li class="link">
|
||||
<a href="https://coceta.coop/" target="_blank">
|
||||
<span class="link-text">Coceta</span>
|
||||
</a>
|
||||
</li>
|
||||
<NuxtLink to="/page/terminos">
|
||||
<li class="link" @click="isMenuOpen = !isMenuOpen">
|
||||
<span class="link-text">Términos y condiciones</span>
|
||||
</li>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/page/legal">
|
||||
<li class="link" @click="isMenuOpen = !isMenuOpen">
|
||||
<span class="link-text">Política de privacidad</span>
|
||||
</li>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/page/cookies">
|
||||
<li class="link" @click="isMenuOpen = !isMenuOpen">
|
||||
<span class="link-text">Cookies</span>
|
||||
</li>
|
||||
</NuxtLink>
|
||||
</ul>
|
||||
<div class="credits">
|
||||
<span>2021 La Tienda.Coop</span>
|
||||
<a href="http://enreda.coop/" target="_blank"
|
||||
>Sitio desarrollado por Enreda</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
isMenuOpen: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isAuthenticated() {
|
||||
return this.$store.getters['auth/isAuthenticated']
|
||||
},
|
||||
isManager() {
|
||||
return this.$store.getters['auth/isManager']
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async logout() {
|
||||
this.isMenuOpen = false
|
||||
await this.$store.dispatch('auth/logout')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.slider-enter-active,
|
||||
.slider-leave-active {
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
.slider-enter,
|
||||
.slider-leave-to {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.nav-menu-container {
|
||||
@include tablet {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
font-family: $font-primary;
|
||||
padding: 30px 0 0 20px;
|
||||
background-color: $color-green;
|
||||
height: 100vh;
|
||||
top: 0;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
z-index: 9999999999;
|
||||
box-shadow: 2px 2px 2px 1px rgba(0, 0, 0, 0.2);
|
||||
overflow: scroll;
|
||||
@include mobile {
|
||||
width: 70%;
|
||||
}
|
||||
@include tablet {
|
||||
width: 30%;
|
||||
}
|
||||
}
|
||||
|
||||
.shadow {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 999999;
|
||||
}
|
||||
|
||||
.burger,
|
||||
.close-icon {
|
||||
width: 1.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 0.4em 0;
|
||||
}
|
||||
|
||||
.section-list {
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid $color-navy;
|
||||
}
|
||||
|
||||
.section-text {
|
||||
font-weight: $medium;
|
||||
font-size: $s;
|
||||
}
|
||||
|
||||
.section-img {
|
||||
width: 1.2rem;
|
||||
margin-right: 0.8rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
color: $color-navy;
|
||||
}
|
||||
|
||||
.link-list,
|
||||
.credits {
|
||||
margin-top: 1rem;
|
||||
margin-left: 1rem;
|
||||
padding: 0.1em;
|
||||
}
|
||||
.link {
|
||||
padding: 0.3em 0;
|
||||
font-size: $xs;
|
||||
}
|
||||
.credits {
|
||||
margin-top: 1.5rem;
|
||||
span,
|
||||
a {
|
||||
display: block;
|
||||
color: $color-navy;
|
||||
font-size: $xs;
|
||||
padding: 0.3em 0;
|
||||
}
|
||||
margin-bottom: 4rem;
|
||||
}
|
||||
.login {
|
||||
padding-left: 2rem;
|
||||
font-weight: $regular;
|
||||
}
|
||||
</style>
|
||||
@@ -95,7 +95,7 @@
|
||||
<span class="section-text login">Importar</span>
|
||||
</li>
|
||||
</NuxtLink>
|
||||
<NuxtLink v-if="isAuthenticated" @click="logout" to="/">
|
||||
<NuxtLink v-if="isAuthenticated" @click="handleLogout" to="/">
|
||||
<li class="section" @click="isMenuOpen = !isMenuOpen">
|
||||
<img
|
||||
class="section-img"
|
||||
@@ -145,6 +145,7 @@ import { mapActions } from 'pinia'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
export default {
|
||||
emits: ['handleLogout'],
|
||||
setup() {
|
||||
const auth = useAuthStore();
|
||||
return {
|
||||
@@ -165,11 +166,11 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions('auth', ['logout']),
|
||||
async logout() {
|
||||
...mapActions(useAuthStore, ['logout']),
|
||||
async handleLogout() {
|
||||
this.isMenuOpen = false
|
||||
this.$emit('logout')
|
||||
await this.logout()
|
||||
this.$emit('handleLogout')
|
||||
//await this.logout()
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
511
components/ProductCard.vue
Normal file
511
components/ProductCard.vue
Normal file
@@ -0,0 +1,511 @@
|
||||
<template>
|
||||
<div class="productcard_container">
|
||||
<div class="row productcard_container-basic">
|
||||
<div class="image_container" :class="expanded ? 'col-md-5' : 'col-md-2'">
|
||||
<img
|
||||
v-if="product.image"
|
||||
class="image"
|
||||
:src="product.image"
|
||||
:alt="product.name"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
class="image"
|
||||
src="@/assets/img/latienda-product-default.svg"
|
||||
:alt="product.name"
|
||||
/>
|
||||
</div>
|
||||
<div class="info_container" :class="expanded ? 'col-md-5' : 'col-md-6'">
|
||||
<h2 v-b-toggle="'collapse-' + product.id" 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>
|
||||
|
||||
<div
|
||||
class="description"
|
||||
:class="{ 'not-expanded-description': !expanded }"
|
||||
>
|
||||
<p
|
||||
v-if="product.description"
|
||||
v-html="sanitizedDescription"
|
||||
></p>
|
||||
<span v-if="product.shipping_terms">{{
|
||||
product.shipping_terms
|
||||
}}</span>
|
||||
</div>
|
||||
<b-collapse :id="'collapse-' + product.id" accordion="my-accordion" @show="onOpen" @hide="onClose">
|
||||
<div class="tags_container">
|
||||
<NuxtLink
|
||||
:to="tagRoute(n)"
|
||||
class="tag_container"
|
||||
v-for="n in product.tags"
|
||||
:key="n"
|
||||
>
|
||||
<img
|
||||
alt="tag image"
|
||||
class="tag_img"
|
||||
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 class="smlogo_link" @click="shareFacebook">
|
||||
<img
|
||||
class="smlogo_img"
|
||||
alt="facebook logo"
|
||||
src="@/assets/img/latienda-smlogo-facebook.svg"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<!-- <a @click="shareTwitter"> -->
|
||||
<a :href="shareTwitter()">
|
||||
<div class="smlogo_container">
|
||||
<img
|
||||
class="smlogo_img"
|
||||
alt="twitter logo"
|
||||
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 v-if="product.company" class="coop_info">
|
||||
<NuxtLink :to="`c/${product.company.id}`">
|
||||
<h2>{{ product.company.company_name }}</h2>
|
||||
</NuxtLink>
|
||||
<p>{{ product.company.description }}</p>
|
||||
<a :href="product.company.web_link">{{
|
||||
product.company.web_link
|
||||
}}</a>
|
||||
</div>
|
||||
</b-collapse>
|
||||
</div>
|
||||
<div
|
||||
:class="
|
||||
expanded
|
||||
? 'col-md-2 button_container-detail'
|
||||
: 'col-md-4 button_container'
|
||||
"
|
||||
align="center"
|
||||
>
|
||||
<button
|
||||
@click="buyIntent"
|
||||
:class="expanded ? 'button_buy-simple' : 'button_buy'"
|
||||
>
|
||||
<img
|
||||
class="button_cart_img"
|
||||
alt="cart"
|
||||
src="@/assets/img/latienda-carrito.svg"
|
||||
/>
|
||||
<span v-show="!expanded">Comprar</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="product.discount && product.discount > 0"
|
||||
class="discount-tag"
|
||||
>
|
||||
{{ `Descuento ${product.discount}%` }}
|
||||
</div>
|
||||
<span v-if="product.company" v-show="!expanded">{{
|
||||
product.company.company_name
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="expanded && relatedProducts" class="related_products">
|
||||
<h2>Productos relacionados</h2>
|
||||
<ProductsRelated :related-products="relatedProducts" />
|
||||
</div>
|
||||
<ProductModal v-if="modal" :product="product" @close-modal="closeModal" />
|
||||
<BModal
|
||||
id="modal-center"
|
||||
v-model="active"
|
||||
centered
|
||||
title="latienda.coop"
|
||||
:ok-variant="modalColor"> {{ modalText }}
|
||||
</BModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DOMPurify from 'dompurify'
|
||||
import { mapState } from 'pinia'
|
||||
import socialShare from '~/utils/socialShare'
|
||||
export default {
|
||||
props: {
|
||||
product: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
expanded: false,
|
||||
modal: false,
|
||||
productUrl: null,
|
||||
relatedProducts: null,
|
||||
active: false,
|
||||
modalText: '',
|
||||
modalColor: 'info',
|
||||
}
|
||||
},
|
||||
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>
|
||||
.productcard_container {
|
||||
border: 3px $color-grey-nav solid;
|
||||
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: $regular;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.not-expanded-description {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 5;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
@include mobile {
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
width: 40px;
|
||||
fill: $color-greytext;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
p {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.related_products {
|
||||
background-color: $color-lighter-green;
|
||||
text-align: center;
|
||||
padding: 0 15px;
|
||||
|
||||
h2 {
|
||||
margin: 35px auto;
|
||||
font-weight: $regular;
|
||||
color: $color-navy;
|
||||
font-size: $m;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.content > h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
467
components/ProductCardDetails.vue
Normal file
467
components/ProductCardDetails.vue
Normal file
@@ -0,0 +1,467 @@
|
||||
<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" />
|
||||
<BModal
|
||||
id="modal-center"
|
||||
v-model="active"
|
||||
centered
|
||||
title="latienda.coop"
|
||||
:ok-variant="modalColor"> {{ modalText }}
|
||||
</BModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState } from 'pinia'
|
||||
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,
|
||||
active: false,
|
||||
modalText: '',
|
||||
modalColor: 'info',
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState(useAuthStore, ['id', 'access']),
|
||||
},
|
||||
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 || 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'
|
||||
|
||||
}
|
||||
},
|
||||
// TODO: implement buyIntent (review functionality, because sendLog is not working)
|
||||
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 response = await $fetch('https://api.ipify.org?format=json', {
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'GET'
|
||||
})
|
||||
const ip = response.ip
|
||||
const object = {
|
||||
action: action,
|
||||
action_object: {
|
||||
model: 'product',
|
||||
id: this.product.id,
|
||||
},
|
||||
}
|
||||
console.log('Sending log OBJECT:', object)
|
||||
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)
|
||||
}
|
||||
},
|
||||
|
||||
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>
|
||||
338
components/ProductFilter.vue
Normal file
338
components/ProductFilter.vue
Normal file
@@ -0,0 +1,338 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div v-b-toggle.collapse-all class="filters-header">
|
||||
<img
|
||||
class="image"
|
||||
src="@/assets/img/product-filter-filtrar.svg"
|
||||
alt=""
|
||||
/>
|
||||
<h2 class="text">FILTRAR POR</h2>
|
||||
</div>
|
||||
<BCollapse id="collapse-all" visible>
|
||||
<!-- Location -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="location">
|
||||
<div class="googleaddress-container">
|
||||
<!-- TODO: Revisar componente -->
|
||||
<!-- <GoogleAddress
|
||||
@addedData="getPlace"
|
||||
:geo="geo"
|
||||
:label="'Cercanía'"
|
||||
/> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="collapsible collapsible-range">
|
||||
<hr />
|
||||
<div v-b-toggle.collapse-price class="m-1 filter-header">
|
||||
<h2 class="title">Precio</h2>
|
||||
<img
|
||||
src="../assets/img/latienda-arrow-down.svg"
|
||||
alt=""
|
||||
class="arrow"
|
||||
/>
|
||||
</div>
|
||||
<BCollapse id="collapse-price" visible>
|
||||
<!-- Price -->
|
||||
<div class="filter-range-container">
|
||||
<ClientOnly>
|
||||
<v-range-slider
|
||||
v-model="priceRange"
|
||||
:min="0"
|
||||
:max="500"
|
||||
step="10"
|
||||
thumb-label="always"
|
||||
color="#8cead8"
|
||||
track-color="#d6d5d5"
|
||||
>
|
||||
<template #prepend>
|
||||
<span>{{ priceRange[0] }} €</span>
|
||||
</template>
|
||||
<template #append>
|
||||
<span>{{ priceRange[1] }} €</span>
|
||||
</template>
|
||||
</v-range-slider>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<hr class="dotted-hr" />
|
||||
<div class="form-check">
|
||||
<input
|
||||
id="checkbox-shipped"
|
||||
v-model="filterForm.shipping_cost"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label class="form-check-label" for="checkbox-shipped">
|
||||
Sin gastos de envío
|
||||
</label>
|
||||
</div>
|
||||
<hr class="dotted-hr" />
|
||||
<div class="form-check">
|
||||
<input
|
||||
id="checkbox-discount"
|
||||
v-model="filterForm.discount"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
value=""
|
||||
/>
|
||||
<label class="form-check-label" for="checkbox-discount">
|
||||
Descuentos
|
||||
</label>
|
||||
</div>
|
||||
</BCollapse>
|
||||
</div>
|
||||
<div class="collapsible collapsible-checkboxes">
|
||||
<hr />
|
||||
<div v-b-toggle.collapse-categories class="m-1 filter-header">
|
||||
<h2 class="title">Categorías</h2>
|
||||
<img
|
||||
src="../assets/img/latienda-arrow-down.svg"
|
||||
alt=""
|
||||
class="arrow"
|
||||
/>
|
||||
</div>
|
||||
<BCollapse id="collapse-categories" visible>
|
||||
<div class="checkboxes">
|
||||
<div
|
||||
v-for="(n, index) in categories"
|
||||
:key="index"
|
||||
class="form-check"
|
||||
>
|
||||
<input
|
||||
:id="'checkbox-' + index"
|
||||
v-model="checkedCategories"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
:value="n"
|
||||
/>
|
||||
<label class="form-check-label" :for="'checkbox-' + index">
|
||||
{{ n }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button class="filter-button" @click="applyFilters">
|
||||
Aplicar
|
||||
</button>
|
||||
</div>
|
||||
</BCollapse>
|
||||
</div>
|
||||
</BCollapse>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
filters: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
prices: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
currentFilters: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
geo: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
|
||||
emits: ['applyFilters'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
filterForm: {
|
||||
shipping_cost: false,
|
||||
discount: false,
|
||||
},
|
||||
visible: true,
|
||||
checkedCategories: [],
|
||||
categories: [
|
||||
'Alimentación, bebida y tabaco',
|
||||
'Arte y ocio',
|
||||
'Bebés y niños pequeños',
|
||||
'Bricolaje',
|
||||
'Cámaras y ópticas',
|
||||
'Casa y jardín',
|
||||
'Economía e industria',
|
||||
'Electrónica',
|
||||
'Elementos religiosos y ceremoniales',
|
||||
'Equipamiento deportivo',
|
||||
'Juegos y juguetes',
|
||||
'Maletas y bolsos de viaje',
|
||||
'Material de oficina',
|
||||
'Mobiliario',
|
||||
'Multimedia',
|
||||
'Productos para adultos',
|
||||
'Productos para mascotas y animales',
|
||||
'Ropa y accesorios',
|
||||
'Salud y belleza',
|
||||
'Software',
|
||||
'Vehículos y recambios',
|
||||
],
|
||||
priceRange: [0, 500],
|
||||
priceRangeFilter: {},
|
||||
place: null,
|
||||
coordinates: null,
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
currentFilters(newCurrentFilters) {
|
||||
if (newCurrentFilters) {
|
||||
if (newCurrentFilters['category']) {
|
||||
this.checkedCategories = newCurrentFilters['category']
|
||||
} else {
|
||||
this.checkedCategories = []
|
||||
}
|
||||
if (Object.keys(newCurrentFilters).includes('shipping_cost')) {
|
||||
this.filterForm.shipping_cost = true
|
||||
} else {
|
||||
this.filterForm.shipping_cost = false
|
||||
}
|
||||
}
|
||||
this.filterForm.discount = this.$route.query.hasOwnProperty('discount')
|
||||
},
|
||||
prices(newPrices) {
|
||||
if (newPrices.min && newPrices.max) {
|
||||
this.priceRange = [newPrices.min, newPrices.max]
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if (this.prices.min && this.prices.max) {
|
||||
this.priceRange = [this.prices.min, this.prices.max]
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
getPlace(value) {
|
||||
this.place = value
|
||||
},
|
||||
|
||||
applyFilters() {
|
||||
const filters = {}
|
||||
filters.price_min = this.priceRange[0]
|
||||
filters.price_max = this.priceRange[1]
|
||||
if (this.filterForm.shipping_cost) filters.shipping_cost = false
|
||||
if (this.filterForm.discount) filters.discount = true
|
||||
filters.category = this.checkedCategories
|
||||
if (this.place) {
|
||||
filters.latitude = this.place.geo.latitude
|
||||
filters.longitude = this.place.geo.longitude
|
||||
}
|
||||
this.$emit('applyFilters', filters)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.filter-button {
|
||||
margin-top: 30px;
|
||||
background-color: $color-navy;
|
||||
color: $color-light;
|
||||
font-size: $xs;
|
||||
padding: 0.5em 0.8em;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.filter-range-container {
|
||||
margin-top: 60px;
|
||||
}
|
||||
.filters-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 20px;
|
||||
outline: none;
|
||||
@include mobile {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.text {
|
||||
font-size: $s;
|
||||
margin: 0;
|
||||
margin-left: 10px;
|
||||
color: $color-navy;
|
||||
font-weight: $bold;
|
||||
}
|
||||
.image {
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.location {
|
||||
label {
|
||||
font-size: $s;
|
||||
color: $color-navy;
|
||||
font-weight: $bold;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
label:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 20px;
|
||||
background: url('../assets/img/product-filter-ubicacion.svg') center /
|
||||
contain no-repeat;
|
||||
}
|
||||
|
||||
.location-input {
|
||||
background-color: $color-grey-nav;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding-top: 40px;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.collapsible {
|
||||
.filter-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
outline: none;
|
||||
|
||||
h2 {
|
||||
font-size: $s;
|
||||
color: $color-navy;
|
||||
font-weight: $bold;
|
||||
}
|
||||
img {
|
||||
width: 12px;
|
||||
}
|
||||
}
|
||||
label {
|
||||
display: inline-block;
|
||||
font-family: Noto Sans Regular, sans-serif;
|
||||
font-size: $s;
|
||||
color: $color-greytext;
|
||||
}
|
||||
}
|
||||
|
||||
.checkboxes {
|
||||
height: 6rem;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.dotted-hr {
|
||||
border-top: 2px dotted $color-greylighter;
|
||||
margin: 8px 0;
|
||||
}
|
||||
</style>
|
||||
376
components/ProductForm.vue
Normal file
376
components/ProductForm.vue
Normal file
@@ -0,0 +1,376 @@
|
||||
<template>
|
||||
<form class="form" @submit.prevent="sendProduct">
|
||||
<div class="cont-col">
|
||||
<BFormCheckbox
|
||||
id="customSwitch1"
|
||||
v-model="form.active"
|
||||
class="label"
|
||||
>Activo
|
||||
<span class="help-text"
|
||||
>Desactiva el producto si quieres paralizar temporalmente su
|
||||
venta</span
|
||||
>
|
||||
</BFormCheckbox>
|
||||
</div>
|
||||
<br />
|
||||
<FormHeader title="General" />
|
||||
<p class="help-text">
|
||||
Estos son los datos básicos de un producto. Procura completar el mayor
|
||||
número de campos posible. Los campos señalados con asterisco (*) son
|
||||
obligatorios, los demás son opcionales. Si el producto o servicio no tiene
|
||||
precio asignado, aparecerá "Consultar precio".
|
||||
</p>
|
||||
<fieldset class="fieldset">
|
||||
<div class="cont">
|
||||
<FormInput
|
||||
v-model="form.name"
|
||||
:value="form.name ? form.name : ''"
|
||||
type="text"
|
||||
label-text="Nombre"
|
||||
required
|
||||
@input="form.name = $event"
|
||||
/>
|
||||
<FormInput
|
||||
v-model="form.price"
|
||||
:value="form.price ? form.price : ''"
|
||||
min="0"
|
||||
step="0.01"
|
||||
type="number"
|
||||
label-text="Precio"
|
||||
placeholder="precio + iva"
|
||||
@input="form.price = $event"
|
||||
/>
|
||||
</div>
|
||||
<div class="cont">
|
||||
<FormInput
|
||||
v-model="form.url"
|
||||
:value="form.url ? form.url : ''"
|
||||
type="text"
|
||||
label-text="Url"
|
||||
placeholder="enlace directo al producto en tu tienda o web"
|
||||
@input="form.url = $event"
|
||||
/>
|
||||
<small v-if="form.url && !isValidUrl(form.url)" class="error">
|
||||
La url no es válida
|
||||
</small>
|
||||
<br />
|
||||
<FormInput
|
||||
v-model="form.sku"
|
||||
:value="form.sku ? form.sku : ''"
|
||||
type="text"
|
||||
label-text="SKU o identificador"
|
||||
@input="form.sku = $event"
|
||||
/>
|
||||
</div>
|
||||
<div class="cont-col">
|
||||
<label for="imagen">Imagen</label>
|
||||
<ClientOnly>
|
||||
<FormInputImage :image-url="form.image" @change="handleImage" />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<!-- <div class="cont-col">
|
||||
<input
|
||||
id="imagen"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="imagenInput"
|
||||
placeholder="Elige una imagen"
|
||||
@change="handleImage"
|
||||
/>
|
||||
</div> -->
|
||||
<div class="cont-col">
|
||||
<label for="">Descripción del producto</label>
|
||||
<textarea v-model="form.description" class="textarea" type="text" />
|
||||
</div>
|
||||
<div class="cont">
|
||||
<FormInput
|
||||
v-model="form.stock"
|
||||
:value="form.stock ? form.stock : ''"
|
||||
type="number"
|
||||
label-text="Stock"
|
||||
@input="form.stock = $event"
|
||||
/>
|
||||
<FormInput
|
||||
v-model="form.discount"
|
||||
:value="form.discount ? form.discount : ''"
|
||||
step="0.01"
|
||||
type="number"
|
||||
label-text="Descuento"
|
||||
@input="form.discount = $event"
|
||||
/>
|
||||
</div>
|
||||
<div class="cont-col">
|
||||
<FormInput
|
||||
v-model="form.identifiers"
|
||||
:value="form.identifiers ? form.identifiers : ''"
|
||||
label-text="Identificador único"
|
||||
@input="form.identifiers = $event"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="fieldset">
|
||||
<FormHeader title="Categorías" />
|
||||
<p class="help-text">
|
||||
Estos datos ayudan a que tu producto sea encontrado por los clientes.
|
||||
Procura completar el mayor número de campos posibles. Los campos
|
||||
señalados con asterisco (*) son obligatorios, los demás son opcionales.
|
||||
</p>
|
||||
|
||||
<div class="cont-col">
|
||||
<label for="category">Categoría*</label>
|
||||
<BFormInput
|
||||
id="category"
|
||||
v-model="form.category"
|
||||
autocomplete="off"
|
||||
list="my-list-id"
|
||||
/>
|
||||
<datalist id="my-list-id">
|
||||
<option v-for="(choice, index) in categories" :key="`category-${index}`">
|
||||
{{ choice }}
|
||||
</option>
|
||||
</datalist>
|
||||
<!-- <b-form-select required v-model="form.category" name="" id="category">
|
||||
<option disabled value="">Categoría</option>
|
||||
<option
|
||||
v-for="(category, key) in categories"
|
||||
:key="key"
|
||||
:value="category"
|
||||
>
|
||||
{{ category }}
|
||||
</option>
|
||||
</b-form-select> -->
|
||||
</div>
|
||||
<div class="cont-col">
|
||||
<label for="tags-basic">Palabras clave</label>
|
||||
<BFormTags
|
||||
v-model="form.tags"
|
||||
placeholder="Añade palabras clave (moda, complementos...)"
|
||||
input-id="tags-basic"
|
||||
/>
|
||||
</div>
|
||||
<div class="cont-col">
|
||||
<label for="tags-basic">Atributos</label>
|
||||
<BFormTags
|
||||
v-model="form.attributes"
|
||||
placeholder="Añade características del producto (talla, color...)"
|
||||
input-id="tags-basic"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<FormHeader title="Envío" />
|
||||
<fieldset class="fieldset">
|
||||
<div class="cont-col">
|
||||
<label for="">Condiciones de envío</label>
|
||||
<p class="help-text">
|
||||
Aquí podrás indicar las condiciones de envío específicas para este
|
||||
producto. Si no lo rellenas se mostrarán las opciones por defecto que
|
||||
puedes editar en tu formulario de edición de la Cooperativa en el
|
||||
apartado condiciones de envío.
|
||||
</p>
|
||||
<textarea v-model="form.shipping_terms" class="textarea" type="text" />
|
||||
</div>
|
||||
<div class="cont-col">
|
||||
<FormInput
|
||||
v-model="form.shipping_cost"
|
||||
:value="form.shipping_cost ? form.shipping_cost : ''"
|
||||
step="0.01"
|
||||
type="number"
|
||||
label-text="Gastos de envío"
|
||||
placeholder="ej. 4,50"
|
||||
@input="form.shipping_cost = $event"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="submit-btn" align="center">
|
||||
<SubmitButton text="guardar" image-url="" />
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FormInputImage from './FormInputImage.vue'
|
||||
import dataProcessing from '~/utils/dataProcessing'
|
||||
export default {
|
||||
components: { FormInputImage },
|
||||
props: {
|
||||
productForm: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
emits: ['send'],
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
sku: '',
|
||||
name: '',
|
||||
description: '',
|
||||
image: null,
|
||||
url: '',
|
||||
price: '',
|
||||
shipping_cost: '',
|
||||
shipping_terms: '',
|
||||
source: 'MANUAL',
|
||||
active: true,
|
||||
discount: '',
|
||||
stock: null,
|
||||
category: '',
|
||||
tags: [],
|
||||
attributes: [],
|
||||
identifiers: '',
|
||||
},
|
||||
// tagsArray: ['tag1', 'tag2', 'tag3', 'tag33'],
|
||||
categories: [
|
||||
'Alimentación, bebida y tabaco',
|
||||
'Arte y ocio',
|
||||
'Bebés y niños pequeños',
|
||||
'Bricolaje',
|
||||
'Cámaras y ópticas',
|
||||
'Casa y jardín',
|
||||
'Economía e industria',
|
||||
'Electrónica',
|
||||
'Elementos religiosos y ceremoniales',
|
||||
'Equipamiento deportivo',
|
||||
'Juegos y juguetes',
|
||||
'Maletas y bolsos de viaje',
|
||||
'Material de oficina',
|
||||
'Mobiliario',
|
||||
'Multimedia',
|
||||
'Productos para adultos',
|
||||
'Productos para mascotas y animales',
|
||||
'Ropa y accesorios',
|
||||
'Salud y belleza',
|
||||
'Software',
|
||||
'Vehículos y recambios',
|
||||
],
|
||||
}
|
||||
},
|
||||
|
||||
async mounted() {
|
||||
await this.getAllCategories()
|
||||
if (this.productForm && Object.keys(this.productForm).length > 0) {
|
||||
Object.keys(this.form).forEach((key) => {
|
||||
this.form[key] = this.productForm[key]
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
isValidUrl: dataProcessing.isValidUrl,
|
||||
async getAllCategories() {
|
||||
const config = useRuntimeConfig()
|
||||
const data = await $fetch(`/products/all_categories/`,{
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'GET',
|
||||
|
||||
})
|
||||
this.categories = data
|
||||
},
|
||||
parseForm() {
|
||||
const formData = new FormData()
|
||||
|
||||
Object.keys(this.form).forEach((key) => {
|
||||
if ((key === 'tags') | (key === 'attributes')) {
|
||||
formData.append(key, JSON.stringify(this.form[key]))
|
||||
} else if (this.form[key] || this.form[key] === '')
|
||||
formData.append(key, this.form[key])
|
||||
})
|
||||
if (typeof formData.get('image') === 'string') {
|
||||
formData.delete('image')
|
||||
}
|
||||
//console.log('FormData:', formData)
|
||||
return formData
|
||||
},
|
||||
|
||||
async handleImage(e) {
|
||||
this.form.image = e
|
||||
},
|
||||
|
||||
sendProduct() {
|
||||
const formData = this.parseForm()
|
||||
//TODO: review with Diego. I changed formaData with this.form
|
||||
this.$emit('send', formData)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.form {
|
||||
@include desktop {
|
||||
width: 40%;
|
||||
}
|
||||
@include tablet {
|
||||
width: 60%;
|
||||
}
|
||||
@include mobile {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.imagenInput {
|
||||
font-size: $s;
|
||||
}
|
||||
|
||||
label, .label {
|
||||
text-align: left;
|
||||
color: $color-navy;
|
||||
font-weight: $bold;
|
||||
font-size: $xs;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
background-color: $color-grey-inputs;
|
||||
border: 1px solid $color-grey-inputs-border;
|
||||
border-radius: 4px;
|
||||
padding: 10px 5px;
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
.fieldset {
|
||||
margin-top: 40px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.cont {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.cont-col {
|
||||
margin: 15px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
h3 {
|
||||
color: $color-navy;
|
||||
font-size: $m;
|
||||
margin: 10px 0 15px 0;
|
||||
}
|
||||
}
|
||||
.submit-btn {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.help-text {
|
||||
text-align: left;
|
||||
font-size: $xs;
|
||||
text-align: justify;
|
||||
font-weight: $regular;
|
||||
color: $color-greylayout;
|
||||
font-family: $font-secondary;
|
||||
background-color: $color-light;
|
||||
margin-bottom: 20px;
|
||||
margin-top: 6px;
|
||||
|
||||
a {
|
||||
text-decoration: underline;
|
||||
color: $color-greytext;
|
||||
}
|
||||
}
|
||||
.error {
|
||||
color: $color-error;
|
||||
}
|
||||
</style>
|
||||
310
components/ProductModal.vue
Normal file
310
components/ProductModal.vue
Normal 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 {
|
||||
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', null)
|
||||
},
|
||||
},
|
||||
}
|
||||
</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>
|
||||
86
components/ProductsRelated.vue
Normal file
86
components/ProductsRelated.vue
Normal 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>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="container wrapper">
|
||||
<form @submit.prevent="search" class="search-container">
|
||||
<form class="search-container" @submit.prevent="search" >
|
||||
<div class="categorias-wrapper">
|
||||
<select v-model="selectedCategory" class="categorias">
|
||||
<option selected value="">Todas las categorías</option>
|
||||
@@ -15,13 +15,13 @@
|
||||
</div>
|
||||
<input
|
||||
id="searchbox"
|
||||
@focus="focused"
|
||||
@blur="focusedOut"
|
||||
v-model="searchText"
|
||||
class="search-text"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder=""
|
||||
@focus="focused"
|
||||
@blur="focusedOut"
|
||||
/>
|
||||
<div class="search-link">
|
||||
<img
|
||||
@@ -76,7 +76,7 @@ export default {
|
||||
this.startTyping()
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
beforeUnmount() {
|
||||
this.stopTyping()
|
||||
},
|
||||
|
||||
@@ -96,8 +96,8 @@ export default {
|
||||
let i = 0
|
||||
let word = 0
|
||||
let step = 0
|
||||
let input = document.querySelector('#searchbox')
|
||||
let placeholderTexts = [
|
||||
const input = document.querySelector('#searchbox')
|
||||
const placeholderTexts = [
|
||||
'Jabón sólido',
|
||||
'Huertos de libertad',
|
||||
'Hierbabuena',
|
||||
|
||||
181
components/TrendStats.vue
Normal file
181
components/TrendStats.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div v-if="values" :style="cssProps" class="trendChart">
|
||||
<div class="data">
|
||||
<div class="data-info">
|
||||
<header>
|
||||
<strong class="title">{{ name }}</strong>
|
||||
</header>
|
||||
<div v-if="info" class="period">
|
||||
<span class="period-text">{{ info?.month }}</span>
|
||||
<strong class="value">{{ info?.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart">
|
||||
<ClientOnly>
|
||||
<LineChart :chart-id="chartId" :line-chart-data="dataset" />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
chartId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
values: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: 'inherit',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
currentInfo: null,
|
||||
months: [
|
||||
'Enero',
|
||||
'Febrero',
|
||||
'Marzo',
|
||||
'Abril',
|
||||
'Mayo',
|
||||
'Junio',
|
||||
'Julio',
|
||||
'Agosto',
|
||||
'Septiembre',
|
||||
'Octubre',
|
||||
'Noviembre',
|
||||
'Diciembre',
|
||||
],
|
||||
abrevMonths: [
|
||||
'Ene',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Abr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Ago',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dic',
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
dataset() {
|
||||
return {
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: this.abrevMonths
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: this.yAxisValues,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
color: this.color
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
cssProps() {
|
||||
return {
|
||||
'--color': this.color,
|
||||
}
|
||||
},
|
||||
info() {
|
||||
const actualMonth = new Date().getMonth();
|
||||
const currentMonthInfo = this.values.sort((a, b) => a.month - b.month)[actualMonth];
|
||||
const res = {
|
||||
month: currentMonthInfo?.month ? this.months[actualMonth] : 'Este mes',
|
||||
value: currentMonthInfo?.value ? currentMonthInfo?.value : 0,
|
||||
};
|
||||
return res;
|
||||
},
|
||||
yAxisValues(){
|
||||
const ArrValues = [...this.values]
|
||||
const res = [];
|
||||
ArrValues.sort((a, b) => a.month - b.month)
|
||||
ArrValues.forEach(item => {
|
||||
res.push(item.value)
|
||||
})
|
||||
return res
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
// onMouseMove(params) {
|
||||
// if (params) {
|
||||
// this.currentInfo = {
|
||||
// month: this.months[params.data[0].month - 1],
|
||||
// value: params.data[0].value,
|
||||
// }
|
||||
// } else {
|
||||
// this.currentInfo = null
|
||||
// }
|
||||
// },
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.trendChart {
|
||||
border-bottom: 2px solid rgba(var(--color), 0.2);
|
||||
.title {
|
||||
color: var(--color);
|
||||
}
|
||||
.data {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
.period {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
.value {
|
||||
font-size: 30px;
|
||||
}
|
||||
.chart {
|
||||
width: 40%;
|
||||
|
||||
}
|
||||
}
|
||||
.stroke {
|
||||
stroke-width: 2;
|
||||
stroke: var(--color);
|
||||
}
|
||||
.fill {
|
||||
opacity: 0.2;
|
||||
fill: var(--color);
|
||||
}
|
||||
.active-line {
|
||||
stroke: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.point {
|
||||
display: none;
|
||||
fill: var(--color);
|
||||
stroke: var(--color);
|
||||
}
|
||||
.point.is-active {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
8
example copy.env
Normal file
8
example copy.env
Normal file
@@ -0,0 +1,8 @@
|
||||
API = 'http://0.0.0.0:8000/'
|
||||
API_V1 = '/api/v1/'
|
||||
BASE_URL= $API$API_V1
|
||||
GOOGLE_CLIENT_ID='1076928279923-s6hdbpcpbeaeqnact5t5kmktrkaipndq.apps.googleusercontent.com'
|
||||
FACEBOOK_ID='751687992155393'
|
||||
|
||||
GOOGLE_MAPS_API_KEY = 'AIzaSyDX_8JveVk4S5cnDcMHijmOO9HGj5OJHmQ'
|
||||
GOOGLE_ANALYTICS_ID = 'G-QT1WE1L619'
|
||||
@@ -5,20 +5,32 @@
|
||||
<NuxtLink to="/admin/cooperativas"> Administrar cooperativas </NuxtLink>
|
||||
<NuxtLink to="/admin/importar"> Alta de cooperativas por .csv </NuxtLink>
|
||||
<NuxtLink to="/admin/estadisticas"> Estadísticas </NuxtLink>
|
||||
<NuxtLink to="/" @click="logout">Cerrar sesión</NuxtLink>
|
||||
<button @click="handleLogout" class="logout-link">Cerrar sesión</button>
|
||||
<!-- <NuxtLink to="/" @click="handleLogout">Cerrar sesión</NuxtLink> -->
|
||||
</div>
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { mapActions } from 'pinia'
|
||||
export default {
|
||||
|
||||
setup() {
|
||||
const auth = useAuthStore();
|
||||
return {
|
||||
auth,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions('auth', ['logout']),
|
||||
async logout() {
|
||||
await this.logout()
|
||||
...mapActions(useAuthStore, ['logout']),
|
||||
async handleLogout() {
|
||||
try {
|
||||
await this.logout()
|
||||
this.$router.push('/')
|
||||
} catch (error) {
|
||||
console.error('Error logging out:', error)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -30,7 +42,7 @@ export default {
|
||||
text-align: center;
|
||||
padding: 20px 20px;
|
||||
|
||||
a {
|
||||
a, button {
|
||||
font-size: $m;
|
||||
font-weight: $bold;
|
||||
color: $color-navy;
|
||||
@@ -38,7 +50,11 @@ export default {
|
||||
}
|
||||
a:nth-child(1):after,
|
||||
a:nth-child(2):after,
|
||||
a:nth-child(3):after {
|
||||
a:nth-child(3):after,
|
||||
button:nth-child(1):after,
|
||||
button:nth-child(2):after,
|
||||
button:nth-child(3):after,
|
||||
button:nth-child(4):after {
|
||||
color: $color-navy;
|
||||
content: '\22EE';
|
||||
margin: 0.5rem;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
export default defineNuxtRouteMiddleware((to, from) => {
|
||||
//TODO: remove logs
|
||||
console.log('🔍 Middleware ejecutado')
|
||||
console.log('📦 to.meta:', to.meta)
|
||||
// console.log('🔍 Middleware ejecutado')
|
||||
// console.log('📦 to.meta:', to.meta)
|
||||
const AUTH_ROLES = {
|
||||
ANON: 0,
|
||||
SHOP_USER: 1,
|
||||
@@ -11,12 +10,12 @@ export default defineNuxtRouteMiddleware((to, from) => {
|
||||
}
|
||||
const authStore = useAuthStore()
|
||||
const userRole = authStore.role
|
||||
console.log('👤 Rol actual:', userRole)
|
||||
//console.log('👤 Rol actual:', userRole)
|
||||
|
||||
const authority = to.meta?.auth?.authority as keyof typeof AUTH_ROLES
|
||||
const requiredLevel = AUTH_ROLES[authority]
|
||||
//const required = to.meta.auth?.authority
|
||||
console.log('⚠️ Autoridad requerida:', authority, requiredLevel)
|
||||
//console.log('⚠️ Autoridad requerida:', authority, requiredLevel)
|
||||
|
||||
|
||||
// Check if user is connected first
|
||||
@@ -25,18 +24,18 @@ export default defineNuxtRouteMiddleware((to, from) => {
|
||||
// Get authorizations for matched routes (with children routes too)
|
||||
|
||||
const userLevel = AUTH_ROLES[userRole as keyof typeof AUTH_ROLES]
|
||||
console.log('🧮 userLevel:', userLevel, 'requiredLevel:', requiredLevel)
|
||||
//console.log('🧮 userLevel:', userLevel, 'requiredLevel:', requiredLevel)
|
||||
|
||||
console.log('[Auth Middleware]', {
|
||||
to: to.path,
|
||||
meta: to.meta,
|
||||
userRole: authStore.role,
|
||||
requiredLevel,
|
||||
userLevel,
|
||||
})
|
||||
// console.log('[Auth Middleware]', {
|
||||
// to: to.path,
|
||||
// meta: to.meta,
|
||||
// userRole: authStore.role,
|
||||
// requiredLevel,
|
||||
// userLevel,
|
||||
// })
|
||||
|
||||
if (userLevel < requiredLevel) {
|
||||
console.log('🚫 Bloqueando acceso - redirigiendo a /login')
|
||||
// console.log('🚫 Bloqueando acceso - redirigiendo a /login')
|
||||
return navigateTo('/login')
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { $fetch } from 'ofetch'
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
components: true,
|
||||
ssr: true,
|
||||
devtools: { enabled: true },
|
||||
modules: [
|
||||
'@nuxt/eslint',
|
||||
@@ -37,7 +38,11 @@ export default defineNuxtConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
plugins: [
|
||||
'~/plugins/google-analytics.client.ts',
|
||||
],
|
||||
|
||||
sitemap: {
|
||||
exclude: ['/admin', '/admin/**', '/editar', '/editar/**'],
|
||||
urls: async () => {
|
||||
|
||||
120
package-lock.json
generated
120
package-lock.json
generated
@@ -12,11 +12,16 @@
|
||||
"@nuxt/eslint": "^1.8.0",
|
||||
"@nuxtjs/sitemap": "^7.4.3",
|
||||
"@pinia/nuxt": "^0.11.2",
|
||||
"dompurify": "^3.2.6",
|
||||
"echarts": "^6.0.0",
|
||||
"eslint": "^9.32.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"nuxt": "^3.17.7",
|
||||
"pinia": "^3.0.3",
|
||||
"pinia-plugin-persistedstate": "^4.4.1",
|
||||
"pinia-plugin-persistedstate": "^4.5.0",
|
||||
"vue": "^3.5.18",
|
||||
"vue-advanced-cropper": "^2.8.9",
|
||||
"vue-gtag": "^3.5.2",
|
||||
"vue-router": "^4.5.1",
|
||||
"vue3-carousel": "^0.16.0"
|
||||
},
|
||||
@@ -4321,6 +4326,13 @@
|
||||
"integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==",
|
||||
"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": {
|
||||
"version": "2.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
||||
@@ -6035,6 +6047,12 @@
|
||||
"consola": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/classnames": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clean-regexp": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/clean-regexp/-/clean-regexp-1.0.0.tgz",
|
||||
@@ -6713,6 +6731,12 @@
|
||||
"integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debounce": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
|
||||
"integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
@@ -7027,6 +7051,15 @@
|
||||
"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": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
@@ -7094,6 +7127,28 @@
|
||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/easy-bem": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/easy-bem/-/easy-bem-1.1.1.tgz",
|
||||
"integrity": "sha512-GJRqdiy2h+EXy6a8E6R+ubmqUM08BK0FWNq41k24fup6045biQ8NXxoXimiwegMQvFFV3t1emADdGNL1TlS61A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/echarts": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
|
||||
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0",
|
||||
"zrender": "6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts/node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -9247,6 +9302,15 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-cookie": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
||||
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -11033,9 +11097,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pinia-plugin-persistedstate": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/pinia-plugin-persistedstate/-/pinia-plugin-persistedstate-4.4.1.tgz",
|
||||
"integrity": "sha512-lmuMPpXla2zJKjxEq34e1E9P9jxkWEhcVwwioCCE0izG45kkTOvQfCzvwhW3i38cvnaWC7T1eRdkd15Re59ldw==",
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/pinia-plugin-persistedstate/-/pinia-plugin-persistedstate-4.5.0.tgz",
|
||||
"integrity": "sha512-QTkP1xJVyCdr2I2p3AKUZM84/e+IS+HktRxKGAIuDzkyaKKV48mQcYkJFVVDuvTxlI5j6X3oZObpqoVB8JnWpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deep-pick-omit": "^1.2.1",
|
||||
@@ -14014,6 +14078,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-advanced-cropper": {
|
||||
"version": "2.8.9",
|
||||
"resolved": "https://registry.npmjs.org/vue-advanced-cropper/-/vue-advanced-cropper-2.8.9.tgz",
|
||||
"integrity": "sha512-1jc5gO674kVGpJKekoaol6ZlwaF5VYDLSBwBOUpViW0IOrrRsyLw6XNszjEqgbavvqinlKNS6Kqlom3B5M72Tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"classnames": "^2.2.6",
|
||||
"debounce": "^1.2.0",
|
||||
"easy-bem": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8",
|
||||
"npm": ">=5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-bundle-renderer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vue-bundle-renderer/-/vue-bundle-renderer-2.1.2.tgz",
|
||||
@@ -14052,6 +14134,21 @@
|
||||
"eslint": "^8.57.0 || ^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-gtag": {
|
||||
"version": "3.5.2",
|
||||
"resolved": "https://registry.npmjs.org/vue-gtag/-/vue-gtag-3.5.2.tgz",
|
||||
"integrity": "sha512-efTY4yrkNraFSu6CZqhFZLX5LggqCr44d6kcPnPPtzYhvu5ywrTFUnuvM2Vm238QC+YT43HkVEXV/L1OYnHvNg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"vue-router": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
|
||||
@@ -14576,6 +14673,21 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
|
||||
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender/node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,16 @@
|
||||
"@nuxt/eslint": "^1.8.0",
|
||||
"@nuxtjs/sitemap": "^7.4.3",
|
||||
"@pinia/nuxt": "^0.11.2",
|
||||
"dompurify": "^3.2.6",
|
||||
"echarts": "^6.0.0",
|
||||
"eslint": "^9.32.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"nuxt": "^3.17.7",
|
||||
"pinia": "^3.0.3",
|
||||
"pinia-plugin-persistedstate": "^4.4.1",
|
||||
"pinia-plugin-persistedstate": "^4.5.0",
|
||||
"vue": "^3.5.18",
|
||||
"vue-advanced-cropper": "^2.8.9",
|
||||
"vue-gtag": "^3.5.2",
|
||||
"vue-router": "^4.5.1",
|
||||
"vue3-carousel": "^0.16.0"
|
||||
},
|
||||
|
||||
180
pages/admin/cooperativas.vue
Normal file
180
pages/admin/cooperativas.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div v-if="!loading" class="container" >
|
||||
<BModal
|
||||
id="modal-center"
|
||||
v-model="activeModal"
|
||||
centered
|
||||
title="latienda.coop"
|
||||
:ok-variant="modalColor"> {{ modalText }}
|
||||
</BModal>
|
||||
<div class="row">
|
||||
<div class="col-10">
|
||||
<h1 class="title">Administrar cooperativas</h1>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="companies">
|
||||
<v-data-table
|
||||
v-model="selectedItemsIndexes"
|
||||
show-select
|
||||
:single-select="false"
|
||||
:headers="headers"
|
||||
:search="search"
|
||||
:items="companies"
|
||||
:loading="loading"
|
||||
loading-text="Cargando cooperativas..."
|
||||
>
|
||||
<template #top>
|
||||
<v-toolbar flat color="white">
|
||||
<!-- Search -->
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
append-icon="mdi-magnify"
|
||||
label="Buscar cooperativa"
|
||||
single-line
|
||||
hide-details
|
||||
/>
|
||||
<!-- . Search -->
|
||||
</v-toolbar>
|
||||
</template>
|
||||
<template #[`item.company_name`]="item">
|
||||
<a :href="`/c/${item.item.id}`" target="_blank" class="mr-2">
|
||||
{{ item.item.company_name }}
|
||||
</a>
|
||||
</template>
|
||||
<template #[`item.is_validated`]="item">
|
||||
<v-icon v-if="item.item.is_validated" small class="mr-2 validated">
|
||||
mdi-check
|
||||
</v-icon>
|
||||
<v-icon v-else small class="mr-2 not-validated"> mdi-close </v-icon>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</template>
|
||||
<div>
|
||||
<button class="submit-btn" @click="validateCompanies">Validar</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
export default {
|
||||
setup() {
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: 'auth',
|
||||
auth: { authority: 'SITE_ADMIN' },
|
||||
})
|
||||
const auth = useAuthStore();
|
||||
return {
|
||||
auth
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
search: '',
|
||||
companies: [],
|
||||
selectedItemsIndexes: [],
|
||||
loading: true,
|
||||
headers: [
|
||||
{ text: 'Nombre', value: 'company_name' },
|
||||
{ text: 'C.I.F', value: 'cif' },
|
||||
{ text: 'Ciudad', value: 'city' },
|
||||
{ text: 'Email', value: 'email' },
|
||||
{ text: 'Validada', value: 'is_validated' },
|
||||
],
|
||||
modalText: '',
|
||||
modalColor: '',
|
||||
activeModal: false
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
selectedItems() {
|
||||
const itemsArr = []
|
||||
this.selectedItemsIndexes.forEach(index => {
|
||||
this.companies.forEach(item => {
|
||||
if (item.id === index) {
|
||||
itemsArr.push(item)
|
||||
}
|
||||
})
|
||||
})
|
||||
return itemsArr
|
||||
}
|
||||
},
|
||||
|
||||
async mounted() {
|
||||
try{
|
||||
const config = useRuntimeConfig()
|
||||
const data = await $fetch('admin_companies/', {
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.auth.access}`
|
||||
}
|
||||
})
|
||||
this.companies = data
|
||||
this.loading = false
|
||||
} catch (error) {
|
||||
console.error('Error fetching companies:', error)
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async validateCompanies() {
|
||||
const config = useRuntimeConfig()
|
||||
try {
|
||||
await this.selectedItems.forEach(async (item) => {
|
||||
await $fetch(`admin_companies/${item.id}/`, {
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
is_validated: true,
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.auth.access}`
|
||||
},
|
||||
})
|
||||
const index = this.companies.indexOf(item)
|
||||
this.companies[index].is_validated = true
|
||||
this.selectedItemsIndexes = []
|
||||
this.modalText = 'Los productos han sido eliminados correctamente.'
|
||||
this.modalColor = 'success'
|
||||
this.activeModal = true
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error validating companies:', error)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
margin-top: 40px;
|
||||
margin-bottom: 80px;
|
||||
}
|
||||
.title {
|
||||
color: $color-navy;
|
||||
font-size: $xxl;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background-color: $color-orange;
|
||||
color: $color-light;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
text-transform: uppercase;
|
||||
padding: 10px 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.validated {
|
||||
color: green;
|
||||
}
|
||||
.not-validated {
|
||||
color: red;
|
||||
}
|
||||
</style>
|
||||
110
pages/admin/estadisticas.vue
Normal file
110
pages/admin/estadisticas.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="row general">
|
||||
<div class="text-center col-6">
|
||||
<h2>Cooperativas</h2>
|
||||
<p class="general-value">
|
||||
<strong>{{ companiesCount }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-center col-6">
|
||||
<h2>Productos</h2>
|
||||
<p class="general-value">
|
||||
<strong>{{ productsCount }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<trend-stats
|
||||
:chart-id="`1`"
|
||||
:color="`#39af77`"
|
||||
:name="`Nuevas cooperativas`"
|
||||
:values="companiesTimeline"
|
||||
/>
|
||||
<trend-stats
|
||||
:chart-id="`2`"
|
||||
:color="`red`"
|
||||
:name="`Nuevos productos`"
|
||||
:values="productsTimeline"
|
||||
/>
|
||||
<trend-stats
|
||||
:chart-id="`3`"
|
||||
:color="`purple`"
|
||||
:name="`Nuevos usuarios`"
|
||||
:values="usersTimeline"
|
||||
/>
|
||||
<trend-stats
|
||||
:chart-id="`4`"
|
||||
:color="`blue`"
|
||||
:name="`Contactados`"
|
||||
:values="contactTimeline"
|
||||
/>
|
||||
<trend-stats
|
||||
:chart-id="`5`"
|
||||
:color="`orange`"
|
||||
:name="`Redirecciones a producto`"
|
||||
:values="shoppingTimeline"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: 'auth',
|
||||
auth: { authority: 'SITE_ADMIN' },
|
||||
})
|
||||
|
||||
const auth = useAuthStore()
|
||||
return { auth }
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
companiesCount: 0,
|
||||
productsCount: 0,
|
||||
companiesTimeline: [],
|
||||
productsTimeline: [],
|
||||
usersTimeline: [],
|
||||
contactTimeline: [],
|
||||
shoppingTimeline: [],
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
const config = useRuntimeConfig()
|
||||
try {
|
||||
const stats = await $fetch('/admin_stats/', {
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.auth.access}`,
|
||||
},
|
||||
})
|
||||
if (stats) {
|
||||
this.companiesCount = stats.company_count
|
||||
this.productsCount = stats.product_count
|
||||
this.companiesTimeline = stats.companies_timeline
|
||||
this.productsTimeline = stats.products_timeline
|
||||
this.usersTimeline = stats.users_timeline
|
||||
this.contactTimeline = stats.contact_timeline
|
||||
this.shoppingTimeline = stats.shopping_timeline
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching admin stats:', error)
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.general-value {
|
||||
font-size: 50px;
|
||||
}
|
||||
</style>
|
||||
114
pages/admin/importar.vue
Normal file
114
pages/admin/importar.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<BModal
|
||||
id="modal-center"
|
||||
v-model="activeModal"
|
||||
centered
|
||||
title="latienda.coop"
|
||||
:ok-variant="modalColor"> {{ modalText }}
|
||||
</BModal>
|
||||
<div class="row">
|
||||
<div class="col-10"></div>
|
||||
<h1 class="title">Importar productos desde CSV</h1>
|
||||
</div>
|
||||
<form @submit.prevent="submitFile">
|
||||
<div class="cont-col">
|
||||
<label for="file"> Archivo .csv</label>
|
||||
<input
|
||||
id="file"
|
||||
type="file"
|
||||
accept=".csv, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel"
|
||||
placeholder="Elige un archivo"
|
||||
required
|
||||
@change="handleFile"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<SubmitButton text="Importar" />
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
export default {
|
||||
setup() {
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: 'auth',
|
||||
auth: { authority: 'SITE_ADMIN' },
|
||||
})
|
||||
const auth = useAuthStore();
|
||||
return {
|
||||
auth
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
file: null,
|
||||
error: null,
|
||||
modalText: '',
|
||||
modalColor: '',
|
||||
activeModal: false
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async handleFile(e) {
|
||||
const selectedFile = await e.target.files[0]
|
||||
this.file = selectedFile
|
||||
},
|
||||
|
||||
async submitFile() {
|
||||
this.error = null
|
||||
const formData = new FormData()
|
||||
formData.append('csv_file', this.file)
|
||||
//TODO: Review functionality
|
||||
try {
|
||||
await $fetch('/load_coops/',{
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.auth.access}`,
|
||||
},
|
||||
})
|
||||
this.modalText = 'Productos importados correctamente'
|
||||
this.modalColor = 'success'
|
||||
this.activeModal = true
|
||||
} catch (error) {
|
||||
this.error = 'Ha habido un error'
|
||||
console.error('Error importing products:', error)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
margin-top: 40px;
|
||||
margin-bottom: 80px;
|
||||
}
|
||||
.title {
|
||||
color: $color-navy;
|
||||
font-size: $xxl;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
.cont-col {
|
||||
margin: 15px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
label {
|
||||
text-align: left;
|
||||
color: $color-navy;
|
||||
font-weight: $bold;
|
||||
font-size: $xs;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: $color-error;
|
||||
}
|
||||
</style>
|
||||
29
pages/admin/index.vue
Normal file
29
pages/admin/index.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<BContainer class="admin-dashboard">
|
||||
<h1>Panel de Administración</h1>
|
||||
<p>Bienvenido al panel de administración. Aquí puedes gestionar la configuración del sitio y los permisos de los usuarios.</p>
|
||||
</BContainer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
setup() {
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: 'auth',
|
||||
auth: { authority: 'SITE_ADMIN' },
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.admin-dashboard {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.admin-dashboard h1 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,18 +1,430 @@
|
||||
<template>
|
||||
<div>
|
||||
pagina busqueda
|
||||
<h1>Busqueda</h1>
|
||||
<p>Esta es la página de búsqueda.</p>
|
||||
<p>Utiliza el componente NavBarSearch para navegar.</p>
|
||||
<div class="container mt-5">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<ProductFilter
|
||||
:filters="filters"
|
||||
:current-filters="currentFilters"
|
||||
:prices="prices"
|
||||
:geo="coordinates"
|
||||
@apply-filters="updateData"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="loadingProducts" class="col-md-9 loading-spinner">
|
||||
<BSpinner />
|
||||
<span>Cargando productos...</span>
|
||||
</div>
|
||||
<div v-if="!loadingProducts" class="col-md-9">
|
||||
<div class="carousel">
|
||||
<h2 class="title">Últimos productos</h2>
|
||||
<ItemsRow class="items" :type="`product`" :items="carouselProducts.results" />
|
||||
</div>
|
||||
<div v-if="hasFilterTags" class="applied-filters">
|
||||
<h2 class="title">FILTROS APLICADOS</h2>
|
||||
<div class="filter-buttons">
|
||||
<button
|
||||
v-if="appliedFilters.hasOwnProperty('shipping_cost')"
|
||||
type="button"
|
||||
class="btn-tag"
|
||||
@click="removeFilter('shipping_cost')"
|
||||
>
|
||||
<span>Sin gastos de envío</span>
|
||||
<img src="@/assets/img/latienda-close.svg" alt="" />
|
||||
</button>
|
||||
<button
|
||||
v-if="appliedFilters.hasOwnProperty('discount')"
|
||||
type="button"
|
||||
class="btn-tag"
|
||||
@click="removeFilter('discount')"
|
||||
>
|
||||
<span>Descuentos</span>
|
||||
<img src="@/assets/img/latienda-close.svg" alt="" />
|
||||
</button>
|
||||
<div v-if="appliedFilters.hasOwnProperty('category')">
|
||||
<button
|
||||
v-for="(cat, key) in appliedFilters.category"
|
||||
:key="key"
|
||||
type="button"
|
||||
class="btn-delete-all"
|
||||
@click="removeCategory(cat)"
|
||||
>
|
||||
<span>{{ cat }}</span>
|
||||
<img src="@/assets/img/latienda-close.svg" alt="" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="results">
|
||||
<h2 class="title"></h2>
|
||||
<p class="count">Hay {{ count }} productos</p>
|
||||
</div>
|
||||
|
||||
<div v-if="products.length !== 0">
|
||||
<div v-for="product in products" :key="product.id">
|
||||
<ProductCard :key="product.key" :product="product" />
|
||||
</div>
|
||||
<BPagination
|
||||
v-model="currentPage"
|
||||
class="pagination"
|
||||
:total-rows="count"
|
||||
:per-page="perPage"
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="no-results">
|
||||
<p>
|
||||
No hemos encontrado resultados para su búsqueda... pero puede buscar
|
||||
otro o consultar estos productos.
|
||||
</p>
|
||||
<div v-for="product in defaultProducts" :key="product.id">
|
||||
<ProductCard :key="product.key" :product="product" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- appliedFilters: {{appliedFilters}} <br>
|
||||
filters: {{filters}} <br>
|
||||
prices: {{prices}} <br>
|
||||
coordinates: {{coordinates}} <br>
|
||||
products: {{products}} <br>
|
||||
defaultProducts: {{defaultProducts}} <br>
|
||||
carouselProducts: {{carouselProducts}} <br>
|
||||
count: {{count}} -->
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import serverSearch from '~/utils/serverSearch'
|
||||
import clientSearch from '~/utils/clientSearch'
|
||||
|
||||
export default {
|
||||
setup(){
|
||||
definePageMeta({
|
||||
layout: 'mainbanner',
|
||||
})
|
||||
|
||||
const auth = useAuthStore()
|
||||
return { auth }
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
searchText: '',
|
||||
currentPage: 1,
|
||||
perPage: 10,
|
||||
filterTags: {
|
||||
shipping_cost: undefined,
|
||||
},
|
||||
previousParams: null,
|
||||
currentFilters: null,
|
||||
mountedProducts: [],
|
||||
appliedFilters: {},
|
||||
filters: {},
|
||||
prices: { min: null, max: null },
|
||||
coordinates: null,
|
||||
products: [],
|
||||
defaultProducts: [],
|
||||
carouselProducts: [],
|
||||
count: 0,
|
||||
loadingProducts: true,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
hasFilterTags() {
|
||||
if (!this.appliedFilters) return false
|
||||
return (
|
||||
Object.keys(this.appliedFilters).includes('shipping_cost') ||
|
||||
Object.keys(this.appliedFilters).includes('discount') ||
|
||||
(Array.isArray(this.appliedFilters.category) &&
|
||||
this.appliedFilters.category.length > 0)
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
'$route.query'(newValue) {
|
||||
//console.log('New Value:', newValue)
|
||||
//console.log('Route changed:', this.$route.fullPath)
|
||||
//console.log('Current params:', this.$route.query)
|
||||
this.updateData(newValue)
|
||||
Object.assign(this.$data, this.$options.data())
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
async beforeCreate() {
|
||||
const config = useRuntimeConfig()
|
||||
const params = import.meta.client ? clientSearch(this.$route.query) : serverSearch(this.$route.query)
|
||||
const data = await $fetch(`/search_products/?`, {
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'GET',
|
||||
params: params,
|
||||
headers: {
|
||||
Authorization: '/',
|
||||
},
|
||||
})
|
||||
//console.log('data', data)
|
||||
|
||||
const products = data.products
|
||||
//console.log('products', products)
|
||||
let defaultProducts = []
|
||||
if (products.length === 0) {
|
||||
//console.log('no products, fetching default')
|
||||
const data = await $fetch(`/search_products/?q=${params.q}`, {
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'GET',
|
||||
params: {
|
||||
order: 'newest',
|
||||
limit: 10,
|
||||
offset: 0
|
||||
},
|
||||
headers: {
|
||||
Authorization: '/',
|
||||
},
|
||||
})
|
||||
defaultProducts = data.products
|
||||
}
|
||||
|
||||
const carouselProducts = await $fetch(`/products/`, {
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'GET',
|
||||
params: {
|
||||
limit: 10,
|
||||
offset: 0
|
||||
},
|
||||
headers: {
|
||||
Authorization: '/',
|
||||
},
|
||||
})
|
||||
//console.log('carouselProducts', carouselProducts)
|
||||
|
||||
let coordinates
|
||||
if (params.latitude && params.longitude) {
|
||||
coordinates = {
|
||||
lat: Number(params.latitude),
|
||||
lng: Number(params.longitude),
|
||||
}
|
||||
}
|
||||
|
||||
let prices
|
||||
if (params.price_min || params.price_max) {
|
||||
prices = { max: params.price_max, min: params.price_min }
|
||||
} else if (data.prices.min || data.prices.max) {
|
||||
prices = data.prices
|
||||
} else {
|
||||
prices = { max: null, min: null }
|
||||
}
|
||||
|
||||
this.appliedFilters = params.q
|
||||
this.filters = data.filters
|
||||
this.prices = prices
|
||||
this.coordinates = coordinates
|
||||
this.products = products
|
||||
this.defaultProducts = defaultProducts
|
||||
this.carouselProducts = carouselProducts
|
||||
this.count = data.count
|
||||
this.loadingProducts = false
|
||||
},
|
||||
|
||||
|
||||
mounted() {
|
||||
this.currentFilters = this.appliedFilters
|
||||
},
|
||||
|
||||
methods: {
|
||||
async handlePageChange(value) {
|
||||
const offset = (value - 1) * this.perPage
|
||||
this.products = await this.getMoreProducts(offset)
|
||||
},
|
||||
|
||||
async getMoreProducts(offset) {
|
||||
const config = useRuntimeConfig()
|
||||
const data = await $fetch(`/search_products/`, {
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'GET',
|
||||
params: {
|
||||
...this.appliedFilters,
|
||||
limit: 10,
|
||||
offset: offset
|
||||
},
|
||||
headers: {
|
||||
Authorization: '/',
|
||||
},
|
||||
})
|
||||
return data.products
|
||||
},
|
||||
|
||||
}
|
||||
async updateData(value) {
|
||||
//console.log('updateData called with:', value)
|
||||
const filters = { q: this.appliedFilters.q }
|
||||
const query = Object.keys(value).length === 0 ? { ...filters } : { ...value, ...filters }
|
||||
|
||||
//console.log('Navigating to busqueda with query:', query)
|
||||
this.$router.push({ name: 'busqueda', query })
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const data = await $fetch('/search_products/', {
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'GET',
|
||||
params: query,
|
||||
headers: { Authorization: '/' },
|
||||
})
|
||||
this.products = data.products
|
||||
this.count = data.count
|
||||
this.loadingProducts = false
|
||||
},
|
||||
|
||||
removeCategory(cat) {
|
||||
this.currentFilters = this.appliedFilters
|
||||
const categoryArray = this.currentFilters.category
|
||||
const newCats = []
|
||||
categoryArray.forEach((element) => {
|
||||
if (element !== cat) {
|
||||
newCats.push(element)
|
||||
}
|
||||
})
|
||||
this.currentFilters.category = newCats
|
||||
const noCategory = {}
|
||||
Object.entries(this.currentFilters).forEach(([key, value]) => {
|
||||
if (key !== 'category') {
|
||||
noCategory[key] = value
|
||||
}
|
||||
})
|
||||
if (newCats.length === 0) {
|
||||
return this.$router.push({
|
||||
path: this.$route.path,
|
||||
query: { ...noCategory },
|
||||
})
|
||||
} else {
|
||||
return this.$router.push({
|
||||
path: this.$route.path,
|
||||
query: { category: newCats, ...noCategory },
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
removeFilter(filter) {
|
||||
this.currentFilters = { ...this.appliedFilters }
|
||||
this.currentFilters = Object.fromEntries(
|
||||
Object.entries(this.currentFilters).filter(([key]) => key !== filter)
|
||||
)
|
||||
|
||||
return this.$router.push({
|
||||
name: 'busqueda',
|
||||
query: { ...this.currentFilters },
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
search() {
|
||||
if (this.searchText) {
|
||||
return this.$router.push({
|
||||
name: 'busqueda',
|
||||
query: { q: this.searchText },
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ad {
|
||||
margin: 40px auto;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
background-color: $color-grey-nav;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
</style>
|
||||
.applied-filters {
|
||||
@include mobile {
|
||||
display: none;
|
||||
}
|
||||
.title {
|
||||
font-size: $xl;
|
||||
color: $color-navy;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-buttons {
|
||||
margin-bottom: 30px;
|
||||
|
||||
button {
|
||||
margin-right: 5px;
|
||||
margin-bottom: 5px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 8px 10px;
|
||||
color: $color-light;
|
||||
background-color: $color-dark-green;
|
||||
|
||||
img {
|
||||
width: 18px;
|
||||
margin-left: 5px;
|
||||
position: relative;
|
||||
bottom: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-delete-all {
|
||||
background-color: $color-darker-green;
|
||||
}
|
||||
}
|
||||
|
||||
.carousel {
|
||||
@include mobile {
|
||||
display: none;
|
||||
}
|
||||
.title {
|
||||
font-size: $xl;
|
||||
color: $color-navy;
|
||||
@include tablet {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
}
|
||||
.items {
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
.results {
|
||||
.title {
|
||||
font-size: $xl;
|
||||
color: $color-navy;
|
||||
}
|
||||
.count {
|
||||
font-size: $xs;
|
||||
color: $color-greytext;
|
||||
}
|
||||
}
|
||||
.pagination {
|
||||
margin-top: 40px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
p {
|
||||
text-align: center;
|
||||
font-size: $xl;
|
||||
color: $color-navy;
|
||||
margin-top: 100px;
|
||||
margin-bottom: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
423
pages/c/[id].vue
423
pages/c/[id].vue
@@ -1,15 +1,426 @@
|
||||
<template>
|
||||
<div>
|
||||
c/id page
|
||||
<div class="container">
|
||||
<div class="row c-description">
|
||||
<div class="col-md-4">
|
||||
<div class="c-image-container">
|
||||
<img v-if="coop?.logo" :src="coop?.logo" alt="" />
|
||||
<img v-else src="@/assets/img/latienda-product-default.svg" alt="" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h1 class="coop-name">{{ coop?.company_name }}</h1>
|
||||
<p class="coop-text">
|
||||
{{ coop?.description }}
|
||||
</p>
|
||||
<div class="tags_container">
|
||||
<NuxtLink
|
||||
v-for="n in coop?.tags"
|
||||
:key="n"
|
||||
:to="tagRoute(n)"
|
||||
class="tag_container"
|
||||
>
|
||||
<img class="tag_img" src="@/assets/img/latienda-tag.svg" />
|
||||
<span>{{ n }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row coop-links">
|
||||
<div class="col-md-4">
|
||||
<BButton
|
||||
v-if="coop?.shop_link"
|
||||
class="div-link"
|
||||
align="center"
|
||||
:href="coop?.shop_link"
|
||||
target="_blank"
|
||||
>
|
||||
<img class="div-link-img" src="@/assets/img/latienda-tienda.svg" />
|
||||
<span>Tienda online</span>
|
||||
</BButton>
|
||||
<BButton
|
||||
v-if="coop?.web_link"
|
||||
class="div-link"
|
||||
align="center"
|
||||
:href="coop?.web_link"
|
||||
target="_blank"
|
||||
>
|
||||
<img class="div-link-img" src="@/assets/img/latienda-web.svg" />
|
||||
<span>Página web</span>
|
||||
</BButton>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div v-if="coop?.mobile || coop?.phone" class="div-action tel">
|
||||
<img
|
||||
class="div-action-img"
|
||||
src="@/assets/img/latienda-telefono.svg"
|
||||
/>
|
||||
<span
|
||||
>{{ coop?.phone }} <br />
|
||||
{{ coop?.mobile }}</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="coop?.email" class="div-action mail">
|
||||
<img class="div-action-img" src="@/assets/img/latienda-email.svg" />
|
||||
<span>{{ coop?.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div v-if="coop?.address" class="div-action address">
|
||||
<img class="div-action-img" src="@/assets/img/latienda-casa.svg" />
|
||||
<span>{{ coop?.address }}</span>
|
||||
</div>
|
||||
<div v-if="coop?.city" class="div-action location">
|
||||
<img
|
||||
class="div-action-img"
|
||||
src="@/assets/img/latienda-ubicacion.svg"
|
||||
/>
|
||||
<span>{{ coop?.city }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12 c-tabs">
|
||||
<div>
|
||||
<BCard no-body>
|
||||
<BTabs card>
|
||||
<BTab title="Devoluciones, garantías y reembolsos" active>
|
||||
<BCardText>
|
||||
{{
|
||||
coop?.sale_terms ||
|
||||
'Consultar con la cooperativa para más información'
|
||||
}}
|
||||
</BCardText>
|
||||
</BTab>
|
||||
<BTab title="Envío">
|
||||
<BCardText>
|
||||
{{
|
||||
coop?.shipping_terms ||
|
||||
'Consultar con la cooperativa para más información'
|
||||
}}
|
||||
</BCardText>
|
||||
</BTab>
|
||||
</BTabs>
|
||||
</BCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="slicedProducts.length !== 0">
|
||||
{{ slicedProducts }} productos encontrados
|
||||
<div v-for="product in slicedProducts" :key="product.id">
|
||||
<ProductCard :key="product.key" :product="product" />
|
||||
</div>
|
||||
<BPagination
|
||||
v-model="currentPage"
|
||||
:v-if="products"
|
||||
class="pagination"
|
||||
:total-rows="rows"
|
||||
:per-page="perPage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
|
||||
}
|
||||
import { mapState } from 'pinia'
|
||||
export default {
|
||||
setup(){
|
||||
definePageMeta({
|
||||
layout: 'main',
|
||||
})
|
||||
},
|
||||
//TODO: implement head() method
|
||||
// head() {
|
||||
// return {
|
||||
// title: `latienda.coop | ${this.coop?.company_name}`,
|
||||
// meta: [
|
||||
// {
|
||||
// hid: 'description',
|
||||
// name: 'description',
|
||||
// content: this.coop?.description,
|
||||
// },
|
||||
// { property: 'og:title', content: this.coop?.company_name },
|
||||
// { property: 'og:description', content: this.coop?.description },
|
||||
// { property: 'og:image', content: this.coop?.logo },
|
||||
// { property: 'og:url', content: this.coop?.web_link },
|
||||
// { name: 'twitter:card', content: 'summary_large_image' },
|
||||
// ],
|
||||
// }
|
||||
// },
|
||||
data() {
|
||||
return {
|
||||
coop: null,
|
||||
products: null,
|
||||
currentPage: 1,
|
||||
perPage: 10,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState(useAuthStore, ['id', 'access']),
|
||||
rows() {
|
||||
return this.products?.length
|
||||
},
|
||||
slicedProducts() {
|
||||
const initial = (this.currentPage - 1) * this.perPage
|
||||
const final = this.currentPage * this.perPage
|
||||
const items = this.products ? this.products?.slice(initial, final) : []
|
||||
return items
|
||||
},
|
||||
},
|
||||
|
||||
async created() {
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
const $route = useRoute()
|
||||
this.coop = await $fetch(`/companies/${$route.params.id}/`,
|
||||
{
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'GET',
|
||||
}
|
||||
)
|
||||
this.products = await $fetch(
|
||||
`/products?company=${route.params.id}`,
|
||||
{
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'GET',
|
||||
}
|
||||
)
|
||||
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.sendLog('view')
|
||||
},
|
||||
|
||||
methods: {
|
||||
tagRoute(tag) {
|
||||
return `/busqueda?tags=${tag}`
|
||||
},
|
||||
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: 'company',
|
||||
id: this.coop?.id,
|
||||
},
|
||||
}
|
||||
//console.log('Sending log OBJECT:', object)
|
||||
if (ip) object.ip = ip
|
||||
if (geo) object.geo = geo
|
||||
try {
|
||||
//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 /stats:', error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending log:', error)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
//global classes for Bootstrap styling
|
||||
ul.nav > li.nav-item {
|
||||
font-weight: bolder;
|
||||
:hover {
|
||||
color: $color-navy;
|
||||
}
|
||||
.active {
|
||||
border-top: 4px solid $color-navy;
|
||||
color: $color-navy;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
</style>
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
margin-top: 80px;
|
||||
margin-bottom: 80px;
|
||||
|
||||
@include mobile {
|
||||
padding: 0.4em 2em;
|
||||
}
|
||||
}
|
||||
|
||||
.c-description {
|
||||
margin-top: 40px;
|
||||
margin-bottom: 40px;
|
||||
@include mobile {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.coop-links {
|
||||
margin-bottom: 40px;
|
||||
|
||||
.div-action {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding-left: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
.img-tel {
|
||||
width: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.div-link-img,
|
||||
.div-action-img {
|
||||
width: 20px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.div-action-img {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.c-image-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.coop-name {
|
||||
font-size: $xl;
|
||||
color: $color-navy;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.coop-text {
|
||||
margin-top: 8px;
|
||||
font-family: $font-secondary;
|
||||
font-size: $s;
|
||||
color: $color-greytext;
|
||||
}
|
||||
|
||||
.div-link:hover {
|
||||
box-shadow: 0 4px 16px rgba(99, 99, 99, 0.2);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.div-link {
|
||||
width: 100%;
|
||||
border: 3px solid $color-orange;
|
||||
border-radius: 5px;
|
||||
background-color: $color-light;
|
||||
font-weight: $bold;
|
||||
padding: 15px 0;
|
||||
margin-bottom: 5px;
|
||||
|
||||
span {
|
||||
color: $color-orange;
|
||||
}
|
||||
}
|
||||
|
||||
.div-action {
|
||||
width: 100%;
|
||||
background-color: $color-grey-nav;
|
||||
border: none;
|
||||
color: $color-greytext;
|
||||
font-family: $font-secondary;
|
||||
font-size: $s;
|
||||
padding: 20px 0;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.tag_container {
|
||||
margin: 5px 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;
|
||||
}
|
||||
}
|
||||
|
||||
.c-tabs {
|
||||
margin-bottom: 40px;
|
||||
|
||||
.b-tabs {
|
||||
font-weight: $bold;
|
||||
color: $color-navy;
|
||||
font-size: $m;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-weight: 700;
|
||||
color: $color-navy;
|
||||
font-size: $l;
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 8px;
|
||||
font-family: Noto Sans, sans-serif;
|
||||
font-size: $s;
|
||||
color: $color-greytext;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,192 @@
|
||||
<template>
|
||||
<div>
|
||||
pagina cooperativas c/index
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-10 coopcard-list">
|
||||
<h1 class="title">Últimas cooperativas añadidas</h1>
|
||||
<p class="help">
|
||||
Si quieres que tu cooperativa forme parte de este gran proyecto
|
||||
registrate en el siguiente
|
||||
<NuxtLink to="/registro/cooperativa"><b>formulario</b></NuxtLink
|
||||
>.
|
||||
</p>
|
||||
<div class="form-container">
|
||||
<form class="search-container" @submit.prevent="search">
|
||||
<input
|
||||
v-model="searchText"
|
||||
class="search-text"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Buscar cooperativas"
|
||||
/>
|
||||
<img
|
||||
class="search-icon"
|
||||
src="@/assets/img/latienda-search-blue.svg"
|
||||
alt="latienda.coop-search"
|
||||
@click="search"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<div v-for="coop in companyList" :key="coop.id">
|
||||
<CoopCard :coop="coop" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <BPagination
|
||||
v-model="currentPage"
|
||||
class="pagination"
|
||||
:total-rows="rows"
|
||||
:per-page="perPage"
|
||||
/> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
|
||||
}
|
||||
export default {
|
||||
setup(){
|
||||
definePageMeta({
|
||||
layout: 'mainbanner',
|
||||
})
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
currentPage: 1,
|
||||
perPage: 10,
|
||||
searchText: '',
|
||||
companyList: null
|
||||
}
|
||||
},
|
||||
// computed: {
|
||||
// rows() {
|
||||
// return this.companyList?.length
|
||||
// },
|
||||
// slicedCompanies() {
|
||||
// const initial = (this.currentPage - 1) * this.perPage
|
||||
// const final = this.currentPage * this.perPage
|
||||
// const items = this.companyList ? this.companyList.slice(initial, final) : []
|
||||
// return items
|
||||
// },
|
||||
// },
|
||||
mounted() {
|
||||
this.getCompanies()
|
||||
},
|
||||
methods: {
|
||||
async getCompanies() {
|
||||
const config = useRuntimeConfig()
|
||||
try {
|
||||
const response = await $fetch(`/companies/sample/`, {
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'GET'
|
||||
})
|
||||
this.companyList = response
|
||||
} catch (error) {
|
||||
console.error('Error fetching companies:', error)
|
||||
}
|
||||
},
|
||||
async search() {
|
||||
const config = useRuntimeConfig()
|
||||
if (this.searchText) {
|
||||
const response = await $fetch(`/search/companies/?search=${this.searchText}`, {
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'GET'
|
||||
})
|
||||
this.companyList = response
|
||||
} else {
|
||||
const response = await $fetch(`/companies/sample/`, {
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'GET'
|
||||
})
|
||||
this.companyList = response
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
margin-top: 80px;
|
||||
margin-bottom: 80px;
|
||||
}
|
||||
|
||||
</style>
|
||||
.title {
|
||||
margin-top: 60px;
|
||||
margin-bottom: 40px;
|
||||
font-size: $xxl;
|
||||
color: $color-navy;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.coopcard-list {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.help {
|
||||
color: $color-navy;
|
||||
margin-bottom: 0px;
|
||||
font-size: $s;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: $color-light-green;
|
||||
width: 50%;
|
||||
height: 32px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 50px;
|
||||
margin-bottom: 50px;
|
||||
|
||||
@include mobile {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.search-container img,
|
||||
input,
|
||||
select {
|
||||
padding: 6px 10px;
|
||||
margin-right: 16px;
|
||||
background: transparent;
|
||||
font-size: $xl;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
float: right;
|
||||
height: 90%;
|
||||
}
|
||||
|
||||
.search-text {
|
||||
outline: none;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: $color-navy;
|
||||
font-size: $s;
|
||||
font-weight: $regular;
|
||||
}
|
||||
|
||||
::placeholder {
|
||||
color: $color-navy;
|
||||
font-size: $s;
|
||||
}
|
||||
|
||||
select {
|
||||
--webkit-appearance: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
59
pages/editar/cooperativa/index.vue
Normal file
59
pages/editar/cooperativa/index.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<BContainer class="container">
|
||||
<BRow align-h="center">
|
||||
<BCol class="edit-coop">
|
||||
<h1 class="title">Editar Cooperativa</h1>
|
||||
<div class="form-container">
|
||||
<CompanyForm />
|
||||
</div>
|
||||
</BCol>
|
||||
</BRow>
|
||||
</BContainer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
setup (){
|
||||
definePageMeta({
|
||||
layout: 'editar',
|
||||
middleware: 'auth',
|
||||
auth: { authority: 'COOP_MANAGER' }
|
||||
})
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
margin-top: 40px;
|
||||
margin-bottom: 80px;
|
||||
}
|
||||
.edit-coop {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.title {
|
||||
color: $color-navy;
|
||||
font-size: $xxl;
|
||||
margin-bottom: 50px;
|
||||
text-align: left;
|
||||
@include desktop {
|
||||
width: 40%;
|
||||
}
|
||||
@include tablet {
|
||||
width: 60%;
|
||||
}
|
||||
@include mobile {
|
||||
width: 90%;
|
||||
margin-top: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -9,18 +9,21 @@
|
||||
type="text"
|
||||
label-text="Contraseña actual"
|
||||
required
|
||||
@input="form.old_password = $event"
|
||||
/>
|
||||
<FormInput
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
label-text="Nueva contraseña"
|
||||
required
|
||||
@input="form.password = $event"
|
||||
/>
|
||||
<FormInput
|
||||
v-model="form.password2"
|
||||
type="password"
|
||||
label-text="Nueva contraseña"
|
||||
required
|
||||
@input="form.password2 = $event"
|
||||
/>
|
||||
<small v-if="error" class="error">{{ error }}</small>
|
||||
<small v-if="success" class="success">{{ success }}</small>
|
||||
@@ -34,6 +37,8 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState } from 'pinia'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
export default {
|
||||
setup() {
|
||||
definePageMeta({
|
||||
@@ -53,18 +58,29 @@ export default {
|
||||
success: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState(useAuthStore, ['id', 'access']),
|
||||
},
|
||||
methods: {
|
||||
async submitUser() {
|
||||
this.error = null
|
||||
this.success = null
|
||||
if (this.form.password === this.form.password2) {
|
||||
try {
|
||||
await this.$api.put(
|
||||
`/user/change_password/${this.$store.state.auth.id}/`,
|
||||
this.form
|
||||
)
|
||||
const config = useRuntimeConfig()
|
||||
const userId = this.id
|
||||
const access = this.access
|
||||
await $fetch(`/user/change_password/${userId}/`, {
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'PUT',
|
||||
body: this.form,
|
||||
headers: {
|
||||
Authorization: `Bearer ${access}`
|
||||
}
|
||||
})
|
||||
this.success = 'Contraseña cambiada correctamente'
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error('Error cambiando la contraseña:', error)
|
||||
this.error = 'La contraseña actual no es correcta'
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -6,12 +6,14 @@
|
||||
<h1 class="title">Editar perfil</h1>
|
||||
<FormInput
|
||||
v-model="form.full_name"
|
||||
:value="form.full_name ? form.full_name : ''"
|
||||
type="text"
|
||||
label-text="Nombre de usuario"
|
||||
@input="form.full_name = $event"
|
||||
/>
|
||||
<FormInput
|
||||
v-model="form.email"
|
||||
:value="form.email ? form.email : ''"
|
||||
type="text"
|
||||
label-text="Email"
|
||||
@input="form.email = $event"
|
||||
@@ -55,31 +57,32 @@ export default {
|
||||
success: false,
|
||||
}
|
||||
},
|
||||
async fetch() {
|
||||
computed: {
|
||||
...mapState(useAuthStore, ['id', 'access']),
|
||||
},
|
||||
async created() {
|
||||
const config = useRuntimeConfig()
|
||||
try {
|
||||
const response = await $fetch(`/my_user/`, {
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'GET',
|
||||
body: JSON.stringify(this.form),
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.access}`
|
||||
}
|
||||
})
|
||||
console.log('User data fetched successfully:', response)
|
||||
//console.log('User data fetched successfully:', response)
|
||||
this.form.full_name = response.full_name
|
||||
this.form.email = response.email
|
||||
this.form.notify = response.notify
|
||||
} catch (err) {
|
||||
console.error('Error fetching user data:', err)
|
||||
this.error = true
|
||||
return
|
||||
}
|
||||
this.form.full_name = response.full_name
|
||||
this.form.email = response.email
|
||||
this.form.notify = response.notify
|
||||
},
|
||||
computed: {
|
||||
...mapState(useAuthStore, ['id', 'access']),
|
||||
},
|
||||
methods: {
|
||||
...mapActions( useAuthStore, ['setUser']),
|
||||
async submitUser() {
|
||||
// TODO: configurar primero el estar logeado (estados, getters) para que peuda coger esa info y actualizarla
|
||||
this.error = false
|
||||
const config = useRuntimeConfig()
|
||||
try {
|
||||
|
||||
169
pages/editar/productos/[id].vue
Normal file
169
pages/editar/productos/[id].vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<BContainer class="container">
|
||||
<BModal
|
||||
id="modal-center"
|
||||
v-model="activeModal"
|
||||
centered
|
||||
title="latienda.coop"
|
||||
:ok-variant="modalColor"> {{ modalText }}
|
||||
</BModal>
|
||||
<BRow align-h="center">
|
||||
<BCol class="edit-product">
|
||||
<h1 class="title">Editar producto</h1>
|
||||
<ProductForm :product-form="form" @send="submitProduct" />
|
||||
<div v-if="form.source !== 'MANUAL'" class="data">
|
||||
<dl class="creation-data">
|
||||
<dt>Fecha de creación:</dt>
|
||||
<dd>{{ formatDatetime(form.created) }}</dd>
|
||||
|
||||
<dt>Fecha de actualización:</dt>
|
||||
<dd>{{ formatDatetime(form.updated) }}</dd>
|
||||
</dl>
|
||||
<dl class="import-data">
|
||||
<dt>Fecha de importación:</dt>
|
||||
<dd>{{ formatDatetime(form.sync_date) }}</dd>
|
||||
|
||||
<dt>Importado desde:</dt>
|
||||
<dd>{{ form.source }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</BCol>
|
||||
</BRow>
|
||||
</BContainer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import dataProcessing from '~/utils/dataProcessing'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
export default {
|
||||
setup() {
|
||||
definePageMeta({
|
||||
layout: 'editar',
|
||||
middleware: 'auth',
|
||||
auth: { authority: 'COOP_MANAGER' },
|
||||
})
|
||||
const auth = useAuthStore();
|
||||
return {
|
||||
auth
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
form: {},
|
||||
activeModal: false,
|
||||
modalText: '',
|
||||
modalColor: '',
|
||||
}
|
||||
},
|
||||
|
||||
async created() {
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
const route = useRoute()
|
||||
this.form = await $fetch(`my_products/${route.params.id}/`, {
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.auth.access}`,
|
||||
},
|
||||
})
|
||||
if (this.form.source !== 'MANUAL' && this.form.history) {
|
||||
try {
|
||||
//TODO: Review Fetching the sync date from the history endpoint
|
||||
const result = await $fetch(`history/${this.form.history}/`, {
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.auth.access}`,
|
||||
},
|
||||
})
|
||||
form.sync_date = result.data.sync_date
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
form.sync_date = null
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
formatDatetime: dataProcessing.formatDatetime,
|
||||
|
||||
async submitProduct(value) {
|
||||
console.log('Submitting product:', value)
|
||||
const config = useRuntimeConfig()
|
||||
const route = useRoute()
|
||||
//TODO: review PUT method, its sending 200 status but not updating the product
|
||||
try {
|
||||
await $fetch(`/my_products/${route.params.id}/`, {
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'PUT',
|
||||
body: value,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.auth.access}`,
|
||||
},
|
||||
})
|
||||
this.modalText = 'Producto actualizado correctamente'
|
||||
this.modalColor = 'success'
|
||||
this.activeModal = true
|
||||
} catch (error) {
|
||||
this.modalText = 'Ha habido un error'
|
||||
this.modalColor = 'danger'
|
||||
this.activeModal = true
|
||||
console.error('Error updating product:', error)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
margin-top: 40px;
|
||||
margin-bottom: 80px;
|
||||
}
|
||||
.edit-product {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.title {
|
||||
color: $color-navy;
|
||||
font-size: $xxl;
|
||||
margin-bottom: 60px;
|
||||
width: 60%;
|
||||
}
|
||||
.help-text {
|
||||
border: 1px solid $color-greylayout;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
text-align: justify;
|
||||
hyphens: auto;
|
||||
font-size: $xs;
|
||||
font-weight: $regular;
|
||||
color: $color-greylayout;
|
||||
font-family: $font-secondary;
|
||||
background-color: $color-light;
|
||||
|
||||
a {
|
||||
text-decoration: underline;
|
||||
color: $color-greytext;
|
||||
}
|
||||
}
|
||||
|
||||
.data {
|
||||
margin: 0 100px;
|
||||
margin-top: 80px;
|
||||
font-size: $xs;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: $color-greytext;
|
||||
}
|
||||
.creation-data,
|
||||
.import-data {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
118
pages/editar/productos/crear.vue
Normal file
118
pages/editar/productos/crear.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<BContainer class="container">
|
||||
<BModal
|
||||
id="modal-center"
|
||||
v-model="activeModal"
|
||||
centered
|
||||
title="latienda.coop"
|
||||
ok-variant="danger"> {{ modalText }}
|
||||
</BModal>
|
||||
<BRow align-h="center">
|
||||
<BCol class="add-product">
|
||||
<h1 class="title">Añadir producto</h1>
|
||||
<ProductForm @send="submitProduct" />
|
||||
</BCol>
|
||||
</BRow>
|
||||
</BContainer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
export default {
|
||||
setup() {
|
||||
definePageMeta({
|
||||
layout: 'editar',
|
||||
middleware: 'auth',
|
||||
auth: { authority: 'COOP_MANAGER' },
|
||||
})
|
||||
const auth = useAuthStore();
|
||||
return {
|
||||
auth
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeModal: false,
|
||||
modalText: '',
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async submitProduct(value) {
|
||||
console.log('Creating product:', value)
|
||||
const config = useRuntimeConfig()
|
||||
try {
|
||||
await $fetch(`/products/`, {
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'POST',
|
||||
body: value,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.auth.access}`,
|
||||
},
|
||||
})
|
||||
this.$router.push({
|
||||
name: 'editar-productos',
|
||||
params: { action: 'created' },
|
||||
})
|
||||
} catch (error) {
|
||||
this.modalText = 'Ha habido un error'
|
||||
this.activeModal = true
|
||||
console.error('Error updating product:', error)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
margin-top: 40px;
|
||||
margin-bottom: 80px;
|
||||
}
|
||||
.add-product {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.title {
|
||||
color: $color-navy;
|
||||
font-size: $xxl;
|
||||
margin-bottom: 60px;
|
||||
text-align: left;
|
||||
@include desktop {
|
||||
width: 40%;
|
||||
}
|
||||
@include tablet {
|
||||
width: 60%;
|
||||
}
|
||||
@include mobile {
|
||||
width: 90%;
|
||||
margin-top: 70px;
|
||||
}
|
||||
}
|
||||
.help-text {
|
||||
border: 1px solid $color-greylayout;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
text-align: justify;
|
||||
hyphens: auto;
|
||||
font-size: $xs;
|
||||
font-weight: $regular;
|
||||
color: $color-greylayout;
|
||||
font-family: $font-secondary;
|
||||
background-color: $color-light;
|
||||
|
||||
a {
|
||||
text-decoration: underline;
|
||||
color: $color-greytext;
|
||||
}
|
||||
}
|
||||
|
||||
.data {
|
||||
margin: 0 100px;
|
||||
margin-top: 80px;
|
||||
font-size: $xs;
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
color: $color-greytext;
|
||||
}
|
||||
</style>
|
||||
408
pages/editar/productos/importar.vue
Normal file
408
pages/editar/productos/importar.vue
Normal file
@@ -0,0 +1,408 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<BModal
|
||||
id="modal-center"
|
||||
v-model="activeModal"
|
||||
centered
|
||||
title="latienda.coop"
|
||||
:ok-variant="modalColor"> {{ modalText }}
|
||||
</BModal>
|
||||
<BContainer class="container">
|
||||
<BRow align-h="start">
|
||||
<div class="import-products">
|
||||
<div
|
||||
class="header"
|
||||
@click="importOpen = !importOpen"
|
||||
>
|
||||
<h4 class="title">
|
||||
Importar productos desde CSV
|
||||
<img
|
||||
src="@/assets/img/latienda-arrow-down.svg"
|
||||
alt=""
|
||||
class="arrow"
|
||||
:class="{
|
||||
'close': !importOpen,
|
||||
'open': importOpen }"
|
||||
/>
|
||||
</h4>
|
||||
</div>
|
||||
<BCollapse id="collapse-import" v-model="importOpen">
|
||||
<div class="description">
|
||||
<p>
|
||||
La función <strong>Importar CSV</strong> te permite subir tus
|
||||
productos a Latienda.coop sin tener que hacerlo manualmente.
|
||||
Puedes ponerles nombres a las columnas en tu hoja de cálculo con
|
||||
las siguientes etiquetas para que los campos se asocien
|
||||
automáticamente en tus productos:
|
||||
</p>
|
||||
<ol>
|
||||
<li>
|
||||
<strong>SKU: </strong>usa esta columna para proporcionar un
|
||||
identificador único a tu producto.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Nombre de producto: </strong>asigna un nombre. Es el
|
||||
texto que se ve primero al listar el producto.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Descripción: </strong>proporciona los detalles del
|
||||
producto.
|
||||
</li>
|
||||
<li><strong>Imagen: </strong>url de la imagen del producto.</li>
|
||||
<li>
|
||||
<strong>URL: </strong>enlace a la página del producto si
|
||||
existe.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Precio: </strong>precio en euros con IVA incluido. Si
|
||||
se deja en blanco aparecerá el texto "Consultar precio".
|
||||
</li>
|
||||
<li>
|
||||
<strong>Gastos de envío: </strong>importe adicional por los
|
||||
gastos de envío. Si se deja en blanco, o nulo, aparecerá el
|
||||
texto "Sin gastos de envío".
|
||||
</li>
|
||||
<li>
|
||||
<strong>Condiciones de envío: </strong>aquí se puede indicar
|
||||
las condiciones de envío específicas para cada producto. Si se
|
||||
deja en blanco se mostrarán las opciones por defecto de tu
|
||||
cooperativa.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Descuento: </strong>porcentaje de descuento sobre el
|
||||
precio del producto.
|
||||
</li>
|
||||
<li><strong>Stock: </strong>número de unidades disponibles.</li>
|
||||
<li>
|
||||
<strong>Tags: </strong>etiquetas libres que describen al
|
||||
producto. Usa el carácter "/" para separar cada tag.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Categoría: </strong>clasificación principal del
|
||||
producto. el Latienda.coop usamos la taxonomía de Google.
|
||||
Puedes ver todas las categorías disponibles
|
||||
<a
|
||||
href="https://www.google.com/basepages/producttype/taxonomy-with-ids.es-ES.txt"
|
||||
target="_blank"
|
||||
>aquí</a
|
||||
>. Elige la categoría o subcategoría de cualquier nivel que
|
||||
mejor defina a tu producto.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Identificadores: </strong>identificador único
|
||||
opcional.
|
||||
</li>
|
||||
</ol>
|
||||
<br />
|
||||
<p>
|
||||
Descarga la
|
||||
<a href="/plantilla-latienda.csv" download="plantilla.csv" type="text/csv">plantilla</a>
|
||||
de referencia.
|
||||
</p>
|
||||
<form @submit.prevent="submitFile">
|
||||
<div>
|
||||
<label for="file"
|
||||
>Archivo .csv
|
||||
<input
|
||||
id="file"
|
||||
type="file"
|
||||
accept=".csv, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel"
|
||||
placeholder="Elige un archivo"
|
||||
required
|
||||
@change="handleFile"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<SubmitButton text="Importar" image-url="" />
|
||||
</form>
|
||||
</div>
|
||||
</BCollapse>
|
||||
</div>
|
||||
</BRow>
|
||||
</BContainer>
|
||||
|
||||
<BContainer class="container">
|
||||
<BRow align-h="start">
|
||||
<div class="import-products">
|
||||
<div
|
||||
class="header"
|
||||
@click="syncOpen = !syncOpen"
|
||||
>
|
||||
<h4 class="title">
|
||||
Sincronización con tu tienda online
|
||||
<img
|
||||
src="@/assets/img/latienda-arrow-down.svg"
|
||||
alt=""
|
||||
class="arrow"
|
||||
:class="{ 'close': !syncOpen, 'open': syncOpen }"
|
||||
/>
|
||||
</h4>
|
||||
</div>
|
||||
<BCollapse id="collapse-sync" v-model="syncOpen">
|
||||
<div v-if="platform" class="description">
|
||||
<div class="platform">
|
||||
<BFormInput v-model="platform" type="text" disabled />
|
||||
</div>
|
||||
<br />
|
||||
<div class="credentials">
|
||||
<BFormTextarea
|
||||
v-model="credentials"
|
||||
type="text"
|
||||
rows="4"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
<p>
|
||||
Si los datos no son correctos puedes editarlos en tu
|
||||
<NuxtLink to="/editar/cooperativa">cooperativa</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="description">
|
||||
<p>
|
||||
No tienes configurada tu tienda online. <br /><br />
|
||||
<NuxtLink to="/editar/cooperativa">Edita</NuxtLink> la
|
||||
información de tu cooperativa y añade la información necesaría
|
||||
para la sincronización con Woocommerce
|
||||
</p>
|
||||
</div>
|
||||
<form @submit.prevent="syncWebsite">
|
||||
<SubmitButton
|
||||
text="Lanzar sincronización"
|
||||
image-url=""
|
||||
/>
|
||||
</form>
|
||||
</BCollapse>
|
||||
</div>
|
||||
</BRow>
|
||||
</BContainer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
export default {
|
||||
setup() {
|
||||
definePageMeta({
|
||||
layout: 'editar',
|
||||
middleware: 'auth',
|
||||
auth: { authority: 'COOP_MANAGER' },
|
||||
})
|
||||
const auth = useAuthStore();
|
||||
return {
|
||||
auth
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
file: null,
|
||||
importOpen: false,
|
||||
syncOpen: false,
|
||||
platform: null,
|
||||
credentials: null,
|
||||
syncForm: null,
|
||||
activeModal: false,
|
||||
modalText: '',
|
||||
modalColor: '',
|
||||
}
|
||||
},
|
||||
|
||||
async mounted() {
|
||||
try{
|
||||
const config = useRuntimeConfig()
|
||||
const data = await $fetch('my_company/', {
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.auth.access}`
|
||||
}
|
||||
})
|
||||
if (data.company.platform === 'WOO_COMMERCE')
|
||||
this.platform = 'Woocommerce'
|
||||
this.credentials = JSON.stringify(data.company.credentials, undefined, 2)
|
||||
this.syncForm = {
|
||||
key: data.company.credentials['key'],
|
||||
secret: data.company.credentials['secret'],
|
||||
url: data.company.shop_link
|
||||
}
|
||||
// const historyResults = await $fetch(`/history/`, {
|
||||
// baseURL: config.public.baseURL,
|
||||
// method: 'GET',
|
||||
// headers: {
|
||||
// Authorization: `Bearer ${this.auth.access}`
|
||||
// }
|
||||
// })
|
||||
// console.log(historyResults.data)
|
||||
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async handleFile(e) {
|
||||
const selectedFile = await e.target.files[0]
|
||||
this.file = selectedFile
|
||||
},
|
||||
|
||||
async submitFile() {
|
||||
const config = useRuntimeConfig()
|
||||
//TODO: Review functionality
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('csv_file', this.file)
|
||||
console.log('Form data to submit:', formData)
|
||||
await $fetch('/load_products/',{
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'POST',
|
||||
body: this.file,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.auth.access}`,
|
||||
},
|
||||
})
|
||||
this.modalText = 'Productos importados correctamente'
|
||||
this.modalColor = 'success'
|
||||
this.activeModal = true
|
||||
} catch {
|
||||
this.modalText = 'Ha habido un error'
|
||||
this.modalColor = 'danger'
|
||||
this.activeModal = true
|
||||
}
|
||||
// Clear the file input
|
||||
this.file = null
|
||||
},
|
||||
|
||||
async syncWebsite() {
|
||||
const config = useRuntimeConfig()
|
||||
//TODO: Error 500, its seems to be backend issue
|
||||
try {
|
||||
console.log('Sync form data:', this.syncForm)
|
||||
await $fetch('/sync_shop/',{
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'POST',
|
||||
body: {
|
||||
url: this.syncForm.url,
|
||||
key: this.syncForm.key,
|
||||
secret: this.syncForm.secret
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.auth.access}`,
|
||||
},
|
||||
})
|
||||
this.modalText = 'Sincronización iniciada'
|
||||
this.modalColor = 'success'
|
||||
this.activeModal = true
|
||||
} catch {
|
||||
this.modalText = 'Ha habido un error'
|
||||
this.modalColor = 'danger'
|
||||
this.activeModal = true
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
margin-top: 40px;
|
||||
margin-bottom: 80px;
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
.title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.arrow {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.close {
|
||||
transform: rotate(-90deg);
|
||||
opacity: 1;
|
||||
transition: all 0.5s;
|
||||
}
|
||||
.open {
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
.import-products {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
}
|
||||
.title {
|
||||
color: $color-navy;
|
||||
@include mobile {
|
||||
margin-top: 70px;
|
||||
}
|
||||
}
|
||||
.subtitle {
|
||||
color: $color-navy;
|
||||
font-size: $l;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type: none;
|
||||
counter-reset: a;
|
||||
}
|
||||
ol > li {
|
||||
counter-increment: a;
|
||||
position: relative;
|
||||
list-style: none;
|
||||
margin-top: 18px;
|
||||
margin-left: 35px;
|
||||
}
|
||||
ol > li:before {
|
||||
color: #fff;
|
||||
background: $color-navy;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
top: 2px;
|
||||
left: -35px;
|
||||
position: absolute;
|
||||
line-height: 20px;
|
||||
font-size: 12px;
|
||||
content: counter(a);
|
||||
text-align: center;
|
||||
font-weight: 400;
|
||||
-webkit-text-stroke: 0.04em;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: $color-error;
|
||||
}
|
||||
|
||||
label {
|
||||
text-align: left;
|
||||
color: $color-navy;
|
||||
font-weight: $bold;
|
||||
font-size: $xs;
|
||||
}
|
||||
|
||||
.cont-col {
|
||||
margin: 15px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@include mobile {
|
||||
margin: 15px 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
font-size: $s;
|
||||
}
|
||||
</style>
|
||||
277
pages/editar/productos/index.vue
Normal file
277
pages/editar/productos/index.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<BContainer class="container">
|
||||
<BRow>
|
||||
<BCol>
|
||||
<h1 class="title">
|
||||
Productos
|
||||
<button
|
||||
class="ml-4 mb-1 btn btn-outline-primary btn-sm"
|
||||
@click="redirectToNewProduct"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</h1>
|
||||
</BCol>
|
||||
</BRow>
|
||||
<BRow>
|
||||
<BCol class="d-flex flex-row-reverse">
|
||||
<button class="btn btn-secondary" :disabled="selectedItemsIndexes.length === 0" @click="desactivateProducts">
|
||||
Desactivar
|
||||
</button>
|
||||
<button class="btn btn-primary mr-3" :disabled="selectedItemsIndexes.length === 0" @click="activateProducts">
|
||||
Activar
|
||||
</button>
|
||||
<div v-show="selectedItemsIndexes.length !== 0" class="selected-products mr-3">
|
||||
{{ selectedItemsIndexes.length }} productos seleccionados
|
||||
</div>
|
||||
</BCol>
|
||||
</BRow>
|
||||
<BRow>
|
||||
<template v-if="products">
|
||||
<v-data-table
|
||||
v-model="selectedItemsIndexes"
|
||||
show-select
|
||||
:single-select="singleSelect"
|
||||
:headers="headers"
|
||||
:items="products"
|
||||
:search="search"
|
||||
:loading="loading"
|
||||
loading-text="Cargando productos..."
|
||||
>
|
||||
<template #top>
|
||||
<v-toolbar flat color="white">
|
||||
<!-- Search -->
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
append-icon="mdi-magnify"
|
||||
label="Buscar producto"
|
||||
single-line
|
||||
hide-details
|
||||
/>
|
||||
<!-- . Search -->
|
||||
</v-toolbar>
|
||||
</template>
|
||||
<template #[`item.active`]="item">
|
||||
<v-icon v-if="item.item.active" small class="mr-2">
|
||||
mdi-check
|
||||
</v-icon>
|
||||
<v-icon v-else small class="mr-2"> mdi-close </v-icon>
|
||||
</template>
|
||||
<template #[`item.actions`]="item">
|
||||
<NuxtLink :to="`/editar/productos/${item.item.id}`">
|
||||
<v-icon small class="mr-2"> mdi-pencil </v-icon>
|
||||
</NuxtLink>
|
||||
<v-icon small class="mr-2" @click="deleteItem(item)">
|
||||
mdi-delete
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</template>
|
||||
</BRow>
|
||||
<BModal
|
||||
id="modal-center"
|
||||
v-model="activeModal"
|
||||
centered
|
||||
title="latienda.coop"
|
||||
:ok-variant="modalColor"> {{ modalText }}
|
||||
</BModal>
|
||||
</BContainer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
setup() {
|
||||
definePageMeta({
|
||||
layout: 'editar',
|
||||
middleware: 'auth',
|
||||
auth: { authority: 'COOP_MANAGER' },
|
||||
})
|
||||
const auth = useAuthStore();
|
||||
return {
|
||||
auth
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
products: [],
|
||||
totalProducts: 0,
|
||||
loading: true,
|
||||
singleSelect: false,
|
||||
selectedItemsIndexes: [],
|
||||
search: '',
|
||||
options: {},
|
||||
headers: [
|
||||
{ title: 'ID', value: 'id' },
|
||||
{ title: 'Nombre', value: 'name' },
|
||||
{ title: 'Categoría', value: 'category' },
|
||||
{ title: 'Activo', value: 'active' },
|
||||
{ title: 'Precio', value: 'price' },
|
||||
{ title: 'Fuente', value: 'source' },
|
||||
{ title: 'Stock', value: 'stock' },
|
||||
{ title: 'Acción', value: 'actions' },
|
||||
],
|
||||
activeModal: false,
|
||||
modalText: '',
|
||||
modalColor: '',
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
selectedItems() {
|
||||
const itemsArr = []
|
||||
this.selectedItemsIndexes.forEach(index => {
|
||||
this.products.forEach(item => {
|
||||
if (item.id === index) {
|
||||
itemsArr.push(item)
|
||||
}
|
||||
})
|
||||
})
|
||||
return itemsArr
|
||||
}
|
||||
},
|
||||
|
||||
async created() {
|
||||
if (this.$route.params.action === 'created') {
|
||||
this.modalText = 'Producto creado correctamente'
|
||||
this.modalColor = 'success'
|
||||
this.activeModal = true
|
||||
}
|
||||
await this.getDataFromApi()
|
||||
},
|
||||
|
||||
methods: {
|
||||
async getDataFromApi() {
|
||||
this.loading = true
|
||||
const config = useRuntimeConfig()
|
||||
const data = await $fetch(`/my_products/`, {
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.auth.access}`,
|
||||
},
|
||||
})
|
||||
this.products = data
|
||||
this.loading = false
|
||||
},
|
||||
|
||||
async deleteItem(item) {
|
||||
if (confirm('Confirma que quieres eliminar este producto')) {
|
||||
const config = useRuntimeConfig()
|
||||
try {
|
||||
await $fetch(`/my_products/${item.item.id}`, {
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.auth.access}`,
|
||||
},
|
||||
})
|
||||
const index = this.products.indexOf(item.item)
|
||||
this.products.splice(index, 1)
|
||||
this.selectedItemsIndexes = []
|
||||
this.modalText = 'Los productos han sido eliminados correctamente.'
|
||||
this.modalColor = 'success'
|
||||
this.activeModal = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
this.modalText = 'Ha habido un error'
|
||||
this.modalColor = 'danger'
|
||||
this.activeModal = true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async activateProducts() {
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
await this.selectedItems.forEach(async (item) => {
|
||||
await $fetch(`/my_products/${item.id}/`, {
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
active: true
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.auth.access}`
|
||||
}
|
||||
})
|
||||
const index = this.products.indexOf(item)
|
||||
this.products[index].active = true
|
||||
})
|
||||
this.selectedItemsIndexes = []
|
||||
this.modalText = 'Los productos han sido activados correctamente.'
|
||||
this.modalColor = 'success'
|
||||
this.activeModal = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
this.modalText = 'Ha habido un error'
|
||||
this.modalColor = 'danger'
|
||||
this.activeModal = true
|
||||
}
|
||||
},
|
||||
async desactivateProducts() {
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
await this.selectedItems.forEach(async (item) => {
|
||||
await $fetch(`/my_products/${item.id}/`, {
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
active: false
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.auth.access}`
|
||||
}
|
||||
})
|
||||
const index = this.products.indexOf(item)
|
||||
this.products[index].active = false
|
||||
})
|
||||
this.selectedItemsIndexes = []
|
||||
this.modalText = 'Los productos han sido desactivados correctamente.'
|
||||
this.modalColor = 'success'
|
||||
this.activeModal = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
this.modalText = 'Ha habido un error'
|
||||
this.modalColor = 'danger'
|
||||
this.activeModal = true
|
||||
}
|
||||
},
|
||||
|
||||
redirectToNewProduct() {
|
||||
this.$router.push({
|
||||
name: 'editar-productos-crear',
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.container {
|
||||
margin-top: 40px;
|
||||
margin-bottom: 80px;
|
||||
}
|
||||
.title {
|
||||
color: $color-navy;
|
||||
font-size: $xxl;
|
||||
margin-bottom: 60px;
|
||||
@include mobile {
|
||||
margin-top: 70px;
|
||||
}
|
||||
}
|
||||
v-toolbar {
|
||||
padding: 0;
|
||||
}
|
||||
.selected-products {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -79,8 +79,9 @@ export default {
|
||||
},
|
||||
async mounted() {
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
const response = await $fetch('/initial', {
|
||||
baseURL: this.$config.public.baseURL,
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'GET'
|
||||
})
|
||||
this.cards = response.cards
|
||||
|
||||
@@ -1,15 +1,92 @@
|
||||
<template>
|
||||
<div>
|
||||
Producto 1
|
||||
<div class="container">
|
||||
<h1 class="title">Detalles del producto</h1>
|
||||
<ProductCardDetails
|
||||
:product="product"
|
||||
:related="related"
|
||||
:related-products="relatedProducts"
|
||||
:company="company"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
|
||||
}
|
||||
import { useRoute } from 'vue-router'
|
||||
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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
margin-top: 40px;
|
||||
margin-bottom: 80px;
|
||||
@include mobile {
|
||||
margin-top: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
.title {
|
||||
margin-bottom: 40px;
|
||||
font-size: $xl;
|
||||
color: $color-navy;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<script>
|
||||
export default {
|
||||
layout: 'admin',
|
||||
}
|
||||
|
||||
// Esto debe estar fuera del export default
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
auth: {
|
||||
authority: 'SITE_ADMIN',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>Solo para admins</h1>
|
||||
</template>
|
||||
@@ -1,48 +0,0 @@
|
||||
// plugins/api.ts
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
const auth = useAuthStore()
|
||||
|
||||
// Función personalizada para hacer requests
|
||||
const apiFetch = async (url: string, options: any = {}) => {
|
||||
try {
|
||||
const res = await $fetch(url, {
|
||||
baseURL: useRuntimeConfig().public.apiBase,
|
||||
credentials: 'include', // para enviar cookies si es necesario
|
||||
headers: {
|
||||
...(options.headers || {}),
|
||||
...(auth.access ? { Authorization: `Bearer ${auth.access}` } : {}),
|
||||
},
|
||||
...options,
|
||||
})
|
||||
return res
|
||||
} catch (error: any) {
|
||||
// Si no es 401, relanzamos el error
|
||||
if (error?.status !== 401) throw error
|
||||
|
||||
// Si es el endpoint de refresh, cerramos sesión
|
||||
if (url.includes('refresh')) {
|
||||
auth.logout()
|
||||
return navigateTo('/login')
|
||||
}
|
||||
|
||||
// Usuario inactivo
|
||||
if (error?.data?.code === 'user_inactive') {
|
||||
auth.logout()
|
||||
return navigateTo('/login')
|
||||
}
|
||||
|
||||
// Intentar refresh
|
||||
try {
|
||||
await auth.refresh()
|
||||
return await apiFetch(url, options) // reintentar la petición original
|
||||
} catch {
|
||||
return navigateTo({ name: 'index', query: { redirected: 'true' } })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inyectar como $api
|
||||
nuxtApp.provide('api', apiFetch)
|
||||
})
|
||||
19
plugins/google-analytics.client.ts
Normal file
19
plugins/google-analytics.client.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// TODO: Review if its OK. https://matteo-gabriele.gitbook.io/vue-gtag/migration-v2-to-v3
|
||||
import { configure } from 'vue-gtag'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
const config = useRuntimeConfig()
|
||||
const router = useRouter()
|
||||
|
||||
if (config.public.googleAnalyticsId) {
|
||||
configure({
|
||||
tagId: config.public.googleAnalyticsId,
|
||||
appName: 'latienda.coop',
|
||||
pageTracker: {
|
||||
router,
|
||||
useScreenview: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
11
public/README.md
Normal file
11
public/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# STATIC
|
||||
|
||||
**This directory is not required, you can delete it if you don't want to use it.**
|
||||
|
||||
This directory contains your static files.
|
||||
Each file inside this directory is mapped to `/`.
|
||||
Thus you'd want to delete this README.md before deploying to production.
|
||||
|
||||
Example: `/static/robots.txt` is mapped as `/robots.txt`.
|
||||
|
||||
More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/assets#static).
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 15 KiB |
1
public/plantilla-latienda.csv
Normal file
1
public/plantilla-latienda.csv
Normal file
@@ -0,0 +1 @@
|
||||
sku,nombre-producto,descripcion,imagen,url,precio,gastos-envio,cond-envio,descuento,stock,tags,categoria,identificadores
|
||||
|
@@ -1,16 +1,36 @@
|
||||
// stores/auth.ts
|
||||
import { defineStore } from 'pinia'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
access: null as string | null,
|
||||
refresh: null as string | null,
|
||||
refreshTokens: null as string | null,
|
||||
id: null as number | null,
|
||||
name: null as string | null,
|
||||
email: null as string | null,
|
||||
role: 'ANON' as string,
|
||||
cookiesAreAccepted: false,
|
||||
}),
|
||||
|
||||
persist: true, // TODO: Enable persistence. Cookies will be stored 'auth' 👉🏻 https://prazdevs.github.io/pinia-plugin-persistedstate/frameworks/nuxt
|
||||
// persist: {
|
||||
// key: 'authentication-cookie',
|
||||
// storage: piniaPluginPersistedstate.cookies({
|
||||
// expires: 14,
|
||||
// sameSite: 'strict',
|
||||
// secure: !import.meta.dev,
|
||||
// }),
|
||||
// paths: [
|
||||
// 'id',
|
||||
// 'name',
|
||||
// 'email',
|
||||
// 'role',
|
||||
// 'access',
|
||||
// 'refreshTokens',
|
||||
// 'cookiesAreAccepted',
|
||||
// ],
|
||||
// },
|
||||
|
||||
getters: {
|
||||
isAuthenticated: (state) => !!state.access,
|
||||
isUser: (state) => state.role === 'SHOP_USER',
|
||||
@@ -29,6 +49,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
method: 'POST',
|
||||
body: { email, password }
|
||||
})
|
||||
//console.log('Login payload:', payload)
|
||||
this.setPayload(payload)
|
||||
},
|
||||
|
||||
@@ -41,25 +62,29 @@ export const useAuthStore = defineStore('auth', {
|
||||
Authorization: `Bearer ${this.access}`
|
||||
}
|
||||
})
|
||||
this.setUserData(data)
|
||||
try {
|
||||
this.setUserData(data)
|
||||
} catch (error) {
|
||||
console.error('Error setting user data:', error)
|
||||
}
|
||||
},
|
||||
|
||||
async refresh() {
|
||||
async refreshAccessToken() {
|
||||
const config = useRuntimeConfig()
|
||||
if (!this.refresh) return
|
||||
if (!this.refreshTokens) return
|
||||
const data = await $fetch('/token/refresh/', {
|
||||
baseURL: config.public.baseURL,
|
||||
method: 'POST',
|
||||
body: { refresh: this.refresh }
|
||||
body: { refresh: this.refreshTokens }
|
||||
})
|
||||
this.setPayload(data)
|
||||
},
|
||||
|
||||
// Mutations migration
|
||||
logout() {
|
||||
this.$reset() // Reset the store state
|
||||
async logout() {
|
||||
this.$reset()
|
||||
},
|
||||
|
||||
|
||||
// Mutations migration
|
||||
acceptCookies() {
|
||||
this.cookiesAreAccepted = true
|
||||
},
|
||||
@@ -74,7 +99,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
setPayload(payload: any) {
|
||||
this.access = payload.access
|
||||
if (payload.refresh) {
|
||||
this.refresh = payload.refresh
|
||||
this.refreshTokens = payload.refresh
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user