Simple Search Bar Component

A minimal, ready-to-use search component with MeiliSearch integration.

Installation Requirements

Before using this component, make sure your project meets the following requirements:

  • Next.js: 14.0.0 or higher
  • Tailwind CSS: 3.3.0 or higher
  • Node.js: 18.17.0 or higher

To install the required dependencies, run:

npm install meilisearch lodash

Simple Search Bar

A clean, minimal search bar with instant results from the monis.rent rental marketplace.

Live Demo

Code

'use client'

import { useState, useEffect, useRef } from 'react'
import { debounce } from 'lodash'
import MeiliSearch from "meilisearch"

const SEARCH_INDEX = {
  name: 'monis.rent',
  id: 'ITGbps8D35v3LXzX3WXX',
  description: 'Rental marketplace',
  meiliApiKey: '86ef24ee88f4053cb165cfed5be82a213871eb16113bd17fbc2ba25d7d19b298',
  showSearchImage: true
}

export default function SimpleSearchBar() {
  const [query, setQuery] = useState('')
  const [searchResults, setSearchResults] = useState([])
  const [showLoading, setShowLoading] = useState(false)
  const [showResults, setShowResults] = useState(false)
  const searchContainerRef = useRef(null)

  // Create MeiliSearch client
  const getMeiliClient = () => {
    return new MeiliSearch({
      host: 'https://client.searchfa.st/',
      apiKey: SEARCH_INDEX.meiliApiKey
    })
  }

  useEffect(() => {
    const handleClickOutside = (event) => {
      if (searchContainerRef.current && !searchContainerRef.current.contains(event.target)) {
        setShowResults(false)
      }
    }

    document.addEventListener('mousedown', handleClickOutside)
    return () => {
      document.removeEventListener('mousedown', handleClickOutside)
    }
  }, [])

  const handleInputClick = () => {
    if (query) {
      setShowResults(true)
    }
  }

  const handleChange = (e) => {
    const newQuery = e.target.value
    setQuery(newQuery)
    setShowResults(true)
    if (newQuery.length >= 2) {
      debouncedSearch(newQuery)
    } else {
      setSearchResults([])
    }
  }

  const debouncedSearch = debounce(async (searchQuery) => {
    if (searchQuery !== '') {
      setShowLoading(true)
      try {
        const meiliClient = getMeiliClient()
        const index = meiliClient.index(SEARCH_INDEX.id)
        const response = await index.search(searchQuery, {
          limit: 10,
          attributesToRetrieve: ['id', 'title', 'description', 'img', 'price', 'category', 'location', 'merchant', 'url']
        })
        setSearchResults(response.hits)
      } catch (error) {
        console.error("Error searching:", error)
        setSearchResults([])
      } finally {
        setShowLoading(false)
      }
    }
  }, 400)

  return (
    <div className="relative" ref={searchContainerRef}>
      <div className="relative">
        <input
          className="h-12 w-full pl-11 pr-4 text-gray-900 outline-none border border-gray-300 rounded-lg placeholder:text-gray-600 sm:text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
          placeholder="Search for anything..."
          onChange={handleChange}
          onClick={handleInputClick}
          value={query}
        />
        
        {!showLoading && (
          <svg className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
          </svg>
        )}

        {showLoading && (
          <div className="absolute left-4 top-1/2 transform -translate-y-1/2">
            <svg className="animate-spin h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
              <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
              <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
            </svg>
          </div>
        )}
      </div>

      {/* Search Results Dropdown */}
      {showResults && query !== '' && (
        <div className="absolute left-0 right-0 mt-2 bg-white rounded-lg shadow-lg border border-gray-200 max-h-96 overflow-hidden z-10">
          <div className="overflow-y-auto max-h-96">
            <ul className="divide-y divide-gray-100">
              {searchResults.length > 0 ? (
                searchResults.map(result => (
                  <li key={result.id} className="p-3 hover:bg-gray-50 cursor-pointer">
                    <div className="flex items-center space-x-3">
                      {SEARCH_INDEX.showSearchImage && result.img && (
                        <img
                          src={result.img}
                          alt={result.title}
                          className="flex-none w-10 h-10 rounded-md object-cover"
                        />
                      )}
                      <div className="flex-1 min-w-0">
                        <div className="flex items-center">
                          <p className="font-semibold text-gray-900 truncate">{result.title}</p>
                          {result.category && (
                            <span className="ml-2 inline-block px-2 py-0 text-xs font-semibold border border-gray-300 text-gray-600 rounded-full">
                              {result.category}
                            </span>
                          )}
                        </div>
                        <div className="flex items-center text-sm text-gray-600">
                          <p className="truncate">{result.description || ''}</p>
                        </div>
                      </div>
                    </div>
                  </li>
                ))
              ) : (
                <li className="p-4 text-center text-gray-500">
                  No results found
                </li>
              )}
            </ul>
          </div>
        </div>
      )}
    </div>
  )
}

