// SATMELI — capa de datos real. Reemplaza el store mock de index.html. // Expone useStore() con la misma forma que el mock para que los componentes // no necesiten cambios: searches/notifs con camelCase y tipos JS nativos. async function apiFetch(path, opts = {}) { const res = await fetch('/api' + path, { credentials: 'same-origin', headers: { 'content-type': 'application/json', ...(opts.headers || {}) }, ...opts, body: opts.body ? JSON.stringify(opts.body) : undefined, }); if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })); const e = new Error(err.error || `HTTP ${res.status}`); e.status = res.status; throw e; } if (res.status === 204) return null; return res.json(); } // ── Normalizadores DB → shape de los componentes ───────────────────────────── function minutesAgo(ts) { if (!ts) return 0; return Math.max(0, Math.round((Date.now() - new Date(ts).getTime()) / 60000)); } function hoursAgo(ts) { return minutesAgo(ts) / 60; } function normalizeSearch(row) { return { id: row.id, term: row.term, cat: row.cat || 'tech', condition: row.cond || 'cualquiera', priceMin: Number(row.price_min) || 0, priceMax: Number(row.price_max) || 0, location: row.location || '', freq: row.freq || '1d', active: !!Number(row.active), notify: !!Number(row.notify), lastCheck: minutesAgo(row.last_check), matches: Number(row.match_count) || 0, unread: Number(row.unread_count) || 0, createdAt: hoursAgo(row.created_at), activity: Array.isArray(row.activity) ? row.activity : Array(14).fill(0), }; } function normalizeNotif(row) { return { id: row.id, searchId: row.search_id, meliId: row.meli_id || null, title: row.title || '', price: Number(row.price) || 0, target: Number(row.target) || 0, condition: row.cond || 'cualquiera', location: row.location || '', permalink: row.permalink || 'https://www.mercadolibre.com.ar', thumbnail: row.thumbnail || null, sellerRep: row.seller_rep || '', foundAt: minutesAgo(row.found_at), read: !!Number(row.is_read), searchTerm: row.search_term || '', }; } // ── Mapeador inverso (frontend → body de API) ───────────────────────────────── function searchToBody(s) { return { term: s.term, cat: s.cat || '', cond: s.condition || s.cond || 'cualquiera', price_min: s.priceMin || 0, price_max: s.priceMax || 0, location: s.location || '', freq: s.freq || '1d', active: s.active !== false ? 1 : 0, notify: s.notify !== false ? 1 : 0, }; } // ── useStore: reemplaza el mock con llamadas REST ───────────────────────────── function useStore() { const [searches, setSearches] = React.useState([]); const [notifs, setNotifs] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const refresh = React.useCallback(async () => { try { const [rawSearches, rawNotifs] = await Promise.all([ apiFetch('/searches'), apiFetch('/notifications'), ]); setSearches(rawSearches.map(normalizeSearch)); setNotifs(rawNotifs.map(normalizeNotif)); setError(null); } catch (err) { setError(err.message); } finally { setLoading(false); } }, []); // Carga inicial; después refresca cada 60 s automáticamente. React.useEffect(() => { refresh(); const id = setInterval(refresh, 60_000); return () => clearInterval(id); }, [refresh]); // ── Acciones ────────────────────────────────────────────────────────────── const toggleSearch = React.useCallback(async (id) => { const s = searches.find((x) => x.id === id); if (!s) return; // Optimistic update setSearches((p) => p.map((x) => x.id === id ? { ...x, active: !x.active } : x)); try { await apiFetch(`/searches/${id}`, { method: 'PATCH', body: { active: s.active ? 0 : 1 } }); } catch (_) { // Revertir en error setSearches((p) => p.map((x) => x.id === id ? { ...x, active: s.active } : x)); } }, [searches]); const markRead = React.useCallback(async (id) => { const n = notifs.find((x) => x.id === id); if (!n) return; setNotifs((p) => p.map((x) => x.id === id ? { ...x, read: !x.read } : x)); try { await apiFetch(`/notifications/${id}`, { method: 'PATCH', body: { is_read: n.read ? 0 : 1 } }); } catch (_) { setNotifs((p) => p.map((x) => x.id === id ? { ...x, read: n.read } : x)); } }, [notifs]); const markAll = React.useCallback(async () => { setNotifs((p) => p.map((n) => ({ ...n, read: true }))); try { await apiFetch('/notifications/read-all', { method: 'POST' }); } catch (err) { console.error('markAll error:', err.message); } }, []); const saveSearch = React.useCallback(async (s) => { try { if (s.id && searches.some((x) => x.id === s.id)) { const updated = await apiFetch(`/searches/${s.id}`, { method: 'PATCH', body: searchToBody(s) }); setSearches((p) => p.map((x) => x.id === s.id ? normalizeSearch({ ...updated, activity: x.activity }) : x)); } else { const created = await apiFetch('/searches', { method: 'POST', body: searchToBody(s) }); setSearches((p) => [normalizeSearch(created), ...p]); } } catch (err) { console.error('saveSearch error:', err.message); } }, [searches]); const deleteSearch = React.useCallback(async (id) => { setSearches((p) => p.filter((x) => x.id !== id)); setNotifs((p) => p.filter((n) => n.searchId !== id)); try { await apiFetch(`/searches/${id}`, { method: 'DELETE' }); } catch (err) { console.error('deleteSearch error:', err.message); } }, []); return { searches, notifs, loading, error, refresh, toggleSearch, markRead, markAll, saveSearch, deleteSearch }; } Object.assign(window, { useStore, apiFetch });