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>
237 lines
6.4 KiB
JavaScript
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,
|
|
};
|
|
})();
|