grubflip/js/app.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

237 lines
6.4 KiB
JavaScript

/* ============================================
GrubFlip — Core App Module
API client, auth, utilities, toast system
============================================ */
const GrubFlip = (() => {
'use strict';
// --- Config ---
const API_BASE = '/api';
let authToken = localStorage.getItem('gf_token') || null;
// --- Auth ---
function setToken(token) {
authToken = token;
if (token) {
localStorage.setItem('gf_token', token);
} else {
localStorage.removeItem('gf_token');
}
}
function getToken() {
return authToken;
}
function isLoggedIn() {
return !!authToken;
}
// --- API Client ---
async function api(endpoint, options = {}) {
const url = `${API_BASE}${endpoint}`;
const headers = {
'Accept': 'application/json',
...options.headers,
};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
// Don't set Content-Type for FormData (browser sets boundary)
if (options.body && !(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
if (typeof options.body === 'object') {
options.body = JSON.stringify(options.body);
}
}
try {
const response = await fetch(url, {
...options,
headers,
});
if (response.status === 401) {
setToken(null);
window.location.href = '/grubflip/login.html';
return null;
}
const data = await response.json();
if (!response.ok) {
throw new ApiError(
data.error || data.message || `Request failed (${response.status})`,
response.status,
data
);
}
return data;
} catch (err) {
if (err instanceof ApiError) throw err;
throw new ApiError('Network error — check your connection', 0);
}
}
class ApiError extends Error {
constructor(message, status, data = null) {
super(message);
this.name = 'ApiError';
this.status = status;
this.data = data;
}
}
// Convenience methods
api.get = (endpoint, params = {}) => {
const query = new URLSearchParams(params).toString();
const url = query ? `${endpoint}?${query}` : endpoint;
return api(url, { method: 'GET' });
};
api.post = (endpoint, body) => {
return api(endpoint, { method: 'POST', body });
};
api.put = (endpoint, body) => {
return api(endpoint, { method: 'PUT', body });
};
api.delete = (endpoint) => {
return api(endpoint, { method: 'DELETE' });
};
api.upload = (endpoint, file, fieldName = 'file') => {
const formData = new FormData();
formData.append(fieldName, file);
return api(endpoint, { method: 'POST', body: formData });
};
// --- Toast System ---
let toastContainer = null;
function ensureToastContainer() {
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.className = 'toast-container';
toastContainer.setAttribute('role', 'status');
toastContainer.setAttribute('aria-live', 'polite');
document.body.appendChild(toastContainer);
}
return toastContainer;
}
function toast(message, type = 'default', duration = 3000) {
const container = ensureToastContainer();
const el = document.createElement('div');
el.className = `toast${type !== 'default' ? ` toast--${type}` : ''}`;
el.textContent = message;
container.appendChild(el);
setTimeout(() => {
el.style.opacity = '0';
el.style.transform = 'translateY(-0.5rem)';
el.style.transition = 'all 0.2s';
setTimeout(() => el.remove(), 200);
}, duration);
}
// --- Time Helpers ---
function timeAgo(dateStr) {
const now = Date.now();
const then = new Date(dateStr).getTime();
const diff = Math.max(0, now - then);
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return new Date(dateStr).toLocaleDateString();
}
function timeUntil(dateStr) {
const now = Date.now();
const then = new Date(dateStr).getTime();
const diff = Math.max(0, then - now);
const hours = Math.floor(diff / 3600000);
const minutes = Math.floor((diff % 3600000) / 60000);
if (diff <= 0) return 'expired';
if (hours > 0) return `${hours}h ${minutes}m left`;
return `${minutes}m left`;
}
// --- DOM Helpers ---
function $(selector, parent = document) {
return parent.querySelector(selector);
}
function $$(selector, parent = document) {
return [...parent.querySelectorAll(selector)];
}
function el(tag, attrs = {}, children = []) {
const element = document.createElement(tag);
for (const [key, val] of Object.entries(attrs)) {
if (key === 'className') element.className = val;
else if (key === 'textContent') element.textContent = val;
else if (key === 'innerHTML') element.innerHTML = val;
else if (key.startsWith('on')) element.addEventListener(key.slice(2).toLowerCase(), val);
else if (key === 'dataset') Object.assign(element.dataset, val);
else element.setAttribute(key, val);
}
for (const child of children) {
if (typeof child === 'string') {
element.appendChild(document.createTextNode(child));
} else if (child) {
element.appendChild(child);
}
}
return element;
}
// --- Star rating HTML ---
function starsHTML(rating, count = null) {
const full = Math.floor(rating);
const half = rating % 1 >= 0.5 ? 1 : 0;
const empty = 5 - full - half;
let html = '<span class="stars" aria-label="' + rating + ' out of 5 stars">';
for (let i = 0; i < full; i++) html += '★';
if (half) html += '★'; // simplified — half star shown as full
for (let i = 0; i < empty; i++) html += '☆';
if (count !== null) html += ` <span style="color:var(--gf-neutral-400)">(${count})</span>`;
html += '</span>';
return html;
}
// --- Debounce ---
function debounce(fn, delay = 300) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
// --- Public API ---
return {
api,
ApiError,
setToken,
getToken,
isLoggedIn,
toast,
timeAgo,
timeUntil,
$, $$, el,
starsHTML,
debounce,
};
})();