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:
parent
70e0ac1c7b
commit
358f35c6d0
4 changed files with 1894 additions and 462 deletions
1253
css/styles.css
Normal file
1253
css/styles.css
Normal file
File diff suppressed because it is too large
Load diff
501
index.html
501
index.html
|
|
@ -3,471 +3,56 @@
|
|||
<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">
|
||||
<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=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>
|
||||
<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>
|
||||
<!-- 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>
|
||||
<a class="nav-cta" href="#signup">Get Early Access</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Feed -->
|
||||
<main class="feed">
|
||||
<div class="container">
|
||||
<div id="feed-container"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<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 →</a>
|
||||
</section>
|
||||
|
||||
<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">📲</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">🤝</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">🍕</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>© 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>
|
||||
|
||||
<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
295
js/feed.js
Normal 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
299
js/meal-detail.js
Normal 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)} · ${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 };
|
||||
})();
|
||||
Loading…
Add table
Reference in a new issue