localgreenchain/lib/marketplace/searchService.ts
Claude b3c2af51bf
Implement marketplace foundation (Agent 9)
Add comprehensive plant trading marketplace with:
- Prisma schema with marketplace models (Listing, Offer, SellerProfile, WishlistItem)
- Service layer for listings, offers, search, and matching
- API endpoints for CRUD operations, search, and recommendations
- Marketplace pages: home, listing detail, create, my-listings, my-offers
- Reusable UI components: ListingCard, ListingGrid, OfferForm, SearchFilters, etc.

Features:
- Browse and search listings by category, price, tags
- Create and manage listings (draft, active, sold, cancelled)
- Make and manage offers on listings
- Seller and buyer views with statistics
- Featured and recommended listings
- In-memory store (ready for database migration via Agent 2)
2025-11-23 03:58:08 +00:00

285 lines
8.1 KiB
TypeScript

// 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<SearchResult> {
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<Listing[]> {
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<Listing[]> {
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<Listing[]> {
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<Listing[]> {
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<MarketplaceStats> {
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, number> = {
[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<string, number> = {};
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();