HowMuchFeed / app.py
GoogleCalc's picture
Update app.py
69cbf4e verified
Raw History Blame Contribute Delete
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)
@app.get("/sitemap.xml")
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=[]
)