Spaces:
Paused
Paused
Download app.py from GoogleCalc/HowMuchFeed: direct link, hf CLI and curl.
- Browser
- Download file 69.7 kB
-
https://huggingface.co/spaces/GoogleCalc/HowMuchFeed/resolve/main/app.py
- Command line
-
hf download hf://spaces/GoogleCalc/HowMuchFeed/app.py
-
curl -L -o app.py https://huggingface.co/spaces/GoogleCalc/HowMuchFeed/resolve/main/app.py
69.7 kB
| import os | |
| import math | |
| import re | |
| import sqlite3 | |
| import json | |
| import urllib.parse | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| import threading | |
| from fastapi import FastAPI | |
| from fastapi.responses import Response | |
| import gradio as gr | |
| from pathlib import Path | |
| SOCIAL_PREVIEW_PATH = Path("social-preview.jpg").resolve() | |
| gr.set_static_paths(paths=[SOCIAL_PREVIEW_PATH]) | |
| # ============================================================ | |
| # APP CONFIG | |
| # ============================================================ | |
| APP_NAME = "🐔 How Much Feed?" | |
| DB_PATH = os.environ.get("DATABASE_PATH", "/data/howmuchfeed.db") | |
| # Admin password - set this in Hugging Face Secrets | |
| ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "") | |
| # Create directory if it doesn't exist | |
| db_dir = os.path.dirname(DB_PATH) | |
| if db_dir and not os.path.exists(db_dir): | |
| os.makedirs(db_dir, exist_ok=True) | |
| STARTER_END_DAY = 21 | |
| # ============================================================ | |
| # GOOGLE SHEETS INTEGRATION | |
| # ============================================================ | |
| def init_google_sheets(): | |
| """Initialize Google Sheets connection.""" | |
| try: | |
| import gspread | |
| from oauth2client.service_account import ServiceAccountCredentials | |
| creds_json = os.environ.get("GOOGLE_SHEETS_CREDENTIALS") | |
| sheet_id = os.environ.get("GOOGLE_SHEETS_ID") | |
| if not creds_json or not sheet_id: | |
| print("⚠️ Google Sheets not configured. Using local database only.") | |
| return None, None | |
| creds_dict = json.loads(creds_json) | |
| scope = [ | |
| "https://spreadsheets.google.com/feeds", | |
| "https://www.googleapis.com/auth/drive" | |
| ] | |
| creds = ServiceAccountCredentials.from_json_keyfile_dict(creds_dict, scope) | |
| client = gspread.authorize(creds) | |
| try: | |
| sheet = client.open_by_key(sheet_id) | |
| print(f"✅ Google Sheets connected: {sheet.title}") | |
| except: | |
| sheet = client.create("How Much Feed Calculator Leads") | |
| print(f"✅ New Google Sheet created") | |
| return client, sheet | |
| except Exception as e: | |
| print(f"⚠️ Google Sheets error: {e}") | |
| return None, None | |
| # Google Sheets is initialized lazily on first sync. | |
| # This keeps the public calculator fast to load even when Sheets is not configured. | |
| gs_client = None | |
| gs_sheet = None | |
| gs_initialized = False | |
| gs_init_lock = threading.Lock() | |
| def get_google_sheets(): | |
| global gs_client, gs_sheet, gs_initialized | |
| if gs_initialized: | |
| return gs_client, gs_sheet | |
| with gs_init_lock: | |
| if not gs_initialized: | |
| gs_client, gs_sheet = init_google_sheets() | |
| gs_initialized = True | |
| return gs_client, gs_sheet | |
| def sync_to_google_sheets(email, country, currency, breed, action="lead", question=""): | |
| """Sync a lead or farmer question to Google Sheets, if configured.""" | |
| client, sheet = get_google_sheets() | |
| if not client or not sheet: | |
| return False | |
| try: | |
| if action == "lead": | |
| worksheet_name = "Leads" | |
| headers = ["Email", "Country", "Currency", "Breed", "Created At"] | |
| row_data = [email, country, currency, breed, datetime.now(timezone.utc).isoformat()] | |
| elif action == "question": | |
| worksheet_name = "Questions" | |
| headers = ["Email", "Question", "Breed", "Created At"] | |
| row_data = [email or "", question or "", breed or "", datetime.now(timezone.utc).isoformat()] | |
| else: | |
| return False | |
| try: | |
| worksheet = sheet.worksheet(worksheet_name) | |
| except Exception: | |
| worksheet = sheet.add_worksheet(title=worksheet_name, rows=1000, cols=20) | |
| worksheet.append_row(headers) | |
| worksheet.append_row(row_data) | |
| print(f"✅ Synced to Google Sheets: {email or 'anonymous'}") | |
| return True | |
| except Exception as e: | |
| print(f"⚠️ Google Sheets sync error: {e}") | |
| return False | |
| # ============================================================ | |
| # VERIFIED BREED DATA - From Official PDFs | |
| # ============================================================ | |
| # Body Weight (kg) at key days - As-Hatched Performance | |
| BREED_WEIGHT_DATA = { | |
| "Ross 308": { | |
| 0: 0.044, 7: 0.213, 14: 0.533, 21: 1.012, | |
| 28: 1.616, 35: 2.296, 42: 2.998, 49: 3.681, 56: 4.318, | |
| }, | |
| "Cobb 500": { | |
| 0: 0.042, 7: 0.177, 14: 0.459, 21: 0.891, | |
| 28: 1.436, 35: 2.067, 42: 2.732, 49: 3.369, 56: 3.958, | |
| }, | |
| "Arbor Acres": { | |
| 0: 0.044, 7: 0.209, 14: 0.527, 21: 1.006, | |
| 28: 1.611, 35: 2.287, 42: 2.964, 49: 3.662, 56: 4.318, | |
| } | |
| } | |
| # Cumulative Feed (kg) at key days - As-Hatched Performance | |
| BREED_FEED_DATA = { | |
| "Ross 308": { | |
| 0: 0.000, 7: 0.166, 14: 0.535, 21: 1.155, | |
| 28: 2.051, 35: 3.211, 42: 4.586, 49: 6.115, 56: 7.733, | |
| }, | |
| "Cobb 500": { | |
| 0: 0.000, 7: 0.150, 14: 0.465, 21: 1.053, | |
| 28: 1.963, 35: 3.216, 42: 4.659, 49: 6.185, 56: 7.772, | |
| }, | |
| "Arbor Acres": { | |
| 0: 0.000, 7: 0.207, 14: 0.638, 21: 1.163, | |
| 28: 2.069, 35: 3.237, 42: 4.625, 49: 6.111, 56: 7.647, | |
| } | |
| } | |
| # Breed descriptions and availability | |
| BREED_INFO = { | |
| "Ross 308": { | |
| "description": "Big chest, thick legs, very fast growth. Excellent for meat production.", | |
| "available_at": "Agrited, Sayed, Valentine", | |
| "color": "#00A86B" | |
| }, | |
| "Cobb 500": { | |
| "description": "Good early growth, strong legs, solid feed conversion.", | |
| "available_at": "Chikun, Zartech", | |
| "color": "#00A86B" | |
| }, | |
| "Arbor Acres": { | |
| "description": "Balanced growth, hardy and adaptable. Great survival rate.", | |
| "available_at": "Supreme, CHI,Vertex, Fidan, Cascada, Sunchi, Farm Support e.t.c", | |
| "color": "#00A86B" | |
| } | |
| } | |
| def get_breed_weight_data(breed): | |
| return BREED_WEIGHT_DATA.get(breed, BREED_WEIGHT_DATA["Ross 308"]) | |
| def get_breed_feed_data(breed): | |
| return BREED_FEED_DATA.get(breed, BREED_FEED_DATA["Ross 308"]) | |
| def interpolate_weight(day, breed): | |
| """Interpolate body weight between known checkpoints.""" | |
| weight_data = get_breed_weight_data(breed) | |
| if day <= 0: | |
| return weight_data[0] | |
| if day >= 56: | |
| return weight_data[56] | |
| keys = sorted(weight_data.keys()) | |
| for i in range(len(keys) - 1): | |
| if keys[i] <= day <= keys[i + 1]: | |
| ratio = (day - keys[i]) / (keys[i + 1] - keys[i]) | |
| return weight_data[keys[i]] + (weight_data[keys[i + 1]] - weight_data[keys[i]]) * ratio | |
| return weight_data[56] | |
| def interpolate_feed(day, breed): | |
| """Interpolate cumulative feed between known checkpoints.""" | |
| feed_data = get_breed_feed_data(breed) | |
| if day <= 0: | |
| return 0.0 | |
| if day >= 56: | |
| return feed_data[56] | |
| keys = sorted(feed_data.keys()) | |
| for i in range(len(keys) - 1): | |
| if keys[i] <= day <= keys[i + 1]: | |
| ratio = (day - keys[i]) / (keys[i + 1] - keys[i]) | |
| return feed_data[keys[i]] + (feed_data[keys[i + 1]] - feed_data[keys[i]]) * ratio | |
| return feed_data[56] | |
| # ============================================================ | |
| # BENCHMARK TABLE GENERATOR | |
| # ============================================================ | |
| def get_benchmark_table(breed): | |
| """Generate benchmark table for the selected breed.""" | |
| weight_data = get_breed_weight_data(breed) | |
| feed_data = get_breed_feed_data(breed) | |
| days = [7, 14, 21, 28, 35, 42, 49, 56] | |
| table_data = [] | |
| for day in days: | |
| weight = weight_data.get(day, 0) | |
| feed = feed_data.get(day, 0) | |
| fcr = feed / weight if weight > 0 else 0 | |
| table_data.append([day, f"{weight:.3f}", f"{feed:.3f}", f"{fcr:.3f}"]) | |
| return table_data, days | |
| def get_benchmark_html(breed): | |
| """Render the selected breed benchmark as lightweight HTML instead of a JS dataframe.""" | |
| table_data, _ = get_benchmark_table(breed) | |
| rows = [] | |
| for day, weight, feed, fcr in table_data: | |
| rows.append( | |
| f"<tr><td>{day}</td><td>{weight} kg</td><td>{feed} kg</td><td>{fcr}</td></tr>" | |
| ) | |
| return f""" | |
| <div class="table-wrap benchmark-html"> | |
| <table class="feed-table benchmark-table"> | |
| <thead><tr><th>Days</th><th>Body Weight</th><th>Cumulative Feed</th><th>FCR</th></tr></thead> | |
| <tbody>{''.join(rows)}</tbody> | |
| </table> | |
| </div> | |
| """ | |
| def get_comparison_html(): | |
| """Render the breed comparison as lightweight HTML.""" | |
| data, breeds, _ = get_breed_comparison_table() | |
| headers = ['Days'] + [f'{b} Weight' for b in breeds] + [f'{b} Feed' for b in breeds] | |
| # Keep the comparison compact on phones: weight and feed are the most useful fields. | |
| rows = [] | |
| for row in data: | |
| day = row[0] | |
| vals = row[1:] | |
| weights = vals[0::3] | |
| feeds = vals[1::3] | |
| cells = [f'<td>{day}</td>'] | |
| cells += [f'<td>{v} kg</td>' for v in weights] | |
| cells += [f'<td>{v} kg</td>' for v in feeds] | |
| rows.append('<tr>' + ''.join(cells) + '</tr>') | |
| head = ''.join(f'<th>{h}</th>' for h in headers) | |
| return f""" | |
| <div class="table-wrap benchmark-html"> | |
| <table class="feed-table benchmark-table comparison-table"> | |
| <thead><tr>{head}</tr></thead> | |
| <tbody>{''.join(rows)}</tbody> | |
| </table> | |
| </div> | |
| """ | |
| def get_breed_comparison_table(): | |
| """Generate comparison table for all three breeds.""" | |
| breeds = ["Ross 308", "Cobb 500", "Arbor Acres"] | |
| days = [7, 14, 21, 28, 35, 42, 49, 56] | |
| table_data = [] | |
| for day in days: | |
| row = [day] | |
| for breed in breeds: | |
| weight_data = get_breed_weight_data(breed) | |
| feed_data = get_breed_feed_data(breed) | |
| weight = weight_data.get(day, 0) | |
| feed = feed_data.get(day, 0) | |
| fcr = feed / weight if weight > 0 else 0 | |
| row.append(f"{weight:.3f}") | |
| row.append(f"{feed:.3f}") | |
| row.append(f"{fcr:.3f}") | |
| table_data.append(row) | |
| return table_data, breeds, days | |
| # ============================================================ | |
| # CURRENCY DATA | |
| # ============================================================ | |
| CURRENCIES = { | |
| "NG": ("Nigeria", "₦", "NGN"), | |
| "GH": ("Ghana", "₵", "GHS"), | |
| "KE": ("Kenya", "KSh", "KES"), | |
| "UG": ("Uganda", "USh", "UGX"), | |
| "TZ": ("Tanzania", "TSh", "TZS"), | |
| "ZA": ("South Africa", "R", "ZAR"), | |
| "ZM": ("Zambia", "ZK", "ZMW"), | |
| "ZW": ("Zimbabwe", "ZiG", "ZWG"), | |
| "CM": ("Cameroon", "FCFA", "XAF"), | |
| "CI": ("Côte d'Ivoire", "CFA", "XOF"), | |
| "SN": ("Senegal", "CFA", "XOF"), | |
| "US": ("United States", "$", "USD"), | |
| "GB": ("United Kingdom", "£", "GBP"), | |
| "CA": ("Canada", "C$", "CAD"), | |
| "AU": ("Australia", "A$", "AUD"), | |
| "IN": ("India", "₹", "INR"), | |
| "PK": ("Pakistan", "₨", "PKR"), | |
| "BD": ("Bangladesh", "৳", "BDT"), | |
| "BR": ("Brazil", "R$", "BRL"), | |
| } | |
| DEFAULT_PRICES = { | |
| "NGN": 24000, "GHS": 300, "KES": 2200, "UGX": 100000, | |
| "TZS": 30000, "ZAR": 300, "ZMW": 350, "USD": 25, | |
| "GBP": 20, "EUR": 23, "INR": 2200, "PKR": 7000, | |
| "BDT": 2500, "AUD": 35, "CAD": 35, "NZD": 40, | |
| "BRL": 130, "XAF": 15000, "XOF": 15000, "ZWG": 350, | |
| } | |
| def detect_country_from_env(): | |
| """Best-effort country detection from environment variables.""" | |
| candidates = [ | |
| os.environ.get("COUNTRY_CODE"), | |
| os.environ.get("CF-IPCOUNTRY"), | |
| os.environ.get("GEOIP_COUNTRY"), | |
| os.environ.get("HTTP_CF_IPCOUNTRY"), | |
| ] | |
| for value in candidates: | |
| if value: | |
| value = value.strip().upper() | |
| if value in CURRENCIES: | |
| return value | |
| return None | |
| def get_country_info(country_code): | |
| """Get currency info for a country code.""" | |
| if country_code in CURRENCIES: | |
| name, symbol, code = CURRENCIES[country_code] | |
| return country_code, symbol, code | |
| return "NG", "₦", "NGN" | |
| def get_default_price(currency_code): | |
| return DEFAULT_PRICES.get(currency_code, 24000) | |
| COUNTRY_LIST = sorted([(code, f"{name} ({symbol})") for code, (name, symbol, curr) in CURRENCIES.items()]) | |
| # ============================================================ | |
| # DATABASE | |
| # ============================================================ | |
| def init_db(): | |
| conn = sqlite3.connect(DB_PATH) | |
| cur = conn.cursor() | |
| cur.execute(""" | |
| CREATE TABLE IF NOT EXISTS leads ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| email TEXT UNIQUE, | |
| country TEXT, | |
| currency TEXT, | |
| breed TEXT, | |
| created_at TEXT | |
| ) | |
| """) | |
| cur.execute(""" | |
| CREATE TABLE IF NOT EXISTS farmer_questions ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| email TEXT, | |
| question TEXT, | |
| breed TEXT, | |
| created_at TEXT | |
| ) | |
| """) | |
| cur.execute(""" | |
| CREATE TABLE IF NOT EXISTS calculations ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| email TEXT, | |
| country TEXT, | |
| currency TEXT, | |
| breed TEXT, | |
| birds INTEGER, | |
| weeks INTEGER, | |
| days INTEGER, | |
| total_days INTEGER, | |
| bag_size REAL, | |
| price_per_bag REAL, | |
| total_feed_kg REAL, | |
| starter_kg REAL, | |
| finisher_kg REAL, | |
| starter_bags REAL, | |
| finisher_bags REAL, | |
| total_bags REAL, | |
| rounded_bags INTEGER, | |
| total_cost REAL, | |
| created_at TEXT | |
| ) | |
| """) | |
| cur.execute(""" | |
| CREATE TABLE IF NOT EXISTS analytics ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| visit_date TEXT, | |
| ip TEXT, | |
| country TEXT, | |
| breed TEXT, | |
| action TEXT, | |
| created_at TEXT | |
| ) | |
| """) | |
| # Backward-compatible analytics migration | |
| analytics_columns = { | |
| "referrer": "TEXT", | |
| "utm_source": "TEXT", | |
| "utm_medium": "TEXT", | |
| "utm_campaign": "TEXT", | |
| "utm_content": "TEXT", | |
| "utm_term": "TEXT", | |
| } | |
| cur.execute("PRAGMA table_info(analytics)") | |
| existing_columns = {row[1] for row in cur.fetchall()} | |
| for column, column_type in analytics_columns.items(): | |
| if column not in existing_columns: | |
| cur.execute( | |
| f"ALTER TABLE analytics ADD COLUMN {column} {column_type}" | |
| ) | |
| conn.commit() | |
| conn.close() | |
| print(f"✅ Database initialized at: {DB_PATH}") | |
| init_db() | |
| # ============================================================ | |
| # DATABASE HELPERS | |
| # ============================================================ | |
| def valid_email(email): | |
| if not email: | |
| return False | |
| email = email.strip() | |
| pattern = r"^[^@\s]+@[^@\s]+\.[^@\s]+$" | |
| return bool(re.match(pattern, email)) | |
| def save_lead(email, country, currency, breed): | |
| if not valid_email(email): | |
| return | |
| # Save to SQLite | |
| conn = sqlite3.connect(DB_PATH) | |
| cur = conn.cursor() | |
| cur.execute(""" | |
| INSERT OR IGNORE INTO leads (email, country, currency, breed, created_at) | |
| VALUES (?, ?, ?, ?, ?) | |
| """, (email.strip().lower(), country, currency, breed, datetime.now(timezone.utc).isoformat())) | |
| conn.commit() | |
| conn.close() | |
| # Sync to Google Sheets | |
| try: | |
| sync_to_google_sheets(email, country, currency, breed, "lead") | |
| except Exception as e: | |
| print(f"Google Sheets sync error: {e}") | |
| def save_question(email, question, breed): | |
| if not question or not question.strip(): | |
| return | |
| # Save to SQLite | |
| conn = sqlite3.connect(DB_PATH) | |
| cur = conn.cursor() | |
| cur.execute(""" | |
| INSERT INTO farmer_questions (email, question, breed, created_at) | |
| VALUES (?, ?, ?, ?) | |
| """, (email.strip().lower() if email else "", question.strip(), breed, datetime.now(timezone.utc).isoformat())) | |
| conn.commit() | |
| conn.close() | |
| # Sync to Google Sheets | |
| try: | |
| sync_to_google_sheets(email, "", "", breed, "question", question) | |
| except Exception as e: | |
| print(f"Google Sheets sync error: {e}") | |
| def save_calculation(email, country, currency, breed, birds, weeks, days, total_days, | |
| bag_size, price_per_bag, total_feed_kg, starter_kg, finisher_kg, | |
| starter_bags, finisher_bags, total_bags, rounded_bags, total_cost): | |
| conn = sqlite3.connect(DB_PATH) | |
| cur = conn.cursor() | |
| cur.execute(""" | |
| INSERT INTO calculations ( | |
| email, country, currency, breed, birds, weeks, days, total_days, | |
| bag_size, price_per_bag, total_feed_kg, starter_kg, finisher_kg, | |
| starter_bags, finisher_bags, total_bags, rounded_bags, total_cost, created_at | |
| ) | |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| """, (email.strip().lower() if email else "", country, currency, breed, birds, weeks, days, total_days, | |
| bag_size, price_per_bag, total_feed_kg, starter_kg, finisher_kg, | |
| starter_bags, finisher_bags, total_bags, rounded_bags, total_cost, | |
| datetime.now(timezone.utc).isoformat())) | |
| conn.commit() | |
| conn.close() | |
| def track_visit(request: gr.Request): | |
| """Track page visit, country, referrer and UTM campaign data.""" | |
| try: | |
| client_ip = ( | |
| request.client.host | |
| if request and hasattr(request, "client") | |
| else "unknown" | |
| ) | |
| headers = request.headers if request and hasattr(request, "headers") else {} | |
| # Referrer | |
| referrer = ( | |
| headers.get("referer") | |
| or headers.get("referrer") | |
| or "" | |
| ) | |
| # UTM parameters | |
| query_params = {} | |
| if request and hasattr(request, "query_params"): | |
| try: | |
| query_params = dict(request.query_params) | |
| except Exception: | |
| query_params = {} | |
| utm_source = query_params.get("utm_source", "") | |
| utm_medium = query_params.get("utm_medium", "") | |
| utm_campaign = query_params.get("utm_campaign", "") | |
| utm_content = query_params.get("utm_content", "") | |
| utm_term = query_params.get("utm_term", "") | |
| country = detect_country_from_env() or "NG" | |
| conn = sqlite3.connect(DB_PATH) | |
| cur = conn.cursor() | |
| cur.execute(""" | |
| INSERT INTO analytics ( | |
| visit_date, | |
| ip, | |
| country, | |
| action, | |
| created_at, | |
| referrer, | |
| utm_source, | |
| utm_medium, | |
| utm_campaign, | |
| utm_content, | |
| utm_term | |
| ) | |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| """, ( | |
| datetime.now().strftime("%Y-%m-%d"), | |
| client_ip, | |
| country, | |
| "page_visit", | |
| datetime.now(timezone.utc).isoformat(), | |
| referrer, | |
| utm_source, | |
| utm_medium, | |
| utm_campaign, | |
| utm_content, | |
| utm_term, | |
| )) | |
| conn.commit() | |
| conn.close() | |
| except Exception as e: | |
| print(f"Analytics error: {e}") | |
| def track_calculation(breed, birds, days): | |
| """Track when a calculation is performed.""" | |
| try: | |
| conn = sqlite3.connect(DB_PATH) | |
| cur = conn.cursor() | |
| cur.execute(""" | |
| INSERT INTO analytics (visit_date, country, breed, action, created_at) | |
| VALUES (?, ?, ?, ?, ?) | |
| """, ( | |
| datetime.now().strftime("%Y-%m-%d"), | |
| detect_country_from_env() or "NG", | |
| breed, | |
| f"calculation_{birds}_{days}", | |
| datetime.now().isoformat() | |
| )) | |
| conn.commit() | |
| conn.close() | |
| return True | |
| except: | |
| return False | |
| def get_analytics_summary(): | |
| """Get analytics summary.""" | |
| conn = sqlite3.connect(DB_PATH) | |
| cur = conn.cursor() | |
| cur.execute("SELECT COUNT(*) FROM analytics WHERE action = 'page_visit'") | |
| total_visits = cur.fetchone()[0] or 0 | |
| cur.execute("SELECT COUNT(*) FROM analytics WHERE action = 'page_visit' AND visit_date = ?", | |
| (datetime.now().strftime("%Y-%m-%d"),)) | |
| today_visits = cur.fetchone()[0] or 0 | |
| cur.execute("SELECT COUNT(*) FROM analytics WHERE action LIKE 'calculation_%'") | |
| total_calculations = cur.fetchone()[0] or 0 | |
| cur.execute("SELECT COUNT(DISTINCT ip) FROM analytics WHERE action = 'page_visit' AND ip != 'unknown'") | |
| unique_visitors = cur.fetchone()[0] or 0 | |
| cur.execute("SELECT breed, COUNT(*) FROM analytics WHERE action LIKE 'calculation_%' GROUP BY breed") | |
| breed_stats = cur.fetchall() | |
| # Total leads | |
| cur.execute("SELECT COUNT(*) FROM leads") | |
| total_leads = cur.fetchone()[0] or 0 | |
| conn.close() | |
| return { | |
| "total_visits": total_visits, | |
| "today_visits": today_visits, | |
| "unique_visitors": unique_visitors, | |
| "total_calculations": total_calculations, | |
| "total_leads": total_leads, | |
| "breed_stats": breed_stats | |
| } | |
| # ============================================================ | |
| # SHARE LINKS GENERATOR | |
| # ============================================================ | |
| def get_share_links(app_url="https://howmuchfeed.com"): | |
| """Generate share links for WhatsApp, Email, Facebook, Twitter.""" | |
| share_text = f"🐔 Calculate your poultry feed needs at {app_url}" | |
| encoded = urllib.parse.quote(share_text) | |
| links = { | |
| "whatsapp": f"https://wa.me/?text={encoded}", | |
| "email": f"mailto:?subject=How Much Feed? - Poultry Feed Calculator&body={encoded}", | |
| "facebook": f"https://www.facebook.com/sharer/sharer.php?u={urllib.parse.quote(app_url)}", | |
| "twitter": f"https://twitter.com/intent/tweet?text={encoded}", | |
| } | |
| return links | |
| # ============================================================ | |
| # FEED CALCULATION - MINT GREEN & EGG YELLOW DESIGN | |
| # ============================================================ | |
| def calculate_feed(birds, weeks, days, bag_size, price_per_bag, email, country_code, breed, request: gr.Request): | |
| # Validation | |
| try: | |
| birds = int(birds) | |
| weeks = int(weeks) | |
| days = int(days) | |
| bag_size = float(bag_size) | |
| price_per_bag = float(price_per_bag) | |
| except Exception: | |
| return "⚠️ Please enter valid numbers.", "" | |
| if birds <= 0: | |
| return "⚠️ Number of birds must be greater than zero.", "" | |
| if weeks < 0: | |
| return "⚠️ Weeks cannot be negative.", "" | |
| if days < 0 or days > 6: | |
| return "⚠️ Days must be between 0 and 6.", "" | |
| if bag_size <= 0: | |
| return "⚠️ Feed bag size must be greater than zero.", "" | |
| if price_per_bag < 0: | |
| return "⚠️ Price per bag cannot be negative.", "" | |
| if not valid_email(email): | |
| return "📧 **Email required**\n\nPlease enter your email address before calculating.", "" | |
| # Country / Currency | |
| country_name, symbol, currency_code = get_country_info(country_code) | |
| currency_symbol = symbol | |
| # Save lead with breed | |
| save_lead(email, country_code, currency_code, breed) | |
| # Track calculation | |
| track_calculation(breed, birds, (weeks * 7 + days)) | |
| # Total days | |
| total_days = (weeks * 7) + days | |
| # Total feed | |
| total_feed_per_bird = interpolate_feed(total_days, breed) | |
| total_feed_kg = birds * total_feed_per_bird | |
| # Starter / Finisher | |
| starter_days = min(total_days, STARTER_END_DAY) | |
| starter_per_bird = interpolate_feed(starter_days, breed) | |
| starter_kg = birds * starter_per_bird | |
| finisher_kg = max(total_feed_kg - starter_kg, 0) | |
| # Bags | |
| starter_bags = starter_kg / bag_size | |
| finisher_bags = finisher_kg / bag_size | |
| total_bags = total_feed_kg / bag_size | |
| rounded_bags = math.ceil(total_bags) | |
| total_cost = rounded_bags * price_per_bag | |
| # Expected performance | |
| expected_weight = interpolate_weight(total_days, breed) | |
| # Save calculation | |
| save_calculation(email, country_code, currency_code, breed, birds, weeks, days, total_days, | |
| bag_size, price_per_bag, total_feed_kg, starter_kg, finisher_kg, | |
| starter_bags, finisher_bags, total_bags, rounded_bags, total_cost) | |
| # Breed info | |
| breed_info = BREED_INFO.get(breed, BREED_INFO["Ross 308"]) | |
| breed_color = breed_info["color"] | |
| # Generate share links | |
| share_links = get_share_links() | |
| # Output HTML - mobile-first farmer-friendly result | |
| starter_bags_display = f"{starter_bags:.1f}" | |
| finisher_bags_display = f"{finisher_bags:.1f}" | |
| age_text = f"{weeks} week{'s' if weeks != 1 else ''}" | |
| if days: | |
| age_text += f" + {days} day{'s' if days != 1 else ''}" | |
| share_text = ( | |
| f"How Much Feed?\n" | |
| f"{breed} broilers: {birds:,} birds for {age_text}.\n" | |
| f"Feed needed: {total_feed_kg:,.1f} kg ({total_bags:.1f} bags).\n" | |
| f"Buy {rounded_bags} bags." | |
| ) | |
| share_encoded = urllib.parse.quote(share_text) | |
| share_links = { | |
| "whatsapp": f"https://wa.me/?text={share_encoded}", | |
| "email": f"mailto:?subject=My How Much Feed result&body={share_encoded}", | |
| "facebook": f"https://www.facebook.com/sharer/sharer.php?u={urllib.parse.quote('https://howmuchfeed.com')}", | |
| "twitter": f"https://twitter.com/intent/tweet?text={share_encoded}", | |
| } | |
| html = f""" | |
| <div class="results-shell"> | |
| <section class="buy-card"> | |
| <div class="eyebrow">YOUR FEED RESULT</div> | |
| <div class="buy-label">BUY</div> | |
| <div class="buy-number">{rounded_bags}</div> | |
| <div class="buy-unit">BAGS</div> | |
| <div class="buy-note">Recommended amount, rounded up to the next whole bag.</div> | |
| </section> | |
| <div class="result-context"> | |
| <strong>{birds:,} {breed} broilers</strong> · {age_text} ({total_days} days) | |
| </div> | |
| <p class="plain-answer"> | |
| You need <strong>{total_feed_kg:,.1f} kg</strong> of feed, equal to about | |
| <strong>{total_bags:.1f} bags</strong> of {bag_size:g} kg feed. | |
| </p> | |
| <div class="metric-grid"> | |
| <div class="metric-card"> | |
| <span class="metric-icon">⚖️</span> | |
| <span class="metric-label">Feed needed</span> | |
| <strong>{total_feed_kg:,.1f} kg</strong> | |
| </div> | |
| <div class="metric-card"> | |
| <span class="metric-icon">✓</span> | |
| <span class="metric-label">Exact amount</span> | |
| <strong>{total_bags:.1f} bags</strong> | |
| </div> | |
| <div class="metric-card"> | |
| <span class="metric-icon">₦</span> | |
| <span class="metric-label">Estimated cost</span> | |
| <strong>{currency_symbol}{total_cost:,.0f}</strong> | |
| </div> | |
| <div class="metric-card"> | |
| <span class="metric-icon">📅</span> | |
| <span class="metric-label">Growing period</span> | |
| <strong>{total_days} days</strong> | |
| </div> | |
| </div> | |
| <section class="result-section"> | |
| <div class="section-title">🌾 Feed Breakdown</div> | |
| <div class="table-wrap"> | |
| <table class="feed-table"> | |
| <thead> | |
| <tr><th>Stage</th><th>Feed</th><th>Bags</th></tr> | |
| </thead> | |
| <tbody> | |
| <tr><td>Starter <span class="muted">(0–21 days)</span></td><td>{starter_kg:,.1f} kg</td><td>{starter_bags_display}</td></tr> | |
| <tr><td>Finisher <span class="muted">(Day 22+)</span></td><td>{finisher_kg:,.1f} kg</td><td>{finisher_bags_display}</td></tr> | |
| <tr class="total-row"><td>TOTAL</td><td>{total_feed_kg:,.1f} kg</td><td>{total_bags:.1f} bags</td></tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </section> | |
| <section class="cost-card"> | |
| <div class="section-title">💰 Feed Cost Estimate</div> | |
| <div class="cost-main">{currency_symbol}{total_cost:,.0f}</div> | |
| <div class="cost-note">Based on {rounded_bags} bags × {currency_symbol}{price_per_bag:,.0f} per bag.</div> | |
| </section> | |
| <section class="result-section performance-section"> | |
| <div class="section-title">📈 Expected Performance</div> | |
| <div class="performance-grid"> | |
| <div><span>Body weight per bird</span><strong>{expected_weight:.3f} kg</strong></div> | |
| </div> | |
| </section> | |
| <section class="result-section share-section"> | |
| <div class="section-title">📤 Share Result</div> | |
| <div class="share-grid"> | |
| <a class="share-btn whatsapp" href="{share_links['whatsapp']}" target="_blank" rel="noopener">WhatsApp</a> | |
| <a class="share-btn email" href="{share_links['email']}" target="_blank" rel="noopener">Email</a> | |
| <a class="share-btn facebook" href="{share_links['facebook']}" target="_blank" rel="noopener">Facebook</a> | |
| <a class="share-btn twitter" href="{share_links['twitter']}" target="_blank" rel="noopener">X</a> | |
| </div> | |
| </section> | |
| <div class="result-disclaimer"> | |
| ⚠️ This is an estimate based on the selected {breed} benchmark. Actual feed use can vary with feed quality, management, environment and bird performance. | |
| </div> | |
| </div> | |
| """ | |
| return html, f"✅ Saved for {email.strip().lower()}." | |
| # ============================================================ | |
| # QUESTION / ANSWER | |
| # ============================================================ | |
| def answer_farmer_question(question, email, breed): | |
| if not question or not question.strip(): | |
| return "Please type or speak your question first." | |
| save_question(email or "", question, breed) | |
| q = question.lower().strip() | |
| if "bag" in q and ("feed" in q or "birds" in q): | |
| answer = """ | |
| ### Feed-bag questions | |
| For an exact feed estimate, use the calculator above. | |
| Enter: | |
| 1. Number of birds | |
| 2. Age in weeks + days | |
| 3. Feed bag size | |
| 4. Price per bag | |
| 5. Your email | |
| 6. Breed | |
| The calculator will give you the estimated total bags, starter/finisher breakdown and feed cost. | |
| """ | |
| elif "breed" in q or "ross" in q or "cobb" in q or "arbor" in q: | |
| answer = """ | |
| ### Common Broiler Breeds in Nigeria | |
| | Breed | Characteristics | Available At | | |
| |-------|-----------------|--------------| | |
| | **Ross 308** | Big chest, thick legs, very fast growth | Agrited, Sayed, Valentine | | |
| | **Cobb 500** | Good early growth, strong legs | Chikun, Zartech | | |
| | **Arbor Acres** | Balanced growth, hardy, adaptable | Supreme, CHI, GS, Vertex, Fidan, Cascada, Orobor, Sunchi, Farm Support, Ajila | | |
| Tip: Before buying chicks, ask about the breed and hatchery. Not all broilers are the same! | |
| """ | |
| elif "starter" in q: | |
| answer = f""" | |
| ### Starter feed | |
| Starter feed is used during the early growth period (Day 0–{STARTER_END_DAY}). | |
| The calculator separates the estimated feed requirement into **Starter** and **Finisher** based on the selected age. | |
| """ | |
| elif "finisher" in q: | |
| answer = f""" | |
| ### Finisher feed | |
| Finisher feed is used after day {STARTER_END_DAY}. | |
| Your calculator automatically estimates the finisher requirement from the selected number of birds and total age. | |
| """ | |
| elif "cost" in q or "price" in q: | |
| answer = """ | |
| ### Feed cost | |
| Enter the current price of one feed bag in the calculator. | |
| The app calculates: **Number of bags × price per bag = estimated feed cost** | |
| The result is for **feed alone**. | |
| """ | |
| else: | |
| answer = f""" | |
| ### Question received | |
| **“{question.strip()}”** | |
| Your question has been saved. | |
| We can use the questions farmers submit to decide which answers, calculators and features should be added to the app next. | |
| """ | |
| return answer | |
| # ============================================================ | |
| # UPDATE FUNCTIONS | |
| # ============================================================ | |
| def update_benchmark(breed): | |
| """Update the benchmark table when breed changes.""" | |
| return get_benchmark_html(breed) | |
| def update_selected_breed_badge(breed): | |
| """Update the always-visible selected-breed badge.""" | |
| safe_breed = breed or "Ross 308" | |
| return f'<span class="selected-breed-badge">{safe_breed}</span>' | |
| def on_country_change_handler(country_selection): | |
| """Update country state and default feed price when country changes.""" | |
| if country_selection == "Auto-Detect": | |
| country_code = detect_country_from_env() or "NG" | |
| else: | |
| country_code = country_selection | |
| _, symbol, currency_code = get_country_info(country_code) | |
| default_price = get_default_price(currency_code) | |
| return country_code, default_price | |
| # ============================================================ | |
| # ADMIN FUNCTIONS | |
| # ============================================================ | |
| def show_analytics(password): | |
| """Display analytics summary - only with correct password.""" | |
| if password != ADMIN_PASSWORD: | |
| return """ | |
| ### Admin Access Required | |
| Please enter the correct admin password to view analytics. | |
| """ | |
| stats = get_analytics_summary() | |
| breed_stats_html = "" | |
| for breed, count in stats["breed_stats"]: | |
| breed_stats_html += f"<li><strong>{breed}</strong>: {count} calculations</li>" | |
| if not breed_stats_html: | |
| breed_stats_html = "<li>No calculations yet</li>" | |
| return f""" | |
| ### How Much Feed? - Analytics | |
| | Metric | Value | | |
| |--------|-------| | |
| | **Total Visits** | **{stats['total_visits']:,}** | | |
| | **Today's Visits** | **{stats['today_visits']}** | | |
| | **Unique Visitors** | **{stats['unique_visitors']:,}** | | |
| | **Total Calculations** | **{stats['total_calculations']:,}** | | |
| | **Total Leads (Emails)** | **{stats['total_leads']:,}** | | |
| ### Most Popular Breeds | |
| <ul>{breed_stats_html}</ul> | |
| --- | |
| *Last updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}* | |
| """ | |
| def admin_login(password): | |
| """Check admin password and return the dashboard text.""" | |
| return show_analytics(password) | |
| def export_leads_to_csv(): | |
| """Export all leads to CSV format.""" | |
| conn = sqlite3.connect(DB_PATH) | |
| cur = conn.cursor() | |
| cur.execute("SELECT email, country, currency, breed, created_at FROM leads ORDER BY created_at DESC") | |
| rows = cur.fetchall() | |
| conn.close() | |
| import csv | |
| from io import StringIO | |
| output = StringIO() | |
| writer = csv.writer(output) | |
| writer.writerow(["Email", "Country", "Currency", "Breed", "Created At"]) | |
| writer.writerows(rows) | |
| return output.getvalue() | |
| def export_leads_wrapper(): | |
| """Export leads to CSV file for download.""" | |
| import tempfile | |
| csv_data = export_leads_to_csv() | |
| temp = tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False, encoding='utf-8') | |
| temp.write(csv_data) | |
| temp.close() | |
| return temp.name | |
| def export_calculations_wrapper(): | |
| """Export calculations to CSV file for download.""" | |
| conn = sqlite3.connect(DB_PATH) | |
| cur = conn.cursor() | |
| cur.execute(""" | |
| SELECT email, breed, birds, total_days, rounded_bags, bag_size, total_cost, created_at | |
| FROM calculations | |
| ORDER BY created_at DESC | |
| """) | |
| rows = cur.fetchall() | |
| conn.close() | |
| import csv | |
| import tempfile | |
| temp = tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False, encoding='utf-8') | |
| writer = csv.writer(temp) | |
| writer.writerow(["Email", "Breed", "Birds", "Total Days", "Bags", "Bag Size (kg)", "Total Cost", "Created At"]) | |
| writer.writerows(rows) | |
| temp.close() | |
| return temp.name | |
| # ============================================================ | |
| # CSS - MINT GREEN & EGG YELLOW DESIGN SYSTEM | |
| # ============================================================ | |
| CSS = """ | |
| :root { | |
| --app-bg-main: #f4f7f5; | |
| --app-card-surface: #ffffff; | |
| --brand-primary-mint: #00a86b; | |
| --brand-dark-charcoal: #0b3022; | |
| --brand-accent-yellow: #ffb800; | |
| --ui-border-muted: #d6e0db; | |
| --text-helper: #5a6e65; | |
| /* Compatibility aliases used throughout the app */ | |
| --mint: var(--brand-primary-mint); | |
| --mint-dark: var(--brand-dark-charcoal); | |
| --mint-soft: #eaf8f1; | |
| --yellow: var(--brand-accent-yellow); | |
| --yellow-soft: #fff7db; | |
| --surface: var(--app-card-surface); | |
| --page: var(--app-bg-main); | |
| --border: var(--ui-border-muted); | |
| --muted: var(--text-helper); | |
| --danger: #b42318; | |
| } | |
| /* ============================================================ | |
| HIDE HUGGING FACE SPACE HEADER | |
| ============================================================ */ | |
| #huggingface-space-header, | |
| #space-header__collapse, | |
| .hf-header, | |
| [data-testid="header"], | |
| .huggingface-space-header { | |
| display: none !important; | |
| visibility: hidden !important; | |
| height: 0 !important; | |
| min-height: 0 !important; | |
| max-height: 0 !important; | |
| padding: 0 !important; | |
| margin: 0 !important; | |
| border: 0 !important; | |
| overflow: hidden !important; | |
| pointer-events: none !important; | |
| } | |
| /* ========================= | |
| GLOBAL MOBILE-FIRST UI | |
| ========================= */ | |
| html, body { | |
| margin: 0 !important; | |
| padding: 0 !important; | |
| background: var(--page) !important; | |
| color: var(--mint-dark) !important; | |
| color-scheme: light !important; | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif !important; | |
| -webkit-text-size-adjust: 100%; | |
| } | |
| body, | |
| .gradio-container, | |
| .gradio-container > .main, | |
| .gradio-container .block, | |
| .gradio-container .gr-group, | |
| .gradio-container .gr-box, | |
| .gradio-container .gr-panel { | |
| background: var(--page) !important; | |
| color: var(--mint-dark) !important; | |
| } | |
| .gradio-container { | |
| width: 100% !important; | |
| max-width: 1180px !important; | |
| margin: 0 auto !important; | |
| padding: 0 12px 48px !important; | |
| box-sizing: border-box !important; | |
| color-scheme: light !important; | |
| --body-background-fill: #f4f7f5 !important; | |
| --body-background-fill-dark: #f4f7f5 !important; | |
| --body-text-color: #0b3022 !important; | |
| --body-text-color-dark: #0b3022 !important; | |
| --body-text-color-subdued: #5a6e65 !important; | |
| --body-text-color-subdued-dark: #5a6e65 !important; | |
| --background-fill-primary: #ffffff !important; | |
| --background-fill-primary-dark: #ffffff !important; | |
| --background-fill-secondary: #f4f7f5 !important; | |
| --background-fill-secondary-dark: #f4f7f5 !important; | |
| --border-color-primary: #d6e0db !important; | |
| --border-color-primary-dark: #d6e0db !important; | |
| } | |
| /* Never force a dark theme onto the mobile browser. */ | |
| .dark body, | |
| .dark .gradio-container, | |
| .dark .gradio-container .block, | |
| .dark .gradio-container .gr-group, | |
| .dark .gradio-container .gr-box, | |
| .dark .gradio-container .gr-panel { | |
| background: var(--page) !important; | |
| color: var(--mint-dark) !important; | |
| } | |
| h1, h2, h3, h4, p, label, span, div { | |
| color: inherit; | |
| } | |
| /* ========================= | |
| HEADER | |
| ========================= */ | |
| .app-header { | |
| background: var(--mint-dark); | |
| color: #fff; | |
| border-radius: 0 0 18px 18px; | |
| padding: 18px 16px 20px; | |
| margin: 0 -12px 16px; | |
| } | |
| .app-header .brand-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .app-header .brand-icon { font-size: 30px; line-height: 1; } | |
| .app-header h1 { | |
| color: #fff !important; | |
| margin: 0; | |
| font-size: clamp(23px, 6vw, 32px); | |
| line-height: 1.1; | |
| font-weight: 800; | |
| } | |
| .app-header p { | |
| color: rgba(255,255,255,.88) !important; | |
| margin: 6px 0 0 40px; | |
| font-size: 14px; | |
| line-height: 1.45; | |
| } | |
| /* ========================= | |
| STATUS / FORM SECTIONS | |
| ========================= */ | |
| .status-card { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-left: 4px solid var(--mint); | |
| border-radius: 12px; | |
| padding: 12px 14px; | |
| margin-bottom: 12px; | |
| font-size: 13px; | |
| } | |
| .status-card strong { color: var(--mint-dark) !important; } | |
| .status-card a { color: var(--mint) !important; font-weight: 700; } | |
| .form-section { | |
| background: var(--surface) !important; | |
| border: 1px solid var(--border) !important; | |
| border-radius: 14px !important; | |
| margin-bottom: 12px !important; | |
| overflow: visible !important; | |
| padding: 14px !important; | |
| box-shadow: 0 2px 8px rgba(11,48,34,.04) !important; | |
| } | |
| .section-heading { | |
| display: flex; | |
| align-items: center; | |
| gap: 9px; | |
| margin: 0 0 12px; | |
| color: var(--mint-dark) !important; | |
| font-size: 17px; | |
| font-weight: 800; | |
| } | |
| .step-number { | |
| width: 28px; | |
| height: 28px; | |
| border-radius: 50%; | |
| background: var(--mint-dark); | |
| color: #fff !important; | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 13px; | |
| font-weight: 800; | |
| flex: 0 0 28px; | |
| } | |
| /* Gradio field controls */ | |
| .gradio-container input, | |
| .gradio-container textarea, | |
| .gradio-container select, | |
| .gradio-container [role="combobox"] { | |
| background: #fff !important; | |
| color: var(--mint-dark) !important; | |
| border: 1px solid #C8D5CE !important; | |
| border-radius: 10px !important; | |
| min-height: 48px !important; | |
| box-sizing: border-box !important; | |
| font-size: 16px !important; | |
| } | |
| .gradio-container input:focus, | |
| .gradio-container textarea:focus, | |
| .gradio-container select:focus, | |
| .gradio-container [role="combobox"]:focus-within { | |
| border-color: var(--mint) !important; | |
| box-shadow: 0 0 0 3px rgba(0,168,107,.16) !important; | |
| } | |
| .gradio-container label { | |
| color: var(--mint-dark) !important; | |
| font-weight: 650 !important; | |
| } | |
| .gradio-container .info, | |
| .gradio-container small { | |
| color: var(--muted) !important; | |
| } | |
| /* Give Weeks and Days a real touch gap on mobile. */ | |
| .age-row, | |
| .feed-row { | |
| gap: 10px !important; | |
| } | |
| .age-row > *, | |
| .feed-row > * { | |
| min-width: 0 !important; | |
| } | |
| .email-box textarea, | |
| .email-box input { | |
| background: #fff !important; | |
| border: 2px solid var(--mint) !important; | |
| } | |
| /* ========================= | |
| PRIMARY ACTION | |
| ========================= */ | |
| .calculate-btn button { | |
| width: 100% !important; | |
| min-height: 52px !important; | |
| border: 0 !important; | |
| border-radius: 12px !important; | |
| background: var(--mint) !important; | |
| color: #fff !important; | |
| font-size: 16px !important; | |
| font-weight: 800 !important; | |
| box-shadow: 0 5px 14px rgba(0,168,107,.20) !important; | |
| } | |
| .calculate-btn button:hover { background: #008F5A !important; } | |
| /* ========================= | |
| RESULT HTML | |
| ========================= */ | |
| .results-shell { | |
| width: 100%; | |
| box-sizing: border-box; | |
| color: var(--mint-dark); | |
| } | |
| .buy-card { | |
| background: linear-gradient(145deg, #F1FBF6 0%, #FFFFFF 100%); | |
| border: 2px solid var(--mint); | |
| border-radius: 16px; | |
| padding: 18px 16px; | |
| text-align: center; | |
| box-shadow: 0 5px 16px rgba(11,48,34,.08); | |
| } | |
| .eyebrow { | |
| color: var(--mint) !important; | |
| font-size: 12px; | |
| font-weight: 800; | |
| letter-spacing: .09em; | |
| } | |
| .buy-label { | |
| margin-top: 7px; | |
| color: var(--mint-dark) !important; | |
| font-size: 13px; | |
| font-weight: 800; | |
| } | |
| .buy-number { | |
| color: var(--mint) !important; | |
| font-size: clamp(62px, 18vw, 92px); | |
| font-weight: 900; | |
| line-height: .9; | |
| margin-top: 2px; | |
| } | |
| .buy-unit { | |
| color: var(--mint-dark) !important; | |
| font-size: 21px; | |
| font-weight: 900; | |
| letter-spacing: .08em; | |
| } | |
| .buy-note { | |
| margin-top: 8px; | |
| color: var(--muted) !important; | |
| font-size: 12px; | |
| } | |
| .result-context { | |
| text-align: center; | |
| margin: 12px 2px 6px; | |
| color: var(--muted) !important; | |
| font-size: 13px; | |
| } | |
| .result-context strong { color: var(--mint-dark) !important; } | |
| .plain-answer { | |
| background: #fff; | |
| border-left: 4px solid var(--yellow); | |
| border-radius: 10px; | |
| padding: 11px 12px; | |
| margin: 10px 0 12px; | |
| font-size: 14px; | |
| line-height: 1.5; | |
| color: var(--mint-dark) !important; | |
| } | |
| .plain-answer strong { color: var(--mint-dark) !important; } | |
| .metric-grid { | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(0,1fr)); | |
| gap: 9px; | |
| margin-bottom: 14px; | |
| } | |
| .metric-card { | |
| background: #fff; | |
| border: 1px solid var(--border); | |
| border-radius: 12px; | |
| padding: 11px 9px; | |
| min-width: 0; | |
| } | |
| .metric-icon { | |
| display: block; | |
| color: var(--mint) !important; | |
| font-size: 18px; | |
| font-weight: 900; | |
| } | |
| .metric-label { | |
| display: block; | |
| color: var(--muted) !important; | |
| font-size: 11px; | |
| margin: 2px 0 3px; | |
| } | |
| .metric-card strong { | |
| display: block; | |
| color: var(--mint-dark) !important; | |
| font-size: 17px; | |
| line-height: 1.2; | |
| overflow-wrap: anywhere; | |
| } | |
| .result-section { margin: 14px 0; } | |
| .section-title { | |
| color: var(--mint-dark) !important; | |
| font-size: 14px; | |
| font-weight: 850; | |
| margin-bottom: 8px; | |
| } | |
| .table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; } | |
| .feed-table { | |
| width: 100%; | |
| min-width: 330px; | |
| border-collapse: separate; | |
| border-spacing: 0; | |
| background: #fff; | |
| border: 1px solid var(--border); | |
| border-radius: 11px; | |
| overflow: hidden; | |
| font-size: 12px; | |
| } | |
| .feed-table th { | |
| background: var(--mint) !important; | |
| color: #fff !important; | |
| padding: 9px 8px; | |
| text-align: left; | |
| font-weight: 800; | |
| } | |
| .feed-table th:not(:first-child), | |
| .feed-table td:not(:first-child) { text-align: right; } | |
| .feed-table td { | |
| padding: 9px 8px; | |
| border-top: 1px solid var(--border); | |
| color: var(--mint-dark) !important; | |
| white-space: nowrap; | |
| } | |
| .feed-table .muted { color: var(--muted) !important; font-size: 10px; } | |
| .feed-table .total-row td { | |
| background: var(--mint-soft) !important; | |
| font-weight: 900 !important; | |
| } | |
| .feed-table .total-row td:last-child { font-size: 14px; } | |
| .cost-card { | |
| background: var(--yellow-soft); | |
| border: 1px solid #EBC34A; | |
| border-left: 4px solid var(--yellow); | |
| border-radius: 12px; | |
| padding: 13px 14px; | |
| margin: 14px 0; | |
| } | |
| .cost-main { | |
| color: var(--mint-dark) !important; | |
| font-size: clamp(27px, 8vw, 38px); | |
| line-height: 1.1; | |
| font-weight: 900; | |
| } | |
| .cost-note { color: var(--muted) !important; font-size: 11px; margin-top: 5px; } | |
| .performance-grid { display: grid; gap: 0; background: #fff; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; } | |
| .performance-grid > div { display: flex; justify-content: space-between; gap: 12px; padding: 11px; border-top: 1px solid var(--border); font-size: 13px; } | |
| .performance-grid > div:first-child { border-top: 0; } | |
| .performance-grid span { color: var(--muted) !important; } | |
| .performance-grid strong { color: var(--mint) !important; white-space: nowrap; } | |
| .share-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; } | |
| .share-btn { | |
| display: block; | |
| text-align: center; | |
| text-decoration: none !important; | |
| background: var(--mint); | |
| color: #fff !important; | |
| border-radius: 9px; | |
| padding: 11px 8px; | |
| font-size: 12px; | |
| font-weight: 800; | |
| min-height: 44px; | |
| box-sizing: border-box; | |
| } | |
| .share-btn:hover { background: #008F5A; } | |
| .result-disclaimer { | |
| background: #F1F6F3; | |
| border: 1px solid var(--border); | |
| border-radius: 10px; | |
| padding: 10px 11px; | |
| color: var(--muted) !important; | |
| font-size: 10px; | |
| line-height: 1.45; | |
| margin-top: 14px; | |
| } | |
| /* ========================= | |
| BENCHMARK / REFERENCE CONTENT | |
| ========================= */ | |
| .benchmark-card { | |
| background: var(--app-card-surface); | |
| border: 1px solid var(--ui-border-muted); | |
| border-radius: 14px; | |
| padding: 14px; | |
| margin: 16px 0 12px; | |
| box-shadow: 0 2px 8px rgba(11,48,34,.04); | |
| } | |
| .benchmark-heading { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 12px; | |
| flex-wrap: wrap; | |
| margin-bottom: 10px; | |
| } | |
| .benchmark-title { | |
| color: var(--brand-dark-charcoal) !important; | |
| font-size: 16px; | |
| font-weight: 850; | |
| } | |
| .selected-breed-badge { | |
| display: inline-flex; | |
| align-items: center; | |
| min-height: 34px; | |
| padding: 6px 12px; | |
| border-radius: 999px; | |
| background: var(--brand-dark-charcoal); | |
| color: #fff !important; | |
| font-size: 13px; | |
| font-weight: 800; | |
| white-space: nowrap; | |
| } | |
| .benchmark-help { | |
| color: var(--text-helper) !important; | |
| font-size: 12px; | |
| line-height: 1.45; | |
| margin: 0 0 10px; | |
| } | |
| .benchmark-accordion { margin-top: 10px !important; } | |
| /* Ask a Question stays visible because it is a farmer-facing feature. */ | |
| .question-card { | |
| background: var(--app-card-surface); | |
| border: 1px solid var(--ui-border-muted); | |
| border-radius: 14px; | |
| padding: 14px; | |
| margin: 14px 0; | |
| box-shadow: 0 2px 8px rgba(11,48,34,.04); | |
| } | |
| .question-heading { | |
| color: var(--brand-dark-charcoal) !important; | |
| font-size: 16px; | |
| font-weight: 850; | |
| margin-bottom: 5px; | |
| } | |
| .question-help { | |
| color: var(--text-helper) !important; | |
| font-size: 12px; | |
| margin-bottom: 10px; | |
| } | |
| /* Traditional social colours: recognizable at a glance. */ | |
| .share-btn.whatsapp { background: #25D366 !important; } | |
| .share-btn.email { background: #EA4335 !important; } | |
| .share-btn.facebook { background: #1877F2 !important; } | |
| .share-btn.twitter { background: #000000 !important; } | |
| .share-btn.whatsapp:hover { background: #1ebe5d !important; } | |
| .share-btn.email:hover { background: #d93025 !important; } | |
| .share-btn.facebook:hover { background: #166fe5 !important; } | |
| .share-btn.twitter:hover { background: #222 !important; } | |
| /* Replace Gradio's default blue emphasis with the brand system. */ | |
| .gradio-container button.primary, | |
| .gradio-container button[data-variant="primary"] { | |
| background: var(--brand-primary-mint) !important; | |
| color: #fff !important; | |
| border-color: var(--brand-primary-mint) !important; | |
| } | |
| .gradio-container button.secondary, | |
| .gradio-container button[data-variant="secondary"] { | |
| background: var(--brand-dark-charcoal) !important; | |
| color: #fff !important; | |
| border-color: var(--brand-dark-charcoal) !important; | |
| } | |
| .gradio-container button:focus-visible { | |
| outline: 3px solid rgba(255,184,0,.45) !important; | |
| outline-offset: 2px; | |
| } | |
| /* ========================= | |
| COLLAPSIBLE LOWER CONTENT | |
| ========================= */ | |
| .gradio-container details, | |
| .gradio-container .gr-accordion { | |
| background: #fff !important; | |
| border: 1px solid var(--border) !important; | |
| border-radius: 12px !important; | |
| margin-bottom: 10px !important; | |
| overflow: hidden !important; | |
| } | |
| .gradio-container summary, | |
| .gradio-container .gr-accordion > button { | |
| color: var(--mint-dark) !important; | |
| font-weight: 800 !important; | |
| } | |
| /* Dataframes: allow horizontal scrolling on phones. */ | |
| .benchmark-wrap { overflow-x: auto !important; -webkit-overflow-scrolling: touch !important; } | |
| .benchmark-wrap .table-wrap { overflow-x: auto !important; } | |
| /* ========================= | |
| ADMIN / FOOTER | |
| ========================= */ | |
| .admin-login { | |
| background: #fff !important; | |
| border: 1px solid var(--border) !important; | |
| border-left: 4px solid var(--yellow) !important; | |
| border-radius: 12px !important; | |
| padding: 12px !important; | |
| } | |
| .admin-footer { | |
| margin-top: 24px; | |
| padding-top: 10px; | |
| border-top: 1px solid var(--ui-border-muted); | |
| } | |
| .admin-footer summary, | |
| .admin-footer .gr-accordion > button { | |
| color: var(--text-helper) !important; | |
| font-size: 12px !important; | |
| font-weight: 650 !important; | |
| } | |
| .footer-note { color: var(--muted) !important; font-size: 11px; line-height: 1.5; } | |
| /* ========================= | |
| PERFORMANCE / MOBILE POLISH | |
| ========================= */ | |
| * { box-sizing: border-box; } | |
| img, svg { max-width: 100%; } | |
| button, input, select, textarea { font: inherit; } | |
| @media (prefers-reduced-motion: reduce) { | |
| *, *::before, *::after { | |
| scroll-behavior: auto !important; | |
| animation-duration: 0.01ms !important; | |
| transition-duration: 0.01ms !important; | |
| } | |
| } | |
| .benchmark-html { width: 100%; } | |
| .benchmark-table { min-width: 0; font-size: 11px; } | |
| .benchmark-table th, .benchmark-table td { padding: 8px 7px; } | |
| .comparison-table { min-width: 620px; } | |
| /* Desktop gets breathing room; mobile stays single-column. */ | |
| @media (min-width: 900px) { | |
| .gradio-container { padding: 0 22px 60px !important; } | |
| .app-header { margin-left: -22px; margin-right: -22px; padding-left: 28px; padding-right: 28px; } | |
| .buy-number { font-size: 84px; } | |
| .metric-grid { grid-template-columns: repeat(4,minmax(0,1fr)); } | |
| } | |
| @media (max-width: 480px) { | |
| .gradio-container { padding-left: 10px !important; padding-right: 10px !important; } | |
| .app-header { margin-left: -10px; margin-right: -10px; } | |
| .age-row, .feed-row { gap: 12px !important; } | |
| .metric-grid { gap: 7px; } | |
| .metric-card { padding: 10px 8px; } | |
| .metric-card strong { font-size: 15px; } | |
| .performance-grid > div { font-size: 12px; } | |
| } | |
| """ | |
| # ============================================================ | |
| # UI | |
| # ============================================================ | |
| SOCIAL_PREVIEW_URL = ( | |
| "https://googlecalc-howmuchfeed.hf.space/" | |
| "gradio_api/file=social-preview.jpg" | |
| ) | |
| # ============================================================ | |
| # SOCIAL MEDIA META TAGS | |
| # ============================================================ | |
| SOCIAL_META = """ | |
| <title>How Much Feed? — Broiler Feed Calculator</title> | |
| <meta name="description" content="Calculate how much feed your broilers need, how many bags to buy, and your estimated feed cost."> | |
| <!-- Open Graph / Facebook --> | |
| <meta property="og:title" content="How Much Feed? — Broiler Feed Calculator"> | |
| <meta property="og:description" content="Calculate how much feed your broilers need, how many bags to buy, and your estimated feed cost."> | |
| <meta property="og:image" content="https://huggingface.co/spaces/GoogleCalc/HowMuchFeed/resolve/main/social-preview.jpg"> | |
| <meta property="og:image:width" content="1200"> | |
| <meta property="og:image:height" content="630"> | |
| <meta property="og:url" content="https://www.howmuchfeed.com/"> | |
| <meta property="og:type" content="website"> | |
| <meta property="og:site_name" content="How Much Feed?"> | |
| <!-- Twitter Card --> | |
| <meta name="twitter:card" content="summary_large_image"> | |
| <meta name="twitter:title" content="How Much Feed? — Broiler Feed Calculator"> | |
| <meta name="twitter:description" content="Calculate your broiler feed requirement, bags needed and estimated feed cost."> | |
| <meta name="twitter:image" content="https://huggingface.co/spaces/GoogleCalc/HowMuchFeed/resolve/main/social-preview.jpg"> | |
| """ | |
| with gr.Blocks(title=APP_NAME, analytics_enabled=False) as demo: | |
| # INJECT SOCIAL META TAGS | |
| gr.HTML(f""" | |
| <title>How Much Feed? — Broiler Feed Calculator</title> | |
| <meta name="description" content="Calculate how much feed your broilers need, how many bags to buy, and your estimated feed cost."> | |
| <meta property="og:title" content="How Much Feed? — Broiler Feed Calculator"> | |
| <meta property="og:description" content="Calculate how much feed your broilers need, how many bags to buy, and your estimated feed cost."> | |
| <meta property="og:image" content="https://huggingface.co/spaces/GoogleCalc/HowMuchFeed/resolve/main/social-preview.jpg"> | |
| <meta property="og:image:width" content="1200"> | |
| <meta property="og:image:height" content="630"> | |
| <meta property="og:url" content="https://www.howmuchfeed.com/"> | |
| <meta property="og:type" content="website"> | |
| <meta property="og:site_name" content="How Much Feed?"> | |
| <meta name="twitter:card" content="summary_large_image"> | |
| <meta name="twitter:title" content="How Much Feed? — Broiler Feed Calculator"> | |
| <meta name="twitter:description" content="Calculate your broiler feed requirement, bags needed and estimated feed cost."> | |
| <meta name="twitter:image" content="https://huggingface.co/spaces/GoogleCalc/HowMuchFeed/resolve/main/social-preview.jpg"> | |
| """) | |
| gr.HTML(""" | |
| <header class="app-header"> | |
| <div class="brand-row"> | |
| <div class="brand-icon">🐔</div> | |
| <div> | |
| <h1>How Much Feed?</h1> | |
| </div> | |
| </div> | |
| <p>Broiler feed calculator — find out how many bags of feed you need for your birds.</p> | |
| </header> | |
| """) | |
| gr.HTML(""" | |
| <div class="status-card"> | |
| <strong>✓ Simple farmer estimate</strong><br> | |
| Enter your breed, number of birds, age and feed price. The calculator shows how much feed you need and the number of bags to buy. | |
| </div> | |
| """) | |
| # ============================================================ | |
| # MOBILE-FIRST INPUT FORM | |
| # ============================================================ | |
| with gr.Column(elem_classes=["mobile-form"]): | |
| with gr.Group(elem_classes=["form-section"]): | |
| gr.HTML('<div class="section-heading"><span class="step-number">1</span><span>🐔 Breed & Birds</span></div>') | |
| breed = gr.Dropdown( | |
| choices=["Ross 308", "Cobb 500", "Arbor Acres"], | |
| value="Ross 308", | |
| label="Breed", | |
| info="Choose the breed of your broilers.", | |
| ) | |
| birds = gr.Dropdown( | |
| choices=[25, 50, 75, 100, 150, 200, 500, 1000], | |
| value=50, | |
| label="Number of birds", | |
| allow_custom_value=True, | |
| ) | |
| with gr.Group(elem_classes=["form-section"]): | |
| gr.HTML('<div class="section-heading"><span class="step-number">2</span><span>📅 Expected Age</span></div>') | |
| with gr.Row(elem_classes=["age-row"]): | |
| weeks = gr.Dropdown( | |
| choices=list(range(0, 21)), value=8, label="Weeks", scale=1 | |
| ) | |
| days = gr.Dropdown( | |
| choices=list(range(0, 7)), value=0, label="Days", scale=1 | |
| ) | |
| with gr.Group(elem_classes=["form-section"]): | |
| gr.HTML('<div class="section-heading"><span class="step-number">3</span><span>🌾 Feed Details</span></div>') | |
| with gr.Row(elem_classes=["feed-row"]): | |
| bag_size = gr.Dropdown( | |
| choices=[10, 20, 25, 50, 100], value=25, label="Bag size (kg)", scale=1 | |
| ) | |
| price = gr.Number( | |
| value=24000, label="Price per bag ", minimum=0, scale=1 | |
| ) | |
| with gr.Group(elem_classes=["form-section"]): | |
| gr.HTML('<div class="section-heading"><span class="step-number">4</span><span>🌍 Your Location</span></div>') | |
| detected_country = detect_country_from_env() or "NG" | |
| country_choices = ["Auto-Detect"] + [code for code, _ in COUNTRY_LIST] | |
| country_dropdown = gr.Dropdown( | |
| choices=country_choices, | |
| value="Auto-Detect", | |
| label="Country", | |
| info="Auto-Detect uses your connection location when available.", | |
| ) | |
| country_code_state = gr.State(detected_country) | |
| with gr.Group(elem_classes=["form-section"]): | |
| gr.HTML('<div class="section-heading"><span class="step-number">5</span><span>📧 Email for your result</span></div>') | |
| email = gr.Textbox( | |
| label="Email address", | |
| placeholder="farmer@example.com", | |
| type="email", | |
| elem_classes=["email-box"], | |
| info="We'll save your calculation so you can access it later.", | |
| ) | |
| calculate_btn = gr.Button( | |
| "🐔 Calculate Feed Requirement", | |
| variant="primary", | |
| size="lg", | |
| elem_classes=["calculate-btn"], | |
| ) | |
| save_status = gr.Markdown("") | |
| # ============================================================ | |
| # RESULTS | |
| # ============================================================ | |
| gr.Markdown("## 📊 Your Feed Result") | |
| result = gr.HTML( | |
| value=""" | |
| <div class="results-shell"> | |
| <section class="buy-card"> | |
| <div class="eyebrow">YOUR FEED RESULT</div> | |
| <div class="buy-label">BUY</div> | |
| <div class="buy-number">—</div> | |
| <div class="buy-unit">BAGS</div> | |
| <div class="buy-note">Enter your flock details above to calculate.</div> | |
| </section> | |
| </div> | |
| """, | |
| elem_classes=["answer-box"], | |
| ) | |
| # ============================================================ | |
| # REFERENCE / BENCHMARKS | |
| # ============================================================ | |
| with gr.Group(elem_classes=["benchmark-card"]): | |
| with gr.Row(elem_classes=["benchmark-heading"]): | |
| gr.HTML('<div class="benchmark-title">📊 Selected Breed Benchmark</div>') | |
| selected_breed_badge = gr.HTML('<span class="selected-breed-badge">Ross 308</span>') | |
| with gr.Accordion( | |
| "📊 Weekly benchmark data used by the calculator", | |
| open=False, | |
| elem_classes=["benchmark-accordion"], | |
| ): | |
| gr.HTML('<p class="benchmark-help">Weekly benchmark data used by the calculator for the selected breed.</p>') | |
| benchmark_table = gr.HTML( | |
| value=get_benchmark_html("Ross 308"), | |
| elem_classes=["benchmark-wrap"], | |
| ) | |
| with gr.Accordion("🐔 Breed Comparison", open=False): | |
| gr.Markdown("Compare Ross 308, Cobb 500 and Arbor Acres at the same age checkpoints.") | |
| comparison_table = gr.HTML( | |
| value=get_comparison_html(), | |
| elem_classes=["benchmark-wrap"], | |
| ) | |
| with gr.Accordion("🐔 Common Broiler Breeds in Nigeria", open=False): | |
| gr.Markdown(""" | |
| | Breed | Characteristics | Available At | | |
| |---|---|---| | |
| | **Ross 308** | Big chest, thick legs, very fast growth. | Agrited, Sayed, Valentine | | |
| | **Cobb 500** | Good early growth, strong legs, solid feed conversion. | Chikun, Zartech | | |
| | **Arbor Acres** | Balanced growth, hardy and adaptable. | Supreme, CHI, GS, Vertex, Fidan, Cascada, Orobor, Sunchi, Farm Support, Ajila | | |
| **Tip:** Ask the hatchery which breed you are buying before using the benchmark. | |
| """) | |
| with gr.Accordion("📌 How It Works & Data Sources", open=False): | |
| gr.Markdown(f""" | |
| **How the estimate works** | |
| - Your weeks + days are converted to total days. | |
| - Breed-specific cumulative feed benchmarks estimate total feed per bird. | |
| - Starter feed is estimated through Day {STARTER_END_DAY}; feed after that is treated as finisher. | |
| - Exact bags = total feed ÷ bag size. | |
| - Bags to buy = exact bags rounded up to the next whole bag. | |
| - Feed cost = bags to buy × your current price per bag. | |
| **Data sources** | |
| - Ross 308 — Aviagen (2022) | |
| - Cobb 500 — Cobb-Vantress | |
| - Arbor Acres — Aviagen (2022) | |
| **Important:** This is a benchmark estimate. Actual feed use varies with management, feed quality, environment and bird performance. | |
| """) | |
| # ============================================================ | |
| # ASK A QUESTION | |
| # ============================================================ | |
| with gr.Column(elem_classes=["question-card"]): | |
| gr.HTML('<div class="question-heading">❓ Ask a Question</div>') | |
| gr.HTML('<div class="question-help">Ask a simple poultry-feed question. Your question can also help us improve the calculator.</div>') | |
| question = gr.Textbox( | |
| label="Your question", | |
| placeholder="How many bags of feed for 100 broilers for 6 weeks?", | |
| lines=3, | |
| ) | |
| ask_btn = gr.Button("💬 Get Answer", variant="secondary", size="lg") | |
| question_answer = gr.Markdown("") | |
| # ============================================================ | |
| # FOOTER + LOW-PROMINENCE ADMIN | |
| # ============================================================ | |
| gr.HTML(""" | |
| <div class="footer-note" style="margin-top:18px; text-align:center;"> | |
| 🐔 <strong>How Much Feed?</strong> — A simple broiler feed planning tool.<br> | |
| Feed estimates are guides and should be adjusted to actual broiler performance. | |
| </div> | |
| """) | |
| with gr.Accordion("🔒 Admin", open=False, elem_classes=["admin-footer"]): | |
| gr.Markdown("Password-protected analytics and CSV exports.") | |
| with gr.Column(elem_classes=["admin-login"]): | |
| admin_password_input = gr.Textbox( | |
| label="Admin Password", placeholder="Enter password...", type="password" | |
| ) | |
| login_btn = gr.Button("View Dashboard", variant="secondary") | |
| admin_output = gr.Markdown("🔒 Enter the admin password to view analytics.") | |
| with gr.Row(): | |
| export_leads_btn = gr.Button("📥 Export Leads (CSV)", variant="secondary") | |
| export_leads_output = gr.File(label="Leads CSV") | |
| with gr.Row(): | |
| export_calc_btn = gr.Button("📥 Export Calculations (CSV)", variant="secondary") | |
| export_calc_output = gr.File(label="Calculations CSV") | |
| # ============================================================ | |
| # EVENTS | |
| # ============================================================ | |
| breed.change( | |
| fn=update_benchmark, | |
| inputs=[breed], | |
| outputs=[benchmark_table], | |
| ) | |
| breed.change( | |
| fn=update_selected_breed_badge, | |
| inputs=[breed], | |
| outputs=[selected_breed_badge], | |
| ) | |
| country_dropdown.change( | |
| fn=on_country_change_handler, | |
| inputs=[country_dropdown], | |
| outputs=[country_code_state, price], | |
| ) | |
| def calculate_wrapper(birds, weeks, days, bag_size, price_per_bag, email, country_code_state, breed, request: gr.Request): | |
| if not country_code_state: | |
| country_code_state = "NG" | |
| return calculate_feed( | |
| birds, weeks, days, bag_size, price_per_bag, | |
| email, country_code_state, breed, request | |
| ) | |
| calculate_btn.click( | |
| fn=calculate_wrapper, | |
| inputs=[birds, weeks, days, bag_size, price, email, country_code_state, breed], | |
| outputs=[result, save_status], | |
| ) | |
| ask_btn.click( | |
| fn=answer_farmer_question, | |
| inputs=[question, email, breed], | |
| outputs=[question_answer], | |
| ) | |
| login_btn.click( | |
| fn=admin_login, | |
| inputs=[admin_password_input], | |
| outputs=[admin_output], | |
| ) | |
| export_leads_btn.click( | |
| fn=export_leads_wrapper, | |
| inputs=[], | |
| outputs=[export_leads_output], | |
| ) | |
| export_calc_btn.click( | |
| fn=export_calculations_wrapper, | |
| inputs=[], | |
| outputs=[export_calc_output], | |
| ) | |
| def track_visit_async(request: gr.Request): | |
| # Do not make the first page render wait for analytics I/O. | |
| threading.Thread(target=track_visit, args=(request,), daemon=True).start() | |
| demo.load(fn=track_visit_async, inputs=None, outputs=None) | |
| # ============================================================ | |
| # START APP | |
| # ============================================================ | |
| # ============================================================ | |
| # SEO SITEMAP | |
| # ============================================================ | |
| SITEMAP_XML = """<?xml version="1.0" encoding="UTF-8"?> | |
| <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> | |
| <url> | |
| <loc>https://www.howmuchfeed.com/</loc> | |
| </url> | |
| </urlset> | |
| """ | |
| app = FastAPI() | |
| # ============================================================ | |
| # SEO METADATA FIX - Remove Duplicate OG Tags | |
| # ============================================================ | |
| from starlette.middleware.base import BaseHTTPMiddleware | |
| from starlette.responses import Response | |
| import re | |
| class SEOMetadataMiddleware(BaseHTTPMiddleware): | |
| """Intercept HTML responses and fix duplicate OG tags.""" | |
| async def dispatch(self, request, call_next): | |
| response = await call_next(request) | |
| # Only process HTML responses | |
| content_type = response.headers.get("content-type", "") | |
| if "text/html" in content_type: | |
| try: | |
| # Read the response body | |
| body = b"" | |
| async for chunk in response.body_iterator: | |
| body += chunk | |
| html = body.decode("utf-8") | |
| # Remove the default Gradio OG image | |
| default_og = r'<meta\s+property="og:image"\s+content="https://raw\.githubusercontent\.com/gradio-app/gradio/main/js/_website/src/lib/assets/img/header-image\.jpg"\s*/>' | |
| html = re.sub(default_og, '', html) | |
| # Remove default Twitter image | |
| default_twitter = r'<meta\s+name="twitter:image"\s+content="https://raw\.githubusercontent\.com/gradio-app/gradio/main/js/_website/src/lib/assets/img/header-image\.jpg"\s*/>' | |
| html = re.sub(default_twitter, '', html) | |
| # Check if correct OG image exists | |
| correct_og = 'content="https://huggingface.co/spaces/GoogleCalc/HowMuchFeed/resolve/main/social-preview.jpg"' | |
| if correct_og not in html: | |
| # Inject it | |
| og_tag = '<meta property="og:image" content="https://huggingface.co/spaces/GoogleCalc/HowMuchFeed/resolve/main/social-preview.jpg">' | |
| html = html.replace('<head>', f'<head>\n {og_tag}') | |
| return Response( | |
| content=html.encode("utf-8"), | |
| status_code=response.status_code, | |
| headers=dict(response.headers), | |
| media_type=response.media_type, | |
| ) | |
| except Exception as e: | |
| print(f"⚠️ SEO middleware error: {e}") | |
| return response | |
| return response | |
| # Add the middleware AFTER the app is created | |
| app.add_middleware(SEOMetadataMiddleware) | |
| def sitemap(): | |
| return Response( | |
| content=SITEMAP_XML, | |
| media_type="application/xml" | |
| ) | |
| if __name__ == "__main__": | |
| port = int(os.environ.get("PORT", 7860)) | |
| if __name__ == "__main__": | |
| port = int(os.environ.get("PORT", 7860)) | |
| demo.launch( | |
| server_name="0.0.0.0", | |
| server_port=port, | |
| ssr_mode=False, | |
| theme=gr.themes.Base(), | |
| css=CSS, | |
| head=SOCIAL_META, | |
| footer_links=[] | |
| ) |