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)
291 lines
7.2 KiB
TypeScript
291 lines
7.2 KiB
TypeScript
// Offer Service for Marketplace
|
|
// Handles offer management for marketplace listings
|
|
|
|
import {
|
|
Offer,
|
|
OfferStatus,
|
|
Listing,
|
|
ListingStatus,
|
|
CreateOfferInput,
|
|
} from './types';
|
|
import { offerStore, listingStore, generateId } from './store';
|
|
|
|
export class OfferService {
|
|
/**
|
|
* Create a new offer on a listing
|
|
*/
|
|
async createOffer(
|
|
buyerId: string,
|
|
buyerName: string,
|
|
input: CreateOfferInput
|
|
): Promise<Offer> {
|
|
const listing = listingStore.getById(input.listingId);
|
|
|
|
if (!listing) {
|
|
throw new Error('Listing not found');
|
|
}
|
|
|
|
if (listing.status !== ListingStatus.ACTIVE) {
|
|
throw new Error('Cannot make offers on inactive listings');
|
|
}
|
|
|
|
if (listing.sellerId === buyerId) {
|
|
throw new Error('Cannot make an offer on your own listing');
|
|
}
|
|
|
|
// Check for existing pending offer from this buyer
|
|
const existingOffer = offerStore.getByListingId(input.listingId)
|
|
.find(o => o.buyerId === buyerId && o.status === OfferStatus.PENDING);
|
|
|
|
if (existingOffer) {
|
|
throw new Error('You already have a pending offer on this listing');
|
|
}
|
|
|
|
const offer: Offer = {
|
|
id: generateId(),
|
|
listingId: input.listingId,
|
|
buyerId,
|
|
buyerName,
|
|
amount: input.amount,
|
|
message: input.message,
|
|
status: OfferStatus.PENDING,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
|
|
};
|
|
|
|
return offerStore.create(offer);
|
|
}
|
|
|
|
/**
|
|
* Get an offer by ID
|
|
*/
|
|
async getOfferById(id: string): Promise<Offer | null> {
|
|
return offerStore.getById(id) || null;
|
|
}
|
|
|
|
/**
|
|
* Get all offers for a listing
|
|
*/
|
|
async getOffersForListing(listingId: string): Promise<Offer[]> {
|
|
return offerStore.getByListingId(listingId);
|
|
}
|
|
|
|
/**
|
|
* Get all offers made by a buyer
|
|
*/
|
|
async getOffersByBuyer(buyerId: string): Promise<Offer[]> {
|
|
return offerStore.getByBuyerId(buyerId);
|
|
}
|
|
|
|
/**
|
|
* Get all offers for a seller's listings
|
|
*/
|
|
async getOffersForSeller(sellerId: string): Promise<(Offer & { listing?: Listing })[]> {
|
|
const sellerListings = listingStore.getBySellerId(sellerId);
|
|
const listingIds = new Set(sellerListings.map(l => l.id));
|
|
|
|
const offers = offerStore.getAll().filter(o => listingIds.has(o.listingId));
|
|
|
|
return offers.map(offer => ({
|
|
...offer,
|
|
listing: listingStore.getById(offer.listingId),
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Accept an offer
|
|
*/
|
|
async acceptOffer(offerId: string, sellerId: string): Promise<Offer> {
|
|
const offer = offerStore.getById(offerId);
|
|
|
|
if (!offer) {
|
|
throw new Error('Offer not found');
|
|
}
|
|
|
|
const listing = listingStore.getById(offer.listingId);
|
|
|
|
if (!listing) {
|
|
throw new Error('Listing not found');
|
|
}
|
|
|
|
if (listing.sellerId !== sellerId) {
|
|
throw new Error('Unauthorized: You do not own this listing');
|
|
}
|
|
|
|
if (offer.status !== OfferStatus.PENDING) {
|
|
throw new Error('Can only accept pending offers');
|
|
}
|
|
|
|
// Accept this offer
|
|
const acceptedOffer = offerStore.update(offerId, {
|
|
status: OfferStatus.ACCEPTED
|
|
});
|
|
|
|
// Reject all other pending offers for this listing
|
|
const otherOffers = offerStore.getByListingId(offer.listingId)
|
|
.filter(o => o.id !== offerId && o.status === OfferStatus.PENDING);
|
|
|
|
for (const otherOffer of otherOffers) {
|
|
offerStore.update(otherOffer.id, { status: OfferStatus.REJECTED });
|
|
}
|
|
|
|
// Mark listing as sold
|
|
listingStore.update(offer.listingId, {
|
|
status: ListingStatus.SOLD,
|
|
quantity: 0
|
|
});
|
|
|
|
if (!acceptedOffer) {
|
|
throw new Error('Failed to accept offer');
|
|
}
|
|
|
|
return acceptedOffer;
|
|
}
|
|
|
|
/**
|
|
* Reject an offer
|
|
*/
|
|
async rejectOffer(offerId: string, sellerId: string): Promise<Offer> {
|
|
const offer = offerStore.getById(offerId);
|
|
|
|
if (!offer) {
|
|
throw new Error('Offer not found');
|
|
}
|
|
|
|
const listing = listingStore.getById(offer.listingId);
|
|
|
|
if (!listing) {
|
|
throw new Error('Listing not found');
|
|
}
|
|
|
|
if (listing.sellerId !== sellerId) {
|
|
throw new Error('Unauthorized: You do not own this listing');
|
|
}
|
|
|
|
if (offer.status !== OfferStatus.PENDING) {
|
|
throw new Error('Can only reject pending offers');
|
|
}
|
|
|
|
const rejectedOffer = offerStore.update(offerId, {
|
|
status: OfferStatus.REJECTED
|
|
});
|
|
|
|
if (!rejectedOffer) {
|
|
throw new Error('Failed to reject offer');
|
|
}
|
|
|
|
return rejectedOffer;
|
|
}
|
|
|
|
/**
|
|
* Withdraw an offer (buyer action)
|
|
*/
|
|
async withdrawOffer(offerId: string, buyerId: string): Promise<Offer> {
|
|
const offer = offerStore.getById(offerId);
|
|
|
|
if (!offer) {
|
|
throw new Error('Offer not found');
|
|
}
|
|
|
|
if (offer.buyerId !== buyerId) {
|
|
throw new Error('Unauthorized: This is not your offer');
|
|
}
|
|
|
|
if (offer.status !== OfferStatus.PENDING) {
|
|
throw new Error('Can only withdraw pending offers');
|
|
}
|
|
|
|
const withdrawnOffer = offerStore.update(offerId, {
|
|
status: OfferStatus.WITHDRAWN
|
|
});
|
|
|
|
if (!withdrawnOffer) {
|
|
throw new Error('Failed to withdraw offer');
|
|
}
|
|
|
|
return withdrawnOffer;
|
|
}
|
|
|
|
/**
|
|
* Counter offer (update amount)
|
|
*/
|
|
async updateOfferAmount(
|
|
offerId: string,
|
|
buyerId: string,
|
|
newAmount: number
|
|
): Promise<Offer> {
|
|
const offer = offerStore.getById(offerId);
|
|
|
|
if (!offer) {
|
|
throw new Error('Offer not found');
|
|
}
|
|
|
|
if (offer.buyerId !== buyerId) {
|
|
throw new Error('Unauthorized: This is not your offer');
|
|
}
|
|
|
|
if (offer.status !== OfferStatus.PENDING) {
|
|
throw new Error('Can only update pending offers');
|
|
}
|
|
|
|
const updatedOffer = offerStore.update(offerId, { amount: newAmount });
|
|
|
|
if (!updatedOffer) {
|
|
throw new Error('Failed to update offer');
|
|
}
|
|
|
|
return updatedOffer;
|
|
}
|
|
|
|
/**
|
|
* Get offer statistics
|
|
*/
|
|
async getOfferStats(userId: string, role: 'buyer' | 'seller'): Promise<{
|
|
totalOffers: number;
|
|
pendingOffers: number;
|
|
acceptedOffers: number;
|
|
rejectedOffers: number;
|
|
}> {
|
|
let offers: Offer[];
|
|
|
|
if (role === 'buyer') {
|
|
offers = offerStore.getByBuyerId(userId);
|
|
} else {
|
|
const sellerListings = listingStore.getBySellerId(userId);
|
|
const listingIds = new Set(sellerListings.map(l => l.id));
|
|
offers = offerStore.getAll().filter(o => listingIds.has(o.listingId));
|
|
}
|
|
|
|
return {
|
|
totalOffers: offers.length,
|
|
pendingOffers: offers.filter(o => o.status === OfferStatus.PENDING).length,
|
|
acceptedOffers: offers.filter(o => o.status === OfferStatus.ACCEPTED).length,
|
|
rejectedOffers: offers.filter(o => o.status === OfferStatus.REJECTED).length,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Expire old pending offers
|
|
*/
|
|
async expireOldOffers(): Promise<number> {
|
|
const now = new Date();
|
|
let expiredCount = 0;
|
|
|
|
const pendingOffers = offerStore.getAll().filter(
|
|
o => o.status === OfferStatus.PENDING && o.expiresAt
|
|
);
|
|
|
|
for (const offer of pendingOffers) {
|
|
if (offer.expiresAt && offer.expiresAt < now) {
|
|
offerStore.update(offer.id, { status: OfferStatus.EXPIRED });
|
|
expiredCount++;
|
|
}
|
|
}
|
|
|
|
return expiredCount;
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const offerService = new OfferService();
|