#50 feat: mostrar dirección y mapa de la tienda en la página de inicio
This commit is contained in:
@@ -108,6 +108,7 @@
|
||||
{ title: 'Compra adm', route: '/compra_admin', icon: 'mdi-cart'},
|
||||
{ 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: 'Datos de la Tienda', route: '/admin/store-settings', icon: 'mdi-map-marker'},
|
||||
{ title: 'Ver Ventas por Catálogo', route: '/admin/catalog-sales', icon: 'mdi-cart-arrow-down'},
|
||||
{ divider: true },
|
||||
{ header: 'Sincronización Tryton' },
|
||||
|
||||
165
src/components/StoreLocation.vue
Normal file
165
src/components/StoreLocation.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<v-container v-if="settings && settings.address">
|
||||
<v-sheet
|
||||
class="store-location-section rounded-xl pa-6 pa-md-8"
|
||||
elevation="2"
|
||||
>
|
||||
<div class="d-flex flex-column align-center text-center mb-4">
|
||||
<div class="d-flex align-center mb-1">
|
||||
<v-icon color="primary" size="36" class="mr-2">mdi-map-marker</v-icon>
|
||||
<h2 class="text-h4 font-weight-bold mb-0">Visítanos</h2>
|
||||
</div>
|
||||
<p class="text-body-1 text-medium-emphasis mb-0">
|
||||
Encuentra nuestra tienda física y adquiere nuestros productos
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center mb-4">
|
||||
<div class="d-flex align-center justify-center">
|
||||
<v-icon color="red" class="mr-2">mdi-map-marker</v-icon>
|
||||
<span class="text-h6 font-weight-medium">
|
||||
{{ settings.address }}
|
||||
</span>
|
||||
</div>
|
||||
<v-btn
|
||||
v-if="hasCoordinates"
|
||||
:href="directionsLink"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
prepend-icon="mdi-directions"
|
||||
class="mt-2"
|
||||
>
|
||||
Cómo llegar
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-else
|
||||
:href="searchLink"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
prepend-icon="mdi-map-search"
|
||||
class="mt-2"
|
||||
>
|
||||
Ver en el mapa
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="hasCoordinates" ref="mapEl" class="map-wrapper"></div>
|
||||
<v-alert v-else type="info" variant="tonal" class="ma-0">
|
||||
La ubicación en el mapa aún no está configurada.
|
||||
</v-alert>
|
||||
</v-sheet>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, inject, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
const api = inject('api');
|
||||
|
||||
const settings = ref(null);
|
||||
const mapEl = ref(null);
|
||||
|
||||
let map = null;
|
||||
let marker = null;
|
||||
|
||||
const hasCoordinates = computed(
|
||||
() =>
|
||||
settings.value &&
|
||||
settings.value.latitude != null &&
|
||||
settings.value.longitude != null
|
||||
);
|
||||
|
||||
const redPinIcon = L.divIcon({
|
||||
className: 'store-map-pin',
|
||||
html: '<i class="mdi mdi-map-marker" style="font-size:48px;line-height:1;color:#f44336;"></i>',
|
||||
iconSize: [48, 48],
|
||||
iconAnchor: [24, 48],
|
||||
});
|
||||
|
||||
const directionsLink = computed(() => {
|
||||
const lat = settings.value.latitude;
|
||||
const lng = settings.value.longitude;
|
||||
return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=16/${lat}/${lng}`;
|
||||
});
|
||||
|
||||
const searchLink = computed(
|
||||
() =>
|
||||
`https://www.openstreetmap.org/search?query=${encodeURIComponent(
|
||||
settings.value.address
|
||||
)}`
|
||||
);
|
||||
|
||||
function initMap() {
|
||||
if (!hasCoordinates.value || !mapEl.value || map) return;
|
||||
|
||||
const lat = settings.value.latitude;
|
||||
const lng = settings.value.longitude;
|
||||
|
||||
map = L.map(mapEl.value, {
|
||||
scrollWheelZoom: false,
|
||||
}).setView([lat, lng], 16);
|
||||
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
}).addTo(map);
|
||||
|
||||
marker = L.marker([lat, lng], { icon: redPinIcon }).addTo(map);
|
||||
marker.bindPopup(settings.value.address);
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
if (map) {
|
||||
map.invalidateSize();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
settings.value = await api.getStoreSettings();
|
||||
await nextTick();
|
||||
initMap();
|
||||
} catch (error) {
|
||||
console.error('Error al cargar la información de la tienda:', error);
|
||||
}
|
||||
window.addEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (map) {
|
||||
map.remove();
|
||||
map = null;
|
||||
marker = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.store-location-section {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.map-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.map-wrapper :deep(.leaflet-control-attribution) {
|
||||
font-size: 10px;
|
||||
}
|
||||
</style>
|
||||
159
src/components/StoreSettingsManagement.vue
Normal file
159
src/components/StoreSettingsManagement.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<v-container fluid>
|
||||
<v-row align="center">
|
||||
<v-col cols="12" md="6">
|
||||
<h1 class="text-h4">Datos de la Tienda</h1>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6" class="text-md-right">
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="elevated"
|
||||
prepend-icon="mdi-content-save"
|
||||
:loading="submitting"
|
||||
:disabled="submitting"
|
||||
@click="submitForm"
|
||||
>
|
||||
Guardar Cambios
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<v-form ref="formRef">
|
||||
<v-text-field
|
||||
v-model="form.address"
|
||||
label="Dirección de la tienda"
|
||||
:rules="[(v) => !!v || 'La dirección es obligatoria']"
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-map-marker"
|
||||
class="mb-3"
|
||||
/>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model.number="form.latitude"
|
||||
label="Latitud (opcional)"
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-crosshairs-gps"
|
||||
type="number"
|
||||
step="any"
|
||||
class="mb-3"
|
||||
hint="Ej: 4.6097"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model.number="form.longitude"
|
||||
label="Longitud (opcional)"
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-crosshairs-gps"
|
||||
type="number"
|
||||
step="any"
|
||||
class="mb-3"
|
||||
hint="Ej: -74.0817"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<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 } from "vue";
|
||||
|
||||
const api = inject("api");
|
||||
|
||||
const formRef = ref(null);
|
||||
const submitting = ref(false);
|
||||
|
||||
const form = ref({
|
||||
address: "",
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
});
|
||||
|
||||
const snackbar = ref({ show: false, message: "", color: "success" });
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const data = await api.getStoreSettings();
|
||||
form.value = {
|
||||
address: data.address || "",
|
||||
latitude: data.latitude ?? null,
|
||||
longitude: data.longitude ?? null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error al cargar la información de la tienda:", error);
|
||||
showSnackbar("Error al cargar la información de la tienda", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
const formComponent = formRef.value;
|
||||
if (formComponent) {
|
||||
const { valid } = await formComponent.validate();
|
||||
if (!valid) return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
await api.updateStoreSettings({
|
||||
address: form.value.address,
|
||||
latitude: toNumberOrNull(form.value.latitude),
|
||||
longitude: toNumberOrNull(form.value.longitude),
|
||||
});
|
||||
showSnackbar("Información de la tienda actualizada", "success");
|
||||
} catch (error) {
|
||||
console.error("Error al guardar la información de la tienda:", error);
|
||||
showSnackbar("Error al guardar la información de la tienda", "error");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toNumberOrNull(value) {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const number = Number(value);
|
||||
return Number.isNaN(number) ? null : number;
|
||||
}
|
||||
|
||||
function showSnackbar(message, color) {
|
||||
snackbar.value = { show: true, message, color };
|
||||
}
|
||||
|
||||
onMounted(loadSettings);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-md-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.text-md-right {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -21,6 +21,10 @@
|
||||
</div>
|
||||
</v-sheet>
|
||||
|
||||
<div class="py-6">
|
||||
<StoreLocation />
|
||||
</div>
|
||||
|
||||
<v-container class="py-6">
|
||||
<v-row>
|
||||
<v-col cols="12" md="4">
|
||||
@@ -117,6 +121,7 @@
|
||||
|
||||
<script setup>
|
||||
import ResaltedText from "@/components/ResaltedText.vue";
|
||||
import StoreLocation from "@/components/StoreLocation.vue";
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import logo from "@/assets/logo_colorful.png";
|
||||
|
||||
|
||||
10
src/pages/admin/store-settings.vue
Normal file
10
src/pages/admin/store-settings.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<StoreSettingsManagement v-if="authStore.isAdmin" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import StoreSettingsManagement from '@/components/StoreSettingsManagement.vue';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
</script>
|
||||
@@ -24,6 +24,7 @@ const ADMIN_ROUTES = [
|
||||
'/admin/products',
|
||||
'/admin/catalog-sales',
|
||||
'/admin/catalogue-images',
|
||||
'/admin/store-settings',
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
@@ -98,6 +98,14 @@ class Api {
|
||||
deleteCatalogueImage(id) {
|
||||
return this.apiImplementation.deleteCatalogueImage(id);
|
||||
}
|
||||
|
||||
getStoreSettings() {
|
||||
return this.apiImplementation.getStoreSettings();
|
||||
}
|
||||
|
||||
updateStoreSettings(data) {
|
||||
return this.apiImplementation.updateStoreSettings(data);
|
||||
}
|
||||
}
|
||||
|
||||
export default Api;
|
||||
|
||||
@@ -154,6 +154,16 @@ class DjangoApi {
|
||||
const url = this.base + `/don_confiao/api/catalogue_images/${id}/`;
|
||||
return http.delete(url).then((r) => r.data);
|
||||
}
|
||||
|
||||
getStoreSettings() {
|
||||
const url = this.base + "/don_confiao/api/store_settings";
|
||||
return this.getRequest(url);
|
||||
}
|
||||
|
||||
updateStoreSettings(data) {
|
||||
const url = this.base + "/don_confiao/api/store_settings";
|
||||
return this.patchRequest(url, data);
|
||||
}
|
||||
}
|
||||
|
||||
export default DjangoApi;
|
||||
|
||||
Reference in New Issue
Block a user