// Search Service for Marketplace // Handles listing search and filtering import { Listing, ListingStatus, SearchFilters, SearchResult, ListingCategory, MarketplaceStats, } from './types'; import { listingStore } from './store'; export class SearchService { /** * Search listings with filters */ async searchListings(filters: SearchFilters): Promise { let listings = listingStore.getAll(); // Only show active listings unless specific status requested if (filters.status) { listings = listings.filter(l => l.status === filters.status); } else { listings = listings.filter(l => l.status === ListingStatus.ACTIVE); } // Text search in title and description if (filters.query) { const query = filters.query.toLowerCase(); listings = listings.filter(l => l.title.toLowerCase().includes(query) || l.description.toLowerCase().includes(query) || l.tags.some(tag => tag.toLowerCase().includes(query)) ); } // Category filter if (filters.category) { listings = listings.filter(l => l.category === filters.category); } // Price range filter if (filters.minPrice !== undefined) { listings = listings.filter(l => l.price >= filters.minPrice!); } if (filters.maxPrice !== undefined) { listings = listings.filter(l => l.price <= filters.maxPrice!); } // Seller filter if (filters.sellerId) { listings = listings.filter(l => l.sellerId === filters.sellerId); } // Tags filter if (filters.tags && filters.tags.length > 0) { const filterTags = filters.tags.map(t => t.toLowerCase()); listings = listings.filter(l => filterTags.some(tag => l.tags.map(t => t.toLowerCase()).includes(tag)) ); } // Location filter (approximate distance) if (filters.location) { listings = listings.filter(l => { if (!l.location) return false; const distance = this.calculateDistance( filters.location!.lat, filters.location!.lng, l.location.lat, l.location.lng ); return distance <= filters.location!.radiusKm; }); } // Sort listings listings = this.sortListings(listings, filters.sortBy || 'date_desc', filters.query); // Pagination const page = filters.page || 1; const limit = filters.limit || 20; const total = listings.length; const startIndex = (page - 1) * limit; const paginatedListings = listings.slice(startIndex, startIndex + limit); return { listings: paginatedListings, total, page, limit, hasMore: startIndex + limit < total, }; } /** * Get featured listings (most viewed active listings) */ async getFeaturedListings(limit: number = 6): Promise { return listingStore .getAll() .filter(l => l.status === ListingStatus.ACTIVE) .sort((a, b) => b.viewCount - a.viewCount) .slice(0, limit); } /** * Get recent listings */ async getRecentListings(limit: number = 10): Promise { return listingStore .getAll() .filter(l => l.status === ListingStatus.ACTIVE) .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) .slice(0, limit); } /** * Get listings by category */ async getListingsByCategory( category: ListingCategory, limit: number = 20 ): Promise { return listingStore .getAll() .filter(l => l.status === ListingStatus.ACTIVE && l.category === category) .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) .slice(0, limit); } /** * Get similar listings based on category and tags */ async getSimilarListings(listingId: string, limit: number = 4): Promise { const listing = listingStore.getById(listingId); if (!listing) return []; return listingStore .getAll() .filter(l => l.id !== listingId && l.status === ListingStatus.ACTIVE && (l.category === listing.category || l.tags.some(tag => listing.tags.includes(tag))) ) .sort((a, b) => { // Score based on matching tags const aScore = a.tags.filter(tag => listing.tags.includes(tag)).length; const bScore = b.tags.filter(tag => listing.tags.includes(tag)).length; return bScore - aScore; }) .slice(0, limit); } /** * Get marketplace statistics */ async getMarketplaceStats(): Promise { const allListings = listingStore.getAll(); const activeListings = allListings.filter(l => l.status === ListingStatus.ACTIVE); const soldListings = allListings.filter(l => l.status === ListingStatus.SOLD); // Category counts const categoryCounts: Record = { [ListingCategory.SEEDS]: 0, [ListingCategory.SEEDLINGS]: 0, [ListingCategory.MATURE_PLANTS]: 0, [ListingCategory.CUTTINGS]: 0, [ListingCategory.PRODUCE]: 0, [ListingCategory.SUPPLIES]: 0, }; activeListings.forEach(l => { categoryCounts[l.category]++; }); // Top categories const topCategories = Object.entries(categoryCounts) .map(([category, count]) => ({ category: category as ListingCategory, count })) .sort((a, b) => b.count - a.count) .slice(0, 5); // Average price const averagePrice = activeListings.length > 0 ? activeListings.reduce((sum, l) => sum + l.price, 0) / activeListings.length : 0; return { totalListings: allListings.length, activeListings: activeListings.length, totalSales: soldListings.length, totalOffers: 0, // Would be calculated from offer store categoryCounts, averagePrice: Math.round(averagePrice * 100) / 100, topCategories, }; } /** * Get popular tags */ async getPopularTags(limit: number = 20): Promise<{ tag: string; count: number }[]> { const tagCounts: Record = {}; listingStore .getAll() .filter(l => l.status === ListingStatus.ACTIVE) .forEach(l => { l.tags.forEach(tag => { tagCounts[tag] = (tagCounts[tag] || 0) + 1; }); }); return Object.entries(tagCounts) .map(([tag, count]) => ({ tag, count })) .sort((a, b) => b.count - a.count) .slice(0, limit); } /** * Sort listings based on sort option */ private sortListings( listings: Listing[], sortBy: string, query?: string ): Listing[] { switch (sortBy) { case 'price_asc': return listings.sort((a, b) => a.price - b.price); case 'price_desc': return listings.sort((a, b) => b.price - a.price); case 'date_asc': return listings.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); case 'date_desc': return listings.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); case 'relevance': if (!query) { return listings.sort((a, b) => b.viewCount - a.viewCount); } // Simple relevance scoring based on title match return listings.sort((a, b) => { const aTitle = a.title.toLowerCase().includes(query.toLowerCase()) ? 1 : 0; const bTitle = b.title.toLowerCase().includes(query.toLowerCase()) ? 1 : 0; return bTitle - aTitle; }); default: return listings; } } /** * Calculate distance between two points (Haversine formula) */ private calculateDistance( lat1: number, lng1: number, lat2: number, lng2: number ): number { const R = 6371; // Earth's radius in km const dLat = this.toRad(lat2 - lat1); const dLng = this.toRad(lng2 - lng1); const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * Math.sin(dLng / 2) * Math.sin(dLng / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; } private toRad(deg: number): number { return deg * (Math.PI / 180); } } // Export singleton instance export const searchService = new SearchService();