15 Commits

Author SHA1 Message Date
mono
523d0b9d08 #45 feat: show 'Resumen del pedido' and hide planilla alert for catalog orders 2026-07-11 19:52:34 -05:00
mono
0806731a1f #45 feat: rename checkout to 'Finalizar Pedido' and add contact info in checkout modal 2026-07-11 19:47:04 -05:00
mono
beb5e153f3 #45 feat: update login prompt message with contact info and icons 2026-07-11 19:39:36 -05:00
mono
3e19b3d272 #45 feat: hide total price when quantity is 0 2026-07-11 19:05:07 -05:00
mono
b21d3a0ca6 #45 fix: cart toggle button not working on mobile 2026-07-11 18:59:46 -05:00
mono
3029e6d4b9 #45 feat: clean catalog view on mobile devices 2026-07-11 18:56:45 -05:00
92483a110a Merge pull request 'fix/catalog-image-overlap' (#44) from fix/catalog-image-overlap into main
Reviewed-on: #44
2026-07-03 19:21:40 -05:00
mono
a89af0f0bb #fix: búsqueda insensible a tildes en el catálogo
Normaliza los strings eliminando diacríticos (NFD) antes de comparar,
para que 'cafe' encuentre 'café' y viceversa.
2026-07-03 19:06:40 -05:00
mono
b2a47c44dc #fix: scroll al inicio del grid al cambiar página en el catálogo
El método scrollToTop usaba scrollIntoView en un elemento sticky,
lo que no generaba desplazamiento. Ahora calcula la posición
absoluta del grid de productos y hace scroll suave.
2026-07-03 18:58:00 -05:00
mono
2c2082266e #fix: corregir solapamiento de imagen en tarjetas del catálogo
- Cambiar height: 100% por aspect-ratio: 1 en .product-image-container
- Eliminar media queries duplicados y obsoletos
- El contenedor ahora se ajusta al tamaño real del v-img
2026-07-03 18:52:33 -05:00
4b006aaa01 #44 feat: restringir acceso a compras para usuarios con rol publico 2026-06-22 12:50:14 -05:00
762fde2cd0 #44 feat: ocultar carrito y deshabilitar controles para usuarios no autenticados en catálogo 2026-06-22 11:46:50 -05:00
bb26f55829 Merge pull request '#40 feat: add catalogue images CRUD and display in product catalog' (#43) from feature/40-catalogue-images into main
Reviewed-on: #43
2026-06-13 18:05:48 -05:00
mono
5397ab2255 #40 fix: upload de imágenes con multipart/form-data 2026-06-13 17:59:14 -05:00
mono
a2ab4fceb7 #40 feat: add catalogue images CRUD and display in product catalog 2026-06-13 16:32:44 -05:00
17 changed files with 782 additions and 262 deletions

View File

@@ -1,2 +1,3 @@
VITE_API_IMPLEMENTATION=django
VITE_DJANGO_BASE_URL=http://localhost:7000
VITE_CONTACT_PHONE=3023567797

View File

@@ -1,2 +1,3 @@
VITE_API_IMPLEMENTATION=django
VITE_DJANGO_BASE_URL=http://localhost:7000
VITE_CONTACT_PHONE=

View File

@@ -3,9 +3,11 @@ FROM node:20-alpine AS builder
ARG VITE_DJANGO_BASE_URL
ARG VITE_API_IMPLEMENTATION
ARG VITE_CONTACT_PHONE
ENV VITE_DJANGO_BASE_URL=$VITE_DJANGO_BASE_URL
ENV VITE_API_IMPLEMENTATION=$VITE_API_IMPLEMENTATION
ENV VITE_CONTACT_PHONE=$VITE_CONTACT_PHONE
WORKDIR /app

View File

@@ -59,6 +59,7 @@ set +a
BUILD_ARGS="--build-arg VITE_DJANGO_BASE_URL=$VITE_DJANGO_BASE_URL"
BUILD_ARGS="$BUILD_ARGS --build-arg VITE_API_IMPLEMENTATION=$VITE_API_IMPLEMENTATION"
BUILD_ARGS="$BUILD_ARGS --build-arg VITE_CONTACT_PHONE=$VITE_CONTACT_PHONE"
FULL_IMAGE_NAME="$GITEA_REGISTRY/$GITEA_USER/$IMAGE_NAME"

View File

@@ -1,39 +1,62 @@
<template>
<v-footer height="40" app>
<a
v-for="item in items"
:key="item.title"
:href="item.href"
:title="item.title"
class="d-inline-block mx-2 social-link"
rel="noopener noreferrer"
target="_blank"
<div>
<!-- Footer completo: visible siempre en desktop, toggle en mobile -->
<v-footer
v-if="expanded || !isMobile"
height="40"
app
class="app-footer"
:class="{ 'footer-overlay': isMobile }"
>
<v-icon
:icon="item.icon"
:size="item.icon === '$vuetify' ? 24 : 16"
/>
</a>
<div
class="text-caption text-disabled"
style="position: absolute; right: 16px;"
>
&copy; 2016-{{ (new Date()).getFullYear() }} <span class="d-none d-sm-inline-block">Vuetify, LLC</span>
—
<a
class="text-decoration-none on-surface"
href="https://vuetifyjs.com/about/licensing/"
v-for="item in items"
:key="item.title"
:href="item.href"
:title="item.title"
class="d-inline-block mx-2 social-link"
rel="noopener noreferrer"
target="_blank"
>
MIT License
<v-icon
:icon="item.icon"
:size="item.icon === '$vuetify' ? 24 : 16"
/>
</a>
</div>
</v-footer>
<div
class="text-caption text-disabled"
style="position: absolute; right: 16px;"
>
&copy; 2016-{{ (new Date()).getFullYear() }} <span class="d-none d-sm-inline-block">Vuetify, LLC</span>
—
<a
class="text-decoration-none on-surface"
href="https://vuetifyjs.com/about/licensing/"
rel="noopener noreferrer"
target="_blank"
>
MIT License
</a>
</div>
</v-footer>
<!-- Botón flotante: solo en mobile -->
<v-btn
v-if="isMobile"
icon
size="small"
class="footer-toggle-btn"
:color="expanded ? 'primary' : undefined"
@click="expanded = !expanded"
>
<v-icon :icon="expanded ? 'mdi-close' : '$vuetify'" :size="expanded ? 20 : 24" />
</v-btn>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
const items = [
{
title: 'Vuetify Documentation',
@@ -65,15 +88,48 @@
icon: `mdi-reddit`,
href: 'https://reddit.com/r/vuetifyjs',
},
]
];
const expanded = ref(false);
const windowWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1200);
const isMobile = computed(() => windowWidth.value < 960);
const onResize = () => {
windowWidth.value = window.innerWidth;
};
onMounted(() => {
window.addEventListener('resize', onResize);
});
onUnmounted(() => {
window.removeEventListener('resize', onResize);
});
</script>
<style scoped lang="sass">
.social-link :deep(.v-icon)
color: rgba(var(--v-theme-on-background), var(--v-disabled-opacity))
text-decoration: none
transition: .2s ease-in-out
.social-link :deep(.v-icon)
color: rgba(var(--v-theme-on-background), var(--v-disabled-opacity))
text-decoration: none
transition: .2s ease-in-out
&:hover
color: rgba(25, 118, 210, 1)
&:hover
color: rgba(25, 118, 210, 1)
/* Botón flotante para mobile */
.footer-toggle-btn
position: fixed
bottom: 12px
right: 12px
z-index: 2000
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25)
/* Overlay del footer en mobile */
.footer-overlay
position: fixed !important
bottom: 0
left: 0
right: 0
z-index: 1999
</style>

