CRM/Contacts Google Maps Integration

This commit is contained in:
2026-06-06 02:39:05 -05:00
parent f024286b5e
commit 3b56fb5c61
2 changed files with 119 additions and 1 deletions

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

View File

@@ -84,6 +84,18 @@
<v-text-field v-model="form.country" label="Country" />
</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" />
<template v-if="form.id">
@@ -120,6 +132,7 @@
<script>
import DataTable from '../components/DataTable.vue';
import AddressMap from '../components/AddressMap.vue';
import { apiRequest } from '../services/api';
import { contactTypeItems, creditTermItems } from '../constants';
import { clonePlain } from '../utils/clone';
@@ -135,7 +148,7 @@ function blankContact() {
}
export default {
components: { DataTable },
components: { DataTable, AddressMap },
props: {
token: { type: String, required: true },
canDelete: { type: Boolean, default: false }