Spaces:
Running
Running
| <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';">×</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> | |