View File

@@ -0,0 +1,383 @@
<template>
<v-container fluid>
<v-row align="center">
<v-col cols="12" md="6">
<h1 class="text-h4">Imágenes de Catálogo</h1>
</v-col>
<v-col cols="12" md="6" class="text-md-right">
<v-btn
color="primary"
variant="elevated"
prepend-icon="mdi-plus"
@click="openCreateDialog"
>
Agregar Imagen
</v-btn>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-card>
<v-data-table
:headers="headers"
:items="images"
:loading="loading"
density="compact"
item-value="id"
items-per-page="25"
:items-per-page-options="[10, 25, 50, 100]"
>
<template #item.image="{ item }">
<v-img
:src="item.image"
max-width="60"
aspect-ratio="1"
cover
class="rounded"
/>
</template>
<template #item.product="{ item }">
{{ productMap[item.product] || `ID: ${item.product}` }}
</template>
<template #item.uploaded_at="{ item }">
{{ formatDate(item.uploaded_at) }}
</template>
<template #item.actions="{ item }">
<v-btn
icon
size="small"
variant="text"
@click="openEditDialog(item)"
>
<v-icon>mdi-pencil</v-icon>
</v-btn>
<v-btn
icon
size="small"
variant="text"
color="error"
@click="openDeleteDialog(item)"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
</template>
<template #loading>
<v-skeleton-loader type="table-row@10" />
</template>
<template #no-data>
<v-alert type="info" variant="tonal" class="my-4">
No hay imágenes de catálogo
</v-alert>
</template>
</v-data-table>
</v-card>
</v-col>
</v-row>
<v-dialog v-model="dialog.show" max-width="500" persistent>
<v-card>
<v-card-title>
{{ dialog.isEdit ? 'Editar' : 'Agregar' }} Imagen de Catálogo
</v-card-title>
<v-card-text>
<v-form ref="formRef">
<v-select
v-model="form.product"
:items="productOptions"
label="Producto"
:rules="[(v) => !!v || 'Seleccione un producto']"
item-title="name"
item-value="id"
variant="outlined"
:disabled="dialog.isEdit"
class="mb-3"
/>
<v-file-input
ref="fileInputRef"
label="Imagen"
accept="image/*"
:rules="[(v) => !!v || 'Seleccione una imagen']"
variant="outlined"
prepend-icon="mdi-camera"
class="mb-3"
@update:model-value="onFileSelected"
/>
<v-img
v-if="form.preview"
:src="form.preview"
max-height="200"
contain
class="mb-3 rounded"
/>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="closeDialog">Cancelar</v-btn>
<v-btn
color="primary"
variant="elevated"
:loading="submitting"
:disabled="submitting"
@click="submitForm"
>
{{ dialog.isEdit ? 'Actualizar' : 'Agregar' }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="deleteDialog.show" max-width="400">
<v-card>
<v-card-title>Confirmar Eliminación</v-card-title>
<v-card-text>
¿Está seguro de eliminar esta imagen de catálogo?
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="deleteDialog.show = false">
Cancelar
</v-btn>
<v-btn
color="error"
variant="elevated"
:loading="deleting"
:disabled="deleting"
@click="confirmDelete"
>
Eliminar
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
:timeout="3000"
location="top"
>
{{ snackbar.message }}
<template #actions>
<v-btn variant="text" @click="snackbar.show = false">Cerrar</v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script setup>
import { ref, inject, onMounted, computed, watch } from "vue";
const api = inject("api");
const images = ref([]);
const products = ref([]);
const loading = ref(false);
const submitting = ref(false);
const deleting = ref(false);
const formRef = ref(null);
const snackbar = ref({ show: false, message: "", color: "success" });
const headers = [
{ title: "ID", key: "id", sortable: true },
{ title: "Producto", key: "product", sortable: true },
{ title: "Imagen", key: "image", sortable: false },
{ title: "Subida el", key: "uploaded_at", sortable: true },
{ title: "Acciones", key: "actions", sortable: false },
];
const dialog = ref({
show: false,
isEdit: false,
editingItem: null,
});
const fileInputRef = ref(null);
const selectedFile = ref(null);
const form = ref({
product: null,
preview: null,
});
const deleteDialog = ref({
show: false,
item: null,
});
let previewObjectUrl = null;
const productMap = computed(() => {
const map = {};
for (const p of products.value) {
map[p.id] = p.name;
}
return map;
});
const productOptions = computed(() => {
return products.value.map((p) => ({
name: p.name,
id: p.id,
}));
});
watch(
selectedFile,
(file) => {
if (previewObjectUrl) {
URL.revokeObjectURL(previewObjectUrl);
previewObjectUrl = null;
}
if (file) {
previewObjectUrl = URL.createObjectURL(file);
form.value.preview = previewObjectUrl;
} else if (dialog.value.isEdit && dialog.value.editingItem) {
form.value.preview = dialog.value.editingItem.image;
} else {
form.value.preview = null;
}
},
);
function onFileSelected(file) {
selectedFile.value = file;
}
function formatDate(dateStr) {
if (!dateStr) return "-";
const d = new Date(dateStr);
return d.toLocaleString("es-CO");
}
async function loadImages() {
loading.value = true;
try {
const data = await api.getCatalogueImages();
images.value = data;
} catch (error) {
console.error("Error al cargar imágenes:", error);
showSnackbar("Error al cargar imágenes", "error");
} finally {
loading.value = false;
}
}
async function loadProducts() {
try {
const data = await api.getProducts("all");
products.value = data;
} catch (error) {
console.error("Error al cargar productos:", error);
}
}
function openCreateDialog() {
dialog.value = { show: true, isEdit: false, editingItem: null };
selectedFile.value = null;
form.value = { product: null, preview: null };
}
function openEditDialog(item) {
dialog.value = { show: true, isEdit: true, editingItem: item };
selectedFile.value = null;
form.value = {
product: item.product,
preview: item.image,
};
}
function closeDialog() {
if (previewObjectUrl) {
URL.revokeObjectURL(previewObjectUrl);
previewObjectUrl = null;
}
selectedFile.value = null;
dialog.value.show = false;
dialog.value.isEdit = false;
dialog.value.editingItem = null;
form.value = { product: null, preview: null };
}
async function submitForm() {
const formComponent = formRef.value;
if (formComponent) {
const { valid } = await formComponent.validate();
if (!valid) return;
}
submitting.value = true;
try {
const fd = new FormData();
fd.append("product", form.value.product);
if (selectedFile.value) {
fd.append("image", selectedFile.value);
}
if (dialog.value.isEdit) {
await api.updateCatalogueImage(dialog.value.editingItem.id, fd);
showSnackbar("Imagen actualizada exitosamente", "success");
} else {
await api.createCatalogueImage(fd);
showSnackbar("Imagen agregada exitosamente", "success");
}
closeDialog();
await loadImages();
} catch (error) {
console.error("Error al guardar imagen:", error);
showSnackbar("Error al guardar imagen", "error");
} finally {
submitting.value = false;
}
}
function openDeleteDialog(item) {
deleteDialog.value = { show: true, item };
}
async function confirmDelete() {
deleting.value = true;
try {
await api.deleteCatalogueImage(deleteDialog.value.item.id);
showSnackbar("Imagen eliminada exitosamente", "success");
deleteDialog.value.show = false;
await loadImages();
} catch (error) {
console.error("Error al eliminar imagen:", error);
showSnackbar("Error al eliminar imagen", "error");
} finally {
deleting.value = false;
}
}
function showSnackbar(message, color) {
snackbar.value = { show: true, message, color };
}
onMounted(() => {
loadImages();
loadProducts();
});
</script>
<style scoped>
.text-md-right {
text-align: right;
}
@media (max-width: 960px) {
.text-md-right {
text-align: left;
}
}
</style>

View File

@@ -1,7 +1,13 @@
<template>
<v-dialog v-model="show" max-width="400">
<v-card>
<v-card-title class="headline">Iniciar sesión</v-card-title>
<v-card-title class="headline d-flex align-center">
Iniciar sesión
<v-spacer />
<v-btn icon size="small" variant="text" @click="show = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text>
<v-form ref="form" @submit.prevent="onSubmit">
<v-text-field

View File

@@ -54,7 +54,7 @@
nav
>
<v-list-item
v-for="item in menuItems"
v-for="item in filteredMenuItems"
:key="item.title"
:title="item.title"
:prepend-icon="item.icon"
@@ -106,8 +106,9 @@
{ title: 'Cuadres de tarro', route: '/cuadres_de_tarro', icon: 'mdi-chart-bar'},
{ title: 'CSV Tryton', route: '/ventas_para_tryton', icon: 'mdi-file-table'},
{ title: 'Compra adm', route: '/compra_admin', icon: 'mdi-cart'},
{ title: 'Gestión de Productos', route: '/admin/products', icon: 'mdi-package-variant'},
{ title: 'Ver Ventas por Catálogo', route: '/admin/catalog-sales', icon: 'mdi-cart-arrow-down'},
{ title: 'Gestión de Productos', route: '/admin/products', icon: 'mdi-package-variant'},
{ title: 'Imágenes de Catálogo', route: '/admin/catalogue-images', icon: 'mdi-image-multiple'},
{ title: 'Ver Ventas por Catálogo', route: '/admin/catalog-sales', icon: 'mdi-cart-arrow-down'},
{ divider: true },
{ header: 'Sincronización Tryton' },
{ title: 'Importar Productos', route: '/sincronizar_productos_tryton', icon: 'mdi-download'},
@@ -119,7 +120,13 @@
computed: {
isAdmin() {
return this.user?.role === 'administrator';
}
},
filteredMenuItems() {
if (this.user?.role === 'publico') {
return this.menuItems.filter(item => item.route !== '/comprar');
}
return this.menuItems;
},
},
mounted() {
this.checkAuth();

View File

@@ -8,7 +8,7 @@
</v-container>
<v-container v-show="id">
<v-toolbar>
<v-toolbar-title> Resumen de la compra {{ id }}</v-toolbar-title>
<v-toolbar-title> {{ type === 'catalog' ? 'Resumen del pedido' : 'Resumen de la compra' }} {{ id }}</v-toolbar-title>
</v-toolbar>
<v-list>
<v-list-item>
@@ -39,7 +39,7 @@
{{ currencyFormat(calculateSubtotal(item.unit_price, item.quantity)) }}
</template>
</v-data-table-virtual>
<v-alert type="info" class="my-4">
<v-alert v-if="type !== 'catalog'" type="info" class="my-4">
Recuerda adicionar a la planilla física lo siguiente
<v-data-table
:headers="headersTemplate"

View File

@@ -47,7 +47,7 @@
</div>
<!-- Precio Total -->
<div class="price-row">
<div v-if="product.quantity > 0" class="price-row">
<span class="price-label text-caption">Precio total</span>
<v-chip
color="success"
@@ -63,50 +63,64 @@
<!-- Footer con Controles de Cantidad -->
<v-card-actions class="product-actions pa-2 pb-3 justify-center">
<div class="quantity-controls">
<template v-if="disabled">
<v-btn
icon
size="small"
color="primary"
variant="tonal"
color="error"
class="qty-btn"
@click="decrease(product)"
:disabled="product.quantity === 0"
prepend-icon="mdi-cart-plus"
class="login-add-btn"
@click="$emit('request-login')"
>
<v-icon size="20">mdi-minus</v-icon>
Agregar
</v-btn>
</template>
<template v-else>
<div class="quantity-controls">
<v-btn
icon
size="small"
variant="tonal"
color="error"
class="qty-btn"
@click="decrease(product)"
:disabled="product.quantity === 0"
>
<v-icon size="20">mdi-minus</v-icon>
</v-btn>
<v-text-field
v-model.number="product.quantity"
type="number"
min="0"
class="quantity-input mx-1"
variant="solo-filled"
density="compact"
hide-details
single-line
flat
aria-label="Cantidad"
@input="handleQuantityChange"
/>
<v-text-field
v-model.number="product.quantity"
type="number"
min="0"
class="quantity-input mx-1"
variant="solo-filled"
density="compact"
hide-details
single-line
flat
aria-label="Cantidad"
@input="handleQuantityChange"
/>
<v-btn
icon
size="small"
variant="tonal"
color="success"
class="qty-btn"
@click="handleIncrease"
>
<v-icon size="20">mdi-plus</v-icon>
</v-btn>
</div>
<v-btn
icon
size="small"
variant="tonal"
color="success"
class="qty-btn"
@click="handleIncrease"
>
<v-icon size="20">mdi-plus</v-icon>
</v-btn>
</div>
</template>
</v-card-actions>
</v-card>
</template>
<script>
export default {
emits: ['add-to-cart', 'request-login'],
props: {
product: {
type: Object,
@@ -128,6 +142,10 @@ export default {
type: Function,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
},
methods: {
handleIncrease() {
@@ -170,7 +188,6 @@ export default {
.product-image-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: linear-gradient(135deg, #fafafa 0%, #ffffff 100%);
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
@@ -307,6 +324,13 @@ export default {
RESPONSIVE BREAKPOINTS
============================================ */
/* Botón de agregar (no autenticado) */
.login-add-btn {
width: 100%;
font-weight: 600;
letter-spacing: 0.5px;
}
/* Móvil pequeño (< 375px) */
@media (max-width: 374px) {
.product-content {
@@ -330,143 +354,6 @@ export default {
/* Móvil estándar (375px - 559px) */
@media (min-width: 375px) and (max-width: 559px) {
.product-name {
font-size: 0.95rem;
}
.price-value {
font-size: 1.05rem;
}
}
/* Tablet (560px - 959px) */
@media (min-width: 560px) and (max-width: 959px) {
.product-content {
padding: 14px;
}
.product-name {
font-size: 1rem;
min-height: 2.6rem;
}
.price-label {
font-size: 0.68rem;
}
.price-value {
font-size: 1.15rem;
}
.price-total-chip {
font-size: 0.95rem;
height: 28px;
}
.quantity-input {
max-width: 70px;
min-width: 70px;
}
}
/* Desktop (≥ 960px) */
@media (min-width: 960px) {
.product-content {
padding: 16px 14px;
}
.product-name {
font-size: 1.05rem;
min-height: 2.7rem;
}
.price-label {
font-size: 0.7rem;
}
.price-value {
font-size: 1.2rem;
}
.price-total-chip {
font-size: 1rem;
height: 30px;
padding: 0 14px;
}
.product-actions {
padding: 8px;
padding-bottom: 12px;
}
.quantity-input {
max-width: 70px;
min-width: 70px;
}
.qty-btn {
width: 36px;
height: 36px;
}
}
/* Desktop Large (≥ 1280px) */
@media (min-width: 1280px) {
.product-content {
padding: 18px 16px;
}
.product-name {
font-size: 1.1rem;
min-height: 2.8rem;
}
.price-value {
font-size: 1.25rem;
}
.price-total-chip {
font-size: 1.05rem;
height: 32px;
}
}
/* Desktop Extra Large (≥ 1920px) */
@media (min-width: 1920px) {
.product-name {
font-size: 1.15rem;
min-height: 3rem;
}
.price-value {
font-size: 1.3rem;
}
.product-content {
padding: 8px;
}
.product-name {
font-size: 0.8rem;
min-height: 1.9rem;
}
.price-value {
font-size: 0.85rem;
}
.quantity-input {
max-width: 50px;
min-width: 50px;
}
}
/* Móvil estándar (375px - 559px) */
@media (min-width: 375px) and (max-width: 559px) {
.product-image-container {
height: 140px;
}
.product-name {
font-size: 0.85rem;
}
@@ -478,10 +365,6 @@ export default {
/* Tablet (560px - 959px) */
@media (min-width: 560px) and (max-width: 959px) {
.product-image-container {
height: 150px;
}
.product-content {
padding: 10px;
}
@@ -512,10 +395,6 @@ export default {
/* Desktop (≥ 960px) */
@media (min-width: 960px) {
.product-image-container {
height: 160px;
}
.product-content {
padding: 12px 10px;
}
@@ -557,10 +436,6 @@ export default {
/* Desktop Large (≥ 1280px) */
@media (min-width: 1280px) {
.product-image-container {
height: 170px;
}
.product-content {
padding: 14px 12px;
}
@@ -582,10 +457,6 @@ export default {
/* Desktop Extra Large (≥ 1920px) */
@media (min-width: 1920px) {
.product-image-container {
height: 180px;
}
.product-name {
font-size: 1.05rem;
min-height: 2.5rem;

View File

@@ -47,7 +47,7 @@
icon
size="small"
variant="text"
@click="$emit('toggle-collapse')"
@click.stop="$emit('toggle-collapse')"
:title="isCollapsed ? 'Expandir carrito' : 'Contraer carrito'"
>
<v-icon>{{ isCollapsed ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
@@ -133,7 +133,7 @@
<v-card-actions v-if="cartItems.length > 0" class="cart-checkout-section">
<v-btn color="primary" block @click="$emit('checkout')">
Finalizar Compra
Finalizar Pedido
</v-btn>
</v-card-actions>
</div>

View File

@@ -0,0 +1,10 @@
<template>
<CatalogueImagesManagement v-if="authStore.isAdmin" />
</template>
<script setup>
import { useAuthStore } from '@/stores/auth';
import CatalogueImagesManagement from '@/components/CatalogueImagesManagement.vue';
const authStore = useAuthStore();
</script>

View File

@@ -2,7 +2,7 @@
<v-container fluid>
<!-- Backdrop para mobile cuando el carrito está expandido -->
<div
v-if="isMobile && !cartCollapsed"
v-if="isMobile && !cartCollapsed && isAuthenticated"
class="cart-backdrop"
@click="cartCollapsed = true"
></div>
@@ -49,20 +49,6 @@
</div>
</v-sheet>
<!-- Controles de paginación superiores -->
<PaginationControls
v-if="filteredItems.length > 0"
:current-page="currentPage"
:total-pages="totalPages"
:items-per-page="itemsPerPage"
:items-per-page-options="itemsPerPageOptions"
:pagination-info="paginationInfo"
:total-visible-pages="totalVisiblePages"
@page-change="handlePageChange"
@items-per-page-change="handleItemsPerPageChange"
position="top"
/>
<!-- Grid de productos paginados -->
<v-row class="product-grid" v-if="paginatedItems.length > 0">
<v-col
@@ -80,7 +66,9 @@
:decrease="decrease"
:currency="currency"
:updateQuantity="updateQuantity"
:disabled="!isAuthenticated"
@add-to-cart="addToCart"
@request-login="showLogin"
/>
</v-col>
</v-row>
@@ -124,6 +112,7 @@
:class="{ 'cart-is-collapsed': cartCollapsed && isMobile }"
>
<Cart
v-if="isAuthenticated"
:cart-items="cartItems"
:currency="currency"
:is-collapsed="cartCollapsed"
@@ -134,6 +123,63 @@
@update-quantity="updateCartQuantity"
@toggle-collapse="toggleCart"
/>
<v-card v-else-if="!isMobile || showLoginPrompt" class="login-prompt-card pa-4 text-center">
<v-btn
icon
size="x-small"
variant="text"
class="login-prompt-close"
@click="showLoginPrompt = false"
>
<v-icon size="18">mdi-close</v-icon>
</v-btn>
<v-icon size="48" color="primary" class="mb-2">mdi-cart-lock</v-icon>
<p class="text-body-2 text-medium-emphasis mb-3">
Para hacer pedidos debes de tener usuario y clave, si no lo tienes comunicate con nosotros al
<strong class="text-primary">{{ contactPhone }}</strong>
para solicitarlo. Esto es necesario porque no podemos procesar tus pedidos todos los días del mes.
Comunicate con nosotros para contarte todos los detalles y que puedas apoyar este proceso.
</p>
<div class="d-flex justify-center ga-2 mb-3">
<v-btn
icon
size="small"
color="primary"
variant="tonal"
:href="'tel:' + contactPhone"
>
<v-icon size="18">mdi-phone</v-icon>
</v-btn>
<v-btn
icon
size="small"
color="success"
variant="tonal"
:href="'https://wa.me/' + contactPhone"
target="_blank"
>
<v-icon size="18">mdi-whatsapp</v-icon>
</v-btn>
<v-btn
icon
size="small"
color="info"
variant="tonal"
:href="'https://t.me/+57' + contactPhone"
target="_blank"
>
<v-icon size="18">mdi-send</v-icon>
</v-btn>
</div>
<v-btn
color="primary"
variant="tonal"
prepend-icon="mdi-login"
@click="openLoginDialog"
>
Iniciar Sesión
</v-btn>
</v-card>
</div>
</v-col>
</v-row>
@@ -141,7 +187,7 @@
<!-- Modal 1: Confirmación de productos -->
<v-dialog v-model="checkoutDialog" max-width="600" persistent>
<v-card>
<v-card-title class="headline">Confirmar Compra</v-card-title>
<v-card-title class="headline">Confirmar Pedido</v-card-title>
<v-card-text>
<v-list v-if="cartItems.length > 0" class="product-list-scroll">
<v-list-item v-for="item in cartItems" :key="item.id">
@@ -176,7 +222,7 @@
</v-card>
</v-dialog>
<!-- Modal 2: Datos personales -->
<!-- Modal 2: Datos personales + aviso de coordinación -->
<v-dialog v-model="personalDataDialog" max-width="500" persistent>
<v-card>
<v-card-title class="headline">Datos de Contacto</v-card-title>
@@ -213,6 +259,48 @@
variant="outlined"
></v-select>
</v-form>
<v-divider class="my-3"></v-divider>
<v-alert type="info" variant="tonal" class="mb-0">
<div class="text-body-2 mb-2">
Para coordinar la entrega de tu pedido escríbenos:
</div>
<div class="d-flex ga-2">
<v-btn
icon
size="x-small"
color="primary"
variant="tonal"
:href="'tel:' + contactPhone"
>
<v-icon size="16">mdi-phone</v-icon>
</v-btn>
<v-btn
icon
size="x-small"
color="success"
variant="tonal"
:href="'https://wa.me/57' + contactPhone"
target="_blank"
>
<v-icon size="16">mdi-whatsapp</v-icon>
</v-btn>
<v-btn
icon
size="x-small"
color="info"
variant="tonal"
:href="'https://t.me/+57' + contactPhone"
target="_blank"
>
<v-icon size="16">mdi-send</v-icon>
</v-btn>
<span class="text-body-2 font-weight-bold ml-1 d-flex align-center">
{{ contactPhone }}
</span>
</div>
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
@@ -224,11 +312,17 @@
:loading="isSubmitting"
:disabled="isSubmitting"
>
Finalizar Compra
Finalizar Pedido
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Login Dialog -->
<LoginDialog
ref="loginDialogRef"
@login-success="onLoginSuccess"
/>
</v-container>
</template>
@@ -236,7 +330,9 @@
import Card from "@/components/catalog/Card.vue";
import Cart from "@/components/catalog/Cart.vue";
import PaginationControls from "@/components/catalog/PaginationControls.vue";
import LoginDialog from "@/components/LoginDialog.vue";
import { useCartStore } from "@/stores/cart";
import { useAuthStore } from "@/stores/auth";
import { inject, ref, computed, onMounted, onUnmounted } from "vue";
import not_image_product from "@/assets/not_image_for_product.jpeg";
@@ -245,10 +341,13 @@ export default {
Card,
Cart,
PaginationControls,
LoginDialog,
},
setup() {
const cartStore = useCartStore();
const cartCollapsed = ref(false); // Cambiado a false para que inicie expandido en desktop
const authStore = useAuthStore();
const cartCollapsed = ref(false);
const showLoginPrompt = ref(false);
const windowWidth = ref(window.innerWidth);
const isMobile = computed(() => windowWidth.value < 960); // Cambiado de 680 a 960
@@ -267,7 +366,9 @@ export default {
return {
cartStore,
authStore,
cartCollapsed,
showLoginPrompt,
isMobile,
windowWidth,
};
@@ -309,12 +410,19 @@ export default {
cartCount() {
return this.cartStore.cartCount;
},
isAuthenticated() {
return this.authStore.isAuthenticated;
},
contactPhone() {
return import.meta.env.VITE_CONTACT_PHONE || '';
},
// Búsqueda
filteredItems() {
if (!this.searchQuery) return this.items;
const query = this.searchQuery.toLowerCase();
const normalize = (s) => s.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
const query = normalize(this.searchQuery);
return this.items.filter((item) =>
item.name.toLowerCase().includes(query),
normalize(item.name).includes(query),
);
},
// Paginación
@@ -379,7 +487,7 @@ export default {
this.items = data.map((product) => ({
...product,
quantity: 0,
img: product.img || not_image_product,
img: (product.catalogue_images?.length > 0) ? product.catalogue_images[0] : (product.img || not_image_product),
}));
})
.catch((error) => {
@@ -440,7 +548,6 @@ export default {
},
async onSubmitPurchase() {
const form = this.$refs.personalForm;
if (form) {
const { valid } = await form.validate();
if (!valid) return;
@@ -462,7 +569,6 @@ export default {
customer_phone: this.customerPhone,
pickup_method: this.pickupMethod,
};
this.api
.createCatalogPurchase(payload)
.then((data) => {
@@ -511,14 +617,27 @@ export default {
}
},
scrollToTop() {
this.$nextTick(() => {
const catalogHeader = this.$el?.querySelector(".page-header");
if (catalogHeader) {
catalogHeader.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
const grid = this.$el?.querySelector(".product-grid");
if (grid) {
const top = grid.getBoundingClientRect().top + window.scrollY - 16;
window.scrollTo({ top, behavior: "smooth" });
} else {
window.scrollTo({ top: 0, behavior: "smooth" });
}
},
openLoginDialog() {
this.$refs.loginDialogRef.open();
},
showLogin() {
this.showLoginPrompt = true;
if (this.isMobile) {
this.cartCollapsed = false;
}
},
onLoginSuccess() {
this.showLoginPrompt = false;
this.api.getCurrentUser().then((user) => {
this.authStore.setUser(user);
});
},
currency(val) {
@@ -726,6 +845,23 @@ export default {
}
}
/* ============================================
LOGIN PROMPT CARD
============================================ */
.login-prompt-card {
border: 2px dashed rgba(0, 0, 0, 0.12);
background: #fafafa !important;
position: relative;
overflow-wrap: break-word;
word-break: break-word;
}
.login-prompt-close {
position: absolute;
top: 8px;
right: 8px;
}
/* ============================================
MODALES
============================================ */

View File

@@ -11,6 +11,8 @@ import { setupLayouts } from 'virtual:generated-layouts'
import { routes } from 'vue-router/auto-routes'
import { useAuthStore } from '@/stores/auth'
const PUBLICO_RESTRICTED = ['/comprar']
const ADMIN_ROUTES = [
'/sincronizar_clientes_tryton',
'/sincronizar_ventas_tryton',
@@ -21,6 +23,7 @@ const ADMIN_ROUTES = [
'/cuadrar_tarro',
'/admin/products',
'/admin/catalog-sales',
'/admin/catalogue-images',
]
const router = createRouter({
@@ -39,6 +42,8 @@ router.beforeEach((to, from, next) => {
next({ path: '/autenticarse', replace: true })
} else if (requiresAdmin && !authStore.isAdmin && authStore.user) {
next({ path: '/', replace: true })
} else if (authStore.user?.role === 'publico' && PUBLICO_RESTRICTED.includes(to.path)) {
next({ path: '/catalog', replace: true })
} else {
next()
}

View File

@@ -82,6 +82,22 @@ class Api {
getCurrentUser() {
return this.apiImplementation.getCurrentUser();
}
getCatalogueImages() {
return this.apiImplementation.getCatalogueImages();
}
createCatalogueImage(data) {
return this.apiImplementation.createCatalogueImage(data);
}
updateCatalogueImage(id, data) {
return this.apiImplementation.updateCatalogueImage(id, data);
}
deleteCatalogueImage(id) {
return this.apiImplementation.deleteCatalogueImage(id);
}
}
export default Api;

View File

@@ -130,6 +130,30 @@ class DjangoApi {
const url = this.base + "/api/users/me/";
return this.getRequest(url);
}
getCatalogueImages() {
const url = this.base + "/don_confiao/api/catalogue_images/";
return this.getRequest(url);
}
createCatalogueImage(data) {
const url = this.base + "/don_confiao/api/catalogue_images/";
return http.post(url, data, {
headers: { 'Content-Type': undefined },
}).then((r) => r.data);
}
updateCatalogueImage(id, data) {
const url = this.base + `/don_confiao/api/catalogue_images/${id}/`;
return http.put(url, data, {
headers: { 'Content-Type': undefined },
}).then((r) => r.data);
}
deleteCatalogueImage(id) {
const url = this.base + `/don_confiao/api/catalogue_images/${id}/`;
return http.delete(url).then((r) => r.data);
}
}
export default DjangoApi;

View File

@@ -6,6 +6,7 @@ export const useAuthStore = defineStore('auth', {
}),
getters: {
isAdmin: (state) => state.user?.role === 'administrator',
isPublico: (state) => state.user?.role === 'publico',
isAuthenticated: (state) => !!state.user
},
actions: {