Files
scout-website/app/app.py
T

1032 lines
74 KiB
Python

import html, json, os, re, time, datetime, urllib.request, urllib.parse
from pathlib import Path
from fastapi import FastAPI, Form, Request
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
# Internal record store + admin API. The store is the source of truth for
# anything a family submits; see store.py for why.
import store
import admin_api
import nearby
import documents
# ---------------------------------------------------------------------------
# Pack & Troop 73 - greenlanescouts73.org
# Calendar comes from the scout-calendar JSON feed (EVENTS_FEED_URL) -
# Radicale is the source of truth, edited in any calendar app. The last good
# copy is cached to /data/events-cache.json and served whenever the feed is
# down, so a feed outage never blanks the site.
# ---------------------------------------------------------------------------
DATA = Path("/data"); DATA.mkdir(parents=True, exist_ok=True)
LEADS = DATA / "leads.jsonl"
EVENTS_FEED_URL = os.environ.get("EVENTS_FEED_URL", "").strip()
EVENTS_CACHE = DATA / "events-cache.json"
NTFY_URL = os.environ.get("NTFY_URL", "")
# Lead pipeline lives IN THIS REPO (no dependency on the scouting73-form
# container): direct Google Sheet append + ntfy push, same sheet and topic.
SHEET_ID = os.environ.get("SHEET_ID", "").strip()
SA_FILE = os.environ.get("SA_FILE", "/app/service_account.json")
NTFY_BASE = os.environ.get("NTFY_BASE", "").rstrip("/")
NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "scouting")
NTFY_TOKEN = os.environ.get("NTFY_TOKEN", "").strip()
SHEET_HEADERS = ["Timestamp", "Parent Name", "Email", "Phone",
"Children (name + current grade)", "Interested In", "How They Heard", "Comments"]
# ---------------------------------------------------------------------------
# The seasonal surface.
#
# These are the only strings on the site that go stale on a schedule rather
# than when something actually changes. They live here so a meeting time move
# is one edit instead of twelve, and so the next person can see the whole
# seasonal surface at a glance rather than grepping for it.
#
# This is NOT a CMS and must not grow into one. Anything with a date in it
# belongs in the calendar feed, not here - a recruitment banner should read
# the next join night and render nothing when there is not one. Anything
# else is evergreen copy and belongs inline where git log can answer what
# the site said during recruitment.
# ---------------------------------------------------------------------------
PROGRAM_YEAR = "2026-2027"
MEETING_DAY = "Tuesday"
MEETING_DAYS = "Tuesdays"
MEETING_DAY_ABBR = "Tue"
PACK_TIME = "6:00 PM"
TROOP_TIME = "7:30 PM"
PACK_CLOCK = "6:00"
TROOP_CLOCK = "7:30"
app = FastAPI(title="Pack & Troop 73")
app.mount("/static", StaticFiles(directory="/app/static"), name="static")
# Creates the schema if absent and backfills the legacy leads.jsonl once.
store.init(backfill_jsonl=LEADS)
app.include_router(admin_api.router)
MB = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC']
MF = ['January','February','March','April','May','June','July','August','September','October','November','December']
DW = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']
DWU = ['MON','TUE','WED','THU','FRI','SAT','SUN']
_FEED_TTL = 300 # seconds between feed fetches; a failure also waits this long
_feed_state = {"events": None, "fetched": 0.0}
def _fetch_feed():
rq = urllib.request.Request(EVENTS_FEED_URL, headers={"User-Agent": "scout-website"})
with urllib.request.urlopen(rq, timeout=5) as resp:
events = json.load(resp).get("events", [])
if not isinstance(events, list) or not events:
raise ValueError("feed returned no events")
return events
def load_events():
"""Events come from the scout-calendar feed. The last good copy is cached
to /data and served whenever the feed is unreachable or empty - the pages
never go blank because the feed had a bad moment. With no feed and no
cache yet, pages fall back to their 'Nothing on the books' empty state."""
now = time.time()
st = _feed_state
if st["events"] is not None and now - st["fetched"] < _FEED_TTL:
return st["events"]
if EVENTS_FEED_URL:
try:
events = _fetch_feed()
st.update(events=events, fetched=now)
try:
tmp = EVENTS_CACHE.with_suffix(".tmp")
tmp.write_text(json.dumps({"events": events}), encoding="utf-8")
tmp.replace(EVENTS_CACHE)
except Exception:
pass
return events
except Exception:
pass
if st["events"] is not None: # feed down: keep serving stale, retry after TTL
st["fetched"] = now
return st["events"]
try:
with open(EVENTS_CACHE, encoding="utf-8") as f:
events = json.load(f).get("events", [])
except Exception:
events = []
if events:
st.update(events=events, fetched=now)
return events
def _dates(ev):
s = datetime.date.fromisoformat(ev["date"])
e = datetime.date.fromisoformat(ev.get("end", ev["date"]))
return s, e
def next_up(units, n=3):
today = datetime.date.today()
out = []
for ev in load_events():
u = ev.get("unit", "pack")
if u not in units and u != "both": continue
s, e = _dates(ev)
if e >= today: out.append((s, ev))
out.sort(key=lambda t: t[0])
return [ev for _, ev in out[:n]]
def unit_label(u): return {"troop": "TROOP", "both": "ALL 73"}.get(u, "PACK")
def ev_meta(ev, s):
return " · ".join(x for x in [DW[s.weekday()], ev.get("time"), ev.get("location")] if x)
def next_up_cards(events, show_unit=False):
rows = []
for ev in events:
s, _ = _dates(ev)
chips = ""
if show_unit:
chips += f'<span class="uchip uchip-n">{unit_label(ev.get("unit","pack"))}</span>'
if ev.get("badge"):
chips += f'<span class="badge">{ev["badge"]}</span>'
rows.append(f'''<div class="evcard">
<div class="datechip"><div class="dc-m">{MB[s.month-1]}</div><div class="dc-d">{s.day}</div></div>
<div><div class="ev-t">{ev["title"]}{chips}</div><div class="ev-m">{ev_meta(ev, s)}</div></div></div>''')
return "\n".join(rows)
CSS = """
*{box-sizing:border-box}html{scroll-behavior:smooth}
body{margin:0;background:#FBF8F1;color:#232838;font-family:'Public Sans',system-ui,sans-serif;line-height:1.6}
a{color:#35608F}a:hover{color:#1E2F52}p{text-wrap:pretty}
::selection{background:#F0AD2D;color:#1E2F52}
.wrap{max-width:1160px;margin:0 auto;padding:0 24px}
.arch{font-family:'Archivo',sans-serif}
header.nav{position:sticky;top:0;z-index:60;background:rgba(251,248,241,.94);backdrop-filter:blur(10px);border-bottom:1px solid #E8E1D0}
.navrow{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:70px;flex-wrap:wrap}
.brand{display:flex;align-items:center;gap:12px;text-decoration:none;padding:10px 0}
.brand img{width:48px;height:48px;border-radius:50%;object-fit:cover;display:block;box-shadow:0 1px 4px rgba(16,25,44,.3)}
.brand .b1{font-family:'Archivo';font-weight:800;font-size:15px;letter-spacing:.06em;color:#1E2F52;display:block;line-height:1.25}
.brand .b2{font-size:11px;color:#6A7280;letter-spacing:.02em;display:block;line-height:1.25}
nav.links{display:flex;align-items:center;gap:26px;flex-wrap:wrap}
nav.links a{text-decoration:none;font-weight:600;font-size:15px;color:#333D52;padding:6px 0}
nav.links a:hover{color:#1E2F52}
nav.links a.on{font-weight:700;color:#1E2F52;border-bottom:2px solid #F0AD2D}
nav.links a.joinbtn{display:inline-block;line-height:1.2;font-family:'Archivo';font-weight:700;font-size:15px;background:#F0AD2D;color:#1E2F52 !important;padding:11px 22px;border-radius:999px;border-bottom:none !important}
nav.links a.joinbtn:hover{background:#DB9714}
.hero{position:relative;display:flex;align-items:flex-end;min-height:520px}
.hero .wrap{padding-top:110px;padding-bottom:58px;width:100%}
.eyebrow{font-family:'Archivo';font-weight:700;font-size:13px;letter-spacing:.2em;text-transform:uppercase;margin-bottom:12px}
.eyebrow.gold{color:#F7C556}.eyebrow.dark{color:#B07B12}
.hero h1{margin:0 0 16px;font-family:'Archivo';font-weight:800;font-size:clamp(2.4rem,5vw,4rem);line-height:1.05;letter-spacing:-.015em;color:#fff;max-width:15ch}
.hero p{margin:0;font-size:clamp(1.05rem,1.7vw,1.2rem);color:rgba(255,255,255,.94);max-width:620px}
.cta{display:inline-block;font-family:'Archivo';font-weight:700;font-size:16px;background:#F0AD2D;color:#1E2F52;padding:15px 30px;border-radius:999px;text-decoration:none}
.cta:hover{background:#F7C556;color:#1E2F52}
.cta.ghost{background:transparent;color:#fff;border:2px solid rgba(255,255,255,.85);padding:13px 28px}
.cta.ghost:hover{background:rgba(255,255,255,.14);color:#fff}
h2.sec{margin:0 0 14px;font-family:'Archivo';font-weight:800;font-size:clamp(1.7rem,3vw,2.4rem);letter-spacing:-.01em;color:#1E2F52}
.lead{margin:0 0 40px;color:#5A6272;font-size:1.08rem;max-width:640px}
.facts{background:#fff;border-radius:16px;box-shadow:0 18px 40px rgba(16,25,44,.12);display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:1px;overflow:hidden;border:1px solid #EBE4D4;position:relative;z-index:5;margin-top:-44px}
.facts>div{padding:22px 26px;background:#fff}
.facts .fl{font-size:11px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:#8B93A3;margin-bottom:4px}
.facts .fv{font-family:'Archivo';font-weight:700;font-size:17px;color:#1E2F52}
.grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:28px}
.grid3{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:18px}
.grid4{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:24px}
.gridcards{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px}
.pcard{text-decoration:none;background:#fff;border:1px solid #EBE4D4;border-radius:18px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 6px 18px rgba(16,25,44,.06);transition:transform .15s;color:inherit}
.pcard:hover{transform:translateY(-4px);color:inherit}
.pcard img{width:100%;height:270px;object-fit:cover;display:block}
.pcard .pad{padding:26px 28px 30px;display:flex;flex-direction:column;gap:10px}
.pcard h3{margin:0;font-family:'Archivo';font-weight:800;font-size:1.45rem;color:#1E2F52}
.chip{font-size:12px;font-weight:700;letter-spacing:.08em;padding:4px 12px;border-radius:999px;white-space:nowrap}
.chip.gold{background:#F3EBD8;color:#7A5B00}.chip.blue{background:#E3E9F2;color:#1E2F52}
.pcard p{margin:0;color:#5A6272}
.plink{font-family:'Archivo';font-weight:700;color:#35608F}
.evcard{display:flex;gap:16px;align-items:center;background:#fff;border:1px solid #EBE4D4;border-radius:14px;padding:16px 18px}
.datechip{width:60px;min-width:60px;text-align:center;background:#1E2F52;border-radius:12px;padding:9px 4px}
.dc-m{font-size:11px;font-weight:700;letter-spacing:.12em;color:#F7C556}
.dc-d{font-family:'Archivo';font-weight:800;font-size:21px;color:#fff;line-height:1.15}
.ev-t{font-family:'Archivo';font-weight:700;font-size:16px;color:#1E2F52;display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.ev-m{color:#6A7280;font-size:14px}
.uchip{font-size:11px;font-weight:700;letter-spacing:.08em;padding:3px 10px;border-radius:999px;white-space:nowrap}
.uchip-n{background:#EEF1F6;color:#33415E}
.uchip-pack{background:#F3EBD8;color:#7A5B00}.uchip-troop{background:#F6E3DA;color:#8A3B1D}.uchip-both{background:#E3E9F2;color:#1E2F52}
.badge{font-size:11px;font-weight:700;letter-spacing:.08em;background:#F0AD2D;color:#1E2F52;padding:3px 10px;border-radius:999px;white-space:nowrap}
.noev{background:#fff;border:1px dashed #CBD2DC;border-radius:14px;padding:22px 24px;color:#5A6272;font-size:15px}
.steps{display:flex;flex-direction:column;gap:22px}
.step{display:flex;gap:18px}
.step .n{font-family:'Archivo';font-weight:800;font-size:28px;color:#F0AD2D;min-width:44px}
.step .t{font-family:'Archivo';font-weight:700;font-size:17px;color:#1E2F52;margin-bottom:2px}
.step .d{color:#5A6272;font-size:15px}
.photoband img{width:100%;height:250px;object-fit:cover;border-radius:14px;display:block}
.volband{background:#1E2F52;border-radius:20px;overflow:hidden;display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr))}
.volband img{width:100%;height:100%;min-height:320px;object-fit:cover;display:block}
.volband .pad{padding:46px 44px;display:flex;flex-direction:column;justify-content:center}
.volband h2{margin:0 0 14px;font-family:'Archivo';font-weight:800;font-size:clamp(1.7rem,3vw,2.3rem);color:#fff;letter-spacing:-.01em}
.volband p{margin:0 0 12px;color:#D8DFEA;font-size:15.5px;line-height:1.7}
.vchip{font-size:12.5px;font-weight:700;background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.22);color:#fff;padding:6px 14px;border-radius:999px}
.band{position:relative;padding:96px 24px;text-align:center}
.band h2{margin:0 0 14px;font-family:'Archivo';font-weight:800;font-size:clamp(1.9rem,3.6vw,2.7rem);color:#fff;letter-spacing:-.01em}
.band p{margin:0 auto 28px;color:rgba(255,255,255,.92);font-size:1.08rem;max-width:560px}
.rank{border-radius:14px;padding:20px 18px;border:1px solid #EBE4D4}
.rank .rl{font-size:11px;font-weight:700;letter-spacing:.12em;margin-bottom:6px;color:#B07B12}
.rank .rn{font-family:'Archivo';font-weight:800;font-size:1.15rem;color:#1E2F52;margin-bottom:4px}
.rank .rd{font-size:13.5px;color:#6A7280}
.rankgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px}
.r1{background:linear-gradient(180deg,#fff,#FFFBF2);border-top:5px solid #F7C556}
.r2{background:linear-gradient(180deg,#fff,#FEF7E8);border-top:5px solid #F0AD2D}
.r3{background:linear-gradient(180deg,#fff,#FDF2DC);border-top:5px solid #E29A25}
.r4{background:linear-gradient(180deg,#fff,#FBEDD2);border-top:5px solid #D4842A}.r4 .rl{color:#A86A15}
.r5{background:linear-gradient(180deg,#fff,#F9E6D0);border-top:5px solid #C4552D}.r5 .rl{color:#A86A15}
.r6{background:linear-gradient(180deg,#fff,#F3DECD);border-top:5px solid #8A3B1D}.r6 .rl{color:#8A3B1D}
.r7{background:linear-gradient(160deg,#1E2F52,#2C4470);border:1px solid #1E2F52;border-top:5px solid #F0AD2D}
.r7 .rl{color:#F7C556}.r7 .rn{color:#fff}.r7 .rd{color:#C3CBDA}
.ycard{background:#fff;border:1px solid #EBE4D4;border-radius:16px;overflow:hidden}
.ycard img{width:100%;height:190px;object-fit:cover;display:block}
.ycard .pad{padding:20px 22px}
.ycard .t{font-family:'Archivo';font-weight:800;font-size:1.1rem;color:#1E2F52;margin-bottom:4px}
.ycard .d{font-size:14.5px;color:#5A6272}
.infocard{background:#fff;border:1px solid #EBE4D4;border-radius:18px;overflow:hidden;display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));align-items:stretch}
.infocard .pad{padding:34px 36px}
.infocard img{width:100%;height:100%;min-height:300px;object-fit:cover;display:block}
.rrow{display:flex;gap:12px;align-items:baseline}
.rrow .k{font-family:'Archivo';font-weight:800;color:#F0AD2D;font-size:1.05rem;min-width:96px}
.rrow .v{color:#333D52;font-size:15px}
.calhead{background:#1E2F52;padding:72px 0 64px}
.calhead h1{margin:0 0 18px;font-family:'Archivo';font-weight:800;font-size:clamp(2.4rem,5vw,4rem);line-height:1.02;letter-spacing:-.015em;color:#fff}
.calhead .sub{margin:0 0 34px;color:#C3CBDA;font-size:1.08rem;max-width:640px}
.rhythm{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px}
.rhythm>div{background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.14);border-radius:14px;padding:18px 22px}
.rhythm .t{font-family:'Archivo';font-weight:800;color:#F7C556;font-size:1.05rem;margin-bottom:2px}
.rhythm .d{color:#D8DFEA;font-size:14px}
select.unitsel{font-family:'Archivo';font-weight:700;font-size:15px;color:#1E2F52;background:#fff;border:1.5px solid #CBD2DC;border-radius:12px;padding:12px 18px;cursor:pointer;min-width:240px}
.mcard{background:#fff;border:1px solid #EBE4D4;border-radius:16px;padding:26px 28px}
.mhead{display:flex;align-items:baseline;justify-content:space-between;border-bottom:2px solid #F0AD2D;padding-bottom:10px;margin-bottom:16px}
.mhead .mn{font-family:'Archivo';font-weight:800;font-size:1.35rem;color:#1E2F52}
.mhead .my{font-size:13px;font-weight:700;color:#8B93A3}
.mrow{display:flex;gap:14px;align-items:baseline}
.mrow .ml{font-family:'Archivo';font-weight:800;color:#B07B12;min-width:84px;font-size:14px}
.mrow .mt{font-size:15px;color:#232838}
.mrow .mm{color:#5A6272}
.note{background:#F3EBD8;border:1px solid #E8DCBB;border-radius:14px;padding:18px 24px;color:#6B5510;font-size:14.5px}
form.lead{background:#fff;border:1px solid #EBE4D4;border-radius:18px;padding:32px 32px 34px;display:grid;gap:18px;box-shadow:0 14px 34px rgba(16,25,44,.08)}
form.lead .ft{font-family:'Archivo';font-weight:800;font-size:1.3rem;color:#1E2F52}
.frow{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px}
label{display:block;font-weight:600;font-size:14px;margin-bottom:6px;color:#333D52}
input,select,textarea{width:100%;padding:12px 13px;border:1px solid #CBD2DC;border-radius:9px;font:inherit;background:#fff}
input:focus,select:focus,textarea:focus{outline:2px solid #35608F;border-color:#35608F}
textarea{resize:vertical}
button.cta{border:none;cursor:pointer}
.req{color:#C4552D}
.success{background:#E4F2E4;border:1px solid #9CC79C;color:#1E5A2E;border-radius:12px;padding:16px 20px;font-weight:600;margin-bottom:18px}
.faq{background:#fff;border:1px solid #EBE4D4;border-radius:16px;padding:24px 26px}
.faq .q{font-family:'Archivo';font-weight:800;font-size:1.05rem;color:#1E2F52;margin-bottom:6px}
.faq .a{font-size:14.5px;color:#5A6272}
footer{background:#1E2F52;color:#C3CBDA;padding:52px 0 40px;margin-top:auto}
.footgrid{display:flex;justify-content:space-between;gap:36px;flex-wrap:wrap}
footer .fh{font-family:'Archivo';font-weight:700;color:#F7C556;font-size:13px;letter-spacing:.14em;margin-bottom:12px}
footer a{color:#D8DFEA;text-decoration:none;font-size:14px}
footer a:hover{color:#fff}
footer a.gold{color:#F7C556;font-weight:700}
footer a.fb{display:inline-flex;align-items:center;gap:8px}
.docrow{display:flex;gap:16px;align-items:center;background:#fff;border:1px solid #EBE4D4;border-radius:14px;padding:16px 18px;text-decoration:none;color:inherit;transition:transform .12s,border-color .12s}
.docrow:hover{transform:translateY(-2px);border-color:#CBD2DC;color:inherit}
.docicon{font-family:'Archivo';font-weight:800;font-size:12px;letter-spacing:.06em;color:#fff;background:#1E2F52;border-radius:10px;padding:13px 0;width:58px;min-width:58px;text-align:center}
.docmain{flex:1;min-width:0}
.doct{font-family:'Archivo';font-weight:700;font-size:16px;color:#1E2F52;display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.docd{display:block;margin-top:3px;color:#6A7280;font-size:14px}
.docmeta{font-size:12.5px;font-weight:600;color:#8B93A3;white-space:nowrap}
@media(max-width:560px){.docrow{flex-wrap:wrap}.docmeta{width:100%;padding-left:74px}}
.annc{border-bottom:1px solid;font-family:'Public Sans',system-ui,sans-serif}
.annc-info{background:#F3EBD8;border-color:#E8DCBB;color:#6B5510}
.annc-urgent{background:#F6E3DA;border-color:#DFB9A4;color:#8A3B1D}
.anncrow{display:flex;align-items:flex-start;gap:9px;padding:11px 0}
.topstick{position:sticky;top:0;z-index:60}
.topstick header.nav{position:static}
.topstick:has(.annc[open]){position:static}
.annc summary{cursor:pointer;list-style:none;-webkit-tap-highlight-color:transparent}
.annc summary::-webkit-details-marker{display:none}
.annc summary:focus-visible{outline:2px solid currentColor;outline-offset:-3px}
.anncico,.anncchev{flex:none;display:flex;align-items:center;margin-top:1px}
.anncchev{transition:transform .15s ease}
.annc[open] .anncchev{transform:rotate(180deg)}
.anncline{flex:1;min-width:0;font-size:14px;line-height:1.4;overflow-wrap:anywhere}
.ancctop{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden}
@media(max-width:560px){.ancctop{-webkit-line-clamp:3}}
.anncopen{display:none}
.annc[open] .ancctop{display:none}
.annc[open] .anncopen{display:block;font-weight:600}
.annccount{flex:none;margin-top:1px;font-size:11px;font-weight:700;padding:1px 7px;border-radius:999px;border:1px solid currentColor}
.annc[open] .annccount{display:none}
.anncbody{max-height:40vh;overflow-y:auto;padding-bottom:12px}
.anncitem{padding:10px 0 0;font-size:14px;line-height:1.5;overflow-wrap:anywhere}
.anncitem+.anncitem{border-top:1px solid rgba(0,0,0,.09);padding-top:10px;margin-top:10px}
.anncitem a{color:inherit;text-decoration:underline;font-size:13px}
.ancclink{flex:none;color:inherit;text-decoration:underline;font-size:13px;white-space:nowrap}
.nu-group{margin-bottom:44px}
.nu-area{font-size:1.45rem;margin-bottom:14px}
.nu-card{padding:22px 24px;display:flex;flex-direction:column;gap:8px}
.nu-head{display:flex;flex-wrap:wrap;align-items:center;gap:10px}
.nu-name{font-family:'Archivo';font-weight:800;font-size:1.15rem;color:#1E2F52;text-decoration:none;border-bottom:2px solid #F0AD2D}
.nu-name:hover{color:#B07B12}
.nu-plain{border-bottom:none;color:#1E2F52}
.nu-chips{display:flex;flex-wrap:wrap;gap:6px}
.nu-where{color:#5A6272;font-size:14.5px;line-height:1.6}
.nu-meets{font-weight:700;color:#1E2F52;font-size:14.5px}
.nu-note{color:#6A7280;font-size:13.5px;line-height:1.6}
.nu-mail{color:#B07B12;font-weight:600;text-decoration:none}
"""
ICO_INFO = ('<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor"'
' stroke-width="2" stroke-linecap="round" aria-hidden="true">'
'<circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 7.6v.01"/></svg>')
ICO_URGENT = ('<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor"'
' stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
'<path d="M10.3 4.3 2.6 17.6a2 2 0 0 0 1.7 3h15.4a2 2 0 0 0 1.7-3L13.7 4.3a2 2 0 0 0-3.4 0Z"/>'
'<path d="M12 9.5v4M12 17.2v.01"/></svg>')
ICO_CHEV = ('<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor"'
' stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
'<path d="m6 9 6 6 6-6"/></svg>')
def _annc_item(a):
"""One announcement in the expanded list. Text is escaped; the link is
rendered only from a url store.create_announcement already validated."""
out = '<div class="anncitem">%s' % html.escape(a["message"])
if a.get("link_url"):
out += '<br><a href="%s">%s</a>' % (
html.escape(a["link_url"], quote=True),
html.escape(a.get("link_text") or "More details"))
return out + "</div>"
def announcement_bar():
"""Render the live announcements, or nothing at all.
FAILS OPEN, deliberately. The admin API fails closed because it serves
family phone numbers; this is the opposite case. A broken announcement
must never take down the public homepage, so any error here renders an
empty string and logs.
Collapsed clamps to two lines, three on a phone, because one line cut the
actual location out of a venue change - the only part of the notice that
mattered. A single notice does not clamp at all: it has no expand
affordance, so clamping it would lose text with no way to recover it.
Collapsed shows the top-ranked notice plus a count.
Expanded lists all of them. Native <details>, so there is no JavaScript
and nothing to load - which matters on a read-only rootfs with no build
step. The single-notice case, which is nearly always the case, renders
with no chevron and no count: it must not look like a widget.
"""
try:
items = store.active_announcements()
except Exception as e:
print("announcement_bar: %s" % e, flush=True)
return ""
if not items:
return ""
top = items[0]
level = "urgent" if any(i["level"] == "urgent" for i in items) else "info"
ico = ICO_URGENT if level == "urgent" else ICO_INFO
label = "Urgent notice" if level == "urgent" else "Notice"
if len(items) == 1:
link = ""
if top.get("link_url"):
link = ('<a class="ancclink" href="%s">%s</a>' % (
html.escape(top["link_url"], quote=True),
html.escape(top.get("link_text") or "More details")))
return ('<div class="annc annc-%s" role="region" aria-label="%s"><div class="wrap anncrow">'
'<span class="anncico">%s</span>'
'<span class="anncline">%s</span>%s'
'</div></div>' % (level, label, ico, html.escape(top["message"]), link))
body = "".join(_annc_item(i) for i in items)
return ('<details class="annc annc-%s"><summary><div class="wrap anncrow">'
'<span class="anncico">%s</span>'
'<span class="anncline ancctop">%s</span>'
'<span class="anncline anncopen">%d notices</span>'
'<span class="annccount">+%d</span>'
'<span class="anncchev">%s</span>'
'</div></summary><div class="wrap anncbody">%s</div></details>'
% (level, ico, html.escape(top["message"]), len(items),
len(items) - 1, ICO_CHEV, body))
def page(title, body, active=""):
def on(k): return ' class="on"' if active == k else ""
return f"""<!doctype html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<meta name="description" content="A Cub Scout pack and a Scout troop in Zieglerville, PA. {MEETING_DAYS} at St. Luke's Lutheran Church, kindergarten through age 17. Every kid welcome. Come see a meeting.">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Pack &amp; Troop 73">
<meta property="og:title" content="{title}">
<meta property="og:description" content="A Cub Scout pack and a Scout troop in Zieglerville, PA. {MEETING_DAYS} at St. Luke's Lutheran Church, kindergarten through age 17. Every kid welcome. Come see a meeting.">
<meta property="og:image" content="https://greenlanescouts73.org/static/img/autumn-hike.jpg">
<meta property="og:image:width" content="1920">
<meta property="og:image:height" content="1278">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="/static/img/patch-73.png">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;600;700;800;900&family=Public+Sans:wght@400;600;700&display=swap" rel="stylesheet">
<style>{CSS}</style>
</head><body style="display:flex;flex-direction:column;min-height:100vh">
<div class="topstick">{announcement_bar()}
<header class="nav"><div class="wrap navrow">
<a class="brand" href="/"><img src="/static/img/patch-73.png" alt="Pack and Troop 73 patch"><span><span class="b1">PACK &amp; TROOP 73</span><span class="b2">Zieglerville, PA · Cradle of Liberty Council</span></span></a>
<nav class="links">
<a href="/"{on('home')}>Home</a>
<a href="/cubs"{on('cubs')}>Cub Scouts</a>
<a href="/troop"{on('troop')}>The Troop</a>
<a href="/calendar"{on('cal')}>Calendar</a>
<a class="joinbtn" href="/join">Join us</a>
</nav></div></header></div>
{body}
<footer><div class="wrap footgrid">
<div style="max-width:340px"><div style="display:flex;align-items:center;gap:10px;margin-bottom:12px"><img src="/static/img/patch-73.png" alt="" style="width:40px;height:40px;border-radius:50%;object-fit:cover"><span class="arch" style="font-weight:800;letter-spacing:.06em;color:#fff">PACK &amp; TROOP 73</span></div>
<div style="font-size:14px;line-height:1.7">Chartered by St. Luke's Lutheran Church · Zieglerville, PA<br>Cradle of Liberty Council · A family program. Every kid welcome.</div></div>
<div><div class="fh">VISIT</div><div style="font-size:14px;line-height:1.9">{MEETING_DAYS} · Pack {PACK_CLOCK} · Troop {TROOP_CLOCK}<br>St. Luke's Lutheran Church<br>Zieglerville, PA</div></div>
<div style="display:flex;flex-direction:column;gap:8px"><div class="fh" style="margin-bottom:4px">EXPLORE</div>
<a href="/cubs">Cub Scouts · Pack 73</a><a href="/troop">Troop 73 · Ages 11-17</a><a href="/calendar">{PROGRAM_YEAR} Calendar</a><a href="/documents">Forms &amp; documents</a><a class="gold" href="/join">Join us</a></div>
<div style="display:flex;flex-direction:column;gap:8px"><div class="fh" style="margin-bottom:4px">FOLLOW</div>
<a class="fb" href="https://www.facebook.com/Pack73GreenLane" target="_blank" rel="noopener" aria-label="Pack 73 on Facebook"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true" style="flex:none"><path d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"/></svg>Pack 73</a><a class="fb" href="https://www.facebook.com/Troop73GreenLane" target="_blank" rel="noopener" aria-label="Troop 73 on Facebook"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true" style="flex:none"><path d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"/></svg>Troop 73</a></div>
</div></footer>
</body></html>"""
def hero(img, pos, eyebrow, h1, sub, extra="", minh=520):
return f"""<div class="hero" style="min-height:{minh}px;background:linear-gradient(180deg,rgba(16,25,44,.08) 30%,rgba(11,20,38,.76) 100%),url('/static/img/{img}') {pos}/cover no-repeat">
<div class="wrap"><div class="eyebrow gold">{eyebrow}</div><h1>{h1}</h1><p>{sub}</p>{extra}</div></div>"""
@app.get("/health")
def health(): return {"status": "ok"}
@app.get("/", response_class=HTMLResponse)
def home():
up = next_up({"pack", "troop"}, 3)
ev_html = next_up_cards(up, show_unit=True) or '<div class="noev">Nothing on the books right now. New dates land on the shared calendar first, so check back soon or message us.</div>'
body = f"""
{hero('autumn-hike.jpg', 'center', 'Family Scouting · Zieglerville, PA', 'Where kids find their trail.',
'A Cub Scout pack and a Scout troop for kindergarten through age 17. Camp, build, lead, and grow alongside friends, right here in the Perkiomen Valley.',
'<div style="display:flex;gap:14px;flex-wrap:wrap;margin-top:28px"><a class="cta" href="/join">Join us</a><a class="cta ghost" href="/calendar">See the year</a></div>', 640)}
<div class="wrap" style="width:100%"><div class="facts">
<div><div class="fl">Meetings</div><div class="fv">{MEETING_DAY_ABBR} · Pack {PACK_CLOCK} · Troop {TROOP_CLOCK}</div></div>
<div><div class="fl">Where</div><div class="fv">St. Luke's Lutheran Church</div></div>
<div><div class="fl">Ages</div><div class="fv">Kindergarten to 17</div></div>
<div><div class="fl">Everyone</div><div class="fv">Every kid welcome · first visit free</div></div>
</div></div>
<section style="padding:88px 0 40px"><div class="wrap">
<div class="eyebrow dark">Two programs, one community</div>
<h2 class="sec">Start in kindergarten or jump in at fifteen.</h2>
<p class="lead">Chartered by St. Luke's Lutheran Church and part of the Cradle of Liberty Council, every kid is welcome. No experience needed at any age.</p>
<div class="grid2">
<a class="pcard" href="/cubs"><img src="/static/img/craft-table.jpg" alt="Cub Scouts painting pinewood derby cars"><div class="pad">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap"><h3>Cub Scouts · Pack 73</h3><span class="chip gold">GRADES K-5</span></div>
<p>Dens by grade, adventures by the armful. Boxcar and Pinewood derbies, first campouts, rockets, hikes, and a room full of new friends every Tuesday.</p>
<span class="plink">Explore Cub Scouts →</span></div></a>
<a class="pcard" href="/troop"><img src="/static/img/tent-lake.jpg" alt="A Scout and leader setting up a tent by the lake"><div class="pad">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap"><h3>The Troop · 73</h3><span class="chip blue">AGES 11-17</span></div>
<p>A youth-led troop for ages 11 to 17. Real camping, real responsibility, and the first steps on the trail to Eagle.</p>
<span class="plink">Explore the Troop →</span></div></a>
</div></div></section>
<section style="padding:48px 0 72px"><div class="wrap" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:48px;align-items:start">
<div><div class="eyebrow dark">Coming up at 73</div><h2 class="sec" style="margin-bottom:26px">Next up at 73.</h2>
<div style="display:flex;flex-direction:column;gap:14px">{ev_html}</div>
<div style="margin-top:12px;font-size:13px;color:#8B93A3">This list updates itself from the shared 73 calendar.</div>
<a href="/calendar" class="plink" style="display:inline-block;margin-top:14px;text-decoration:none">See the full {PROGRAM_YEAR} calendar →</a></div>
<div><div class="eyebrow dark">New here?</div><h2 class="sec" style="margin-bottom:26px">Your first visit, step by step.</h2>
<div class="steps">
<div class="step"><div class="n">01</div><div><div class="t">Say hello</div><div class="d">Send the interest form and a real leader (a local parent, not a call center) replies within a day or two.</div></div></div>
<div class="step"><div class="n">02</div><div><div class="t">Drop in on a {MEETING_DAY}</div><div class="d">Come watch a meeting, no commitment and nothing to bring. Kids usually join the fun within ten minutes.</div></div></div>
<div class="step"><div class="n">03</div><div><div class="t">Take it at your pace</div><div class="d">Register when your family is ready. We help with uniforms, costs, and every question in between.</div></div></div>
</div>
<a href="/join" class="cta" style="margin-top:24px;font-size:15px;padding:13px 26px;background:#1E2F52;color:#fff">Start with the form →</a></div>
</div></section>
<section style="padding:0 0 84px"><div class="wrap grid3 photoband">
<img src="/static/img/rockets.jpg" alt="Scouts building model rockets">
<img src="/static/img/lion-craft.jpg" alt="A Lion Cub Scout working on a craft">
<img src="/static/img/troop-boulder.jpg" alt="A scout climbing at the crag">
</div></section>
<section style="padding:0 0 88px"><div class="wrap"><div class="volband">
<img src="/static/img/trail-sign.jpg" alt="Parents and scouts exploring a trail together">
<div class="pad"><div class="eyebrow gold">Parents make it go</div><h2>We run on volunteers.</h2>
<p>Every derby track, campfire, and trail snack table is parent-powered. Nobody shows up knowing how; training is provided and the pack will happily teach you the knots.</p>
<p style="margin-bottom:22px">Help comes in every size: one chocolate booth shift, planning a hike, running a derby heat, or leading a den. The more hands we have, the better the year gets for every kid.</p>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:26px"><span class="vchip">One-shift helpers</span><span class="vchip">Hike planners</span><span class="vchip">Derby pit crew</span><span class="vchip">Den leaders</span></div>
<a class="cta" href="/join" style="align-self:start;font-size:15px;padding:13px 26px">Raise your hand →</a></div>
</div></div></section>
<div class="band" style="background:linear-gradient(180deg,rgba(13,22,40,.55),rgba(13,22,40,.72)),url('/static/img/campfire-dusk.jpg') center/cover no-repeat;padding:110px 24px">
<h2 style="font-size:clamp(2rem,4vw,3rem)">Your first campfire is waiting.</h2>
<p style="max-width:520px">Tell us a little about your child and we will save you a seat at the next meeting.</p>
<a class="cta" href="/join" style="padding:15px 34px">Join Pack &amp; Troop 73</a></div>"""
return page("Pack & Troop 73 · Family Scouting in Zieglerville, PA", body, "home")
@app.get("/cubs", response_class=HTMLResponse)
def cubs():
up = next_up({"pack"}, 3)
ev_html = next_up_cards(up) or '<div class="noev">Nothing on the books right now. New pack dates land on the shared calendar first, so check back soon or message us.</div>'
body = f"""
{hero('circle-game.jpg', 'center 30%', 'Pack 73 · Grades K-5', 'Little kids. Big adventures.',
'Cub Scouts is where it starts: derby cars, first hikes, campfire songs, and a den of friends who cheer each other on.')}
<section style="padding:76px 0 30px"><div class="wrap" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:44px;align-items:center">
<div><div class="eyebrow dark">How it works</div><h2 class="sec">A den for every grade, a pack for every family.</h2>
<p style="margin:0 0 14px;color:#5A6272;font-size:1.05rem">Scouts meet in small grade-level dens, and the whole pack comes together for the big stuff: derbies, campouts, and award nights. Meetings are {MEETING_DAYS} at {PACK_TIME} at St. Luke's, so siblings in different dens land on the same night.</p>
<p style="margin:0;color:#5A6272;font-size:1.05rem">A parent or guardian stays for every Cub Scout meeting and event; it is a do-it-together program. Lions and Tigers work hands-on with their grown-up, while older dens mostly need you in the room (and extra hands are always put to work).</p></div>
<img src="/static/img/lion-craft.jpg" alt="A Lion Cub Scout working on a craft" style="width:100%;height:380px;object-fit:cover;border-radius:18px">
</div></section>
<section style="padding:56px 0"><div class="wrap">
<div class="eyebrow dark">The trail through Cub Scouts</div><h2 class="sec" style="margin-bottom:30px">Six ranks, kindergarten to crossover.</h2>
<div class="rankgrid">
<div class="rank" style="background:#fff"><div class="rl">KINDERGARTEN</div><div class="rn">Lion</div><div class="rd">Big firsts with a grown-up along for every step.</div></div>
<div class="rank" style="background:#fff"><div class="rl">1ST GRADE</div><div class="rn">Tiger</div><div class="rd">Games, field trips, and the first real badges.</div></div>
<div class="rank" style="background:#fff"><div class="rl">2ND GRADE</div><div class="rn">Wolf</div><div class="rd">More independence, more time on the trail.</div></div>
<div class="rank" style="background:#fff"><div class="rl">3RD GRADE</div><div class="rn">Bear</div><div class="rd">Whittling chips, science, and real outdoor skills.</div></div>
<div class="rank" style="background:#fff"><div class="rl">4TH GRADE</div><div class="rn">Webelos</div><div class="rd">Camping like the big kids and leading the little ones.</div></div>
<div class="rank r7"><div class="rl">5TH GRADE</div><div class="rn">Arrow of Light</div><div class="rd">The bridge to the troop, crossing over each spring.</div></div>
</div></div></section>
<section style="padding:56px 0"><div class="wrap">
<div class="eyebrow dark">A year in the life</div><h2 class="sec" style="margin-bottom:30px">The traditions your kid will talk about all week.</h2>
<div class="grid4">
<div class="ycard"><img src="/static/img/craft-table.jpg" alt="Painting pinewood derby cars"><div class="pad"><div class="t">Derby season, twice</div><div class="d">Boxcar Derby in September, the classic Pinewood Derby in February. Build it, paint it, race it.</div></div></div>
<div class="ycard"><img src="/static/img/tent-lake.jpg" alt="Setting up tents at the fall campout"><div class="pad"><div class="t">Family campouts</div><div class="d">Fall and spring weekends under the stars at Green Lane Park and Camp Garrison, whole families welcome.</div></div></div>
<div class="ycard"><img src="/static/img/trail-sign.jpg" alt="Scouts on a monthly pack hike"><div class="pad"><div class="t">Monthly pack hikes</div><div class="d">A different trailhead almost every month, from the Perkiomen Trail to Green Lane. Strollers welcome.</div></div></div>
<div class="ycard"><img src="/static/img/rockets.jpg" alt="Scouts with model rockets"><div class="pad"><div class="t">Movie nights &amp; more</div><div class="d">Rockets, Trunk or Treat, Blue &amp; Gold banquet, Mario Party night, and a kickball party to close the year.</div></div></div>
</div></div></section>
<section style="padding:0 0 56px"><div class="wrap">
<div class="eyebrow dark">On the calendar</div><h2 class="sec" style="margin-bottom:24px;font-size:clamp(1.6rem,2.8vw,2.1rem)">Next up for Pack 73.</h2>
<div class="gridcards">{ev_html}</div>
<a href="/calendar?unit=pack" class="plink" style="display:inline-block;margin-top:18px;text-decoration:none">See the full pack calendar →</a>
</div></section>
<section style="padding:40px 0 80px"><div class="wrap">
<div style="background:#fff;border:1px solid #EBE4D4;border-radius:18px;padding:34px 36px;display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:28px;align-items:center">
<div><div class="eyebrow dark" style="margin-bottom:8px">Keeping it affordable</div>
<h2 class="arch" style="margin:0 0 10px;font-weight:800;font-size:1.5rem;color:#1E2F52">The famous chocolate booth.</h2>
<p style="margin:0;color:#5A6272;font-size:15.5px">You will find us selling chocolate bars at the Green Lane Farmers Market on Saturday mornings in season. Fundraisers like the booth keep dues low and help every scout get to camp. Cost should never keep a kid out of Scouting; if it is a stretch, talk to us.</p></div>
<div style="display:flex;flex-direction:column;gap:10px">
<div class="rrow"><span class="k" style="min-width:88px">{MEETING_DAYS}</span><span class="v">Den and pack meetings, {PACK_TIME} at St. Luke's</span></div>
<div class="rrow"><span class="k" style="min-width:88px">Monthly</span><span class="v">Weekend pack hike, usually 1:00 PM</span></div>
<div class="rrow"><span class="k" style="min-width:88px">Saturdays</span><span class="v">Chocolate booth at the Green Lane Farmers Market, 7:00 AM in season</span></div>
</div></div></div></section>
<div class="band" style="background:linear-gradient(180deg,rgba(13,22,40,.5),rgba(13,22,40,.7)),url('/static/img/autumn-hike.jpg') center/cover no-repeat">
<h2>Come try a {MEETING_DAY}.</h2>
<p style="max-width:520px">Your first visit is free and easy: just show up and see if it clicks. Most kids know by the end of the night.</p>
<a class="cta" href="/join" style="padding:15px 34px">Tell us you're coming</a></div>"""
return page("Cub Scouts · Pack 73 · Zieglerville, PA", body, "cubs")
@app.get("/troop", response_class=HTMLResponse)
def troop():
up = next_up({"troop"}, 3)
ev_html = next_up_cards(up) or '<div class="noev">Nothing on the books right now. New troop dates land on the shared calendar first, so check back soon or message us.</div>'
body = f"""
{hero('troop-hero.jpg', 'center 35%', 'Troop 73 · Ages 11-17', 'Youth led. Outdoors first.',
'The troop is where kids run the show: planning trips, leading patrols, and learning the kind of confidence you cannot teach indoors.')}
<section style="padding:76px 0 30px"><div class="wrap" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:44px;align-items:center">
<div><div class="eyebrow dark">How it works</div><h2 class="sec">The scouts run it. Really.</h2>
<p style="margin:0 0 14px;color:#5A6272;font-size:1.05rem">Troop 73 is organized into patrols that elect their leaders, plan the trips, cook the meals, and fix their own mistakes. Trained, background-checked adults coach from the back row.</p>
<p style="margin:0 0 14px;color:#5A6272;font-size:1.05rem">We meet {MEETING_DAYS} at {TROOP_TIME} at St. Luke's, right after the pack, so scouting families land on one night. Every kid ages 11 to 17 is welcome, with or without a Cub Scout past. (Officially we charter as a Scouts BSA troop. Around here, it's just the troop.)</p>
<p style="margin:0;color:#5A6272;font-size:1.05rem">Founded in spring 2026 at St. Luke's, the troop is built by the scouts in it; the traditions are theirs to invent.</p></div>
<img src="/static/img/troop-fire.jpg" alt="Scouts around the campfire circle at dusk" style="width:100%;height:380px;object-fit:cover;border-radius:18px">
</div></section>
<section style="padding:56px 0"><div class="wrap">
<div class="eyebrow dark">The trail through the troop</div><h2 class="sec" style="margin-bottom:30px">Seven ranks, first knot to Eagle.</h2>
<div class="rankgrid">
<div class="rank r1"><div class="rl">STOP 1</div><div class="rn">Scout</div><div class="rd">The basics, the oath, and the handshake. Everyone starts here.</div></div>
<div class="rank r2"><div class="rl">STOP 2</div><div class="rn">Tenderfoot</div><div class="rd">First campout skills: knots, flags, and first aid.</div></div>
<div class="rank r3"><div class="rl">STOP 3</div><div class="rn">Second Class</div><div class="rd">Navigation, outdoor cooking, and safe swimming.</div></div>
<div class="rank r4"><div class="rl">STOP 4</div><div class="rn">First Class</div><div class="rd">The full outdoor toolkit, ready to lead a patrol.</div></div>
<div class="rank r5"><div class="rl">STOP 5</div><div class="rn">Star</div><div class="rd">Leadership roles and merit badges start stacking up.</div></div>
<div class="rank r6"><div class="rl">STOP 6</div><div class="rn">Life</div><div class="rd">Leading service projects and mentoring younger scouts.</div></div>
<div class="rank r7"><div class="rl">THE SUMMIT</div><div class="rn">Eagle</div><div class="rd">Scouting's highest rank, earned with a big service project of their own.</div></div>
</div></div></section>
<section style="padding:56px 0"><div class="wrap">
<div class="eyebrow dark">A year in the life</div><h2 class="sec" style="margin-bottom:30px">Weekends worth putting the phone down for.</h2>
<div class="grid4">
<div class="ycard"><img src="/static/img/troop-tents.jpg" alt="Scouts relaxing at a campsite"><div class="pad"><div class="t">Campouts in every season</div><div class="d">Fall, winter, and spring weekends. Scouts plan the menu, pack the trailer, and run the camp.</div></div></div>
<div class="ycard"><img src="/static/img/troop-raft.jpg" alt="Scouts whitewater rafting"><div class="pad"><div class="t">On the water</div><div class="d">Canoe and river trips when the weather warms, including the troop's May river run.</div></div></div>
<div class="ycard"><img src="/static/img/troop-climb.jpg" alt="A scout on a climbing wall"><div class="pad"><div class="t">Climb, build, navigate</div><div class="d">Climbing, orienteering, camp gadgets, and the Klondike winter derby: skills merit badges are made of.</div></div></div>
<div class="ycard"><img src="/static/img/troop-service.jpg" alt="Scouts doing a community service project"><div class="pad"><div class="t">Service &amp; Courts of Honor</div><div class="d">Projects that make the town better, then a Court of Honor where every rank gets its moment.</div></div></div>
</div></div></section>
<section style="padding:0 0 56px"><div class="wrap">
<div class="eyebrow dark">On the calendar</div><h2 class="sec" style="margin-bottom:24px;font-size:clamp(1.6rem,2.8vw,2.1rem)">Next up for Troop 73.</h2>
<div class="gridcards">{ev_html}</div>
<a href="/calendar?unit=troop" class="plink" style="display:inline-block;margin-top:18px;text-decoration:none">See the full troop calendar →</a>
</div></section>
<section style="padding:40px 0 80px"><div class="wrap"><div class="infocard">
<div class="pad"><div class="eyebrow dark" style="margin-bottom:8px">The rhythm</div>
<h2 class="arch" style="margin:0 0 14px;font-weight:800;font-size:1.5rem;color:#1E2F52">One night a week, one adventure a month.</h2>
<div style="display:flex;flex-direction:column;gap:10px;margin-bottom:16px">
<div class="rrow"><span class="k">{MEETING_DAYS}</span><span class="v">Troop meeting, {TROOP_TIME} at St. Luke's, right after the pack</span></div>
<div class="rrow"><span class="k">Most months</span><span class="v">A weekend campout, trip, or service project</span></div>
<div class="rrow"><span class="k">All year</span><span class="v">Merit badges and rank work at each scout's own pace</span></div>
</div>
<p style="margin:0;color:#5A6272;font-size:14.5px">Registration plus modest troop dues; fundraisers keep costs low and gear can be borrowed. Cost never keeps a kid off the trail.</p></div>
<img src="/static/img/troop-honor.jpg" alt="Scouts in uniform at a Court of Honor">
</div></div></section>
<div class="band" style="background:linear-gradient(180deg,rgba(13,22,40,.42),rgba(13,22,40,.6)),url('/static/img/morning-trail.jpg') center/cover no-repeat">
<h2 style="margin-bottom:10px">No experience needed. Curiosity helps.</h2>
<p style="margin-bottom:0">Plenty of great scouts never spent a day in Cub Scouts. If your kid is 11 to 17 and likes the idea of a weekend outdoors, they belong here.</p></div>
<div style="background:#FBF8F1;padding:84px 24px;text-align:center">
<h2 class="arch" style="margin:0 0 12px;font-weight:800;font-size:clamp(1.8rem,3.4vw,2.6rem);color:#1E2F52;letter-spacing:-.01em">Come watch a {MEETING_DAY}.</h2>
<p style="margin:0 auto 28px;color:#5A6272;font-size:1.06rem;max-width:480px">The troop takes over at {TROOP_TIME} at St. Luke's. Drop in, meet the patrols, stay if it clicks.</p>
<a class="cta" href="/join" style="padding:15px 34px">Message the troop</a></div>"""
return page("The Troop · Troop 73 · Zieglerville, PA", body, "troop")
@app.get("/calendar", response_class=HTMLResponse)
def calendar(unit: str = "all"):
if unit not in ("all", "pack", "troop"): unit = "all"
groups = {}
for ev in load_events():
u = ev.get("unit", "pack")
if unit != "all" and u != unit and u != "both": continue
s, e = _dates(ev)
key = s.year * 12 + s.month
groups.setdefault(key, []).append((s, e, ev))
cards = []
for key in sorted(groups):
rows = []
for s, e, ev in sorted(groups[key], key=lambda t: t[0]):
label = f"{DWU[s.weekday()]} {s.day}" + (f"-{e.day}" if e != s else "")
u = ev.get("unit", "pack")
badge = f' <span class="badge">{ev["badge"]}</span>' if ev.get("badge") else ""
meta = " · ".join(x for x in [ev.get("time"), ev.get("location")] if x)
meta = f'<span class="mm"> · {meta}</span>' if meta else ""
rows.append(f'<div class="mrow"><span class="ml">{label}</span><span class="mt"><span style="font-weight:600">{ev["title"]}</span> <span class="uchip uchip-{u}">{unit_label(u)}</span>{badge}{meta}</span></div>')
y, m = divmod(key - 1, 12)
cards.append(f'<div class="mcard"><div class="mhead"><span class="mn">{MF[m]}</span><span class="my">{y}</span></div><div style="display:flex;flex-direction:column;gap:13px">{"".join(rows)}</div></div>')
sel = {u: " selected" if unit == u else "" for u in ("all", "pack", "troop")}
body = f"""
<div class="calhead"><div class="wrap">
<div class="eyebrow gold">Pack &amp; Troop 73 program year</div>
<h1>The {PROGRAM_YEAR} year, at a glance.</h1>
<p class="sub">The highlights below are open to visiting families. Come to any of them, or just show up on a {MEETING_DAY}.</p>
<div class="rhythm">
<div><div class="t">Pack {MEETING_DAYS} · {PACK_TIME}</div><div class="d">Pack or den meetings at St. Luke's Lutheran Church (off on school holidays)</div></div>
<div><div class="t">Troop {MEETING_DAYS} · {TROOP_TIME}</div><div class="d">Troop 73 meets right after the pack, same building</div></div>
<div><div class="t">About monthly · weekends</div><div class="d">Pack hikes at rotating trailheads, usually 1:00 PM</div></div>
<div><div class="t">Saturdays in season · 7:00 AM</div><div class="d">Chocolate booth at the Green Lane Farmers Market</div></div>
</div></div></div>
<section style="padding:52px 0 30px">
<div class="wrap" style="padding-bottom:24px;display:flex;gap:10px;flex-wrap:wrap;align-items:center">
<form method="get" action="/calendar" style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<label for="unit" style="margin:0;font-size:13px;font-weight:700;letter-spacing:.1em;color:#8B93A3;text-transform:uppercase">Show</label>
<select id="unit" name="unit" class="unitsel" onchange="this.form.submit()">
<option value="all"{sel['all']}>Everything · Pack &amp; Troop</option>
<option value="pack"{sel['pack']}>Pack 73 · Grades K-5</option>
<option value="troop"{sel['troop']}>Troop 73 · Ages 11-17</option>
</select>
<noscript><button class="cta" style="padding:12px 20px;font-size:14px">Go</button></noscript>
</form></div>
<div class="wrap" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:24px">{"".join(cards)}</div>
</section>
<section style="padding:26px 0 80px"><div class="wrap"><div class="note">
Dates occasionally shift. Registered families get the live TeamSnap calendar with every den and troop meeting; the newest info always wins there.
</div></div></section>"""
return page(f"{PROGRAM_YEAR} Calendar · Pack & Troop 73", body, "cal")
# ---------------------------------------------------------------------------
# Documents. Files come from the manifest in /docs (see documents.py); they are
# deliberately served through this route rather than a static mount, so member
# login can gate them later without a single public URL changing.
# ---------------------------------------------------------------------------
def doc_row(d):
label, _ = documents.kind(d)
u = d["unit"]
chip = f' <span class="uchip uchip-{u}">{unit_label(u)}</span>' if u != "both" else ""
desc = f'<span class="docd">{d["description"]}</span>' if d["description"] else ""
meta = " · ".join(x for x in [documents.size_text(d), documents.updated_text(d)] if x)
return (f'<a class="docrow" href="/documents/{d["slug"]}">'
f'<span class="docicon">{label}</span>'
f'<span class="docmain"><span class="doct">{d["title"]}{chip}</span>{desc}</span>'
f'<span class="docmeta">{meta}</span></a>')
@app.get("/documents", response_class=HTMLResponse)
def documents_index():
groups = documents.listing()
if groups:
cards = []
for cat, rows in groups:
blurb = f'<p class="lead" style="margin:0 0 18px;font-size:15px">{cat["blurb"]}</p>' if cat["blurb"] else ""
cards.append(f'<div style="margin-bottom:44px"><h2 class="sec" style="font-size:1.5rem;margin-bottom:6px">{cat["title"]}</h2>{blurb}'
f'<div style="display:flex;flex-direction:column;gap:12px">{"".join(doc_row(d) for d in rows)}</div></div>')
inner = "".join(cards)
else:
inner = '<div class="noev">Nothing posted here yet. Forms and handouts will land on this page as the program year gets going.</div>'
body = f"""
<div class="calhead"><div class="wrap">
<div class="eyebrow gold">Pack &amp; Troop 73</div>
<h1>Forms and documents.</h1>
<p class="sub">Everything worth handing out, in one place. These links are permanent, so a QR code or a printed handout keeps working even after the file behind it is replaced.</p>
</div></div>
<section style="padding:52px 0 70px"><div class="wrap">{inner}</div></section>
<section style="padding:0 0 80px"><div class="wrap"><div class="note">
Can't find something, or need a form in another format? Message us on Facebook or catch a leader on a {MEETING_DAY} night.
</div></div></section>"""
return page("Forms & Documents · Pack & Troop 73", body, "docs")
@app.get("/documents/{slug}")
def document_file(slug: str):
d = documents.find(slug)
if d is None:
body = """
<div class="calhead"><div class="wrap">
<div class="eyebrow gold">Pack &amp; Troop 73</div>
<h1>That document isn't here.</h1>
<p class="sub">The link may be out of date, or the file may have been taken down. Everything currently posted is on the documents page.</p>
<a class="cta" href="/documents">See all documents</a>
</div></div>"""
return HTMLResponse(page("Not found · Pack & Troop 73", body, "docs"), status_code=404)
_, mime = documents.kind(d)
headers = {"X-Robots-Tag": "noindex, nofollow"} if documents.noindex(d) else None
return FileResponse(d["path"], media_type=mime, filename=d["path"].name,
content_disposition_type=documents.disposition(d),
headers=headers)
# ---------------------------------------------------------------------------
# Find a unit. Other people's units, from nearby_units (see nearby.py). There
# are deliberately NO entry points elsewhere on the site yet - reachable by
# direct link only until the placement is settled. noindex, so a courtesy
# directory never outranks another unit for its own name.
# ---------------------------------------------------------------------------
@app.get("/find-a-unit", response_class=HTMLResponse)
def find_a_unit():
h = hero("morning-trail.jpg", "center 62%",
"Continental District \u00b7 Scouting America",
"Find a unit near you.",
"Packs, troops, a ship and an Exploring club within a short drive of "
"Zieglerville, so you can pick the one that fits your family.",
minh=440)
return HTMLResponse(page("Find a Unit Near You \u00b7 Pack & Troop 73",
nearby.body_html(h), "nearby"),
headers={"X-Robots-Tag": "noindex, nofollow"})
FAQ = [
("What does it cost?", "Annual registration plus modest pack or troop dues. Fundraisers like our chocolate booth keep costs down, and quiet help is available. Cost never keeps a kid out of Pack 73; just talk to us."),
("Do parents stay at meetings?", "Yes: a parent or guardian stays at every Cub Scout meeting and event. Lions and Tigers pair up with their grown-up for the hands-on parts; for older dens, being in the room is enough. Troop scouts (11 to 17) can be dropped off."),
("Which program fits my kid?", "K through 5th grade join Pack 73's dens. Ages 11 to 17 join Troop 73. Fifth graders get the best of both: Arrow of Light now, crossover to the troop in spring."),
("Can we join mid-year?", f"Any {MEETING_DAY} works. Badges and adventures meet kids where they are, so a January start is just as good as a September one."),
("What about uniforms and gear?", "Start with sneakers and a water bottle. Uniform pieces come gradually, and we keep a stash of hand-me-downs. Nobody needs camping gear to start."),
("Who can join?", "Every kid. Pack 73 and Troop 73 are open to all children and teens, whatever their background or beliefs. If your kid wants to camp, build, and belong, there's a spot for them."),
]
SOURCE_JS = """<script>
(function(){var s=document.getElementById('source');if(!s)return;s.addEventListener('change',function(){var v=s.value;document.getElementById('source-place').style.display=v==='Other school or daycare'?'block':'none';document.getElementById('source-other').style.display=v==='Other'?'block':'none';});})();
</script>"""
def thanks_body():
return f"""
<section style="padding:72px 0 40px"><div class="wrap" style="max-width:760px">
<div class="success" style="font-size:1.05rem;padding:20px 24px;margin-bottom:30px">Thanks! Your note is on its way. A leader will be in touch soon.</div>
<div class="eyebrow dark">Join Pack &amp; Troop 73</div>
<h1 class="arch" style="margin:0 0 16px;font-weight:800;font-size:clamp(2rem,4.5vw,3rem);line-height:1.05;letter-spacing:-.015em;color:#1E2F52;max-width:16ch">We got it. Here is what happens next.</h1>
<p style="margin:0 0 30px;color:#5A6272;font-size:1.08rem;max-width:560px">No commitment, and your first visit is always free. There is nothing else you need to do - we will be in touch with the next meeting date.</p>
<div style="display:flex;flex-direction:column;gap:16px;margin-bottom:30px">
<div class="rrow"><span class="k arch" style="min-width:32px;font-size:20px">1</span><span class="v" style="font-size:15.5px"><strong>Your form is in.</strong> It went straight to our volunteer leaders' phones.</span></div>
<div class="rrow"><span class="k arch" style="min-width:32px;font-size:20px">2</span><span class="v" style="font-size:15.5px"><strong>Hear back within a day or two</strong> from a real Pack or Troop 73 parent, with the right den or troop and directions.</span></div>
<div class="rrow"><span class="k arch" style="min-width:32px;font-size:20px">3</span><span class="v" style="font-size:15.5px"><strong>Visit a {MEETING_DAY}</strong> at St. Luke's: Pack {PACK_CLOCK}, Troop {TROOP_CLOCK}. Sneakers and a water bottle are all you need.</span></div>
</div>
<div class="note">If a couple of days go by with nothing, check your spam folder, then send the form again. We would rather hear twice than miss you.</div>
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-top:30px">
<a class="cta" href="/calendar" style="font-size:15px;padding:13px 26px">See the year ahead</a>
<a class="cta" href="/cubs" style="font-size:15px;padding:13px 26px;background:#1E2F52;color:#fff">What a meeting looks like</a>
</div>
<img src="/static/img/circle-game.jpg" alt="Scouts cheering during a pack game" style="width:100%;height:260px;object-fit:cover;border-radius:16px;margin-top:36px">
</div></section>"""
def join_body(error=""):
err_html = (
'<div style="background:#FDECEC;border:1px solid #E4A0A0;color:#8A2B2B;padding:12px 14px;border-radius:10px;font-size:14.5px;margin-bottom:4px">'
+ error + "</div>") if error else ""
faq_html = "".join(f'<div class="faq"><div class="q">{q}</div><div class="a">{a}</div></div>' for q, a in FAQ)
return f"""
<section style="padding:72px 0 30px"><div class="wrap" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:52px;align-items:start">
<div><div class="eyebrow dark">Join Pack &amp; Troop 73</div>
<h1 class="arch" style="margin:0 0 16px;font-weight:800;font-size:clamp(2.2rem,4.5vw,3.4rem);line-height:1.05;letter-spacing:-.015em;color:#1E2F52;max-width:14ch">Come see a meeting. That's it.</h1>
<p style="margin:0 0 26px;color:#5A6272;font-size:1.08rem;max-width:520px">Tell us a little about your child and a leader will reach out with the right den or troop, what to expect, and directions. No commitment, and your first visit is always free.</p>
<div style="display:flex;flex-direction:column;gap:16px;margin-bottom:30px">
<div class="rrow"><span class="k arch" style="min-width:32px;font-size:20px">1</span><span class="v" style="font-size:15.5px"><strong>Send the form.</strong> It goes straight to our volunteer leaders' phones.</span></div>
<div class="rrow"><span class="k arch" style="min-width:32px;font-size:20px">2</span><span class="v" style="font-size:15.5px"><strong>Hear back within a day or two</strong> from a real Pack or Troop 73 parent.</span></div>
<div class="rrow"><span class="k arch" style="min-width:32px;font-size:20px">3</span><span class="v" style="font-size:15.5px"><strong>Visit a {MEETING_DAY}</strong> at St. Luke's: Pack {PACK_CLOCK}, Troop {TROOP_CLOCK}. Sneakers recommended.</span></div>
</div>
<img src="/static/img/circle-game.jpg" alt="Scouts cheering during a pack game" style="width:100%;height:280px;object-fit:cover;border-radius:16px"></div>
<div>
<form class="lead" method="post" action="/join">
<div class="ft">Interest form</div>{err_html}
<div aria-hidden="true" style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden"><label>Company website</label><input name="website" type="text" tabindex="-1" autocomplete="off"></div>
<div class="frow">
<div><label>Parent / guardian name <span class="req">*</span></label><input name="parent_name" required></div>
<div><label>Email <span class="req">*</span></label><input type="email" name="email" required></div>
</div>
<div class="frow">
<div><label>Phone</label><input name="phone" inputmode="tel" placeholder="(610) 555-0100"></div>
<div><label>Interested in <span class="req">*</span></label><select name="interested_in" required><option value="" disabled selected>Select one...</option><option value="Pack 73 (Cub Scouts, K-5)">Pack 73: Cub Scouts (K-5)</option><option value="Troop 73 (Scouts BSA, 11-17)">Troop 73: ages 11-17</option><option value="Both / kids spanning ages">Both: kids spanning ages</option></select></div>
</div>
<div><label>Child(ren): names and current grades <span class="req">*</span></label><textarea name="children" rows="2" required placeholder="Emma (Kindergarten), Jack (2nd grade)"></textarea><div style="font-size:12.5px;color:#8B93A3;margin-top:4px">List all kids you're considering. Any grade K-12 welcome.</div></div>
<div><label>How did you hear about us? <span class="req">*</span></label><select name="source" id="source" required><option value="" disabled selected>Select one...</option>
<optgroup label="Daycares &amp; Preschools"><option>New Hanover Child Care</option><option>Jerusalem Lutheran Nursery</option><option>Goddard School Schwenksville</option><option>St. Teresa Early Learning</option></optgroup>
<optgroup label="Elementary Schools"><option>Schwenksville Elementary</option><option>Marlborough Elementary (Green Lane)</option><option>New Hanover Elementary</option><option>Upper Perkiomen 4/5 Center</option></optgroup>
<optgroup label="Middle &amp; High Schools"><option>Perkiomen Valley Middle School</option><option>Upper Perkiomen Middle School</option><option>Perkiomen Valley High School</option><option>Upper Perkiomen High School</option></optgroup>
<optgroup label="Pack / Troop Events"><option>Back-to-School Night table</option><option>Pack Open House</option><option>Sign-Up Night</option></optgroup>
<optgroup label="Other"><option>Pack 73 / Troop 73 family referral</option><option>BeAScout / searched online</option><option value="Other school or daycare">Other school or daycare</option><option value="Other">Other</option></optgroup>
</select></div>
<div id="source-place" style="display:none"><label>Which school or daycare?</label><input name="source_place" placeholder="e.g. Oak Ridge Elementary"></div>
<div id="source-other" style="display:none"><label>Please tell us how you heard about us</label><input name="source_other" placeholder="e.g. a friend, Facebook, yard sign"></div>
<div><label>Questions, or anything you'd like us to know</label><textarea name="message" rows="3"></textarea></div>
<button class="cta" type="submit" style="justify-self:start;font-size:16px;padding:14px 30px">Send interest form</button>
<div style="font-size:13px;color:#8B93A3">We only use this to reply about Scouting. No newsletters, no sharing.</div>
</form>
{SOURCE_JS}</div>
</div></section>
<section style="padding:64px 0 84px"><div class="wrap">
<div class="eyebrow dark">New-family FAQ</div><h2 class="sec" style="margin-bottom:30px">The questions every new family asks.</h2>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:22px">{faq_html}</div>
</div></section>
<div class="band" style="background:linear-gradient(180deg,rgba(13,22,40,.5),rgba(13,22,40,.68)),url('/static/img/campfire-dusk.jpg') center/cover no-repeat;padding:88px 24px">
<h2 style="font-size:clamp(1.8rem,3.4vw,2.6rem);margin-bottom:12px">See you on a {MEETING_DAY}.</h2>
<p style="margin-bottom:0;max-width:480px">St. Luke's Lutheran Church, Zieglerville · Pack {PACK_CLOCK}, Troop {TROOP_CLOCK}. Look for the kids having the most fun in the building.</p></div>"""
@app.get("/join", response_class=HTMLResponse)
def join_get(sent: int = 0):
if sent:
return page("Thanks · Pack & Troop 73", thanks_body())
return page("Join us · Pack & Troop 73", join_body())
@app.get("/contact")
def contact_redirect():
return RedirectResponse(url="/join", status_code=301)
def sheet_append(rec):
import gspread
from google.oauth2.service_account import Credentials
creds = Credentials.from_service_account_file(SA_FILE, scopes=["https://www.googleapis.com/auth/spreadsheets"])
sheet = gspread.authorize(creds).open_by_key(SHEET_ID).sheet1
vals = sheet.get_all_values()
if not vals:
sheet.append_row(SHEET_HEADERS)
else:
if len(vals[0]) == 7:
sheet.update_cell(1, 8, "Comments")
# The header used to read "fall grade". Repair it in place rather than
# leaving the live sheet disagreeing with the form label.
if len(vals[0]) >= 5 and vals[0][4] != SHEET_HEADERS[4]:
sheet.update_cell(1, 5, SHEET_HEADERS[4])
# The submission time, not the time of the write. A replay months later must
# not relabel a lead as arriving today.
stamp = (rec.get("ts") or "")[:16].replace("T", " ") \
or datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
sheet.append_row([
stamp,
rec["parent_name"], rec["email"], rec["phone"], rec["children"],
rec["interested_in"], rec["source"], rec["message"],
])
def pipeline_notify(rec, sheet_error=None):
"""Push to the shared 'scouting' ntfy topic (JSON endpoint, UTF-8 safe)."""
if not NTFY_BASE:
return False
lines = [
"Email: %s" % rec["email"],
"Phone: %s" % (rec["phone"] or "(none given)"),
"Kids: %s" % rec["children"],
"Wants: %s" % rec["interested_in"],
]
if rec["source"]:
lines.append("Heard via: %s" % rec["source"])
if rec["message"]:
lines.append("Note: %s" % rec["message"])
if sheet_error:
lines.append("")
lines.append("!! NOT saved to the sheet: %s" % sheet_error)
lines.append("!! This push is the only copy - record it manually.")
headers = {"Content-Type": "application/json"}
if NTFY_TOKEN:
headers["Authorization"] = "Bearer " + NTFY_TOKEN
try:
body = json.dumps({
"topic": NTFY_TOPIC,
"title": "New 73 lead - %s" % rec["parent_name"],
"message": "\n".join(lines),
"tags": ["tent", "inbox_tray"],
"priority": 5 if sheet_error else 4,
}).encode()
rq = urllib.request.Request(NTFY_BASE, data=body, headers=headers)
with urllib.request.urlopen(rq, timeout=5) as resp:
return resp.status < 400
except Exception:
return False
# --- form integrity --------------------------------------------------------
# 2026-08-30: three bot submissions landed with both <select> fields carrying
# the placeholder label "Select one...". `required` is browser-only, and a
# direct POST skips it entirely. Validate server-side; honeypot the rest.
VALID_INTERESTED = {
"Pack 73 (Cub Scouts, K-5)",
"Troop 73 (Scouts BSA, 11-17)",
"Both / kids spanning ages",
}
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s.]+(\.[^@\s.]+)+$")
def lead_problem(parent_name, email, children, interested_in, source):
"""Return a short reason string if this submission cannot be genuine."""
if len(parent_name.strip()) < 2:
return "name"
if not EMAIL_RE.match(email.strip()):
return "email"
if len(children.strip()) < 2:
return "children"
if interested_in.strip() not in VALID_INTERESTED:
return "interested_in"
if not source.strip():
return "source"
for v in (parent_name, children, interested_in, source):
if "select one" in v.lower():
return "placeholder"
return ""
@app.post("/join")
def join_post(parent_name: str = Form(...), email: str = Form(...), phone: str = Form(""),
children: str = Form(""), interested_in: str = Form(""), source: str = Form(""),
source_place: str = Form(""), source_other: str = Form(""), message: str = Form(""),
website: str = Form(""), request: Request = None):
client_ip = "?"
if request is not None:
client_ip = (request.headers.get("x-forwarded-for", "").split(",")[0].strip()
or (request.client.host if request.client else "?"))
# Honeypot. A browser never fills a field it cannot see. Answer 303 anyway
# so the bot books a success and has no signal to tune against.
if website.strip():
print("LEAD REJECTED (honeypot) from %s: %r <%s>"
% (client_ip, parent_name[:40], email[:60]), flush=True)
return RedirectResponse(url="/join?sent=1", status_code=303)
problem = lead_problem(parent_name, email, children, interested_in, source)
if problem:
print("LEAD REJECTED (%s) from %s: %r <%s>"
% (problem, client_ip, parent_name[:40], email[:60]), flush=True)
return HTMLResponse(page("Join us \u00b7 Pack & Troop 73", join_body(
error="We could not read that submission. Please complete every field "
"marked * and send it again.")), status_code=400)
heard_from = source.strip()
heard_from_detail = ""
if source == "Other school or daycare" and source_place.strip():
heard_from_detail = source_place.strip()
source = "School/Daycare: " + heard_from_detail
elif source == "Other" and source_other.strip():
heard_from_detail = source_other.strip()
source = "Other: " + heard_from_detail
rec = {"ts": datetime.datetime.now().isoformat(timespec="seconds"),
"parent_name": parent_name.strip(), "email": email.strip(), "phone": phone.strip(),
"children": children.strip() or "(not given)",
"interested_in": interested_in.strip() or "(not given)",
"source": source.strip() or "greenlanescouts73.org", "message": message.strip()}
try:
with open(LEADS, "a", encoding="utf-8") as f:
f.write(json.dumps(rec) + "\n")
except Exception:
pass
# Land it internally FIRST. Everything below this point is a mirror of a
# row that already exists locally, and each mirror's outcome is recorded
# against the record so a failure is replayable instead of just shouted.
record_id = None
try:
record_id = store.insert_lead(
parent_name=rec["parent_name"], email=rec["email"], phone=rec["phone"],
interested_in=rec["interested_in"], children=rec["children"],
heard_from=heard_from, heard_from_detail=heard_from_detail,
message=rec["message"], payload=rec, submitted_at=rec["ts"])
except Exception as e:
print("STORE WRITE FAILED for %s <%s>: %s" % (rec["parent_name"], rec["email"], e), flush=True)
# Register every mirror as pending BEFORE attempting it. If the process dies
# or a call never runs, the gap is visible and replayable instead of the row
# simply having no mirror record at all.
if record_id:
for _t in store.TARGETS:
store.set_mirror_pending(record_id, _t)
sheet_error = None
try:
sheet_append(rec)
except Exception as e:
sheet_error = str(e)
print("SHEET WRITE FAILED for %s <%s>: %s" % (rec["parent_name"], rec["email"], e), flush=True)
if record_id:
store.set_mirror(record_id, "google_sheet", sheet_error is None, sheet_error)
delivered = pipeline_notify(rec, sheet_error)
if record_id:
store.set_mirror(record_id, "ntfy", bool(delivered),
None if delivered else "ntfy push not delivered")
if sheet_error and not delivered and NTFY_URL:
# last-ditch push on the fallback topic so a lead is never silently lost
try:
b = ("LEAD (sheet+push failed): %s (%s) | Kids: %s | %s | %s" % (
rec["parent_name"], rec["email"], rec["children"], rec["interested_in"], rec["source"])).encode()
rq = urllib.request.Request(NTFY_URL, data=b, headers={"Title": "greenlanescouts73.org lead", "Tags": "tent,warning"})
urllib.request.urlopen(rq, timeout=3)
except Exception:
pass
return RedirectResponse(url="/join?sent=1", status_code=303)