localgreenchain/lib/marketplace/matchingService.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

230 lines
6.5 KiB
TypeScript

// Matching Service for Marketplace
// Matches buyers with sellers based on preferences and listings
import { Listing, ListingCategory, ListingStatus } from './types';
import { listingStore, sellerProfileStore } from './store';
export interface BuyerPreferences {
categories: ListingCategory[];
maxPrice?: number;
preferredLocation?: {
lat: number;
lng: number;
maxDistanceKm: number;
};
preferredTags?: string[];
preferVerifiedSellers?: boolean;
}
export interface MatchResult {
listing: Listing;
score: number;
matchReasons: string[];
}
export class MatchingService {
/**
* Find listings that match buyer preferences
*/
async findMatchesForBuyer(
buyerId: string,
preferences: BuyerPreferences,
limit: number = 10
): Promise<MatchResult[]> {
const activeListings = listingStore
.getAll()
.filter(l => l.status === ListingStatus.ACTIVE && l.sellerId !== buyerId);
const matches: MatchResult[] = [];
for (const listing of activeListings) {
const { score, reasons } = this.calculateMatchScore(listing, preferences);
if (score > 0) {
matches.push({
listing,
score,
matchReasons: reasons,
});
}
}
// Sort by score descending
matches.sort((a, b) => b.score - a.score);
return matches.slice(0, limit);
}
/**
* Find buyers who might be interested in a listing
* (In a real system, this would query user preferences)
*/
async findPotentialBuyers(listingId: string): Promise<string[]> {
// Placeholder - would match against stored buyer preferences
// For now, return empty array as we don't have buyer preference storage
return [];
}
/**
* Get recommended listings for a user based on their history
*/
async getRecommendedListings(
userId: string,
limit: number = 8
): Promise<Listing[]> {
// Get user's purchase history and viewed listings
// For now, return featured listings as recommendations
const activeListings = listingStore
.getAll()
.filter(l => l.status === ListingStatus.ACTIVE && l.sellerId !== userId);
// Sort by a combination of view count and recency
return activeListings
.sort((a, b) => {
const aScore = a.viewCount * 0.7 + this.recencyScore(a.createdAt) * 0.3;
const bScore = b.viewCount * 0.7 + this.recencyScore(b.createdAt) * 0.3;
return bScore - aScore;
})
.slice(0, limit);
}
/**
* Find sellers with good ratings in a category
*/
async findTopSellersInCategory(
category: ListingCategory,
limit: number = 5
): Promise<{ userId: string; displayName: string; rating: number; listingCount: number }[]> {
// Get all active listings in category
const categoryListings = listingStore
.getAll()
.filter(l => l.status === ListingStatus.ACTIVE && l.category === category);
// Group by seller
const sellerStats: Record<string, { count: number; sellerId: string }> = {};
for (const listing of categoryListings) {
if (!sellerStats[listing.sellerId]) {
sellerStats[listing.sellerId] = { count: 0, sellerId: listing.sellerId };
}
sellerStats[listing.sellerId].count++;
}
// Get seller profiles and sort by rating
const topSellers = Object.values(sellerStats)
.map(stat => {
const profile = sellerProfileStore.getByUserId(stat.sellerId);
return {
userId: stat.sellerId,
displayName: profile?.displayName || 'Unknown Seller',
rating: profile?.rating || 0,
listingCount: stat.count,
};
})
.sort((a, b) => b.rating - a.rating)
.slice(0, limit);
return topSellers;
}
/**
* Calculate match score between a listing and buyer preferences
*/
private calculateMatchScore(
listing: Listing,
preferences: BuyerPreferences
): { score: number; reasons: string[] } {
let score = 0;
const reasons: string[] = [];
// Category match (highest weight)
if (preferences.categories.includes(listing.category)) {
score += 40;
reasons.push(`Matches preferred category: ${listing.category}`);
}
// Price within budget
if (preferences.maxPrice && listing.price <= preferences.maxPrice) {
score += 20;
reasons.push('Within budget');
}
// Location match
if (preferences.preferredLocation && listing.location) {
const distance = this.calculateDistance(
preferences.preferredLocation.lat,
preferences.preferredLocation.lng,
listing.location.lat,
listing.location.lng
);
if (distance <= preferences.preferredLocation.maxDistanceKm) {
score += 25;
reasons.push(`Nearby (${Math.round(distance)}km away)`);
}
}
// Tag matches
if (preferences.preferredTags && preferences.preferredTags.length > 0) {
const matchingTags = listing.tags.filter(tag =>
preferences.preferredTags!.includes(tag.toLowerCase())
);
if (matchingTags.length > 0) {
score += matchingTags.length * 5;
reasons.push(`Matching tags: ${matchingTags.join(', ')}`);
}
}
// Verified seller preference
if (preferences.preferVerifiedSellers) {
const profile = sellerProfileStore.getByUserId(listing.sellerId);
if (profile?.verified) {
score += 10;
reasons.push('Verified seller');
}
}
return { score, reasons };
}
/**
* Calculate recency score (0-100, higher for more recent)
*/
private recencyScore(date: Date): number {
const now = Date.now();
const created = date.getTime();
const daysOld = (now - created) / (1000 * 60 * 60 * 24);
// Exponential decay: 100 for today, ~37 for 7 days, ~14 for 14 days
return 100 * Math.exp(-daysOld / 10);
}
/**
* 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 matchingService = new MatchingService();