From fef7c160e27324cc08de19a15af8e24a7500b9cc Mon Sep 17 00:00:00 2001 From: Zias van Nes Date: Mon, 27 Oct 2025 14:57:54 +0100 Subject: [PATCH] feat:use GMaps places api for searching poi's --- .env.example | 3 + src/lib/components/CreateFindModal.svelte | 152 +++++++- src/lib/components/POISearch.svelte | 403 ++++++++++++++++++++++ src/lib/utils/places.ts | 194 +++++++++++ src/routes/api/places/+server.ts | 83 +++++ 5 files changed, 820 insertions(+), 15 deletions(-) create mode 100644 src/lib/components/POISearch.svelte create mode 100644 src/lib/utils/places.ts create mode 100644 src/routes/api/places/+server.ts diff --git a/.env.example b/.env.example index 6164d9e..cfbb5aa 100644 --- a/.env.example +++ b/.env.example @@ -35,3 +35,6 @@ R2_ACCOUNT_ID="" R2_ACCESS_KEY_ID="" R2_SECRET_ACCESS_KEY="" R2_BUCKET_NAME="" + +# Google Maps API for Places search +GOOGLE_MAPS_API_KEY="your_google_maps_api_key_here" diff --git a/src/lib/components/CreateFindModal.svelte b/src/lib/components/CreateFindModal.svelte index d4a1560..07284e6 100644 --- a/src/lib/components/CreateFindModal.svelte +++ b/src/lib/components/CreateFindModal.svelte @@ -4,6 +4,8 @@ import { Label } from '$lib/components/label'; import { Button } from '$lib/components/button'; import { coordinates } from '$lib/stores/location'; + import POISearch from './POISearch.svelte'; + import type { PlaceResult } from '$lib/utils/places'; interface Props { isOpen: boolean; @@ -23,6 +25,7 @@ let selectedFiles = $state(null); let isSubmitting = $state(false); let uploadedMedia = $state>([]); + let useManualLocation = $state(false); const categories = [ { value: 'cafe', label: 'Café' }, @@ -136,14 +139,31 @@ } } + function handlePlaceSelected(place: PlaceResult) { + locationName = place.name; + latitude = place.latitude.toString(); + longitude = place.longitude.toString(); + } + + function toggleLocationMode() { + useManualLocation = !useManualLocation; + if (!useManualLocation && $coordinates) { + latitude = $coordinates.latitude.toString(); + longitude = $coordinates.longitude.toString(); + } + } + function resetForm() { title = ''; description = ''; locationName = ''; + latitude = ''; + longitude = ''; category = 'cafe'; isPublic = true; selectedFiles = null; uploadedMedia = []; + useManualLocation = false; const fileInput = document.querySelector('#media-files') as HTMLInputElement; if (fileInput) { @@ -187,13 +207,31 @@ > -
- - +
+
+ + +
+ + {#if useManualLocation} +
+ + +
+ {:else} + + {/if}
@@ -267,16 +305,33 @@ {/if}
-
-
- - + {#if useManualLocation || (!latitude && !longitude)} +
+
+ + +
+
+ + +
-
- - + {:else if latitude && longitude} +
+ +
+ Lat: {parseFloat(latitude).toFixed(6)} + Lng: {parseFloat(longitude).toFixed(6)} + +
-
+ {/if}
@@ -475,6 +530,73 @@ flex: 1; } + .location-section { + display: flex; + flex-direction: column; + gap: 1rem; + } + + .location-header { + display: flex; + align-items: center; + justify-content: space-between; + } + + .toggle-button { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + height: auto; + background: transparent; + border: 1px solid hsl(var(--border)); + border-radius: 6px; + color: hsl(var(--foreground)); + cursor: pointer; + transition: all 0.2s ease; + } + + .toggle-button:hover { + background: hsl(var(--muted)); + } + + .coordinates-display { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .coordinates-info { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem; + background: hsl(var(--muted) / 0.5); + border: 1px solid hsl(var(--border)); + border-radius: 8px; + font-size: 0.875rem; + } + + .coordinate { + color: hsl(var(--muted-foreground)); + font-family: monospace; + } + + .edit-coords-button { + margin-left: auto; + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + height: auto; + background: transparent; + border: 1px solid hsl(var(--border)); + border-radius: 6px; + color: hsl(var(--foreground)); + cursor: pointer; + transition: all 0.2s ease; + } + + .edit-coords-button:hover { + background: hsl(var(--muted)); + } + @media (max-width: 640px) { .form-content { padding: 1rem; diff --git a/src/lib/components/POISearch.svelte b/src/lib/components/POISearch.svelte new file mode 100644 index 0000000..4c8c608 --- /dev/null +++ b/src/lib/components/POISearch.svelte @@ -0,0 +1,403 @@ + + +
+
+ +
+ + {#if showNearbyButton && $coordinates} + + {/if} +
+
+ + {#if showSuggestions && suggestions.length > 0} +
+
Search Results
+ {#each suggestions as suggestion (suggestion.placeId)} + + {/each} +
+ {/if} + + {#if showNearby && nearbyPlaces.length > 0} +
+
Nearby Places
+ {#each nearbyPlaces as place (place.placeId)} + + {/each} +
+ {/if} + + {#if isLoading} +
+ + + + Searching... +
+ {/if} +
+ + diff --git a/src/lib/utils/places.ts b/src/lib/utils/places.ts new file mode 100644 index 0000000..0348533 --- /dev/null +++ b/src/lib/utils/places.ts @@ -0,0 +1,194 @@ +export interface PlaceResult { + placeId: string; + name: string; + formattedAddress: string; + latitude: number; + longitude: number; + types: string[]; + vicinity?: string; + rating?: number; + priceLevel?: number; +} + +interface GooglePlacesPrediction { + place_id: string; + description: string; + types: string[]; +} + +interface GooglePlacesResult { + place_id: string; + name: string; + formatted_address?: string; + vicinity?: string; + geometry: { + location: { + lat: number; + lng: number; + }; + }; + types?: string[]; + rating?: number; + price_level?: number; +} + +export interface PlaceSearchOptions { + query?: string; + location?: { lat: number; lng: number }; + radius?: number; + type?: string; +} + +export class GooglePlacesService { + private apiKey: string; + private baseUrl = 'https://maps.googleapis.com/maps/api/place'; + + constructor(apiKey: string) { + this.apiKey = apiKey; + } + + /** + * Search for places using text query + */ + async searchPlaces( + query: string, + location?: { lat: number; lng: number } + ): Promise { + const url = new URL(`${this.baseUrl}/textsearch/json`); + url.searchParams.set('query', query); + url.searchParams.set('key', this.apiKey); + + if (location) { + url.searchParams.set('location', `${location.lat},${location.lng}`); + url.searchParams.set('radius', '50000'); // 50km radius + } + + try { + const response = await fetch(url.toString()); + const data = await response.json(); + + if (data.status !== 'OK') { + throw new Error(`Places API error: ${data.status}`); + } + + return data.results.map(this.formatPlaceResult); + } catch (error) { + console.error('Error searching places:', error); + throw error; + } + } + + /** + * Get place autocomplete suggestions + */ + async getAutocompleteSuggestions( + input: string, + location?: { lat: number; lng: number } + ): Promise> { + const url = new URL(`${this.baseUrl}/autocomplete/json`); + url.searchParams.set('input', input); + url.searchParams.set('key', this.apiKey); + + if (location) { + url.searchParams.set('location', `${location.lat},${location.lng}`); + url.searchParams.set('radius', '50000'); + } + + try { + const response = await fetch(url.toString()); + const data = await response.json(); + + if (data.status !== 'OK' && data.status !== 'ZERO_RESULTS') { + throw new Error(`Places API error: ${data.status}`); + } + + return ( + data.predictions?.map((prediction: GooglePlacesPrediction) => ({ + placeId: prediction.place_id, + description: prediction.description, + types: prediction.types + })) || [] + ); + } catch (error) { + console.error('Error getting autocomplete suggestions:', error); + throw error; + } + } + + /** + * Get detailed information about a place + */ + async getPlaceDetails(placeId: string): Promise { + const url = new URL(`${this.baseUrl}/details/json`); + url.searchParams.set('place_id', placeId); + url.searchParams.set( + 'fields', + 'place_id,name,formatted_address,geometry,types,vicinity,rating,price_level' + ); + url.searchParams.set('key', this.apiKey); + + try { + const response = await fetch(url.toString()); + const data = await response.json(); + + if (data.status !== 'OK') { + throw new Error(`Places API error: ${data.status}`); + } + + return this.formatPlaceResult(data.result); + } catch (error) { + console.error('Error getting place details:', error); + throw error; + } + } + + /** + * Find nearby places + */ + async findNearbyPlaces( + location: { lat: number; lng: number }, + radius: number = 5000, + type?: string + ): Promise { + const url = new URL(`${this.baseUrl}/nearbysearch/json`); + url.searchParams.set('location', `${location.lat},${location.lng}`); + url.searchParams.set('radius', radius.toString()); + url.searchParams.set('key', this.apiKey); + + if (type) { + url.searchParams.set('type', type); + } + + try { + const response = await fetch(url.toString()); + const data = await response.json(); + + if (data.status !== 'OK' && data.status !== 'ZERO_RESULTS') { + throw new Error(`Places API error: ${data.status}`); + } + + return data.results?.map(this.formatPlaceResult) || []; + } catch (error) { + console.error('Error finding nearby places:', error); + throw error; + } + } + + private formatPlaceResult = (place: GooglePlacesResult): PlaceResult => { + return { + placeId: place.place_id, + name: place.name, + formattedAddress: place.formatted_address || place.vicinity || '', + latitude: place.geometry.location.lat, + longitude: place.geometry.location.lng, + types: place.types || [], + vicinity: place.vicinity, + rating: place.rating, + priceLevel: place.price_level + }; + }; +} + +export function createPlacesService(apiKey: string): GooglePlacesService { + return new GooglePlacesService(apiKey); +} diff --git a/src/routes/api/places/+server.ts b/src/routes/api/places/+server.ts new file mode 100644 index 0000000..eed1f37 --- /dev/null +++ b/src/routes/api/places/+server.ts @@ -0,0 +1,83 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; +import { createPlacesService } from '$lib/utils/places'; + +const getPlacesService = () => { + const apiKey = env.GOOGLE_MAPS_API_KEY; + if (!apiKey) { + throw new Error('GOOGLE_MAPS_API_KEY environment variable is not set'); + } + return createPlacesService(apiKey); +}; + +export const GET: RequestHandler = async ({ url, locals }) => { + if (!locals.user) { + throw error(401, 'Unauthorized'); + } + + const action = url.searchParams.get('action'); + const query = url.searchParams.get('query'); + const placeId = url.searchParams.get('placeId'); + const lat = url.searchParams.get('lat'); + const lng = url.searchParams.get('lng'); + const radius = url.searchParams.get('radius'); + const type = url.searchParams.get('type'); + + try { + const placesService = getPlacesService(); + + let location; + if (lat && lng) { + location = { lat: parseFloat(lat), lng: parseFloat(lng) }; + } + + switch (action) { + case 'search': { + if (!query) { + throw error(400, 'Query parameter is required for search'); + } + const searchResults = await placesService.searchPlaces(query, location); + return json(searchResults); + } + + case 'autocomplete': { + if (!query) { + throw error(400, 'Query parameter is required for autocomplete'); + } + const suggestions = await placesService.getAutocompleteSuggestions(query, location); + return json(suggestions); + } + + case 'details': { + if (!placeId) { + throw error(400, 'PlaceId parameter is required for details'); + } + const placeDetails = await placesService.getPlaceDetails(placeId); + return json(placeDetails); + } + + case 'nearby': { + if (!location) { + throw error(400, 'Location parameters (lat, lng) are required for nearby search'); + } + const radiusNum = radius ? parseInt(radius) : 5000; + const nearbyResults = await placesService.findNearbyPlaces( + location, + radiusNum, + type || undefined + ); + return json(nearbyResults); + } + + default: + throw error(400, 'Invalid action parameter'); + } + } catch (err) { + console.error('Places API error:', err); + if (err instanceof Error) { + throw error(500, err.message); + } + throw error(500, 'Failed to fetch places data'); + } +};