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>
299 lines
11 KiB
JavaScript
299 lines
11 KiB
JavaScript
/* ============================================
|
|
GrubFlip — Meal Detail Modal
|
|
Shows full meal info in a bottom sheet modal.
|
|
Triggered from feed card clicks.
|
|
============================================ */
|
|
|
|
const MealDetail = (() => {
|
|
'use strict';
|
|
|
|
const { api, $, el, timeUntil, timeAgo, starsHTML, toast, isLoggedIn } = GrubFlip;
|
|
|
|
let overlay = null;
|
|
let currentMeal = null;
|
|
|
|
// --- Build Modal DOM (once) ---
|
|
function ensureModal() {
|
|
if (overlay) return overlay;
|
|
|
|
overlay = el('div', {
|
|
className: 'modal-overlay',
|
|
role: 'dialog',
|
|
'aria-modal': 'true',
|
|
'aria-label': 'Meal details',
|
|
});
|
|
|
|
const sheet = el('div', { className: 'modal-sheet meal-detail' });
|
|
|
|
sheet.innerHTML = `
|
|
<div class="modal-sheet__handle"></div>
|
|
<button class="meal-detail__close" aria-label="Close">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
</svg>
|
|
</button>
|
|
<div class="meal-detail__image-wrap">
|
|
<img class="meal-detail__image" src="" alt="">
|
|
<div class="meal-detail__badges"></div>
|
|
</div>
|
|
<div class="meal-detail__content">
|
|
<h2 class="meal-detail__title"></h2>
|
|
<p class="meal-detail__desc"></p>
|
|
<div class="meal-detail__meta"></div>
|
|
<div class="meal-detail__tags"></div>
|
|
<div class="meal-detail__divider"></div>
|
|
<div class="meal-detail__user-section"></div>
|
|
<div class="meal-detail__divider"></div>
|
|
<div class="meal-detail__details-grid"></div>
|
|
<div class="meal-detail__actions"></div>
|
|
</div>
|
|
`;
|
|
|
|
overlay.appendChild(sheet);
|
|
document.body.appendChild(overlay);
|
|
|
|
// Close on overlay click (not sheet)
|
|
overlay.addEventListener('click', (e) => {
|
|
if (e.target === overlay) close();
|
|
});
|
|
|
|
// Close button
|
|
sheet.querySelector('.meal-detail__close').addEventListener('click', close);
|
|
|
|
// Close on Escape
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && overlay.classList.contains('open')) close();
|
|
});
|
|
|
|
return overlay;
|
|
}
|
|
|
|
// --- Populate Modal with Meal Data ---
|
|
function populate(meal) {
|
|
currentMeal = meal;
|
|
const modal = ensureModal();
|
|
const sheet = modal.querySelector('.modal-sheet');
|
|
|
|
// Image
|
|
const img = sheet.querySelector('.meal-detail__image');
|
|
img.src = meal.image_url || meal.image_thumb_url;
|
|
img.alt = meal.title;
|
|
|
|
// Badges (trade type + expiry)
|
|
const badges = sheet.querySelector('.meal-detail__badges');
|
|
badges.innerHTML = '';
|
|
|
|
const tradeLabel = meal.trade_type === 'swap' ? 'Swap'
|
|
: meal.trade_type === 'free' ? 'Free'
|
|
: meal.trade_type;
|
|
|
|
const tradeClass = meal.trade_type === 'free' ? 'meal-detail__badge--free' : '';
|
|
badges.innerHTML += `<span class="meal-detail__badge ${tradeClass}">${tradeLabel}</span>`;
|
|
|
|
if (meal.expires_at) {
|
|
const remaining = timeUntil(meal.expires_at);
|
|
if (remaining !== 'expired') {
|
|
badges.innerHTML += `<span class="meal-detail__badge meal-detail__badge--expiry">
|
|
<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}
|
|
</span>`;
|
|
} else {
|
|
badges.innerHTML += `<span class="meal-detail__badge meal-detail__badge--expired">Expired</span>`;
|
|
}
|
|
}
|
|
|
|
// Title
|
|
sheet.querySelector('.meal-detail__title').textContent = meal.title;
|
|
|
|
// Description (full, no truncation)
|
|
sheet.querySelector('.meal-detail__desc').textContent = meal.description;
|
|
|
|
// Meta line (posted time + servings)
|
|
const meta = sheet.querySelector('.meal-detail__meta');
|
|
const metaParts = [];
|
|
if (meal.created_at) metaParts.push(`Posted ${timeAgo(meal.created_at)}`);
|
|
if (meal.servings) metaParts.push(`${meal.servings} serving${meal.servings > 1 ? 's' : ''}`);
|
|
if (meal.distance_km != null) metaParts.push(`${meal.distance_km} km away`);
|
|
meta.innerHTML = metaParts.map(p =>
|
|
`<span class="meal-detail__meta-item">${p}</span>`
|
|
).join('');
|
|
|
|
// Tags (all of them — no limit)
|
|
const tagsWrap = sheet.querySelector('.meal-detail__tags');
|
|
tagsWrap.innerHTML = '';
|
|
if (meal.cuisine) {
|
|
tagsWrap.innerHTML += `<span class="meal-detail__tag meal-detail__tag--cuisine">${meal.cuisine}</span>`;
|
|
}
|
|
for (const tag of (meal.dietary_tags || [])) {
|
|
tagsWrap.innerHTML += `<span class="meal-detail__tag">${tag}</span>`;
|
|
}
|
|
|
|
// User section
|
|
const userSection = sheet.querySelector('.meal-detail__user-section');
|
|
userSection.innerHTML = `
|
|
<div class="meal-detail__user">
|
|
<img class="meal-detail__user-avatar"
|
|
src="${meal.user.avatar_url}"
|
|
alt="${meal.user.display_name}"
|
|
loading="lazy">
|
|
<div class="meal-detail__user-info">
|
|
<span class="meal-detail__user-name">${meal.user.display_name}</span>
|
|
<span class="meal-detail__user-stats">
|
|
${starsHTML(meal.user.rating)} · ${meal.user.trade_count} trade${meal.user.trade_count !== 1 ? 's' : ''}
|
|
</span>
|
|
</div>
|
|
<button class="btn btn--ghost btn--sm meal-detail__message-btn" aria-label="Message ${meal.user.display_name}">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
|
|
Message
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
// Message button handler
|
|
const msgBtn = userSection.querySelector('.meal-detail__message-btn');
|
|
msgBtn.addEventListener('click', () => {
|
|
if (!isLoggedIn()) {
|
|
toast('Sign in to message this person', 'warning');
|
|
return;
|
|
}
|
|
// Navigate to chat with context
|
|
window.location.href = `chat.html?user=${meal.user.id}&meal=${meal.id}`;
|
|
});
|
|
|
|
// Details grid (pickup info, preferences, etc.)
|
|
const detailsGrid = sheet.querySelector('.meal-detail__details-grid');
|
|
detailsGrid.innerHTML = '';
|
|
|
|
if (meal.pickup_location || meal.pickup_notes) {
|
|
detailsGrid.innerHTML += `
|
|
<div class="meal-detail__detail-item">
|
|
<svg width="16" height="16" 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>
|
|
<div>
|
|
<span class="meal-detail__detail-label">Pickup</span>
|
|
<span class="meal-detail__detail-value">${meal.pickup_location || meal.pickup_notes || 'Contact for details'}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (meal.looking_for) {
|
|
detailsGrid.innerHTML += `
|
|
<div class="meal-detail__detail-item">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 16l-4-4m0 0l4-4m-4 4h18M17 8l4 4m0 0l-4 4m4-4H3"/></svg>
|
|
<div>
|
|
<span class="meal-detail__detail-label">Looking for</span>
|
|
<span class="meal-detail__detail-value">${meal.looking_for}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (meal.allergen_info) {
|
|
detailsGrid.innerHTML += `
|
|
<div class="meal-detail__detail-item meal-detail__detail-item--warning">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
|
<div>
|
|
<span class="meal-detail__detail-label">Allergen info</span>
|
|
<span class="meal-detail__detail-value">${meal.allergen_info}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Actions
|
|
const actions = sheet.querySelector('.meal-detail__actions');
|
|
const isExpired = meal.expires_at && timeUntil(meal.expires_at) === 'expired';
|
|
const isOwnMeal = parseInt(localStorage.getItem('gf_user_id') || '0', 10) === meal.user.id;
|
|
|
|
actions.innerHTML = '';
|
|
|
|
if (isOwnMeal) {
|
|
actions.innerHTML = `
|
|
<button class="btn btn--outline btn--block" data-action="edit">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
|
Edit Meal
|
|
</button>
|
|
<button class="btn btn--ghost btn--block" data-action="delete" style="color:var(--gf-error)">
|
|
Remove Listing
|
|
</button>
|
|
`;
|
|
} else if (isExpired) {
|
|
actions.innerHTML = `
|
|
<div class="meal-detail__expired-notice">
|
|
<svg width="20" height="20" 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>
|
|
This meal listing has expired
|
|
</div>
|
|
`;
|
|
} else {
|
|
actions.innerHTML = `
|
|
<button class="btn btn--primary btn--lg btn--block" data-action="propose">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 16l-4-4m0 0l4-4m-4 4h18M17 8l4 4m0 0l-4 4m4-4H3"/></svg>
|
|
Propose a Trade
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
// Action handlers
|
|
const proposeBtn = actions.querySelector('[data-action="propose"]');
|
|
if (proposeBtn) {
|
|
proposeBtn.addEventListener('click', () => {
|
|
if (!isLoggedIn()) {
|
|
toast('Sign in to propose a trade', 'warning');
|
|
return;
|
|
}
|
|
close();
|
|
window.location.href = `trades.html?propose=${meal.id}`;
|
|
});
|
|
}
|
|
|
|
const editBtn = actions.querySelector('[data-action="edit"]');
|
|
if (editBtn) {
|
|
editBtn.addEventListener('click', () => {
|
|
close();
|
|
window.location.href = `post.html?edit=${meal.id}`;
|
|
});
|
|
}
|
|
|
|
const deleteBtn = actions.querySelector('[data-action="delete"]');
|
|
if (deleteBtn) {
|
|
deleteBtn.addEventListener('click', async () => {
|
|
if (!confirm('Remove this meal listing?')) return;
|
|
try {
|
|
await api.delete(`/meals/${meal.id}`);
|
|
toast('Meal removed', 'success');
|
|
close();
|
|
// Refresh feed
|
|
if (typeof Feed !== 'undefined' && Feed.loadMeals) {
|
|
Feed.loadMeals(true);
|
|
}
|
|
} catch (err) {
|
|
toast(err.message, 'error');
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// --- Open / Close ---
|
|
function open(meal) {
|
|
populate(meal);
|
|
const modal = ensureModal();
|
|
|
|
// Prevent body scroll
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
// Small delay for transition
|
|
requestAnimationFrame(() => {
|
|
modal.classList.add('open');
|
|
});
|
|
}
|
|
|
|
function close() {
|
|
if (!overlay) return;
|
|
overlay.classList.remove('open');
|
|
document.body.style.overflow = '';
|
|
currentMeal = null;
|
|
}
|
|
|
|
// --- Public API ---
|
|
return { open, close };
|
|
})();
|