diff --git a/backend/models/user.py b/backend/models/user.py
index fa3f0cc..706df73 100644
--- a/backend/models/user.py
+++ b/backend/models/user.py
@@ -79,6 +79,7 @@ def to_dict(self):
)
data["inventory"] = inventory_data
+ data["inventory_id"] = self.inventory.id if self.inventory else None
return data
diff --git a/backend/routes/b2b.py b/backend/routes/b2b.py
index 9a8185f..9abad81 100644
--- a/backend/routes/b2b.py
+++ b/backend/routes/b2b.py
@@ -71,8 +71,9 @@ def delete(self, gb_id):
@b2b_ns.route("/offers")
class OfferList(Resource):
def get(self):
- """List all direct B2B stock offers."""
- return {"offers": get_all_offers()}, 200
+ """List direct B2B stock offers, optionally filtered by producer_id."""
+ producer_id = request.args.get("producer_id", type=int)
+ return {"offers": get_all_offers(producer_id=producer_id)}, 200
def post(self):
"""Submit a new offer from a retailer to a producer."""
diff --git a/backend/routes/orders.py b/backend/routes/orders.py
index e8727d7..2db6d0f 100644
--- a/backend/routes/orders.py
+++ b/backend/routes/orders.py
@@ -34,10 +34,9 @@
@orders_ns.route("")
class OrderList(Resource):
def get(self):
- """
- List all inventory orders.
- """
- return {"orders": get_all_orders()}, 200
+ """List all inventory orders, optionally filtered by retailer_id."""
+ retailer_id = request.args.get("retailer_id", type=int)
+ return {"orders": get_all_orders(retailer_id=retailer_id)}, 200
@orders_ns.expect(order_model)
def post(self):
diff --git a/backend/services/b2b_service.py b/backend/services/b2b_service.py
index d6fb256..40130ab 100644
--- a/backend/services/b2b_service.py
+++ b/backend/services/b2b_service.py
@@ -1,5 +1,7 @@
from database import db
from models.b2b import BuyStockOffer, GroupBuy
+from models.catalog import Item
+from models.user import Retailer
def get_all_group_buys():
@@ -55,11 +57,28 @@ def delete_group_buy(gb_id):
return True
-def get_all_offers():
- offers = BuyStockOffer.query.all()
- return [
- {"id": o.id, "retailer_id": o.retailer_id, "producer_id": o.producer_id, "status": o.status} for o in offers
- ]
+def _serialize_offer(o):
+ retailer = db.session.get(Retailer, o.retailer_id)
+ item = db.session.get(Item, o.item_id)
+ return {
+ "id": o.id,
+ "retailer_id": o.retailer_id,
+ "retailer_name": retailer.company_name if retailer else None,
+ "producer_id": o.producer_id,
+ "item_id": o.item_id,
+ "item_name": item.name if item else None,
+ "item_unit": item.unit_type if item else None,
+ "offered_price": float(o.offered_price),
+ "requested_quantity": float(o.requested_quantity),
+ "status": o.status,
+ }
+
+
+def get_all_offers(producer_id=None):
+ q = BuyStockOffer.query
+ if producer_id is not None:
+ q = q.filter(BuyStockOffer.producer_id == producer_id)
+ return [_serialize_offer(o) for o in q.all()]
def get_offer(offer_id):
diff --git a/backend/services/order_service.py b/backend/services/order_service.py
index fe6d7c7..ad3c14b 100644
--- a/backend/services/order_service.py
+++ b/backend/services/order_service.py
@@ -2,10 +2,21 @@
from models.orders import InventoryOrder, OrderItem
-def get_all_orders():
- orders = InventoryOrder.query.all()
+def get_all_orders(retailer_id=None):
+ q = InventoryOrder.query
+ if retailer_id is not None:
+ q = q.filter(InventoryOrder.retailer_id == retailer_id)
+ orders = q.order_by(InventoryOrder.order_date.desc()).all()
return [
- {"id": o.id, "consumer_id": o.consumer_id, "total_amount": float(o.total_amount), "status": o.status}
+ {
+ "id": o.id,
+ "consumer_id": o.consumer_id,
+ "retailer_id": o.retailer_id,
+ "total_amount": float(o.total_amount),
+ "status": o.status,
+ "order_date": o.order_date.isoformat() if o.order_date else None,
+ "item_count": len(o.order_items),
+ }
for o in orders
]
diff --git a/backend/services/user_service.py b/backend/services/user_service.py
index 560ee4c..b734b51 100644
--- a/backend/services/user_service.py
+++ b/backend/services/user_service.py
@@ -18,6 +18,7 @@ def get_all_users(user_type=None):
"""
if user_type == "producer":
users = Producer.query.options(selectinload(Producer.items).selectinload(Item.prices)).all()
+ return [u.to_dict() for u in users]
elif user_type == "retailer":
users = Retailer.query.all()
elif user_type == "consumer":
diff --git a/frontend/src/components/ProducerDashboard/ProducerDashboard.css b/frontend/src/components/ProducerDashboard/ProducerDashboard.css
index 1a0b7fa..10b72cb 100644
--- a/frontend/src/components/ProducerDashboard/ProducerDashboard.css
+++ b/frontend/src/components/ProducerDashboard/ProducerDashboard.css
@@ -1,216 +1,72 @@
-.producer-dashboard {
- padding: 2rem;
- max-width: 98vw;
- width: 100%;
- box-sizing: border-box;
-}
-
-.dashboard-header {
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
- margin-bottom: 2rem;
-}
-
-.dashboard-header h1 {
- font-family: 'Playfair Display', serif;
- color: #3e2f1c;
- margin: 0 0 0.25rem;
-}
-
-.dashboard-subtitle {
- color: #7a5c3e;
- font-size: 0.9rem;
- margin: 0;
-}
-
-.dashboard-stats {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 1rem;
- margin-bottom: 2rem;
-}
-
-.stat-card {
- background: #fff;
- border: 1px solid #d4c4a8;
- border-radius: 8px;
- padding: 1.25rem 1.5rem;
- display: flex;
- flex-direction: column;
- gap: 0.25rem;
-}
-
-.stat-card.warn {
- border-color: #c1694f;
-}
-
-.stat-number {
- font-size: 2rem;
- font-weight: 700;
- color: #3e2f1c;
- font-family: 'Playfair Display', serif;
-}
-
-.stat-card.warn .stat-number {
- color: #c1694f;
-}
-
-.stat-label {
- font-size: 0.85rem;
- color: #7a5c3e;
-}
-
-.dashboard-section {
- background: #fff;
- border: 1px solid #d4c4a8;
- border-radius: 8px;
- padding: 1.5rem;
-}
-
-.section-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 1rem;
-}
-
-.section-header h2 {
- font-family: 'Playfair Display', serif;
- color: #3e2f1c;
- margin: 0;
- font-size: 1.2rem;
-}
+@import '../dashboard-shared.css';
+/* ── Stock list (uses .inventory-row from Inventory.css via StockRow) ── */
.stock-list {
display: flex;
flex-direction: column;
gap: 0.2rem;
+ width: 100%;
}
-.stock-card {
- display: flex;
+/* ── Offer rows ── */
+.offer-row {
+ display: grid;
+ grid-template-columns: minmax(0, 2fr) minmax(0, 1.5fr) 6rem 8rem 7rem 10rem;
align-items: center;
- gap: 0.6rem;
- padding: 0.3rem 0.7rem;
- background: #faf6ef;
- border-radius: 5px;
- border-left: 3px solid #4a7c59;
- min-height: 2.2rem;
-}
-
-.stock-card:hover {
- border-left-color: #7a5c3e;
-}
-
-.stock-card-main {
- display: flex;
- align-items: baseline;
- gap: 0.5rem;
- flex: 1;
- min-width: 0;
+ padding: 0.3em 0.7em;
+ border-left: 3px solid #c4a882;
+ border-radius: 4px;
+ background-color: #faf6ef;
+ min-height: 2.2em;
+ font-size: 0.9rem;
}
-.stock-name {
+.offer-row-header {
font-weight: 600;
- font-size: 0.88rem;
- color: #3e2f1c;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
+ font-size: 0.8rem;
+ color: #6b5744;
+ border-left-color: transparent;
+ background: transparent;
+ padding-bottom: 0.2em;
}
-.stock-sku {
+.offer-status {
font-size: 0.75rem;
- color: #7a5c3e;
- white-space: nowrap;
- flex-shrink: 0;
-}
-
-.stock-card-meta {
- display: flex;
- gap: 1rem;
- align-items: center;
- font-size: 0.82rem;
- color: #7a5c3e;
- flex-shrink: 0;
-}
-
-.stock-qty {
font-weight: 600;
- color: #4a7c59;
- white-space: nowrap;
+ text-transform: uppercase;
+ padding: 0.15em 0.5em;
+ border-radius: 999px;
+ display: inline-block;
}
-.stock-qty.low {
- color: #c1694f;
-}
-
-.stock-expiry {
- white-space: nowrap;
-}
-
-.stock-price {
- color: #3e2f1c;
- white-space: nowrap;
-}
+.offer-status--pending { background: #fff3cd; color: #856404; }
+.offer-status--accepted { background: #d1e7dd; color: #0f5132; }
+.offer-status--rejected { background: #f8d7da; color: #842029; }
-.empty-state {
- text-align: center;
- padding: 2rem;
- color: #7a5c3e;
+.offer-actions {
+ display: flex;
+ gap: 0.4rem;
}
-.btn-primary {
+.btn-accept {
+ padding: 0.2em 0.7em;
background: #4a7c59;
color: #fff;
border: none;
- padding: 0.6rem 1.25rem;
- border-radius: 6px;
- font-family: 'Nunito', sans-serif;
- font-size: 0.95rem;
+ border-radius: 4px;
cursor: pointer;
+ font-size: 0.8rem;
}
-.btn-primary:hover {
- background: #3a6347;
-}
-
-.btn-link {
- background: none;
- border: none;
- color: #4a7c59;
+.btn-reject {
+ padding: 0.2em 0.7em;
+ background: transparent;
+ color: #842029;
+ border: 1px solid #842029;
+ border-radius: 4px;
cursor: pointer;
- font-size: 0.9rem;
- padding: 0;
- font-family: 'Nunito', sans-serif;
-}
-
-.dashboard-loading,
-.dashboard-error {
- text-align: center;
- padding: 2rem;
- color: #7a5c3e;
-}
-
-.dashboard-error {
- color: #c1694f;
+ font-size: 0.8rem;
}
-.modal-back-btn {
- display: flex;
- align-items: center;
- gap: 0.3em;
- background: none;
- border: none;
- cursor: pointer;
- color: var(--farm-text-muted, #7a5c3e);
- font-size: 0.9rem;
- font-family: var(--brand-sans);
- padding: 0;
- margin-bottom: 0.8em;
-}
-
-.modal-back-btn:hover {
- color: var(--text-color, #3e2f1c);
-}
+.btn-accept:hover { background: #3a6147; }
+.btn-reject:hover { background: #f8d7da; }
diff --git a/frontend/src/components/ProducerDashboard/ProducerDashboard.jsx b/frontend/src/components/ProducerDashboard/ProducerDashboard.jsx
index abcd559..ca998ff 100644
--- a/frontend/src/components/ProducerDashboard/ProducerDashboard.jsx
+++ b/frontend/src/components/ProducerDashboard/ProducerDashboard.jsx
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { X, ArrowLeft } from 'lucide-react';
import { useAuth } from '../../contexts/useAuth';
-import { inventoryAPI } from '../../utils/api';
+import { inventoryAPI, b2bAPI } from '../../utils/api';
import Inventory from '../Inventory/Inventory';
import NewItem from '../Inventory/NewItem';
import StockRow from '../Inventory/StockRow';
@@ -16,6 +16,7 @@ export default function ProducerDashboard() {
const [modal, setModal] = useState(false);
const [modalView, setModalView] = useState('inventory');
const [editingStock, setEditingStock] = useState(null);
+ const [offers, setOffers] = useState([]);
useEffect(() => {
if (!user?.inventory_id) return;
@@ -25,6 +26,19 @@ export default function ProducerDashboard() {
.finally(() => setLoading(false));
}, [user]);
+ useEffect(() => {
+ if (!user?.id) return;
+ b2bAPI.getOffers(user.id)
+ .then(data => setOffers(data.offers || []))
+ .catch(() => {});
+ }, [user]);
+
+ const handleOfferAction = (offerId, status) => {
+ b2bAPI.updateOffer(offerId, status)
+ .then(() => setOffers(prev => prev.map(o => o.id === offerId ? { ...o, status } : o)))
+ .catch(() => {});
+ };
+
const totalItems = stocks.length;
const lowStock = stocks.filter(s => s.quantity < 10);
const expiringItems = stocks.filter(s => {
@@ -90,6 +104,41 @@ export default function ProducerDashboard() {
)}
+ {offers.length > 0 && (
+
+
+
Incoming Offers
+
+
+
+ Retailer
+ Item
+ Qty
+ Offered Price
+ Status
+
+
+ {offers.map(o => (
+
+ {o.retailer_name || `Retailer #${o.retailer_id}`}
+ {o.item_name || `Item #${o.item_id}`}
+ {o.requested_quantity} {o.item_unit}
+ ${parseFloat(o.offered_price).toFixed(2)}
+ {o.status}
+
+ {o.status === 'pending' && (
+ <>
+ handleOfferAction(o.id, 'accepted')}>Accept
+ handleOfferAction(o.id, 'rejected')}>Reject
+ >
+ )}
+
+
+ ))}
+
+
+ )}
+
{editingStock && (
setEditingStock(null)}>
e.stopPropagation()}>
diff --git a/frontend/src/components/RetailerDashboard/RetailerDashboard.css b/frontend/src/components/RetailerDashboard/RetailerDashboard.css
index b89650c..b9feb6d 100644
--- a/frontend/src/components/RetailerDashboard/RetailerDashboard.css
+++ b/frontend/src/components/RetailerDashboard/RetailerDashboard.css
@@ -1,158 +1,62 @@
-.retailer-dashboard {
- padding: 2rem;
- max-width: 1100px;
- margin: 0 auto;
-}
-
-.dashboard-header {
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
- margin-bottom: 2rem;
-}
-
-.dashboard-header h1 {
- font-family: 'Playfair Display', serif;
- color: #3e2f1c;
- margin: 0 0 0.25rem;
-}
-
-.dashboard-subtitle {
- color: #7a5c3e;
- font-size: 0.9rem;
- margin: 0;
-}
-
-.dashboard-stats {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 1rem;
- margin-bottom: 2rem;
-}
-
-.stat-card {
- background: #fff;
- border: 1px solid #d4c4a8;
- border-radius: 8px;
- padding: 1.25rem 1.5rem;
- display: flex;
- flex-direction: column;
- gap: 0.25rem;
-}
-
-.stat-card.warn .stat-number { color: #c1694f; }
-
-.stat-number {
- font-size: 2rem;
- font-weight: 700;
- color: #3e2f1c;
- font-family: 'Playfair Display', serif;
-}
-
-.stat-label {
- font-size: 0.85rem;
- color: #7a5c3e;
-}
-
-.dashboard-section {
- background: #fff;
- border: 1px solid #d4c4a8;
- border-radius: 8px;
- padding: 1.5rem;
-}
-
-.section-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 1rem;
-}
-
-.section-header h2 {
- font-family: 'Playfair Display', serif;
- color: #3e2f1c;
- margin: 0;
- font-size: 1.2rem;
-}
+@import '../dashboard-shared.css';
+/* ── Order rows: order# | status | date | items | total ── */
.order-list {
display: flex;
flex-direction: column;
- gap: 0.75rem;
+ gap: 0.2rem;
+ width: 100%;
}
-.order-card {
- display: flex;
- justify-content: space-between;
+/* order-id | status | date | item-count | total */
+.order-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1.5fr) 7rem 8rem 5rem 7rem;
align-items: center;
- padding: 0.875rem 1rem;
+ padding: 0.3em 0.7em;
+ border-left: 3px solid #d4c4a8;
+ border-radius: 5px;
background: #faf6ef;
- border-radius: 6px;
- border: 1px solid #e8dfd0;
- border-left: 4px solid #d4c4a8;
+ min-height: 2.2em;
+ box-sizing: border-box;
+ width: 100%;
}
-.order-card-main {
- display: flex;
- flex-direction: column;
- gap: 0.2rem;
+.order-row:hover {
+ border-left-color: #7a5c3e;
}
-.order-id {
+.order-row-id {
font-weight: 600;
+ font-size: 0.88rem;
color: #3e2f1c;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ padding-right: 0.5em;
}
-.order-total {
- font-size: 0.85rem;
- color: #7a5c3e;
-}
-
-.order-card-meta {
- display: flex;
- gap: 1rem;
- align-items: center;
-}
-
-.order-status {
- font-size: 0.75rem;
+.order-status-badge {
+ display: inline-block;
+ font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
- padding: 3px 10px;
+ padding: 2px 8px;
border-radius: 3px;
border: 1px solid;
+ white-space: nowrap;
}
-.order-date {
- font-size: 0.85rem;
- color: #7a5c3e;
-}
-
-.empty-state {
- text-align: center;
- padding: 2rem;
+.order-row-date,
+.order-row-items,
+.order-row-total {
+ font-size: 0.82rem;
color: #7a5c3e;
+ white-space: nowrap;
}
-.btn-primary {
- background: #4a7c59;
- color: #fff;
- border: none;
- padding: 0.6rem 1.25rem;
- border-radius: 6px;
- font-family: 'Nunito', sans-serif;
- font-size: 0.95rem;
- cursor: pointer;
-}
-
-.btn-primary:hover { background: #3a6347; }
-
-.dashboard-loading,
-.dashboard-error {
- text-align: center;
- padding: 2rem;
- color: #7a5c3e;
+.order-row-total {
+ font-weight: 600;
+ color: #3e2f1c;
}
-
-.dashboard-error { color: #c1694f; }
diff --git a/frontend/src/components/RetailerDashboard/RetailerDashboard.jsx b/frontend/src/components/RetailerDashboard/RetailerDashboard.jsx
index cb41497..0645102 100644
--- a/frontend/src/components/RetailerDashboard/RetailerDashboard.jsx
+++ b/frontend/src/components/RetailerDashboard/RetailerDashboard.jsx
@@ -5,12 +5,34 @@ import { ordersAPI } from '../../utils/api';
import './RetailerDashboard.css';
const STATUS_COLORS = {
- pending: { bg: '#fff3e0', text: '#c1694f', border: '#c1694f' },
- shipped: { bg: '#e8f5e9', text: '#4a7c59', border: '#4a7c59' },
+ pending: { bg: '#fff3e0', text: '#c1694f', border: '#c1694f' },
+ shipped: { bg: '#e8f5e9', text: '#4a7c59', border: '#4a7c59' },
completed: { bg: '#e8f5e9', text: '#4a7c59', border: '#4a7c59' },
cancelled: { bg: '#fdecea', text: '#b00020', border: '#b00020' },
};
+function OrderRow({ order }) {
+ const colors = STATUS_COLORS[order.status] || STATUS_COLORS.pending;
+ const date = order.order_date
+ ? new Date(order.order_date).toLocaleDateString()
+ : '—';
+
+ return (
+
+ Order #{order.id}
+
+ {order.status}
+
+ {date}
+ {order.item_count ?? 0} items
+ ${order.total_amount?.toFixed(2)}
+
+ );
+}
+
export default function RetailerDashboard() {
const { user } = useAuth();
const navigate = useNavigate();
@@ -19,13 +41,14 @@ export default function RetailerDashboard() {
const [error, setError] = useState(null);
useEffect(() => {
- ordersAPI.getAll()
+ if (!user?.id) return;
+ ordersAPI.getAll(user.id)
.then(data => setOrders(data.orders || []))
.catch(e => setError(e.message))
.finally(() => setLoading(false));
- }, []);
+ }, [user]);
- const pending = orders.filter(o => o.status === 'pending');
+ const pending = orders.filter(o => o.status === 'pending');
const completed = orders.filter(o => o.status === 'completed');
return (
@@ -55,7 +78,7 @@ export default function RetailerDashboard() {
- {loading && Loading orders...
}
+ {loading && Loading orders…
}
{error && {error}
}
{!loading && !error && (
@@ -73,26 +96,7 @@ export default function RetailerDashboard() {
) : (
- {orders.map(o => {
- const colors = STATUS_COLORS[o.status] || STATUS_COLORS.pending;
- return (
-
-
- Order #{o.id}
- ${o.total_amount?.toFixed(2)}
-
-
-
- {o.status}
-
- {o.order_date ? new Date(o.order_date).toLocaleDateString() : ''}
-
-
- );
- })}
+ {orders.map(o =>
)}
)}
diff --git a/frontend/src/components/SearchPanel/Checkout.jsx b/frontend/src/components/SearchPanel/Checkout.jsx
index 680eef5..4e28ef6 100644
--- a/frontend/src/components/SearchPanel/Checkout.jsx
+++ b/frontend/src/components/SearchPanel/Checkout.jsx
@@ -1,29 +1,66 @@
-import React, { useState } from "react";
+import { useState } from "react";
+import { b2bAPI } from "../../utils/api";
-const Checkout = ({ product, producer, mode, onClose }) => {
+const getUnitPrice = (tiers, qty) => {
+ const sorted = [...tiers].sort((a, b) => a.min_quantity - b.min_quantity);
+ let price = sorted[0]?.price_per_unit ?? 0;
+ for (const tier of sorted) {
+ if (qty >= tier.min_quantity) price = tier.price_per_unit;
+ else break;
+ }
+ return Number(price);
+};
+
+const Checkout = ({ stock, producer, mode, user, onClose }) => {
const [quantity, setQuantity] = useState(1);
const [targetQuantity, setTargetQuantity] = useState(50);
- const [step, setStep] = useState("FORM");
const [confirmed, setConfirmed] = useState(false);
+ const [step, setStep] = useState("FORM"); // FORM | SUBMITTING | SUCCESS | ERROR
+ const [errorMsg, setErrorMsg] = useState(null);
- {/* TODO: integrate */}
- const getUnitPrice = (qty) => {
- if (qty >= 13) return Math.floor(product.base_price * 0.8);
- if (qty >= 6) return Math.floor(product.base_price * 0.9);
- return product.base_price;
- };
+ const tiers = stock?.item?.prices ?? [];
+ const unitPrice = getUnitPrice(tiers, quantity);
+ const totalPrice = (unitPrice * quantity).toFixed(2);
- const currentUnitPrice = getUnitPrice(quantity);
- const totalPrice = currentUnitPrice * quantity;
+ const handleConfirm = async () => {
+ setStep("SUBMITTING");
+ setErrorMsg(null);
+ try {
+ if (mode === "DIRECT") {
+ await b2bAPI.createOffer({
+ retailer_id: user?.id,
+ producer_id: producer?.id,
+ item_id: stock?.item?.id,
+ offered_price: unitPrice,
+ requested_quantity: quantity,
+ status: "pending",
+ });
+ } else {
+ await b2bAPI.createGroupBuy({
+ initiator_retailer_id: user?.id,
+ item_id: stock?.item?.id,
+ target_quantity: targetQuantity,
+ status: "open",
+ });
+ }
+ setStep("SUCCESS");
+ } catch (err) {
+ setErrorMsg(err.message);
+ setStep("ERROR");
+ }
+ };
if (step === "SUCCESS") {
return (
-
Order Confirmed
+
+ {mode === "DIRECT" ? "Offer Submitted" : "Group Buy Started"}
+
- Your request for {quantity} units of {product.name} has been received.
- {mode === "GROUP" ? " We'll notify you once the group goal is reached." : ""}
+ Your {mode === "DIRECT" ? "offer" : "group buy"} for {quantity} {stock?.item?.unit_type} of{" "}
+ {stock?.item?.name} from {producer?.name} has been received.
+ {mode === "GROUP" ? " Other retailers can now join the group to hit the target quantity." : ""}
Return to Map
@@ -31,6 +68,21 @@ const Checkout = ({ product, producer, mode, onClose }) => {
);
}
+ if (step === "ERROR") {
+ return (
+
+
+
Something went wrong
+
{errorMsg}
+
+ setStep("FORM")} style={{ ...primaryBtnStyle, background: "#7a5c3e" }}>Go Back
+ Close
+
+
+
+ );
+ }
+
return (
@@ -41,40 +93,54 @@ const Checkout = ({ product, producer, mode, onClose }) => {
✕
+ {/* Item info */}
- {producer.name}
-
{product.name}
+ {producer?.name}
+ {stock?.item?.name}
+ {stock?.quantity} {stock?.item?.unit_type} available
+ {/* Price tiers summary */}
+ {tiers.length > 0 && (
+
+ {[...tiers].sort((a, b) => a.min_quantity - b.min_quantity).map((t, i) => (
+
+ {t.min_quantity}{t.max_quantity ? `–${t.max_quantity}` : "+"}: ${Number(t.price_per_unit).toFixed(2)}
+
+ ))}
+
+ )}
+
{mode === "GROUP" && (
)}
-
Your Quantity
+
Your Quantity ({stock?.item?.unit_type})
setQuantity(parseInt(e.target.value) || 0)}
+ onChange={(e) => setQuantity(parseInt(e.target.value) || 1)}
style={inputStyle}
/>
+
+ Unit price at this quantity: ${unitPrice.toFixed(2)}
+
setConfirmed(e.target.checked)} style={{ margin: 0, flexShrink: 0 }} />
- I confirm that I meet all applicable legal and platform requirements to place this order.
+
+ I confirm that I meet all applicable legal and platform requirements to place this order.
+
@@ -83,33 +149,22 @@ const Checkout = ({ product, producer, mode, onClose }) => {
${totalPrice}
setStep("SUCCESS")}
- disabled={!confirmed}
- style={{ ...primaryBtnStyle, ...(confirmed ? {} : { opacity: 0.5, cursor: "not-allowed" }) }}
+ onClick={handleConfirm}
+ disabled={!confirmed || step === "SUBMITTING"}
+ style={{ ...primaryBtnStyle, opacity: confirmed ? 1 : 0.5, cursor: confirmed ? "pointer" : "not-allowed" }}
>
- Confirm Order
+ {step === "SUBMITTING" ? "Submitting…" : "Confirm Order"}
-
+
);
};
-const overlayStyle = {
- position: "fixed", top: 0, left: 0, width: "100%", height: "100%",
- backgroundColor: "rgba(62, 47, 28, 0.4)", backdropFilter: "blur(4px)",
- display: "flex", justifyContent: "center", alignItems: "center", zIndex: 1000
-};
-const modalStyle = {
- background: "#fffdf7", padding: "40px", borderRadius: "8px", width: "450px",
- boxShadow: "0 6px 0 rgba(122, 92, 62, 0.1)", border: "2px solid #c4a882"
-};
+const overlayStyle = { position: "fixed", top: 0, left: 0, width: "100%", height: "100%", backgroundColor: "rgba(62, 47, 28, 0.4)", backdropFilter: "blur(4px)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: 1000 };
+const modalStyle = { background: "#fffdf7", padding: "40px", borderRadius: "8px", width: "450px", boxShadow: "0 6px 0 rgba(122, 92, 62, 0.1)", border: "2px solid #c4a882" };
const labelStyle = { display: "block", marginBottom: "8px", fontWeight: 700, fontSize: "0.85rem", color: "#3e2f1c", textTransform: "uppercase", letterSpacing: "0.03em" };
const inputStyle = { width: "100%", padding: "12px", borderRadius: "4px", border: "2px solid #d4c4a8", fontSize: "1rem", boxSizing: "border-box", color: "#3e2f1c", backgroundColor: "#fffdf7" };
-const primaryBtnStyle = {
- backgroundColor: "#4a7c59", color: "white", padding: "14px 28px",
- borderRadius: "5px", border: "none", cursor: "pointer", fontWeight: 700, fontSize: "0.95rem",
- textTransform: "uppercase", letterSpacing: "0.04em"
-};
+const primaryBtnStyle = { backgroundColor: "#4a7c59", color: "white", padding: "14px 28px", borderRadius: "5px", border: "none", cursor: "pointer", fontWeight: 700, fontSize: "0.95rem", textTransform: "uppercase", letterSpacing: "0.04em" };
export default Checkout;
diff --git a/frontend/src/components/SearchPanel/ProducerDetail.jsx b/frontend/src/components/SearchPanel/ProducerDetail.jsx
index a3d9fae..ec4662c 100644
--- a/frontend/src/components/SearchPanel/ProducerDetail.jsx
+++ b/frontend/src/components/SearchPanel/ProducerDetail.jsx
@@ -1,139 +1,54 @@
import React, { useState } from "react";
import { X } from "lucide-react";
-const ProductItem = ({ product, onOrder }) => {
+const ProductItem = ({ stock, onOrder }) => {
const [isExpanded, setIsExpanded] = useState(false);
-
- {
- /* TODO: integrate */
- }
- const tiers = [
- { qty: "1-5", price: product.base_price },
- { qty: "6-12", price: Math.floor(product.base_price * 0.9) },
- { qty: "13+", price: Math.floor(product.base_price * 0.8) },
- ];
+ const tiers = [...(stock.item.prices ?? [])].sort((a, b) => a.min_quantity - b.min_quantity);
+ const basePrice = tiers[0]?.price_per_unit ?? 0;
return (
-
+
setIsExpanded(!isExpanded)}
>
-
- {product.name}
-
+ {stock.item.name}
- Starting at ${product.base_price}
+ from ${Number(basePrice).toFixed(2)} / {stock.item.unit_type} · {stock.quantity} in stock
-
- {isExpanded ? "▲" : "▼"}
-
+
{isExpanded ? "▲" : "▼"}
{isExpanded && (
-
-
-
- Qty
- Price
-
-
-
- {tiers.map((t, i) => (
-
-
- {t.qty} units
-
-
- ${t.price}
-
+ {tiers.length > 0 && (
+
+
+
+ Min qty
+ Max qty
+ Price / {stock.item.unit_type}
- ))}
-
-
+
+
+ {tiers.map((t, i) => (
+
+ {t.min_quantity}
+ {t.max_quantity ?? "+"}
+ ${Number(t.price_per_unit).toFixed(2)}
+
+ ))}
+
+
+ )}
- onOrder(product, "DIRECT")}
- style={{
- flex: 1,
- padding: "10px",
- borderRadius: "5px",
- border: "none",
- backgroundColor: "#4a7c59",
- color: "white",
- cursor: "pointer",
- fontSize: "0.85rem",
- fontWeight: 700,
- textTransform: "uppercase",
- letterSpacing: "0.04em",
- }}
- >
+ onOrder(stock, "DIRECT")} style={{ flex: 1, padding: "10px", borderRadius: "5px", border: "none", backgroundColor: "#4a7c59", color: "white", cursor: "pointer", fontSize: "0.85rem", fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.04em" }}>
Direct
- onOrder(product, "GROUP")}
- style={{
- flex: 1,
- padding: "10px",
- borderRadius: "5px",
- border: "2px solid #4a7c59",
- backgroundColor: "transparent",
- color: "#4a7c59",
- cursor: "pointer",
- fontSize: "0.85rem",
- fontWeight: 700,
- textTransform: "uppercase",
- letterSpacing: "0.04em",
- }}
- >
+ onOrder(stock, "GROUP")} style={{ flex: 1, padding: "10px", borderRadius: "5px", border: "2px solid #4a7c59", backgroundColor: "transparent", color: "#4a7c59", cursor: "pointer", fontSize: "0.85rem", fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.04em" }}>
Group
@@ -143,68 +58,20 @@ const ProductItem = ({ product, onOrder }) => {
);
};
-function ProducerDetail({ producer, onClose, onOrder }) {
+function ProducerDetail({ producer, stocks = [], onClose, onOrder }) {
const [isCloseHovered, setIsCloseHovered] = useState(false);
return (
-
-
-
+
+
+
{producer.name}
-
setIsCloseHovered(true)}
onMouseLeave={() => setIsCloseHovered(false)}
- style={{
- position: "absolute",
- top: "-4px",
- right: "0",
- border: "none",
- background: isCloseHovered ? "#f0e6d3" : "transparent",
- cursor: "pointer",
- color: isCloseHovered ? "#3e2f1c" : "#7a5c3e",
- borderRadius: "50%",
- width: "32px",
- height: "32px",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- transition: "all 0.2s ease",
- }}
+ style={{ position: "absolute", top: "-4px", right: "0", border: "none", background: isCloseHovered ? "#f0e6d3" : "transparent", cursor: "pointer", color: isCloseHovered ? "#3e2f1c" : "#7a5c3e", borderRadius: "50%", width: "32px", height: "32px", display: "flex", alignItems: "center", justifyContent: "center", transition: "all 0.2s ease" }}
aria-label="Close"
>
@@ -212,44 +79,23 @@ function ProducerDetail({ producer, onClose, onOrder }) {
-
- {producer.type}
-
-
- 📍 {producer.address}
-
+
{producer.type}
+
📍 {producer.address}
-
+
Available Products
- {producer.fullProducts.map((prod, idx) => (
+ {stocks.length === 0 && (
+
No products listed.
+ )}
+ {stocks.map((stock) => (
onOrder(p, producer, mode)}
+ key={stock.stock_id}
+ stock={stock}
+ onOrder={(s, mode) => onOrder(s, producer, mode)}
/>
))}
diff --git a/frontend/src/components/SearchPanel/SearchSidebar.jsx b/frontend/src/components/SearchPanel/SearchSidebar.jsx
index 62fbf60..374f8df 100644
--- a/frontend/src/components/SearchPanel/SearchSidebar.jsx
+++ b/frontend/src/components/SearchPanel/SearchSidebar.jsx
@@ -114,7 +114,7 @@ function SearchSidebar({ searchQuery, setSearchQuery, producers, onSelect }) {
marginTop: "10px",
}}
>
- {dist.products.join(", ")}
+ {dist.products.length > 0 ? dist.products.join(", ") : dist.address}
))
diff --git a/frontend/src/components/dashboard-shared.css b/frontend/src/components/dashboard-shared.css
new file mode 100644
index 0000000..25856ef
--- /dev/null
+++ b/frontend/src/components/dashboard-shared.css
@@ -0,0 +1,148 @@
+/* ── Shared dashboard layout ── */
+.producer-dashboard,
+.retailer-dashboard {
+ padding: 2rem;
+ max-width: 98vw;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.dashboard-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 2rem;
+}
+
+.dashboard-header h1 {
+ font-family: 'Playfair Display', serif;
+ color: #3e2f1c;
+ margin: 0 0 0.25rem;
+}
+
+.dashboard-subtitle {
+ color: #7a5c3e;
+ font-size: 0.9rem;
+ margin: 0;
+}
+
+/* ── Stats ── */
+.dashboard-stats {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 1rem;
+ margin-bottom: 2rem;
+}
+
+.stat-card {
+ background: #fff;
+ border: 1px solid #d4c4a8;
+ border-radius: 8px;
+ padding: 1.25rem 1.5rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.stat-card.warn {
+ border-color: #c1694f;
+}
+
+.stat-number {
+ font-size: 2rem;
+ font-weight: 700;
+ color: #3e2f1c;
+ font-family: 'Playfair Display', serif;
+}
+
+.stat-card.warn .stat-number {
+ color: #c1694f;
+}
+
+.stat-label {
+ font-size: 0.85rem;
+ color: #7a5c3e;
+}
+
+/* ── Section ── */
+.dashboard-section {
+ background: #fff;
+ border: 1px solid #d4c4a8;
+ border-radius: 8px;
+ padding: 1.5rem;
+}
+
+.section-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.75rem;
+}
+
+.section-header h2 {
+ font-family: 'Playfair Display', serif;
+ color: #3e2f1c;
+ margin: 0;
+ font-size: 1.2rem;
+}
+
+/* ── Shared row list ── */
+.row-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ width: 100%;
+}
+
+/* ── Empty / loading / error ── */
+.empty-state {
+ text-align: center;
+ padding: 2rem;
+ color: #7a5c3e;
+}
+
+.dashboard-loading,
+.dashboard-error {
+ text-align: center;
+ padding: 2rem;
+ color: #7a5c3e;
+}
+
+.dashboard-error {
+ color: #c1694f;
+}
+
+/* ── Buttons ── */
+.btn-primary {
+ background: #4a7c59;
+ color: #fff;
+ border: none;
+ padding: 0.6rem 1.25rem;
+ border-radius: 6px;
+ font-family: 'Nunito', sans-serif;
+ font-size: 0.95rem;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.btn-primary:hover {
+ background: #3a6347;
+}
+
+.modal-back-btn {
+ display: flex;
+ align-items: center;
+ gap: 0.3em;
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: #7a5c3e;
+ font-size: 0.9rem;
+ font-family: var(--brand-sans);
+ padding: 0;
+ margin-bottom: 0.8em;
+}
+
+.modal-back-btn:hover {
+ color: #3e2f1c;
+}
diff --git a/frontend/src/pages/MapView.jsx b/frontend/src/pages/MapView.jsx
index 1f334e0..299c8b3 100644
--- a/frontend/src/pages/MapView.jsx
+++ b/frontend/src/pages/MapView.jsx
@@ -4,13 +4,16 @@ import Header from "../components/Header/Header.jsx";
import SearchSidebar from "../components/SearchPanel/SearchSidebar.jsx";
import ProducerDetail from "../components/SearchPanel/ProducerDetail.jsx";
import Checkout from "../components/SearchPanel/Checkout.jsx";
-import { api } from "../utils/api.js";
+import { api, inventoryAPI } from "../utils/api.js";
+import { useAuth } from "../contexts/useAuth.jsx";
import { ChevronLeft, ChevronRight } from "lucide-react";
export default function MapView() {
+ const { user } = useAuth();
const [producers, setProducers] = useState([]);
const [searchQuery, setSearchQuery] = useState("");
const [selectedProducer, setSelectedProducer] = useState(null);
+ const [producerStocks, setProducerStocks] = useState([]);
const [checkoutInfo, setCheckoutInfo] = useState(null);
const [isLoading, setIsLoading] = useState(true);
@@ -30,8 +33,8 @@ export default function MapView() {
lat: p.lat || 49.8625,
lng: p.lng || -119.4625,
address: p.primary_address || "Address not provided",
- products: p.inventory ? p.inventory.map((item) => item.name) : [],
- fullProducts: p.inventory || [],
+ inventory_id: p.inventory_id,
+ products: (p.inventory || []).map((item) => item.name),
}));
setProducers(mappedProducers);
} catch (error) {
@@ -43,6 +46,14 @@ export default function MapView() {
fetchProducers();
}, []);
+ // Fetch stocks when a producer is selected
+ useEffect(() => {
+ if (!selectedProducer?.inventory_id) { setProducerStocks([]); return; }
+ inventoryAPI.getStocks(selectedProducer.inventory_id)
+ .then(data => setProducerStocks(data.stocks || []))
+ .catch(() => setProducerStocks([]));
+ }, [selectedProducer]);
+
// Resizer logic
const handleMouseDown = (e) => {
e.preventDefault();
@@ -71,8 +82,8 @@ export default function MapView() {
const searchLower = searchQuery.toLowerCase();
return (
p.name.toLowerCase().includes(searchLower) ||
- (p.products &&
- p.products.some((prod) => prod.toLowerCase().includes(searchLower)))
+ p.address.toLowerCase().includes(searchLower) ||
+ p.products.some((prod) => prod.toLowerCase().includes(searchLower))
);
});
@@ -178,9 +189,10 @@ export default function MapView() {
>
setSelectedProducer(null)}
- onOrder={(product, dist, mode) =>
- setCheckoutInfo({ product, producer: dist, mode })
+ onOrder={(stock, dist, mode) =>
+ setCheckoutInfo({ stock, producer: dist, mode })
}
/>
@@ -209,9 +221,10 @@ export default function MapView() {
{checkoutInfo && (
setCheckoutInfo(null)}
/>
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index a361d27..2505e0f 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -39,12 +39,20 @@ export const inventoryAPI = {
};
export const ordersAPI = {
- getAll: () => apiCall('/orders'),
+ getAll: (retailerId) => apiCall(`/orders${retailerId ? `?retailer_id=${retailerId}` : ''}`),
getOne: (id) => apiCall(`/orders/${id}`),
create: (data) => apiCall('/orders', { method: 'POST', body: JSON.stringify(data) }),
updateStatus: (id, status) => apiCall(`/orders/${id}`, { method: 'PUT', body: JSON.stringify({ status }) }),
};
+export const b2bAPI = {
+ createOffer: (data) => apiCall('/b2b/offers', { method: 'POST', body: JSON.stringify(data) }),
+ createGroupBuy: (data) => apiCall('/b2b/group-buys', { method: 'POST', body: JSON.stringify(data) }),
+ getOffers: (producerId) => apiCall(`/b2b/offers${producerId ? `?producer_id=${producerId}` : ''}`),
+ updateOffer: (id, status) => apiCall(`/b2b/offers/${id}`, { method: 'PUT', body: JSON.stringify({ status }) }),
+ getGroupBuys: () => apiCall('/b2b/group-buys'),
+};
+
export const usersAPI = {
getAll: (userType = null) => apiCall(`/users${userType ? `?user_type=${userType}` : ''}`),
getOne: (id) => apiCall(`/users/${id}`),