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)
285 lines
8.1 KiB
TypeScript
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();
|