/** * convex/lib/googlePlaces.ts * * Dünner Client für die Google Places API (New). Wird ausschließlich aus * Convex *Actions* aufgerufen (Mutations/Queries dürfen kein fetch). * * Kostenrelevant: nationalPhoneNumber + websiteUri liegen in der teureren * Contact-Data-SKU. Field-Mask bewusst schlank halten (siehe Pricing-Modell). */ const PLACES_SEARCH_TEXT_URL = "https://places.googleapis.com/v1/places:searchText"; // Nur die Felder, die wir wirklich brauchen — jedes Feld kostet. const FIELD_MASK = [ "places.id", "places.displayName", "places.formattedAddress", "places.nationalPhoneNumber", "places.websiteUri", "places.rating", "places.location", "nextPageToken", ].join(","); /** Geschätzte Stückkosten — Quelle: PitchFast_Pricing_Modell.xlsx. */ export const COSTS = { /** Search-Call (~$0,032) amortisiert auf ~20 Ergebnisse. */ leadSearchAmortizedUsd: 0.032 / 20, /** Place Details (Pro) ~$0,017 + Contact-Data ~$0,003. */ leadDetailsUsd: 0.02, }; export function leadLookupCostUsd(): number { return COSTS.leadSearchAmortizedUsd + COSTS.leadDetailsUsd; // ~$0,022 } export type PlaceResult = { placeId: string; name: string; address?: string; phone?: string; website?: string; domain?: string; rating?: number; lat?: number; lng?: number; }; type SearchArgs = { apiKey: string; textQuery: string; // z. B. "Friseur" oder Freitext-Nische lat: number; lng: number; radiusMeters: number; // Places-Limit: max. 50.000 pageToken?: string; }; type SearchPage = { places: PlaceResult[]; nextPageToken?: string }; /** Eine Seite Ergebnisse (bis zu 20) holen. */ export async function searchPlacesPage(args: SearchArgs): Promise { const body: Record = { textQuery: args.textQuery, languageCode: "de", regionCode: "DE", pageSize: 20, locationBias: { circle: { center: { latitude: args.lat, longitude: args.lng }, radius: Math.min(args.radiusMeters, 50000), }, }, }; if (args.pageToken) body.pageToken = args.pageToken; const res = await fetch(PLACES_SEARCH_TEXT_URL, { method: "POST", headers: { "Content-Type": "application/json", "X-Goog-Api-Key": args.apiKey, "X-Goog-FieldMask": FIELD_MASK, }, body: JSON.stringify(body), }); if (!res.ok) { const text = await res.text(); throw new Error(`Places API ${res.status}: ${text.slice(0, 300)}`); } const data = (await res.json()) as { places?: Array<{ id: string; displayName?: { text?: string }; formattedAddress?: string; nationalPhoneNumber?: string; websiteUri?: string; rating?: number; location?: { latitude?: number; longitude?: number }; }>; nextPageToken?: string; }; const places: PlaceResult[] = (data.places ?? []).map((p) => ({ placeId: p.id, name: p.displayName?.text ?? "(ohne Namen)", address: p.formattedAddress, phone: p.nationalPhoneNumber, website: p.websiteUri, domain: extractDomain(p.websiteUri), rating: p.rating, lat: p.location?.latitude, lng: p.location?.longitude, })); return { places, nextPageToken: data.nextPageToken }; } /** Domain aus einer URL extrahieren (für Dublettenprüfung & Blacklist). */ export function extractDomain(url?: string): string | undefined { if (!url) return undefined; try { return new URL(url).hostname.replace(/^www\./, "").toLowerCase(); } catch { return undefined; } } /** * Priorisierungs-Heuristik (Recherchephase, ohne Audit): * - keine Website = höchstes Potenzial für Webdesign * - schwache Bewertung trotz Website = mittel * - sonst niedrig */ export function computePriority( place: PlaceResult, ): "high" | "medium" | "low" { if (!place.website) return "high"; if (place.rating !== undefined && place.rating < 3.5) return "medium"; return "low"; }