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)
230 lines
6.5 KiB
TypeScript
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();
|