grubflip/js/trades.js
Sarah 05dd55e0b6 Add GrubFlip consumer app — feed, trades, chat, post, landing, admin login
Full vanilla HTML/CSS/JS consumer app with mobile-first responsive design.
Pages: feed (index), post meal, trades, messages inbox, chat, landing page,
and admin login skeleton. Uses Ava's design tokens throughout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 05:44:30 +00:00

306 lines
10 KiB
JavaScript

/* ============================================
GrubFlip — Trade Flow
Trade state machine, trade cards, actions
============================================ */
const Trades = (() => {
'use strict';
const { api, $, $$, el, timeAgo, toast } = GrubFlip;
// --- State Machine Definition ---
const TRANSITIONS = {
pending: ['accepted', 'declined', 'cancelled', 'expired'],
accepted: ['confirmed', 'declined', 'cancelled'],
confirmed: ['completed', 'cancelled'],
completed: [], // terminal
declined: [], // terminal
cancelled: [], // terminal
expired: [], // terminal
};
const STATUS_META = {
pending: { label: 'Pending', icon: '⏳', actionable: true },
accepted: { label: 'Accepted', icon: '👍', actionable: true },
confirmed: { label: 'Confirmed', icon: '📍', actionable: true },
completed: { label: 'Completed', icon: '✅', actionable: false },
declined: { label: 'Declined', icon: '❌', actionable: false },
cancelled: { label: 'Cancelled', icon: '🚫', actionable: false },
expired: { label: 'Expired', icon: '⏰', actionable: false },
};
let trades = [];
let activeTab = 'active'; // 'active' | 'completed'
let loading = false;
// --- Trade Card Rendering ---
function renderTradeCard(trade, currentUserId) {
const card = el('div', { className: 'trade-card', dataset: { tradeId: trade.id } });
// Status bar
const statusBar = el('div', { className: 'trade-card__status-bar' });
const meta = STATUS_META[trade.status] || STATUS_META.pending;
statusBar.appendChild(el('span', {
className: `trade-card__status trade-card__status--${trade.status}`,
textContent: `${meta.icon} ${meta.label}`,
}));
statusBar.appendChild(el('span', {
className: 'trade-card__time',
textContent: timeAgo(trade.updated_at || trade.created_at),
}));
card.appendChild(statusBar);
// Meals being traded
const mealsSection = el('div', { className: 'trade-card__meals' });
// Offered meal (what the requester is offering)
const offeredMeal = el('div', { className: 'trade-card__meal' });
offeredMeal.appendChild(el('img', {
className: 'trade-card__meal-img',
src: trade.offered_meal.image_thumb_url,
alt: trade.offered_meal.title,
loading: 'lazy',
}));
const offeredInfo = el('div', { className: 'trade-card__meal-info' });
offeredInfo.appendChild(el('p', { className: 'trade-card__meal-title', textContent: trade.offered_meal.title }));
offeredInfo.appendChild(el('p', { className: 'trade-card__meal-user', textContent: trade.requester.display_name }));
offeredMeal.appendChild(offeredInfo);
mealsSection.appendChild(offeredMeal);
// Swap icon
mealsSection.appendChild(el('span', {
className: 'trade-card__swap-icon',
innerHTML: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="28" height="28"><path d="M7 16l-4-4m0 0l4-4m-4 4h18M17 8l4 4m0 0l-4 4m4-4H3"/></svg>`,
}));
// Requested meal (what the requester wants)
const requestedMeal = el('div', { className: 'trade-card__meal' });
requestedMeal.appendChild(el('img', {
className: 'trade-card__meal-img',
src: trade.requested_meal.image_thumb_url,
alt: trade.requested_meal.title,
loading: 'lazy',
}));
const requestedInfo = el('div', { className: 'trade-card__meal-info' });
requestedInfo.appendChild(el('p', { className: 'trade-card__meal-title', textContent: trade.requested_meal.title }));
requestedInfo.appendChild(el('p', { className: 'trade-card__meal-user', textContent: trade.owner.display_name }));
requestedMeal.appendChild(requestedInfo);
mealsSection.appendChild(requestedMeal);
card.appendChild(mealsSection);
// Message
if (trade.message) {
card.appendChild(el('p', {
className: 'trade-card__message',
textContent: `"${trade.message}"`,
}));
}
// Meetup info (if confirmed)
if (trade.status === 'confirmed' && (trade.meetup_location || trade.meetup_time)) {
const meetup = el('div', {
className: 'trade-card__message',
style: 'font-style:normal; background:var(--gf-secondary-bg);',
});
let meetupText = '📍 Meetup: ';
if (trade.meetup_location) meetupText += trade.meetup_location;
if (trade.meetup_time) meetupText += ` at ${new Date(trade.meetup_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
meetup.textContent = meetupText;
card.appendChild(meetup);
}
// Action buttons — based on status and role
const actions = getActions(trade, currentUserId);
if (actions.length > 0) {
const actionsWrap = el('div', { className: 'trade-card__actions' });
for (const action of actions) {
actionsWrap.appendChild(el('button', {
className: `btn btn--${action.style} btn--sm`,
textContent: action.label,
onClick: () => handleAction(trade.id, action.action),
}));
}
card.appendChild(actionsWrap);
}
return card;
}
// --- Determine Available Actions ---
function getActions(trade, currentUserId) {
const isOwner = trade.owner.id === currentUserId;
const isRequester = trade.requester.id === currentUserId;
const actions = [];
switch (trade.status) {
case 'pending':
if (isOwner) {
actions.push({ label: 'Accept', action: 'accept', style: 'primary' });
actions.push({ label: 'Decline', action: 'decline', style: 'ghost' });
}
if (isRequester) {
actions.push({ label: 'Cancel Request', action: 'cancel', style: 'ghost' });
}
break;
case 'accepted':
actions.push({ label: 'Set Meetup', action: 'set-meetup', style: 'primary' });
actions.push({ label: 'Cancel', action: 'cancel', style: 'ghost' });
break;
case 'confirmed':
actions.push({ label: 'Mark Complete', action: 'complete', style: 'secondary' });
actions.push({ label: 'Cancel', action: 'cancel', style: 'ghost' });
break;
}
return actions;
}
// --- Handle Trade Actions ---
async function handleAction(tradeId, action) {
try {
switch (action) {
case 'accept':
await api.put(`/trades/${tradeId}`, { status: 'accepted' });
toast('Trade accepted! Set up a meetup time.', 'success');
break;
case 'decline':
if (!confirm('Decline this trade request?')) return;
await api.put(`/trades/${tradeId}`, { status: 'declined' });
toast('Trade declined.', 'default');
break;
case 'cancel':
if (!confirm('Cancel this trade?')) return;
await api.put(`/trades/${tradeId}`, { status: 'cancelled' });
toast('Trade cancelled.', 'default');
break;
case 'complete':
await api.put(`/trades/${tradeId}`, { status: 'completed' });
toast('Trade completed! 🎉', 'success');
break;
case 'set-meetup':
openMeetupModal(tradeId);
return; // Don't reload — modal handles it
default:
return;
}
loadTrades(); // Refresh
} catch (err) {
toast('Action failed: ' + err.message, 'error');
}
}
// --- Meetup Modal ---
function openMeetupModal(tradeId) {
const overlay = $('#meetup-modal');
if (!overlay) return;
overlay.classList.add('open');
const form = $('#meetup-form');
form.onsubmit = async (e) => {
e.preventDefault();
const location = $('#meetup-location').value.trim();
const time = $('#meetup-time').value;
if (!location) {
toast('Please enter a meetup location', 'warning');
return;
}
try {
await api.put(`/trades/${tradeId}`, {
status: 'confirmed',
meetup_location: location,
meetup_time: time || null,
});
toast('Meetup confirmed! 📍', 'success');
overlay.classList.remove('open');
loadTrades();
} catch (err) {
toast('Failed to confirm meetup: ' + err.message, 'error');
}
};
// Close on overlay click
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.classList.remove('open');
});
}
// --- Load Trades ---
async function loadTrades() {
if (loading) return;
loading = true;
const list = $('#trades-list');
if (!list) return;
list.innerHTML = '<div class="spinner"></div>';
try {
const data = await api.get('/trades', { status: activeTab === 'active' ? 'active' : 'completed' });
trades = data.trades || [];
list.innerHTML = '';
// TODO: get actual current user ID from auth
const currentUserId = parseInt(localStorage.getItem('gf_user_id') || '0', 10);
if (trades.length === 0) {
list.innerHTML = `
<div class="feed__empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:64px;height:64px;margin:0 auto 1rem;opacity:0.4">
<path d="M7 16l-4-4m0 0l4-4m-4 4h18M17 8l4 4m0 0l-4 4m4-4H3"/>
</svg>
<h3>No ${activeTab} trades</h3>
<p>${activeTab === 'active' ? 'Browse the feed and request a swap!' : 'Your completed trades will show here.'}</p>
</div>`;
return;
}
for (const trade of trades) {
list.appendChild(renderTradeCard(trade, currentUserId));
}
} catch (err) {
toast('Failed to load trades: ' + err.message, 'error');
list.innerHTML = '';
} finally {
loading = false;
}
}
// --- Tab Switching ---
function setupTabs() {
const tabs = $$('.trade-tab');
for (const tab of tabs) {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
activeTab = tab.dataset.tab;
loadTrades();
});
}
}
// --- Init ---
function init() {
const tradesContainer = $('#trades-container');
if (!tradesContainer) return;
setupTabs();
loadTrades();
}
return { init, renderTradeCard, loadTrades };
})();
document.addEventListener('DOMContentLoaded', Trades.init);