On a phone the /join grid collapses to one column, so the success banner rendered below the entire pitch column and the 303 landed the parent at the top of an apparently unchanged page. The empty form underneath then read as a failed send. ?sent=1 now returns a dedicated confirmation body: banner first, what happens next, and no form to resubmit.
710 lines
58 KiB
Python
710 lines
58 KiB
Python
import json, os, shutil, datetime, urllib.request, urllib.parse
|
|
from pathlib import Path
|
|
from fastapi import FastAPI, Form
|
|
from fastapi.responses import 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
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pack & Troop 73 - greenlanescouts73.org
|
|
# Calendar lives in ONE file: /data/events.json (seeded from app/events.json on
|
|
# first boot). Edit that file on the host (or ask Claude) and every page that
|
|
# shows dates updates on the next refresh. No rebuild needed.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
DATA = Path("/data"); DATA.mkdir(parents=True, exist_ok=True)
|
|
LEADS = DATA / "leads.jsonl"
|
|
EVENTS_LIVE = DATA / "events.json"
|
|
EVENTS_SEED = Path(__file__).parent / "events.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 + fall grade)", "Interested In", "How They Heard", "Comments"]
|
|
|
|
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)
|
|
|
|
if not EVENTS_LIVE.exists() and EVENTS_SEED.exists():
|
|
try: shutil.copyfile(EVENTS_SEED, EVENTS_LIVE)
|
|
except Exception: pass
|
|
|
|
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']
|
|
|
|
def load_events():
|
|
for p in (EVENTS_LIVE, EVENTS_SEED):
|
|
try:
|
|
with open(p, encoding="utf-8") as f:
|
|
return json.load(f).get("events", [])
|
|
except Exception:
|
|
continue
|
|
return []
|
|
|
|
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}
|
|
"""
|
|
|
|
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. Tuesdays 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 & Troop 73">
|
|
<meta property="og:title" content="{title}">
|
|
<meta property="og:description" content="A Cub Scout pack and a Scout troop in Zieglerville, PA. Tuesdays 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">
|
|
<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 & 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>
|
|
{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 & 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">Tuesdays · Pack 6:00 · Troop 7:30<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">2026-2027 Calendar</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 this fall</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">Tue · Pack 6:00 · Troop 7:30</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 2026-2027 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 Tuesday</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 & 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 Tuesdays at 6:00 PM 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 & more</div><div class="d">Rockets, Trunk or Treat, Blue & 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. 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">Tuesdays</span><span class="v">Den and pack meetings, 6:00 PM 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</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 Tuesday.</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 Tuesdays at 7:30 PM 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 & 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">Tuesdays</span><span class="v">Troop meeting, 7:30 PM 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 Tuesday.</h2>
|
|
<p style="margin:0 auto 28px;color:#5A6272;font-size:1.06rem;max-width:480px">The troop takes over at 7:30 PM 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 & Troop 73 program year</div>
|
|
<h1>The 2026-2027 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 Tuesday.</p>
|
|
<div class="rhythm">
|
|
<div><div class="t">Pack Tuesdays · 6:00 PM</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 Tuesdays · 7:30 PM</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 · 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 & 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("2026-2027 Calendar · Pack & Troop 73", body, "cal")
|
|
|
|
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?", "Any Tuesday 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 """
|
|
<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 & 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 to do between now and Tuesday.</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 Tuesday</strong> at St. Luke's: Pack 6:00, Troop 7:30. 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():
|
|
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 & 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 Tuesday</strong> at St. Luke's: Pack 6:00, Troop 7:30. 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>
|
|
<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 grades this fall <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 & 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 & 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 Tuesday.</h2>
|
|
<p style="margin-bottom:0;max-width:480px">St. Luke's Lutheran Church, Zieglerville · Pack 6:00, Troop 7:30. 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)
|
|
elif len(vals[0]) == 7:
|
|
sheet.update_cell(1, 8, "Comments")
|
|
# 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
|
|
|
|
@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("")):
|
|
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)
|