feat:use GMaps places api for searching poi's

This commit is contained in:
2025-10-27 14:57:54 +01:00
parent 43afa6dacc
commit fef7c160e2
5 changed files with 820 additions and 15 deletions

View File

@@ -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"

View File

@@ -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<FileList | null>(null);
let isSubmitting = $state(false);
let uploadedMedia = $state<Array<{ type: string; url: string; thumbnailUrl: string }>>([]);
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,6 +207,15 @@
></textarea>
</div>
<div class="location-section">
<div class="location-header">
<Label>Location</Label>
<button type="button" onclick={toggleLocationMode} class="toggle-button">
{useManualLocation ? 'Use Search' : 'Manual Entry'}
</button>
</div>
{#if useManualLocation}
<div class="field">
<Label for="location-name">Location name</Label>
<Input
@@ -195,6 +224,15 @@
bind:value={locationName}
/>
</div>
{:else}
<POISearch
onPlaceSelected={handlePlaceSelected}
placeholder="Search for cafés, restaurants, landmarks..."
label=""
showNearbyButton={true}
/>
{/if}
</div>
<div class="field-group">
<div class="field">
@@ -267,6 +305,7 @@
{/if}
</div>
{#if useManualLocation || (!latitude && !longitude)}
<div class="field-group">
<div class="field">
<Label for="latitude">Latitude</Label>
@@ -277,6 +316,22 @@
<Input name="longitude" type="text" required bind:value={longitude} />
</div>
</div>
{:else if latitude && longitude}
<div class="coordinates-display">
<Label>Selected coordinates</Label>
<div class="coordinates-info">
<span class="coordinate">Lat: {parseFloat(latitude).toFixed(6)}</span>
<span class="coordinate">Lng: {parseFloat(longitude).toFixed(6)}</span>
<button
type="button"
onclick={() => (useManualLocation = true)}
class="edit-coords-button"
>
Edit
</button>
</div>
</div>
{/if}
</div>
<div class="actions">
@@ -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;

View File

@@ -0,0 +1,403 @@
<script lang="ts">
import { Input } from '$lib/components/input';
import { Label } from '$lib/components/label';
import { Button } from '$lib/components/button';
import { coordinates } from '$lib/stores/location';
import type { PlaceResult } from '$lib/utils/places';
interface Props {
onPlaceSelected: (place: PlaceResult) => void;
placeholder?: string;
label?: string;
showNearbyButton?: boolean;
}
let {
onPlaceSelected,
placeholder = 'Search for a place or address...',
label = 'Search location',
showNearbyButton = true
}: Props = $props();
let searchQuery = $state('');
let suggestions = $state<Array<{ placeId: string; description: string; types: string[] }>>([]);
let nearbyPlaces = $state<PlaceResult[]>([]);
let isLoading = $state(false);
let showSuggestions = $state(false);
let showNearby = $state(false);
let debounceTimeout: ReturnType<typeof setTimeout>;
async function searchPlaces(query: string) {
if (!query.trim()) {
suggestions = [];
showSuggestions = false;
return;
}
isLoading = true;
try {
const params = new URLSearchParams({
action: 'autocomplete',
query: query.trim()
});
if ($coordinates) {
params.set('lat', $coordinates.latitude.toString());
params.set('lng', $coordinates.longitude.toString());
}
const response = await fetch(`/api/places?${params}`);
if (response.ok) {
suggestions = await response.json();
showSuggestions = true;
}
} catch (error) {
console.error('Error searching places:', error);
} finally {
isLoading = false;
}
}
async function selectPlace(placeId: string, description: string) {
isLoading = true;
try {
const params = new URLSearchParams({
action: 'details',
placeId
});
const response = await fetch(`/api/places?${params}`);
if (response.ok) {
const place: PlaceResult = await response.json();
onPlaceSelected(place);
searchQuery = description;
showSuggestions = false;
}
} catch (error) {
console.error('Error getting place details:', error);
} finally {
isLoading = false;
}
}
async function findNearbyPlaces() {
if (!$coordinates) {
alert('Please enable location access first');
return;
}
isLoading = true;
showNearby = true;
try {
const params = new URLSearchParams({
action: 'nearby',
lat: $coordinates.latitude.toString(),
lng: $coordinates.longitude.toString(),
radius: '2000' // 2km radius
});
const response = await fetch(`/api/places?${params}`);
if (response.ok) {
nearbyPlaces = await response.json();
}
} catch (error) {
console.error('Error finding nearby places:', error);
} finally {
isLoading = false;
}
}
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
searchQuery = target.value;
clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(() => {
searchPlaces(searchQuery);
}, 300);
}
function selectNearbyPlace(place: PlaceResult) {
onPlaceSelected(place);
searchQuery = place.name;
showNearby = false;
showSuggestions = false;
}
function handleClickOutside(event: Event) {
const target = event.target as HTMLElement;
if (!target.closest('.poi-search-container')) {
showSuggestions = false;
showNearby = false;
}
}
// Close dropdowns when clicking outside
if (typeof window !== 'undefined') {
document.addEventListener('click', handleClickOutside);
}
</script>
<div class="poi-search-container">
<div class="field">
<Label for="poi-search">{label}</Label>
<div class="search-input-container">
<Input id="poi-search" type="text" {placeholder} value={searchQuery} oninput={handleInput} />
{#if showNearbyButton && $coordinates}
<Button
type="button"
variant="ghost"
size="sm"
onclick={findNearbyPlaces}
disabled={isLoading}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M21 10C21 17 12 23 12 23C12 23 3 17 3 10C3 5.58172 6.58172 2 12 2C17.4183 2 21 5.58172 21 10Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
</svg>
Nearby
</Button>
{/if}
</div>
</div>
{#if showSuggestions && suggestions.length > 0}
<div class="suggestions-dropdown">
<div class="suggestions-header">Search Results</div>
{#each suggestions as suggestion (suggestion.placeId)}
<button
class="suggestion-item"
onclick={() => selectPlace(suggestion.placeId, suggestion.description)}
disabled={isLoading}
>
<div class="suggestion-content">
<span class="suggestion-name">{suggestion.description}</span>
<div class="suggestion-types">
{#each suggestion.types.slice(0, 2) as type}
<span class="suggestion-type">{type.replace(/_/g, ' ')}</span>
{/each}
</div>
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="suggestion-icon">
<path
d="M21 10C21 17 12 23 12 23C12 23 3 17 3 10C3 5.58172 6.58172 2 12 2C17.4183 2 21 5.58172 21 10Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
</svg>
</button>
{/each}
</div>
{/if}
{#if showNearby && nearbyPlaces.length > 0}
<div class="suggestions-dropdown">
<div class="suggestions-header">Nearby Places</div>
{#each nearbyPlaces as place (place.placeId)}
<button
class="suggestion-item"
onclick={() => selectNearbyPlace(place)}
disabled={isLoading}
>
<div class="suggestion-content">
<span class="suggestion-name">{place.name}</span>
<span class="suggestion-address">{place.vicinity || place.formattedAddress}</span>
{#if place.rating}
<div class="suggestion-rating">
<span class="rating-stars"></span>
<span>{place.rating.toFixed(1)}</span>
</div>
{/if}
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="suggestion-icon">
<path
d="M21 10C21 17 12 23 12 23C12 23 3 17 3 10C3 5.58172 6.58172 2 12 2C17.4183 2 21 5.58172 21 10Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
</svg>
</button>
{/each}
</div>
{/if}
{#if isLoading}
<div class="loading-indicator">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" class="loading-spinner">
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
stroke-linecap="round"
stroke-dasharray="32"
stroke-dashoffset="32"
/>
</svg>
<span>Searching...</span>
</div>
{/if}
</div>
<style>
.poi-search-container {
position: relative;
}
.field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.search-input-container {
position: relative;
display: flex;
gap: 0.5rem;
}
.suggestions-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-height: 300px;
overflow-y: auto;
z-index: 1000;
margin-top: 0.25rem;
backdrop-filter: blur(8px);
}
.suggestions-header {
padding: 0.75rem;
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
border-bottom: 1px solid hsl(var(--border));
background: hsl(var(--muted));
backdrop-filter: blur(12px);
}
.suggestion-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
border: none;
background: hsl(var(--background));
text-align: left;
cursor: pointer;
transition: background-color 0.2s ease;
border-bottom: 1px solid hsl(var(--border));
backdrop-filter: blur(12px);
}
.suggestion-item:last-child {
border-bottom: none;
}
.suggestion-item:hover:not(:disabled) {
background: hsl(var(--muted) / 0.5);
}
.suggestion-item:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.suggestion-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
}
.suggestion-name {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--foreground));
}
.suggestion-address {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.suggestion-types {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.suggestion-type {
font-size: 0.625rem;
padding: 0.125rem 0.375rem;
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
border-radius: 4px;
text-transform: capitalize;
}
.suggestion-rating {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.rating-stars {
color: #fbbf24;
}
.suggestion-icon {
color: hsl(var(--muted-foreground));
flex-shrink: 0;
}
.loading-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
color: hsl(var(--muted-foreground));
font-size: 0.875rem;
}
.loading-spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (max-width: 640px) {
.search-input-container {
flex-direction: column;
}
}
</style>

194
src/lib/utils/places.ts Normal file
View File

@@ -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<PlaceResult[]> {
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<Array<{ placeId: string; description: string; types: string[] }>> {
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<PlaceResult> {
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<PlaceResult[]> {
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);
}

View File

@@ -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');
}
};