diff --git a/backend/api/views.py b/backend/api/views.py index 0a82bfa..8912666 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -39,16 +39,65 @@ class FlavorViewSet(viewsets.ModelViewSet): permission_classes = [permissions.IsAuthenticatedOrReadOnly] def get_queryset(self): - return Flavor.objects.select_related('category').annotate(average_rating=Avg('ratings__score')).order_by('-average_rating').distinct() + qs = Flavor.objects.select_related('category').annotate(average_rating=Avg('ratings__score')).order_by('-average_rating').distinct() + + # Handle category keywords in search query for main results too + search_query = self.request.query_params.get('search', '').lower().strip() + if search_query: + cat_keywords = { + 'iced tea': 'iced-tea', + 'eistee': 'iced-tea', + 'energy': 'energy', + 'hydration': 'hydration', + 'milkshake': 'milkshake' + } + for keyword, slug in cat_keywords.items(): + if keyword in search_query: + qs = qs.filter(category__slug=slug) + break # Only one category at a time + + return qs @action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny]) def search(self, request): - # Global unpaginated search for autocomplete - results = [] + query = request.query_params.get('q', '').lower().strip() + if not query: + return Response([]) + + # Start with all flavors + flavors = Flavor.objects.select_related('category').distinct() + + # Intelligent category filtering based on keywords + cat_keywords = { + 'iced tea': 'iced-tea', + 'eistee': 'iced-tea', + 'energy': 'energy', + 'hydration': 'hydration', + 'milkshake': 'milkshake' + } + + active_cat_slug = None + remaining_query = query + for keyword, slug in cat_keywords.items(): + if keyword in query: + active_cat_slug = slug + remaining_query = remaining_query.replace(keyword, '').strip() + break - # 1. Flavors - flavors = Flavor.objects.select_related('category').order_by('name').distinct() - for f in flavors: + if active_cat_slug: + flavors = flavors.filter(category__slug=active_cat_slug) + + # Filter by remaining query words in name or description + if remaining_query: + words = remaining_query.split() + for word in words: + flavors = flavors.filter( + Q(name__icontains=word) | Q(description__icontains=word) + ) + + # Limit results for performance + results = [] + for f in flavors.order_by('name')[:15]: results.append({ 'id': f.id, 'name': f.name, @@ -57,30 +106,6 @@ class FlavorViewSet(viewsets.ModelViewSet): 'image_url': request.build_absolute_uri(f.image.url) if f.image else f.image_url, 'slug': None }) - - # 2. Categories - categories = Category.objects.all().order_by('name') - for c in categories: - results.append({ - 'id': c.id, - 'name': c.name, - 'type': 'category', - 'subtitle': 'Category', - 'image_url': None, - 'slug': c.slug - }) - - # 3. Users - users = User.objects.filter(is_active=True).order_by('username') - for u in users: - results.append({ - 'id': u.id, - 'name': u.username, - 'type': 'user', - 'subtitle': 'Community Member', - 'image_url': request.build_absolute_uri(u.avatar.url) if u.avatar else None, - 'slug': u.username - }) return Response(results) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0ce0498..a4d35ea 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -79,28 +79,37 @@ const GlobalSearch = () => { const { t } = useTranslation(); const [query, setQuery] = useState(''); const [options, setOptions] = useState([]); + const [loading, setLoading] = useState(false); const navigate = useNavigate(); const location = useLocation(); + // Debounced search useEffect(() => { - const fetchSearchOptions = async () => { + if (!query.trim()) { + setOptions([]); + return; + } + + const timer = setTimeout(async () => { + setLoading(true); try { - // Fetch unified results: flavors, categories, and users - const res = await api.get('flavors/search/'); - // Filter to only flavors - const results = (Array.isArray(res.data) ? res.data : (res.data.results || [])) - .filter((item: any) => item.type === 'flavor'); + const res = await api.get(`flavors/search/?q=${encodeURIComponent(query)}`); + const results = Array.isArray(res.data) ? res.data : (res.data.results || []); setOptions(results); } catch (err) { - console.error('Failed to fetch search options'); + console.error('Search failed'); + } finally { + setLoading(false); } - }; - fetchSearchOptions(); - }, []); + }, 300); + + return () => clearTimeout(timer); + }, [query]); useEffect(() => { const params = new URLSearchParams(location.search); - setQuery(params.get('q') || ''); + const q = params.get('q'); + if (q) setQuery(q); }, [location.search]); const handleSearch = (e: React.FormEvent) => { @@ -118,7 +127,9 @@ const GlobalSearch = () => { fullWidth freeSolo size="small" + loading={loading} options={options} + filterOptions={(x) => x} // Disable local filtering as we do it on server getOptionLabel={(option) => { if (typeof option === 'string') return option; return option.name || ''; @@ -155,6 +166,12 @@ const GlobalSearch = () => { ), + endAdornment: ( + + {loading ? : null} + {params.InputProps.endAdornment} + + ), }} /> )} @@ -162,15 +179,13 @@ const GlobalSearch = () => { const { key, ...optionProps } = props as any; return ( - {option.image_url ? ( - - ) : ( - - {option.name.charAt(0).toUpperCase()} - - )} + - {option.name} + {option.name} {option.subtitle}