Código.gs
const SHEET_NAME = 'Clientes';
const HEADERS = ['ID_Cliente', 'Nome', 'Telefone', 'Endereco', 'Latitude', 'Longitude'];
/**
* Ponto de entrada leve.
*/
function doGet(e) {
return HtmlService.createTemplateFromFile('Index')
.evaluate()
.addMetaTag('viewport', 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no')
.setTitle('Gestão de Clientes & Mapa');
}
/**
* Cria a aba e os cabeçalhos se não existirem. Executada apenas nas gravações.
*/
function setupSheet() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
let sheet = ss.getSheetByName(SHEET_NAME);
if (!sheet) {
sheet = ss.insertSheet(SHEET_NAME);
sheet.appendRow(HEADERS);
sheet.setFrozenRows(1);
}
return sheet;
}
/**
* Extrai as coordenadas reais do cliente analisando a URL final.
* Utiliza o método de redirecionamento de cabeçalho extremamente rápido e imune a consentimentos.
* O Regex foi otimizado para pescar qualquer par de coordenadas decimais na URL (seja sob /search/, /place/ ou @).
*/
function extrairCoordenadasDeLink(url) {
try {
let targetUrl = url;
// Se for um link encurtado, expande capturando o cabeçalho Location (rápido e imune a captchas)
if (url.includes("maps.app.goo.gl") || url.includes("goo.gl/maps") || url.includes("g.co")) {
const response = UrlFetchApp.fetch(url, {followRedirects: false, muteHttpExceptions: true});
const headers = response.getHeaders();
const location = headers['Location'] || headers['location'];
if (location) {
targetUrl = decodeURIComponent(location);
} else {
return { error: "Não foi possível descompactar o link curto fornecido." };
}
}
Logger.log("Analisando URL final expandida: " + targetUrl);
let match = targetUrl.match(/([-]?\d+\.\d+),\s*\+?([-]?\d+\.\d+)/);
if (match) {
const lat = parseFloat(match[1]);
const lng = parseFloat(match[2]);
if (!(Math.abs(lat - 37.0625) < 0.001 && Math.abs(lng - -95.677068) < 0.001)) {
Logger.log("✅ Coordenadas extraídas com sucesso: " + lat + ", " + lng);
return { lat: lat, lng: lng };
} else {
Logger.log("Aviso: Par de coordenadas padrão dos EUA ignorado.");
}
}
match = targetUrl.match(/\/place\/([^/]+)/);
if (match && match[1] && match[1].trim() !== "") {
const enderecoTexto = match[1].replace(/\+/g, " "); // Limpa os caracteres "+"
Logger.log("Geocodificando nome do local extraído: " + enderecoTexto);
const geoResponse = Maps.newGeocoder().geocode(enderecoTexto);
if (geoResponse.status === 'OK' && geoResponse.results.length > 0) {
const loc = geoResponse.results[0].geometry.location;
Logger.log("✅ Coordenadas obtidas via fallback de texto: " + loc.lat + ", " + loc.lng);
return { lat: loc.lat, lng: loc.lng };
}
}
} catch (e) {
return { error: "Erro de rede (UrlFetchApp): " + e.message };
}
return null;
}
/**
* Salva o cliente e gerencia as coordenadas com retorno inteligente de erros.
*/
function salvarCliente(clienteData) {
try {
const sheet = setupSheet();
const idCliente = "CLI_" + new Date().getTime();
let lat = "";
let lng = "";
let enderecoFinal = "";
if (clienteData.MapsLink && clienteData.MapsLink.trim() !== "") {
const result = extrairCoordenadasDeLink(clienteData.MapsLink);
if (result && result.error) {
return { success: false, error: result.error };
}
if (result && result.lat) {
lat = result.lat;
lng = result.lng;
enderecoFinal = clienteData.MapsLink;
} else {
return { success: false, error: "Não conseguimos extrair as coordenadas desse link. Verifique o link ou preencha o endereço por extenso." };
}
} else {
const logradouro = clienteData.Logradouro || "";
const bairro = clienteData.Bairro || "";
const cidade = clienteData.Cidade || "";
const estado = clienteData.Estado || "";
enderecoFinal = `${logradouro}, ${bairro} - ${cidade}, ${estado}, Brasil`;
try {
const response = Maps.newGeocoder().geocode(enderecoFinal);
if (response.status === 'OK' && response.results.length > 0) {
lat = response.results[0].geometry.location.lat;
lng = response.results[0].geometry.location.lng;
}
} catch (geoError) {
Logger.log("Erro na API do Geocoder: " + geoError.message);
}
}
sheet.appendRow([idCliente, clienteData.Nome, clienteData.Telefone, enderecoFinal, lat, lng]);
return { success: true };
} catch (e) {
return { success: false, error: "Erro geral no salvamento: " + e.message };
}
}
/**
* Busca a base de clientes cadastrados.
*/
function obterClientes() {
try {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName(SHEET_NAME);
if (!sheet) return [];
const lastRow = sheet.getLastRow();
if (lastRow <= 1) return [];
const data = sheet.getRange(2, 1, lastRow - 1, HEADERS.length).getValues();
return data.map(row => ({
id: row[0],
nome: row[1],
telefone: row[2],
endereco: row[3],
lat: row[4],
lng: row[5]
}));
} catch (e) {
Logger.log("Erro ao carregar clientes: " + e.message);
return [];
}
}
Index.html
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Clientes & Mapa</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap"
rel="stylesheet">
<style>
:root {
--primary: #4f46e5;
--primary-hover: #4338ca;
--background: #f8fafc;
--surface: #ffffff;
--text: #0f172a;
--text-muted: #64748b;
--border: #e2e8f0;
--nav-bg: #1e293b;
--radius: 12px;
--shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Plus Jakarta Sans', sans-serif;
background-color: var(--background);
color: var(--text);
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
header {
background-color: var(--nav-bg);
color: white;
padding: 0 1.5rem;
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
position: fixed;
top: 0;
left: 0;
width: 100%;
z-index: 1000;
box-shadow: var(--shadow);
}
.logo {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 700;
font-size: 1.1rem;
}
.nav-buttons {
display: flex;
gap: 0.5rem;
}
.nav-btn {
background: transparent;
border: none;
color: #94a3b8;
padding: 0.5rem 1rem;
border-radius: 9999px;
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.25rem;
transition: all 0.2s ease;
}
.nav-btn:hover {
color: white;
background-color: rgba(255, 255, 255, 0.1);
}
.nav-btn.active {
color: white;
background-color: var(--primary);
}
main {
margin-top: 64px;
flex-grow: 1;
height: calc(100vh - 64px);
position: relative;
}
.section {
display: none;
height: 100%;
padding: 1.5rem;
overflow-y: auto;
}
/* Animação de transição suave para as telas (SPA) */
@keyframes sectionFadeIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.section.active {
display: flex;
flex-direction: column;
animation: sectionFadeIn 0.45s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.section-header h2 {
font-size: 1.5rem;
font-weight: 700;
}
.btn {
background-color: var(--primary);
color: white;
border: none;
padding: 0.6rem 1.2rem;
border-radius: var(--radius);
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.5rem;
transition: background 0.2s ease, transform 0.1s ease;
}
.btn:hover {
background-color: var(--primary-hover);
}
.btn:active {
transform: scale(0.98);
}
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.card {
background-color: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
gap: 0.5rem;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
}
.card-name {
font-size: 1.1rem;
font-weight: 600;
}
.card-info {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--text-muted);
}
.card-info .material-icons {
font-size: 1rem;
}
.card-link {
color: var(--primary);
text-decoration: none;
font-weight: 500;
}
.card-link:hover {
text-decoration: underline;
}
#map {
width: 100%;
height: 100%;
border-radius: var(--radius);
border: 1px solid var(--border);
}
/* Modais */
.modal-backdrop {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(15, 23, 42, 0.6);
z-index: 2000;
justify-content: center;
align-items: center;
padding: 1rem;
}
.modal-backdrop.open {
display: flex;
}
.modal-content {
background-color: var(--surface);
border-radius: var(--radius);
width: 100%;
max-width: 500px;
box-shadow: var(--shadow);
overflow: hidden;
animation: modalSlide 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes modalSlide {
from {
transform: translateY(10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.modal-header {
padding: 1.25rem;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
font-size: 1.2rem;
font-weight: 700;
}
.close-btn {
background: transparent;
border: none;
cursor: pointer;
color: var(--text-muted);
}
.modal-body {
padding: 1.25rem;
max-height: 70vh;
overflow-y: auto;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.35rem;
font-weight: 600;
font-size: 0.85rem;
}
.form-group input {
width: 100%;
padding: 0.6rem;
border: 1px solid var(--border);
border-radius: 6px;
font-family: inherit;
font-size: 0.9rem;
outline: none;
transition: border 0.2s;
}
.form-group input:focus {
border-color: var(--primary);
}
.form-group input:disabled {
background-color: #f1f5f9;
cursor: not-allowed;
}
.divider-or {
text-align: center;
margin: 1.5rem 0;
font-weight: 700;
font-size: 0.75rem;
color: var(--text-muted);
position: relative;
}
.divider-or span {
background: var(--surface);
padding: 0 12px;
position: relative;
z-index: 2;
}
.divider-or::after {
content: "";
position: absolute;
top: 50%;
left: 0;
width: 100%;
border-top: 1px dashed var(--border);
z-index: 1;
}
.modal-footer {
padding: 1rem 1.25rem;
background-color: #f8fafc;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.loader {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(15, 23, 42, 0.4);
z-index: 3000;
justify-content: center;
align-items: center;
}
.loader.active {
display: flex;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-left-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.toast {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%) translateY(100px);
background-color: #0f172a;
color: white;
padding: 0.75rem 1.5rem;
border-radius: 9999px;
z-index: 4000;
font-weight: 500;
font-size: 0.9rem;
box-shadow: var(--shadow);
opacity: 0;
transition: all 0.3s ease;
}
.toast.show {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
.custom-popup .leaflet-popup-content-wrapper {
border-radius: 8px;
font-family: 'Plus Jakarta Sans', sans-serif;
}
.custom-popup-title {
font-weight: 700;
font-size: 1rem;
margin-bottom: 0.25rem;
}
.custom-popup-phone {
color: var(--text-muted);
font-size: 0.8rem;
margin-bottom: 0.5rem;
}
.whatsapp-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
color: #10b981;
text-decoration: none;
font-weight: 600;
font-size: 0.85rem;
}
.whatsapp-btn:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<header>
<div class="logo">
<span class="material-icons" style="color: var(--primary);">pin_drop</span>
<span>GeoClientes</span>
</div>
<nav class="nav-buttons">
<button class="nav-btn active" id="btn-tab-clientes" onclick="switchSection('clientes')">
<span class="material-icons">people</span> Clientes
</button>
<button class="nav-btn" id="btn-tab-mapa" onclick="switchSection('mapa')">
<span class="material-icons">map</span> Mapa
</button>
</nav>
</header>
<main>
<section id="section-clientes" class="section active">
<div class="section-header">
<h2>Clientes Cadastrados</h2>
<button class="btn" onclick="openModal()">
<span class="material-icons">add</span> Novo Cliente
</button>
</div>
<div class="cards-grid" id="clientes-list"></div>
</section>
<section id="section-mapa" class="section">
<div id="map"></div>
</section>
</main>
<!-- Modal de Cadastro -->
<div class="modal-backdrop" id="cadastro-modal">
<div class="modal-content">
<div class="modal-header">
<h3>Novo Cliente</h3>
<button class="close-btn" onclick="closeModal()">
<span class="material-icons">close</span>
</button>
</div>
<form id="cliente-form" onsubmit="enviarFormulario(event)">
<div class="modal-body">
<div class="form-group">
<label for="Nome">Nome do Cliente</label>
<input type="text" id="Nome" required placeholder="Nome completo">
</div>
<div class="form-group">
<label for="Telefone">Telefone</label>
<input type="tel" id="Telefone" required placeholder="Ex: (11) 00000-0000">
</div>
<!-- OPÇÃO 1: ENDEREÇO ESTRUTURADO (NO TOPO) -->
<div id="grupo-endereco-estruturado">
<div class="form-group">
<label for="Logradouro">Logradouro (Rua, Av, Qd e Lt)</label>
<input type="text" id="Logradouro" >
</div>
<div class="form-group">
<label for="Bairro">Bairro</label>
<input type="text" id="Bairro" >
</div>
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 0.5rem;">
<div class="form-group">
<label for="Cidade">Cidade</label>
<!-- Ajuste os valores padronizados da sua cidade foco -->
<input type="text" id="Cidade" >
</div>
<div class="form-group">
<label for="Estado">Estado</label>
<input type="text" id="Estado" maxlength="2">
</div>
</div>
</div>
<!-- DIVISOR "OU" NO MEIO -->
<div class="divider-or">
<span>OU COLE O LINK DO GOOGLE MAPS</span>
</div>
<!-- OPÇÃO 2: LINK DO GOOGLE MAPS (ABAIXO) -->
<div class="form-group">
<label for="MapsLink" style="color: var(--primary);">Opção 2: Link do Google Maps (Compartilhamento)</label>
<input type="url" id="MapsLink" placeholder="https://maps.app.goo.gl/... ou https://goo.gl..." oninput="restringirCamposEndereco(this.value)">
<small style="color: var(--text-muted); font-size: 0.75rem; display:block; margin-top:0.25rem;">Cole o link compartilhado para 100% de precisão de GPS (ideal para Qd/Lt ou Chácaras).</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn" style="background-color: transparent; color: var(--text-muted);" onclick="closeModal()">Cancelar</button>
<button type="submit" class="btn">Salvar Cliente</button>
</div>
</form>
</div>
</div>
<div class="loader" id="app-loader">
<div class="spinner"></div>
</div>
<div class="toast" id="app-toast"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script>
let clientes = [];
let map = null;
let markersGroup = null;
document.addEventListener("DOMContentLoaded", () => {
carregarClientes();
const ultimaSecao = localStorage.getItem('secaoAtiva') || 'clientes';
switchSection(ultimaSecao);
});
function switchSection(target) {
document.querySelectorAll('.section').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.nav-btn').forEach(el => el.classList.remove('active'));
document.getElementById(`section-${target}`).classList.add('active');
document.getElementById(`btn-tab-${target}`).classList.add('active');
localStorage.setItem('secaoAtiva', target);
if (target === 'mapa') {
renderizarMapa();
}
}
// UX: Se o link do Maps for inserido, os outros campos de endereço ficam desabilitados
function restringirCamposEndereco(linkValue) {
const inputs = document.querySelectorAll("#grupo-endereco-estruturado input");
const isFilled = linkValue.trim() !== "";
inputs.forEach(input => {
input.disabled = isFilled;
if (isFilled) {
input.value = "";
}
});
// Se limpar o link, restaura os padrões da sua cidade
if (!isFilled) {
document.getElementById("Cidade").value = "Londrina";
document.getElementById("Estado").value = "PR";
}
}
function carregarClientes() {
showLoader(true);
google.script.run
.withSuccessHandler(data => {
clientes = data;
atualizarCards();
showLoader(false);
})
.withFailureHandler(err => {
showToast("Erro ao carregar dados: " + err.message);
showLoader(false);
})
.obterClientes();
}
function atualizarCards() {
const listContainer = document.getElementById("clientes-list");
if (clientes.length === 0) {
listContainer.innerHTML = `
<div style="grid-column: 1/-1; text-align: center; padding: 3rem; color: var(--text-muted);">
<span class="material-icons" style="font-size: 3rem; margin-bottom: 0.5rem;">people_outline</span>
<p>Nenhum cliente cadastrado.</p>
</div>
`;
return;
}
listContainer.innerHTML = clientes.map(c => {
// Se o endereço for um link, exibe como um link de redirecionamento interativo
const enderecoExibicao = c.endereco.startsWith("http")
? `<a href="${c.endereco}" target="_blank" class="card-link">Localização Compartilhada (Link)</a>`
: escapeHtml(c.endereco);
return `
<div class="card">
<div class="card-name">${escapeHtml(c.nome)}</div>
<div class="card-info">
<span class="material-icons">phone</span>
<span>${escapeHtml(c.telefone)}</span>
</div>
<div class="card-info">
<span class="material-icons">place</span>
<span>${enderecoExibicao}</span>
</div>
</div>
`;
}).join('');
}
function renderizarMapa() {
if (!map) {
map = L.map('map').setView([-15.78, -47.92], 4);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap'
}).addTo(map);
markersGroup = L.layerGroup().addTo(map);
}
setTimeout(() => {
map.invalidateSize();
plotarMarcadores();
}, 180);
}
function plotarMarcadores() {
if (!markersGroup) return;
markersGroup.clearLayers();
const validPoints = [];
clientes.forEach(c => {
if (c.lat && c.lng) {
const latNum = parseFloat(c.lat);
const lngNum = parseFloat(c.lng);
if (!isNaN(latNum) && !isNaN(lngNum)) {
const whatsLink = "https://wa.me/55" + c.telefone.replace(/\D/g, "");
const popupContent = `
<div class="custom-popup">
<div class="custom-popup-title">${escapeHtml(c.nome)}</div>
<div class="custom-popup-phone">${escapeHtml(c.telefone)}</div>
<a href="${whatsLink}" target="_blank" class="whatsapp-btn">
<span class="material-icons" style="font-size: 1rem; vertical-align: middle;">chat</span> WhatsApp
</a>
</div>
`;
const marker = L.marker([latNum, lngNum]).bindPopup(popupContent);
markersGroup.addLayer(marker);
validPoints.push([latNum, lngNum]);
}
}
});
if (validPoints.length > 0) {
const bounds = L.latLngBounds(validPoints);
map.flyToBounds(bounds, {
padding: [40, 40],
duration: 2.0
});
}
}
function enviarFormulario(e) {
e.preventDefault();
const mapsLink = document.getElementById("MapsLink").value.trim();
const logradouro = document.getElementById("Logradouro").value.trim();
const bairro = document.getElementById("Bairro").value.trim();
const cidade = document.getElementById("Cidade").value.trim();
const estado = document.getElementById("Estado").value.trim();
// Validação: Exige um ou outro método de endereço
if (!mapsLink && (!logradouro || !bairro || !cidade || !estado)) {
showToast("Por favor, cole um link do Google Maps OU preencha o endereço completo!");
return;
}
const payload = {
Nome: document.getElementById("Nome").value.trim(),
Telefone: document.getElementById("Telefone").value.trim(),
MapsLink: mapsLink,
Logradouro: logradouro,
Bairro: bairro,
Cidade: cidade,
Estado: estado
};
showLoader(true);
closeModal();
google.script.run
.withSuccessHandler(res => {
if (res.success) {
showToast("Cliente salvo com sucesso!");
document.getElementById("cliente-form").reset();
restringirCamposEndereco(""); // Restaura inputs de endereço
carregarClientes();
} else {
showToast(res.error);
showLoader(false);
}
})
.withFailureHandler(err => {
showToast("Erro de comunicação: " + err.message);
showLoader(false);
})
.salvarCliente(payload);
}
function openModal() {
document.getElementById("cadastro-modal").classList.add("open");
setTimeout(() => document.getElementById("Nome").focus(), 150);
}
function closeModal() {
document.getElementById("cadastro-modal").classList.remove("open");
}
function showLoader(show) {
const loader = document.getElementById("app-loader");
if (show) loader.classList.add("active");
else loader.classList.remove("active");
}
function showToast(message) {
const toast = document.getElementById("app-toast");
toast.innerText = message;
toast.classList.add("show");
setTimeout(() => toast.classList.remove("show"), 3500);
}
function escapeHtml(text) {
if (!text) return "";
return String(text)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
</script>
</body>
</html>