Simple AI Button

A customizable AI button component that opens a modal for asking questions. Easy to integrate with your own API endpoint.

Live Demo

Code

'use client'

import { useState } from 'react'
import { SparklesIcon } from '@heroicons/react/24/outline'
import MeiliSearch from "meilisearch"

export default function SimpleAiButton({ 
  apiEndpoint = '/api/ai', 
  placeholder = 'Ask me anything...',
  buttonText = 'Ask AI',
  className = '',
  searchIndex = {
    name: 'monis.rent',
    id: 'ITGbps8D35v3LXzX3WXX',
    meiliApiKey: '86ef24ee88f4053cb165cfed5be82a213871eb16113bd17fbc2ba25d7d19b298'
  }
}) {
  const [isOpen, setIsOpen] = useState(false)
  const [question, setQuestion] = useState('')
  const [loading, setLoading] = useState(false)
  const [answer, setAnswer] = useState('')
  const [searchResults, setSearchResults] = useState([])
  const [showResults, setShowResults] = useState(false)

  const handleSubmit = async (e) => {
    e.preventDefault()
    if (!question.trim()) return

    setLoading(true)
    setSearchResults([])
    setShowResults(true)
    setAnswer('')

    try {
      // First, search for relevant content using MeiliSearch
      const meiliClient = new MeiliSearch({
        host: 'https://client.searchfa.st/',
        apiKey: searchIndex.meiliApiKey
      })
      const index = meiliClient.index(searchIndex.id)
      
      const searchResponse = await index.search(question.trim(), {
        limit: 5,
        attributesToRetrieve: ['id', 'title', 'description', 'img', 'price', 'category', 'location', 'merchant', 'url', 'texts'],
        hybrid: {
          embedder: "openai",
          semanticRatio: 0.5
        },
        showRankingScore: true
      })

      setSearchResults(searchResponse.hits)

      // Then call the AI API with search results
      const response = await fetch(apiEndpoint, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          question: question.trim(),
          searchResults: searchResponse.hits
        }),
      })

      if (!response.ok) {
        throw new Error('Failed to get AI response')
      }

      const data = await response.json()
      setAnswer(data.answer || data.response || 'No response received')
    } catch (error) {
      console.error('Error:', error)
      setAnswer('Sorry, I encountered an error while processing your question.')
    } finally {
      setLoading(false)
    }
  }

  return (
    <>
      {/* AI Button */}
      <button
        onClick={() => setIsOpen(true)}
        className={`inline-flex items-center gap-2 px-4 py-2 text-sm font-medium border border-gray-300 rounded-lg bg-white shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors ${className}`}
      >
        <SparklesIcon className="w-4 h-4 text-indigo-500" />
        {buttonText}
      </button>

      {/* Modal */}
      {isOpen && (
        <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
          {/* Backdrop */}
          <div 
            className="fixed inset-0 bg-black bg-opacity-30" 
            onClick={() => setIsOpen(false)}
          />
          
          {/* Modal Content */}
          <div className="relative bg-white rounded-xl shadow-xl w-full max-w-md mx-auto">
            <div className="p-6">
              {/* Header */}
              <div className="flex items-center justify-between mb-4">
                <div className="flex items-center gap-2">
                  <div className="bg-indigo-100 p-2 rounded-full">
                    <SparklesIcon className="w-4 h-4 text-indigo-600" />
                  </div>
                  <h2 className="text-lg font-semibold text-gray-900">Ask AI</h2>
                </div>
                <button 
                  onClick={() => setIsOpen(false)}
                  className="text-gray-400 hover:text-gray-600 text-2xl leading-none"
                >
                  ×
                </button>
              </div>

              {/* Form */}
              <form onSubmit={handleSubmit} className="space-y-4">
                <div>
                  <input
                    type="text"
                    value={question}
                    onChange={(e) => setQuestion(e.target.value)}
                    placeholder={placeholder}
                    className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
                    disabled={loading}
                  />
                </div>
                
                <button
                  type="submit"
                  disabled={!question.trim() || loading}
                  className="w-full bg-indigo-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
                >
                  {loading ? 'Processing...' : 'Ask Question'}
                </button>
              </form>

              {/* Loading State */}
              {loading && (
                <div className="mt-4 flex items-center gap-2 text-sm text-gray-600">
                  <div className="animate-spin h-4 w-4 border-2 border-indigo-500 border-t-transparent rounded-full"></div>
                  Generating answer...
                </div>
              )}

              {/* Answer */}
              {answer && (
                <div className="mt-4 p-4 bg-gray-50 rounded-lg">
                  <h3 className="text-sm font-medium text-gray-900 mb-2">Answer:</h3>
                  <p className="text-sm text-gray-700 whitespace-pre-wrap">{answer}</p>
                </div>
              )}

              {/* Search Results */}
              {showResults && searchResults.length > 0 && (
                <div className="mt-4">
                  <div className="text-xs text-gray-500 mb-2">Found relevant pages:</div>
                  <div className="space-y-1">
                    {searchResults.map((result, index) => (
                      <div key={index} className="text-xs text-gray-600 bg-gray-50 p-2 rounded flex items-center gap-2">
                        <span className="text-gray-400">{(result._rankingScore * 100).toFixed(0)}%</span>
                        <a 
                          href={result.url.startsWith('http') ? result.url : `https://${result.url}`} 
                          target="_blank" 
                          rel="noopener noreferrer" 
                          className="text-indigo-600 hover:text-indigo-800 truncate"
                        >
                          {result.url}
                        </a>
                        {result.texts && (
                          <span className="text-gray-500 truncate">
                            - {result.texts.substring(0, 100)}...
                          </span>
                        )}
                      </div>
                    ))}
                  </div>
                </div>
              )}
            </div>
          </div>
        </div>
      )}
    </>
  )
}

