Overview
AndanDo’s search system combines text-based search with intelligent filtering to help users find tours that match their preferences. The system uses Unicode normalization for international text support and real-time filtering for instant results.
Text Normalization Handles accents, diacritics, and multiple languages seamlessly
Multi-field Search Searches across title, location, and duration fields
Filter Combinations Combine multiple filters for precise tour discovery
URL State Management Share searches via URL with encoded filter parameters
Search Architecture
Query Processing Pipeline
private async Task LoadResultsAsync ()
{
_loading = true ;
_error = null ;
try
{
// 1. Decode filter from URL
Filter = DecodeFilter ( EncodedFilter );
// 2. Handle plain text query
var plainQuery = NormalizePlainQuery ( PlainQuery );
if ( Filter is null && ! string . IsNullOrWhiteSpace ( plainQuery ))
{
Filter = new SearchFilterDto (
Location : plainQuery ,
NearLabel : null ,
Latitude : null ,
Longitude : null ,
DateLabel : null );
}
// 3. Validate search criteria
if ( ! HasSearchCriteria ( Filter , plainQuery ))
{
_error = "Debes ingresar algun valor para buscar." ;
return ;
}
// 4. Fetch and filter tours
var tours = await TourService . GetToursForMarketplaceAsync ();
var searchText = BuildSearchText ( Filter , plainQuery );
var normalizedQuery = Normalize ( searchText );
var query = tours . Where ( t => IsKeywordMatch ( normalizedQuery , t ));
_results = await BuildCardViewModelsAsync ( query );
}
finally
{
_loading = false ;
}
}
Text Normalization
The search system uses Unicode normalization to handle international characters:
private static string Normalize ( string ? value )
{
if ( string . IsNullOrWhiteSpace ( value ))
{
return string . Empty ;
}
// Decompose Unicode characters (e.g., á → a + ´)
var normalized = value . Trim (). Normalize ( NormalizationForm . FormD );
var sb = new StringBuilder ();
// Remove diacritics (accent marks)
foreach ( var c in normalized )
{
var category = CharUnicodeInfo . GetUnicodeCategory ( c );
if ( category != UnicodeCategory . NonSpacingMark )
{
sb . Append ( c );
}
}
// Recompose and clean
var noDiacritics = sb . ToString (). Normalize ( NormalizationForm . FormC );
var lettersAndSpaces = new string ( noDiacritics
. Where ( ch => char . IsLetterOrDigit ( ch ) || char . IsWhiteSpace ( ch ))
. ToArray ());
// Normalize whitespace and lowercase
return Regex . Replace ( lettersAndSpaces , " \\ s+" , " " ). Trim (). ToLowerInvariant ();
}
This normalization allows searches to match regardless of accents. For example, “Punta Cana” will match “punta cana”, “Punta Caná”, or “PUNTA CANA”.
Search Filter DTO
The search system uses a structured filter object:
public record SearchFilterDto (
string ? Location ,
string ? NearLabel ,
double ? Latitude ,
double ? Longitude ,
string ? DateLabel
);
URL Encoding
Filters are Base64URL-encoded for sharing:
private static SearchFilterDto ? DecodeFilter ( string ? encoded )
{
if ( string . IsNullOrWhiteSpace ( encoded ))
{
return null ;
}
try
{
var bytes = WebEncoders . Base64UrlDecode ( encoded );
var json = Encoding . UTF8 . GetString ( bytes );
return JsonSerializer . Deserialize < SearchFilterDto >( json );
}
catch ( Exception ex )
{
Console . WriteLine ( ex );
return null ;
}
}
Base64URL encoding allows complex filter objects to be safely embedded in URLs and shared between users.
Keyword Matching
The search engine uses intelligent keyword matching:
private static bool IsKeywordMatch ( string normalizedQuery , TourMarketplaceItemDto tour )
{
if ( string . IsNullOrWhiteSpace ( normalizedQuery ))
{
return true ;
}
// Build searchable text from tour fields
var normalizedTitle = Normalize ( tour . Title );
var normalizedLocation = Normalize ( tour . LocationLabel );
var normalizedDuration = Normalize ( tour . DurationLabel );
var combined = $" { normalizedTitle } { normalizedLocation } { normalizedDuration } " . Trim ();
if ( string . IsNullOrWhiteSpace ( combined ))
{
return false ;
}
// Check for exact phrase match
if ( combined . Contains ( normalizedQuery ))
{
return true ;
}
// Check if all tokens are present (AND logic)
var tokens = normalizedQuery . Split ( ' ' , StringSplitOptions . RemoveEmptyEntries );
return tokens . All ( token => combined . Contains ( token ));
}
Phrase Matching
First checks if the entire search phrase exists in the tour data
Token Matching
If phrase doesn’t match, splits query into tokens and requires all tokens to be present
Normalized Comparison
All comparisons use normalized text for accent-insensitive matching
Search UI Components
Desktop Search Bar
Desktop Searchbar Component
< div class = "search-desktop-only" >
< Searchbar />
</ div >
The Searchbar component provides location autocomplete and date picking.
Mobile Search
< div class = "search-mobile-search" >
< form class = "search-mobile-search__form"
@onsubmit = "ExecuteMobileSearch"
@onsubmit:preventDefault = "true" >
< span class = "search-mobile-search__icon" >
< i class = "icon-search text-16" ></ i >
</ span >
< input class = "search-mobile-search__input"
placeholder = "Buscar servicios o ubicacion"
@bind = "MobileSearchText"
@bind:event = "oninput" />
< button type = "submit" class = "button -sm -dark-1 bg-accent-1 text-white" >
Buscar
</ button >
</ form >
@if ( ! string . IsNullOrWhiteSpace ( _mobileSearchMessage ))
{
< div class = "search-mobile-search__error" > @ _mobileSearchMessage </ div >
}
</ div >
private void ExecuteMobileSearch ()
{
var query = _mobileSearchText ? . Trim ();
if ( string . IsNullOrWhiteSpace ( query ))
{
_mobileSearchMessage = "Debes ingresar algun valor para buscar." ;
return ;
}
_mobileSearchMessage = null ;
var url = $"/search?q= { Uri . EscapeDataString ( query )} " ;
NavigationManager . NavigateTo ( url );
}
Filter Display
Search results show active filters as chips:
< div class = "search-hero" >
< div class = "search-hero__title fw-700" > Resultados de busqueda </ div >
< div class = "d-flex x-gap-8 y-gap-8 flex-wrap" >
@if ( ! string . IsNullOrWhiteSpace ( Filter ? . Location ))
{
< span class = "chip" >
< i class = "icon-pin" ></ i > Donde: @ Filter . Location
</ span >
}
@if ( ! string . IsNullOrWhiteSpace ( Filter ? . NearLabel ))
{
< span class = "chip" >
< i class = "icon-pin" ></ i > Cerca de ti: @ Filter . NearLabel
</ span >
}
@if ( ! string . IsNullOrWhiteSpace ( Filter ? . DateLabel ))
{
< span class = "chip" >
< i class = "icon-calendar" ></ i > @ Filter . DateLabel
</ span >
}
</ div >
</ div >
Combined Filter Logic
The marketplace page supports combining multiple filters:
private IEnumerable < TourCardViewModel > FilteredTours => Tours
. Where ( c =>
{
// Text search
if ( ! string . IsNullOrWhiteSpace ( _searchQuery ))
{
var q = _searchQuery ;
if ( ! c . Tour . Title . Contains ( q , StringComparison . OrdinalIgnoreCase ) &&
! ( c . Tour . LocationLabel ? . Contains ( q , StringComparison . OrdinalIgnoreCase ) ?? false ))
return false ;
}
// Duration filter
if ( _durationFilter == "short" )
{
var dur = c . Tour . DurationLabel ?? "" ;
if ( ! dur . Contains ( "hora" , StringComparison . OrdinalIgnoreCase ) &&
! dur . Contains ( "min" , StringComparison . OrdinalIgnoreCase ))
return false ;
}
if ( _durationFilter == "long" )
{
var dur = c . Tour . DurationLabel ?? "" ;
if ( ! dur . Contains ( "día" , StringComparison . OrdinalIgnoreCase ) &&
! dur . Contains ( "dia" , StringComparison . OrdinalIgnoreCase ) &&
! dur . Contains ( "semana" , StringComparison . OrdinalIgnoreCase ))
return false ;
}
// Price filter
if ( _priceFilter == "low" && c . Tour . FromPrice . HasValue && c . Tour . FromPrice . Value > 3000m )
return false ;
if ( _priceFilter == "high" && c . Tour . FromPrice . HasValue && c . Tour . FromPrice . Value <= 3000m )
return false ;
// Upcoming filter
if ( _onlyUpcoming && ! c . IsUpcoming )
return false ;
return true ;
})
. OrderBy ( c => _sortOrder switch
{
"priceLow" => c . Tour . FromPrice ?? decimal . MaxValue ,
"priceHigh" => - ( c . Tour . FromPrice ?? 0m ),
_ => 0m
});
All filtering happens in-memory on the server. For production deployments with large tour catalogs, consider moving filter logic to SQL stored procedures.
Empty State Handling
@if ( ! _results . Any ())
{
< div class = "empty-state" >
< div class = "empty-icon" >< i class = "icon-search" ></ i ></ div >
< div class = "text-16 fw-600" > No encontramos servicios con este filtro. </ div >
< div class = "text-13 text-light-2" > Prueba con otra ubicacion o ajusta las fechas. </ div >
< a class = "button -sm -dark-1 mt-10" href = "#refilter" > Ajustar busqueda </ a >
</ div >
}
Query Parameters
The search page supports two query parameter formats:
Plain Text Query
Encoded Filter Object
[ Parameter , SupplyParameterFromQuery ( Name = "f" )]
public string ? EncodedFilter { get ; set ; }
[ Parameter , SupplyParameterFromQuery ( Name = "q" )]
public string ? PlainQuery { get ; set ; }
Search Analytics
Track search performance and user behavior:
private async Task LoadResultsAsync ()
{
var stopwatch = Stopwatch . StartNew ();
try
{
// Load and filter results...
}
finally
{
stopwatch . Stop ();
Console . WriteLine ( $"Search completed in { stopwatch . ElapsedMilliseconds } ms" );
Console . WriteLine ( $"Query: { BuildSearchText ( Filter , PlainQuery )} " );
Console . WriteLine ( $"Results: { _results . Count } " );
}
}
Debouncing Implement search debouncing to reduce server load during typing
Caching Cache normalized tour data to avoid repeated text processing
Indexing Use full-text search indexes in SQL Server for large datasets
Pagination Load results in pages rather than all at once
Best Practices
Validate Input
Always validate and sanitize search queries before processing
Handle Empty Queries
Provide helpful error messages for empty or invalid searches
Preserve State
Keep search state in URL for sharing and browser history
Show Progress
Display loading indicators during search operations
Optimize Queries
Use database-level filtering for large datasets
Next Steps
Explore Booking System Learn how users book tours through AndanDo’s multi-step checkout flow