store-pulse / index.html
tbdavid2019's picture
✨ 新增距離計算功能,優化搜尋結果排序及顯示距離資訊
454ef0a
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="搜尋與比較連鎖店面的 Google Maps 評價,快速掌握各分店聲量">
<title>[店面脈搏]]多店面評價速查 Store Pulse - david888 </title>
<!-- PWA 支援 -->
<link rel="manifest" href="manifest.json">
<meta name="theme-color" content="#ff6f61">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Store Pulse">
<!-- 圖標 -->
<link rel="icon" type="image/x-icon" href="icons/favicon.ico">
<link rel="icon" type="image/svg+xml" href="icons/favicon.svg">
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="192x192" href="icons/icon-192x192.png">
<link rel="icon" type="image/png" sizes="512x512" href="icons/icon-512x512.png">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://tbdavid2019-store-pulse.hf.space">
<meta property="og:title" content="Store Pulse 店面脈搏">
<meta property="og:description" content="搜尋與比較連鎖店面的 Google Maps 評價,快速掌握各分店聲量">
<meta property="og:image" content="icons/icon-512x512.png">
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:url" content="https://tbdavid2019-store-pulse.hf.space">
<meta property="twitter:title" content="Store Pulse 店面脈搏">
<meta property="twitter:description" content="搜尋與比較連鎖店面的 Google Maps 評價,快速掌握各分店聲量">
<meta property="twitter:image" content="icons/icon-512x512.png">
<style>
body {
font-family: "Segoe UI", sans-serif;
margin: 0;
background: #fff1e0;
}
header {
background: linear-gradient(to right, #fff4e6, #ff6f61);
color: white;
padding: 2rem;
margin: 2rem auto 0;
width: 90%;
max-width: 1000px;
border-radius: 20px;
text-align: center;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
}
header h2 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.container {
background: white;
margin: 2rem auto;
padding: 2rem;
width: 90%;
max-width: 1000px;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
}
#controls {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1rem;
align-items: center;
justify-content: space-between;
}
input, select, button {
padding: 10px;
font-size: 1rem;
border: 1px solid #ccc;
border-radius: 8px;
}
button {
background: linear-gradient(to right, #667eea, #764ba2);
color: white;
cursor: pointer;
border: none;
}
#map {
height: 400px;
margin-top: 1rem;
border-radius: 12px;
}
#results {
margin-top: 2rem;
}
.card-container {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
margin-top: 1rem;
}
.card {
background: #fff;
border-radius: 10px;
padding: 1rem;
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
transition: transform 0.3s ease;
cursor: pointer;
}
.card:hover, .card.active {
transform: scale(1.05);
box-shadow: 0 6px 15px rgba(0,0,0,0.2);
}
.card h4 {
margin: 0 0 0.5rem 0;
}
.card p {
margin: 0.25rem 0;
}
.reviews-container {
margin-top: 1rem;
border-top: 1px solid #eee;
padding-top: 1rem;
}
.reviews-container h5 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
}
.reviews-container ul {
list-style: none;
padding: 0;
margin: 0;
}
.reviews-container li {
margin-bottom: 1rem;
font-size: 0.9rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #f5f5f5;
}
.reviews-container li:last-child {
border-bottom: none;
}
.reviews-container li p {
margin: 0.2rem 0;
}
.low-rating-review {
background-color: #ff6f61;
border: 1px solid #ffdde1;
padding: 0.5rem;
border-radius: 8px;
}
.custom-infowindow {
background: url('https://raw.githubusercontent.com/elvis860812/image-assets/main/custom-speech-bubble.png') no-repeat center center;
background-size: contain;
width: 240px;
height: 140px;
display: flex;
flex-direction: column;
justify-content: center;
padding: 1.5rem;
font-size: 14px;
font-weight: bold;
position: relative;
text-align: center;
}
.custom-infowindow .close-btn {
position: absolute;
top: 6px;
right: 10px;
cursor: pointer;
font-weight: bold;
font-size: 18px;
}
/* Loader styles */
.loader-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.loader-content {
text-align: center;
padding: 2rem;
background: #fff;
border-radius: 15px;
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
.spinner {
border: 8px solid #f3f3f3; /* Light grey */
border-top: 8px solid #ff6f61; /* Primary color */
border-radius: 50%;
width: 60px;
height: 60px;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<header>
<h2>🍽️ 多店面管理評價速查</h2>
<p>輸入店名,即時掌握各分店評價</p>
</header>
<div id="loader" class="loader-overlay" style="display: none;">
<div class="loader-content">
<div class="spinner"></div>
<p>正在搜尋,請稍候...</p>
</div>
</div>
<div class="container">
<div id="controls">
<input id="searchQuery" type="text" placeholder="請輸入店家名稱(例:麥當勞)" style="flex:1;">
<button onclick="searchStores()">搜尋</button>
</div>
<div id="map"></div>
<div id="results">
<h3>推薦店家</h3>
<div class="card-container" id="restaurantCards">
<p>請輸入地址開始搜尋附近餐廳</p>
</div>
</div>
</div>
<script>
let map, geocoder, placesService; // 將 placesService 宣告在更廣的作用域
const markers = [];
const markerMap = new Map();
let activeInfoWindow = null;
let userLocation = null;
const EARTH_RADIUS_METERS = 6371000;
function toRadians(degrees) {
return degrees * (Math.PI / 180);
}
function calculateDistanceMeters(origin, destination) {
if (!origin || !destination) {
return Number.POSITIVE_INFINITY;
}
const lat1 = origin.lat;
const lng1 = origin.lng;
const lat2 = typeof destination.lat === 'function' ? destination.lat() : destination.lat;
const lng2 = typeof destination.lng === 'function' ? destination.lng() : destination.lng;
if ([lat1, lng1, lat2, lng2].some(value => typeof value !== 'number')) {
return Number.POSITIVE_INFINITY;
}
const dLat = toRadians(lat2 - lat1);
const dLng = toRadians(lng2 - lng1);
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return EARTH_RADIUS_METERS * c;
}
function initMap() {
const defaultLoc = { lat: 25.0330, lng: 121.5654 }; // 預設台北市中心
map = new google.maps.Map(document.getElementById("map"), {
zoom: 12,
center: defaultLoc
});
geocoder = new google.maps.Geocoder();
placesService = new google.maps.places.PlacesService(map);
// 頁面載入時請求 GPS 位置
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(position => {
userLocation = {
lat: position.coords.latitude,
lng: position.coords.longitude
};
map.setCenter(userLocation);
map.setZoom(15);
new google.maps.Marker({ map, position: userLocation, icon: 'https://maps.google.com/mapfiles/ms/icons/blue-dot.png', title: '您的位置' });
}, () => {
console.log("使用者拒絕提供位置資訊。");
}, {
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 300000
});
}
// 讓使用者可以在輸入框中按下 Enter 鍵來觸發搜尋
document.getElementById('searchQuery').addEventListener('keyup', function(event) {
if (event.key === 'Enter') {
event.preventDefault(); // 防止可能的表單提交行為
searchStores();
}
});
}
function searchStores() {
const query = document.getElementById("searchQuery").value;
if (!query) return alert("請輸入店家名稱");
const loader = document.getElementById('loader');
loader.style.display = 'flex';
// 清除之前的結果
const container = document.getElementById("restaurantCards");
container.innerHTML = "";
markers.forEach(m => m.setMap(null));
markers.length = 0;
markerMap.clear();
if (activeInfoWindow) {
activeInfoWindow.close();
activeInfoWindow = null;
}
// 使用 Google Places Text Search
const request = {
query: query,
location: userLocation || new google.maps.LatLng(25.0330, 121.5654),
radius: 40000,
fields: ['name', 'geometry', 'place_id', 'rating', 'user_ratings_total', 'vicinity', 'url']
};
placesService.textSearch(request, (results, status) => {
loader.style.display = 'none';
if (status === google.maps.places.PlacesServiceStatus.OK && results && results.length) {
let processedResults = results.slice();
if (userLocation) {
processedResults.forEach(place => {
place.distanceFromUser = calculateDistanceMeters(userLocation, place.geometry.location);
});
processedResults.sort((a, b) => (a.distanceFromUser || Infinity) - (b.distanceFromUser || Infinity));
}
// 限制最多 10 個結果
const limitedResults = processedResults.slice(0, 10);
if (userLocation) {
map.setCenter(userLocation);
map.setZoom(14);
} else if (limitedResults.length > 0) {
map.setCenter(limitedResults[0].geometry.location);
map.setZoom(13);
}
// 為每個結果獲取詳細信息(包含評論)
limitedResults.forEach(place => {
const detailsRequest = {
placeId: place.place_id,
fields: ['name', 'rating', 'user_ratings_total', 'vicinity', 'geometry', 'place_id', 'reviews', 'url']
};
placesService.getDetails(detailsRequest, (placeDetails, detailsStatus) => {
if (detailsStatus === google.maps.places.PlacesServiceStatus.OK) {
if (typeof place.distanceFromUser === 'number') {
placeDetails.distanceFromUser = place.distanceFromUser;
}
createMarker(placeDetails);
createCard(placeDetails);
}
});
});
} else {
container.innerHTML = "<p>找不到符合條件的店家。請嘗試其他關鍵字。</p>";
console.log('Places service status:', status);
}
});
}
function createCard(place) {
const container = document.getElementById("restaurantCards");
const card = document.createElement("div");
card.className = "card";
let reviewsHtml = '';
if (place.reviews && place.reviews.length > 0) {
reviewsHtml = '<div class="reviews-container">';
reviewsHtml += '<h5>最新評論:</h5>';
reviewsHtml += '<ul>';
place.reviews.forEach(review => {
const ratingClass = review.rating <= 3 ? 'class="low-rating-review"' : '';
reviewsHtml += `
<li ${ratingClass}>
<p><strong>${review.author_name}</strong> (⭐ ${review.rating})</p>
<p style="white-space: pre-wrap;">${review.text}</p>
</li>
`;
});
reviewsHtml += '</ul></div>';
}
const distanceText = typeof place.distanceFromUser === 'number'
? `<p>📏 距離:約 ${(place.distanceFromUser / 1000).toFixed(1)} 公里</p>`
: '';
card.innerHTML = `
<h4>${place.name}</h4>
<p>⭐ ${place.rating || '無評分'} (${place.user_ratings_total || 0} 則評論)</p>
<p>📍 <a href="${place.url}" target="_blank">${place.vicinity}</a></p>
${distanceText}
${reviewsHtml}
`;
card.onclick = () => {
const marker = markerMap.get(place.place_id);
if (marker) {
new google.maps.event.trigger(marker, 'click');
document.querySelectorAll('.card').forEach(c => c.classList.remove('active'));
card.classList.add('active');
}
};
container.appendChild(card);
}
function createMarker(place) {
const marker = new google.maps.Marker({
map,
position: place.geometry.location,
title: place.name
});
const distanceInfo = typeof place.distanceFromUser === 'number'
? `<br>距離:約 ${(place.distanceFromUser / 1000).toFixed(1)} 公里`
: '';
const infoWindowContent = `
<div class="custom-infowindow">
<span class="close-btn" onclick="this.parentElement.parentElement.style.display='none';">&times;</span>
<strong>${place.name}</strong><br>
${place.rating || 'N/A'} | 評論: ${place.user_ratings_total || 0}<br>
<a href="${place.url}" target="_blank">在 Google 地圖上查看</a>${distanceInfo}
</div>
`;
const infoWindow = new google.maps.InfoWindow({
content: infoWindowContent,
ariaLabel: place.name,
});
marker.addListener("click", () => {
if (activeInfoWindow) {
activeInfoWindow.close();
}
infoWindow.open({
anchor: marker,
map,
});
activeInfoWindow = infoWindow;
map.panTo(marker.getPosition());
});
markers.push(marker);
markerMap.set(place.place_id, marker);
}
// 當用戶點擊地圖其他地方時,關閉 InfoWindow
google.maps.event.addDomListener(map, 'click', () => {
if (activeInfoWindow) {
activeInfoWindow.close();
activeInfoWindow = null;
}
document.querySelectorAll('.card').forEach(c => c.classList.remove('active'));
});
// 註冊 Service Worker (PWA 支援)
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('service-worker.js')
.then(registration => {
console.log('Service Worker 註冊成功:', registration);
})
.catch(error => {
console.log('Service Worker 註冊失敗:', error);
});
});
}
// 添加安裝提示
let deferredPrompt;
const installButton = document.createElement('button');
installButton.textContent = '📱 安裝 App';
installButton.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background: linear-gradient(to right, #667eea, #764ba2);
color: white;
border: none;
padding: 12px 20px;
border-radius: 25px;
font-size: 14px;
cursor: pointer;
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
display: none;
z-index: 1000;
font-family: inherit;
`;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
installButton.style.display = 'block';
document.body.appendChild(installButton);
});
installButton.addEventListener('click', async () => {
if (deferredPrompt) {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log(`User response to the install prompt: ${outcome}`);
deferredPrompt = null;
installButton.style.display = 'none';
}
});
window.addEventListener('appinstalled', () => {
console.log('PWA 已安裝成功');
installButton.style.display = 'none';
});
</script>
<script async defer src="https://maps.googleapis.com/maps/api/js?key=AIzaSyB9zIkabAHoudMDAwbKom6URgjmUuejEpo&callback=initMap&libraries=places,marker&v=weekly"></script>
</body>
</html>