localtoast Public
Comparing changes
Choose two branches to see what's changed or to start a new pull request.
Able to merge.
These branches can be automatically merged.
14 commits
12 files changed
2 contributors
Commits on trunk
backend/api/urls.pymodified@@ -5,6 +5,9 @@ urlpatterns = [ | ||
| 5 | 5 | # Health check |
| 6 | 6 | path('health/', views.health_check, name='health_check'), |
| 7 | 7 | |
| 8 | + # CORS test endpoint | |
| 9 | + path('cors-test/', views.cors_test, name='cors_test'), | |
| 10 | + | |
| 8 | 11 | # Restaurant endpoints |
| 9 | 12 | path('restaurants/', views.RestaurantListCreateView.as_view(), name='restaurant_list_create'), |
| 10 | 13 | path('restaurants/nearby/', views.NearbyRestaurantsView.as_view(), name='restaurants_nearby'), |
backend/api/views.pymodified@@ -1,4 +1,4 @@ | ||
| 1 | -from django.http import JsonResponse | |
| 1 | +from django.http import JsonResponse, HttpResponse | |
| 2 | 2 | from django.utils import timezone |
| 3 | 3 | from django.conf import settings |
| 4 | 4 | from django.db.models import Q |
@@ -39,6 +39,15 @@ def calculate_distance(lat1, lon1, lat2, lon2): | ||
| 39 | 39 | class NearbyRestaurantsView(APIView): |
| 40 | 40 | """Get restaurants near a location""" |
| 41 | 41 | |
| 42 | + def options(self, request, *args, **kwargs): | |
| 43 | + """Handle preflight requests""" | |
| 44 | + response = HttpResponse() | |
| 45 | + response['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') | |
| 46 | + response['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS' | |
| 47 | + response['Access-Control-Allow-Headers'] = 'Content-Type, Accept, X-Requested-With' | |
| 48 | + response['Access-Control-Max-Age'] = '3600' | |
| 49 | + return response | |
| 50 | + | |
| 42 | 51 | def get(self, request): |
| 43 | 52 | lat = request.query_params.get('lat') |
| 44 | 53 | lng = request.query_params.get('lng') |
@@ -93,6 +102,15 @@ class RestaurantListCreateView(generics.ListCreateAPIView): | ||
| 93 | 102 | queryset = Restaurant.objects.all() |
| 94 | 103 | serializer_class = RestaurantSerializer |
| 95 | 104 | |
| 105 | + def options(self, request, *args, **kwargs): | |
| 106 | + """Handle preflight requests""" | |
| 107 | + response = HttpResponse() | |
| 108 | + response['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') | |
| 109 | + response['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS' | |
| 110 | + response['Access-Control-Allow-Headers'] = 'Content-Type, Accept, X-Requested-With' | |
| 111 | + response['Access-Control-Max-Age'] = '3600' | |
| 112 | + return response | |
| 113 | + | |
| 96 | 114 | def create(self, request, *args, **kwargs): |
| 97 | 115 | # Check if restaurant already exists |
| 98 | 116 | place_id = request.data.get('place_id') |
@@ -109,11 +127,29 @@ class RestaurantDetailView(generics.RetrieveAPIView): | ||
| 109 | 127 | """Get detailed info about a restaurant""" |
| 110 | 128 | queryset = Restaurant.objects.all() |
| 111 | 129 | serializer_class = RestaurantDetailSerializer |
| 130 | + | |
| 131 | + def options(self, request, *args, **kwargs): | |
| 132 | + """Handle preflight requests""" | |
| 133 | + response = HttpResponse() | |
| 134 | + response['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') | |
| 135 | + response['Access-Control-Allow-Methods'] = 'GET, OPTIONS' | |
| 136 | + response['Access-Control-Allow-Headers'] = 'Content-Type, Accept, X-Requested-With' | |
| 137 | + response['Access-Control-Max-Age'] = '3600' | |
| 138 | + return response | |
| 112 | 139 | |
| 113 | 140 | |
| 114 | 141 | class RestaurantRatingView(APIView): |
| 115 | 142 | """Add a rating to a restaurant or get all ratings""" |
| 116 | 143 | |
| 144 | + def options(self, request, *args, **kwargs): | |
| 145 | + """Handle preflight requests""" | |
| 146 | + response = HttpResponse() | |
| 147 | + response['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') | |
| 148 | + response['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS' | |
| 149 | + response['Access-Control-Allow-Headers'] = 'Content-Type, Accept, X-Requested-With' | |
| 150 | + response['Access-Control-Max-Age'] = '3600' | |
| 151 | + return response | |
| 152 | + | |
| 117 | 153 | def get(self, request, pk): |
| 118 | 154 | try: |
| 119 | 155 | restaurant = Restaurant.objects.get(pk=pk) |
@@ -152,6 +188,15 @@ class RestaurantRatingView(APIView): | ||
| 152 | 188 | class RestaurantToastStatusView(APIView): |
| 153 | 189 | """Update restaurant's toast status""" |
| 154 | 190 | |
| 191 | + def options(self, request, *args, **kwargs): | |
| 192 | + """Handle preflight requests""" | |
| 193 | + response = HttpResponse() | |
| 194 | + response['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') | |
| 195 | + response['Access-Control-Allow-Methods'] = 'PATCH, OPTIONS' | |
| 196 | + response['Access-Control-Allow-Headers'] = 'Content-Type, Accept, X-Requested-With' | |
| 197 | + response['Access-Control-Max-Age'] = '3600' | |
| 198 | + return response | |
| 199 | + | |
| 155 | 200 | def patch(self, request, pk): |
| 156 | 201 | try: |
| 157 | 202 | restaurant = Restaurant.objects.get(pk=pk) |
@@ -181,6 +226,15 @@ class RestaurantToastStatusView(APIView): | ||
| 181 | 226 | class SearchPlacesView(APIView): |
| 182 | 227 | """Search for places that might serve toast""" |
| 183 | 228 | |
| 229 | + def options(self, request, *args, **kwargs): | |
| 230 | + """Handle preflight requests""" | |
| 231 | + response = HttpResponse() | |
| 232 | + response['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*') | |
| 233 | + response['Access-Control-Allow-Methods'] = 'GET, OPTIONS' | |
| 234 | + response['Access-Control-Allow-Headers'] = 'Content-Type, Accept, X-Requested-With' | |
| 235 | + response['Access-Control-Max-Age'] = '3600' | |
| 236 | + return response | |
| 237 | + | |
| 184 | 238 | def get(self, request): |
| 185 | 239 | lat = request.query_params.get('lat') |
| 186 | 240 | lng = request.query_params.get('lng') |
@@ -353,6 +407,23 @@ class SearchPlacesView(APIView): | ||
| 353 | 407 | ] |
| 354 | 408 | |
| 355 | 409 | |
| 410 | +@api_view(['GET', 'OPTIONS']) | |
| 411 | +def cors_test(request): | |
| 412 | + """Test endpoint for CORS""" | |
| 413 | + if request.method == 'OPTIONS': | |
| 414 | + response = HttpResponse() | |
| 415 | + response['Access-Control-Allow-Origin'] = '*' | |
| 416 | + response['Access-Control-Allow-Methods'] = 'GET, OPTIONS' | |
| 417 | + response['Access-Control-Allow-Headers'] = '*' | |
| 418 | + return response | |
| 419 | + | |
| 420 | + return Response({ | |
| 421 | + 'status': 'CORS test successful', | |
| 422 | + 'origin': request.headers.get('Origin', 'No origin header'), | |
| 423 | + 'method': request.method | |
| 424 | + }) | |
| 425 | + | |
| 426 | + | |
| 356 | 427 | @api_view(['POST']) |
| 357 | 428 | def seed_data(request): |
| 358 | 429 | """Seed the database with sample restaurants""" |
backend/localtoast/settings.pymodified@@ -31,7 +31,7 @@ INSTALLED_APPS = [ | ||
| 31 | 31 | |
| 32 | 32 | MIDDLEWARE = [ |
| 33 | 33 | 'django.middleware.security.SecurityMiddleware', |
| 34 | - 'corsheaders.middleware.CorsMiddleware', | |
| 34 | + 'corsheaders.middleware.CorsMiddleware', # Must be before CommonMiddleware | |
| 35 | 35 | 'django.middleware.common.CommonMiddleware', |
| 36 | 36 | 'django.middleware.csrf.CsrfViewMiddleware', |
| 37 | 37 | 'django.contrib.sessions.middleware.SessionMiddleware', |
@@ -106,16 +106,45 @@ REST_FRAMEWORK = { | ||
| 106 | 106 | ], |
| 107 | 107 | } |
| 108 | 108 | |
| 109 | -# CORS settings | |
| 109 | +# CORS settings - Updated for better compatibility | |
| 110 | 110 | CORS_ALLOWED_ORIGINS = [ |
| 111 | 111 | "http://localhost:3000", |
| 112 | 112 | "http://localhost:3001", |
| 113 | - "https://localtoast.vercel.app", # change me matt | |
| 113 | + "https://localtoast-frontend.vercel.app", | |
| 114 | + "https://www.localtoast-frontend.vercel.app", | |
| 115 | + "https://www.localtoast.fyi", | |
| 116 | + "https://localtoast.fyi", | |
| 114 | 117 | ] |
| 115 | 118 | |
| 116 | -# Allow CORS during development | |
| 117 | -if DEBUG: | |
| 118 | - CORS_ALLOW_ALL_ORIGINS = True | |
| 119 | +# Add frontend URL from environment if provided | |
| 120 | +if os.getenv('FRONTEND_URL'): | |
| 121 | + CORS_ALLOWED_ORIGINS.append(os.getenv('FRONTEND_URL')) | |
| 122 | + | |
| 123 | +# Explicit CORS configuration | |
| 124 | +CORS_ALLOW_ALL_ORIGINS = False # Never True in production | |
| 125 | +CORS_ALLOW_CREDENTIALS = True | |
| 126 | +CORS_PREFLIGHT_MAX_AGE = 86400 | |
| 127 | + | |
| 128 | +CORS_ALLOW_METHODS = [ | |
| 129 | + 'DELETE', | |
| 130 | + 'GET', | |
| 131 | + 'OPTIONS', | |
| 132 | + 'PATCH', | |
| 133 | + 'POST', | |
| 134 | + 'PUT', | |
| 135 | +] | |
| 136 | + | |
| 137 | +CORS_ALLOW_HEADERS = [ | |
| 138 | + 'accept', | |
| 139 | + 'accept-encoding', | |
| 140 | + 'authorization', | |
| 141 | + 'content-type', | |
| 142 | + 'dnt', | |
| 143 | + 'origin', | |
| 144 | + 'user-agent', | |
| 145 | + 'x-csrftoken', | |
| 146 | + 'x-requested-with', | |
| 147 | +] | |
| 119 | 148 | |
| 120 | 149 | # Media files |
| 121 | 150 | MEDIA_URL = '/media/' |
backend/railway.jsonmodified@@ -2,10 +2,10 @@ | ||
| 2 | 2 | "$schema": "https://railway.app/railway.schema.json", |
| 3 | 3 | "build": { |
| 4 | 4 | "builder": "NIXPACKS", |
| 5 | - "buildCommand": "python manage.py collectstatic --noinput" | |
| 5 | + "buildCommand": "python manage.py collectstatic --noinput && python manage.py migrate" | |
| 6 | 6 | }, |
| 7 | 7 | "deploy": { |
| 8 | - "startCommand": "python manage.py migrate && gunicorn localtoast.wsgi", | |
| 8 | + "startCommand": "gunicorn localtoast.wsgi:application --bind 0.0.0.0:$PORT", | |
| 9 | 9 | "restartPolicyType": "ON_FAILURE", |
| 10 | 10 | "restartPolicyMaxRetries": 10 |
| 11 | 11 | } |
frontend/app/page.tsxmodified@@ -2,8 +2,8 @@ | ||
| 2 | 2 | |
| 3 | 3 | import { useState, useEffect } from 'react'; |
| 4 | 4 | import dynamic from 'next/dynamic'; |
| 5 | -import { MapPin, Plus, Loader2 } from 'lucide-react'; | |
| 6 | -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; | |
| 5 | +import { Plus, Loader2 } from 'lucide-react'; | |
| 6 | +import { useMutation } from '@tanstack/react-query'; | |
| 7 | 7 | import { Providers } from './providers'; |
| 8 | 8 | import { Loading } from '@/components/Loading'; |
| 9 | 9 | import ReviewModal from '@/components/ReviewModal'; |
@@ -16,6 +16,15 @@ const Map = dynamic(() => import('@/components/Map'), { | ||
| 16 | 16 | loading: () => <div className="h-full w-full bg-gray-100 animate-pulse" />, |
| 17 | 17 | }); |
| 18 | 18 | |
| 19 | +// Type for API errors | |
| 20 | +type ApiError = { | |
| 21 | + response?: { | |
| 22 | + data?: { | |
| 23 | + error?: string; | |
| 24 | + }; | |
| 25 | + }; | |
| 26 | +}; | |
| 27 | + | |
| 19 | 28 | function HomePage() { |
| 20 | 29 | const [userLocation, setUserLocation] = useState<{ lat: number; lng: number } | null>(null); |
| 21 | 30 | const [selectedRestaurant, setSelectedRestaurant] = useState<Restaurant | null>(null); |
@@ -23,7 +32,6 @@ function HomePage() { | ||
| 23 | 32 | const [searchResultsMinimized, setSearchResultsMinimized] = useState(false); |
| 24 | 33 | const [searchResults, setSearchResults] = useState<PlaceSearchResult[]>([]); |
| 25 | 34 | const [restaurants, setRestaurants] = useState<Restaurant[]>([]); |
| 26 | - const queryClient = useQueryClient(); | |
| 27 | 35 | |
| 28 | 36 | // Get user's location |
| 29 | 37 | useEffect(() => { |
@@ -58,8 +66,9 @@ function HomePage() { | ||
| 58 | 66 | } |
| 59 | 67 | alert('Toast rating added! 🍞'); |
| 60 | 68 | }, |
| 61 | - onError: (error: any) => { | |
| 62 | - alert(error.response?.data?.error || 'Failed to add rating'); | |
| 69 | + onError: (error: unknown) => { | |
| 70 | + const apiError = error as ApiError; | |
| 71 | + alert(apiError.response?.data?.error || 'Failed to add rating'); | |
| 63 | 72 | }, |
| 64 | 73 | }); |
| 65 | 74 | |
@@ -79,8 +88,9 @@ function HomePage() { | ||
| 79 | 88 | setShowSearchResults(true); |
| 80 | 89 | setSelectedRestaurant(null); // Close any open restaurant panel |
| 81 | 90 | }, |
| 82 | - onError: () => { | |
| 83 | - alert('Failed to search for places'); | |
| 91 | + onError: (error: unknown) => { | |
| 92 | + const apiError = error as ApiError; | |
| 93 | + alert(apiError.response?.data?.error || 'Failed to search for places'); | |
| 84 | 94 | }, |
| 85 | 95 | }); |
| 86 | 96 | |
@@ -95,8 +105,9 @@ function HomePage() { | ||
| 95 | 105 | setRestaurants(updatedRestaurants); |
| 96 | 106 | } |
| 97 | 107 | }, |
| 98 | - onError: () => { | |
| 99 | - alert('Failed to update toast status'); | |
| 108 | + onError: (error: unknown) => { | |
| 109 | + const apiError = error as ApiError; | |
| 110 | + alert(apiError.response?.data?.error || 'Failed to update toast status'); | |
| 100 | 111 | }, |
| 101 | 112 | }); |
| 102 | 113 | |
frontend/components/Map.tsxmodified@@ -8,8 +8,13 @@ import { Star, ThumbsUp, ThumbsDown, MessageSquare } from 'lucide-react'; | ||
| 8 | 8 | import { Restaurant } from '@/lib/api'; |
| 9 | 9 | |
| 10 | 10 | // Fix for default markers in React-Leaflet |
| 11 | +interface LeafletIconDefault extends L.Icon.Default { | |
| 12 | + _getIconUrl?: string; | |
| 13 | +} | |
| 14 | + | |
| 11 | 15 | if (typeof window !== 'undefined') { |
| 12 | - delete (L.Icon.Default.prototype as any)._getIconUrl; | |
| 16 | + const iconDefault = L.Icon.Default.prototype as LeafletIconDefault; | |
| 17 | + delete iconDefault._getIconUrl; | |
| 13 | 18 | L.Icon.Default.mergeOptions({ |
| 14 | 19 | iconRetinaUrl: '/leaflet/marker-icon-2x.png', |
| 15 | 20 | iconUrl: '/leaflet/marker-icon.png', |
frontend/components/RestaurantPanel.tsxmodified@@ -26,8 +26,6 @@ export default function RestaurantPanel({ restaurant, onClose, onAddRating }: Re | ||
| 26 | 26 | setShowRatingForm(false); |
| 27 | 27 | setRating(5); |
| 28 | 28 | setReview(''); |
| 29 | - } catch (error) { | |
| 30 | - // Error handling done in parent | |
| 31 | 29 | } finally { |
| 32 | 30 | setIsSubmitting(false); |
| 33 | 31 | } |
frontend/components/ReviewModal.tsxmodified@@ -23,7 +23,7 @@ export default function ReviewModal({ restaurant, onClose, onSubmit }: ReviewMod | ||
| 23 | 23 | try { |
| 24 | 24 | await onSubmit({ rating, review }); |
| 25 | 25 | onClose(); |
| 26 | - } catch (error) { | |
| 26 | + } catch { | |
| 27 | 27 | // Error handling done in parent |
| 28 | 28 | } finally { |
| 29 | 29 | setIsSubmitting(false); |
frontend/components/SearchPanel.tsxmodified@@ -1,7 +1,7 @@ | ||
| 1 | 1 | 'use client'; |
| 2 | 2 | |
| 3 | 3 | import { useState } from 'react'; |
| 4 | -import { X, ThumbsUp, ThumbsDown, MessageSquare, MapPin, Star } from 'lucide-react'; | |
| 4 | +import { ThumbsUp, ThumbsDown, MessageSquare, MapPin } from 'lucide-react'; | |
| 5 | 5 | import { PlaceSearchResult } from '@/lib/api'; |
| 6 | 6 | |
| 7 | 7 | interface SearchPanelProps { |
frontend/lib/api.tsmodified@@ -90,12 +90,23 @@ export const restaurantApi = { | ||
| 90 | 90 | return response.data; |
| 91 | 91 | }, |
| 92 | 92 | |
| 93 | - // Search places | |
| 93 | + // Search places - FIXED to handle 404 with data | |
| 94 | 94 | searchPlaces: async (lat: number, lng: number, radius: number = 1000) => { |
| 95 | - const response = await api.get<PlaceSearchResult[]>('/search/places/', { | |
| 96 | - params: { lat, lng, radius } | |
| 97 | - }); | |
| 98 | - return response.data; | |
| 95 | + try { | |
| 96 | + const response = await api.get<PlaceSearchResult[]>('/search/places/', { | |
| 97 | + params: { lat, lng, radius } | |
| 98 | + }); | |
| 99 | + return response.data; | |
| 100 | + } catch (error) { | |
| 101 | + // Handle the bizarre 404-with-data issue from Railway | |
| 102 | + if (axios.isAxiosError(error)) { | |
| 103 | + if (error.response?.status === 404 && Array.isArray(error.response?.data)) { | |
| 104 | + console.warn('Got 404 with valid data - returning data anyway'); | |
| 105 | + return error.response.data as PlaceSearchResult[]; | |
| 106 | + } | |
| 107 | + } | |
| 108 | + throw error; | |
| 109 | + } | |
| 99 | 110 | }, |
| 100 | 111 | |
| 101 | 112 | // Seed data |
frontend/next.config.tsmodified@@ -1,6 +1,8 @@ | ||
| 1 | 1 | import type { NextConfig } from "next"; |
| 2 | 2 | |
| 3 | 3 | const nextConfig: NextConfig = { |
| 4 | + | |
| 5 | + output: 'standalone', | |
| 4 | 6 | // Disable SSR for Leaflet components |
| 5 | 7 | transpilePackages: ['leaflet'], |
| 6 | 8 | |
frontend/vercel.jsonadded@@ -0,0 +1,4 @@ | ||
| 1 | +{ | |
| 2 | + "framework": "nextjs", | |
| 3 | + "outputDirectory": ".next" | |
| 4 | +} | |