Bläddra i källkod

Google Places local competitor discovery for local businesses

Adds Google Places API integration to find real nearby competitors by address or area. Settings card stores/masks the API key (AES-256-GCM); Competitors page exposes a 'Find Nearby' form that geocodes the location and runs a Places Text Search, returning up to 5 local businesses as one-click competitor suggestions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Benjamin Harris 3 veckor sedan
förälder
incheckning
5a438daa65
5 ändrade filer med 281 tillägg och 7 borttagningar
  1. 93 0
      services/gateway/server.js
  2. 17 0
      ui/src/locales/en.ts
  3. 17 0
      ui/src/locales/tr.ts
  4. 76 7
      ui/src/views/Competitors.vue
  5. 78 0
      ui/src/views/Settings.vue

+ 93 - 0
services/gateway/server.js

@@ -2853,6 +2853,99 @@ async function buildCompetitorSystemSuffix() {
   }
 }
 
+// Save Google Places API key (used for local competitor discovery)
+app.post('/credentials/google-places', async (request, reply) => {
+  const { apiKey } = request.body || {};
+  if (!apiKey?.trim()) return reply.code(400).send({ error: 'apiKey is required' });
+  const db = await getDb();
+  await db.collection('platform_credentials').updateOne(
+    { _id: 'google_places' },
+    { $set: { apiKey: apiKey.trim(), updatedAt: new Date() } },
+    { upsert: true },
+  );
+  return { success: true };
+});
+
+app.get('/credentials/google-places', async () => {
+  const db = await getDb();
+  const cred = await db.collection('platform_credentials').findOne({ _id: 'google_places' });
+  return { configured: !!cred?.apiKey, keyHint: cred?.apiKey ? `****${cred.apiKey.slice(-4)}` : null };
+});
+
+app.delete('/credentials/google-places', async () => {
+  const db = await getDb();
+  await db.collection('platform_credentials').deleteOne({ _id: 'google_places' });
+  return { success: true };
+});
+
+// Discover local competitors via Google Places API
+app.post('/competitors/discover-local', async (request, reply) => {
+  const { location, businessType, radiusMeters = 5000 } = request.body || {};
+  if (!location) return reply.code(400).send({ error: 'location is required' });
+
+  const db = await getDb();
+  const cred = await db.collection('platform_credentials').findOne({ _id: 'google_places' });
+  if (!cred?.apiKey) return reply.code(400).send({ error: 'Google Places API key not configured — add it in Settings.' });
+
+  // Step 1: Geocode the location string to coordinates
+  let lat, lng;
+  try {
+    const geoRes = await axios.get('https://maps.googleapis.com/maps/api/geocode/json', {
+      params: { address: location, key: cred.apiKey },
+      timeout: 10000,
+    });
+    const loc = geoRes.data.results?.[0]?.geometry?.location;
+    if (!loc) return reply.code(400).send({ error: `Could not geocode location: "${location}"` });
+    lat = loc.lat;
+    lng = loc.lng;
+  } catch (err) {
+    return reply.code(503).send({ error: 'Geocoding failed', detail: err.message });
+  }
+
+  // Step 2: Text Search for nearby businesses of the given type
+  try {
+    const query = businessType ? `${businessType} near ${location}` : location;
+    const searchRes = await axios.get('https://maps.googleapis.com/maps/api/place/textsearch/json', {
+      params: {
+        query,
+        location: `${lat},${lng}`,
+        radius: Math.min(radiusMeters, 50000),
+        key: cred.apiKey,
+      },
+      timeout: 10000,
+    });
+
+    const places = (searchRes.data.results || []).slice(0, 10);
+    if (!places.length) return { success: true, suggestions: [] };
+
+    // Step 3: Fetch website URLs for places that have them
+    const suggestions = [];
+    for (const place of places.slice(0, 8)) {
+      try {
+        const detailRes = await axios.get('https://maps.googleapis.com/maps/api/place/details/json', {
+          params: { place_id: place.place_id, fields: 'name,website,formatted_address,rating', key: cred.apiKey },
+          timeout: 8000,
+        });
+        const detail = detailRes.data.result || {};
+        suggestions.push({
+          name: detail.name || place.name,
+          websiteUrl: detail.website || null,
+          address: detail.formatted_address || '',
+          rating: detail.rating || null,
+          reason: `Local ${businessType || 'business'} near ${location}${detail.rating ? ` · ${detail.rating}★` : ''}`,
+        });
+      } catch {
+        suggestions.push({ name: place.name, websiteUrl: null, address: '', rating: null, reason: `Local ${businessType || 'business'} near ${location}` });
+      }
+    }
+
+    log.info({ action: 'discover_local_competitors', location, count: suggestions.length, outcome: 'success' });
+    return { success: true, suggestions };
+  } catch (err) {
+    return reply.code(503).send({ error: 'Google Places search failed', detail: err.message });
+  }
+});
+
 // Discover competitors automatically using AI + account profile context
 app.post('/competitors/discover', async (request, reply) => {
   const db = await getDb();

+ 17 - 0
ui/src/locales/en.ts

@@ -446,6 +446,18 @@ export default {
       expiryReconnect: 'Reconnect',
       expiryDismiss: 'Dismiss',
     },
+
+    googlePlaces: {
+      sectionTitle: 'Google Places',
+      sectionSubtitle: 'Discover local competitors by searching businesses near an address or area.',
+      keyConfigured: 'API key configured ({hint})',
+      keyPlaceholder: 'Google Places API key',
+      getKeyHint: 'Get a free API key from Google Cloud Console — enable the Places API and Geocoding API.',
+      save: 'Save Key',
+      saving: 'Saving…',
+      disconnect: 'Remove',
+      disconnectConfirm: 'Remove the Google Places API key?',
+    },
   },
 
   ai: {
@@ -548,6 +560,11 @@ export default {
     profileFeatures: 'Key features',
     profileChannels: 'Channels',
     discoverButton: 'Find Competitors Automatically',
+    discoverLocalButton: 'Find Nearby',
+    discoverLocalSearch: 'Search',
+    localDiscoveryLabel: 'Find local competitors by location',
+    localLocationPlaceholder: 'e.g. New York, NY  or  123 Main St, Chicago',
+    localBusinessTypePlaceholder: 'Business type (optional) — e.g. coffee shop, gym',
     discovering: 'Discovering…',
     discoverySuggestionsLabel: 'AI-suggested competitors — click Add to track them:',
     discoverAccept: 'Add',

+ 17 - 0
ui/src/locales/tr.ts

@@ -446,6 +446,18 @@ export default {
       expiryReconnect: 'Yeniden Bağlan',
       expiryDismiss: 'Kapat',
     },
+
+    googlePlaces: {
+      sectionTitle: 'Google Places',
+      sectionSubtitle: 'Bir adres veya bölge yakınındaki işletmeleri arayarak yerel rakipleri keşfedin.',
+      keyConfigured: 'API anahtarı yapılandırıldı ({hint})',
+      keyPlaceholder: 'Google Places API anahtarı',
+      getKeyHint: 'Google Cloud Console\'dan ücretsiz API anahtarı alın — Places API ve Geocoding API\'yi etkinleştirin.',
+      save: 'Anahtarı Kaydet',
+      saving: 'Kaydediliyor…',
+      disconnect: 'Kaldır',
+      disconnectConfirm: 'Google Places API anahtarı kaldırılsın mı?',
+    },
   },
 
   ai: {
@@ -548,6 +560,11 @@ export default {
     profileFeatures: 'Önemli özellikler',
     profileChannels: 'Kanallar',
     discoverButton: 'Rakipleri Otomatik Bul',
+    discoverLocalButton: 'Yakınlarda Bul',
+    discoverLocalSearch: 'Ara',
+    localDiscoveryLabel: 'Konuma göre yerel rakip bul',
+    localLocationPlaceholder: 'ör. İstanbul, Kadıköy  veya  Bağdat Caddesi, İstanbul',
+    localBusinessTypePlaceholder: 'İşletme türü (isteğe bağlı) — ör. kafe, spor salonu',
     discovering: 'Aranıyor…',
     discoverySuggestionsLabel: 'YZ tarafından önerilen rakipler — eklemek için Ekle\'ye tıklayın:',
     discoverAccept: 'Ekle',

+ 76 - 7
ui/src/views/Competitors.vue

@@ -453,16 +453,48 @@
     <div v-if="competitorStore.competitors.length < 5" class="bg-gray-800 border border-gray-700 rounded-lg p-5 max-w-xl">
       <div class="flex items-center justify-between mb-3">
         <h2 class="text-sm font-semibold text-white">{{ t('competitors.addCompetitor') }}</h2>
+        <div class="flex gap-1.5" v-if="!competitorStore.discoverySuggestions.length">
+          <button
+            @click="competitorStore.discoverCompetitors()"
+            :disabled="competitorStore.discoveringCompetitors"
+            class="flex items-center gap-1.5 text-xs px-2.5 py-1 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded disabled:opacity-50"
+          >
+            <i class="fa-solid fa-magnifying-glass text-[10px]" :class="{ 'animate-pulse': competitorStore.discoveringCompetitors }"></i>
+            {{ competitorStore.discoveringCompetitors ? t('competitors.discovering') : t('competitors.discoverButton') }}
+          </button>
+          <button
+            v-if="placesConfigured"
+            @click="showLocalForm = !showLocalForm"
+            class="flex items-center gap-1.5 text-xs px-2.5 py-1 bg-green-800 hover:bg-green-700 text-green-200 rounded"
+          >
+            <i class="fa-solid fa-location-dot text-[10px]"></i>
+            {{ t('competitors.discoverLocalButton') }}
+          </button>
+        </div>
+      </div>
+      <!-- Local discovery form -->
+      <div v-if="showLocalForm && placesConfigured" class="mb-3 p-3 bg-green-900/20 border border-green-800/50 rounded-lg space-y-2">
+        <div class="text-xs text-green-400 font-medium mb-1">{{ t('competitors.localDiscoveryLabel') }}</div>
+        <input
+          v-model="localLocation"
+          class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-1.5 text-white text-sm focus:outline-none focus:border-green-500"
+          :placeholder="t('competitors.localLocationPlaceholder')"
+        />
+        <input
+          v-model="localBusinessType"
+          class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-1.5 text-white text-sm focus:outline-none focus:border-green-500"
+          :placeholder="t('competitors.localBusinessTypePlaceholder')"
+        />
         <button
-          v-if="!competitorStore.discoverySuggestions.length"
-          @click="competitorStore.discoverCompetitors()"
-          :disabled="competitorStore.discoveringCompetitors"
-          class="flex items-center gap-1.5 text-xs px-2.5 py-1 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded disabled:opacity-50"
+          @click="discoverLocal"
+          :disabled="discoveringLocal || !localLocation.trim()"
+          class="flex items-center gap-1.5 text-xs px-3 py-1.5 bg-green-700 hover:bg-green-600 text-white rounded disabled:opacity-50"
         >
-          <i class="fa-solid fa-magnifying-glass text-[10px]" :class="{ 'animate-pulse': competitorStore.discoveringCompetitors }"></i>
-          {{ competitorStore.discoveringCompetitors ? t('competitors.discovering') : t('competitors.discoverButton') }}
+          <i class="fa-solid fa-location-dot" :class="{ 'animate-pulse': discoveringLocal }"></i>
+          {{ discoveringLocal ? t('competitors.discovering') : t('competitors.discoverLocalSearch') }}
         </button>
       </div>
+
       <div class="space-y-2">
         <input
           v-model="newForm.name"
@@ -491,7 +523,8 @@
 import { ref, reactive, computed, onMounted } from 'vue'
 import { useRouter } from 'vue-router'
 import { useI18n } from 'vue-i18n'
-import { useCompetitorStore, type Competitor, type KeywordIntent } from '../stores/competitors'
+import axios from 'axios'
+import { useCompetitorStore, type Competitor, type KeywordIntent, type CompetitorSuggestion } from '../stores/competitors'
 import { useComposeStore } from '../stores/compose'
 
 const { t } = useI18n()
@@ -605,6 +638,41 @@ async function createCompetitor() {
   }
 }
 
+// ─── Local competitor discovery via Google Places ─────────────────────────────
+
+const placesConfigured  = ref(false)
+const showLocalForm     = ref(false)
+const localLocation     = ref('')
+const localBusinessType = ref('')
+const discoveringLocal  = ref(false)
+
+async function checkPlacesConfig() {
+  try {
+    const res = await axios.get('/api/credentials/google-places')
+    placesConfigured.value = res.data.configured
+  } catch { /* not configured */ }
+}
+
+async function discoverLocal() {
+  if (!localLocation.value.trim()) return
+  discoveringLocal.value = true
+  competitorStore.error = null
+  try {
+    const res = await axios.post('/api/competitors/discover-local', {
+      location: localLocation.value.trim(),
+      businessType: localBusinessType.value.trim() || undefined,
+    })
+    const withWebsite = (res.data.suggestions || []).filter((s: any) => s.websiteUrl)
+    competitorStore.discoverySuggestions.splice(0, Infinity, ...withWebsite)
+    if (!withWebsite.length) competitorStore.error = 'No businesses with websites found — try a broader location or business type.'
+    showLocalForm.value = false
+  } catch (err: any) {
+    competitorStore.error = err.response?.data?.error || 'Local discovery failed'
+  } finally {
+    discoveringLocal.value = false
+  }
+}
+
 async function acceptSuggestion(s: { name: string; websiteUrl: string }) {
   const ok = await competitorStore.addCompetitor({ name: s.name, websiteUrl: s.websiteUrl })
   if (ok) {
@@ -637,5 +705,6 @@ async function confirmDelete(id: string) {
 
 onMounted(() => {
   competitorStore.fetchCompetitors()
+  checkPlacesConfig()
 })
 </script>

+ 78 - 0
ui/src/views/Settings.vue

@@ -1257,6 +1257,48 @@
         </div>
       </template>
 
+      <!-- ═══════════════════════════════════════════════════════════════════
+           GOOGLE PLACES — local competitor discovery
+      ════════════════════════════════════════════════════════════════════ -->
+      <div class="bg-gray-900 border border-gray-800 rounded-2xl overflow-hidden">
+        <div class="p-5 border-b border-gray-800 flex items-center gap-3">
+          <div class="w-9 h-9 rounded-full bg-green-700 flex items-center justify-center shrink-0">
+            <i class="fa-solid fa-location-dot text-white text-sm"></i>
+          </div>
+          <div>
+            <p class="font-semibold">{{ $t('settings.googlePlaces.sectionTitle') }}</p>
+            <p class="text-xs text-gray-500 mt-0.5">{{ $t('settings.googlePlaces.sectionSubtitle') }}</p>
+          </div>
+        </div>
+
+        <div class="p-5 space-y-4">
+          <div v-if="placesConfigured" class="flex items-center justify-between">
+            <span class="text-sm text-gray-300">{{ $t('settings.googlePlaces.keyConfigured', { hint: placesKeyHint }) }}</span>
+            <button @click="removePlacesKey" class="text-xs px-3 py-1.5 border border-red-800/60 text-red-400 hover:bg-red-900/20 rounded-lg transition-colors">
+              {{ $t('settings.googlePlaces.disconnect') }}
+            </button>
+          </div>
+          <div v-else class="space-y-3">
+            <p class="text-xs text-gray-500">{{ $t('settings.googlePlaces.getKeyHint') }}</p>
+            <div class="flex gap-2">
+              <input
+                v-model="placesApiKey"
+                type="password"
+                :placeholder="$t('settings.googlePlaces.keyPlaceholder')"
+                class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-100 placeholder-gray-600 focus:outline-none focus:border-green-500"
+              />
+              <button
+                @click="savePlacesKey"
+                :disabled="!placesApiKey.trim() || placesSaving"
+                class="px-4 py-2 bg-green-700 hover:bg-green-600 disabled:opacity-40 rounded-lg text-sm font-medium text-white transition-colors"
+              >
+                {{ placesSaving ? $t('settings.googlePlaces.saving') : $t('settings.googlePlaces.save') }}
+              </button>
+            </div>
+          </div>
+        </div>
+      </div>
+
       <!-- Refresh button -->
       <button
         @click="platformsStore.fetchStatuses()"
@@ -1625,6 +1667,41 @@ async function toggleProfile(key: string) {
   }
 }
 
+// ─── Google Places ────────────────────────────────────────────────────────────
+
+const placesApiKey    = ref('')
+const placesSaving    = ref(false)
+const placesConfigured = ref(false)
+const placesKeyHint   = ref<string | null>(null)
+
+async function loadPlacesConfig() {
+  try {
+    const res = await axios.get('/api/credentials/google-places')
+    placesConfigured.value = res.data.configured
+    placesKeyHint.value = res.data.keyHint
+  } catch { /* not configured */ }
+}
+
+async function savePlacesKey() {
+  if (!placesApiKey.value.trim()) return
+  placesSaving.value = true
+  try {
+    await axios.post('/api/credentials/google-places', { apiKey: placesApiKey.value.trim() })
+    placesConfigured.value = true
+    placesKeyHint.value = `****${placesApiKey.value.trim().slice(-4)}`
+    placesApiKey.value = ''
+  } finally {
+    placesSaving.value = false
+  }
+}
+
+async function removePlacesKey() {
+  if (!confirm(t('settings.googlePlaces.disconnectConfirm'))) return
+  await axios.delete('/api/credentials/google-places')
+  placesConfigured.value = false
+  placesKeyHint.value = null
+}
+
 async function auditProfile(key: string) {
   profileAuditing.value = key
   try {
@@ -1769,6 +1846,7 @@ onMounted(async () => {
     aiStore.fetchConfig(),
     aiStore.fetchProviders(),
     hashtagStore.fetchGroups(),
+    loadPlacesConfig(),
   ])
 
   // Seed board checkboxes from current selection