grubflip/js/post.js
Sarah 05dd55e0b6 Add GrubFlip consumer app — feed, trades, chat, post, landing, admin login
Full vanilla HTML/CSS/JS consumer app with mobile-first responsive design.
Pages: feed (index), post meal, trades, messages inbox, chat, landing page,
and admin login skeleton. Uses Ava's design tokens throughout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 05:44:30 +00:00

249 lines
7.3 KiB
JavaScript

/* ============================================
GrubFlip — Post Meal Form
Image upload, dietary tag picker, meal
creation with preview.
============================================ */
const PostMeal = (() => {
'use strict';
const { api, $, $$, el, toast } = GrubFlip;
let uploadedImage = null; // { url, thumb_url, upload_id }
let submitting = false;
const DIETARY_TAGS = [
'vegan', 'vegetarian', 'gluten-free', 'dairy', 'dairy-free',
'nut-free', 'halal', 'kosher', 'high-protein', 'low-carb', 'spicy'
];
const CUISINES = [
'Italian', 'Mexican', 'Chinese', 'Japanese', 'Indian', 'Thai',
'Mediterranean', 'American', 'Korean', 'Vietnamese', 'French',
'Middle Eastern', 'Caribbean', 'Ethiopian', 'Other'
];
// --- Image Upload ---
function setupImageUpload() {
const zone = $('#upload-zone');
const fileInput = $('#image-input');
if (!zone || !fileInput) return;
// Click to select
zone.addEventListener('click', (e) => {
if (e.target.closest('.btn--remove')) return;
fileInput.click();
});
// File selected
fileInput.addEventListener('change', () => {
if (fileInput.files.length) handleFile(fileInput.files[0]);
});
// Drag and drop
zone.addEventListener('dragover', (e) => {
e.preventDefault();
zone.classList.add('dragover');
});
zone.addEventListener('dragleave', () => {
zone.classList.remove('dragover');
});
zone.addEventListener('drop', (e) => {
e.preventDefault();
zone.classList.remove('dragover');
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
});
// Remove button
const removeBtn = zone.querySelector('.btn--remove');
if (removeBtn) {
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
clearUpload();
});
}
}
async function handleFile(file) {
// Validate
const validTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!validTypes.includes(file.type)) {
toast('Please upload a JPG, PNG, or WebP image', 'error');
return;
}
if (file.size > 20 * 1024 * 1024) {
toast('Image must be under 20MB', 'error');
return;
}
const zone = $('#upload-zone');
const previewImg = $('#upload-preview-img');
// Show local preview immediately
const reader = new FileReader();
reader.onload = (e) => {
previewImg.src = e.target.result;
zone.classList.add('has-preview');
};
reader.readAsDataURL(file);
// Upload to server
try {
zone.style.opacity = '0.7';
const data = await api.upload('/uploads/image', file);
uploadedImage = {
url: data.url,
thumb_url: data.thumb_url,
upload_id: data.upload_id,
};
toast('Image uploaded!', 'success');
} catch (err) {
toast('Upload failed — ' + err.message, 'error');
clearUpload();
} finally {
zone.style.opacity = '';
}
}
function clearUpload() {
uploadedImage = null;
const zone = $('#upload-zone');
const fileInput = $('#image-input');
const previewImg = $('#upload-preview-img');
if (zone) zone.classList.remove('has-preview');
if (fileInput) fileInput.value = '';
if (previewImg) previewImg.src = '';
}
// --- Expiry Picker ---
function setupExpiryPicker() {
const select = $('#expires-select');
if (!select) return;
// Populate with reasonable time windows
const options = [
{ value: '2', label: '2 hours' },
{ value: '4', label: '4 hours' },
{ value: '6', label: '6 hours' },
{ value: '8', label: '8 hours' },
{ value: '12', label: '12 hours' },
{ value: '24', label: '24 hours' },
];
select.innerHTML = '<option value="">Pick up within...</option>';
for (const opt of options) {
select.appendChild(el('option', { value: opt.value, textContent: opt.label }));
}
}
// --- Form Validation ---
function validate() {
const errors = [];
const title = $('#meal-title')?.value.trim();
const description = $('#meal-description')?.value.trim();
const expiresHours = $('#expires-select')?.value;
if (!title || title.length < 3) errors.push({ field: 'meal-title', msg: 'Title must be at least 3 characters' });
if (title && title.length > 100) errors.push({ field: 'meal-title', msg: 'Title is too long (max 100 chars)' });
if (!description) errors.push({ field: 'meal-description', msg: 'Add a short description' });
if (!expiresHours) errors.push({ field: 'expires-select', msg: 'Set a pickup window' });
if (!uploadedImage) errors.push({ field: 'upload-zone', msg: 'Add a photo of your meal' });
// Clear previous errors
$$('.form-error').forEach(el => el.remove());
$$('.form-input, .form-textarea, .form-select').forEach(el => el.style.borderColor = '');
// Show errors
for (const error of errors) {
const fieldEl = $(`#${error.field}`);
if (fieldEl) {
fieldEl.style.borderColor = 'var(--gf-error)';
const errEl = document.createElement('p');
errEl.className = 'form-error';
errEl.textContent = error.msg;
fieldEl.parentElement.appendChild(errEl);
}
}
return errors.length === 0;
}
// --- Submit ---
async function handleSubmit(e) {
e.preventDefault();
if (submitting) return;
if (!validate()) return;
submitting = true;
const submitBtn = $('#submit-btn');
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.textContent = 'Posting...';
}
// Gather selected dietary tags
const selectedTags = $$('.tag-picker__option:checked').map(cb => cb.value);
const expiresHours = parseInt($('#expires-select').value, 10);
const expiresAt = new Date(Date.now() + expiresHours * 3600000).toISOString();
const payload = {
title: $('#meal-title').value.trim(),
description: $('#meal-description').value.trim(),
upload_id: uploadedImage.upload_id,
dietary_tags: selectedTags,
cuisine: $('#cuisine-select').value || null,
portion_size: $('#portion-select').value || 'single',
trade_type: $('#trade-type-select')?.value || 'swap',
expires_at: expiresAt,
};
try {
const result = await api.post('/meals', payload);
toast('Meal posted! 🎉', 'success');
// Redirect to feed after short delay
setTimeout(() => {
window.location.href = '/grubflip/index.html';
}, 1000);
} catch (err) {
toast('Failed to post — ' + err.message, 'error');
} finally {
submitting = false;
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = 'Post Meal';
}
}
}
// --- Init ---
function init() {
const form = $('#post-meal-form');
if (!form) return;
setupImageUpload();
setupExpiryPicker();
form.addEventListener('submit', handleSubmit);
// Character counter for description
const desc = $('#meal-description');
const counter = $('#desc-counter');
if (desc && counter) {
desc.addEventListener('input', () => {
const len = desc.value.length;
counter.textContent = `${len}/300`;
counter.style.color = len > 300 ? 'var(--gf-error)' : 'var(--gf-neutral-400)';
});
}
}
return { init };
})();
document.addEventListener('DOMContentLoaded', PostMeal.init);