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)
305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import Link from 'next/link';
|
|
import Head from 'next/head';
|
|
|
|
interface Offer {
|
|
id: string;
|
|
listingId: string;
|
|
amount: number;
|
|
message?: string;
|
|
status: string;
|
|
createdAt: string;
|
|
listing?: {
|
|
id: string;
|
|
title: string;
|
|
price: number;
|
|
sellerId: string;
|
|
sellerName?: string;
|
|
};
|
|
}
|
|
|
|
interface OfferStats {
|
|
totalOffers: number;
|
|
pendingOffers: number;
|
|
acceptedOffers: number;
|
|
rejectedOffers: number;
|
|
}
|
|
|
|
const statusColors: Record<string, string> = {
|
|
pending: 'bg-yellow-100 text-yellow-800',
|
|
accepted: 'bg-green-100 text-green-800',
|
|
rejected: 'bg-red-100 text-red-800',
|
|
withdrawn: 'bg-gray-100 text-gray-800',
|
|
expired: 'bg-gray-100 text-gray-800',
|
|
};
|
|
|
|
export default function MyOffersPage() {
|
|
const [offers, setOffers] = useState<Offer[]>([]);
|
|
const [stats, setStats] = useState<OfferStats | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [role, setRole] = useState<'buyer' | 'seller'>('buyer');
|
|
const [statusFilter, setStatusFilter] = useState<string>('');
|
|
|
|
useEffect(() => {
|
|
fetchOffers();
|
|
}, [role, statusFilter]);
|
|
|
|
const fetchOffers = async () => {
|
|
setLoading(true);
|
|
try {
|
|
let url = `/api/marketplace/my-offers?role=${role}`;
|
|
if (statusFilter) {
|
|
url += `&status=${statusFilter}`;
|
|
}
|
|
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
'x-user-id': role === 'buyer' ? 'demo-buyer' : 'demo-user',
|
|
},
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
throw new Error(data.error || 'Failed to fetch offers');
|
|
}
|
|
|
|
setOffers(data.offers || []);
|
|
setStats(data.stats || null);
|
|
} catch (error) {
|
|
console.error('Error fetching offers:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleOfferAction = async (offerId: string, action: string) => {
|
|
if (action === 'withdraw' && !confirm('Are you sure you want to withdraw this offer?')) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/marketplace/offers/${offerId}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'x-user-id': role === 'buyer' ? 'demo-buyer' : 'demo-user',
|
|
},
|
|
body: JSON.stringify({ action }),
|
|
});
|
|
|
|
if (response.ok) {
|
|
fetchOffers();
|
|
} else {
|
|
const data = await response.json();
|
|
alert(data.error || 'Failed to update offer');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error updating offer:', error);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
|
|
<Head>
|
|
<title>My Offers - LocalGreenChain Marketplace</title>
|
|
</Head>
|
|
|
|
{/* Header */}
|
|
<header className="bg-white shadow-sm">
|
|
<div className="max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
|
|
<div className="flex items-center justify-between">
|
|
<Link href="/marketplace">
|
|
<a className="text-2xl font-bold text-green-800">LocalGreenChain</a>
|
|
</Link>
|
|
<nav className="flex gap-4">
|
|
<Link href="/marketplace/my-listings">
|
|
<a className="text-green-600 hover:text-green-700">My Listings</a>
|
|
</Link>
|
|
<Link href="/marketplace">
|
|
<a className="text-green-600 hover:text-green-700">Marketplace</a>
|
|
</Link>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Main Content */}
|
|
<main className="max-w-7xl mx-auto px-4 py-12 sm:px-6 lg:px-8">
|
|
<h1 className="text-3xl font-bold text-gray-900 mb-8">My Offers</h1>
|
|
|
|
{/* Role Toggle */}
|
|
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
|
<div className="flex gap-4 items-center">
|
|
<span className="text-gray-600">View as:</span>
|
|
<button
|
|
onClick={() => setRole('buyer')}
|
|
className={`px-4 py-2 rounded-lg ${
|
|
role === 'buyer' ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
Buyer (Offers I Made)
|
|
</button>
|
|
<button
|
|
onClick={() => setRole('seller')}
|
|
className={`px-4 py-2 rounded-lg ${
|
|
role === 'seller' ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
Seller (Offers Received)
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats Cards */}
|
|
{stats && (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
|
<div className="bg-white rounded-lg shadow p-4">
|
|
<div className="text-2xl font-bold text-gray-900">{stats.totalOffers}</div>
|
|
<div className="text-sm text-gray-500">Total Offers</div>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow p-4">
|
|
<div className="text-2xl font-bold text-yellow-600">{stats.pendingOffers}</div>
|
|
<div className="text-sm text-gray-500">Pending</div>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow p-4">
|
|
<div className="text-2xl font-bold text-green-600">{stats.acceptedOffers}</div>
|
|
<div className="text-sm text-gray-500">Accepted</div>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow p-4">
|
|
<div className="text-2xl font-bold text-red-600">{stats.rejectedOffers}</div>
|
|
<div className="text-sm text-gray-500">Rejected</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Status Filter */}
|
|
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => setStatusFilter('')}
|
|
className={`px-4 py-2 rounded-lg ${
|
|
!statusFilter ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
All
|
|
</button>
|
|
<button
|
|
onClick={() => setStatusFilter('pending')}
|
|
className={`px-4 py-2 rounded-lg ${
|
|
statusFilter === 'pending' ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
Pending
|
|
</button>
|
|
<button
|
|
onClick={() => setStatusFilter('accepted')}
|
|
className={`px-4 py-2 rounded-lg ${
|
|
statusFilter === 'accepted' ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
Accepted
|
|
</button>
|
|
<button
|
|
onClick={() => setStatusFilter('rejected')}
|
|
className={`px-4 py-2 rounded-lg ${
|
|
statusFilter === 'rejected' ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
Rejected
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Offers List */}
|
|
{loading ? (
|
|
<div className="text-center py-12">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600 mx-auto"></div>
|
|
<p className="mt-4 text-gray-600">Loading offers...</p>
|
|
</div>
|
|
) : offers.length === 0 ? (
|
|
<div className="bg-white rounded-lg shadow-lg p-12 text-center">
|
|
<div className="text-6xl mb-4">📨</div>
|
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">No Offers Yet</h2>
|
|
<p className="text-gray-600 mb-6">
|
|
{role === 'buyer'
|
|
? 'Browse the marketplace and make offers on items you like.'
|
|
: 'When buyers make offers on your listings, they will appear here.'}
|
|
</p>
|
|
<Link href="/marketplace">
|
|
<a className="inline-block px-6 py-3 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 transition">
|
|
Browse Marketplace
|
|
</a>
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{offers.map((offer) => (
|
|
<div key={offer.id} className="bg-white rounded-lg shadow p-6">
|
|
<div className="flex justify-between items-start">
|
|
<div className="flex-1">
|
|
{offer.listing && (
|
|
<Link href={`/marketplace/listings/${offer.listing.id}`}>
|
|
<a className="text-lg font-semibold text-gray-900 hover:text-green-600">
|
|
{offer.listing.title}
|
|
</a>
|
|
</Link>
|
|
)}
|
|
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
|
|
<span>
|
|
Listing price: ${offer.listing?.price.toFixed(2)}
|
|
</span>
|
|
<span>|</span>
|
|
<span>
|
|
{new Date(offer.createdAt).toLocaleDateString()}
|
|
</span>
|
|
</div>
|
|
{offer.message && (
|
|
<p className="mt-3 text-gray-600 bg-gray-50 p-3 rounded">
|
|
"{offer.message}"
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="text-right ml-6">
|
|
<div className="text-2xl font-bold text-green-600">
|
|
${offer.amount.toFixed(2)}
|
|
</div>
|
|
<span className={`inline-block px-3 py-1 rounded-full text-sm font-medium mt-2 ${statusColors[offer.status]}`}>
|
|
{offer.status}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
{offer.status === 'pending' && (
|
|
<div className="flex gap-3 mt-4 pt-4 border-t">
|
|
{role === 'buyer' ? (
|
|
<button
|
|
onClick={() => handleOfferAction(offer.id, 'withdraw')}
|
|
className="px-4 py-2 text-red-600 hover:bg-red-50 rounded-lg transition"
|
|
>
|
|
Withdraw Offer
|
|
</button>
|
|
) : (
|
|
<>
|
|
<button
|
|
onClick={() => handleOfferAction(offer.id, 'accept')}
|
|
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
|
|
>
|
|
Accept Offer
|
|
</button>
|
|
<button
|
|
onClick={() => handleOfferAction(offer.id, 'reject')}
|
|
className="px-4 py-2 text-red-600 hover:bg-red-50 rounded-lg transition"
|
|
>
|
|
Reject
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|