/* ============================================ 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: ` ${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: ` ${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 = `
`; 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 = `

No meals found

Try adjusting your filters or check back later

`; } } 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);