agrosense / web /app.js
johnpitteera's picture
Upload folder using huggingface_hub
d27b187 verified
Raw
History Blame Contribute Delete
55.1 kB
/* AgroSense SPA — talks to the FastAPI backend (same origin). */
const $ = (id) => document.getElementById(id);
const el = (html) => { const d = document.createElement('div'); d.innerHTML = html.trim(); return d.firstChild; };
const esc = (s) => (s ?? '').toString().replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
const md = (s) => esc(s).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/\n/g, '<br>');
const num = (v, suf = '') => (v === null || v === undefined ? '—' : v + suf);
async function getJSON(path) { const r = await fetch(path); if (!r.ok) throw new Error(r.status); return r.json(); }
async function postForm(path, obj, headers = {}) {
const fd = new FormData();
for (const [k, v] of Object.entries(obj)) if (v !== null && v !== undefined && v !== '') fd.append(k, v);
const r = await fetch(path, { method: 'POST', body: fd, headers });
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.detail || r.status);
return data;
}
async function postJSON(path, obj) {
const r = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(obj) });
if (!r.ok) throw new Error(r.status); return r.json();
}
const state = () => ({
location: $('loc').value.trim(), language: $('lang').value,
crop: $('crop').value.trim(), stage: $('stage').value,
});
const spin = (target) => { target.innerHTML = '<div class="spinner"></div> loading…'; };
const errBox = (e) => `<div class="alert danger">Request failed (${esc(e.message)}). Is the API reachable?</div>`;
const qs = (o) => Object.entries(o).filter(([, v]) => v !== '' && v != null)
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&');
/* ---------- header: datetime, languages, news, commodities ---------- */
async function loadDateTime() {
try {
const d = await getJSON('/datetime');
$('dt-greg').textContent = '📅 ' + d.gregorian;
const pg = d.panchang;
let saka = '🪔 ' + d.indian_national + (d.lunar_day ? ' · 🌙 ' + d.lunar_day : '');
if (pg && pg.nakshatra) saka += ' · ⭐ ' + pg.nakshatra;
const sakaEl = $('dt-saka');
sakaEl.textContent = saka;
if (pg) sakaEl.title = `Panchang — Vaara: ${pg.vaara} | Tithi: ${pg.tithi} | ` +
`Nakshatra: ${pg.nakshatra} | Yoga: ${pg.yoga} | Karana: ${pg.karana}`;
$('dt-ist').textContent = '🕐 ' + d.ist_time + ' IST';
} catch (e) { /* ignore */ }
}
async function loadLanguages() {
try {
const d = await getJSON('/languages');
$('lang').innerHTML = Object.entries(d.languages).map(([c, n]) =>
`<option value="${c}">${esc(n)}</option>`).join('');
} catch (e) { $('lang').innerHTML = '<option value="en">English</option>'; }
}
async function loadNews() {
const region = $('news-region').value || 'IN';
const topic = $('news-topic').value || 'Top stories';
try {
const d = await getJSON('/news?' + qs({ region, topic, limit: 18 }));
if (!$('news-region').dataset.init) {
$('news-region').innerHTML = Object.entries(d.available_regions || { IN: 'India' })
.map(([c, n]) => `<option value="${c}">${esc(n)}</option>`).join('');
$('news-topic').innerHTML = (d.available_topics || ['Top stories'])
.map(t => `<option>${esc(t)}</option>`).join('');
$('news-region').dataset.init = '1';
}
const items = d.items || [];
$('news-ticker').innerHTML = '📰 ' + (items.length
? items.map(i => `<a href="${esc(i.link)}" target="_blank">${esc(i.title)}</a>`).join(' • ')
: 'No headlines.');
} catch (e) { $('news-ticker').textContent = '📰 News unavailable.'; }
}
async function loadCommodities() {
try {
const d = await getJSON('/commodities');
const chips = (d.items || []).map(c => {
if (c.price === null) return `<span>${esc(c.name)}: <i>n/a</i></span>`;
let delta = '';
if (c.change_pct != null) {
const cls = c.change_pct >= 0 ? 'up' : 'down';
delta = ` <span class="${cls}">${c.change_pct >= 0 ? '▲' : '▼'}${Math.abs(c.change_pct)}%</span>`;
}
return `<span>${esc(c.name)}: ${esc(c.currency)}${c.price}/${esc(c.unit)}${delta}</span>`;
});
$('cmd-ticker').innerHTML = '💹 ' + chips.join('  ');
} catch (e) { $('cmd-ticker').textContent = '💹 Commodities unavailable.'; }
}
/* ---------- tabs ---------- */
function setupTabs() {
const panels = [...document.querySelectorAll('.panel')];
const names = panels.map(p => p.dataset.tab);
$('tabs').innerHTML = names.map((n, i) =>
`<button class="tab${i === 0 ? ' active' : ''}" data-t="${esc(n)}">${esc(n)}</button>`).join('');
$('tabs').addEventListener('click', (e) => {
const b = e.target.closest('.tab'); if (!b) return;
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t === b));
panels.forEach(p => p.classList.toggle('active', p.dataset.tab === b.dataset.t));
if (b.dataset.t === 'Location intel') maybeAutoIntel(); // auto-load on open
});
}
/* ---------- speaking avatar (Web Speech API) ---------- */
let LAST_ANSWER_TEXT = '';
const LANG_BCP = { en: 'en-IN', hi: 'hi-IN', kn: 'kn-IN', te: 'te-IN', ta: 'ta-IN',
mr: 'mr-IN', bn: 'bn-IN', ml: 'ml-IN', gu: 'gu-IN', pa: 'pa-IN' };
const ttsOK = () => typeof window !== 'undefined' && 'speechSynthesis' in window;
function setTts(s) { const e = $('tts-status'); if (e) e.textContent = s; }
function avatarTalk(on) { const a = $('avatar'); if (a) a.classList.toggle('talking', on); }
function speakableText(answer) {
// Speak the recommendation prose only: drop the Sources/URLs and markdown.
let t = (answer || '').split(/\*\*Sources\*\*/)[0].split('**Local weather')[0];
t = t.replace(/https?:\S+/g, '').replace(/\*\*/g, '').replace(/\[\d+\]/g, '')
.replace(/[#`>_*]/g, '').replace(/\s+/g, ' ').trim();
return t.slice(0, 650);
}
function pickVoice(langCode) {
const voices = window.speechSynthesis.getVoices() || [];
const bcp = LANG_BCP[langCode] || 'en-IN';
const base = bcp.split('-')[0];
return voices.find(v => v.lang === bcp) ||
voices.find(v => v.lang && v.lang.toLowerCase().startsWith(base)) ||
voices.find(v => v.lang && v.lang.startsWith('en')) || null;
}
function speak(text, langCode) {
if (!ttsOK()) { setTts('voice not supported on this browser'); return; }
if (!text) { setTts('ask something first'); return; }
window.speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text);
u.lang = LANG_BCP[langCode] || 'en-IN';
const v = pickVoice(langCode); if (v) u.voice = v;
u.rate = 1; u.pitch = 1;
u.onstart = () => { avatarTalk(true); setTts('speaking…'); };
u.onend = () => { avatarTalk(false); setTts('ready'); };
u.onerror = () => { avatarTalk(false); setTts('voice error'); };
window.speechSynthesis.speak(u);
}
function stopSpeak() { if (ttsOK()) window.speechSynthesis.cancel(); avatarTalk(false); setTts('stopped'); }
/* ---------- advisor ---------- */
async function ask() {
const s = state(); const out = $('answer');
if (!$('q').value.trim()) { out.innerHTML = '<div class="alert info">Type a question first.</div>'; return; }
spin(out);
try {
const r = await postJSON('/query', {
query: $('q').value.trim(), language: s.language, location: s.location || null,
include_satellite: $('t-sat').checked, include_advisories: $('t-adv').checked,
include_prices: $('t-prices').checked, commodity: s.crop || null, stage: s.stage || null,
});
let html = `<div class="answer">${md(r.answer)}</div>`;
if (r.citations && r.citations.length)
html += `<div class="cite">Citations: ${r.citations.map(c => `[${c.n}] ${esc(c.source)} (${esc(c.crop)})`).join(' · ')}</div>`;
html += `<div class="cite">lang ${esc(r.language)} · ${r.latency_ms} ms · ${esc(r.backends?.embedding || '')}</div>`;
if (r.prices) html += priceCard(r.prices);
out.innerHTML = html;
LAST_ANSWER_TEXT = speakableText(r.answer);
if ($('autospeak') && $('autospeak').checked) speak(LAST_ANSWER_TEXT, s.language);
} catch (e) { out.innerHTML = errBox(e); }
}
/* ---------- location intel ---------- */
function card(title, body) { return `<div class="card"><h3>${title}</h3>${body}</div>`; }
function kv(k, v) { return `<div class="kv"><span class="muted">${esc(k)}</span><span class="v">${v}</span></div>`; }
let intelLoadedFor = null;
function maybeAutoIntel() {
const s = state();
if (s.location && s.location !== intelLoadedFor) loadIntel();
}
function onLocationChange() {
intelLoadedFor = null; // force a refresh for the new location
const p = document.querySelector('.panel[data-tab="Location intel"]');
if (p && p.classList.contains('active')) maybeAutoIntel();
}
async function loadIntel() {
const s = state(); const root = $('intel');
if (!s.location) { root.innerHTML = '<div class="alert info">Enter a location above.</div>'; return; }
intelLoadedFor = s.location;
root.innerHTML = '<div class="spinner"></div> loading the dashboard…';
const loc = encodeURIComponent(s.location);
const cards = {};
const set = () => root.innerHTML = Object.values(cards).join('');
const add = (key, html) => { cards[key] = html; set(); };
// fire all in parallel; render as each resolves
getJSON('/weather?location=' + loc).then(w => add('w', weatherCard(w))).catch(() => {});
getJSON('/environment?location=' + loc).then(e => add('e', e.available ? envCard(e) : '')).catch(() => {});
getJSON('/satellite?location=' + loc).then(x => add('s', x.available ? satCard(x) : '')).catch(() => {});
getJSON('/planetary?location=' + loc).then(p => add('p', p.available ? planetCard(p) : '')).catch(() => {});
getJSON('/hazards?location=' + loc).then(h => add('h', h.available ? hazardCard(h) : '')).catch(() => {});
getJSON('/advisories?' + qs({ location: s.location, crop: s.crop, stage: s.stage }))
.then(a => add('a', a.available ? advisoryCard(a) : '')).catch(() => {});
}
function weatherCard(w) {
if (!w.available) return '';
let b = kv('Now', num(w.current_temp_c, '°C')) + kv('Humidity', num(w.current_humidity, '%'));
if (w.current_precip_mm != null) b += kv('Precipitation now', num(w.current_precip_mm, ' mm'));
// Full daily forecast list.
b += '<div style="margin:8px 0 2px"><b>Forecast</b></div>';
(w.daily || []).forEach(d => {
b += `<div class="kv"><span class="muted">${esc(d.date)}</span>
<span class="v">${num(d.tmin_c)}${num(d.tmax_c)}°C · rain ${num(d.precip_mm, ' mm')}`
+ (d.precip_prob != null ? ` (${d.precip_prob}%)` : '') + `</span></div>`;
});
(w.advisories || []).forEach(a => b += `<div class="alert warn">${esc(a)}</div>`);
b += `<div class="cite">Source: ${esc(w.source || 'Open-Meteo')}</div>`;
return card('🌤️ Weather — ' + esc(w.location_name), b);
}
function envCard(e) {
const s = e.sunlight || {}, wd = e.wind || {}, aq = e.air_quality || {}, gw = e.groundwater || {};
const b = `<div class="metrics">
<div class="metric"><div class="n">${num(e.elevation_m, 'm')}</div><div class="l">Altitude</div></div>
<div class="metric"><div class="n">${num(e.population)}</div><div class="l">Population</div></div>
<div class="metric"><div class="n">${num(e.humidity_pct, '%')}</div><div class="l">Humidity</div></div>
<div class="metric"><div class="n">${num(wd.speed_kmh)} ${esc(wd.direction_compass || '')}</div><div class="l">Wind km/h</div></div>
<div class="metric"><div class="n">${num(s.sunshine_hours, 'h')}</div><div class="l">Sunshine</div></div>
<div class="metric"><div class="n">${num(aq.us_aqi)}</div><div class="l">US AQI ${esc(aq.category || '')}</div></div>
<div class="metric"><div class="n">${num(aq.pm2_5)}</div><div class="l">PM2.5</div></div>
<div class="metric"><div class="n">${gw.level_m != null ? gw.level_m + 'm' : num(gw.soil_moisture_m3m3)}</div><div class="l">Groundwater</div></div>
</div><div class="cite">${esc(gw.note || '')}</div>`;
return card('🌍 Environment — ' + esc(e.location_name), b);
}
function satCard(x) {
const ac = x.agroclimate || {}, nd = x.numeric_ndvi;
let b = `<img class="sat" src="${esc(x.imagery.ndvi)}" alt="NDVI" />`;
b += kv('Agroclimate solar', num(ac.avg_solar_mj, ' MJ/m²/d'));
b += kv('Recent rain', num(ac.total_precip_mm, ' mm'));
(ac.notes || []).forEach(n => b += `<div class="alert ok">${esc(n)}</div>`);
b += nd && nd.latest != null ? kv('Field NDVI', `${nd.latest} (${esc(nd.status)})`)
: `<div class="cite">Field NDVI: configure Earth Engine to enable.</div>`;
b += `<div class="cite"><a href="${esc(x.imagery.worldview)}" target="_blank">Open in NASA Worldview</a></div>`;
return card('🛰️ Satellite — ' + esc(x.location_name), b);
}
function planetCard(p) {
const mp = p.moon_phase || {};
let b = `<div class="alert info">🌙 ${esc(mp.name)}${Math.round((mp.illumination || 0) * 100)}% illuminated</div>`;
b += '<table class="tbl"><tr><th>Body</th><th>Alt</th><th>Az</th><th></th></tr>' +
(p.bodies || []).map(x => `<tr><td>${esc(x.name)}</td><td>${x.altitude_deg}°</td>
<td>${x.azimuth_deg}° ${esc(x.azimuth_compass)}</td><td>${x.above_horizon ? '✅' : '—'}</td></tr>`).join('') + '</table>';
return card('🪐 Sky — ' + esc(p.location_name), b);
}
function hazardCard(h) {
let b = '';
const ev = h.events || [];
b += ev.length ? ev.slice(0, 6).map(e => `<div class="alert warn"><b>${esc(e.category)}</b> — ${esc(e.title)}${e.distance_km != null ? ' · ' + e.distance_km + ' km' : ''}</div>`).join('')
: '<div class="alert ok">No hazard events within range.</div>';
if (h.fires === null) b += '<div class="cite">🔥 Active fires: set a NASA FIRMS key to enable.</div>';
else if (h.fires.length) b += `<div class="alert danger">🔥 ${h.fires.length} active fire(s) nearby; nearest ${h.fires[0].distance_km} km.</div>`;
else b += '<div class="cite">🔥 No active fires nearby.</div>';
return card('⚠️ Hazards — ' + esc(h.location_name), b);
}
function advisoryCard(a) {
const items = a.advisories || [];
const b = (items.length ? items : []).map(x =>
`<div class="alert ${x.urgency === 'high' ? 'danger' : x.urgency === 'medium' ? 'warn' : 'ok'}">
<span class="badge ${x.urgency}">${esc(x.urgency)}</span> <b>${esc(x.title)}</b>: ${esc(x.action)}
<div class="cite">${esc(x.rationale)}</div></div>`).join('') || '<div class="alert ok">No urgent advisories.</div>';
return card('🧭 Advisories' + (a.crop ? ' — ' + esc(a.crop) + (a.stage ? ' / ' + esc(a.stage) : '') : ''), b);
}
/* ---------- market ---------- */
function priceCard(p) {
if (!p) return '';
const s = p.summary || {};
let b = '';
if (s.modal_min != null) b += `<div class="metrics">
<div class="metric"><div class="n">₹${s.modal_min}</div><div class="l">min</div></div>
<div class="metric"><div class="n">₹${s.modal_avg}</div><div class="l">avg</div></div>
<div class="metric"><div class="n">₹${s.modal_max}</div><div class="l">max</div></div></div>`;
(p.records || []).slice(0, 6).forEach(r => b += kv(`${esc(r.market)} (${esc(r.state)})`, '₹' + num(r.modal_price)));
(p.notes || []).forEach(n => b += `<div class="cite">${esc(n)}</div>`);
return `<div style="margin-top:8px">${b || '<span class="muted">No price records.</span>'}</div>`;
}
async function loadPrices() {
const out = $('prices'); spin(out);
try {
const d = await getJSON('/prices?' + qs({ commodity: $('price-commodity').value.trim(), state: $('price-state').value.trim() }));
out.innerHTML = d.available ? priceCard(d) : '<div class="alert info">Prices unavailable — set a data.gov.in key (AGROSENSE_DATAGOV_API_KEY).</div>';
} catch (e) { out.innerHTML = errBox(e); }
}
async function fillCommoditiesTable() {
try {
const d = await getJSON('/commodities');
$('commodities').innerHTML = '<table class="tbl"><tr><th>Commodity</th><th>Price</th><th>Δ</th></tr>' +
d.items.map(c => `<tr><td>${esc(c.name)}</td><td>${c.price == null ? 'n/a' : esc(c.currency) + c.price + '/' + esc(c.unit)}</td>
<td>${c.change_pct == null ? '' : (c.change_pct >= 0 ? '▲' : '▼') + Math.abs(c.change_pct) + '%'}</td></tr>`).join('') + '</table>';
} catch (e) { $('commodities').innerHTML = errBox(e); }
}
/* ---------- farmer trading platform ---------- */
let TRADE_USER = '';
function trBadge(l) {
return l.type === 'sell'
? '<span class="badge low">Selling</span>' : '<span class="badge medium">Buying</span>';
}
function trPrice(l) {
const cur = l.currency === 'INR' ? '₹' : esc(l.currency) + ' ';
return `${cur}${l.price}/${esc(l.unit)} · ${l.quantity} ${esc(l.unit)}`;
}
async function loadListings() {
const out = $('tr-list'); spin(out);
try {
const d = await getJSON('/market/listings?' + qs({ type: $('tr-type').value,
commodity: $('tr-commodity').value.trim(), state: $('tr-state').value.trim() }));
out.innerHTML = (d.listings || []).map(l => `<div class="msg">
${trBadge(l)} <b>${esc(l.commodity)}</b>
<span class="cite">${trPrice(l)}</span>
${l.grade ? `<span class="cite">· ${esc(l.grade)}</span>` : ''}
${l.location || l.state ? `<span class="cite">· 📍 ${esc(l.location || l.state)}</span>` : ''}
${l.inquiry_count ? `<span class="cite">· 💬 ${l.inquiry_count}</span>` : ''}
<div class="small" style="margin:4px 0">${esc(l.description || '')}</div>
<button class="ghost tr-open" data-id="${esc(l.id)}">View &amp; contact</button>
</div>`).join('') || '<span class="muted">No matching listings. Post one below!</span>';
out.innerHTML += `<div class="disclaimer">🤝 ${esc(d.disclaimer || '')}</div>`;
out.querySelectorAll('.tr-open').forEach(b => b.onclick = () => openListing(b.dataset.id));
} catch (e) { out.innerHTML = errBox(e); }
}
async function openListing(id) {
const out = $('tr-detail'); spin(out);
try { renderListing(await getJSON('/market/listings/' + encodeURIComponent(id))); }
catch (e) { out.innerHTML = errBox(e); }
}
function renderListing(l) {
const closed = l.status !== 'open';
const inqs = (l.inquiries || []).map(q => `<div class="msg"><b>${esc(q.name)}</b>
${q.contact ? `<span class="cite">· ☎ ${esc(q.contact)}</span>` : ''}
${q.quantity ? `<span class="cite">· wants ${q.quantity}</span>` : ''}
${q.at ? `<span class="cite">· ${esc(q.at)}</span>` : ''}
<br>${esc(q.message || '')}</div>`).join('');
$('tr-detail').innerHTML = `<div class="card">
<h3>${trBadge(l)} ${esc(l.commodity)} ${closed ? '<span class="badge high">Closed</span>' : ''}</h3>
<div class="cite">${trPrice(l)} ${l.grade ? '· ' + esc(l.grade) : ''}
${l.location || l.state ? '· 📍 ' + esc(l.location || '') + ' ' + esc(l.state || '') : ''}</div>
<p>${esc(l.description || '')}</p>
<div class="cite">Posted by ${esc(l.seller || 'Anonymous')}${l.harvest_date ? ' · ' + esc(l.harvest_date) : ''}
${l.contact ? ' · ' + esc(l.contact) : ''}</div>
<div class="row" style="margin-top:6px">
<a href="${esc(l.room_url)}" target="_blank"><button>🎥 Negotiate (video room)</button></a>
${closed ? '' : `<button class="ghost" id="tr-close" data-id="${esc(l.id)}">Mark sold/closed</button>`}
</div>
<h3 style="margin-top:10px">💬 Inquiries</h3>
<div class="chat">${inqs || '<span class="muted">No inquiries yet.</span>'}</div>
${closed ? '<div class="cite">This listing is closed.</div>' : `<div class="row" style="margin-top:8px">
<input id="inq-name" placeholder="Your name" value="${esc(TRADE_USER)}" style="width:130px" />
<input id="inq-contact" placeholder="Your contact (phone)" style="width:160px" />
<input id="inq-qty" placeholder="qty" style="width:70px" />
<input id="inq-msg" placeholder="Your offer / message…" style="flex:1" />
<button id="inq-send" data-id="${esc(l.id)}">Send inquiry</button>
</div>`}</div>`;
const closeBtn = $('tr-close');
if (closeBtn) closeBtn.onclick = async () => renderListing(
(await postForm(`/market/listings/${l.id}/close`, { status: 'closed' })).listing);
const sendBtn = $('inq-send');
if (sendBtn) sendBtn.onclick = async () => {
const msg = $('inq-msg').value.trim(), contact = $('inq-contact').value.trim();
if (!msg && !contact) { return; }
TRADE_USER = $('inq-name').value.trim() || TRADE_USER;
try {
renderListing(await postForm(`/market/listings/${l.id}/inquire`,
{ name: TRADE_USER, contact, message: msg, quantity: $('inq-qty').value.trim() }));
} catch (e) { $('tr-detail').insertAdjacentHTML('beforeend', errBox(e)); } // keep form handlers
};
}
async function createListing() {
const msg = $('nt-msg');
const f = (id) => $(id).value.trim();
try {
const d = await postForm('/market/listings', {
type: $('nt-type').value, commodity: f('nt-commodity'), quantity: f('nt-quantity'),
unit: $('nt-unit').value, price: f('nt-price'), grade: f('nt-grade'),
location: f('nt-location'), state: f('nt-state'), seller: f('nt-seller'),
contact: f('nt-contact'), description: f('nt-description') });
msg.innerHTML = `<span style="color:var(--ok)">Posted your ${esc(d.listing.type)} listing for ${esc(d.listing.commodity)}.</span>`;
['nt-commodity', 'nt-quantity', 'nt-price', 'nt-grade', 'nt-location', 'nt-state',
'nt-seller', 'nt-contact', 'nt-description'].forEach(i => $(i).value = '');
await loadListings(); openListing(d.listing.id);
} catch (e) { msg.innerHTML = `<span style="color:var(--danger)">${esc(e.message)}</span>`; }
}
/* ---------- plant clinic ---------- */
async function classifyImage() {
const out = $('vision'); const f = $('img').files[0];
if (!f) { out.innerHTML = '<div class="alert info">Choose an image first.</div>'; return; }
spin(out);
try {
const r = await postForm('/vision/classify?task=all', { file: f });
const line = (t, o) => `<div class="kv"><span class="muted">${t}</span><span class="v">${esc(o.label)} (${Math.round((o.confidence || 0) * 100)}%)</span></div><div class="cite">${esc(o.note || '')}</div>`;
out.innerHTML = line('Disease/health', r.disease || {}) + line('Plant ID', r.plant || {}) + line('Pest ID', r.pest || {});
} catch (e) { out.innerHTML = errBox(e); }
}
async function telemedicine() {
const s = state(); const out = $('tele'); spin(out);
try {
const r = await postForm('/telemedicine', {
crop: s.crop, symptoms: $('symptoms').value.trim(), location: s.location,
file: $('img').files[0] || null,
});
let b = `<div class="alert ${r.health_status === 'Likely healthy' ? 'ok' : 'warn'}">
<b>${esc(r.diagnosis)}</b> · ${esc(r.health_status)} · severity ${esc(r.severity)} · ${Math.round((r.confidence || 0) * 100)}%</div>`;
if (r.weather_note) b += `<div class="alert warn">🌦️ ${esc(r.weather_note)}</div>`;
b += '<b>📋 Prescription</b>';
(r.prescription || []).forEach(p => b += `<div class="msg"><b>${esc(p.category)}:</b> ${esc(p.instruction)}</div>`);
if (r.citations?.length) b += `<div class="cite">Sources: ${esc(r.citations.join(', '))}</div>`;
b += `<div class="disclaimer">⚕️ ${esc(r.disclaimer)}</div>`;
out.innerHTML = b;
} catch (e) { out.innerHTML = errBox(e); }
}
/* ---------- live doctor ---------- */
const stars = (avg, count) => avg == null ? '<span class="cite">no ratings yet</span>'
: `<span style="color:var(--warn)">${'★'.repeat(Math.round(avg))}${'☆'.repeat(5 - Math.round(avg))}</span>
<span class="cite">${avg} (${count})</span>`;
async function loadExperts() {
try {
const d = await getJSON('/experts');
$('experts').innerHTML = d.experts.map(e => `<div class="msg">
<b>${esc(e.name)}</b> — ${esc(e.specialization)} &nbsp; ${stars(e.rating_avg, e.rating_count)}
<div class="cite">${esc(e.region)} · ${esc(e.languages.join(', '))}
${e.registration_no ? '· Reg ' + esc(e.registration_no) : ''}</div>
<button class="ghost exp-profile" data-id="${esc(e.id)}" style="margin-top:4px">View profile</button>
</div>`).join('') || '<span class="muted">No verified doctors yet.</span>';
$('experts').querySelectorAll('.exp-profile').forEach(b => b.onclick = () => showProfile(b.dataset.id));
} catch (e) { $('experts').innerHTML = errBox(e); }
}
async function showProfile(id) {
const out = $('doctor-profile'); spin(out);
try {
const p = await getJSON('/doctors/' + encodeURIComponent(id));
let b = `<div class="card"><h3>👨‍⚕️ ${esc(p.name)}</h3>
<div>${esc(p.specialization)} &nbsp; ${stars(p.rating_avg, p.rating_count)}</div>
<div class="kv"><span class="muted">Region</span><span class="v">${esc(p.region)}</span></div>
<div class="kv"><span class="muted">Languages</span><span class="v">${esc(p.languages.join(', '))}</span></div>
<div class="kv"><span class="muted">ICAR / Reg. no.</span><span class="v">${esc(p.registration_no || '—')}</span></div>
<div class="kv"><span class="muted">Credentials</span><span class="v">${esc(p.credentials || '—')}</span></div>
<div class="kv"><span class="muted">Contact</span><span class="v">${esc(p.contact || '—')}</span></div>`;
if (p.ratings.length) b += '<h3 style="margin-top:10px">Recent reviews</h3>' +
p.ratings.slice().reverse().map(r => `<div class="msg"><span style="color:var(--warn)">${'★'.repeat(r.stars)}</span>
${esc(r.comment || '')} <span class="cite">${esc(r.at || '')}</span></div>`).join('');
b += `<div class="row" style="margin-top:10px;align-items:flex-end">
<label class="field" style="width:90px"><span>Stars</span>
<select id="rate-stars">${[5, 4, 3, 2, 1].map(s => `<option value="${s}">${s} ★</option>`).join('')}</select></label>
<input id="rate-comment" placeholder="Leave a review (optional)" style="flex:1">
<button id="rate-go" data-id="${esc(p.id)}">Rate</button></div>
<div id="rate-msg" class="cite"></div></div>`;
out.innerHTML = b;
$('rate-go').onclick = () => rateDoctor(p.id);
} catch (e) { out.innerHTML = errBox(e); }
}
async function rateDoctor(id) {
try {
await postForm('/doctors/' + encodeURIComponent(id) + '/rate',
{ stars: $('rate-stars').value, comment: $('rate-comment').value.trim() });
await showProfile(id); await loadExperts();
} catch (e) { $('rate-msg').innerHTML = `<span style="color:var(--danger)">${esc(e.message)}</span>`; }
}
function renderConsult(c) {
const out = $('consult-session');
let b = `<div class="kv"><span class="muted">Consultation ${esc(c.id)}</span><span class="v">${esc(c.status)}</span></div>`;
if (c.expert) b += `<div class="alert ok">👨‍⚕️ <b>${esc(c.expert.name)}</b> — ${esc(c.expert.specialization)}<br>
<span class="cite">${esc(c.expert.region)} · ${esc(c.expert.languages.join(', '))}</span></div>`;
if (c.summary) b += `<div class="cite">Shared: ${esc(c.summary)}</div>`;
const okch = (c.notifications || []).filter(n => n.ok).map(n => n.channel).join(', ');
if (okch) b += `<div class="cite">🔔 Notified via: ${esc(okch)}</div>`;
if (c.room_url) b += `<a href="${esc(c.room_url)}" target="_blank"><button style="margin:8px 0">🎥 Join live video room</button></a>`;
b += '<div class="chat">' + (c.messages || []).map(m => {
const who = { farmer: '🧑‍🌾 You', expert: '👨‍⚕️ Expert', system: 'ℹ️ System' }[m.sender] || m.sender;
return `<div class="msg"><b>${who}:</b> ${esc(m.text)} ${m.at ? `<span class="cite">${esc(m.at)}</span>` : ''}</div>`;
}).join('') + '</div>';
b += `<div class="row" style="margin-top:8px"><input id="cmsg" placeholder="Message the expert…" style="flex:1">
<button id="csend" data-id="${esc(c.id)}">Send</button></div>`;
out.innerHTML = b;
$('csend').onclick = async () => {
const txt = $('cmsg').value.trim(); if (!txt) return;
const upd = await postForm(`/consult/${c.id}/message`, { sender: 'farmer', text: txt });
renderConsult(upd);
};
}
async function applyDoctor() {
const msg = $('doc-apply-msg');
try {
const d = await postForm('/doctors/apply', {
name: $('doc-name').value.trim(), specialization: $('doc-spec').value.trim(),
region: $('doc-region').value.trim(), languages: $('doc-langs').value.trim(),
contact: $('doc-contact').value.trim(), credentials: $('doc-cred').value.trim(),
registration_no: $('doc-reg').value.trim(),
});
msg.innerHTML = `<span style="color:var(--ok)">Submitted as ${esc(d.doctor.id)}${esc(d.message)}</span>`;
['doc-name', 'doc-spec', 'doc-region', 'doc-langs', 'doc-contact', 'doc-cred', 'doc-reg'].forEach(i => $(i).value = '');
} catch (e) {
msg.innerHTML = `<span style="color:var(--danger)">${esc(e.message)}</span>`;
}
}
async function requestConsult() {
const s = state(); const out = $('consult-session'); spin(out);
try {
const c = await postForm('/consult/request', {
farmer_name: $('farmer').value.trim() || 'Farmer', crop: s.crop,
symptoms: $('symptoms').value.trim(), channel: $('channel').value,
language: $('consult-lang').value,
});
renderConsult(c);
} catch (e) { out.innerHTML = errBox(e); }
}
/* ---------- farmers' digital clubs ---------- */
let CLUB_USER = '';
async function loadClubs() {
const out = $('clubs-list'); spin(out);
try {
const d = await getJSON('/clubs?' + qs({ type: $('club-type').value, key: $('club-key').value.trim() }));
out.innerHTML = (d.clubs || []).map(c => `<div class="msg">
<b>${esc(c.name)}</b> <span class="badge ${c.type === 'location' ? 'low' : 'medium'}">${esc(c.type)}</span>
<span class="cite">${esc(c.key)} · ${c.member_count} member(s)</span>
<div class="row" style="margin-top:4px"><button class="ghost club-open" data-id="${esc(c.id)}">Open</button></div>
</div>`).join('') || '<span class="muted">No clubs found — create one!</span>';
out.querySelectorAll('.club-open').forEach(b => b.onclick = () => openClub(b.dataset.id));
} catch (e) { out.innerHTML = errBox(e); }
}
async function createClub() {
const msg = $('nc-msg');
try {
const d = await postForm('/clubs', { name: $('nc-name').value.trim(), type: $('nc-type').value,
key: $('nc-key').value.trim(), description: $('nc-desc').value.trim(), creator: $('nc-creator').value.trim() });
msg.innerHTML = `<span style="color:var(--ok)">Created "${esc(d.club.name)}".</span>`;
['nc-name', 'nc-key', 'nc-desc', 'nc-creator'].forEach(i => $(i).value = '');
await loadClubs(); openClub(d.club.id);
} catch (e) { msg.innerHTML = `<span style="color:var(--danger)">${esc(e.message)}</span>`; }
}
async function openClub(id) {
const out = $('club-detail'); spin(out);
try { renderClub(await getJSON('/clubs/' + encodeURIComponent(id))); }
catch (e) { out.innerHTML = errBox(e); }
}
function renderClub(c) {
const posts = (c.posts || []).map(p => `<div class="msg"><b>${esc(p.author)}:</b> ${esc(p.text)}
${p.link ? `<a href="${esc(p.link)}" target="_blank">link</a>` : ''}
${p.at ? `<span class="cite">${esc(p.at)}</span>` : ''}</div>`).join('');
$('club-detail').innerHTML = `<div class="card"><h3>👥 ${esc(c.name)}</h3>
<div class="cite">${esc(c.type)} · ${esc(c.key)} · ${c.member_count} member(s)</div>
<p>${esc(c.description || '')}</p>
<div class="row">
<input id="club-member" placeholder="Your name" value="${esc(CLUB_USER)}" style="flex:1" />
<button id="club-join" data-id="${esc(c.id)}">Join</button>
<a href="${esc(c.room_url)}" target="_blank"><button>🎥 Join video meeting</button></a>
</div>
<div class="cite">Members: ${esc((c.members || []).join(', ') || '—')}</div>
<h3 style="margin-top:10px">💬 Discussion & sharing</h3>
<div class="chat">${posts || '<span class="muted">No posts yet.</span>'}</div>
<div class="row" style="margin-top:8px">
<input id="club-post-author" placeholder="Your name" value="${esc(CLUB_USER)}" style="width:130px" />
<input id="club-post-text" placeholder="Share a message or info…" style="flex:1" />
<input id="club-post-link" placeholder="link (optional)" style="width:150px" />
<button id="club-send" data-id="${esc(c.id)}">Post</button>
</div></div>`;
$('club-join').onclick = async () => {
CLUB_USER = $('club-member').value.trim();
renderClub(await postForm(`/clubs/${c.id}/join`, { member: CLUB_USER }));
};
$('club-send').onclick = async () => {
const t = $('club-post-text').value.trim(); if (!t) return;
CLUB_USER = $('club-post-author').value.trim() || CLUB_USER;
renderClub(await postForm(`/clubs/${c.id}/post`,
{ author: CLUB_USER, text: t, link: $('club-post-link').value.trim() }));
};
}
/* ---------- traditional advisor ---------- */
async function loadTraditional() {
const out = $('trad-out'); spin(out);
const s = state();
try {
const d = await getJSON('/traditional?' + qs({ activity: $('trad-activity').value, location: s.location }));
const a = d.astrology || {}, p = d.panchang || {};
const v = (a.verdict || '').toLowerCase();
const cls = v.includes('favourable') ? 'low'
: (v.includes('postpone') || v.includes('not ideal') || v.includes('caution')) ? 'high' : 'medium';
const box = cls === 'low' ? 'ok' : cls === 'high' ? 'danger' : 'warn';
let b = `<div class="alert ${box}" style="margin-top:8px">
<span class="badge ${cls}">${esc(a.verdict)}</span> for <b>${esc(d.activity)}</b>
${d.location_name ? '· ' + esc(d.location_name) : ''}</div>`;
b += `<div class="cite">🪔 Panchang — Vaara ${esc(p.vaara)} · ${esc(p.tithi)} · `
+ `Nakshatra ${esc(p.nakshatra)} · Yoga ${esc(p.yoga)} · Karana ${esc(p.karana)}</div>`;
b += '<b>🔮 Astrological note</b>';
(a.reasons || []).forEach(r => b += `<div class="msg">• ${esc(r)}</div>`);
b += '<b>🌾 Traditional practices</b>';
(d.practices || []).forEach(pr => b += `<div class="msg"><b>${esc(pr.title)}</b>
<span class="cite">${esc(pr.region)} · ${esc(pr.source)}</span><br>${esc(pr.practice)}</div>`);
b += `<div class="disclaimer">🪔 ${esc(d.disclaimer)}</div>`;
out.innerHTML = b;
} catch (e) { out.innerHTML = errBox(e); }
}
/* ---------- agricultural finance (bank assistance / loans) ---------- */
let FIN_CATS_LOADED = false;
async function loadFinance() {
const out = $('fins-list'); spin(out);
try {
const d = await getJSON('/finance?' + qs({ category: $('fin-category').value,
search: $('fin-search').value.trim() }));
if (!FIN_CATS_LOADED && (d.categories || []).length) {
const cur = $('fin-category').value;
$('fin-category').innerHTML = '<option value="">All categories</option>' +
d.categories.map(c => `<option value="${esc(c)}">${esc(c)}</option>`).join('');
$('fin-category').value = cur;
FIN_CATS_LOADED = true;
}
const rows = (d.products || []).map(p => `<div class="msg">
<b>${esc(p.name)}</b> <span class="badge low">${esc(p.category)}</span>
<span class="cite">· ${esc(p.provider)}</span>
<div class="small" style="margin:4px 0">${esc(p.summary)}</div>
<span class="cite">💸 ${esc(p.interest || '')} ${p.loan_amount ? '· ' + esc(p.loan_amount) : ''}</span>
<div class="row" style="margin-top:4px"><button class="ghost fin-open" data-id="${esc(p.id)}">View &amp; apply</button></div>
</div>`).join('') || '<span class="muted">No matching products.</span>';
out.innerHTML = rows + (d.disclaimer ? `<div class="disclaimer">🏦 ${esc(d.disclaimer)}</div>` : '');
out.querySelectorAll('.fin-open').forEach(b => b.onclick = () => openFinance(b.dataset.id));
} catch (e) { out.innerHTML = errBox(e); }
}
async function openFinance(id) {
const out = $('fin-detail'); spin(out);
try { renderFinance(await getJSON('/finance/' + encodeURIComponent(id))); }
catch (e) { out.innerHTML = errBox(e); }
}
function renderFinance(p) {
const li = (arr) => (arr || []).map(i => `<li>${esc(i)}</li>`).join('');
const steps = (p.application_process || []).map(s => `<li>${esc(s)}</li>`).join('');
const s = state();
$('fin-detail').innerHTML = `<div class="card"><h3>🏦 ${esc(p.name)}</h3>
<div class="cite"><span class="badge low">${esc(p.category)}</span> ${esc(p.provider)}</div>
<p>${esc(p.summary)}</p>
<div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(180px,1fr))">
${p.interest ? `<div class="msg"><b>Interest</b><br>${esc(p.interest)}</div>` : ''}
${p.loan_amount ? `<div class="msg"><b>Loan amount</b><br>${esc(p.loan_amount)}</div>` : ''}
${p.tenure ? `<div class="msg"><b>Tenure</b><br>${esc(p.tenure)}</div>` : ''}
</div>
${p.benefits ? `<h3>💰 Benefits</h3><p>${esc(p.benefits)}</p>` : ''}
${p.eligibility ? `<h3>✅ Eligibility</h3><p>${esc(p.eligibility)}</p>` : ''}
${steps ? `<h3>📝 How to apply</h3><ol>${steps}</ol>` : ''}
${(p.documents || []).length ? `<h3>📄 Documents needed</h3><ul>${li(p.documents)}</ul>` : ''}
<div class="row" style="margin-top:8px">
${p.portal ? `<a href="${esc(p.portal)}" target="_blank"><button>🔗 Official portal</button></a>` : ''}
${p.helpline ? `<span class="cite">☎ ${esc(p.helpline)}</span>` : ''}
</div>
<h3 style="margin-top:10px">🧾 Lodge a loan enquiry</h3>
<div class="row">
<input id="fa-name" placeholder="Your name *" style="flex:1" />
<input id="fa-contact" placeholder="Phone / email *" style="flex:1" />
</div>
<div class="row">
<input id="fa-amount" placeholder="Amount needed (₹, optional)" style="flex:1" />
<input id="fa-location" placeholder="Location" value="${esc(s.location)}" style="flex:1" />
</div>
<input id="fa-message" placeholder="Purpose / message (optional)" />
<button id="fa-send" data-id="${esc(p.id)}" style="margin-top:8px">Submit enquiry</button>
<div id="fa-msg" class="cite"></div>
${p.source ? `<div class="cite">Source: ${esc(p.source)}</div>` : ''}
${p.disclaimer ? `<div class="disclaimer">🏦 ${esc(p.disclaimer)}</div>` : ''}</div>`;
$('fa-send').onclick = applyFinance;
try { $('fin-detail').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } catch (e) { /* jsdom */ }
}
async function applyFinance() {
const id = $('fa-send').dataset.id; const msg = $('fa-msg');
try {
const d = await postForm(`/finance/${encodeURIComponent(id)}/apply`, {
name: $('fa-name').value.trim(), contact: $('fa-contact').value.trim(),
amount: $('fa-amount').value.trim(), location: $('fa-location').value.trim(),
message: $('fa-message').value.trim() });
msg.innerHTML = `<span style="color:var(--ok)">Enquiry ${esc(d.application.id)} lodged. ${esc(d.message)}</span>`;
['fa-name', 'fa-contact', 'fa-amount', 'fa-message'].forEach(i => $(i).value = '');
} catch (e) { msg.innerHTML = `<span style="color:var(--danger)">${esc(e.message)}</span>`; }
}
/* ---------- government subsidies & schemes ---------- */
async function loadSubsidies() {
const out = $('subs-list'); spin(out);
const s = state();
try {
const d = await getJSON('/subsidies?' + qs({ level: $('sub-level').value,
state: s.location, search: $('sub-search').value.trim() }));
const rows = (d.schemes || []).map(x => `<div class="msg">
<b>${esc(x.name)}</b>
<span class="badge ${x.level === 'central' ? 'medium' : 'low'}">${esc(x.level === 'central' ? 'Central' : 'State')}</span>
${x.state ? `<span class="cite">${esc(x.state)}</span>` : ''}
<span class="cite">· ${esc(x.category)}${x.update_count ? ' · 📢 ' + x.update_count : ''}</span>
<div class="small" style="margin:4px 0">${esc(x.summary)}</div>
<button class="ghost sub-open" data-id="${esc(x.id)}">View details</button>
</div>`).join('') || '<span class="muted">No schemes match. Try clearing filters.</span>';
out.innerHTML = rows + (d.disclaimer ? `<div class="disclaimer">🏛️ ${esc(d.disclaimer)}</div>` : '');
out.querySelectorAll('.sub-open').forEach(b => b.onclick = () => openSubsidy(b.dataset.id));
} catch (e) { out.innerHTML = errBox(e); }
}
async function openSubsidy(id) {
const out = $('sub-detail'); spin(out);
try { renderSubsidy(await getJSON('/subsidies/' + encodeURIComponent(id))); }
catch (e) { out.innerHTML = errBox(e); }
}
function renderSubsidy(x) {
const li = (arr) => (arr || []).map(i => `<li>${esc(i)}</li>`).join('');
const steps = (x.application_process || []).map(s => `<li>${esc(s)}</li>`).join('');
const ups = (x.updates || []).slice().reverse().map(u =>
`<div class="msg"><span class="cite">${esc(u.date)}</span><br>${esc(u.text)}</div>`).join('');
$('sub-detail').innerHTML = `<div class="card"><h3>🏛️ ${esc(x.name)}</h3>
<div class="cite"><span class="badge ${x.level === 'central' ? 'medium' : 'low'}">${esc(x.level === 'central' ? 'Central' : 'State')}</span>
${x.state ? esc(x.state) + ' · ' : ''}${esc(x.category)}</div>
<p>${esc(x.summary)}</p>
${x.benefits ? `<h3>💰 Benefits</h3><p>${esc(x.benefits)}</p>` : ''}
${x.eligibility ? `<h3>✅ Eligibility</h3><p>${esc(x.eligibility)}</p>` : ''}
${steps ? `<h3>📝 How to apply</h3><ol>${steps}</ol>` : ''}
${(x.documents || []).length ? `<h3>📄 Documents needed</h3><ul>${li(x.documents)}</ul>` : ''}
<div class="row" style="margin-top:8px">
${x.portal ? `<a href="${esc(x.portal)}" target="_blank"><button>🔗 Official portal</button></a>` : ''}
${x.helpline ? `<span class="cite">☎ ${esc(x.helpline)}</span>` : ''}
</div>
${ups ? `<h3 style="margin-top:10px">📢 Announcements & updates</h3>${ups}` : ''}
${x.source ? `<div class="cite">Source: ${esc(x.source)}</div>` : ''}
${x.disclaimer ? `<div class="disclaimer">🏛️ ${esc(x.disclaimer)}</div>` : ''}</div>`;
try { $('sub-detail').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } catch (e) { /* jsdom */ }
}
async function loadSubsidyUpdates() {
const out = $('sub-updates'); if (!out) return;
try {
const d = await getJSON('/subsidies/updates?limit=10');
out.innerHTML = (d.updates || []).map(u => `<div class="msg">
<span class="cite">${esc(u.date)} · ${esc(u.scheme)}</span><br>${esc(u.text)}</div>`).join('')
|| '<span class="muted">No announcements.</span>';
} catch (e) { out.innerHTML = errBox(e); }
}
/* ---------- land records ---------- */
let LR_STATES_LOADED = false;
async function loadLandRecords() {
const out = $('lr-out'); spin(out);
const s = state();
const chosen = $('lr-state').value;
try {
const d = await getJSON('/land-records?' + qs(chosen ? { state: chosen } : { location: s.location }));
if (!LR_STATES_LOADED && (d.covered_states || []).length) {
$('lr-state').innerHTML = '<option value="">Use my location</option>' +
d.covered_states.map(x => `<option value="${esc(x)}">${esc(x)}</option>`).join('');
$('lr-state').value = chosen;
LR_STATES_LOADED = true;
}
const li = (arr) => (arr || []).map(i => `<li>${esc(i)}</li>`).join('');
const ol = (arr) => (arr || []).map(i => `<li>${esc(i)}</li>`).join('');
const stateName = d.state || 'your State';
const dlq = qs(chosen ? { state: chosen } : { location: s.location });
const uncovered = d.covered === false;
out.innerHTML = `<div class="card">
<h3>🗺️ ${esc(stateName)}${esc(d.system || 'land records')}</h3>
${uncovered ? `<div class="alert warn">No state-specific guide for "${esc(stateName)}" yet — showing the general all-India steps. Use the official portal below.</div>` : ''}
<div class="cite">Record: <b>${esc(d.record_name || '')}</b></div>
<p>${esc(d.summary || '')}</p>
<div class="row" style="margin:6px 0">
${d.portal ? `<a href="${esc(d.portal)}" target="_blank"><button>🔗 Open portal</button></a>` : ''}
${d.map_portal ? `<a href="${esc(d.map_portal)}" target="_blank"><button class="ghost">🗺️ Cadastral map</button></a>` : ''}
${d.helpline ? `<span class="cite">☎ ${esc(d.helpline)}</span>` : ''}
</div>
<h3>🔎 Search by</h3><ul>${li(d.search_by)}</ul>
<h3>📝 How to search &amp; download</h3><ol>${ol(d.steps)}</ol>
<h3>📄 The record contains</h3><ul>${li(d.contains)}</ul>
${d.notes ? `<div class="cite">${esc(d.notes)}</div>` : ''}
<h3 style="margin-top:10px">⬇️ Download a printable guide</h3>
<div class="row">
<a href="/land-records/guide.pdf?${dlq}" target="_blank"><button>📕 PDF guide</button></a>
<a href="/land-records/guide.docx?${dlq}" target="_blank"><button class="ghost">📘 Word guide</button></a>
</div>
<div class="disclaimer">🗺️ ${esc(d.disclaimer || '')}</div></div>`;
} catch (e) { out.innerHTML = errBox(e); }
}
/* ---------- internet radio ---------- */
let RADIO_STATIONS = [];
async function loadRadio() {
const out = $('radio-list'); spin(out);
try {
const d = await getJSON('/radio?' + qs({ country: 'IN', search: $('radio-search').value.trim(), limit: 80 }));
RADIO_STATIONS = d.stations || [];
out.innerHTML = RADIO_STATIONS.map((s, i) => `<div class="msg">
<button class="ghost radio-play" data-i="${i}">▶ Play</button>
<b>${esc(s.name)}</b>
<span class="cite">${esc(s.state || '')} ${s.codec ? '· ' + esc(s.codec) : ''} ${s.bitrate ? s.bitrate + 'kbps' : ''}</span>
</div>`).join('') || '<span class="muted">No stations found. Try a different filter.</span>';
out.querySelectorAll('.radio-play').forEach(b => b.onclick = () => playStation(+b.dataset.i));
} catch (e) { out.innerHTML = errBox(e); }
}
function playStation(i) {
const s = RADIO_STATIONS[i]; if (!s) return;
const a = $('radio-audio'); a.src = s.url;
$('radio-now').innerHTML = `▶ Now playing: <b>${esc(s.name)}</b>` +
(s.homepage ? ` — <a href="${esc(s.homepage)}" target="_blank">station site</a>` : '');
try {
const p = a.play();
if (p && p.catch) p.catch(() => {
$('radio-now').innerHTML += ' <span style="color:var(--danger)">(stream unavailable - try another)</span>';
});
} catch (e) { /* jsdom / autoplay */ }
}
/* ---------- admin: configure knowledge base ---------- */
let ADMIN_TOKEN = '';
const ADM_FIELDS = ['crop', 'category', 'soil_type', 'rainfall_mm', 'region', 'season',
'source', 'recommended_fertilizer', 'disease_prevention', 'pest_management'];
async function adminReq(method, path, body) {
const opts = { method, headers: { 'X-Admin-Token': ADMIN_TOKEN } };
if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); }
const r = await fetch(path, opts);
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.detail || r.status);
return data;
}
async function adminUnlock() {
ADMIN_TOKEN = $('admin-token').value;
const st = $('admin-status');
try {
await adminLoad();
$('admin-area').style.display = '';
st.innerHTML = '<span style="color:var(--ok)">Unlocked. Changes rebuild the live index.</span>';
} catch (e) {
$('admin-area').style.display = 'none';
st.innerHTML = `<span style="color:var(--danger)">${esc(e.message)}</span>`;
}
}
async function adminLoadDoctors() {
const d = await adminReq('GET', '/admin/doctors');
const badge = (s) => `<span class="badge ${s === 'verified' ? 'low' : s === 'rejected' ? 'high' : 'medium'}">${esc(s)}</span>`;
$('admin-doctors').innerHTML = (d.doctors || []).map(x => `
<div class="msg"><b>${esc(x.name)}</b> ${badge(x.status)}
<div class="cite">${esc(x.specialization)} · ${esc(x.region)} · ${esc((x.languages || []).join(', '))}
· ${esc(x.contact || '')} · ${esc(x.credentials || '')}</div>
${x.status !== 'verified' ? `<button class="ghost doc-ok" data-id="${esc(x.id)}">Verify</button>` : ''}
${x.status !== 'rejected' ? `<button class="ghost doc-no" data-id="${esc(x.id)}">Reject</button>` : ''}
</div>`).join('') || '<span class="muted">No doctors.</span>';
$('admin-doctors').querySelectorAll('.doc-ok').forEach(b => b.onclick = () => adminVerify(b.dataset.id, true));
$('admin-doctors').querySelectorAll('.doc-no').forEach(b => b.onclick = () => adminVerify(b.dataset.id, false));
}
async function adminVerify(id, approve) {
try {
await postForm(`/admin/doctors/${encodeURIComponent(id)}/verify`, { approve },
{ 'X-Admin-Token': ADMIN_TOKEN });
await adminLoadDoctors(); await loadExperts();
} catch (e) { $('admin-doctors').innerHTML = errBox(e); }
}
async function adminLoadSubsidies() {
const sel = $('asub-id'); if (!sel) return;
const d = await getJSON('/subsidies'); // public list (summaries)
sel.innerHTML = (d.schemes || []).map(s =>
`<option value="${esc(s.id)}">${esc(s.name)} (${esc(s.level)})</option>`).join('');
}
async function adminPostSubsidyUpdate() {
const msg = $('asub-msg'); const text = $('asub-text').value.trim();
if (!text) { msg.innerHTML = '<span style="color:var(--danger)">Announcement text is required.</span>'; return; }
try {
const d = await postForm(`/admin/subsidies/${encodeURIComponent($('asub-id').value)}/update`,
{ text, date: $('asub-date').value.trim() }, { 'X-Admin-Token': ADMIN_TOKEN });
msg.innerHTML = `<span style="color:var(--ok)">Posted to "${esc(d.scheme.name)}".</span>`;
$('asub-text').value = ''; $('asub-date').value = '';
loadSubsidyUpdates(); // refresh the public feed
} catch (e) { msg.innerHTML = `<span style="color:var(--danger)">${esc(e.message)}</span>`; }
}
async function adminLoadFinance() {
const out = $('admin-finance'); if (!out) return;
const d = await adminReq('GET', '/admin/finance/applications');
$('admin-fin-count').textContent = d.count;
out.innerHTML = (d.applications || []).map(a => `<div class="msg">
<b>${esc(a.name)}</b> <span class="cite">· ${esc(a.product)}</span>
${a.amount ? `<span class="cite">· ₹${a.amount}</span>` : ''}
<div class="cite">☎ ${esc(a.contact)} ${a.location ? '· 📍 ' + esc(a.location) : ''} ${a.at ? '· ' + esc(a.at) : ''}</div>
${a.message ? `<div class="small">${esc(a.message)}</div>` : ''}
</div>`).join('') || '<span class="muted">No loan enquiries yet.</span>';
}
async function adminLoad() {
await adminLoadDoctors();
await adminLoadSubsidies();
await adminLoadFinance();
const d = await adminReq('GET', '/admin/kb');
$('admin-count').textContent = d.count;
$('admin-list').innerHTML = (d.entries || []).map(e => `
<div class="msg"><b>${esc(e.crop)}</b> <span class="cite">${esc(e.id)}</span>
<span class="cite">· ${esc(e.soil_type || '')} · ${esc(e.source || '')}</span>
<div class="row" style="margin-top:4px">
<button class="ghost adm-edit" data-id="${esc(e.id)}">Edit</button>
<button class="ghost adm-del" data-id="${esc(e.id)}">Delete</button>
</div></div>`).join('') || '<span class="muted">No entries.</span>';
$('admin-list').querySelectorAll('.adm-edit').forEach(b =>
b.onclick = () => adminEdit(d.entries.find(x => x.id === b.dataset.id)));
$('admin-list').querySelectorAll('.adm-del').forEach(b =>
b.onclick = () => adminDelete(b.dataset.id));
}
function adminCollect() {
const e = {};
ADM_FIELDS.forEach(f => { const v = $('adm-' + f).value.trim(); if (v) e[f] = v; });
const id = $('adm-id').value; if (id) e.id = id;
return e;
}
function adminEdit(entry) {
$('adm-id').value = entry.id || '';
ADM_FIELDS.forEach(f => $('adm-' + f).value = entry[f] ?? '');
$('admin-form-title').textContent = '✏️ Edit ' + entry.id;
}
function adminClear() {
$('adm-id').value = '';
ADM_FIELDS.forEach(f => $('adm-' + f).value = '');
$('admin-form-title').textContent = '➕ Add entry';
$('admin-form-msg').textContent = '';
}
async function adminSave() {
const entry = adminCollect(); const msg = $('admin-form-msg');
try {
const id = $('adm-id').value;
const d = id ? await adminReq('PUT', '/admin/kb/' + encodeURIComponent(id), entry)
: await adminReq('POST', '/admin/kb', entry);
msg.innerHTML = `<span style="color:var(--ok)">Saved ${esc(d.entry.id)} · KB now ${d.count} entries (index rebuilt).</span>`;
adminClear(); await adminLoad();
} catch (e) { msg.innerHTML = `<span style="color:var(--danger)">${esc(e.message)}</span>`; }
}
async function adminDelete(id) {
try { const d = await adminReq('DELETE', '/admin/kb/' + encodeURIComponent(id));
$('admin-form-msg').innerHTML = `<span class="cite">Deleted ${esc(id)} · KB now ${d.count} entries.</span>`;
await adminLoad();
} catch (e) { $('admin-form-msg').innerHTML = `<span style="color:var(--danger)">${esc(e.message)}</span>`; }
}
/* ---------- wire up ---------- */
function init() {
setupTabs();
loadDateTime(); loadLanguages(); loadNews(); loadCommodities();
loadExperts(); fillCommoditiesTable();
setInterval(loadDateTime, 60000);
$('news-region').onchange = loadNews; $('news-topic').onchange = loadNews;
$('ask').onclick = ask;
$('load-intel').onclick = loadIntel;
$('loc').addEventListener('change', onLocationChange); // auto-refresh intel on location change
$('load-prices').onclick = loadPrices;
$('tr-find').onclick = loadListings;
$('tr-create').onclick = createListing;
loadListings();
$('classify').onclick = classifyImage;
$('consult').onclick = telemedicine;
$('request-consult').onclick = requestConsult;
$('admin-unlock').onclick = adminUnlock;
$('admin-save').onclick = adminSave;
$('admin-clear').onclick = adminClear;
$('doc-apply').onclick = applyDoctor;
$('radio-load').onclick = loadRadio;
$('trad-go').onclick = loadTraditional;
$('club-find').onclick = loadClubs;
$('club-create').onclick = createClub;
$('sub-find').onclick = loadSubsidies;
$('asub-post').onclick = adminPostSubsidyUpdate;
loadSubsidyUpdates();
$('fin-find').onclick = loadFinance;
loadFinance();
$('lr-find').onclick = loadLandRecords;
$('lr-state').onchange = loadLandRecords;
// Speaking avatar (Web Speech API).
$('speak-btn').onclick = () => speak(LAST_ANSWER_TEXT, state().language);
$('stop-btn').onclick = stopSpeak;
if (ttsOK()) {
try { window.speechSynthesis.getVoices(); window.speechSynthesis.onvoiceschanged = () => {}; }
catch (e) { /* ignore */ }
} else {
setTts('voice not supported'); $('speak-btn').disabled = true;
}
// Docs dropdown menu (toggle; close on outside click / Escape).
const dt = $('docs-toggle'), dm = $('docs-menu');
if (dt && dm) {
dt.onclick = (e) => { e.stopPropagation(); dm.classList.toggle('open'); };
document.addEventListener('click', (e) => {
if (!dm.contains(e.target) && e.target !== dt) dm.classList.remove('open');
});
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') dm.classList.remove('open'); });
}
}
document.addEventListener('DOMContentLoaded', init);