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>
306 lines
10 KiB
JavaScript
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);
|