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>
295 lines
9.2 KiB
JavaScript
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);
|