/* ============================================ 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 = ''; 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 += ` (${count})`; html += ''; 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, }; })();