CRM/Contacts Google Maps Integration
This commit is contained in:
105
src/components/AddressMap.vue
Normal file
105
src/components/AddressMap.vue
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<template>
|
||||||
|
<div class="address-map">
|
||||||
|
<div class="toolbar-row mb-2">
|
||||||
|
<h3 class="section-title">Location map</h3>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
v-if="hasAddress"
|
||||||
|
:href="linkUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
prepend-icon="mdi-open-in-new"
|
||||||
|
>Open in Google Maps</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!hasAddress" class="map-empty quiet text-body-2">
|
||||||
|
Enter an address above to preview the location on a map.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="!show" class="map-empty">
|
||||||
|
<v-btn variant="tonal" prepend-icon="mdi-map-search-outline" @click="show = true">Show map</v-btn>
|
||||||
|
<div class="quiet text-caption mt-2">{{ activeQuery }}</div>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
v-else
|
||||||
|
:key="embedUrl"
|
||||||
|
:src="embedUrl"
|
||||||
|
class="map-frame"
|
||||||
|
title="Location map"
|
||||||
|
loading="lazy"
|
||||||
|
referrerpolicy="no-referrer-when-downgrade"
|
||||||
|
allowfullscreen
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Embeds an interactive Google map for an address using the keyless maps.google.com `output=embed`
|
||||||
|
// endpoint (no API key required). The address is debounced so live typing in the form doesn't reload
|
||||||
|
// the iframe on every keystroke.
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
line1: { type: String, default: '' },
|
||||||
|
line2: { type: String, default: '' },
|
||||||
|
city: { type: String, default: '' },
|
||||||
|
region: { type: String, default: '' },
|
||||||
|
postalCode: { type: String, default: '' },
|
||||||
|
country: { type: String, default: '' },
|
||||||
|
// Start with the map already visible (e.g. when viewing an existing contact that has an address).
|
||||||
|
autoShow: { type: Boolean, default: false }
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return { activeQuery: '', show: this.autoShow, timer: null };
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
formattedAddress() {
|
||||||
|
return [this.line1, this.line2, this.city, this.region, this.postalCode, this.country]
|
||||||
|
.map((part) => String(part || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
},
|
||||||
|
hasAddress() {
|
||||||
|
return this.formattedAddress.length > 0;
|
||||||
|
},
|
||||||
|
embedUrl() {
|
||||||
|
return `https://maps.google.com/maps?q=${encodeURIComponent(this.activeQuery)}&z=15&output=embed`;
|
||||||
|
},
|
||||||
|
linkUrl() {
|
||||||
|
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(this.activeQuery)}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
// Debounce: only refresh the embedded query a beat after the user stops typing.
|
||||||
|
formattedAddress(value) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = setTimeout(() => { this.activeQuery = value; }, 600);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.activeQuery = this.formattedAddress;
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.map-frame {
|
||||||
|
width: 100%;
|
||||||
|
height: 320px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.map-empty {
|
||||||
|
border: 1px dashed rgba(var(--v-theme-on-surface), 0.25);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -84,6 +84,18 @@
|
|||||||
<v-text-field v-model="form.country" label="Country" />
|
<v-text-field v-model="form.country" label="Country" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AddressMap
|
||||||
|
:key="form.id || 'new'"
|
||||||
|
class="mt-3"
|
||||||
|
:line1="form.address_line1"
|
||||||
|
:line2="form.address_line2"
|
||||||
|
:city="form.city"
|
||||||
|
:region="form.state_region"
|
||||||
|
:postal-code="form.postal_code"
|
||||||
|
:country="form.country"
|
||||||
|
:auto-show="Boolean(form.id && (form.address_line1 || form.city))"
|
||||||
|
/>
|
||||||
|
|
||||||
<v-textarea v-model="form.notes" class="mt-3" label="Notes" rows="2" />
|
<v-textarea v-model="form.notes" class="mt-3" label="Notes" rows="2" />
|
||||||
|
|
||||||
<template v-if="form.id">
|
<template v-if="form.id">
|
||||||
@@ -120,6 +132,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import DataTable from '../components/DataTable.vue';
|
import DataTable from '../components/DataTable.vue';
|
||||||
|
import AddressMap from '../components/AddressMap.vue';
|
||||||
import { apiRequest } from '../services/api';
|
import { apiRequest } from '../services/api';
|
||||||
import { contactTypeItems, creditTermItems } from '../constants';
|
import { contactTypeItems, creditTermItems } from '../constants';
|
||||||
import { clonePlain } from '../utils/clone';
|
import { clonePlain } from '../utils/clone';
|
||||||
@@ -135,7 +148,7 @@ function blankContact() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { DataTable },
|
components: { DataTable, AddressMap },
|
||||||
props: {
|
props: {
|
||||||
token: { type: String, required: true },
|
token: { type: String, required: true },
|
||||||
canDelete: { type: Boolean, default: false }
|
canDelete: { type: Boolean, default: false }
|
||||||
|
|||||||
Reference in New Issue
Block a user