Enhanced AI Button (with Classification)

An advanced AI button that first classifies your question to extract better search terms, then uses those terms for more accurate MeiliSearch results. Compare this with the simple AI button above!

Live Demo

How it works:
1. Analyzes your question to extract relevant search terms
2. Uses those terms for more accurate MeiliSearch queries
3. Shows you the classification results and search terms used
4. Provides AI answers based on better search results

Code

'use client'

import { useState } from 'react'
import { SparklesIcon } from '@heroicons/react/24/outline'
import MeiliSearch from "meilisearch"

export default function EnhancedAiButton({ 
  classifyEndpoint = '/api/ai-classify',
  aiEndpoint = '/api/ai', 
  placeholder = 'Ask me anything...',
  buttonText = 'Ask AI (Enhanced)',
  className = '',
  searchIndex = {
    name: 'monis.rent',
    id: 'ITGbps8D35v3LXzX3WXX',
    meiliApiKey: '86ef24ee88f4053cb165cfed5be82a213871eb16113bd17fbc2ba25d7d19b298'
  }
}) {
  const [isOpen, setIsOpen] = useState(false)
  const [question, setQuestion] = useState('')
  const [loading, setLoading] = useState(false)
  const [answer, setAnswer] = useState('')
  const [searchResults, setSearchResults] = useState([])
  const [showResults, setShowResults] = useState(false)
  const [classification, setClassification] = useState(null)
  const [searchTerms, setSearchTerms] = useState([])

  const handleSubmit = async (e) => {
    e.preventDefault()
    if (!question.trim()) return

    setLoading(true)
    setSearchResults([])
    setShowResults(true)
    setAnswer('')
    setClassification(null)
    setSearchTerms([])

    try {
      // Step 1: Classify the question and extract search terms
      const classifyResponse = await fetch(classifyEndpoint, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          question: question.trim()
        }),
      })

      if (!classifyResponse.ok) {
        throw new Error('Failed to classify question')
      }

      const classifyData = await classifyResponse.json()
      setClassification(classifyData)
      setSearchTerms(classifyData.searchTerms)

      // Step 2: Search using the extracted terms
      const meiliClient = new MeiliSearch({
        host: 'https://client.searchfa.st/',
        apiKey: searchIndex.meiliApiKey
      })
      const index = meiliClient.index(searchIndex.id)
      
      // Use the first suggested query or combine search terms
      const searchQuery = classifyData.suggestedQueries?.[0] || classifyData.searchTerms.join(' ')
      
      const searchResponse = await index.search(searchQuery, {
        limit: 8,
        attributesToRetrieve: ['id', 'title', 'description', 'img', 'price', 'category', 'location', 'merchant', 'url', 'texts'],
        hybrid: {
          embedder: "openai",
          semanticRatio: 0.5
        },
        showRankingScore: true
      })

      setSearchResults(searchResponse.hits)

      // Step 3: Get AI answer using the search results
      const aiResponse = await fetch(aiEndpoint, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          question: question.trim(),
          searchResults: searchResponse.hits
        }),
      })

      if (!aiResponse.ok) {
        throw new Error('Failed to get AI response')
      }

      const aiData = await aiResponse.json()
      setAnswer(aiData.answer || aiData.response || 'No response received')
    } catch (error) {
      console.error('Error:', error)
      setAnswer('Sorry, I encountered an error while processing your question.')
    } finally {
      setLoading(false)
    }
  }

  return (
    <>
      {/* AI Button */}
      <button
        onClick={() => setIsOpen(true)}
        className={`inline-flex items-center gap-2 px-4 py-2 text-sm font-medium border border-gray-300 rounded-lg bg-white shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors ${className}`}
      >
        <SparklesIcon className="w-4 h-4 text-indigo-500" />
        {buttonText}
      </button>

      {/* Modal */}
      {isOpen && (
        <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
          {/* Backdrop */}
          <div 
            className="fixed inset-0 bg-black bg-opacity-30" 
            onClick={() => setIsOpen(false)}
          />
          
          {/* Modal Content */}
          <div className="relative bg-white rounded-xl shadow-xl w-full max-w-2xl mx-auto max-h-[90vh] overflow-y-auto">
            <div className="p-6">
              {/* Header */}
              <div className="flex items-center justify-between mb-4">
                <div className="flex items-center gap-2">
                  <div className="bg-indigo-100 p-2 rounded-full">
                    <SparklesIcon className="w-4 h-4 text-indigo-600" />
                  </div>
                  <h2 className="text-lg font-semibold text-gray-900">Enhanced AI Assistant</h2>
                </div>
                <button 
                  onClick={() => setIsOpen(false)}
                  className="text-gray-400 hover:text-gray-600 text-2xl leading-none"
                >
                  ×
                </button>
              </div>

              {/* Form */}
              <form onSubmit={handleSubmit} className="space-y-4">
                <div>
                  <input
                    type="text"
                    value={question}
                    onChange={(e) => setQuestion(e.target.value)}
                    placeholder={placeholder}
                    className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
                    disabled={loading}
                  />
                </div>
                
                <button
                  type="submit"
                  disabled={!question.trim() || loading}
                  className="w-full bg-indigo-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
                >
                  {loading ? 'Processing...' : 'Ask Question'}
                </button>
              </form>

              {/* Loading State */}
              {loading && (
                <div className="mt-4 space-y-2">
                  <div className="flex items-center gap-2 text-sm text-gray-600">
                    <div className="animate-spin h-4 w-4 border-2 border-indigo-500 border-t-transparent rounded-full"></div>
                    Analyzing question and searching for relevant content...
                  </div>
                </div>
              )}

              {/* Classification Results */}
              {classification && (
                <div className="mt-4 p-4 bg-blue-50 rounded-lg border border-blue-200">
                  <h3 className="text-sm font-medium text-blue-900 mb-2">Question Analysis:</h3>
                  <div className="space-y-2 text-sm">
                    <div>
                      <span className="font-medium text-blue-800">Information Type:</span>
                      <span className="text-blue-700 ml-2">{classification.informationType}</span>
                    </div>
                    <div>
                      <span className="font-medium text-blue-800">Search Terms:</span>
                      <div className="flex flex-wrap gap-1 mt-1">
                        {classification.searchTerms.map((term, index) => (
                          <span key={index} className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs">
                            {term}
                          </span>
                        ))}
                      </div>
                    </div>
                    <div>
                      <span className="font-medium text-blue-800">Search Query Used:</span>
                      <span className="text-blue-700 ml-2 font-mono text-xs">
                        {classification.suggestedQueries?.[0] || classification.searchTerms.join(' ')}
                      </span>
                    </div>
                  </div>
                </div>
              )}

              {/* Answer */}
              {answer && (
                <div className="mt-4 p-4 bg-gray-50 rounded-lg">
                  <h3 className="text-sm font-medium text-gray-900 mb-2">Answer:</h3>
                  <div className="text-sm text-gray-700 whitespace-pre-wrap prose prose-sm max-w-none">
                    {answer}
                  </div>
                </div>
              )}

              {/* Search Results */}
              {showResults && searchResults.length > 0 && (
                <div className="mt-4">
                  <div className="text-xs text-gray-500 mb-2">
                    Found {searchResults.length} relevant pages (using enhanced search terms):
                  </div>
                  <div className="space-y-1 max-h-48 overflow-y-auto">
                    {searchResults.map((result, index) => (
                      <div key={index} className="text-xs text-gray-600 bg-gray-50 p-2 rounded flex items-center gap-2">
                        <span className="text-gray-400 font-mono">{(result._rankingScore * 100).toFixed(0)}%</span>
                        <a 
                          href={result.url.startsWith('http') ? result.url : `https://${result.url}`} 
                          target="_blank" 
                          rel="noopener noreferrer" 
                          className="text-indigo-600 hover:text-indigo-800 truncate flex-1"
                        >
                          {result.url}
                        </a>
                        {result.texts && (
                          <span className="text-gray-500 truncate max-w-xs">
                            - {result.texts.substring(0, 80)}...
                          </span>
                        )}
                      </div>
                    ))}
                  </div>
                </div>
              )}
            </div>
          </div>
        </div>
      )}
    </>
  )
}