| """Naija Solar: bespoke custom frontend served over FastAPI (the off-brand surface). |
| |
| This is a fully hand-built web app (web/index.html + web/app.js + web/shell.css) served by FastAPI. |
| All the heavy lifting is reused from app.py: the deterministic sizing engine, every SVG/HTML |
| generator, the Three.js scene, and the model clients. app.py is imported with BUILDSMALL_NO_GRADIO=1 |
| so its (now unused) Gradio Blocks is never built. The Gradio app remains a one-line fallback. |
| |
| uvicorn server:app --host 0.0.0.0 --port 7860 # local + Docker entrypoint |
| """ |
| import os |
| os.environ.setdefault("BUILDSMALL_NO_GRADIO", "1") |
|
|
| import base64 |
| import hashlib |
| import hmac |
| import pathlib |
| import secrets as _secrets |
| import sys |
| import tempfile |
| import threading |
| import time |
|
|
| HERE = pathlib.Path(__file__).resolve().parent |
| sys.path.insert(0, str(HERE)) |
| if not (HERE / "buildsmall").exists(): |
| sys.path.insert(0, str(HERE.parents[1] / "_platform")) |
|
|
| import app as core |
| import store |
| import strings |
|
|
| from fastapi import FastAPI, File, Form, Request, UploadFile |
| from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response |
| from fastapi.staticfiles import StaticFiles |
|
|
| app = FastAPI(title="Naija Solar") |
| WEB = HERE / "web" |
|
|
| |
| |
| |
| DATA_DIR = os.environ.get("BUILDSMALL_DATA_DIR", "user_data") |
|
|
|
|
| def _persisted(name, gen): |
| try: |
| os.makedirs(DATA_DIR, exist_ok=True) |
| p = os.path.join(DATA_DIR, name) |
| if os.path.exists(p): |
| return open(p).read().strip() |
| v = gen() |
| open(p, "w").write(v) |
| return v |
| except Exception: |
| return gen() |
|
|
|
|
| SECRET = (os.environ.get("SECRET_KEY") or _persisted(".session_key", lambda: _secrets.token_hex(32))).encode("utf-8") |
| ADMIN_KEY = os.environ.get("ADMIN_KEY") or _persisted(".admin_key", lambda: "naija-" + _secrets.token_hex(8)) |
| ON_SPACE = bool(os.environ.get("SPACE_ID")) |
| COOKIE, TTL = "ns_session", 30 * 24 * 3600 |
| print("[naija] storage data dir: %s | admin key (for /api/admin/users?key=...): %s" % (DATA_DIR, ADMIN_KEY)) |
|
|
| |
| |
| |
| try: |
| _mark = pathlib.Path(DATA_DIR) / ".testi_reset_v1" |
| if not _mark.exists(): |
| store.STORE.reset_testimonials() |
| _mark.write_text("done") |
| print("[naija] cleared development testimonials (one-time)") |
| except Exception as _e: |
| print("[naija] testimonial reset skipped:", _e) |
|
|
|
|
| def _b64(b): |
| return base64.urlsafe_b64encode(b).decode().rstrip("=") |
|
|
|
|
| def _ub64(s): |
| return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) |
|
|
|
|
| def make_token(email): |
| payload = ("%s|%d" % (email, int(time.time()) + TTL)).encode("utf-8") |
| return _b64(payload) + "." + _b64(hmac.new(SECRET, payload, hashlib.sha256).digest()) |
|
|
|
|
| def read_token(tok): |
| try: |
| p, s = (tok or "").split(".") |
| payload = _ub64(p) |
| if not hmac.compare_digest(_ub64(s), hmac.new(SECRET, payload, hashlib.sha256).digest()): |
| return None |
| email, exp = payload.decode().rsplit("|", 1) |
| return email if int(exp) > time.time() else None |
| except Exception: |
| return None |
|
|
|
|
| def current_user(request): |
| email = read_token(request.cookies.get(COOKIE)) |
| return store.STORE.get(email) if email else None |
|
|
|
|
| def _set_cookie(resp, email): |
| resp.set_cookie(COOKIE, make_token(email), max_age=TTL, httponly=True, samesite="lax", secure=ON_SPACE, path="/") |
|
|
|
|
| def _rec(sel, r, state, lang): |
| return {"appliances": sel, "panels": r["panel"]["count"], "kva": r["inverter"]["kva"], |
| "kwh": r["daily_kwh"], "cost": r["batteries"]["durable"]["total"], "state": state, "lang": lang, |
| "label": ", ".join("%d %s" % (v, k.split(" (")[0]) for k, v in list(sel.items())[:4])} |
|
|
|
|
| def _save_if_user(request, sel, r, state, lang, bundle): |
| u = current_user(request) |
| if u: |
| store.STORE.add_sizing(u["email"], _rec(sel, r, state, lang)) |
| bundle["saved"] = True |
| bundle["userCount"] = store.STORE.get(u["email"])["count"] |
|
|
|
|
| |
| def _sess(): |
| return core.get_session(None) |
|
|
|
|
| def _size(appliances, state="Lagos", geolat=""): |
| sel = {str(k): int(v) for k, v in (appliances or {}).items() if int(v) > 0} |
| r = core.engine.size(sel, core._psh(state, geolat)) |
| return sel, r |
|
|
|
|
| def _bundle(sel, r, lang="en"): |
| """Everything the frontend needs to paint a full result, reusing the existing generators.""" |
| return { |
| "ok": True, |
| "sel": sel, |
| "house": core._house_data(r, sel), |
| "narration": core.narrate(r, lang), |
| "summary": core.set_summary(r)[0], |
| "tiles": core.stat_tiles(r), |
| "cards": core.rec_cards(r), |
| "chips": core.chips_html(sel), |
| "system": core.system_view(r), |
| "twod": core.home_2d_svg(r), |
| "breakdown": core.load_breakdown_html(sel), |
| "vendors": core.vendors_html(), |
| "count": core.count_html(), |
| "panels": r["panel"]["count"], |
| "kva": r["inverter"]["kva"], |
| "kwh": r["daily_kwh"], |
| "cost": r["batteries"]["durable"]["total"], |
| } |
|
|
|
|
| |
| _LANGS = ["en", "pcm", "yo", "ha", "ig"] |
|
|
|
|
| @app.get("/api/config") |
| def config(): |
| return { |
| "ui": core.UI, |
| "langName": core.LANG_NAME, |
| "states": list(core.locations.STATES), |
| "guideTitle": core.GUIDE_TITLE, |
| "guard": core.GUARD, |
| "count": core.count_html(), |
| "appliances": list(core.data.APPLIANCES), |
| "logo": core.logo_html(), |
| "hero": {L: core.hero_html(L) for L in _LANGS}, |
| "steps": {L: core.steps_html(L) for L in _LANGS}, |
| "strings": strings.STRINGS, |
| } |
|
|
|
|
| @app.post("/api/size") |
| async def api_size(payload: dict, request: Request): |
| text = (payload or {}).get("text", "") |
| lang = (payload or {}).get("lang", "en") |
| sel = core.extract(text) |
| if not sel: |
| msg = core.GUARD.get(lang, core.GUARD["en"]) if (text or "").strip() else "" |
| return {"ok": False, "msg": msg} |
| state = payload.get("state", "Lagos") |
| _, r = _size(sel, state, payload.get("geolat", "")) |
| b = _bundle(sel, r, lang) |
| _save_if_user(request, sel, r, state, lang, b) |
| return b |
|
|
|
|
| @app.post("/api/recalc") |
| async def api_recalc(payload: dict, request: Request): |
| sel = {k: int(v) for k, v in (payload.get("appliances") or {}).items() if int(v) > 0} |
| if not sel: |
| return {"ok": False, "msg": "Add an appliance first."} |
| state = payload.get("state", "Lagos") |
| lang = payload.get("lang", "en") |
| _, r = _size(sel, state, payload.get("geolat", "")) |
| b = _bundle(sel, r, lang) |
| _save_if_user(request, sel, r, state, lang, b) |
| return b |
|
|
|
|
| @app.post("/api/asr") |
| async def api_asr(file: UploadFile = File(...)): |
| data = await file.read() |
| p = tempfile.mktemp(suffix=".wav") |
| with open(p, "wb") as f: |
| f.write(data) |
| try: |
| text = core.asr.transcribe(p) |
| except Exception: |
| text = "" |
| return {"text": text or ""} |
|
|
|
|
| @app.post("/api/vision") |
| async def api_vision(request: Request, files: list[UploadFile] = File(...), state: str = Form("Lagos"), |
| lang: str = Form("en"), geolat: str = Form("")): |
| seen = {} |
| for f in (files or [])[:5]: |
| data = await f.read() |
| p = tempfile.mktemp(suffix=".png") |
| with open(p, "wb") as fp: |
| fp.write(data) |
| try: |
| desc = core.vision.describe(p, "List every household electrical appliance you can see, with " |
| "counts. Use simple names like fridge, fan, bulb, TV, AC, freezer, laptop.") |
| for k, v in core.extract(desc).items(): |
| seen[k] = seen.get(k, 0) + v |
| except Exception: |
| pass |
| if not seen: |
| return {"ok": False, "msg": "Could not spot appliances. Try a clearer photo or type them."} |
| _, r = _size(seen, state, geolat) |
| b = _bundle(seen, r, lang) |
| _save_if_user(request, seen, r, state, lang, b) |
| return b |
|
|
|
|
| _WARM_TS = [0.0] |
| _WARM_FILES = {} |
|
|
|
|
| def _warm_assets(): |
| if not _WARM_FILES: |
| import wave |
| wp = tempfile.mktemp(suffix=".wav") |
| try: |
| with wave.open(wp, "w") as w: |
| w.setnchannels(1); w.setsampwidth(2); w.setframerate(16000) |
| w.writeframes(b"\x00\x00" * 3200) |
| except Exception: |
| wp = None |
| ip = tempfile.mktemp(suffix=".png") |
| try: |
| from PIL import Image |
| Image.new("RGB", (32, 32), (210, 210, 210)).save(ip) |
| except Exception: |
| ip = None |
| _WARM_FILES["wav"], _WARM_FILES["img"] = wp, ip |
| return _WARM_FILES |
|
|
|
|
| @app.post("/api/warm") |
| async def api_warm(): |
| """Wake EVERY model (text, speech, voice, vision) in the background when a visitor arrives, so |
| the first real request is fast. The models scale to zero when idle, so this is throttled and |
| fires again whenever someone returns after a lull. Returns warming=true when it actually fired.""" |
| now = time.time() |
| if now - _WARM_TS[0] < 60: |
| return {"ok": True, "warming": False} |
| _WARM_TS[0] = now |
| a = _warm_assets() |
| jobs = [lambda: core.llm.complete("Hi", model="Qwen/Qwen3-1.7B"), |
| lambda: core.tts.speak("Ready.", lang="en", out_path=tempfile.mktemp(suffix=".wav"))] |
| if a.get("wav"): |
| jobs.append(lambda: core.asr.transcribe(a["wav"])) |
| if a.get("img"): |
| jobs.append(lambda: core.vision.describe(a["img"], "List appliances.")) |
|
|
| def _run(fn): |
| try: |
| fn() |
| except Exception: |
| pass |
| for fn in jobs: |
| threading.Thread(target=_run, args=(fn,), daemon=True).start() |
| return {"ok": True, "warming": True} |
|
|
|
|
| @app.post("/api/narration") |
| async def api_narration(payload: dict): |
| """The written explanation, the exact words the voice will read. Instant (no model).""" |
| sel = {k: int(v) for k, v in (payload.get("appliances") or {}).items() if int(v) > 0} |
| if not sel: |
| return {"text": ""} |
| _, r = _size(sel, payload.get("state", "Lagos"), payload.get("geolat", "")) |
| return {"text": core.narrate(r, payload.get("lang", "en"))} |
|
|
|
|
| @app.post("/api/narrate") |
| async def api_narrate(payload: dict): |
| sel = {k: int(v) for k, v in (payload.get("appliances") or {}).items() if int(v) > 0} |
| if not sel: |
| return JSONResponse({"ok": False}, status_code=400) |
| _, r = _size(sel, payload.get("state", "Lagos"), payload.get("geolat", "")) |
| audio, _ = core.speak(r, payload.get("lang", "en"), _sess()) |
| if audio and os.path.exists(audio): |
| return FileResponse(audio, media_type="audio/wav") |
| return JSONResponse({"ok": False}, status_code=503) |
|
|
|
|
| @app.post("/api/ask") |
| async def api_ask(payload: dict): |
| sel = {k: int(v) for k, v in (payload.get("appliances") or {}).items() if int(v) > 0} |
| if not sel: |
| return {"answer": "Size your appliances first, then I can answer questions about your plan."} |
| _, r = _size(sel, payload.get("state", "Lagos"), payload.get("geolat", "")) |
| ans, _ = core.ask(payload.get("question", ""), r, payload.get("lang", "en"), _sess()) |
| return {"answer": ans} |
|
|
|
|
| @app.post("/api/feedback") |
| async def api_feedback(payload: dict, request: Request): |
| rating = payload.get("rating", "up") |
| stars = payload.get("stars") |
| comment = (payload.get("comment") or "").strip() |
| u = current_user(request) |
| name = ((u["name"] if u else (payload.get("name") or "")) or "").strip() or "A Naija Solar user" |
| lang = payload.get("lang", "en") |
| core.track_event("feedback", {"rating": rating, "stars": stars, "comment": comment[:300], "lang": lang}) |
| positive = (rating == "up") or (isinstance(stars, (int, float)) and stars >= 4) |
| if comment and positive and len(comment) >= 6: |
| store.STORE.add_testimonial({"name": name[:40], "text": comment[:240], "rating": int(stars or 5), |
| "lang": lang if lang in _LANGS else "en"}) |
| return {"ok": True, "html": core.submit_feedback(rating if rating in ("up", "down") else "up", comment)} |
|
|
|
|
| @app.get("/api/testimonials") |
| def api_testimonials(): |
| real = store.STORE.list_testimonials(60) |
| items, seen = [], set() |
| for t in real: |
| txt = (t.get("text") or "").strip() |
| if not txt: |
| continue |
| k = ((t.get("name") or "").strip().lower(), txt.lower()) |
| if k in seen: |
| continue |
| seen.add(k) |
| items.append({"name": t.get("name", "A user"), "text": txt, |
| "rating": t.get("rating", 5), "lang": t.get("lang", "en")}) |
| return {"items": items[:24], "realCount": len(items)} |
|
|
|
|
| @app.post("/api/email") |
| async def api_email(payload: dict): |
| return {"html": core.submit_email(payload.get("email", ""))} |
|
|
|
|
| @app.post("/api/event") |
| async def api_event(payload: dict): |
| core.track_event(payload.get("type", "event"), payload.get("data")) |
| return {"ok": True} |
|
|
|
|
| @app.get("/api/stats") |
| def api_stats(): |
| return core.get_stats() |
|
|
|
|
| @app.post("/api/geo") |
| async def api_geo(payload: dict): |
| return {"note": core.geo_note(payload.get("geolat", ""))} |
|
|
|
|
| |
| @app.post("/api/auth/signup") |
| async def auth_signup(payload: dict): |
| u, err = store.STORE.create_user(payload.get("email"), payload.get("password"), payload.get("name")) |
| if err: |
| return JSONResponse({"ok": False, "error": err}, status_code=400) |
| core.track_event("signup", {"email": u["email"]}) |
| resp = JSONResponse({"ok": True, "user": u}) |
| _set_cookie(resp, u["email"]) |
| return resp |
|
|
|
|
| @app.post("/api/auth/login") |
| async def auth_login(payload: dict): |
| u, err = store.STORE.login(payload.get("email"), payload.get("password")) |
| if err: |
| return JSONResponse({"ok": False, "error": err}, status_code=400) |
| resp = JSONResponse({"ok": True, "user": u}) |
| _set_cookie(resp, u["email"]) |
| return resp |
|
|
|
|
| @app.post("/api/auth/logout") |
| async def auth_logout(): |
| resp = JSONResponse({"ok": True}) |
| resp.delete_cookie(COOKIE, path="/") |
| return resp |
|
|
|
|
| @app.get("/api/auth/me") |
| def auth_me(request: Request): |
| return {"user": current_user(request)} |
|
|
|
|
| @app.get("/api/me/sizings") |
| def my_sizings(request: Request): |
| u = current_user(request) |
| if not u: |
| return {"ok": False, "sizings": []} |
| return {"ok": True, "sizings": store.STORE.list_sizings(u["email"])} |
|
|
|
|
| @app.post("/api/me/save") |
| async def my_save(payload: dict, request: Request): |
| u = current_user(request) |
| if not u: |
| return {"ok": False} |
| sel = {k: int(v) for k, v in (payload.get("appliances") or {}).items() if int(v) > 0} |
| if not sel: |
| return {"ok": False} |
| _, r = _size(sel, payload.get("state", "Lagos"), payload.get("geolat", "")) |
| store.STORE.add_sizing(u["email"], _rec(sel, r, payload.get("state", "Lagos"), payload.get("lang", "en"))) |
| return {"ok": True, "count": store.STORE.get(u["email"])["count"]} |
|
|
|
|
| @app.get("/api/admin/users") |
| def admin_users(key: str = ""): |
| if not key or key != ADMIN_KEY: |
| return JSONResponse({"error": "unauthorized"}, status_code=401) |
| return store.STORE.admin_overview() |
|
|
|
|
| |
| @app.get("/app.css") |
| def app_css(): |
| return Response(core.CSS, media_type="text/css") |
|
|
|
|
| _ASSET_V = None |
|
|
|
|
| def _asset_version(): |
| """A short hash of the frontend assets, appended as ?v=… so browsers never serve a stale |
| app.js/CSS after a deploy (which can make features look broken). Recomputed each restart.""" |
| global _ASSET_V |
| if _ASSET_V is None: |
| h = hashlib.md5() |
| for p in (WEB / "app.js", WEB / "shell.css"): |
| try: |
| h.update(p.read_bytes()) |
| except Exception: |
| pass |
| h.update(core.CSS.encode("utf-8")) |
| _ASSET_V = h.hexdigest()[:10] |
| return _ASSET_V |
|
|
|
|
| @app.get("/classic", response_class=HTMLResponse) |
| def index(): |
| """The hand-built FastAPI/SPA frontend, kept reachable while the Gradio app is the root interface.""" |
| shell = (WEB / "index.html").read_text(encoding="utf-8") |
| |
| html = shell.replace("<!--HEAD-->", core.THREE_HEAD).replace("__V__", _asset_version()) |
| return HTMLResponse(html) |
|
|
|
|
| app.mount("/web", StaticFiles(directory=str(WEB)), name="web") |
|
|
| |
| |
| |
| |
| |
| import gradio as gr |
|
|
| |
| |
| |
| GRADIO_CSS = """ |
| gradio-app, .gradio-container, body{ |
| background:radial-gradient(1200px 620px at 50% -14%, #fff1d2 0%, #fffaf0 42%, #edfaf3 100%) fixed !important} |
| footer{display:none !important} |
| .gradio-container{padding-bottom:64px !important} |
| /* cinematic hero */ |
| .banner{border-radius:24px !important; min-height:206px !important; overflow:hidden; position:relative; |
| box-shadow:0 24px 64px rgba(16,76,52,.24) !important} |
| .banner::after{content:""; position:absolute; inset:0; background:linear-gradient(105deg, rgba(6,60,42,.62) 0%, rgba(6,60,42,.18) 52%, rgba(6,60,42,0) 78%); pointer-events:none} |
| .banner .bwrap{position:relative; z-index:1} |
| .banner .bwrap h1{font-size:2.5rem !important; line-height:1.08 !important; letter-spacing:-.015em; text-shadow:0 2px 16px rgba(0,0,0,.32) !important} |
| .banner .bwrap p{font-size:1.06rem !important; opacity:.97; text-shadow:0 1px 10px rgba(0,0,0,.28)} |
| /* premium depth on the input card */ |
| .miccard{box-shadow:0 18px 52px rgba(120,80,20,.13) !important; border-radius:22px !important} |
| /* primary buttons: warm glow + subtle lift */ |
| .gobtn{transition:box-shadow .18s, transform .15s, filter .15s !important} |
| .gobtn:hover{box-shadow:0 13px 32px rgba(255,138,0,.38) !important; transform:translateY(-1px); filter:brightness(1.03)} |
| /* language pills breathe a little more */ |
| .langrow{gap:8px !important; row-gap:8px !important} |
| /* footer with the classic-UI / GitHub / SoroTTS links */ |
| .gfoot{text-align:center; margin:26px 0 6px; font-size:.9rem; color:#6b7280} |
| .gfoot a{color:#1f7a4d; text-decoration:none; font-weight:600} |
| .gfoot a:hover{text-decoration:underline} |
| """ |
|
|
| |
| |
| |
| GRADIO_WARM_JS = """ |
| <script> |
| (function(){ |
| var s=document.createElement('style'); s.textContent='@keyframes nsspin{to{transform:rotate(360deg)}}'; document.head.appendChild(s); |
| function warm(){ |
| try{ fetch('/api/warm',{method:'POST'}); }catch(e){} |
| if(document.getElementById('ns-warm')) return; |
| var n=document.createElement('div'); n.id='ns-warm'; |
| n.style.cssText='position:fixed;left:50%;top:12px;transform:translateX(-50%);z-index:99999;max-width:560px;width:calc(100% - 28px);background:#fff7e9;border:1.5px solid #f3d9a8;border-radius:14px;padding:10px 14px;color:#7a4a12;font:600 13px/1.4 Inter,system-ui,sans-serif;box-shadow:0 12px 34px rgba(120,80,20,.20);display:flex;align-items:center;gap:9px'; |
| n.innerHTML='<span style="flex:none;width:13px;height:13px;border:2px solid #e3b770;border-top-color:transparent;border-radius:50%;display:inline-block;animation:nsspin .8s linear infinite"></span>' |
| +'<span>Waking up the AI models. They sleep when idle to stay free, so your first result may take a few extra seconds.</span>' |
| +'<button aria-label="Dismiss" style="flex:none;margin-left:auto;background:none;border:none;color:#b07d3a;font-size:19px;line-height:1;cursor:pointer" onclick="this.parentNode.remove()">×</button>'; |
| document.body.appendChild(n); |
| setTimeout(function(){ var e=document.getElementById('ns-warm'); if(e){ e.remove(); } }, 55000); |
| } |
| if(document.readyState==='loading'){ document.addEventListener('DOMContentLoaded', function(){ setTimeout(warm,700); }); } |
| else { setTimeout(warm,700); } |
| })(); |
| </script> |
| """ |
|
|
| |
| |
| |
| app = gr.mount_gradio_app(app, core.build(), path="/", ssr_mode=False, |
| css=core.CSS + GRADIO_CSS, theme=core.THEME, head=core.THREE_HEAD + GRADIO_WARM_JS) |
|
|