Merge pull request #1 from enredacoop/migration/latienda-v2-to-v3

migration/latienda-v2-to-v3
This commit is contained in:
Diego Calvo Castillo
2025-08-28 14:26:51 +02:00
committed by GitHub
50 changed files with 6235 additions and 451 deletions

441
components/CompanyForm.vue Normal file
View 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
View 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>

View 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 &amp; 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>

View File

@@ -4,6 +4,7 @@
>{{ labelText + (required ? '*' : '') }}
<input
v-model="inputValue"
:value="value ? value : inputValue"
:required="required"
:type="type"
:step="step"

View 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>

View 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
View 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>

View File

@@ -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')

View File

@@ -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
},
},
}

View File

@@ -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)
}
},
},
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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
View 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>

View 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>

View 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
View 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
View File

@@ -0,0 +1,310 @@
<template>
<transition name="modal">
<div v-if="product" class="mask">
<div class="wrapper" @click.self="handleEmit">
<div class="container">
<div class="element header">
<h2>Interesado en comprar el producto</h2>
<img src="@/assets/img/latienda-lineapuntos-2.svg" alt="" />
</div>
<div class="modal-body">
<form @submit.prevent="sendForm">
<div>
<BFormGroup class="element">
<BFormInput
v-model="form.email"
required
class="input"
size="lg"
placeholder="Email"
/>
</BFormGroup>
</div>
<div>
<BFormGroup class="element">
<BFormInput
v-model="form.telephone"
required
class="input"
size="lg"
placeholder="Teléfono"
/>
</BFormGroup>
</div>
<div v-if="!isAuthenticated" class="element">
<BButton
class="input"
size="lg"
variant="outline-primary w-100"
@click="redirectToLogin"
>Login</BButton
>
</div>
<div class="element coop">
<!-- <BCard
img-src="https://placekitten.com/1000/300"
img-alt="Card image"
img-left
>
<BCardText>
<h3>Cooperativa</h3>
<p>Dirección</p>
</BCardText>
</BCard> -->
<div class="content">
<img :src="getImgUrl(product.company.logo)" alt="" />
<div class="text">
<h3>{{ product?.company?.company_name }}</h3>
<p>{{ product?.company?.address }}</p>
<br />
</div>
</div>
</div>
<div class="element coop">
<!-- <BCard
img-src="https://placekitten.com/1000/300"
img-alt="Card image"
img-left
>
<BCardText>
<h3>Cooperativa</h3>
<p>Dirección</p>
</BCardText>
</BCard> -->
<div class="content">
<img :src="getImgUrl(product.image)" alt="" />
<div class="text">
<h3>{{ product?.name }}</h3>
<p>{{ product?.price }} </p>
<p class="text-muted">
{{ product?.shipping_cost || 'Sin gastos de envío' }}
</p>
</div>
</div>
</div>
<div class="element">
<BFormTextarea
id="textarea-no-resize"
v-model="form.comment"
class="input"
placeholder="Comentarios"
rows="3"
no-resize
/>
</div>
<div>
<BButton type="submit" class="enviar-button">
<v-progress-circular
v-if="loading"
:size="15"
:width="2"
indeterminate
/>
<span v-else>Enviar</span>
</BButton>
</div>
</form>
</div>
</div>
</div>
</div>
</transition>
</template>
<script>
import { useAuthStore } from '@/stores/auth'
export default {
props: {
product: { type: Object, default: () => ({}) },
},
emits: ['closeModal'],
setup() {
const authStore = useAuthStore()
return {
authStore
}
},
data() {
return {
isAuthenticated: true,
form: {
email: undefined,
telephone: undefined,
company: undefined,
product: undefined,
comment: '',
},
loading: false,
}
},
mounted() {
this.isAuthenticated = this.authStore.isAuthenticated
if (this.isAuthenticated) {
const email = this.authStore.email
this.form.email = email
}
},
methods: {
async sendForm() {
this.form.company = this.product.company.id
this.form.product = this.product.id
this.loading = true
let response
let status
const config = useRuntimeConfig()
try {
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>

View File

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

View File

@@ -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
View 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
View 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'

View File

@@ -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;

View File

@@ -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')
}

View File

@@ -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',
@@ -38,6 +39,10 @@ export default defineNuxtConfig({
},
},
plugins: [
'~/plugins/google-analytics.client.ts',
],
sitemap: {
exclude: ['/admin', '/admin/**', '/editar', '/editar/**'],
urls: async () => {

120
package-lock.json generated
View File

@@ -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"
}
}
}

View File

@@ -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"
},

View 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>

View 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
View 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
View 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>

View File

@@ -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;
}
.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>

View File

@@ -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 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>

View File

@@ -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;
}
.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>

View 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>

View File

@@ -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 {

View File

@@ -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 {

View 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>

View 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>

View 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 &nbsp;
<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 &nbsp;
<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>

View 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>

View File

@@ -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

View File

@@ -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;
}
}
.title {
margin-bottom: 40px;
font-size: $xl;
color: $color-navy;
}
</style>

View File

@@ -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>

View File

@@ -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)
})

View 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
View 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

View File

@@ -0,0 +1 @@
sku,nombre-producto,descripcion,imagen,url,precio,gastos-envio,cond-envio,descuento,stock,tags,categoria,identificadores
1 sku nombre-producto descripcion imagen url precio gastos-envio cond-envio descuento stock tags categoria identificadores

View File

@@ -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
}
}
}