Files
C_UAS_WEBGIS_AndrieWijaya_D…/01/index.html
T
2026-06-11 18:14:21 +07:00

1230 lines
35 KiB
HTML

<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGIS — Point of Interest Pontianak</title>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
rel="stylesheet">
<style>
/* ── Reset & Base ─────────────────────────────── */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--c-bg: #fafaf9;
--c-surface: #ede8e2;
--c-surface2: #e5dfd8;
--c-border: #ddd8d2;
--c-text: #201515;
--c-muted: #7a7067;
--c-accent: #0d7490;
--c-accent-h: #0a5f7a;
--c-danger: #ef4444;
--c-warn: #f59e0b;
--c-wa: #25d366;
--c-wa-h: #1fad53;
--radius: 12px;
--shadow: 0 4px 12px rgba(32, 21, 21, .08);
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: var(--font-body);
}
html,
body {
height: 100%;
font-family: var(--font-body);
background: var(--c-bg);
color: var(--c-text);
overflow: hidden;
}
/* ── Layout ──────────────────────────────────── */
.app-wrapper {
display: flex;
flex-direction: column;
height: 100vh;
}
/* ── Header ──────────────────────────────────── */
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: 56px;
background: var(--c-surface);
border-bottom: 1px solid var(--c-border);
flex-shrink: 0;
gap: 16px;
z-index: 1000;
}
.app-logo {
display: flex;
align-items: center;
gap: 10px;
}
.app-logo svg {
flex-shrink: 0;
}
.app-logo-title {
font-size: 15px;
font-weight: 700;
letter-spacing: -.3px;
white-space: nowrap;
}
.app-logo-sub {
font-size: 11px;
color: var(--c-muted);
font-family: var(--font-mono);
white-space: nowrap;
}
.header-badge {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
background: rgba(13, 116, 144, .12);
border: 1px solid rgba(13, 116, 144, .3);
border-radius: 20px;
font-size: 12px;
font-weight: 600;
color: var(--c-accent-h);
font-family: var(--font-mono);
}
.header-badge .dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--c-accent);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: .5;
transform: scale(.8);
}
}
.header-hint {
font-size: 12px;
color: var(--c-muted);
display: flex;
align-items: center;
gap: 6px;
}
.header-hint kbd {
background: var(--c-border);
border-radius: 4px;
padding: 2px 6px;
font-family: var(--font-mono);
font-size: 11px;
}
/* ── Map Container ───────────────────────────── */
#map {
flex: 1;
width: 100%;
cursor: crosshair;
}
/* ── Counter Badge ───────────────────────────── */
.poi-counter {
position: fixed;
bottom: 28px;
left: 20px;
z-index: 999;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: 20px;
font-size: 13px;
font-family: var(--font-mono);
color: var(--c-muted);
box-shadow: var(--shadow);
transition: all .3s ease;
}
.poi-counter strong {
color: var(--c-accent-h);
font-size: 15px;
}
/* ── Leaflet Popup Override ───────────────────── */
.leaflet-popup-content-wrapper {
background: var(--c-surface) !important;
border: 1px solid var(--c-border) !important;
border-radius: var(--radius) !important;
box-shadow: var(--shadow) !important;
padding: 0 !important;
color: var(--c-text) !important;
min-width: 280px;
}
.leaflet-popup-content {
margin: 0 !important;
width: 100% !important;
}
.leaflet-popup-tip-container {
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, .4));
}
.leaflet-popup-tip {
background: var(--c-border) !important;
}
.leaflet-popup-close-button {
color: var(--c-muted) !important;
font-size: 20px !important;
top: 10px !important;
right: 10px !important;
width: 24px !important;
height: 24px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
line-height: 1 !important;
}
/* ── Form Popup ───────────────────────────────── */
.form-popup {
padding: 20px;
}
.form-popup-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
padding-bottom: 14px;
border-bottom: 1px solid var(--c-border);
}
.form-popup-icon {
width: 36px;
height: 36px;
border-radius: 8px;
background: rgba(13, 116, 144, .15);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.form-popup-title {
font-size: 14px;
font-weight: 700;
}
.form-popup-coords {
font-size: 10px;
color: var(--c-muted);
font-family: var(--font-mono);
margin-top: 2px;
}
.form-group {
margin-bottom: 14px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 500;
color: #3d3530;
margin-bottom: 6px;
}
.form-group input[type="text"],
.form-group input[type="tel"],
.form-group select {
width: 100%;
padding: 8px 12px;
background: var(--c-bg);
border: 1px solid var(--c-border);
border-radius: 7px;
color: var(--c-text);
font-family: var(--font-body);
font-size: 13px;
transition: border-color .2s;
outline: none;
}
.form-group input:focus,
.form-group select:focus {
border-color: var(--c-accent);
}
.form-group input::placeholder {
color: var(--c-muted);
}
/* Radio group */
.radio-group {
display: flex;
gap: 8px;
}
.radio-option {
flex: 1;
position: relative;
}
.radio-option input[type="radio"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.radio-option label {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px;
background: var(--c-bg);
border: 1px solid var(--c-border);
border-radius: 7px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
color: var(--c-muted);
text-transform: none;
letter-spacing: 0;
transition: all .2s;
margin-bottom: 0;
}
.radio-option input:checked+label {
border-color: var(--c-accent);
color: var(--c-accent-h);
background: rgba(13, 116, 144, .1);
}
.btn-save {
width: 100%;
padding: 10px;
background: var(--c-accent);
color: #fff;
border: none;
border-radius: 8px;
font-family: var(--font-body);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background .2s;
margin-top: 4px;
}
.btn-save:hover {
background: var(--c-accent-h);
}
.btn-save:disabled {
opacity: .5;
cursor: not-allowed;
}
.form-status {
font-size: 12px;
text-align: center;
margin-top: 8px;
min-height: 18px;
font-family: var(--font-mono);
}
.form-status.error {
color: var(--c-danger);
}
.form-status.success {
color: var(--c-accent);
}
/* ── Info Popup ───────────────────────────────── */
.info-popup {
padding: 18px 20px 16px;
}
.info-popup-header {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 14px;
}
.info-popup-icon {
width: 38px;
height: 38px;
border-radius: 8px;
background: rgba(13, 116, 144, .15);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 18px;
}
.info-popup-name {
font-size: 15px;
font-weight: 700;
line-height: 1.3;
}
.info-popup-id {
font-size: 10px;
color: var(--c-muted);
font-family: var(--font-mono);
margin-top: 2px;
}
.info-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-top: 1px solid var(--c-border);
font-size: 13px;
}
.info-row-icon {
width: 28px;
height: 28px;
border-radius: 6px;
background: rgba(0, 0, 0, .04);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
}
.info-row-label {
font-size: 10px;
color: var(--c-muted);
text-transform: uppercase;
letter-spacing: .5px;
font-weight: 600;
}
.info-row-value {
font-weight: 500;
}
.badge-open {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 700;
}
.badge-open.yes {
background: rgba(13, 116, 144, .15);
color: var(--c-accent-h);
border: 1px solid rgba(13, 116, 144, .3);
}
.badge-open.no {
background: rgba(239, 68, 68, .12);
color: var(--c-danger);
border: 1px solid rgba(239, 68, 68, .3);
}
.btn-wa {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
margin-top: 14px;
padding: 9px;
background: var(--c-wa);
color: #fff;
border: none;
border-radius: 7px;
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
text-decoration: none;
transition: background .2s;
}
.btn-wa:hover {
background: var(--c-wa-h);
}
.btn-hapus {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
margin-top: 8px;
padding: 9px;
background: transparent;
color: var(--c-danger);
border: 1px solid rgba(239, 68, 68, .4);
border-radius: 7px;
font-family: var(--font-body);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all .2s;
}
.btn-hapus:hover {
background: rgba(239, 68, 68, .08);
border-color: #ef4444;
}
/* ── Notification Toast ───────────────────────── */
.toast {
position: fixed;
top: 70px;
right: 20px;
z-index: 9999;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 18px;
background: var(--c-surface);
border: 1px solid var(--c-border);
border-left: 3px solid var(--c-accent);
border-radius: var(--radius);
box-shadow: var(--shadow);
font-size: 13px;
font-weight: 500;
max-width: 320px;
transform: translateX(360px);
opacity: 0;
transition: all .35s cubic-bezier(.34, 1.56, .64, 1);
}
.toast.show {
transform: translateX(0);
opacity: 1;
}
.toast.error {
border-left-color: var(--c-danger);
}
/* ── Custom Confirm Modal ─────────────────────── */
.confirm-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, .55);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
z-index: 10000;
opacity: 0;
pointer-events: none;
transition: opacity .2s ease;
}
.confirm-overlay.show {
opacity: 1;
pointer-events: auto;
}
.confirm-dialog {
width: min(420px, 100%);
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: 12px;
box-shadow: var(--shadow);
overflow: hidden;
}
.confirm-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--c-border);
font-weight: 600;
}
.confirm-icon {
width: 30px;
height: 30px;
border-radius: 8px;
background: rgba(239, 68, 68, .15);
display: flex;
align-items: center;
justify-content: center;
color: var(--c-danger);
}
.confirm-body {
padding: 16px;
font-size: 14px;
line-height: 1.5;
color: var(--c-text);
}
.confirm-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
padding: 14px 16px;
border-top: 1px solid var(--c-border);
}
.btn-confirm-cancel,
.btn-confirm-delete {
padding: 8px 14px;
border-radius: 8px;
font-family: var(--font-body);
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: all .2s;
}
.btn-confirm-cancel {
border: 1px solid var(--c-border);
background: transparent;
color: var(--c-text);
}
.btn-confirm-cancel:hover {
background: rgba(255, 255, 255, .05);
}
.btn-confirm-delete {
border: 1px solid #ef4444;
background: rgba(239, 68, 68, .08);
color: #ef4444;
}
.btn-confirm-delete:hover {
background: rgba(239, 68, 68, .15);
}
/* ── Leaflet Light Attribution ─────────────────── */
.leaflet-control-attribution {
background: rgba(250, 250, 249, .88) !important;
color: var(--c-muted) !important;
font-size: 10px !important;
}
.leaflet-control-attribution a {
color: #3d3530 !important;
}
.leaflet-control-zoom a {
background: var(--c-surface) !important;
color: var(--c-text) !important;
border-color: var(--c-border) !important;
}
.leaflet-control-zoom a:hover {
background: #ede8e2 !important;
}
/* ── Loading Overlay ──────────────────────────── */
.loading-overlay {
position: fixed;
inset: 0;
background: var(--c-bg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
z-index: 99999;
transition: opacity .4s ease;
}
.loading-overlay.hide {
opacity: 0;
pointer-events: none;
}
.loader-ring {
width: 48px;
height: 48px;
border: 3px solid var(--c-border);
border-top-color: var(--c-accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
font-family: var(--font-mono);
font-size: 13px;
color: var(--c-muted);
}
</style>
</head>
<body>
<!-- Loading Overlay -->
<div class="loading-overlay" id="loadingOverlay">
<div class="loader-ring"></div>
<div class="loading-text">Memuat peta...</div>
</div>
<!-- Toast Notification -->
<div class="toast" id="toast">
<span id="toastIcon"></span>
<span id="toastMsg"></span>
</div>
<!-- Delete Confirm Modal -->
<div class="confirm-overlay" id="deleteConfirmOverlay" aria-hidden="true">
<div class="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="deleteConfirmTitle">
<div class="confirm-header">
<div class="confirm-icon">🗑</div>
<div id="deleteConfirmTitle">Hapus Lokasi</div>
</div>
<div class="confirm-body" id="deleteConfirmMessage">
Yakin ingin menghapus lokasi ini?
</div>
<div class="confirm-actions">
<button type="button" class="btn-confirm-cancel" id="btnDeleteCancel">Batal</button>
<button type="button" class="btn-confirm-delete" id="btnDeleteConfirm">Ya, Hapus</button>
</div>
</div>
</div>
<!-- App Wrapper -->
<div class="app-wrapper">
<!-- Header -->
<header class="app-header">
<div class="app-logo">
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="30" height="30" rx="7" fill="rgba(13,116,144,.15)" />
<path
d="M15 5C11.134 5 8 8.134 8 12C8 17.25 15 25 15 25C15 25 22 17.25 22 12C22 8.134 18.866 5 15 5ZM15 15C13.343 15 12 13.657 12 12C12 10.343 13.343 9 15 9C16.657 9 18 10.343 18 12C18 13.657 16.657 15 15 15Z"
fill="#0d7490" />
</svg>
<div>
<div class="app-logo-title">WebGIS POI</div>
<div class="app-logo-sub">Pontianak · Leaflet.js</div>
</div>
</div>
<div class="header-hint">
<kbd>Klik</kbd> pada peta untuk menambah titik lokasi
</div>
<div class="header-badge">
<div class="dot"></div>
<span id="headerCount">0</span> POI
</div>
</header>
<!-- Map -->
<div id="map"></div>
</div>
<!-- POI Counter -->
<div class="poi-counter" id="poiCounter">
📍 Total: <strong id="totalPoi">0</strong> lokasi
</div>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script>
// ══════════════════════════════════════════════════
// INISIALISASI PETA
// ══════════════════════════════════════════════════
const CENTER_LAT = -0.0557;
const CENTER_LNG = 109.3487;
const ZOOM_LEVEL = 15;
const map = L.map('map', {
center: [CENTER_LAT, CENTER_LNG],
zoom: ZOOM_LEVEL,
zoomControl: true
});
// Base Layer: OpenStreetMap
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
// ══════════════════════════════════════════════════
// CUSTOM MARKER ICON
// ══════════════════════════════════════════════════
function createCustomIcon(color = '#2ea043') {
return L.divIcon({
className: '',
html: `
<svg width="28" height="38" viewBox="0 0 28 38" xmlns="http://www.w3.org/2000/svg">
<filter id="shadow" x="-30%" y="-10%" width="160%" height="160%">
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-opacity="0.4"/>
</filter>
<path d="M14 0C6.268 0 0 6.268 0 14C0 24.5 14 38 14 38C14 38 28 24.5 28 14C28 6.268 21.732 0 14 0Z"
fill="${color}" filter="url(#shadow)"/>
<circle cx="14" cy="14" r="6" fill="white" opacity="0.9"/>
</svg>`,
iconSize: [28, 38],
iconAnchor: [14, 38],
popupAnchor: [0, -40]
});
}
const defaultIcon = createCustomIcon('#2ea043');
const tempIcon = createCustomIcon('#e3b341');
// ══════════════════════════════════════════════════
// UTILITY: TOAST NOTIFICATION
// ══════════════════════════════════════════════════
let toastTimer;
function showToast(msg, type = 'success') {
const toast = document.getElementById('toast');
const toastMsg = document.getElementById('toastMsg');
const toastIcon = document.getElementById('toastIcon');
toastMsg.textContent = msg;
toastIcon.textContent = type === 'success' ? '✓' : '✕';
toast.className = 'toast ' + type + ' show';
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toast.className = 'toast ' + type; }, 3500);
}
// ══════════════════════════════════════════════════
// UPDATE COUNTER
// ══════════════════════════════════════════════════
let markerCount = 0;
const allMarkers = {}; // registry semua marker: { id: markerObject }
let deleteConfirmResolver = null;
function updateCount(n) {
markerCount = n;
document.getElementById('totalPoi').textContent = n;
document.getElementById('headerCount').textContent = n;
}
function setupDeleteConfirmModal() {
const overlay = document.getElementById('deleteConfirmOverlay');
const messageEl = document.getElementById('deleteConfirmMessage');
const btnCancel = document.getElementById('btnDeleteCancel');
const btnConfirm = document.getElementById('btnDeleteConfirm');
function closeModal(result) {
overlay.classList.remove('show');
overlay.setAttribute('aria-hidden', 'true');
if (deleteConfirmResolver) {
deleteConfirmResolver(result);
deleteConfirmResolver = null;
}
}
btnCancel.addEventListener('click', () => closeModal(false));
btnConfirm.addEventListener('click', () => closeModal(true));
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeModal(false);
});
document.addEventListener('keydown', (e) => {
if (!overlay.classList.contains('show')) return;
if (e.key === 'Escape') closeModal(false);
});
window.showDeleteConfirm = (text) => {
messageEl.textContent = text;
overlay.classList.add('show');
overlay.setAttribute('aria-hidden', 'false');
setTimeout(() => btnConfirm.focus(), 0);
return new Promise((resolve) => {
deleteConfirmResolver = resolve;
});
};
}
// ══════════════════════════════════════════════════
// FUNGSI: LOAD MARKERS dari database
// ══════════════════════════════════════════════════
function loadMarkers() {
fetch('ambil_data.php?_=' + Date.now(), { cache: 'no-store' })
.then(res => {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
})
.then(json => {
if (json.status !== 'success') throw new Error(json.message);
updateCount(json.total);
json.data.forEach(poi => addMarkerToMap(poi));
// Sembunyikan loading overlay
const overlay = document.getElementById('loadingOverlay');
overlay.classList.add('hide');
})
.catch(err => {
console.error('Gagal memuat marker:', err);
document.getElementById('loadingOverlay').classList.add('hide');
showToast('Gagal memuat data dari server.', 'error');
});
}
// ══════════════════════════════════════════════════
// FUNGSI: Tambah Satu Marker ke Peta
// ══════════════════════════════════════════════════
function addMarkerToMap(poi) {
const isOpen24h = Number(poi.buka_24jam) === 1;
const icon = isOpen24h ? createCustomIcon('#2ea043') : createCustomIcon('#f85149');
const marker = L.marker([poi.latitude, poi.longitude], { icon, draggable: true }).addTo(map);
marker._poiId = poi.id; // simpan ID di objek marker
allMarkers[poi.id] = marker; // daftarkan ke registry global
marker.on('dragend', function (e) {
const { lat, lng } = e.target.getLatLng();
const formData = new FormData();
formData.append('id', poi.id);
formData.append('latitude', lat.toFixed(8));
formData.append('longitude', lng.toFixed(8));
fetch('update_posisi.php', { method: 'POST', body: formData })
.then(async res => {
const text = await res.text();
let json;
try {
json = JSON.parse(text);
} catch {
throw new Error('Respons server tidak valid: ' + text.slice(0, 120));
}
if (!res.ok) {
throw new Error(json.message || ('HTTP ' + res.status));
}
return json;
})
.then(json => {
if (json.status === 'success') {
showToast(`Posisi "${poi.nama_tempat}" diperbarui!`);
} else {
throw new Error(json.message || 'Gagal memperbarui posisi.');
}
})
.catch((err) => showToast(err.message || 'Gagal terhubung ke server.', 'error'));
});
// Siapkan link WhatsApp
const noWaBersih = poi.no_wa ? poi.no_wa.replace(/\D/g, '') : '';
const waLink = noWaBersih
? `<a class="btn-wa" href="https://wa.me/${noWaBersih}" target="_blank" rel="noopener">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/></svg>
Chat WhatsApp
</a>`
: `<div style="font-size:12px;color:var(--c-muted);text-align:center;padding:8px 0;">Tidak ada nomor WA</div>`;
const buka24Badge = isOpen24h
? `<span class="badge-open yes">🟢 Buka 24 Jam</span>`
: `<span class="badge-open no">🔴 Tidak 24 Jam</span>`;
const popupHTML = `
<div class="info-popup">
<div class="info-popup-header">
<div class="info-popup-icon">📍</div>
<div>
<div class="info-popup-name">${escapeHTML(poi.nama_tempat)}</div>
<div class="info-popup-id">#${poi.id}</div>
</div>
</div>
<div class="info-row">
<div class="info-row-icon">📞</div>
<div>
<div class="info-row-label">No. WhatsApp</div>
<div class="info-row-value">${poi.no_wa ? escapeHTML(poi.no_wa) : '—'}</div>
</div>
</div>
<div class="info-row">
<div class="info-row-icon">🕐</div>
<div>
<div class="info-row-label">Jam Operasional</div>
<div class="info-row-value">${buka24Badge}</div>
</div>
</div>
<div class="info-row">
<div class="info-row-icon">🌐</div>
<div>
<div class="info-row-label">Koordinat</div>
<div class="info-row-value" style="font-family:var(--font-mono);font-size:11px;">
${poi.latitude.toFixed(6)}, ${poi.longitude.toFixed(6)}
</div>
</div>
</div>
${waLink}
<button class="btn-hapus" onclick="hapusMarker(${poi.id}, this)">
🗑 Hapus Lokasi
</button>
</div>`;
marker.bindPopup(popupHTML, { maxWidth: 300 });
}
// ══════════════════════════════════════════════════
// FUNGSI: Klik Peta → Popup Form Input
// ══════════════════════════════════════════════════
let tempMarker = null;
map.on('click', function (e) {
const lat = e.latlng.lat.toFixed(8);
const lng = e.latlng.lng.toFixed(8);
// Hapus marker sementara sebelumnya jika ada
if (tempMarker) map.removeLayer(tempMarker);
tempMarker = L.marker([lat, lng], { icon: tempIcon }).addTo(map);
const formHTML = `
<div class="form-popup">
<div class="form-popup-header">
<div class="form-popup-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#0d7490" stroke-width="2.5">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/>
<line x1="8" y1="12" x2="16" y2="12"/>
</svg>
</div>
<div>
<div class="form-popup-title">Tambah Lokasi Baru</div>
<div class="form-popup-coords">${lat}, ${lng}</div>
</div>
</div>
<div class="form-group">
<label>Nama Tempat *</label>
<input type="text" id="f_nama" placeholder="cth. Warung Kopi Aroma" maxlength="100" autocomplete="off">
</div>
<div class="form-group">
<label>No. WhatsApp</label>
<input type="tel" id="f_wa" placeholder="628xxxxxxxxxx" maxlength="15" autocomplete="off">
</div>
<div class="form-group">
<label>Buka 24 Jam?</label>
<div class="radio-group">
<div class="radio-option">
<input type="radio" name="buka24" id="r_ya" value="1">
<label for="r_ya">✅ Ya</label>
</div>
<div class="radio-option">
<input type="radio" name="buka24" id="r_tidak" value="0" checked>
<label for="r_tidak">❌ Tidak</label>
</div>
</div>
</div>
<input type="hidden" id="f_lat" value="${lat}">
<input type="hidden" id="f_lng" value="${lng}">
<button class="btn-save" id="btnSimpan" onclick="simpanData()">
💾 Simpan Lokasi
</button>
<div class="form-status" id="formStatus"></div>
</div>`;
L.popup({ maxWidth: 320, closeOnClick: false })
.setLatLng(e.latlng)
.setContent(formHTML)
.openOn(map);
// Focus pada input nama
setTimeout(() => {
const el = document.getElementById('f_nama');
if (el) el.focus();
}, 200);
});
// ══════════════════════════════════════════════════
// FUNGSI: Simpan Data via Fetch API
// ══════════════════════════════════════════════════
function simpanData() {
const nama = document.getElementById('f_nama').value.trim();
const wa = document.getElementById('f_wa').value.trim();
const buka24 = document.querySelector('input[name="buka24"]:checked')?.value ?? '0';
const lat = document.getElementById('f_lat').value;
const lng = document.getElementById('f_lng').value;
const status = document.getElementById('formStatus');
const btn = document.getElementById('btnSimpan');
// Validasi sisi klien
if (!nama) {
status.textContent = '⚠ Nama tempat wajib diisi.';
status.className = 'form-status error';
document.getElementById('f_nama').focus();
return;
}
btn.disabled = true;
btn.textContent = '⏳ Menyimpan...';
status.textContent = '';
const formData = new FormData();
formData.append('nama_tempat', nama);
formData.append('no_wa', wa);
formData.append('buka_24jam', buka24);
formData.append('latitude', lat);
formData.append('longitude', lng);
fetch('simpan.php', { method: 'POST', body: formData })
.then(res => {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
})
.then(json => {
if (json.status === 'success') {
// Tutup popup & hapus marker sementara
map.closePopup();
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
// Tambahkan marker permanen ke peta
addMarkerToMap(json.data);
updateCount(markerCount + 1);
showToast(`"${nama}" berhasil disimpan!`);
} else {
throw new Error(json.message || 'Gagal menyimpan.');
}
})
.catch(err => {
status.textContent = '✕ ' + err.message;
status.className = 'form-status error';
btn.disabled = false;
btn.textContent = '💾 Simpan Lokasi';
});
}
function hapusMarker(id, btnEl) {
showDeleteConfirm('Yakin ingin menghapus lokasi ini?')
.then((confirmed) => {
if (!confirmed) return;
btnEl.disabled = true;
btnEl.textContent = '⏳ Menghapus...';
const formData = new FormData();
formData.append('id', id);
return fetch('hapus.php', { method: 'POST', body: formData })
.then(async res => {
const text = await res.text();
let json;
try {
json = JSON.parse(text);
} catch {
throw new Error('Respons server tidak valid: ' + text.slice(0, 120));
}
if (!res.ok) {
throw new Error(json.message || ('HTTP ' + res.status));
}
return json;
})
.then(json => {
if (json.status === 'success') {
map.closePopup();
if (allMarkers[id]) {
map.removeLayer(allMarkers[id]);
delete allMarkers[id];
}
updateCount(Math.max(0, markerCount - 1));
showToast('Lokasi berhasil dihapus.');
} else {
throw new Error(json.message || 'Gagal menghapus lokasi.');
}
});
})
.catch((err) => {
showToast(err.message || 'Gagal terhubung ke server.', 'error');
btnEl.disabled = false;
btnEl.textContent = '🗑 Hapus Lokasi';
});
}
// ══════════════════════════════════════════════════
// UTILITY: Escape HTML
// ══════════════════════════════════════════════════
function escapeHTML(str) {
const div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
// Izinkan Enter untuk submit form popup
document.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && document.getElementById('btnSimpan')) {
simpanData();
}
});
// ══════════════════════════════════════════════════
// BOOT: Muat semua marker saat halaman siap
// ══════════════════════════════════════════════════
setupDeleteConfirmModal();
loadMarkers();
</script>
</body>
</html>