grubflip/js/feed.js
Sarah 358f35c6d0 Add meal detail modal — tap any meal card to see full info
New bottom-sheet modal opens when users tap a meal card in the feed.
Shows full image, description, dietary tags, user profile with rating,
pickup/trade details, and a "Propose a Trade" CTA. Handles own-meal
edit/delete, expired states, and message button. Mobile-first with
desktop centered dialog variant. Resolves the TODO in feed.js:120.

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

295 lines
9.2 KiB
JavaScript

/* ============================================
GrubFlip — Feed & Meal Cards
Renders the meal feed with filtering,
pagination, and card interactions.
============================================ */
const Feed = (() => {
'use strict';
const { api, $, $$, el, timeUntil, timeAgo, starsHTML, debounce, toast } = GrubFlip;
// --- State ---
let meals = [];
let pagination = { page: 1, per_page: 20, total: 0, has_more: false };
let loading = false;
let filters = {
dietary: [],
cuisine: '',
trade_type: '',
sort: 'newest',
};
const DIETARY_OPTIONS = [
'vegan', 'vegetarian', 'gluten-free', 'dairy', 'dairy-free',
'nut-free', 'halal', 'kosher', 'high-protein', 'low-carb', 'spicy'
];
// --- Meal Card Rendering ---
function renderMealCard(meal) {
const card = el('article', { className: 'meal-card', dataset: { mealId: meal.id } });
// Image
const imageWrap = el('div', { className: 'meal-card__image-wrap' });
const img = el('img', {
className: 'meal-card__image',
src: meal.image_thumb_url || meal.image_url,
alt: meal.title,
loading: 'lazy',
});
imageWrap.appendChild(img);
// Trade type badge
const tradeLabel = meal.trade_type === 'swap' ? 'Swap' : meal.trade_type;
imageWrap.appendChild(el('span', {
className: 'meal-card__trade-type',
textContent: tradeLabel,
}));
// Expiry countdown
if (meal.expires_at) {
const remaining = timeUntil(meal.expires_at);
if (remaining !== 'expired') {
imageWrap.appendChild(el('span', {
className: 'meal-card__expiry',
innerHTML: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg> ${remaining}`,
}));
}
}
card.appendChild(imageWrap);
// Body
const body = el('div', { className: 'meal-card__body' });
body.appendChild(el('h3', { className: 'meal-card__title', textContent: meal.title }));
body.appendChild(el('p', { className: 'meal-card__desc', textContent: meal.description }));
// Tags
const tagsWrap = el('div', { className: 'meal-card__tags' });
if (meal.cuisine) {
tagsWrap.appendChild(el('span', {
className: 'meal-card__tag meal-card__tag--cuisine',
textContent: meal.cuisine,
}));
}
for (const tag of (meal.dietary_tags || []).slice(0, 3)) {
tagsWrap.appendChild(el('span', {
className: 'meal-card__tag',
textContent: tag,
}));
}
body.appendChild(tagsWrap);
// Footer — user + distance
const footer = el('div', { className: 'meal-card__footer' });
const userSection = el('div', { className: 'meal-card__user' });
userSection.appendChild(el('img', {
className: 'meal-card__avatar',
src: meal.user.avatar_url,
alt: meal.user.display_name,
loading: 'lazy',
}));
const userInfo = el('div', { className: 'meal-card__user-info' });
userInfo.appendChild(el('span', {
className: 'meal-card__user-name',
textContent: meal.user.display_name,
}));
const metaHTML = `${starsHTML(meal.user.rating)} · ${meal.user.trade_count} trades`;
userInfo.appendChild(el('span', {
className: 'meal-card__user-meta',
innerHTML: metaHTML,
}));
userSection.appendChild(userInfo);
footer.appendChild(userSection);
if (meal.distance_km != null) {
footer.appendChild(el('span', {
className: 'meal-card__distance',
innerHTML: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg> ${meal.distance_km} km`,
}));
}
body.appendChild(footer);
card.appendChild(body);
// Click handler — open detail modal
card.addEventListener('click', () => {
if (typeof MealDetail !== 'undefined') {
MealDetail.open(meal);
} else {
toast(`Opening ${meal.title}...`);
}
});
return card;
}
// --- Skeleton Card ---
function renderSkeleton() {
const card = el('div', { className: 'meal-card' });
card.innerHTML = `
<div class="meal-card__image-wrap skeleton" style="aspect-ratio:4/3"></div>
<div class="meal-card__body">
<div class="skeleton" style="height:1rem;width:70%;margin-bottom:0.5rem"></div>
<div class="skeleton" style="height:0.75rem;width:90%;margin-bottom:0.75rem"></div>
<div style="display:flex;gap:0.375rem;margin-bottom:0.75rem">
<div class="skeleton" style="height:1.25rem;width:4rem;border-radius:9999px"></div>
<div class="skeleton" style="height:1.25rem;width:5rem;border-radius:9999px"></div>
</div>
<div style="display:flex;align-items:center;gap:0.5rem;padding-top:0.75rem;border-top:1px solid var(--gf-neutral-100)">
<div class="skeleton" style="width:32px;height:32px;border-radius:50%"></div>
<div class="skeleton" style="height:0.75rem;width:5rem"></div>
</div>
</div>`;
return card;
}
// --- Filter Bar ---
function renderFilterBar(container) {
const bar = el('div', { className: 'filter-bar', role: 'toolbar', 'aria-label': 'Filter meals' });
// Sort chip
const sortChip = el('button', {
className: 'filter-chip active',
textContent: '✨ Newest',
onClick: () => {
filters.sort = filters.sort === 'newest' ? 'nearest' : 'newest';
sortChip.textContent = filters.sort === 'newest' ? '✨ Newest' : '📍 Nearest';
loadMeals(true);
},
});
bar.appendChild(sortChip);
// Dietary filters
for (const tag of DIETARY_OPTIONS) {
const chip = el('button', {
className: 'filter-chip',
textContent: tag,
onClick: () => {
const idx = filters.dietary.indexOf(tag);
if (idx >= 0) {
filters.dietary.splice(idx, 1);
chip.classList.remove('active');
} else {
filters.dietary.push(tag);
chip.classList.add('active');
}
loadMeals(true);
},
});
bar.appendChild(chip);
}
container.appendChild(bar);
}
// --- Load Meals ---
async function loadMeals(reset = false) {
if (loading) return;
loading = true;
const listEl = $('#feed-list');
if (!listEl) return;
if (reset) {
pagination.page = 1;
meals = [];
listEl.innerHTML = '';
// Show skeletons
for (let i = 0; i < 6; i++) {
listEl.appendChild(renderSkeleton());
}
}
try {
const params = {
page: pagination.page,
sort: filters.sort,
};
if (filters.dietary.length) params.dietary = filters.dietary.join(',');
if (filters.cuisine) params.cuisine = filters.cuisine;
if (filters.trade_type) params.trade_type = filters.trade_type;
const data = await api.get('/meals', params);
if (reset) listEl.innerHTML = '';
meals = reset ? data.meals : [...meals, ...data.meals];
pagination = data.pagination;
for (const meal of data.meals) {
listEl.appendChild(renderMealCard(meal));
}
// Load more button
const existingMore = $('#load-more-btn');
if (existingMore) existingMore.remove();
if (pagination.has_more) {
const moreBtn = el('button', {
className: 'btn btn--outline btn--block',
id: 'load-more-btn',
textContent: 'Load more meals',
onClick: () => {
pagination.page++;
loadMeals(false);
},
});
listEl.parentElement.appendChild(moreBtn);
}
// Empty state
if (meals.length === 0) {
listEl.innerHTML = `
<div class="feed__empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg>
<h3>No meals found</h3>
<p>Try adjusting your filters or check back later</p>
</div>`;
}
} catch (err) {
toast(err.message, 'error');
if (reset) listEl.innerHTML = '';
} finally {
loading = false;
}
}
// --- Infinite Scroll ---
function setupInfiniteScroll() {
const scrollHandler = debounce(() => {
if (loading || !pagination.has_more) return;
const scrollBottom = window.innerHeight + window.scrollY;
const pageBottom = document.documentElement.offsetHeight - 200;
if (scrollBottom >= pageBottom) {
pagination.page++;
loadMeals(false);
}
}, 100);
window.addEventListener('scroll', scrollHandler, { passive: true });
}
// --- Init ---
function init() {
const feedContainer = $('#feed-container');
if (!feedContainer) return;
renderFilterBar(feedContainer);
const list = el('div', { className: 'feed__list', id: 'feed-list', role: 'feed', 'aria-label': 'Meal listings' });
feedContainer.appendChild(list);
loadMeals(true);
setupInfiniteScroll();
}
return { init, loadMeals, renderMealCard };
})();
document.addEventListener('DOMContentLoaded', Feed.init);