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)
233 lines
5.7 KiB
TypeScript
233 lines
5.7 KiB
TypeScript
// Listing Service for Marketplace
|
|
// Handles CRUD operations for marketplace listings
|
|
|
|
import {
|
|
Listing,
|
|
ListingStatus,
|
|
CreateListingInput,
|
|
UpdateListingInput,
|
|
} from './types';
|
|
import { listingStore, generateId } from './store';
|
|
|
|
export class ListingService {
|
|
/**
|
|
* Create a new listing
|
|
*/
|
|
async createListing(
|
|
sellerId: string,
|
|
sellerName: string,
|
|
input: CreateListingInput
|
|
): Promise<Listing> {
|
|
const listing: Listing = {
|
|
id: generateId(),
|
|
sellerId,
|
|
sellerName,
|
|
plantId: input.plantId,
|
|
title: input.title,
|
|
description: input.description,
|
|
price: input.price,
|
|
currency: input.currency || 'USD',
|
|
quantity: input.quantity,
|
|
category: input.category,
|
|
status: ListingStatus.DRAFT,
|
|
location: input.location,
|
|
tags: input.tags || [],
|
|
images: [],
|
|
viewCount: 0,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
expiresAt: input.expiresAt,
|
|
};
|
|
|
|
return listingStore.create(listing);
|
|
}
|
|
|
|
/**
|
|
* Get a listing by ID
|
|
*/
|
|
async getListingById(id: string): Promise<Listing | null> {
|
|
const listing = listingStore.getById(id);
|
|
return listing || null;
|
|
}
|
|
|
|
/**
|
|
* Get a listing and increment view count
|
|
*/
|
|
async viewListing(id: string): Promise<Listing | null> {
|
|
listingStore.incrementViewCount(id);
|
|
return this.getListingById(id);
|
|
}
|
|
|
|
/**
|
|
* Get all listings by seller
|
|
*/
|
|
async getListingsBySeller(sellerId: string): Promise<Listing[]> {
|
|
return listingStore.getBySellerId(sellerId);
|
|
}
|
|
|
|
/**
|
|
* Get all active listings
|
|
*/
|
|
async getActiveListings(): Promise<Listing[]> {
|
|
return listingStore.getAll().filter(l => l.status === ListingStatus.ACTIVE);
|
|
}
|
|
|
|
/**
|
|
* Update a listing
|
|
*/
|
|
async updateListing(
|
|
id: string,
|
|
sellerId: string,
|
|
updates: UpdateListingInput
|
|
): Promise<Listing | null> {
|
|
const listing = listingStore.getById(id);
|
|
|
|
if (!listing) {
|
|
return null;
|
|
}
|
|
|
|
// Verify ownership
|
|
if (listing.sellerId !== sellerId) {
|
|
throw new Error('Unauthorized: You do not own this listing');
|
|
}
|
|
|
|
const updated = listingStore.update(id, updates);
|
|
return updated || null;
|
|
}
|
|
|
|
/**
|
|
* Publish a draft listing (make it active)
|
|
*/
|
|
async publishListing(id: string, sellerId: string): Promise<Listing | null> {
|
|
const listing = listingStore.getById(id);
|
|
|
|
if (!listing) {
|
|
return null;
|
|
}
|
|
|
|
if (listing.sellerId !== sellerId) {
|
|
throw new Error('Unauthorized: You do not own this listing');
|
|
}
|
|
|
|
if (listing.status !== ListingStatus.DRAFT) {
|
|
throw new Error('Only draft listings can be published');
|
|
}
|
|
|
|
return listingStore.update(id, { status: ListingStatus.ACTIVE }) || null;
|
|
}
|
|
|
|
/**
|
|
* Cancel a listing
|
|
*/
|
|
async cancelListing(id: string, sellerId: string): Promise<Listing | null> {
|
|
const listing = listingStore.getById(id);
|
|
|
|
if (!listing) {
|
|
return null;
|
|
}
|
|
|
|
if (listing.sellerId !== sellerId) {
|
|
throw new Error('Unauthorized: You do not own this listing');
|
|
}
|
|
|
|
if (listing.status === ListingStatus.SOLD) {
|
|
throw new Error('Cannot cancel a sold listing');
|
|
}
|
|
|
|
return listingStore.update(id, { status: ListingStatus.CANCELLED }) || null;
|
|
}
|
|
|
|
/**
|
|
* Mark listing as sold
|
|
*/
|
|
async markAsSold(id: string, sellerId: string): Promise<Listing | null> {
|
|
const listing = listingStore.getById(id);
|
|
|
|
if (!listing) {
|
|
return null;
|
|
}
|
|
|
|
if (listing.sellerId !== sellerId) {
|
|
throw new Error('Unauthorized: You do not own this listing');
|
|
}
|
|
|
|
return listingStore.update(id, {
|
|
status: ListingStatus.SOLD,
|
|
quantity: 0
|
|
}) || null;
|
|
}
|
|
|
|
/**
|
|
* Delete a listing (only drafts or cancelled)
|
|
*/
|
|
async deleteListing(id: string, sellerId: string): Promise<boolean> {
|
|
const listing = listingStore.getById(id);
|
|
|
|
if (!listing) {
|
|
return false;
|
|
}
|
|
|
|
if (listing.sellerId !== sellerId) {
|
|
throw new Error('Unauthorized: You do not own this listing');
|
|
}
|
|
|
|
if (listing.status === ListingStatus.ACTIVE || listing.status === ListingStatus.SOLD) {
|
|
throw new Error('Cannot delete active or sold listings. Cancel first.');
|
|
}
|
|
|
|
return listingStore.delete(id);
|
|
}
|
|
|
|
/**
|
|
* Get listing statistics for a seller
|
|
*/
|
|
async getSellerStats(sellerId: string): Promise<{
|
|
totalListings: number;
|
|
activeListings: number;
|
|
soldListings: number;
|
|
totalViews: number;
|
|
averagePrice: number;
|
|
}> {
|
|
const listings = listingStore.getBySellerId(sellerId);
|
|
|
|
const activeListings = listings.filter(l => l.status === ListingStatus.ACTIVE);
|
|
const soldListings = listings.filter(l => l.status === ListingStatus.SOLD);
|
|
|
|
const totalViews = listings.reduce((sum, l) => sum + l.viewCount, 0);
|
|
const averagePrice = listings.length > 0
|
|
? listings.reduce((sum, l) => sum + l.price, 0) / listings.length
|
|
: 0;
|
|
|
|
return {
|
|
totalListings: listings.length,
|
|
activeListings: activeListings.length,
|
|
soldListings: soldListings.length,
|
|
totalViews,
|
|
averagePrice: Math.round(averagePrice * 100) / 100,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check and expire old listings
|
|
*/
|
|
async expireOldListings(): Promise<number> {
|
|
const now = new Date();
|
|
let expiredCount = 0;
|
|
|
|
const activeListings = listingStore.getAll().filter(
|
|
l => l.status === ListingStatus.ACTIVE && l.expiresAt
|
|
);
|
|
|
|
for (const listing of activeListings) {
|
|
if (listing.expiresAt && listing.expiresAt < now) {
|
|
listingStore.update(listing.id, { status: ListingStatus.EXPIRED });
|
|
expiredCount++;
|
|
}
|
|
}
|
|
|
|
return expiredCount;
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const listingService = new ListingService();
|