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>
This commit is contained in:
Sarah 2026-03-27 05:44:12 +00:00
parent 70e0ac1c7b
commit 358f35c6d0
4 changed files with 1894 additions and 462 deletions

1253
css/styles.css Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,473 +1,58 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Grubflip — Swap Employee Discounts with Nearby Restaurant Workers</title>
<meta name="description" content="Grubflip lets restaurant employees share their employee food discounts with each other. Stop eating where you work — flip your grub!">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="192x192" href="/android-chrome-192x192.png">
<link rel="icon" type="image/png" sizes="512x512" href="/android-chrome-512x512.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--orange: #FF6B35;
--orange-dark: #E55A2B;
--dark: #1A1A2E;
--darker: #0F0F1A;
--gray: #8892A0;
--light: #F5F5F7;
--white: #FFFFFF;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--darker);
color: var(--light);
line-height: 1.6;
overflow-x: hidden;
}
/* Nav */
nav {
position: fixed;
top: 0;
width: 100%;
padding: 1.25rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 100;
background: rgba(15, 15, 26, 0.85);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255, 107, 53, 0.1);
}
.nav-logo {
display: flex;
align-items: center;
gap: 0.75rem;
text-decoration: none;
}
.nav-logo img {
width: 40px;
height: 40px;
border-radius: 10px;
}
.nav-logo span {
font-size: 1.5rem;
font-weight: 800;
color: var(--white);
letter-spacing: -0.5px;
}
.nav-cta {
background: var(--orange);
color: var(--white);
padding: 0.6rem 1.5rem;
border-radius: 50px;
text-decoration: none;
font-weight: 600;
font-size: 0.9rem;
transition: background 0.3s, transform 0.2s;
}
.nav-cta:hover {
background: var(--orange-dark);
transform: translateY(-1px);
}
/* Hero */
.hero {
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding: 8rem 2rem 4rem;
position: relative;
}
.hero::before {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(255, 107, 53, 0.15) 0%, transparent 70%);
pointer-events: none;
}
.hero-badge {
display: inline-block;
background: rgba(255, 107, 53, 0.15);
color: var(--orange);
padding: 0.5rem 1.25rem;
border-radius: 50px;
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 2rem;
border: 1px solid rgba(255, 107, 53, 0.25);
}
.hero h1 {
font-size: clamp(2.5rem, 6vw, 4.5rem);
font-weight: 800;
line-height: 1.1;
max-width: 800px;
margin-bottom: 1.5rem;
letter-spacing: -1.5px;
}
.hero h1 .highlight {
color: var(--orange);
}
.hero p {
font-size: 1.2rem;
color: var(--gray);
max-width: 550px;
margin-bottom: 2.5rem;
}
.hero-cta {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: var(--orange);
color: var(--white);
padding: 1rem 2.5rem;
border-radius: 50px;
text-decoration: none;
font-weight: 700;
font-size: 1.1rem;
transition: background 0.3s, transform 0.2s, box-shadow 0.3s;
box-shadow: 0 4px 20px rgba(255, 107, 53, 0.3);
}
.hero-cta:hover {
background: var(--orange-dark);
transform: translateY(-2px);
box-shadow: 0 6px 30px rgba(255, 107, 53, 0.4);
}
/* How It Works */
.how-it-works {
padding: 6rem 2rem;
max-width: 1100px;
margin: 0 auto;
}
.section-title {
text-align: center;
font-size: 2.25rem;
font-weight: 800;
margin-bottom: 1rem;
letter-spacing: -0.5px;
}
.section-subtitle {
text-align: center;
color: var(--gray);
font-size: 1.1rem;
margin-bottom: 4rem;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.steps {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 2rem;
}
.step {
background: rgba(26, 26, 46, 0.6);
border: 1px solid rgba(255, 107, 53, 0.1);
border-radius: 20px;
padding: 2.5rem 2rem;
text-align: center;
transition: border-color 0.3s, transform 0.3s;
}
.step:hover {
border-color: rgba(255, 107, 53, 0.3);
transform: translateY(-4px);
}
.step-icon {
font-size: 2.5rem;
margin-bottom: 1.25rem;
}
.step h3 {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 0.75rem;
}
.step p {
color: var(--gray);
font-size: 0.95rem;
}
/* Benefits */
.benefits {
padding: 4rem 2rem 6rem;
max-width: 900px;
margin: 0 auto;
text-align: center;
}
.benefit-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.5rem;
margin-top: 3rem;
}
.benefit {
padding: 2rem 1.5rem;
background: rgba(26, 26, 46, 0.4);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.benefit h4 {
font-size: 1.1rem;
font-weight: 700;
margin-bottom: 0.5rem;
color: var(--orange);
}
.benefit p {
font-size: 0.9rem;
color: var(--gray);
}
/* CTA Section */
.cta-section {
padding: 6rem 2rem;
text-align: center;
position: relative;
}
.cta-section::before {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 500px;
height: 400px;
background: radial-gradient(circle, rgba(255, 107, 53, 0.1) 0%, transparent 70%);
pointer-events: none;
}
.cta-section h2 {
font-size: 2.5rem;
font-weight: 800;
margin-bottom: 1rem;
letter-spacing: -0.5px;
}
.cta-section p {
color: var(--gray);
font-size: 1.1rem;
margin-bottom: 2rem;
max-width: 500px;
margin-left: auto;
margin-right: auto;
}
/* Email form */
.signup-form {
display: flex;
gap: 0.75rem;
justify-content: center;
flex-wrap: wrap;
max-width: 500px;
margin: 0 auto;
}
.signup-form input[type="email"] {
flex: 1;
min-width: 250px;
padding: 0.9rem 1.25rem;
border-radius: 50px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(26, 26, 46, 0.8);
color: var(--white);
font-size: 1rem;
font-family: 'Inter', sans-serif;
outline: none;
transition: border-color 0.3s;
}
.signup-form input[type="email"]::placeholder {
color: var(--gray);
}
.signup-form input[type="email"]:focus {
border-color: var(--orange);
}
.signup-form button {
background: var(--orange);
color: var(--white);
border: none;
padding: 0.9rem 2rem;
border-radius: 50px;
font-weight: 700;
font-size: 1rem;
cursor: pointer;
font-family: 'Inter', sans-serif;
transition: background 0.3s, transform 0.2s;
}
.signup-form button:hover {
background: var(--orange-dark);
transform: translateY(-1px);
}
.form-note {
color: var(--gray);
font-size: 0.8rem;
margin-top: 1rem;
}
.form-success {
display: none;
color: var(--orange);
font-weight: 600;
font-size: 1.1rem;
margin-top: 1rem;
}
/* Footer */
footer {
padding: 3rem 2rem;
text-align: center;
border-top: 1px solid rgba(255, 255, 255, 0.05);
color: var(--gray);
font-size: 0.85rem;
}
footer a {
color: var(--orange);
text-decoration: none;
}
/* Mobile */
@media (max-width: 600px) {
nav { padding: 1rem 1.25rem; }
.nav-logo span { font-size: 1.25rem; }
.hero { padding: 7rem 1.5rem 3rem; }
.hero p { font-size: 1.05rem; }
.how-it-works, .benefits, .cta-section { padding-left: 1.25rem; padding-right: 1.25rem; }
.signup-form input[type="email"] { min-width: 100%; }
}
</style>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GrubFlip — Swap Meals Near You</title>
<meta name="description" content="Trade homemade meals with people nearby. Share food, discover new flavors.">
<meta name="theme-color" content="#FF6B35">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@600;700;800&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/styles.css">
</head>
<body>
<nav>
<a class="nav-logo" href="/">
<img src="/logo.png" alt="Grubflip">
<span>Grubflip</span>
</a>
<a class="nav-cta" href="#signup">Get Early Access</a>
</nav>
<!-- Header -->
<header class="gf-header">
<div class="gf-header__logo">Grub<span>Flip</span></div>
<div class="gf-header__actions">
<a href="post.html" class="btn btn--primary btn--sm" aria-label="Post a meal">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Post
</a>
</div>
</header>
<section class="hero">
<span class="hero-badge">Coming Soon</span>
<h1>Stop eating where you <span class="highlight">work.</span></h1>
<p>Grubflip lets restaurant employees swap their food discounts with workers at nearby spots. New food, same savings.</p>
<a class="hero-cta" href="#signup">Join the Waitlist &rarr;</a>
</section>
<!-- Main Feed -->
<main class="feed">
<div class="container">
<div id="feed-container"></div>
</div>
</main>
<section class="how-it-works">
<h2 class="section-title">How It Works</h2>
<p class="section-subtitle">Three steps to start flipping your grub.</p>
<div class="steps">
<div class="step">
<div class="step-icon">&#x1F4F2;</div>
<h3>Sign Up</h3>
<p>Create your profile and verify where you work. It takes 30 seconds.</p>
</div>
<div class="step">
<div class="step-icon">&#x1F91D;</div>
<h3>Connect</h3>
<p>Browse nearby restaurant workers who want to swap discounts with you.</p>
</div>
<div class="step">
<div class="step-icon">&#x1F355;</div>
<h3>Flip Your Grub</h3>
<p>Use their discount, they use yours. Everybody eats something different for less.</p>
</div>
</div>
</section>
<section class="benefits">
<h2 class="section-title">Why Grubflip?</h2>
<p class="section-subtitle">Built by restaurant people, for restaurant people.</p>
<div class="benefit-grid">
<div class="benefit">
<h4>Save Money</h4>
<p>Employee discounts at places you actually want to eat. No more paying full price.</p>
</div>
<div class="benefit">
<h4>New Food Daily</h4>
<p>Tired of eating the same menu every shift? Try something new from down the block.</p>
</div>
<div class="benefit">
<h4>Meet Your Neighbors</h4>
<p>Connect with other restaurant workers in your area. Build your local food network.</p>
</div>
<div class="benefit">
<h4>100% Free</h4>
<p>Grubflip is free for restaurant employees. No fees, no catch.</p>
</div>
</div>
</section>
<section class="cta-section" id="signup">
<h2>Ready to Flip?</h2>
<p>Sign up for early access and be first in line when we launch in your area.</p>
<form class="signup-form" id="signupForm">
<input type="email" placeholder="Enter your email" required>
<button type="submit">Join Waitlist</button>
</form>
<p class="form-note">No spam. Just a heads-up when we launch.</p>
<p class="form-success" id="formSuccess">You're on the list! We'll be in touch soon.</p>
</section>
<footer>
<p>&copy; 2026 Grubflip. A <a href="https://www.payfrit.com">Payfrit</a> product. All rights reserved.</p>
</footer>
<script>
document.getElementById('signupForm').addEventListener('submit', function(e) {
e.preventDefault();
var email = this.querySelector('input[type="email"]').value;
// Store locally for now — can hook up to API later
console.log('Waitlist signup:', email);
this.style.display = 'none';
document.querySelector('.form-note').style.display = 'none';
document.getElementById('formSuccess').style.display = 'block';
});
</script>
<!-- Bottom Nav -->
<nav class="gf-bottom-nav" aria-label="Main navigation">
<a href="index.html" class="gf-bottom-nav__item active" aria-current="page">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
Feed
</a>
<a href="post.html" class="gf-bottom-nav__item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
Post
</a>
<a href="trades.html" class="gf-bottom-nav__item" style="position:relative">
<svg 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>
Trades
</a>
<button class="gf-bottom-nav__item" aria-label="Profile">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Profile
</button>
</nav>
<script src="js/app.js"></script>
<script src="js/meal-detail.js"></script>
<script src="js/feed.js"></script>
</body>
</html>

295
js/feed.js Normal file
View file

@ -0,0 +1,295 @@
/* ============================================
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);

299
js/meal-detail.js Normal file
View file

@ -0,0 +1,299 @@
/* ============================================
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)} &middot; ${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 };
})();