From 7a0b96083e1932f539ad82f2b1faee0a953ebd2b Mon Sep 17 00:00:00 2001 From: Chubi-alt <197414625+Chubi-alt@users.noreply.github.com> Date: Thu, 14 May 2026 00:04:52 +0800 Subject: [PATCH] Add recreation.gov mirror --- .dockerignore | 1 + .gitignore | 8 +- Dockerfile | 4 +- GUIDANCE.md | 535 ++++ control_server.py | 2 +- sites/recreation_gov/_health.py | 3 + sites/recreation_gov/app.py | 1579 ++++++++++++ sites/recreation_gov/requirements.txt | 4 + sites/recreation_gov/seed_data.py | 395 +++ sites/recreation_gov/static/css/.gitkeep | 0 sites/recreation_gov/static/css/main.css | 2207 +++++++++++++++++ sites/recreation_gov/static/icons/.gitkeep | 0 sites/recreation_gov/static/js/.gitkeep | 0 sites/recreation_gov/static/js/main.js | 380 +++ sites/recreation_gov/tasks.jsonl | 20 + sites/recreation_gov/templates/.gitkeep | 0 sites/recreation_gov/templates/404.html | 11 + .../templates/_facility_card.html | 54 + .../templates/_reviews_section.html | 75 + sites/recreation_gov/templates/account.html | 27 + sites/recreation_gov/templates/article.html | 27 + sites/recreation_gov/templates/articles.html | 23 + sites/recreation_gov/templates/base.html | 99 + sites/recreation_gov/templates/cart.html | 23 + sites/recreation_gov/templates/category.html | 24 + sites/recreation_gov/templates/checkout.html | 21 + .../templates/facility_detail.html | 296 +++ sites/recreation_gov/templates/help.html | 99 + sites/recreation_gov/templates/index.html | 333 +++ sites/recreation_gov/templates/login.html | 24 + sites/recreation_gov/templates/register.html | 25 + .../templates/reservations.html | 17 + sites/recreation_gov/templates/saved.html | 10 + sites/recreation_gov/templates/search.html | 97 + .../templates/site_pass_detail.html | 71 + websyn_start.sh | 12 +- 36 files changed, 6494 insertions(+), 12 deletions(-) create mode 100644 GUIDANCE.md create mode 100644 sites/recreation_gov/_health.py create mode 100644 sites/recreation_gov/app.py create mode 100644 sites/recreation_gov/requirements.txt create mode 100644 sites/recreation_gov/seed_data.py create mode 100644 sites/recreation_gov/static/css/.gitkeep create mode 100644 sites/recreation_gov/static/css/main.css create mode 100644 sites/recreation_gov/static/icons/.gitkeep create mode 100644 sites/recreation_gov/static/js/.gitkeep create mode 100644 sites/recreation_gov/static/js/main.js create mode 100644 sites/recreation_gov/tasks.jsonl create mode 100644 sites/recreation_gov/templates/.gitkeep create mode 100644 sites/recreation_gov/templates/404.html create mode 100644 sites/recreation_gov/templates/_facility_card.html create mode 100644 sites/recreation_gov/templates/_reviews_section.html create mode 100644 sites/recreation_gov/templates/account.html create mode 100644 sites/recreation_gov/templates/article.html create mode 100644 sites/recreation_gov/templates/articles.html create mode 100644 sites/recreation_gov/templates/base.html create mode 100644 sites/recreation_gov/templates/cart.html create mode 100644 sites/recreation_gov/templates/category.html create mode 100644 sites/recreation_gov/templates/checkout.html create mode 100644 sites/recreation_gov/templates/facility_detail.html create mode 100644 sites/recreation_gov/templates/help.html create mode 100644 sites/recreation_gov/templates/index.html create mode 100644 sites/recreation_gov/templates/login.html create mode 100644 sites/recreation_gov/templates/register.html create mode 100644 sites/recreation_gov/templates/reservations.html create mode 100644 sites/recreation_gov/templates/saved.html create mode 100644 sites/recreation_gov/templates/search.html create mode 100644 sites/recreation_gov/templates/site_pass_detail.html diff --git a/.dockerignore b/.dockerignore index a921bf1..e4b52eb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,6 +12,7 @@ sites/*/scraped_data/ sites/*/__pycache__/ **/__pycache__/ sites/*/venv/ +.venv/ sites/*/*.pyc # Don't ship — HF asset tarballs are build-time inputs (fetched/extracted by diff --git a/.gitignore b/.gitignore index c2efc04..e789923 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,10 @@ sites/*/static/external_cache/ # ============================================================= # Intermediate / volatile — never committed anywhere. # ============================================================= -sites/*/scraped_data/ # scrape pipeline intermediate; runtime data lives in instance_seed/*.db -sites/*/instance/ # rebuilt at every container boot from instance_seed/ +# scrape pipeline intermediate; runtime data lives in instance_seed/*.db +sites/*/scraped_data/ +# rebuilt at every container boot from instance_seed/ +sites/*/instance/ sites/*/venv/ # HF download metadata produced by `hf download`. @@ -92,4 +94,4 @@ secrets.json # ============================================================ # Agent demo results # ============================================================= -agent_demo/runs/ \ No newline at end of file +agent_demo/runs/ diff --git a/Dockerfile b/Dockerfile index 991e5ab..6e4a5c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # WebHarbor — slim, self-contained image. -# 15 Flask mirror sites + control plane on :8101. +# Flask mirror sites + control plane on :8101. FROM python:3.12-slim-bookworm @@ -33,6 +33,6 @@ COPY control_server.py /opt/control_server.py COPY site_runner.py /opt/site_runner.py RUN chmod +x /opt/websyn_start.sh -EXPOSE 8101 40000-40014 +EXPOSE 8101 40000-40015 CMD ["/opt/websyn_start.sh"] diff --git a/GUIDANCE.md b/GUIDANCE.md new file mode 100644 index 0000000..5b9b908 --- /dev/null +++ b/GUIDANCE.md @@ -0,0 +1,535 @@ +# WebHarbor 网站 Clone Guidance + +这份文档说明 WebHarbor 是如何把一个真实网站做成可本地运行、可重置、可用于 GUI agent benchmark 的镜像环境,并给出新增一个网站的实操流程。 + +## 1. 这个 repo 所谓的 clone 是什么 + +WebHarbor 不是简单把网页 HTML 静态下载下来,也不是运行时代理真实网站。它做的是一个确定性的本地镜像: + +- 用真实网站做参照,采集关键页面结构、导航、文案、截图和图片资产。 +- 用 Flask + Jinja2 + SQLAlchemy 重新实现前端页面、后端路由、搜索、登录、购物车、账户、收藏、订单等交互。 +- 用 SQLite seed DB 固定初始状态,让每次 benchmark rollout 都从同一份数据开始。 +- 用 Docker 把多个站点打进同一个镜像,每个站点一个独立 Flask 进程。 +- 用 `control_server.py` 暴露 `/reset/`,每次重置时杀掉站点进程、删除 runtime DB、从 seed DB 复制一份新的 DB,然后重新启动。 + +所以这里的 clone 更准确地说是“仿真式镜像”:视觉和行为要足够像真实网站,但运行时完全离线、稳定、可控。 + +## 2. 仓库结构和职责 + +核心目录: + +```text +sites// +├── app.py Flask app: routes, SQLAlchemy models, auth, handlers +├── seed_data.py 构造初始数据,生成 SQLite seed +├── _health.py 站点健康检查 +├── templates/ Jinja2 页面模板 +├── static/css/ 小型 CSS,进 Git +├── static/js/ 小型 JS,进 Git +├── static/icons/ 小型图标,进 Git +├── static/images/ 大图片资产,走 Hugging Face dataset +├── static/external_cache/ 可选外部缓存资产,走 Hugging Face dataset +├── instance_seed/.db seed DB,走 Hugging Face dataset +├── instance/.db runtime DB,启动和 reset 时从 seed 复制 +├── scraped_data/ 采集阶段中间文件,不进入镜像 +└── tasks.jsonl benchmark tasks +``` + +顶层运行文件: + +```text +websyn_start.sh 容器入口,启动所有站点和控制面 +control_server.py :8101 控制面,支持 health/reset/restart +site_runner.py 单站点 supervisor,负责可杀进程组 +Dockerfile 构建包含所有站点的镜像 +scripts/new_site.py 新站点脚手架 +scripts/fetch_assets.sh 从 HF dataset 拉取大资产 +scripts/extract_assets.sh 把大资产打包成 .tar.gz 供 HF 上传 +scripts/check_assets.sh 构建前检查 seed DB 是否存在 +scripts/build.sh 检查资产并 docker build +``` + +资产边界: + +- Git repo 保存代码、小模板、小 CSS/JS、任务文件和文档。 +- Hugging Face dataset `ChilleD/WebHarbor` 保存大资产:`instance_seed/`、`static/images/`、`static/external_cache/`。 +- `.assets-revision` 决定 `fetch_assets.sh` 拉哪个 HF revision。 +- `.gitignore` 会忽略 HF 管理的大资产和 runtime 中间文件。 +- `.dockerignore` 不忽略 `instance_seed/` 和 `static/images/`,因为构建镜像时必须把已经拉下来的资产复制进去。 + +## 3. 运行和 reset 机制 + +容器启动时: + +1. `websyn_start.sh` 读取 `SITES=(...)`。 +2. 对每个站点删除 `/opt/WebSyn//instance`。 +3. 从 `/opt/WebSyn//instance_seed` 复制出新的 `/opt/WebSyn//instance`。 +4. 按顺序启动每个站点,端口是 `40000 + index`。 +5. 启动 `control_server.py`,监听 `:8101`。 + +reset 单站点时: + +1. `POST /reset/` 进入 `control_server.py`。 +2. 控制面读取 `/tmp/websyn_pids/.pid`。 +3. 用 process group kill 掉 `site_runner.py` 和它的 Flask child。 +4. 删除 runtime `instance/`。 +5. 从 `instance_seed/` 复制一份干净 DB。 +6. 重新启动站点并等待首页可访问。 + +这就是 WebHarbor 对 RL/benchmark 友好的关键:每个任务后可以恢复到同一份初始状态。 + +严格要求:reset 后 runtime DB 和 seed DB 的 md5 应该一致。 + +```bash +docker exec wh-test md5sum \ + /opt/WebSyn//instance/.db \ + /opt/WebSyn//instance_seed/.db +``` + +如果两个 md5 不一致,通常是 `seed_*()` 函数没有做到函数级 idempotent,或者 app 启动时写入了 runtime DB。 + +## 4. 新增网站的端到端流程 + +### Step 0: 定义目标和 slug + +先明确: + +- 真实站点 URL,例如 `https://www.recreation.gov/`。 +- 本地 slug,例如 `recreation_gov`,只能用小写字母、数字、下划线。 +- 站点的核心功能面:搜索、浏览、详情、登录、账户、收藏、购物车、下单、订单管理等。 +- benchmark 需要覆盖的任务类型。 + +不要一开始就追求全站爬取。WebHarbor 更看重可交互的核心功能和任务覆盖。 + +### Step 1: scaffold + +```bash +./scripts/new_site.py +``` + +脚手架会生成: + +```text +sites//app.py +sites//_health.py +sites//requirements.txt +sites//templates/index.html +sites//static/{css,js,icons,images,external_cache}/ +sites//instance_seed/ +sites//instance/ +sites//scraped_data/ +``` + +然后把站点注册到三个地方,顺序必须一致: + +- `websyn_start.sh` 的 `SITES=(...)` +- `control_server.py` 的 `SITES = [...]` +- `Dockerfile` 的 `EXPOSE 8101 40000-400NN` + +端口规则是 `40000 + SITES index`。如果当前最后一个站点 index 是 15,那么本地端口就是 `40015`,Docker 测试时也要暴露到对应范围。 + +### Step 2: 侦察真实网站 + +目标是理解“需要仿什么”,不是盲目下载所有页面。 + +建议保存到 `sites//scraped_data/`: + +- 首页 HTML、纯文本和截图。 +- 搜索结果页、分类页、详情页、登录页、账户页、购物车/checkout 页。 +- 主要导航结构、URL pattern、表单字段、筛选项、排序项。 +- 页面视觉特征:布局、字体层级、颜色、卡片结构、按钮样式、响应式行为。 +- 图片清单:logo、hero 图、商品/文章/地点图片、图标。 +- `recon_summary.json`,总结站点信息架构、关键路由、实体类型和待实现功能。 + +`scraped_data/` 是中间材料,`.gitignore` 和 `.dockerignore` 都会排除它。运行时不能依赖它。 + +### Step 3: 采集并整理真实资产 + +图片、截图、缓存页面等大资产放到: + +```text +sites//static/images/ +sites//static/external_cache/ +``` + +要求: + +- 尽量使用真实网站相关资产,不用灰色 placeholder。 +- 文件名稳定、可读,模板里用 `url_for("static", filename="images/...")` 引用。 +- 大图和 seed DB 不直接提交到 Git,后续用 `extract_assets.sh` 打包上传到 HF dataset。 +- 小型 CSS/JS/icon 可以留在 Git repo。 + +### Step 4: 建模和 seed DB + +在 `app.py` 定义 SQLAlchemy models,在 `seed_data.py` 构造初始数据。 + +典型实体: + +- 内容型网站:Article、Topic、Author、Bookmark、SearchHistory。 +- 电商网站:Product、Category、Review、CartItem、Order、Address、PaymentMethod。 +- 预订网站:Facility、Room/Campsite、Reservation、SavedItem、PaymentMethod。 +- 开发者网站:User、Repository、Issue、PullRequest、Notification、Star、Watch。 + +原则: + +- Runtime handler 只读 SQLite,不读 `scraped_data/*.json`。 +- `seed_data.py` 可以读取/转化采集结果,但最后要落入 `instance_seed/.db`。 +- 每个主要实体至少有足够多的 distractors,不要让任务答案成为唯一结果。 +- 所有 `seed_*()` 函数必须函数级 idempotent。 + +正确模式: + +```python +def seed_database(db, Product, Category): + if Product.query.count() > 0: + return + # add rows + db.session.commit() + + +def seed_benchmark_users(db, User): + if User.query.filter_by(email="alice.j@test.com").first(): + return + # add users + db.session.commit() +``` + +反模式: + +```python +def seed_database(): + for row in rows: + if not Product.query.filter_by(slug=row["slug"]).first(): + db.session.add(Product(**row)) + db.session.commit() +``` + +反模式的问题是:即使没有新增行,一次空 commit 也可能改变 SQLite 元数据,导致 reset 后 md5 不一致。 + +### Step 5: 实现 Flask routes 和交互 + +`app.py` 是每个站点的核心。常见路由: + +```text +GET / +GET /search +GET /category/ +GET /product/ 或 /facility/ +GET/POST /login +GET/POST /register +GET/POST /account +GET /saved 或 /wishlist +GET/POST /cart +GET/POST /checkout +GET /orders 或 /reservations +POST /.../cancel +GET /_health +``` + +交互要求: + +- 所有重要页面都能从首页点击到,不要有孤儿页面。 +- 搜索不要只做 exact match,建议做 token-overlap scoring。 +- 表单提交要真实修改 DB,并给出可见反馈。 +- 登录态用 Flask-Login,benchmark 用户统一使用 `TestPass123!`。 +- 登录后任务要有预置状态,例如已有收藏、购物车、订单、地址、支付方式。 +- 404/空状态也要有页面,不能直接报错。 + +### Step 6: 复刻前端 + +模板放在 `templates/`,样式放在 `static/css/`,少量行为放在 `static/js/`。 + +目标是“对 agent 来说像真实网站”: + +- 导航、搜索框、筛选侧栏、详情卡、CTA 位置要接近真实网站。 +- 重要文本、按钮 label、表单字段要符合真实站点习惯。 +- 图片和卡片布局要足够丰富,避免所有结果长得一样。 +- 移动端至少不能崩。 +- 不追求像素级一致,但不能像 toy app。 + +Jinja 模板应从 SQLAlchemy objects 渲染,避免把答案硬编码进模板。 + +### Step 7: 写 benchmark tasks + +任务写到: + +```text +sites//tasks.jsonl +``` + +每行一个 JSON object,使用 WebVoyager schema: + +```json +{"web_name":"My Site","id":"MySite--0","ques":"Search for ...","web":"http://localhost:40015/","upstream_url":"https://www.example.com/"} +``` + +建议 15-20 个任务,覆盖: + +- 搜索和筛选。 +- 打开详情页读取属性。 +- 多结果比较。 +- 登录后状态操作。 +- 收藏/购物车/checkout/取消订单等写操作。 +- 账户设置修改。 +- 帮助页、文章页、政策页等非交易型内容。 +- 3-5 个需要多步推理或消歧的 hard tasks。 + +任务设计要反向驱动环境完善。每个任务都应该能在本地镜像里通过真实点击和阅读完成。 + +### Step 8: task-driven evolution + +写完 tasks 后逐个跑一遍,按任务补环境。 + +检查点: + +- 任务提到的每个页面、按钮、表单都存在。 +- 搜索结果里有足够的近似候选,而不是只有目标项。 +- 需要比较的信息必须在详情页或更深层页面,不要一眼暴露在卡片标题。 +- checkout、cancel、save、edit profile 等操作必须真正改变 DB。 +- hard task 不能靠 URL 猜测或页面标题直接完成。 + +这个阶段经常会新增 route、模板、seed rows、筛选逻辑和 UI 状态。 + +### Step 9: hardening + +WebHarbor 的难点不只是“能点通”,而是避免任务被 reward hacking。 + +重点审查四件事: + +- De-leak:答案不要直接出现在 task 文本、卡片标题、结果摘要、页面 heading。 +- Distractors:每个任务至少有多个 near-miss 结果,只差一个条件。 +- Catalog breadth:搜索结果要有多个类别、价格、状态、地区、评分等维度。 +- Cross-field consistency:修改产品/设施字段时,同步更新 specs、description、tags、filters。 + +常见坏味道: + +- 搜索某个商品只返回一个结果。 +- 任务问“哪一个支持 X”,结果卡片标题就写着 X。 +- 详情页描述和筛选字段互相矛盾。 +- 表单提交只是 redirect,没有修改 DB。 +- 登录后所有用户看到同一份 cart/order。 +- seed 里只有任务答案,没有自然背景数据。 + +### Step 10: 稳定 seed DB + +本地生成 seed 的常见方式: + +```bash +python3 -m py_compile sites//app.py +python3 sites//app.py +``` + +或通过 Docker build/run 触发 app import 和 `db.create_all()`。 + +最终要把稳定 DB 放到: + +```text +sites//instance_seed/.db +``` + +然后验证: + +```bash +./scripts/build.sh webharbor:dev + +docker run -d --rm --name wh-test \ + -p 8201:8101 -p 41000-410NN:40000-400NN \ + webharbor:dev + +curl -X POST http://localhost:8201/reset/ + +docker exec wh-test md5sum \ + /opt/WebSyn//instance/.db \ + /opt/WebSyn//instance_seed/.db +``` + +两个 md5 必须一致。 + +## 5. 构建、运行和检查 + +安装或拉取资产: + +```bash +./scripts/fetch_assets.sh +``` + +只拉某个站点: + +```bash +./scripts/fetch_assets.sh +``` + +构建镜像: + +```bash +./scripts/build.sh webharbor:dev +``` + +运行镜像。端口范围要覆盖当前 `SITES` 中所有站点: + +```bash +docker run -d --rm --name wh-test \ + -p 8201:8101 \ + -p 41000-410NN:40000-400NN \ + webharbor:dev +``` + +检查控制面: + +```bash +curl -s http://localhost:8201/health | python3 -m json.tool +``` + +检查页面是否返回 200: + +```bash +for p in $(seq 41000 410NN); do + curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ +done +``` + +检查单站点 reset: + +```bash +curl -X POST http://localhost:8201/reset/ +``` + +停止测试容器: + +```bash +docker stop wh-test +``` + +## 6. 资产提交流程 + +新增或更新大资产后,把 HF 管理路径打包: + +```bash +./scripts/extract_assets.sh ../wh-static-pr/ +``` + +会生成: + +```text +../wh-static-pr/.tar.gz +``` + +上传到你的 HF dataset fork: + +```bash +cd ../wh-static-pr +hf upload .tar.gz /WebHarbor .tar.gz --repo-type dataset +``` + +在 Hugging Face 上给 `ChilleD/WebHarbor` 开 asset PR。合并后,把这个代码 repo 的 `.assets-revision` bump 到 HF merge commit,再开 GitHub PR。 + +如果一次打包所有站点: + +```bash +./scripts/extract_assets.sh ../wh-static-pr/ +``` + +但新增单站点时优先只打包该站点,PR 更容易 review。 + +## 7. PR 前 definition of done + +提交前至少满足: + +- `python3 -m py_compile sites//app.py` 通过。 +- `./scripts/build.sh webharbor:dev` 通过。 +- Docker 容器启动后所有站点 ready。 +- 新站点首页、搜索页、详情页、登录页、主要写操作返回正常。 +- `POST /reset/` 返回 `ready: true`。 +- runtime DB 和 seed DB md5 一致。 +- `tasks.jsonl` 有 15-20 个任务。 +- 每个任务都能通过真实 UI 操作完成。 +- 任务答案没有明显泄漏。 +- 搜索/筛选有足够 distractors。 +- 大资产已打包并上传到 HF PR。 +- GitHub PR 描述包含真实网站 URL、seed row 数、HF PR 链接、reset 输出和截图对比。 + +## 8. 常见问题 + +### 构建时报缺少 `instance_seed` + +先运行: + +```bash +./scripts/fetch_assets.sh +``` + +如果是新站点,需要先生成 `sites//instance_seed/.db`。 + +### reset 后 md5 不一致 + +检查: + +- 是否有 `seed_*()` 函数没有在函数开头 early return。 +- 是否 app import 时写入了时间戳、访问日志、默认设置等 runtime 数据。 +- 是否某个 request handler 在首页访问时修改 DB。 +- 是否把 runtime `instance/.db` 当成 seed 以外的可变数据源。 + +### 站点启动失败 + +进入容器看日志: + +```bash +docker exec wh-test tail -n 200 /tmp/websyn_.log +``` + +常见原因是 app import 阶段 seed 失败、模板引用不存在的字段、图片路径错误、SQLite 文件不存在。 + +### 新站点端口不通 + +检查三处是否同步: + +- `websyn_start.sh` +- `control_server.py` +- `Dockerfile EXPOSE` + +同时检查 `docker run -p` 的端口范围是否覆盖新站点端口。 + +### task 太简单 + +增加 near-miss distractors,把关键信息下沉到详情页或规格表,扩大 catalog,并避免把答案词直接放在标题和摘要里。 + +## 9. 推荐的一次性 agent prompt + +如果让 coding agent 执行新增网站,可以用这个简化 prompt: + +```text +Target site: +Site slug: + +Build a WebHarbor mirror under sites//. + +Requirements: +- Run ./scripts/new_site.py if the folder does not exist. +- Register the site in websyn_start.sh, control_server.py, and Dockerfile. +- Recon the real site: navigation, core routes, page types, forms, visual style, assets. +- Save scrape intermediates under sites//scraped_data/. +- Implement a self-contained Flask + SQLAlchemy app in sites//app.py. +- Put runtime data in SQLite only; do not read scraped_data at request time. +- Implement idempotent seed_database() and seed_benchmark_users(). +- Seed 4 benchmark users using password TestPass123!. +- Build Jinja templates and CSS/JS matching the real site's core UI. +- Add search, details, auth, account, saved/cart/order style flows as appropriate. +- Write 15-20 WebVoyager-schema tasks in sites//tasks.jsonl. +- Evolve the app until every task is solvable through the UI. +- Harden tasks against answer leakage and insufficient distractors. +- Produce sites//instance_seed/.db. +- Run py_compile, build, docker run, /reset/, and md5 verification. +- Stop before opening PRs. Summarize changed files, seeded row counts, task count, reset result, and required HF/GitHub PR steps. +``` + +## 10. Existing example to study + +`sites/recreation_gov/` is a useful reference for a task-driven mirror: + +- `scraped_data/` contains representative HTML/text/screenshots from reconnaissance. +- `app.py` implements models, scored search, map-ish presentation, auth, saved items, cart, checkout, reservations, account editing, API and health route. +- `seed_data.py` creates facilities, campsites, reviews, benchmark users, saved items, cart items and reservations with function-level gates. +- `tasks.jsonl` covers search, comparison, authenticated state changes, checkout, cancellation, help content and disambiguation. +- `instance_seed/recreation_gov.db` is the seed DB copied on every reset. + +Use it as a pattern, not as shared code. Each site must stay self-contained. diff --git a/control_server.py b/control_server.py index c255253..bc6bcca 100644 --- a/control_server.py +++ b/control_server.py @@ -26,7 +26,7 @@ 'allrecipes', 'amazon', 'apple', 'arxiv', 'bbc_news', 'booking', 'github', 'google_flights', 'google_map', 'google_search', 'huggingface', 'wolfram_alpha', 'cambridge_dictionary', - 'coursera', 'espn', + 'coursera', 'espn', 'recreation_gov', ] BASE_PORT = 40000 WEBSYN_DIR = '/opt/WebSyn' diff --git a/sites/recreation_gov/_health.py b/sites/recreation_gov/_health.py new file mode 100644 index 0000000..62055ca --- /dev/null +++ b/sites/recreation_gov/_health.py @@ -0,0 +1,3 @@ +"""Per-site health probe (optional, called by control_server).""" +def health(): + return {"ok": True, "site": "recreation_gov"} diff --git a/sites/recreation_gov/app.py b/sites/recreation_gov/app.py new file mode 100644 index 0000000..5f3789c --- /dev/null +++ b/sites/recreation_gov/app.py @@ -0,0 +1,1579 @@ +"""Recreation.gov mirror - Flask app.""" +from __future__ import annotations + +import json +import os +import re +from datetime import date, datetime, timedelta +from decimal import Decimal + +from flask import Flask, abort, flash, jsonify, redirect, render_template, request, url_for +from flask_login import LoginManager, UserMixin, current_user, login_required, login_user, logout_user +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import or_ +from werkzeug.security import check_password_hash, generate_password_hash + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +app = Flask(__name__, instance_path=os.path.join(BASE_DIR, "instance")) +app.config["SECRET_KEY"] = "webharbor-recreation-gov-dev-key" +app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{os.path.join(BASE_DIR, 'instance', 'recreation_gov.db')}" +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + +db = SQLAlchemy(app) +login_manager = LoginManager(app) +login_manager.login_view = "login" +login_manager.login_message_category = "warning" + +STOP_WORDS = {"the", "a", "an", "and", "or", "for", "of", "to", "in", "near", "with", "from", "on", "at", "by", "is", "are", "me", "my"} + +INVENTORY_LABELS = { + "camping": "Camping & Lodging", + "tickets": "Tickets & Tours", + "permits": "Permits", + "passes": "Activity & Site Passes", + "day_use": "Day Use / Venues", + "lottery": "Lotteries", +} + +HOME_EXPERIENCE_PANELS = [ + { + "key": "all", + "label": "All Experiences", + "inventory_types": ("camping", "tickets", "permits", "passes", "day_use"), + "description": "Popular outdoor stays, timed entry, tours, and permit windows within driving distance.", + }, + { + "key": "camping", + "label": "Camping & Lodging", + "inventory_types": ("camping",), + "description": "Campgrounds, cabins, and overnight options with upcoming availability.", + }, + { + "key": "tickets", + "label": "Tickets & Tours", + "inventory_types": ("tickets",), + "description": "Guided tours, monument access, and scheduled visitor experiences.", + }, + { + "key": "permits", + "label": "Permits", + "inventory_types": ("permits",), + "description": "Lottery windows, wilderness permits, and backcountry entry rules.", + }, + { + "key": "passes", + "label": "Activity Passes", + "inventory_types": ("passes",), + "description": "Timed-entry and standard amenity passes for high-demand destinations.", + }, + { + "key": "day_use", + "label": "Day Use & Venues", + "inventory_types": ("day_use",), + "description": "Picnic areas, launch points, and reservable day-use venues.", + }, +] + +CATEGORY_TILES = [ + ("camping", "Camping & Lodging", "real-nav-camping.webp", "Book campsites, cabins, RV sites, and overnight stays.", None), + ("tickets", "Tickets & Tours", "real-nav-tickets.webp", "Reserve tours, tickets, ranger programs, and visitor experiences.", None), + ("permits", "Permits", "real-nav-permits.webp", "Find wilderness, day-use, and special access permits.", None), + ("passes", "Activity Passes", "real-nav-passes.webp", "Buy activity and site passes before you arrive.", None), + ("lottery", "Lotteries", "real-nav-lotteries.webp", "Enter high-demand lotteries and timed entry drawings.", None), + ("fishing", "Hunting & Fishing", "real-nav-fishing.webp", "Discover fishing access, permit windows, and outdoor sport experiences.", "fishing"), + ("day_use", "Day Use & Venues", "real-nav-venues.webp", "Reserve picnic areas, venues, parking, and day-use sites.", None), +] + +HOME_NEARBY_SLUGS = [ + "aquatic-park-cove-overnight-anchoring", + "point-reyes-national-seashore-campground", + "pinnacles-campground", + "oak-knoll-campground", + "glory-hole-recreation-area", + "lake-sonoma-boat-in-sites", +] + +HOME_AI_EXAMPLES = [ + "RV camping near Zion with electric hookups for 4 people next month", + "Tent sites near Yosemite with hiking trails this weekend", + "Day use permits for Yellowstone", + "Accessible cabins in Alaska with fishing available in July", + "Waterfront camping at Grand Canyon with full hookups for a 35 foot RV", + "Timed entry passes for Rocky Mountain with easy morning access", + "Guided historic tours near San Francisco this weekend", + "Family-friendly picnic areas near Sedona with parking", + "Backcountry permits near Lake Tahoe for two nights", + "Cabins with fishing access in Alaska during July", +] + +HOME_ARTICLE_SLUGS = [ + "campfire-safety-tips", + "play-it-safe-trip-planning", + "beautiful-beach-destinations", +] + +ARTICLE_LIBRARY = { + "campfire-safety-tips": { + "slug": "campfire-safety-tips", + "title": "Campfire Safety Tips", + "kicker": "Location Spotlight", + "summary": "Review current fire rules, wood collection limits, and quiet-hour expectations before you head out.", + "image": "real-aquatic-park-cove.webp", + "paragraphs": [ + "Campfire rules vary by agency, season, and current fire conditions. Visitors should confirm whether fires are allowed, whether only provided rings may be used, and whether local bans override normal campground rules.", + "Pack a shovel, keep water close by, and never leave a fire unattended. Even established campgrounds may require fires to be fully extinguished before you depart for a trail, shower, or picnic area.", + "If your trip includes high-demand sites such as Point Reyes or Pinnacles, review facility alerts before arrival because weather and fuel conditions can change operating rules quickly.", + ], + }, + "play-it-safe-trip-planning": { + "slug": "play-it-safe-trip-planning", + "title": "Plan Ahead and Play It Safe for Your Next Outdoor Adventure", + "kicker": "Trip Planning", + "summary": "Compare permits, timed entry windows, and campsite rules before committing to a long drive.", + "image": "real-point-reyes-seashore.webp", + "paragraphs": [ + "Popular public lands destinations often combine several reservation systems: overnight stays, timed entry, transit shuttles, or wilderness permits. Checking those constraints together prevents avoidable no-entry situations.", + "A strong plan starts with three checks: the inventory type you need, the allowed date window, and the agency rules attached to that reservation. Recreation.gov mirrors make those details visible side by side so agents can compare options quickly.", + "For higher-risk itineraries, save alternatives nearby before checkout. That gives you a practical fallback if a site closes, a permit window changes, or access conditions tighten.", + ], + }, + "beautiful-beach-destinations": { + "slug": "beautiful-beach-destinations", + "title": "10 Beautiful Beach Destinations", + "kicker": "Inspiration", + "summary": "From sheltered coves to long Pacific overlooks, coastal inventory mixes day-use, tours, and overnight stays.", + "image": "real-aquatic-park-cove.webp", + "paragraphs": [ + "Beach and waterfront inventory spans more than campgrounds. Visitors often need to compare parking passes, harbor tours, overnight anchoring, and reservable launch or picnic access in the same trip plan.", + "San Francisco Maritime, Aquatic Park Cove, Point Reyes, and Fort Point demonstrate how different reservation types cluster around the same coastal region.", + "When recreating real trip-planning behavior, treat nearby day-use and tour inventory as complements to camping rather than separate flows.", + ], + }, + "celebrate-america-250": { + "slug": "celebrate-america-250", + "title": "Celebrate 250 Years of American Discovery", + "kicker": "America250", + "summary": "Historic parks, cultural sites, public lands, and commemorative itineraries tied to the America250 campaign.", + "image": "real-america-250-background.webp", + "paragraphs": [ + "Explore historic parks, cultural sites, scenic landscapes, and public lands connected to America's 250th anniversary. The mirror highlights places like San Francisco Maritime National Historical Park, Fort Point, Golden Gate, Yosemite, Yellowstone, Denali, and Cumberland Island.", + "Trip ideas include pairing a historic tour with a nearby campground, reserving a timed-entry pass for a national park, or comparing wilderness permits before a backpacking route.", + "The real site uses these editorial pages to connect inspiration with bookable inventory. Keeping those links inside the same mirror makes the homepage feel closer to the original product.", + ], + }, + "accessible-camping-trip-ideas": { + "slug": "accessible-camping-trip-ideas", + "title": "Accessible Camping Trip Ideas for National Parks and Forests", + "kicker": "Accessibility", + "summary": "Compare accessible campsites, paved access routes, parking notes, and nearby visitor services before departure.", + "image": "real-point-reyes-campground.webp", + "paragraphs": [ + "Accessible camping planning works best when travelers compare more than a single campsite photo. Arrival surfaces, restroom access, parking distance, and whether trailheads or viewpoints are reachable from camp all affect whether a listing fits the trip.", + "Listings such as Point Reyes, Pinnacles, Glory Hole, and Sherando Lake show how accessibility details can appear across different agencies and reservation types. Some highlight accessible sites, while others emphasize parking, visitor-center routes, or day-use support.", + "A practical workflow is to save two or three near-miss alternatives before checkout. That gives travelers backup options if an accessible site is no longer available or if the closest campground does not match the rest of the itinerary.", + ], + }, + "compare-pass-types-before-you-go": { + "slug": "compare-pass-types-before-you-go", + "title": "Compare Pass Types Before You Go", + "kicker": "Passes & Entry", + "summary": "Timed entry, site passes, and activity permits often look similar until you read the validity window and on-arrival rules.", + "image": "real-passes-featured.webp", + "paragraphs": [ + "Public-land passes can represent different products: timed vehicle entry, amenity access, activity permits, or all-day site admission. Looking only at the title can lead to booking the wrong product for the trip you have planned.", + "A better comparison starts with three checks: how long the pass remains valid, whether it applies to one person or one vehicle, and whether the park also honors annual interagency passes or in-person payment.", + "Yellowstone fishing permits, Rocky Mountain timed-entry products, and park site passes at Yosemite or Grand Teton all illustrate how similar booking cards can hide very different access rules.", + ], + }, + "fishing-permits-and-season-windows": { + "slug": "fishing-permits-and-season-windows", + "title": "Fishing Permits and Season Windows to Check Before You Travel", + "kicker": "Activity Planning", + "summary": "Fishing access often depends on permit duration, seasonal rules, and how the activity overlaps with campsite or backcountry reservations.", + "image": "real-nav-fishing.webp", + "paragraphs": [ + "Fishing-related products on Recreation.gov can behave differently from standard camping reservations. Some are valid for a short multi-day window, while others cover a broader season or are paired with separate access reservations.", + "Travelers heading to Yellowstone, Lake Sonoma, Hemlock Cabin, or Oak Knoll usually need to compare fishing opportunities against surrounding facilities, launch access, and whether a separate camping or parking reservation is also required.", + "When planning a mixed itinerary, keep the permit duration visible alongside campsite dates so that the activity window does not silently expire before the rest of the reservation does.", + ], + }, + "scenic-drives-and-timed-entry": { + "slug": "scenic-drives-and-timed-entry", + "title": "Scenic Drives, Timed Entry, and Arrival Planning", + "kicker": "Trip Logistics", + "summary": "Some of the highest-demand destinations require travelers to coordinate park-entry products with tours, parking, or overnight stays.", + "image": "real-yosemite-site-pass-hero.jpeg", + "paragraphs": [ + "Timed-entry products change how visitors should plan a trip. Arriving too early, too late, or at the wrong entrance can turn a fully reserved day into a partial itinerary with limited access.", + "Yosemite, Rocky Mountain, Mariposa Grove, and other high-demand destinations often work best when visitors compare park-entry timing with shuttle inventory, parking reservations, and nearby overnight options.", + "A strong plan starts by confirming the exact validity window on the booking card and then checking whether the destination also requires a separate parking, campsite, or activity reservation after entry.", + ], + }, + "cabin-packing-for-remote-stays": { + "slug": "cabin-packing-for-remote-stays", + "title": "Packing for Remote Cabin Stays", + "kicker": "Cabins", + "summary": "Remote cabins look simple on search cards, but the detail pages often contain the practical information that determines whether a stay is feasible.", + "image": "066-preview-photo-of-hemlock-cabin.webp", + "paragraphs": [ + "Cabin listings frequently compress important constraints into a few amenity tags. Travelers still need to read whether access is by trail, boat, or winter road, and whether heat, cookware, or potable water are actually provided.", + "Hemlock Cabin, Samsing Cove Cabin, Tilly Jane A-Frame, and other remote stays show why 'cabin' alone is not enough. A wood stove, bunk configuration, or required boat access can completely change the trip plan.", + "Before booking, compare the access method, the shelter type, and the weather-sensitive equipment requirements together. That prevents cabin trips from becoming last-minute logistics problems.", + ], + }, + "family-friendly-lake-camping": { + "slug": "family-friendly-lake-camping", + "title": "Family-Friendly Lake Camping to Save for Summer", + "kicker": "Camping Inspiration", + "summary": "Lake destinations often mix standard campsites, primitive areas, marinas, and reservable day-use options in the same region.", + "image": "real-lake-sonoma-boat-in.webp", + "paragraphs": [ + "Lake-focused itineraries reward comparison. One shoreline listing may be best for fishing, another for swimming, and another for larger family groups that need parking, showers, or a nearby marina.", + "Berlin Lake, Lake Sonoma, Center Hill Lake, New Hogan Lake, and New Melones Lake all demonstrate how similarly named facilities can still vary sharply in access, available amenities, and reservation style.", + "Families planning around weather changes should save both an overnight option and a nearby day-use fallback so the trip still works if the preferred campsite is no longer available.", + ], + }, + "ranger-programs-and-historic-tours": { + "slug": "ranger-programs-and-historic-tours", + "title": "Ranger Programs and Historic Tours Worth Pairing With a Weekend Trip", + "kicker": "Tours & Programs", + "summary": "Tour inventory can add structure to a trip, especially when it is paired with nearby camping, parking, or ferry-based access.", + "image": "real-sf-maritime-tours.webp", + "paragraphs": [ + "Historic tours and ranger-led programs often serve as the anchor activity for an otherwise flexible itinerary. They provide a fixed time around which parking, lodging, or campground plans can be organized.", + "San Francisco Maritime, Fort Point, Voyageurs, and Campbell Creek show how tour pages can differ in accessibility, seating, route style, and whether the experience is outdoors, indoors, or on the water.", + "When demand is high, save a nearby campground or day-use option before purchasing tickets. That makes it easier to keep the weekend intact if travel timing changes after the core reservation is made.", + ], + }, + "wilderness-permit-checklist": { + "slug": "wilderness-permit-checklist", + "title": "A Wilderness Permit Checklist Before Checkout", + "kicker": "Backcountry", + "summary": "Permit detail pages hide high-value clues in the trailhead, quota, and season sections that should be reviewed before booking.", + "image": "real-desolation-hero.webp", + "paragraphs": [ + "Backcountry permits should be read as operational documents, not just product pages. Trailhead constraints, zone rules, quota windows, and printing requirements often determine whether a permit actually matches the route in mind.", + "Desolation Wilderness, Inyo, Aravaipa Canyon, Canyonlands, and Yosemite wilderness products illustrate how different agencies expose similar information with different terminology.", + "Before checkout, compare the entry point, the permit duration, whether day-use and overnight products are separated, and what document or digital confirmation must be carried in the field.", + ], + }, + "coastal-trip-combos": { + "slug": "coastal-trip-combos", + "title": "Build a Better Coastal Trip by Combining Camping, Tours, and Day Use", + "kicker": "Coastal Planning", + "summary": "Coastal inventory tends to be strongest when travelers mix overnight access with a second reservation type nearby.", + "image": "real-aquatic-park-cove.webp", + "paragraphs": [ + "Many coastal trips work better when visitors book more than one inventory type. A waterfront campground, nearby shuttle or parking reservation, and a historic or ranger-led tour can create a more complete itinerary than any single listing alone.", + "Point Reyes, Aquatic Park Cove, Fort Point, San Francisco Maritime, and Cumberland Island all show how beaches and waterfront access can spread across camping, tours, anchoring, and permit workflows.", + "The best comparison questions are often logistical: which listing controls overnight access, which one controls arrival timing, and which one adds the experience you want once you are already in the area.", + ], + }, +} + +DETAIL_GALLERIES = { + "point-reyes-national-seashore-campground": [ + ("real-point-reyes-gallery-oak.webp", "Large oak tree at a Point Reyes campsite"), + ("real-point-reyes-gallery-drakes.webp", "Campsite with distant views of Drakes Bay"), + ("real-point-reyes-gallery-campsite.webp", "Campsite with picnic table and bear box"), + ], + "desolation-wilderness-permit": [ + ("real-desolation-hero.webp", "High alpine panorama in Desolation Wilderness"), + ("real-desolation-granite.webp", "Granite peaks and alpine lakes in Desolation Wilderness"), + ("real-desolation-lake.webp", "Lake view inside Desolation Wilderness"), + ], +} + +INSPIRATION_CARDS = [ + { + "title": "Campfire Safety Tips", + "kicker": "Location Spotlight", + "image": "real-a250-fireworks.webp", + "href": "help_center", + }, + { + "title": "Plan Ahead and Play It Safe", + "kicker": "Trip Planning", + "image": "real-point-reyes-seashore.webp", + "href": "help_center", + }, + { + "title": "10 Beautiful Beach Destinations", + "kicker": "Inspiration", + "image": "real-aquatic-park-cove.webp", + "href": "search", + }, +] + +STATE_CENTERS = { + "AK": (63.7, -149.5), "AZ": (34.2, -111.7), "CA": (36.7, -119.4), + "CO": (39.0, -105.5), "GA": (32.6, -83.4), "KS": (38.5, -98.2), + "KY": (37.6, -85.2), "MI": (44.3, -85.5), "MN": (46.1, -94.3), + "MS": (32.7, -89.7), "NC": (35.5, -79.4), "NM": (34.5, -106.1), + "NY": (42.9, -75.5), "OH": (40.3, -82.7), "OK": (35.6, -97.5), + "OR": (43.9, -120.5), "TN": (35.8, -86.4), "TX": (31.1, -99.3), + "UT": (39.3, -111.6), "VA": (37.7, -78.2), "WA": (47.4, -120.7), + "WI": (44.6, -89.7), "WY": (43.0, -107.6), "FL": (27.8, -81.7), + "ME": (45.2, -69.0), +} + +DEFAULT_MAP_CENTER = (39.6, -98.5) + +US_STATES = [ + ("AL", "Alabama"), ("AK", "Alaska"), ("AZ", "Arizona"), ("AR", "Arkansas"), + ("CA", "California"), ("CO", "Colorado"), ("CT", "Connecticut"), ("DE", "Delaware"), + ("FL", "Florida"), ("GA", "Georgia"), ("HI", "Hawaii"), ("ID", "Idaho"), + ("IL", "Illinois"), ("IN", "Indiana"), ("IA", "Iowa"), ("KS", "Kansas"), + ("KY", "Kentucky"), ("LA", "Louisiana"), ("ME", "Maine"), ("MD", "Maryland"), + ("MA", "Massachusetts"), ("MI", "Michigan"), ("MN", "Minnesota"), ("MS", "Mississippi"), + ("MO", "Missouri"), ("MT", "Montana"), ("NE", "Nebraska"), ("NV", "Nevada"), + ("NH", "New Hampshire"), ("NJ", "New Jersey"), ("NM", "New Mexico"), ("NY", "New York"), + ("NC", "North Carolina"), ("ND", "North Dakota"), ("OH", "Ohio"), ("OK", "Oklahoma"), + ("OR", "Oregon"), ("PA", "Pennsylvania"), ("RI", "Rhode Island"), ("SC", "South Carolina"), + ("SD", "South Dakota"), ("TN", "Tennessee"), ("TX", "Texas"), ("UT", "Utah"), + ("VT", "Vermont"), ("VA", "Virginia"), ("WA", "Washington"), ("WV", "West Virginia"), + ("WI", "Wisconsin"), ("WY", "Wyoming"), +] +STATE_NAMES = dict(US_STATES) + +DETAIL_ALERTS = { + "desolation-wilderness-permit": "Tahoe Rim Trail thru-hike permits and quota-zone availability can change during the 2026 season. Review trailhead restrictions and plan backup zones before reserving.", +} + +DETAIL_OPTION_OVERRIDES = { + "desolation-wilderness-permit": ["Zone 06 Rubicon", "Zone 18 Eagle", "Zone 33 Aloha", "Zone 45 Twin Lakes"], + "yellowstone-national-park-fishing-permit": ["3-Day Individual Permit", "7-Day Individual Permit", "Season Permit"], +} + +BOOKING_OPTION_SPAN_OVERRIDES = { + "yellowstone-national-park-fishing-permit": { + "3-Day Individual Permit": 2, + "7-Day Individual Permit": 6, + }, +} + +BOOKING_OPTION_END_DATE_OVERRIDES = { + "yellowstone-national-park-fishing-permit": { + "Season Permit": "2026-12-31", + }, +} + +SITEPASS_ID_TO_SLUG = { + 74296: "yosemite-national-park-site-pass", +} + +SITE_PASS_CONTENT = { + "default": { + "page_title": "Site Pass Selection", + "alert": "Since January 1, 2026, changes to National Park Entrance Fees and Passes for non-residents have taken effect.", + "notice": "", + "about": "This site pass can be used digitally on a phone or tablet and may also be honored alongside other valid interagency pass products. Review park-specific operating rules before arrival.", + "about_link_label": "Fees & Passes web page.", + "hero_image": "", + }, + "yosemite-national-park-site-pass": { + "page_title": "Site Pass Selection", + "alert": "Since January 1, 2026, changes to National Park Entrance Fees and Passes for non-residents have taken effect.", + "notice": "Non-U.S. Residents must pay an additional fee for each person 16 years or older in the party.", + "about": "Yosemite National Park also accepts payment in person during Operating Hours and honors valid America the Beautiful Passes (Annual, Senior, Access, Military, etc.). For more information, visit the park's Fees & Passes web page.", + "about_link_label": "Fees & Passes web page.", + "hero_image": "real-yosemite-site-pass-hero.jpeg", + }, +} + + +class User(UserMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + email = db.Column(db.String(160), unique=True, nullable=False) + password_hash = db.Column(db.String(255), nullable=False) + display_name = db.Column(db.String(120), nullable=False) + phone = db.Column(db.String(40), default="") + home_city = db.Column(db.String(120), default="") + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + def set_password(self, password: str) -> None: + self.password_hash = generate_password_hash(password) + + def check_password(self, password: str) -> bool: + return check_password_hash(self.password_hash, password) + + +class Facility(db.Model): + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(180), unique=True, nullable=False) + name = db.Column(db.String(220), nullable=False) + inventory_type = db.Column(db.String(40), nullable=False, index=True) + agency = db.Column(db.String(80), nullable=False) + parent_area = db.Column(db.String(180), default="") + location = db.Column(db.String(180), nullable=False) + state = db.Column(db.String(2), nullable=False) + distance_miles = db.Column(db.Float, default=0) + price = db.Column(db.Numeric(8, 2), default=0) + price_unit = db.Column(db.String(40), default="night") + rating = db.Column(db.Float, default=4.0) + review_count = db.Column(db.Integer, default=0) + available = db.Column(db.Boolean, default=True) + accessible = db.Column(db.Boolean, default=False) + reservable = db.Column(db.Boolean, default=True) + image = db.Column(db.String(220), default="") + short_description = db.Column(db.Text, default="") + long_description = db.Column(db.Text, default="") + amenities_json = db.Column(db.Text, default="[]") + activities_json = db.Column(db.Text, default="[]") + rules_json = db.Column(db.Text, default="[]") + checkin_date = db.Column(db.String(20), default="2026-05-15") + checkout_date = db.Column(db.String(20), default="2026-05-17") + capacity = db.Column(db.Integer, default=4) + tags = db.Column(db.String(500), default="") + + campsites = db.relationship("Campsite", backref="facility", cascade="all, delete-orphan") + reviews = db.relationship("Review", backref="facility", cascade="all, delete-orphan") + + @property + def label(self) -> str: + return INVENTORY_LABELS.get(self.inventory_type, self.inventory_type.title()) + + @property + def amenities(self) -> list[str]: + return json.loads(self.amenities_json or "[]") + + @property + def activities(self) -> list[str]: + return json.loads(self.activities_json or "[]") + + @property + def rules(self) -> list[str]: + return json.loads(self.rules_json or "[]") + + @property + def price_display(self) -> str: + amount = float(self.price or 0) + if amount <= 0: + return "Free" + if self.price_unit == "pass": + return f"${amount:,.0f} / pass" + if self.price_unit == "permit": + return f"${amount:,.0f} / permit" + if self.price_unit == "ticket": + return f"${amount:,.0f} / ticket" + return f"${amount:,.0f} / {self.price_unit}" + + @property + def trip_window(self) -> str: + return _date_window_label(self.checkin_date, self.checkout_date) + + @property + def image_url(self) -> str: + fallback = "008-a-common-yellowthroat-warbler-perched-among-lush-green-vegetation-at-p.webp" + return url_for("static", filename=f"images/{self.image or fallback}") + + +class Campsite(db.Model): + id = db.Column(db.Integer, primary_key=True) + facility_id = db.Column(db.Integer, db.ForeignKey("facility.id"), nullable=False) + name = db.Column(db.String(140), nullable=False) + site_type = db.Column(db.String(80), default="Standard") + nightly_rate = db.Column(db.Numeric(8, 2), default=0) + capacity = db.Column(db.Integer, default=4) + accessible = db.Column(db.Boolean, default=False) + available_dates = db.Column(db.String(250), default="") + + @property + def dates(self) -> list[str]: + return [d.strip() for d in (self.available_dates or "").split(",") if d.strip()] + + +class Review(db.Model): + id = db.Column(db.Integer, primary_key=True) + facility_id = db.Column(db.Integer, db.ForeignKey("facility.id"), nullable=False) + author = db.Column(db.String(120), nullable=False) + rating = db.Column(db.Integer, default=5) + body = db.Column(db.Text, nullable=False) + visit_date = db.Column(db.String(40), default="April 2026") + + +class Address(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + label = db.Column(db.String(80), default="Home") + street = db.Column(db.String(180), nullable=False) + city = db.Column(db.String(120), nullable=False) + state = db.Column(db.String(2), nullable=False) + zip_code = db.Column(db.String(20), nullable=False) + is_default = db.Column(db.Boolean, default=True) + + +class PaymentMethod(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + card_type = db.Column(db.String(40), default="Visa") + last4 = db.Column(db.String(4), nullable=False) + expiry = db.Column(db.String(7), default="12/28") + is_default = db.Column(db.Boolean, default=True) + + +class SavedItem(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + facility_id = db.Column(db.Integer, db.ForeignKey("facility.id"), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + facility = db.relationship("Facility") + + +class CartItem(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + facility_id = db.Column(db.Integer, db.ForeignKey("facility.id"), nullable=False) + campsite_id = db.Column(db.Integer, db.ForeignKey("campsite.id"), nullable=True) + start_date = db.Column(db.String(20), nullable=False) + end_date = db.Column(db.String(20), nullable=False) + guests = db.Column(db.Integer, default=2) + quantity = db.Column(db.Integer, default=1) + facility = db.relationship("Facility") + campsite = db.relationship("Campsite") + + @property + def total(self) -> Decimal: + nights = _nights(self.start_date, self.end_date) + base = self.campsite.nightly_rate if self.campsite else self.facility.price + multiplier = max(nights, 1) if self.facility.inventory_type == "camping" else self.quantity + return Decimal(base or 0) * multiplier + + +class Reservation(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + facility_id = db.Column(db.Integer, db.ForeignKey("facility.id"), nullable=False) + campsite_name = db.Column(db.String(160), default="") + start_date = db.Column(db.String(20), nullable=False) + end_date = db.Column(db.String(20), nullable=False) + guests = db.Column(db.Integer, default=2) + total_cost = db.Column(db.Numeric(10, 2), default=0) + status = db.Column(db.String(40), default="Upcoming") + confirmation_code = db.Column(db.String(40), unique=True, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + facility = db.relationship("Facility") + + +@login_manager.user_loader +def load_user(user_id: str): + return User.query.get(int(user_id)) + + +@app.context_processor +def inject_globals(): + cart_count = 0 + saved_ids: set[int] = set() + if current_user.is_authenticated: + cart_count = CartItem.query.filter_by(user_id=current_user.id).count() + saved_ids = {item.facility_id for item in SavedItem.query.filter_by(user_id=current_user.id).all()} + return {"inventory_labels": INVENTORY_LABELS, "cart_count": cart_count, "saved_ids": saved_ids, "today": date(2026, 5, 12)} + + +def _tokens(query: str) -> list[str]: + return [t for t in re.split(r"\W+", (query or "").lower()) if len(t) > 1 and t not in STOP_WORDS] + + +def _facility_haystack(facility: Facility) -> str: + state_full_name = STATE_NAMES.get(facility.state, facility.state) + parts = [ + facility.name, facility.label, facility.agency, facility.parent_area, + facility.location, facility.state, facility.short_description, + state_full_name, facility.long_description, facility.tags, " ".join(facility.amenities), + " ".join(facility.activities), + "accessible" if facility.accessible else "", + "available" if facility.available else "", + "reservable" if facility.reservable else "", + ] + return " ".join(parts).lower() + + +def _scored_search(query: str, items: list[Facility]) -> list[Facility]: + tokens = _tokens(query) + if not tokens: + return items + scored: list[tuple[Facility, int]] = [] + for item in items: + haystack = _facility_haystack(item) + score = sum(1 for token in tokens if token in haystack) + if score > 0: + scored.append((item, score)) + scored.sort(key=lambda pair: (-pair[1], pair[0].distance_miles, -pair[0].rating, pair[0].name)) + return [item for item, _ in scored] + + +def _filtered_facilities(args) -> list[Facility]: + query = Facility.query + inventory_type = args.get("inventory_type") or args.get("type") + if inventory_type: + query = query.filter(Facility.inventory_type == inventory_type) + agency = args.get("agency") + if agency: + query = query.filter(Facility.agency == agency) + activity = args.get("activity") + if activity: + query = query.filter(or_(Facility.activities_json.ilike(f"%{activity}%"), Facility.tags.ilike(f"%{activity}%"))) + if args.get("accessible") == "1": + query = query.filter(Facility.accessible.is_(True)) + if args.get("available") == "1": + query = query.filter(Facility.available.is_(True)) + max_price = args.get("max_price") + if max_price: + try: + query = query.filter(Facility.price <= float(max_price)) + except ValueError: + pass + results = _scored_search(args.get("q", "").strip(), query.all()) + sort = args.get("sort", "best") + if sort == "price": + results.sort(key=lambda f: (float(f.price or 0), f.name)) + elif sort == "rating": + results.sort(key=lambda f: (-f.rating, -f.review_count, f.name)) + elif sort == "distance": + results.sort(key=lambda f: (f.distance_miles, f.name)) + elif sort == "available": + results.sort(key=lambda f: (not f.available, f.distance_miles, f.name)) + return results + + +def _nights(start_date: str, end_date: str) -> int: + try: + start = datetime.strptime(start_date, "%Y-%m-%d").date() + end = datetime.strptime(end_date, "%Y-%m-%d").date() + return max((end - start).days, 1) + except ValueError: + return 1 + + +def _date_window_label(start_date: str, end_date: str) -> str: + try: + start = datetime.strptime(start_date, "%Y-%m-%d").date() + end = datetime.strptime(end_date, "%Y-%m-%d").date() + except ValueError: + return "PLAN AHEAD" + if start.month == end.month: + return f"{start.strftime('%b').upper()} {start.day} - {end.day}" + return f"{start.strftime('%b').upper()} {start.day} - {end.strftime('%b').upper()} {end.day}" + + +def _pretty_date(value: str) -> str: + try: + return datetime.strptime(value, "%Y-%m-%d").strftime("%b %d, %Y") + except ValueError: + return value + + +def _display_trip_window(start_date: str, end_date: str) -> str: + try: + start = datetime.strptime(start_date, "%Y-%m-%d").date() + end = datetime.strptime(end_date, "%Y-%m-%d").date() + except ValueError: + if start_date and end_date: + return f"{start_date} to {end_date}" + return start_date or end_date or "" + if start == end: + return start.strftime("%b %d, %Y") + if start.year == end.year and start.month == end.month: + return f"{start.strftime('%b %d')} - {end.day}, {end.year}" + if start.year == end.year: + return f"{start.strftime('%b %d')} - {end.strftime('%b %d, %Y')}" + return f"{start.strftime('%b %d, %Y')} - {end.strftime('%b %d, %Y')}" + + +def _booking_end_date( + start_date: str, + option: str, + option_spans: dict[str, int], + option_end_dates: dict[str, str], + fallback_end_date: str, +) -> str: + if option in option_end_dates: + return option_end_dates[option] + if option in option_spans: + try: + start = datetime.strptime(start_date, "%Y-%m-%d").date() + return (start + timedelta(days=option_spans[option])).isoformat() + except ValueError: + return fallback_end_date + return fallback_end_date + + +@app.template_filter("trip_window") +def trip_window_filter(value: object) -> str: + if isinstance(value, (tuple, list)) and len(value) == 2: + return _display_trip_window(str(value[0]), str(value[1])) + return str(value or "") + + +def _stable_offset(seed: str, scale: float) -> float: + total = sum((idx + 11) * ord(char) for idx, char in enumerate(seed)) + return (((total % 2000) / 1000) - 1) * scale + + +def _relative_map_position(lat: float, lng: float) -> tuple[float, float]: + clamped_lng = min(max(lng, -124.5), -66.5) + clamped_lat = min(max(lat, 24.0), 49.5) + x = ((clamped_lng + 124.5) / 58.0) * 100 + y = ((49.5 - clamped_lat) / 25.5) * 100 + return round(min(max(x, 6), 94), 2), round(min(max(y, 8), 92), 2) + + +def _facility_map_point(facility: Facility, rank: int = 1) -> dict[str, object]: + base_lat, base_lng = STATE_CENTERS.get(facility.state, DEFAULT_MAP_CENTER) + lat = round(base_lat + _stable_offset(f"{facility.slug}-lat", 1.45), 4) + lng = round(base_lng + _stable_offset(f"{facility.slug}-lng", 1.95), 4) + x, y = _relative_map_position(lat, lng) + return { + "id": facility.id, + "slug": facility.slug, + "name": facility.name, + "label": facility.label, + "location": f"{facility.location}, {facility.state}", + "distance": f"{facility.distance_miles:.0f} miles away", + "rating": f"{facility.rating:.1f} rating", + "reviews": f"{facility.review_count:,} reviews", + "price_display": facility.price_display, + "href": url_for("facility_detail", slug=facility.slug), + "lat": lat, + "lng": lng, + "x": x, + "y": y, + "rank": rank, + "available": facility.available, + "accessible": facility.accessible, + } + + +def _map_entry(facility: Facility, rank: int = 1) -> dict[str, object]: + return {"facility": facility, "point": _facility_map_point(facility, rank)} + + +def _build_experience_panel(panel_spec: dict[str, object], limit: int = 5) -> dict[str, object]: + inventory_types = panel_spec["inventory_types"] + available_results = ( + Facility.query.filter(Facility.inventory_type.in_(inventory_types), Facility.available.is_(True)) + .order_by(Facility.distance_miles, Facility.rating.desc(), Facility.review_count.desc()) + .all() + ) + ranked_results = list(available_results) + seen_ids = {facility.id for facility in ranked_results} + if len(ranked_results) < limit: + fallback_results = ( + Facility.query.filter(Facility.inventory_type.in_(inventory_types)) + .order_by(Facility.rating.desc(), Facility.review_count.desc(), Facility.distance_miles) + .all() + ) + for facility in fallback_results: + if facility.id in seen_ids: + continue + ranked_results.append(facility) + seen_ids.add(facility.id) + if len(ranked_results) >= limit: + break + selected = ranked_results[:limit] + entries = [_map_entry(facility, rank=index + 1) for index, facility in enumerate(selected)] + return { + "key": panel_spec["key"], + "label": panel_spec["label"], + "description": panel_spec["description"], + "entries": entries, + "count": len(entries), + } + + +def _cart_total(items: list[CartItem]) -> Decimal: + return sum((item.total for item in items), Decimal("0")) + + +def _next_confirmation() -> str: + return f"RG-2026-{Reservation.query.count() + 1:05d}" + + +def _facilities_by_slug(slugs: list[str]) -> list[Facility]: + rows = Facility.query.filter(Facility.slug.in_(slugs)).all() + by_slug = {facility.slug: facility for facility in rows} + return [by_slug[slug] for slug in slugs if slug in by_slug] + + +def _detail_gallery(facility: Facility) -> list[dict[str, str]]: + gallery = DETAIL_GALLERIES.get(facility.slug) + if not gallery: + gallery = [ + (facility.image or "real-point-reyes-campground.webp", f"Photo of {facility.name}"), + ("real-point-reyes-seashore.webp", f"Landscape near {facility.name}"), + ("real-pinnacles-national-park.webp", "Nearby public lands destination"), + ] + return [ + {"src": url_for("static", filename=f"images/{filename}"), "alt": alt} + for filename, alt in gallery + ] + + +def _home_tab_results(inventory_type: str, limit: int = 10) -> list[Facility]: + results = ( + Facility.query.filter_by(inventory_type=inventory_type, available=True) + .order_by(Facility.distance_miles, Facility.rating.desc(), Facility.review_count.desc()) + .limit(limit) + .all() + ) + if len(results) < limit: + seen = {facility.id for facility in results} + fallback = Facility.query.filter_by(inventory_type=inventory_type) + if seen: + fallback = fallback.filter(~Facility.id.in_(seen)) + results.extend( + fallback.order_by(Facility.rating.desc(), Facility.review_count.desc(), Facility.distance_miles) + .limit(limit - len(results)) + .all() + ) + if len(results) < limit and inventory_type == "day_use": + seen = {facility.id for facility in results} + related = Facility.query.filter(Facility.inventory_type.in_(["passes", "tickets"])) + if seen: + related = related.filter(~Facility.id.in_(seen)) + results.extend( + related.order_by(Facility.distance_miles, Facility.rating.desc(), Facility.review_count.desc()) + .limit(limit - len(results)) + .all() + ) + return results + + +def _booking_config(facility: Facility) -> dict[str, object]: + titles = { + "camping": "Available Campsites", + "tickets": "Available Tickets", + "permits": "Available Permits", + "passes": "Available Passes", + "day_use": "Available Day Use", + "lottery": "Available Lotteries", + } + intros = { + "camping": "Select travel dates and add an available site to your cart.", + "tickets": "Select an available day and reserve timed entry or tour inventory.", + "permits": "Select an available day to reserve a permit.", + "passes": "Choose the pass type and entry date before checkout.", + "day_use": "Reserve parking, picnic, or day-use access before arrival.", + "lottery": "Choose a season window and enter the latest lottery cycle.", + } + option_label = { + "camping": "Select Campsite", + "tickets": "Select Experience", + "permits": "Select Destination Zone", + "passes": "Select Pass Type", + "day_use": "Select Access Type", + "lottery": "Select Lottery Window", + }.get(facility.inventory_type, "Select Option") + options: list[str] + if facility.inventory_type == "camping" and facility.campsites: + options = [f"{site.name} · {site.site_type}" for site in facility.campsites[:5]] + else: + options = DETAIL_OPTION_OVERRIDES.get(facility.slug, facility.activities[:4] or [facility.label]) + option_spans = BOOKING_OPTION_SPAN_OVERRIDES.get(facility.slug, {}) + option_end_dates = BOOKING_OPTION_END_DATE_OVERRIDES.get(facility.slug, {}) + default_option = options[0] if options else "" + quantity_label = "Guests" if facility.inventory_type == "camping" else "Group Members" + date_selection_mode = "window" + calendar_span_days = 2 + primary_date_label = "Entry Date" + secondary_date_label = "Reservation Window" + end_date_value = facility.checkout_date + selected_status_label = _display_trip_window(facility.checkin_date, facility.checkout_date) + if facility.inventory_type == "camping": + date_selection_mode = "range" + calendar_span_days = max(_nights(facility.checkin_date, facility.checkout_date), 1) + secondary_date_label = "Departure Date" + elif facility.inventory_type in {"passes", "tickets", "day_use"}: + date_selection_mode = "single_day" + calendar_span_days = 0 + primary_date_label = "Visit Date" + secondary_date_label = "" + end_date_value = facility.checkin_date + selected_status_label = _pretty_date(facility.checkin_date) + if default_option and (default_option in option_spans or default_option in option_end_dates): + date_selection_mode = "window" + primary_date_label = "Visit Date" + secondary_date_label = "Valid For" + end_date_value = _booking_end_date( + facility.checkin_date, + default_option, + option_spans, + option_end_dates, + facility.checkout_date, + ) + calendar_span_days = max(_nights(facility.checkin_date, end_date_value), 0) + selected_status_label = _display_trip_window(facility.checkin_date, end_date_value) + return { + "title": titles.get(facility.inventory_type, "Availability"), + "intro": intros.get(facility.inventory_type, "Review current availability before booking."), + "option_label": option_label, + "options": options, + "option_span_days": option_spans, + "option_end_dates": option_end_dates, + "quantity_label": quantity_label, + "primary_cta": "Book Now" if facility.reservable else "Details Only", + "date_selection_mode": date_selection_mode, + "calendar_span_days": calendar_span_days, + "primary_date_label": primary_date_label, + "secondary_date_label": secondary_date_label, + "entry_date_label": _pretty_date(facility.checkin_date), + "window_label": _display_trip_window(facility.checkin_date, facility.checkout_date), + "end_date_value": end_date_value, + "selected_status_label": selected_status_label, + } + + +def _important_dates(facility: Facility) -> list[dict[str, str]]: + if facility.inventory_type == "permits": + try: + checkin = datetime.strptime(facility.checkin_date, "%Y-%m-%d").date() + except ValueError: + checkin = date(2026, 5, 15) + return [ + {"date": _pretty_date(facility.checkin_date), "info": "Mirror entry window opens for the first available permit date."}, + {"date": _pretty_date((checkin - timedelta(days=7)).isoformat()), "info": "Printable permit window typically opens seven days before entry."}, + {"date": "Sep 30, 2026", "info": "Quota-season demand usually relaxes after this period for wilderness inventory."}, + ] + return [ + {"date": _pretty_date(facility.checkin_date), "info": f"Primary arrival date in this mirror for {facility.name}."}, + {"date": _pretty_date(facility.checkout_date), "info": "Sample departure date used for current availability and trip pricing."}, + {"date": facility.trip_window.title(), "info": "Current featured weekend window displayed across the site."}, + ] + + +def _review_breakdown(facility: Facility) -> list[dict[str, object]]: + total = max(int(facility.review_count or 0), len(facility.reviews), 1) + avg = max(1.0, min(5.0, float(facility.rating or 4.0))) + if avg >= 4.6: + weights = {5: 0.72, 4: 0.18, 3: 0.06, 2: 0.02, 1: 0.02} + elif avg >= 4.2: + weights = {5: 0.58, 4: 0.24, 3: 0.10, 2: 0.04, 1: 0.04} + elif avg >= 3.8: + weights = {5: 0.42, 4: 0.28, 3: 0.16, 2: 0.08, 1: 0.06} + else: + weights = {5: 0.30, 4: 0.24, 3: 0.20, 2: 0.14, 1: 0.12} + counts = {stars: int(round(total * ratio)) for stars, ratio in weights.items()} + delta = total - sum(counts.values()) + counts[5] += delta + return [ + { + "stars": stars, + "count": counts[stars], + "percent": max((counts[stars] / total) * 100, 0), + } + for stars in range(5, 0, -1) + ] + + +def _detail_resources(facility: Facility) -> list[dict[str, str]]: + resources = [ + { + "label": f"{facility.parent_area or facility.agency} overview", + "href": url_for("search", q=facility.parent_area or facility.agency), + }, + { + "label": f"More {facility.label.lower()} near {facility.location}", + "href": url_for("search", q=facility.location, inventory_type=facility.inventory_type), + }, + { + "label": "Rules & Reservation Policies", + "href": f"{url_for('help_center')}#passes-permits", + }, + { + "label": "Contact & Support Paths", + "href": f"{url_for('help_center')}#contact", + }, + ] + return resources + + +def _site_pass_context(facility: Facility) -> dict[str, str]: + content = {**SITE_PASS_CONTENT["default"], **SITE_PASS_CONTENT.get(facility.slug, {})} + hero_image = content.get("hero_image") or facility.image_url + if hero_image and not str(hero_image).startswith("http"): + hero_image = url_for("static", filename=f"images/{hero_image}") + return { + "page_title": content["page_title"], + "alert": content["alert"], + "notice": content["notice"], + "about": content["about"], + "about_link_label": content["about_link_label"], + "hero_image": hero_image, + "interagency_image": url_for("static", filename="images/real-interagency-passes.webp"), + } + + +@app.route("/") +def index(): + featured = Facility.query.filter_by(available=True).order_by(Facility.rating.desc()).limit(8).all() + nearby = _facilities_by_slug(HOME_NEARBY_SLUGS) + seen = {facility.id for facility in nearby} + fallback_query = Facility.query.filter_by(inventory_type="camping", available=True) + if seen: + fallback_query = fallback_query.filter(~Facility.id.in_(seen)) + nearby.extend( + fallback_query.order_by(Facility.distance_miles, Facility.rating.desc()) + .limit(max(12 - len(nearby), 0)) + .all() + ) + passes = Facility.query.filter(Facility.inventory_type.in_(["passes", "permits"])).order_by(Facility.review_count.desc()).limit(6).all() + experience_panels = [_build_experience_panel(spec) for spec in HOME_EXPERIENCE_PANELS] + home_map_markers = [ + {**entry["point"], "panel_key": panel["key"]} + for panel in experience_panels + for entry in panel["entries"] + ] + home_map_center = home_map_markers[0] if home_map_markers else {"lat": DEFAULT_MAP_CENTER[0], "lng": DEFAULT_MAP_CENTER[1]} + popular_location_names = sorted( + { + facility.parent_area.strip() + for facility in Facility.query.filter(Facility.parent_area != "").all() + if facility.parent_area.strip() + }, + key=str.lower, + ) + state_links = [{"code": code, "name": name, "href": url_for("search", q=name)} for code, name in US_STATES] + home_tab_panels = [ + { + "key": "all", + "type": "explore_all", + }, + { + "key": "camping", + "type": "inventory", + "title": "Available This Weekend", + "description": "Campgrounds with upcoming availability.", + "inventory_type": "camping", + "placeholder": "Search by location or campground name", + "secondary": "Ways to Stay", + "tertiary": "When", + "entries": _home_tab_results("camping", limit=10), + }, + { + "key": "tickets", + "type": "inventory", + "title": "Available This Weekend", + "description": "Tickets and tours with upcoming availability.", + "inventory_type": "tickets", + "placeholder": "Search by location or facility name", + "secondary": "Time", + "tertiary": "mm/dd/yyyy", + "entries": _home_tab_results("tickets", limit=10), + }, + { + "key": "permits", + "type": "inventory", + "title": "Permits Near You", + "description": "Closest entrance, activity, and vehicle permits.", + "inventory_type": "permits", + "placeholder": "Search by location or facility name", + "secondary": "", + "tertiary": "", + "entries": _home_tab_results("permits", limit=10), + }, + { + "key": "day_use", + "type": "inventory", + "title": "Available This Weekend", + "description": "Venues and day-use facilities with upcoming availability.", + "inventory_type": "day_use", + "placeholder": "Search by location or facility name", + "secondary": "", + "tertiary": "mm/dd/yyyy", + "entries": _home_tab_results("day_use", limit=10), + }, + { + "key": "ai", + "type": "ai", + "placeholder": "Tell us what kind of outdoor recreation you want to reserve. Be as descriptive as possible.", + "examples": HOME_AI_EXAMPLES, + }, + ] + promo_features = [ + { + "eyebrow": "Mobile App", + "title": "Adventure is at Your Fingertips", + "body": "Find campgrounds, timed entry, and permits on the go with trip details saved in one place.", + "image": url_for("static", filename="images/real-mobile-app-featured-background.webp"), + "href": url_for("search", inventory_type="camping"), + }, + { + "eyebrow": "Passes", + "title": "Plan Day Use Ahead of Time", + "body": "Reserve high-demand entry and amenity passes before your trip window opens up.", + "image": url_for("static", filename="images/real-passes-featured.webp"), + "href": url_for("passes"), + }, + ] + category_tiles = [ + { + "key": key, + "label": label, + "image": url_for("static", filename=f"images/{image}"), + "description": description, + "href": url_for("category", inventory_type=key) if key in INVENTORY_LABELS else url_for("search", q=search_query or label), + } + for key, label, image, description, search_query in CATEGORY_TILES + ] + article_cards = [ + { + **ARTICLE_LIBRARY[slug], + "href": url_for("article_detail", slug=slug), + "image_url": url_for("static", filename=f"images/{ARTICLE_LIBRARY[slug]['image']}"), + } + for slug in HOME_ARTICLE_SLUGS + ] + return render_template( + "index.html", + featured=featured, + nearby=nearby, + passes=passes, + popular_location_names=popular_location_names, + state_links=state_links, + home_tab_panels=home_tab_panels, + experience_panels=experience_panels, + home_map_markers=home_map_markers, + home_map_center=home_map_center, + promo_features=promo_features, + category_tiles=category_tiles, + article_cards=article_cards, + anchor_city="Santa Clara, CA", + ) + + +@app.route("/search") +def search(): + results = _filtered_facilities(request.args) + agencies = [row[0] for row in db.session.query(Facility.agency).distinct().order_by(Facility.agency).all()] + search_entries = [_map_entry(facility, rank=index + 1) for index, facility in enumerate(results[:24])] + return render_template("search.html", results=results, agencies=agencies, args=request.args, search_entries=search_entries) + + +@app.route("/category/") +def category(inventory_type: str): + if inventory_type not in INVENTORY_LABELS: + abort(404) + args = request.args.to_dict(flat=True) + args["inventory_type"] = inventory_type + results = _filtered_facilities(args) + return render_template("category.html", inventory_type=inventory_type, results=results) + + +@app.route("/camping") +def camping(): + return redirect(url_for("category", inventory_type="camping")) + + +@app.route("/tickets") +def tickets(): + return redirect(url_for("category", inventory_type="tickets")) + + +@app.route("/permits") +def permits(): + return redirect(url_for("category", inventory_type="permits")) + + +@app.route("/permits/") +def permit_detail_alias(permit_id: int): + if permit_id == 233261: + return redirect(url_for("facility_detail", slug="desolation-wilderness-permit")) + abort(404) + + +@app.route("/sitepass/") +def sitepass_detail_alias(sitepass_id: int): + slug = SITEPASS_ID_TO_SLUG.get(sitepass_id) + if slug: + return redirect(url_for("facility_detail", slug=slug)) + abort(404) + + +@app.route("/passes") +def passes(): + return redirect(url_for("category", inventory_type="passes")) + + +@app.route("/day-use") +def day_use(): + return redirect(url_for("category", inventory_type="day_use")) + + +@app.route("/lottery") +def lottery(): + return redirect(url_for("category", inventory_type="lottery")) + + +@app.route("/facility/") +def facility_detail(slug: str): + facility = Facility.query.filter_by(slug=slug).first_or_404() + related = Facility.query.filter(Facility.inventory_type == facility.inventory_type, Facility.id != facility.id).order_by(Facility.rating.desc()).limit(4).all() + detail_map_entries = [_map_entry(item, rank=index + 1) for index, item in enumerate([facility] + related[:3])] + recent_reviews = Review.query.filter_by(facility_id=facility.id).order_by(Review.id.desc()).limit(48).all() + current_author = "" + if current_user.is_authenticated: + current_author = current_user.display_name or current_user.username + is_site_pass = facility.inventory_type == "passes" and "site pass" in facility.name.lower() + template_name = "site_pass_detail.html" if is_site_pass else "facility_detail.html" + return render_template( + template_name, + facility=facility, + related=related, + detail_map_entries=detail_map_entries, + gallery_images=_detail_gallery(facility), + detail_alert=DETAIL_ALERTS.get(facility.slug), + booking_config=_booking_config(facility), + important_dates=_important_dates(facility), + review_breakdown=_review_breakdown(facility), + recent_reviews=recent_reviews, + current_review_author=current_author, + detail_resources=_detail_resources(facility), + detail_map_markers=[entry["point"] for entry in detail_map_entries], + state_name=STATE_NAMES.get(facility.state, facility.state), + site_pass_context=_site_pass_context(facility), + ) + + +@app.route("/facility//reviews", methods=["POST"]) +@login_required +def add_review(slug: str): + facility = Facility.query.filter_by(slug=slug).first_or_404() + body = request.form.get("body", "").strip() + visit_date = request.form.get("visit_date", "").strip() or datetime.utcnow().strftime("%B %Y") + try: + rating = max(1, min(5, int(request.form.get("rating", "5")))) + except ValueError: + rating = 5 + if len(body) < 12: + flash("Write at least a short trip note before submitting a review.", "danger") + return redirect(url_for("facility_detail", slug=slug, _anchor="reviews")) + review = Review( + facility_id=facility.id, + author=current_user.display_name or current_user.username, + rating=rating, + body=body, + visit_date=visit_date, + ) + aggregate_count = int(facility.review_count or 0) + aggregate_rating = float(facility.rating or 0) + facility.rating = round((((aggregate_rating * aggregate_count) + rating) / (aggregate_count + 1)) if aggregate_count else rating, 1) + facility.review_count = aggregate_count + 1 + db.session.add(review) + db.session.commit() + flash("Review submitted.", "success") + return redirect(url_for("facility_detail", slug=slug, _anchor="reviews")) + + +@app.route("/facility//save", methods=["POST"]) +@login_required +def save_facility(slug: str): + facility = Facility.query.filter_by(slug=slug).first_or_404() + existing = SavedItem.query.filter_by(user_id=current_user.id, facility_id=facility.id).first() + if existing: + db.session.delete(existing) + flash("Removed from your saved list.", "info") + else: + db.session.add(SavedItem(user_id=current_user.id, facility_id=facility.id)) + flash("Saved to your trip list.", "success") + db.session.commit() + return redirect(request.referrer or url_for("facility_detail", slug=slug)) + + +@app.route("/cart") +@login_required +def cart(): + items = CartItem.query.filter_by(user_id=current_user.id).all() + return render_template("cart.html", items=items, total=_cart_total(items)) + + +@app.route("/cart/add/", methods=["POST"]) +@login_required +def add_to_cart(facility_id: int): + facility = Facility.query.get_or_404(facility_id) + if not facility.reservable: + flash("This location is informational only and cannot be reserved.", "warning") + return redirect(url_for("facility_detail", slug=facility.slug)) + start_date = request.form.get("start_date") or facility.checkin_date + end_date = request.form.get("end_date") or facility.checkout_date + guests = max(int(request.form.get("guests") or 2), 1) + campsite_id = request.form.get("campsite_id") + campsite = Campsite.query.get(int(campsite_id)) if campsite_id else None + db.session.add(CartItem(user_id=current_user.id, facility_id=facility.id, campsite_id=campsite.id if campsite else None, start_date=start_date, end_date=end_date, guests=guests, quantity=max(int(request.form.get("quantity") or 1), 1))) + db.session.commit() + flash(f"Added {facility.name} to your cart.", "success") + return redirect(url_for("cart")) + + +@app.route("/cart/remove/", methods=["POST"]) +@login_required +def remove_cart_item(item_id: int): + item = CartItem.query.filter_by(id=item_id, user_id=current_user.id).first_or_404() + db.session.delete(item) + db.session.commit() + flash("Cart item removed.", "info") + return redirect(url_for("cart")) + + +@app.route("/checkout", methods=["GET", "POST"]) +@login_required +def checkout(): + items = CartItem.query.filter_by(user_id=current_user.id).all() + if not items: + flash("Your cart is empty.", "warning") + return redirect(url_for("search")) + addresses = Address.query.filter_by(user_id=current_user.id).order_by(Address.is_default.desc()).all() + payments = PaymentMethod.query.filter_by(user_id=current_user.id).order_by(PaymentMethod.is_default.desc()).all() + total = _cart_total(items) + if request.method == "POST": + if not addresses or not payments: + flash("Add a saved address and payment method before checkout.", "danger") + return redirect(url_for("account")) + base_count = Reservation.query.count() + for offset, item in enumerate(items, start=1): + reservation = Reservation(user_id=current_user.id, facility_id=item.facility_id, campsite_name=item.campsite.name if item.campsite else item.facility.label, start_date=item.start_date, end_date=item.end_date, guests=item.guests, total_cost=item.total, status="Upcoming", confirmation_code=_next_confirmation()) + reservation.confirmation_code = f"RG-2026-{base_count + offset:05d}" + db.session.add(reservation) + db.session.delete(item) + db.session.commit() + flash("Your reservation is confirmed.", "success") + return redirect(url_for("reservations")) + return render_template("checkout.html", items=items, total=total, addresses=addresses, payments=payments) + + +@app.route("/saved") +@login_required +def saved(): + items = SavedItem.query.filter_by(user_id=current_user.id).order_by(SavedItem.created_at.desc()).all() + return render_template("saved.html", items=items) + + +@app.route("/reservations") +@login_required +def reservations(): + upcoming = Reservation.query.filter_by(user_id=current_user.id).order_by(Reservation.start_date.desc()).all() + return render_template("reservations.html", reservations=upcoming) + + +@app.route("/reservations//cancel", methods=["POST"]) +@login_required +def cancel_reservation(reservation_id: int): + reservation = Reservation.query.filter_by(id=reservation_id, user_id=current_user.id).first_or_404() + reservation.status = "Cancelled" + db.session.commit() + flash(f"Cancelled reservation {reservation.confirmation_code}.", "info") + return redirect(url_for("reservations")) + + +@app.route("/account", methods=["GET", "POST"]) +@login_required +def account(): + if request.method == "POST": + current_user.display_name = request.form.get("display_name", current_user.display_name).strip() or current_user.display_name + current_user.phone = request.form.get("phone", current_user.phone).strip() + current_user.home_city = request.form.get("home_city", current_user.home_city).strip() + db.session.commit() + flash("Profile updated.", "success") + return redirect(url_for("account")) + addresses = Address.query.filter_by(user_id=current_user.id).all() + payments = PaymentMethod.query.filter_by(user_id=current_user.id).all() + return render_template("account.html", addresses=addresses, payments=payments) + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if current_user.is_authenticated: + return redirect(url_for("account")) + if request.method == "POST": + email = request.form.get("email", "").lower().strip() + password = request.form.get("password", "") + user = User.query.filter_by(email=email).first() + if user and user.check_password(password): + login_user(user) + flash("Signed in.", "success") + return redirect(request.args.get("next") or url_for("account")) + flash("Invalid email or password.", "danger") + return render_template("login.html") + + +@app.route("/register", methods=["GET", "POST"]) +def register(): + if request.method == "POST": + email = request.form.get("email", "").lower().strip() + username = request.form.get("username", "").strip() or email.split("@")[0] + display_name = request.form.get("display_name", "").strip() or username + password = request.form.get("password", "") + if not email or "@" not in email or len(password) < 8: + flash("Use a valid email and an 8+ character password.", "danger") + return render_template("register.html") + if User.query.filter(or_(User.email == email, User.username == username)).first(): + flash("That email or username already exists.", "danger") + return render_template("register.html") + user = User(username=username, email=email, display_name=display_name) + user.set_password(password) + db.session.add(user) + db.session.flush() + db.session.add(Address(user_id=user.id, label="Home", street="100 Trailhead Dr", city="Denver", state="CO", zip_code="80202")) + db.session.add(PaymentMethod(user_id=user.id, card_type="Visa", last4="4242", expiry="12/28")) + db.session.commit() + login_user(user) + flash("Account created.", "success") + return redirect(url_for("account")) + return render_template("register.html") + + +@app.route("/signup") +@app.route("/create-account") +def signup_alias(): + return redirect(url_for("register")) + + +@app.route("/logout") +@login_required +def logout(): + logout_user() + flash("Signed out.", "info") + return redirect(url_for("index")) + + +@app.route("/help") +def help_center(): + topic_cards = [ + { + "title": "Manage Reservations", + "body": "Review cancellations, booking windows, early departures, and how existing reservations can be changed.", + "href": "#reservations", + }, + { + "title": "Account & Sign-In", + "body": "Create an account, sign in, and keep saved trips, cart items, and reservation updates in one place.", + "href": "#account-help", + }, + { + "title": "Passes, Permits & Timed Entry", + "body": "Compare permit windows, timed entry availability, and fee rules before you commit to a trip.", + "href": "#passes-permits", + }, + { + "title": "Accessibility Support", + "body": "Find accessibility assistance, alternate-format support, and TDD contact information.", + "href": "#accessibility", + }, + ] + faq_entries = [ + { + "question": "Why do I need an account to make most reservations?", + "answer": "Accounts act as the unique identifier for reservation limits, updates, closure notices, and saved-trip workflows. They also keep reservation communications tied to one profile.", + }, + { + "question": "What is a booking window?", + "answer": "A booking window determines how far in advance an arrival date becomes reservable. Windows vary by facility and inventory type, so visitors should confirm details on the specific listing.", + }, + { + "question": "How are fees set on Recreation.gov?", + "answer": "Participating agencies set recreation fees, while reservation and service fees follow Recreation.gov policy. Those fees vary by inventory type and whether a reservation is made online, by call center, or in person.", + }, + { + "question": "How should I prepare for high-demand on-sales?", + "answer": "Use one signed-in account, verify your dates ahead of time, keep backup inventory nearby, and expect heavy traffic around release windows for popular campgrounds and permits.", + }, + ] + policy_points = [ + "Camping, day-use, and cabin reservations typically include an $8 online reservation fee. Ticket reservations typically include a $1 online reservation fee.", + "A $10 service fee is usually withheld from cancellations, and late cancellations may also forfeit the first night's use fee or the applicable day-use fee.", + "Reservation changes outside the cut-off window may incur a fee depending on the type of change, while same-site or same-date adjustments may not.", + "Refund requests can be submitted through the customer profile after a reservation ends, and emergency closures may qualify for full fee refunds.", + ] + return render_template( + "help.html", + topic_cards=topic_cards, + faq_entries=faq_entries, + policy_points=policy_points, + ) + + +@app.route("/articles") +def articles_index(): + articles = [ + { + **article, + "href": url_for("article_detail", slug=slug), + "image_url": url_for("static", filename=f"images/{article['image']}"), + } + for slug, article in ARTICLE_LIBRARY.items() + ] + return render_template("articles.html", articles=articles) + + +@app.route("/articles/") +def article_detail(slug: str): + article = ARTICLE_LIBRARY.get(slug) + if not article: + abort(404) + return render_template( + "article.html", + title=article["title"], + kind="editorial", + article={ + **article, + "image_url": url_for("static", filename=f"images/{article['image']}"), + }, + ) + + +@app.route("/about-us") +def about(): + return render_template("article.html", title="About Recreation.gov", kind="about") + + +@app.route("/articles/location-spotlight/Celebrating-America's-250th-Anniversary/1367") +def america250_article(): + return redirect(url_for("article_detail", slug="celebrate-america-250")) + + +@app.route("/api/facilities") +def api_facilities(): + results = _filtered_facilities(request.args) + response = [] + for facility in results: + point = _facility_map_point(facility) + response.append( + { + "name": facility.name, + "slug": facility.slug, + "inventory_type": facility.inventory_type, + "agency": facility.agency, + "location": facility.location, + "price": float(facility.price or 0), + "rating": facility.rating, + "available": facility.available, + "lat": point["lat"], + "lng": point["lng"], + } + ) + return jsonify(response) + + +@app.route("/_health") +def health(): + return {"ok": True, "site": "recreation_gov", "facilities": Facility.query.count()} + + +@app.errorhandler(404) +def not_found(_): + return render_template("404.html"), 404 + + +from seed_data import seed_benchmark_users, seed_database # noqa: E402 + +os.makedirs(os.path.join(BASE_DIR, "instance"), exist_ok=True) +with app.app_context(): + db.create_all() + seed_database(db, Facility, Campsite, Review) + seed_benchmark_users(db, User, Address, PaymentMethod, SavedItem, CartItem, Reservation, Facility, Campsite) + + +if __name__ == "__main__": + port = int(os.environ.get("PORT", 5000)) + app.run(host="0.0.0.0", port=port, debug=False) diff --git a/sites/recreation_gov/requirements.txt b/sites/recreation_gov/requirements.txt new file mode 100644 index 0000000..cd52ce0 --- /dev/null +++ b/sites/recreation_gov/requirements.txt @@ -0,0 +1,4 @@ +Flask +Flask-SQLAlchemy +Flask-Login +Werkzeug diff --git a/sites/recreation_gov/seed_data.py b/sites/recreation_gov/seed_data.py new file mode 100644 index 0000000..e1159c8 --- /dev/null +++ b/sites/recreation_gov/seed_data.py @@ -0,0 +1,395 @@ +"""Seed data for the Recreation.gov mirror. + +The seed functions are deliberately gated at the function level. Re-running +them against a populated DB must be a no-op so /reset/recreation_gov remains +byte-identical to instance_seed/recreation_gov.db. +""" +from __future__ import annotations + +import json +import re +from decimal import Decimal + +PASSWORD = "TestPass123!" + +IMAGE_POOL = [ + "015-point-reyes-national-seashore.webp", + "016-pinnacles-national-park.webp", + "018-preview-photo-of-outflow-camping.webp", + "019-preview-photo-of-brooks-camp-camping-permit.webp", + "020-preview-photo-of-mammoth-cave-backcountry-camping.webp", + "021-preview-photo-of-spinreel-sand-camping.webp", + "022-preview-photo-of-ausable-river-camping.webp", + "023-preview-photo-of-umpqua-sand-camping.webp", + "024-preview-photo-of-basin-cove-backcountry-camping.webp", + "025-preview-photo-of-horsfall-sand-camping.webp", + "026-preview-photo-of-bluff-hike-in-camping.webp", + "027-preview-photo-of-cumberland-island-national-seashore-camping-permits.webp", + "028-preview-photo-of-voyageurs-national-park-camping-permits.webp", + "029-preview-photo-of-siltcoos-sand-camping.webp", + "030-preview-photo-of-hauser-sand-camping.webp", + "031-preview-photo-of-dale-hollow-lake-primitive-camping.webp", + "032-preview-photo-of-rock-castle-gorge-backcountry-camping.webp", + "033-preview-photo-of-johns-river-road-backcountry-camping.webp", + "034-preview-photo-of-twin-creek-campground-group-camping-site.webp", + "035-preview-photo-of-big-bend-backcountry-camping.webp", + "036-preview-photo-of-sylvania-wilderness-backcountry-camping.webp", + "037-preview-photo-of-winhall-brook-camping-area.webp", + "038-preview-photo-of-santa-rosa-island-backcountry-beach-camping.webp", + "039-preview-photo-of-apostle-islands-national-lakeshore-camping-permits.webp", + "040-preview-photo-of-calpine-lookout.webp", + "041-preview-photo-of-okefenokee-national-wildlife-refuge-overnight-camping.webp", + "042-preview-photo-of-chopawamsic-backcountry-camping-permits.webp", + "043-preview-photo-of-chilkoot-trail-camping-permits.webp", + "044-preview-photo-of-delta-national-forest-camping.webp", + "045-preview-photo-of-south-jetty-sand-camping.webp", + "046-preview-photo-of-pictured-rocks-national-lakeshore-backcountry-camping.webp", + "047-preview-photo-of-manzanita-lake-camping-cabins-ca.webp", + "048-preview-photo-of-center-hill-lake-primitive-camping-areas.webp", + "049-preview-photo-of-canning-creek.webp", + "050-preview-photo-of-lake-powhatan-glamping.webp", + "051-preview-photo-of-bumping-lake-campground.webp", + "052-preview-photo-of-death-valley-backcountry-roadside-camping.webp", + "053-preview-photo-of-mill-creek-camping-berlin-lake.webp", + "054-preview-photo-of-sherando-lake-recreation-area-family-camping.webp", + "055-preview-photo-of-katahdin-woods-waters-national-monument-camping.webp", + "056-preview-photo-of-samsing-cove-cabin.webp", + "057-preview-photo-of-rampart-range-recreation-area-designated-dispersed-ca.webp", + "058-preview-photo-of-soda-springs-campground-bumping-river-wa.webp", + "059-preview-photo-of-cedro-peak-camping-sites-robin-and-jay.webp", + "060-preview-photo-of-mt-whitney.webp", + "061-preview-photo-of-enchantment-permit-area-advanced-lottery.webp", + "062-preview-photo-of-desolation-wilderness-permit.webp", + "063-preview-photo-of-yellowstone-national-park-backcountry-permits.webp", + "064-preview-photo-of-canyonlands-national-park-overnight-backcountry-permi.webp", + "065-preview-photo-of-fire-island-national-seashore-permits.webp", + "066-preview-photo-of-hemlock-cabin.webp", + "067-preview-photo-of-inyo-national-forest-wilderness-permits.webp", + "068-preview-photo-of-mount-margaret-backcountry.webp", + "069-preview-photo-of-aravaipa-canyon-wilderness-permits.webp", + "070-preview-photo-of-central-cascades-wilderness-overnight-permits.webp", + "071-preview-photo-of-charon-s-garden.webp", + "072-preview-photo-of-tilly-jane-a-frame.webp", +] + +IMAGE_OVERRIDES = { + "Aquatic Park Cove Overnight Anchoring": "real-aquatic-park-cove.webp", + "Point Reyes National Seashore Campground": "real-point-reyes-campground.webp", + "Pinnacles Campground": "real-pinnacles-campground.webp", + "Oak Knoll Campground": "real-oak-knoll-campground.webp", + "Glory Hole Recreation Area": "real-glory-hole.webp", + "Lake Sonoma Boat-In Sites": "real-lake-sonoma-boat-in.webp", + "San Francisco Maritime Historic Park Tours": "real-sf-maritime-tours.webp", + "Fort Point National Historic Site Tours": "real-fort-point-tours.webp", +} + +BASE_FACILITIES = [ + ("Point Reyes National Seashore Campground", "camping", "NPS", "Point Reyes National Seashore", "Olema", "CA", 37, 30, "night", 4.8, 1175, True, True, ["Camping", "Hiking", "Wildlife Viewing"], ["Accessible Sites", "Tent Pads", "Drinking Water"]), + ("Pinnacles Campground", "camping", "NPS", "Pinnacles National Park", "Paicines", "CA", 72, 39, "night", 4.7, 3682, True, True, ["Camping", "Stargazing", "Climbing"], ["Accessible Sites", "RV Hookups", "Showers"]), + ("Kirby Cove Campground", "camping", "NPS", "Golden Gate National Recreation Area", "Sausalito", "CA", 43, 50, "night", 4.6, 612, True, False, ["Camping", "Beach", "Photography"], ["Tent Pads", "Picnic Tables", "Vault Toilets"]), + ("Rob Hill Group Campground", "camping", "Presidio Trust", "Presidio of San Francisco", "San Francisco", "CA", 42, 105, "night", 4.5, 28, True, True, ["Group Camping", "Urban Parks", "Hiking"], ["Accessible Sites", "Group Fire Ring", "Restrooms"]), + ("Aquatic Park Cove Overnight Anchoring", "camping", "NPS", "San Francisco Maritime National Historical Park", "San Francisco", "CA", 41, 10, "night", 4.2, 55, True, False, ["Boating", "Sailing", "Historic Sites"], ["Anchoring Permit", "Waterfront Access"]), + ("Arroyo Seco", "camping", "USFS", "Los Padres National Forest", "Greenfield", "CA", 112, 35, "night", 4.4, 234, True, False, ["Camping", "Swimming", "Hiking"], ["River Access", "Picnic Tables", "Vault Toilets"]), + ("Oak Knoll Campground", "camping", "USACE", "New Hogan Lake", "Valley Springs", "CA", 94, 28, "night", 4.1, 59, True, False, ["Camping", "Boating", "Fishing"], ["Boat Ramp", "Picnic Tables", "Dump Station"]), + ("Glory Hole Recreation Area", "camping", "USACE", "New Melones Lake", "Angels Camp", "CA", 118, 34, "night", 4.4, 437, True, True, ["Camping", "Fishing", "Boating"], ["Accessible Sites", "Marina", "Showers"]), + ("Yosemite Creek Campground", "camping", "NPS", "Yosemite National Park", "Yosemite National Park", "CA", 2, 24, "night", 4.6, 811, True, False, ["Camping", "Waterfalls", "Hiking"], ["Creek Access", "Tent Pads", "Vault Toilets"]), + ("Porcupine Flat Campground", "camping", "NPS", "Yosemite National Park", "Yosemite National Park", "CA", 16, 20, "night", 4.3, 267, False, False, ["Camping", "Hiking", "Scenic Drives"], ["Tent Pads", "Vault Toilets"]), + ("Manzanita Lake Camping Cabins", "camping", "NPS", "Lassen Volcanic National Park", "Mineral", "CA", 204, 76, "night", 4.8, 512, True, True, ["Cabins", "Lake", "Volcanoes"], ["Accessible Cabins", "Camp Store", "Showers"]), + ("Lake Powhatan Glamping", "camping", "USFS", "National Forests in North Carolina", "Asheville", "NC", 2510, 99, "night", 4.7, 180, True, True, ["Glamping", "Mountain Biking", "Lake"], ["Canvas Tents", "Accessible Sites", "Showers"]), + ("Mammoth Cave Backcountry Camping", "camping", "NPS", "Mammoth Cave National Park", "Mammoth Cave", "KY", 2280, 10, "permit", 4.6, 422, True, False, ["Backcountry Camping", "Caves", "Hiking"], ["Permit Required", "Primitive Sites"]), + ("Big Bend Backcountry Camping", "camping", "NPS", "Big Bend National Park", "Big Bend National Park", "TX", 1650, 16, "permit", 4.5, 650, True, False, ["Backcountry Camping", "Desert", "Stargazing"], ["Permit Required", "Primitive Sites"]), + ("Death Valley Backcountry Roadside Camping", "camping", "NPS", "Death Valley National Park", "Death Valley", "CA", 505, 0, "permit", 4.3, 344, True, False, ["Backcountry Camping", "Desert", "Scenic Drives"], ["Free Permit", "High Clearance Routes"]), + ("Sherando Lake Family Camping", "camping", "USFS", "George Washington and Jefferson National Forests", "Lyndhurst", "VA", 2670, 32, "night", 4.7, 495, True, True, ["Camping", "Swimming", "Hiking"], ["Accessible Sites", "Beach", "Showers"]), + ("Tilly Jane A-Frame", "camping", "USFS", "Mt. Hood National Forest", "Parkdale", "OR", 620, 90, "night", 4.9, 91, True, False, ["Cabins", "Skiing", "Hiking"], ["Wood Stove", "Bunk Beds", "Trail Access"]), + ("Hemlock Cabin", "camping", "USFS", "Tongass National Forest", "Sitka", "AK", 1750, 65, "night", 4.8, 64, True, False, ["Cabins", "Fishing", "Wildlife Viewing"], ["Cabin", "Boat Access", "Wood Stove"]), + ("Yosemite National Park Wilderness Permits", "permits", "NPS", "Yosemite National Park", "Yosemite National Park", "CA", 2, 10, "permit", 4.7, 1047, True, False, ["Wilderness", "Backpacking", "Half Dome"], ["Quota Permit", "Bear Canister Required"]), + ("Mt. Whitney", "permits", "USFS", "Inyo National Forest", "Lone Pine", "CA", 331, 15, "permit", 4.8, 891, True, False, ["Hiking", "Wilderness", "Summit"], ["Lottery", "Quota Permit", "Day Use"]), + ("Enchantment Permit Area Advanced Lottery", "lottery", "USFS", "Okanogan-Wenatchee National Forest", "Leavenworth", "WA", 835, 6, "permit", 4.9, 763, True, False, ["Lottery", "Backpacking", "Alpine Lakes"], ["Advanced Lottery", "Quota Permit"]), + ("Desolation Wilderness Permit", "permits", "USFS", "Eldorado National Forest", "South Lake Tahoe", "CA", 214, 10, "permit", 4.7, 386, True, False, ["Wilderness", "Backpacking", "Lakes"], ["Quota Permit", "Overnight Permit"]), + ("Yellowstone National Park Backcountry Permits", "permits", "NPS", "Yellowstone National Park", "Yellowstone National Park", "WY", 0, 10, "permit", 4.6, 253, True, False, ["Backcountry", "Wildlife Viewing", "Fishing"], ["Bear Safety", "Permit Required"]), + ("Canyonlands Overnight Backcountry Permits", "permits", "NPS", "Canyonlands National Park", "Moab", "UT", 780, 36, "permit", 4.8, 318, True, False, ["Backcountry", "Canyons", "4x4"], ["Vehicle Permit", "Overnight Permit"]), + ("Fire Island National Seashore Permits", "permits", "NPS", "Fire Island National Seashore", "Patchogue", "NY", 2980, 25, "permit", 4.4, 88, True, False, ["Beach", "Permits", "Wildlife Viewing"], ["Driving Permit", "Seasonal Rules"]), + ("Inyo National Forest Wilderness Permits", "permits", "USFS", "Inyo National Forest", "Bishop", "CA", 340, 6, "permit", 4.7, 912, True, False, ["Wilderness", "Backpacking", "Fishing"], ["Quota Permit", "Trailhead Entry"]), + ("Aravaipa Canyon Wilderness Permits", "permits", "BLM", "Aravaipa Canyon Wilderness", "Winkelman", "AZ", 742, 5, "permit", 4.6, 179, True, False, ["Canyons", "Hiking", "Wildlife Viewing"], ["Day Use Permit", "Overnight Permit"]), + ("Central Cascades Wilderness Overnight Permits", "permits", "USFS", "Willamette National Forest", "Bend", "OR", 590, 6, "permit", 4.5, 248, True, False, ["Wilderness", "Backpacking", "Lakes"], ["Quota Permit", "Trailhead Entry"]), + ("Voyageurs National Park Tours", "tickets", "NPS", "Voyageurs National Park", "International Falls", "MN", 0, 40, "ticket", 4.8, 522, True, True, ["Tours", "Boating", "Wildlife Viewing"], ["Accessible Seating", "Ranger Program"]), + ("Mariposa Grove Commercial Bus Parking", "tickets", "NPS", "Yosemite National Park", "Fish Camp", "CA", 24, 25, "ticket", 4.2, 8, True, True, ["Timed Entry", "Shuttles", "Giant Sequoias"], ["Timed Entry", "Commercial Vehicle"]), + ("San Francisco Maritime Historic Park Tours", "tickets", "NPS", "San Francisco Maritime National Historical Park", "San Francisco", "CA", 42, 15, "ticket", 4.5, 133, True, True, ["Tours", "Historic Ships", "Waterfront"], ["Accessible Route", "Ranger Program"]), + ("Fort Point National Historic Site Tours", "tickets", "NPS", "Golden Gate National Recreation Area", "San Francisco", "CA", 45, 12, "ticket", 4.4, 96, True, True, ["Tours", "History", "Photography"], ["Accessible Route", "Timed Entry"]), + ("Voyageurs Winter Equipment Rentals", "tickets", "NPS", "Voyageurs National Park", "Kabetogama", "MN", 0, 20, "ticket", 4.1, 6, True, False, ["Equipment Rentals", "Skiing", "Snowshoeing"], ["Rental Window", "Winter Access"]), + ("Campbell Creek Science Center Public Programs", "tickets", "BLM", "Campbell Tract", "Anchorage", "AK", 3110, 8, "ticket", 4.4, 42, True, True, ["Education", "Wildlife Viewing", "Family Programs"], ["Accessible Route", "Indoor Program"]), + ("Yellowstone National Park Fishing Permit", "passes", "NPS", "Yellowstone National Park", "Yellowstone National Park", "WY", 0, 20, "pass", 4.7, 637, True, False, ["Fishing", "Activity Pass", "Rivers"], ["Digital Pass", "Seasonal Rules"]), + ("Denali National Park Site Pass", "passes", "NPS", "Denali National Park and Preserve", "Healy", "AK", 0, 15, "pass", 4.6, 501, True, True, ["Site Pass", "Scenic Drives", "Wildlife Viewing"], ["Digital Pass", "Accessible Visitor Center"]), + ("Yosemite National Park Site Pass", "passes", "NPS", "Yosemite National Park", "Yosemite National Park", "CA", 0, 35, "pass", 4.8, 2410, True, True, ["Site Pass", "Waterfalls", "Hiking"], ["Digital Pass", "Entrance Station"]), + ("Grand Teton National Park Site Pass", "passes", "NPS", "Grand Teton National Park", "Moose", "WY", 7, 35, "pass", 4.8, 1312, True, True, ["Site Pass", "Mountains", "Wildlife Viewing"], ["Digital Pass", "Entrance Station"]), + ("Rocky Mountain National Park Timed Entry", "passes", "NPS", "Rocky Mountain National Park", "Estes Park", "CO", 1010, 2, "pass", 4.6, 1852, True, True, ["Timed Entry", "Scenic Drives", "Hiking"], ["Timed Entry", "Park Access"]), + ("Zion Canyon Shuttle Tickets", "tickets", "NPS", "Zion National Park", "Springdale", "UT", 690, 1, "ticket", 4.5, 830, False, True, ["Shuttle", "Canyons", "Hiking"], ["Timed Entry", "Accessible Seating"]), + ("Muir Woods Parking and Shuttle", "day_use", "NPS", "Muir Woods National Monument", "Mill Valley", "CA", 32, 9, "ticket", 4.6, 2094, True, True, ["Day Use", "Redwoods", "Shuttle"], ["Parking Reservation", "Accessible Parking"]), + ("Crescent Moon Picnic Site", "day_use", "USFS", "Coconino National Forest", "Sedona", "AZ", 790, 12, "day", 4.5, 420, True, True, ["Day Use", "Picnicking", "Photography"], ["Picnic Tables", "Creek Access"]), + ("Lake Sonoma Boat-In Sites", "camping", "USACE", "Lake Sonoma", "Geyserville", "CA", 97, 20, "night", 4.5, 133, True, False, ["Boat-In Camping", "Fishing", "Boating"], ["Boat Access", "Primitive Sites"]), + ("Cumberland Island Camping Permits", "permits", "NPS", "Cumberland Island National Seashore", "St Marys", "GA", 2830, 12, "permit", 4.8, 412, True, False, ["Beach Camping", "Wilderness", "Wildlife Viewing"], ["Ferry Access", "Permit Required"]), + ("Apostle Islands Camping Permits", "permits", "NPS", "Apostle Islands National Lakeshore", "Bayfield", "WI", 2160, 15, "permit", 4.7, 289, True, False, ["Island Camping", "Kayaking", "Lakes"], ["Permit Required", "Primitive Sites"]), +] + +EXTRA_NAMES = [ + ("Bumping Lake Campground", "camping", "USFS", "Okanogan-Wenatchee National Forest", "Naches", "WA", ["Camping", "Lake", "Fishing"]), + ("Soda Springs Campground", "camping", "USFS", "Bumping River", "Naches", "WA", ["Camping", "River", "Hiking"]), + ("Rampart Range Designated Dispersed Camping", "camping", "USFS", "Pike-San Isabel National Forest", "Woodland Park", "CO", ["Camping", "OHV", "Forest"]), + ("Cedro Peak Robin and Jay Sites", "camping", "USFS", "Cibola National Forest", "Tijeras", "NM", ["Camping", "Mountain Biking", "Forest"]), + ("Okefenokee Overnight Camping Permit", "permits", "FWS", "Okefenokee National Wildlife Refuge", "Folkston", "GA", ["Permits", "Paddling", "Wildlife Viewing"]), + ("Chilkoot Trail Camping Permits", "permits", "NPS", "Klondike Gold Rush National Historical Park", "Skagway", "AK", ["Permits", "Backpacking", "History"]), + ("Calpine Lookout", "camping", "USFS", "Tahoe National Forest", "Calpine", "CA", ["Cabins", "Lookout", "Stargazing"]), + ("Samsing Cove Cabin", "camping", "USFS", "Tongass National Forest", "Petersburg", "AK", ["Cabins", "Fishing", "Boat Access"]), + ("Chopawamsic Backcountry Permits", "permits", "NPS", "Prince William Forest Park", "Triangle", "VA", ["Permits", "Backcountry", "Hiking"]), + ("Mount Margaret Backcountry", "permits", "USFS", "Gifford Pinchot National Forest", "Toutle", "WA", ["Permits", "Volcanoes", "Backpacking"]), + ("Charon's Garden Wilderness Permit", "permits", "FWS", "Wichita Mountains Wildlife Refuge", "Cache", "OK", ["Permits", "Climbing", "Wildlife Viewing"]), + ("South Jetty Sand Camping", "camping", "USFS", "Siuslaw National Forest", "Florence", "OR", ["Camping", "Sand Dunes", "OHV"]), + ("Delta National Forest Camping", "camping", "USFS", "Delta National Forest", "Rolling Fork", "MS", ["Camping", "Hunting", "Fishing"]), + ("Center Hill Primitive Camping Areas", "camping", "USACE", "Center Hill Lake", "Lancaster", "TN", ["Camping", "Lake", "Boating"]), + ("Canning Creek", "camping", "USACE", "Council Grove Lake", "Council Grove", "KS", ["Camping", "Fishing", "Boating"]), + ("Mill Creek Camping", "camping", "USACE", "Berlin Lake", "Berlin Center", "OH", ["Camping", "Lake", "Family"]), + ("Santa Rosa Island Backcountry Beach Camping", "camping", "NPS", "Channel Islands National Park", "Ventura", "CA", ["Camping", "Beach", "Island"]), + ("Sylvania Wilderness Backcountry Camping", "camping", "USFS", "Ottawa National Forest", "Watersmeet", "MI", ["Camping", "Canoeing", "Wilderness"]), + ("Brooks Camp Camping Permit", "camping", "NPS", "Katmai National Park & Preserve", "King Salmon", "AK", ["Camping", "Bear Viewing", "Fishing"]), + ("Umpqua Sand Camping", "camping", "USFS", "Siuslaw National Forest", "Reedsport", "OR", ["Camping", "Sand Dunes", "OHV"]), + ("Siltcoos Sand Camping", "camping", "USFS", "Oregon Dunes National Recreation Area", "Florence", "OR", ["Camping", "Sand Dunes", "ATV"]), + ("Hauser Sand Camping", "camping", "USFS", "Oregon Dunes National Recreation Area", "North Bend", "OR", ["Camping", "OHV", "Beach"]), + ("Horsfall Sand Camping", "camping", "USFS", "Oregon Dunes National Recreation Area", "North Bend", "OR", ["Camping", "Sand Dunes", "Beach"]), + ("Bluff Hike-In Camping", "camping", "NPS", "Cumberland Island National Seashore", "St Marys", "GA", ["Camping", "Beach", "Hiking"]), + ("Pictured Rocks Backcountry Camping", "camping", "NPS", "Pictured Rocks National Lakeshore", "Munising", "MI", ["Camping", "Kayaking", "Hiking"]), + ("Katahdin Woods Waters Monument Camping", "camping", "NPS", "Katahdin Woods and Waters National Monument", "Patten", "ME", ["Camping", "Stargazing", "Paddling"]), + ("Berlin Lake Shoreline Camping", "camping", "USACE", "Berlin Lake", "Berlin Center", "OH", ["Camping", "Fishing", "Family"]), + ("Ausable River Camping", "camping", "NPS", "Pictured Rocks National Lakeshore", "Grand Marais", "MI", ["Camping", "River", "Hiking"]), + ("Rock Castle Gorge Backcountry Camping", "permits", "NPS", "Blue Ridge Parkway", "Mabry Mill", "VA", ["Backpacking", "Waterfalls", "Hiking"]), + ("Johns River Road Backcountry Camping", "permits", "NPS", "Blue Ridge Parkway", "Blowing Rock", "NC", ["Backpacking", "Forest", "Hiking"]), + ("Voyageurs Island Campsites", "camping", "NPS", "Voyageurs National Park", "International Falls", "MN", ["Boat-In Camping", "Fishing", "Paddling"]), + ("Okefenokee Canal Run Camping", "permits", "FWS", "Okefenokee National Wildlife Refuge", "Folkston", "GA", ["Paddling", "Wildlife Viewing", "Camping"]), + ("Basin Cove Backcountry Camping", "camping", "NPS", "Acadia National Park", "Bar Harbor", "ME", ["Camping", "Island", "Hiking"]), + ("Dale Hollow Primitive Camping", "camping", "USACE", "Dale Hollow Lake", "Celina", "TN", ["Camping", "Boating", "Fishing"]), + ("Twin Creek Group Camping", "camping", "USACE", "Tenkiller Ferry Lake", "Vian", "OK", ["Group Camping", "Lake", "Boating"]), + ("Chilkoot Trail Campgrounds", "permits", "NPS", "Klondike Gold Rush National Historical Park", "Skagway", "AK", ["Backpacking", "History", "Camping"]), + ("Apostle Islands Kayak Camping", "permits", "NPS", "Apostle Islands National Lakeshore", "Bayfield", "WI", ["Kayaking", "Camping", "Lakes"]), + ("Firehole Canyon Picnic Area", "day_use", "NPS", "Yellowstone National Park", "Madison", "WY", ["Day Use", "Picnicking", "Scenic Drives"]), + ("Jenny Lake Shuttle Boat Tickets", "tickets", "NPS", "Grand Teton National Park", "Moose", "WY", ["Boating", "Hiking", "Scenic Views"]), + ("Mesa Verde Cliff Dwelling Tours", "tickets", "NPS", "Mesa Verde National Park", "Mesa Verde", "CO", ["Tours", "History", "Archeology"]), + ("Arches Scenic Drive Timed Entry", "passes", "NPS", "Arches National Park", "Moab", "UT", ["Timed Entry", "Scenic Drives", "Photography"]), + ("Biscayne National Park Heritage Tours", "tickets", "NPS", "Biscayne National Park", "Homestead", "FL", ["Tours", "Boating", "Snorkeling"]), +] + +REVIEW_AUTHORS = [ + "Maya R.", "Jordan P.", "Sam K.", "Taylor M.", "Avery L.", "Cameron J.", + "Riley S.", "Logan W.", "Harper T.", "Casey N.", "Morgan E.", "Dakota F.", + "Jamie C.", "Skyler A.", "Quinn B.", "Parker D.", "Emerson H.", "Rowan G.", +] + +REVIEW_VISIT_DATES = [ + "January 2026", "February 2026", "March 2026", "April 2026", + "May 2026", "June 2026", "July 2026", "August 2026", + "September 2025", "October 2025", "November 2025", "December 2025", +] + +REVIEW_OPENERS = [ + "The reservation details lined up with what we found on arrival.", + "This was one of the smoother Recreation.gov bookings we've used recently.", + "We compared several nearby options before settling on this listing and were glad we did.", + "The listing was detailed enough that we could plan arrival and gear without guessing.", + "This spot worked well for a trip where timing and access rules mattered.", + "The page made it easier to understand what was included and what still needed planning.", + "This location felt close to the real experience described in the listing.", + "We booked this after reviewing a few near-miss alternatives and it held up well.", +] + +REVIEW_CLOSERS = [ + "We would book it again for a similar itinerary.", + "It is worth checking nearby alternatives too, but this one delivered on the basics.", + "The key rules were accurate and easy to confirm before departure.", + "The reservation flow and on-site expectations were more straightforward than expected.", + "This is a strong option if you want the same activity mix shown on the page.", + "The arrival guidance helped avoid last-minute confusion.", + "For a return trip, we would save this alongside one backup option nearby.", + "The listing gave us a realistic sense of the tradeoffs before checkout.", +] + +RATING_OFFSETS = [0, 0, 0, -1, 0, 0, -1, 0, -2, 0, 0, -1, 0, 0, -1, 0, 0, 0] + + +def _slugify(value: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + + +def _description(name: str, parent: str, activities: list[str], amenities: list[str]) -> tuple[str, str]: + activity_text = ", ".join(activities[:3]).lower() + amenity_text = ", ".join(amenities[:3]).lower() + short = f"{name} offers {activity_text} within {parent}." + long = ( + f"{name} is seeded from real Recreation.gov search patterns and mirrors the booking " + f"details agents need to inspect: agency ownership, location, availability windows, " + f"fees, accessible options, activities, and facility rules. Visitors should compare " + f"{activity_text} opportunities with nearby alternatives before reserving. Key " + f"amenities include {amenity_text}." + ) + return short, long + + +def _facility_dict(row: tuple, idx: int) -> dict: + name, inventory_type, agency, parent, city, state, distance, price, unit, rating, reviews, available, accessible, activities, amenities = row + rules = ["Carry confirmation details", "Check local alerts before arrival", "Follow posted quiet hours and wildlife guidance"] + short, long = _description(name, parent, activities, amenities) + return { + "slug": _slugify(name), + "name": name, + "inventory_type": inventory_type, + "agency": agency, + "parent_area": parent, + "location": city, + "state": state, + "distance_miles": distance, + "price": Decimal(str(price)), + "price_unit": unit, + "rating": rating, + "review_count": reviews, + "available": available, + "accessible": accessible, + "reservable": inventory_type not in {"day_use"} or "Parking" in name or "Picnic" in name, + "image": IMAGE_OVERRIDES.get(name, IMAGE_POOL[idx % len(IMAGE_POOL)]), + "short_description": short, + "long_description": long, + "amenities_json": json.dumps(amenities), + "activities_json": json.dumps(activities), + "rules_json": json.dumps(rules), + "checkin_date": "2026-05-15", + "checkout_date": "2026-05-17", + "capacity": 8 if "Group" in name else 4, + "tags": " ".join([inventory_type, agency, parent, city, state] + activities + amenities), + } + + +def _extra_facilities() -> list[tuple]: + rows = [] + for idx, (name, inventory_type, agency, parent, city, state, activities) in enumerate(EXTRA_NAMES): + price = [18, 22, 28, 35, 45, 60][idx % 6] + unit = "permit" if inventory_type == "permits" else "night" + amenities = ["Reservable", "Map View", "Mobile Confirmation"] + if inventory_type == "camping": + amenities += ["Picnic Tables", "Vault Toilets"] + else: + amenities += ["Quota Details", "Date Entry"] + rows.append((name, inventory_type, agency, parent, city, state, 70 + idx * 43, price, unit, 4.1 + (idx % 8) / 10, 40 + idx * 23, idx % 5 != 0, idx % 4 == 0, activities, amenities)) + return rows + + +def seed_database(db, Facility, Campsite, Review): + if Facility.query.count() > 0: + return + + facilities = [] + for idx, row in enumerate(BASE_FACILITIES + _extra_facilities()): + facility = Facility(**_facility_dict(row, idx)) + db.session.add(facility) + facilities.append(facility) + db.session.flush() + + for idx, facility in enumerate(facilities): + if facility.inventory_type == "camping": + for site_no in range(1, 4): + db.session.add(Campsite( + facility_id=facility.id, + name=f"{facility.name.split()[0]} Site {site_no}", + site_type=["Standard", "Tent Only", "RV / Trailer"][site_no - 1], + nightly_rate=facility.price + Decimal(site_no * 4), + capacity=facility.capacity + site_no, + accessible=facility.accessible and site_no == 1, + available_dates="2026-05-15,2026-05-16,2026-05-17,2026-06-01,2026-06-02", + )) + if facility.review_count >= 1000: + review_target = 42 + elif facility.review_count >= 250: + review_target = 34 + elif facility.review_count >= 80: + review_target = 26 + else: + review_target = 18 + if facility.inventory_type in {"tickets", "day_use"}: + review_target = max(review_target - 4, 16) + if facility.inventory_type == "passes": + review_target = max(review_target, 30) + + for review_no in range(review_target): + activity = facility.activities[review_no % len(facility.activities)] if facility.activities else facility.label + amenity = facility.amenities[review_no % len(facility.amenities)] if facility.amenities else "arrival details" + author = REVIEW_AUTHORS[(idx + review_no) % len(REVIEW_AUTHORS)] + visit_date = REVIEW_VISIT_DATES[(idx + review_no) % len(REVIEW_VISIT_DATES)] + rating = max(1, min(5, int(round(facility.rating)) + RATING_OFFSETS[review_no % len(RATING_OFFSETS)])) + body = ( + f"{REVIEW_OPENERS[(idx + review_no) % len(REVIEW_OPENERS)]} " + f"The {activity.lower()} component stood out, and the {amenity.lower()} details matched the listing. " + f"{REVIEW_CLOSERS[(idx + review_no) % len(REVIEW_CLOSERS)]}" + ) + db.session.add(Review( + facility_id=facility.id, + author=author, + rating=rating, + body=body, + visit_date=visit_date, + )) + db.session.commit() + + +def seed_benchmark_users(db, User, Address, PaymentMethod, SavedItem, CartItem, Reservation, Facility, Campsite): + if User.query.filter_by(email="alice.j@test.com").first(): + return + + users = [ + ("alice_j", "alice.j@test.com", "Alice Johnson", "San Jose"), + ("bob_c", "bob.c@test.com", "Bob Chen", "Seattle"), + ("carol_d", "carol.d@test.com", "Carol Davis", "Denver"), + ("david_k", "david.k@test.com", "David Kim", "Atlanta"), + ] + created = {} + for idx, (username, email, display_name, city) in enumerate(users): + user = User(username=username, email=email, display_name=display_name, phone=f"555-010{idx}", home_city=city) + user.set_password(PASSWORD) + db.session.add(user) + db.session.flush() + db.session.add(Address(user_id=user.id, label="Home", street=f"{100 + idx} Trailhead Ave", city=city, state=["CA", "WA", "CO", "GA"][idx], zip_code=["95113", "98101", "80202", "30303"][idx], is_default=True)) + db.session.add(PaymentMethod(user_id=user.id, card_type=["Visa", "Mastercard", "Amex", "Visa"][idx], last4=["4242", "1881", "3005", "9191"][idx], expiry="12/28", is_default=True)) + created[email] = user + + def facility(slug: str): + return Facility.query.filter_by(slug=slug).first() + + alice = created["alice.j@test.com"] + bob = created["bob.c@test.com"] + carol = created["carol.d@test.com"] + david = created["david.k@test.com"] + + for user, slugs in [ + (alice, ["point-reyes-national-seashore-campground", "yosemite-national-park-wilderness-permits", "muir-woods-parking-and-shuttle"]), + (bob, ["enchantment-permit-area-advanced-lottery", "bumping-lake-campground", "voyageurs-national-park-tours"]), + (carol, ["rocky-mountain-national-park-timed-entry", "grand-teton-national-park-site-pass", "canyonlands-overnight-backcountry-permits"]), + (david, ["cumberland-island-camping-permits", "okefenokee-overnight-camping-permit", "sherando-lake-family-camping"]), + ]: + for slug in slugs: + f = facility(slug) + if f: + db.session.add(SavedItem(user_id=user.id, facility_id=f.id)) + + for user, slug in [(alice, "pinnacles-campground"), (bob, "lake-powhatan-glamping"), (carol, "denali-national-park-site-pass"), (david, "voyageurs-national-park-tours")]: + f = facility(slug) + site = f.campsites[0] if f and f.campsites else None + if f: + db.session.add(CartItem(user_id=user.id, facility_id=f.id, campsite_id=site.id if site else None, start_date="2026-05-15", end_date="2026-05-17", guests=2, quantity=1)) + + reservations = [ + (alice, "kirby-cove-campground", "Kirby Site 1", "2026-06-12", "2026-06-14", "RG-2026-AJ01", "Upcoming", 108), + (alice, "yellowstone-national-park-fishing-permit", "Activity Pass", "2026-07-03", "2026-07-03", "RG-2026-AJ02", "Upcoming", 20), + (bob, "mt-whitney", "Day Use Permit", "2026-08-11", "2026-08-11", "RG-2026-BC01", "Upcoming", 15), + (carol, "yosemite-national-park-site-pass", "Site Pass", "2026-05-20", "2026-05-20", "RG-2026-CD01", "Upcoming", 35), + (david, "fort-point-national-historic-site-tours", "Timed Ticket", "2026-04-19", "2026-04-19", "RG-2026-DK01", "Completed", 12), + ] + for user, slug, campsite_name, start, end, code, status, total in reservations: + f = facility(slug) + if f: + db.session.add(Reservation(user_id=user.id, facility_id=f.id, campsite_name=campsite_name, start_date=start, end_date=end, guests=2, total_cost=Decimal(str(total)), status=status, confirmation_code=code)) + + db.session.commit() diff --git a/sites/recreation_gov/static/css/.gitkeep b/sites/recreation_gov/static/css/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sites/recreation_gov/static/css/main.css b/sites/recreation_gov/static/css/main.css new file mode 100644 index 0000000..0197016 --- /dev/null +++ b/sites/recreation_gov/static/css/main.css @@ -0,0 +1,2207 @@ +:root { + --ink: #1f2933; + --muted: #5e6a75; + --line: #d7dee5; + --soft: #f4f6f8; + --blue: #005ea8; + --blue-dark: #1a4f85; + --green: #2e8540; + --orange: #fdb81e; + --danger: #b50909; + --radius: 6px; +} + +* { box-sizing: border-box; } +body { + margin: 0; + font-family: "Open Sans", "Source Sans Pro", -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif; + color: var(--ink); + background: #fff; +} +a { color: var(--blue); text-decoration: none; } +a:hover { text-decoration: underline; } +img { max-width: 100%; display: block; } + +.usa-banner { + background: #eef2f6; + border-bottom: 1px solid var(--line); + font-size: 13px; + color: #31404d; +} +.wrap { max-width: 1180px; margin: 0 auto; padding: 0 22px; } +.banner-inner { display: flex; align-items: center; gap: 8px; min-height: 34px; } +.flag-dot { width: 18px; height: 12px; background: linear-gradient(#b31942 0 20%, #fff 20% 40%, #b31942 40% 60%, #fff 60% 80%, #b31942 80%); border-left: 8px solid #0a3161; } + +.topbar { + border-bottom: 1px solid var(--line); + background: white; + position: sticky; + top: 0; + z-index: 10; +} +.nav { min-height: 72px; display: flex; align-items: center; gap: 22px; } +.brand { display: flex; align-items: center; gap: 10px; color: var(--ink); font-weight: 800; font-size: 23px; } +.brand-mark { width: 34px; height: 34px; border-radius: 50%; background: var(--blue); color: #fff; display: grid; place-items: center; font-weight: 900; } +.nav-links { display: flex; gap: 18px; flex: 1; flex-wrap: wrap; } +.nav-links a { color: #283845; font-weight: 700; font-size: 14px; } +.nav-actions { display: flex; align-items: center; gap: 12px; font-weight: 700; font-size: 14px; } + +.hero { + min-height: 380px; + background: + linear-gradient(rgba(17, 42, 64, .58), rgba(17, 42, 64, .35)), + url("../images/008-a-common-yellowthroat-warbler-perched-among-lush-green-vegetation-at-p.webp") center/cover; + color: white; + display: flex; + align-items: center; +} +.hero-kicker { color: #d6e6f4; margin-bottom: 10px; } +.hero h1 { font-size: clamp(34px, 5vw, 58px); line-height: 1.02; margin: 0 0 18px; max-width: 760px; } +.hero p { font-size: 19px; max-width: 680px; margin: 0 0 24px; } +.search-panel { + background: white; + border-radius: var(--radius); + box-shadow: 0 10px 30px rgba(0,0,0,.22); + padding: 16px; + max-width: 920px; +} +.hero-shortcuts { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; } +.hero-chip { + display: inline-flex; + align-items: center; + min-height: 38px; + padding: 0 14px; + border-radius: 999px; + background: rgba(255,255,255,.13); + border: 1px solid rgba(255,255,255,.26); + color: white; + font-weight: 700; +} +.hero-chip:hover { background: rgba(255,255,255,.2); text-decoration: none; } +.search-grid { display: grid; grid-template-columns: 1.5fr 180px 180px 120px; gap: 10px; } +input:not([type="checkbox"]):not([type="radio"]), select, textarea { + width: 100%; + border: 1px solid #aeb7c2; + border-radius: 4px; + padding: 12px 13px; + font: inherit; + min-height: 44px; +} +input[type="checkbox"] { + width: 18px; + height: 18px; + min-height: 18px; + padding: 0; + margin: 0 8px 0 0; + vertical-align: middle; +} +label { font-weight: 800; font-size: 13px; color: #354553; display: block; margin-bottom: 6px; } +.btn { + border: 0; + border-radius: 4px; + background: var(--blue); + color: white; + font-weight: 800; + min-height: 44px; + padding: 0 16px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} +.btn:hover { background: var(--blue-dark); text-decoration: none; } +.btn.secondary { background: #e7eef5; color: var(--blue-dark); } +.btn.ghost { background: white; color: var(--blue); border: 1px solid var(--blue); } +.btn.danger { background: var(--danger); } + +.section { padding: 38px 0; } +.section.alt { background: var(--soft); } +.section-head { display: flex; align-items: end; justify-content: space-between; gap: 18px; margin-bottom: 18px; } +.section h2 { margin: 0; font-size: 28px; } +.muted { color: var(--muted); } +.inline-action { color: var(--blue-dark); font-weight: 700; } + +.category-row { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; } +.category-tile { + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 16px; + background: white; + min-height: 112px; + color: var(--ink); +} +.category-tile strong { display: block; margin-bottom: 8px; } + +.layout { display: grid; grid-template-columns: 280px minmax(0, 1fr); gap: 24px; } +.filters { + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 16px; + background: white; + align-self: start; + position: sticky; + top: 92px; +} +.filters .field { margin-bottom: 14px; } +.result-meta { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 14px; } +.result-meta > div:first-child { display: grid; gap: 4px; } +.result-meta.compact { display: grid; gap: 6px; align-items: start; } + +.cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; } +.card { + border: 1px solid var(--line); + border-radius: var(--radius); + background: white; + overflow: hidden; + min-width: 0; +} +.card-media { display: block; color: inherit; } +.card-img { aspect-ratio: 4 / 3; width: 100%; object-fit: cover; background: #d9e2ea; } +.card-body { padding: 14px; } +.card-date { color: var(--blue-dark); font-size: 12px; font-weight: 900; letter-spacing: .08em; text-transform: uppercase; } +.availability-label { color: #365b79; } +.eyebrow { color: var(--blue-dark); font-size: 12px; font-weight: 900; letter-spacing: .04em; text-transform: uppercase; } +.card h3 { margin: 6px 0 8px; font-size: 18px; line-height: 1.18; } +.facts { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0; } +.facts-compact { margin-top: 8px; } +.facts-search { margin-bottom: 14px; } +.pill { background: #edf3f8; color: #26465f; border-radius: 999px; padding: 5px 9px; font-size: 12px; font-weight: 800; } +.price { font-weight: 900; color: #1b5e20; } +.search-card-meta { display: grid; gap: 12px; } +.search-card-price { display: flex; justify-content: space-between; align-items: center; gap: 12px; } + +.rail-shell { position: relative; } +.rail-track { + display: flex; + gap: 18px; + overflow-x: auto; + scroll-snap-type: x mandatory; + padding: 4px 48px 16px 4px; + scrollbar-width: none; +} +.rail-track::-webkit-scrollbar { display: none; } +.rail-card { + flex: 0 0 min(360px, calc(100vw - 96px)); + scroll-snap-align: start; +} +.rail-card .card-body { display: grid; gap: 8px; } +.rail-nav { + position: absolute; + top: 42%; + z-index: 2; + width: 40px; + height: 40px; + border: 0; + border-radius: 50%; + background: rgba(31, 41, 51, .88); + color: white; + cursor: pointer; + display: grid; + place-items: center; + font-size: 22px; +} +.rail-nav.prev { left: -6px; } +.rail-nav.next { right: -6px; } +.rail-nav[disabled] { opacity: .35; cursor: default; } + +.segmented { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; } +.segmented-btn, .toggle-btn { + min-height: 38px; + padding: 0 14px; + border-radius: 999px; + border: 1px solid var(--line); + background: white; + color: var(--ink); + font: inherit; + font-weight: 700; + cursor: pointer; +} +.segmented-btn.is-active, .toggle-btn.is-active { + background: var(--blue); + border-color: var(--blue); + color: white; +} +.view-switch { display: flex; gap: 8px; flex-wrap: wrap; } + +.experience-browser, +.detail-context-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 480px; + gap: 22px; + align-items: start; +} +.experience-sidebar { min-width: 0; } +.experience-panel { display: none; } +.experience-panel.is-active { display: block; } +.experience-list { display: grid; gap: 10px; margin-top: 14px; } +.experience-row { + display: grid; + grid-template-columns: 40px minmax(0, 1fr); + gap: 12px; + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 14px; + background: white; + color: var(--ink); +} +.experience-row:hover, +.experience-row.is-active, +.search-card-row.is-active .search-card { + border-color: var(--blue); + box-shadow: 0 10px 28px rgba(0, 94, 168, .12); + text-decoration: none; +} +.experience-index { + width: 40px; + height: 40px; + border-radius: 50%; + background: #edf3f8; + color: var(--blue-dark); + display: grid; + place-items: center; + font-weight: 900; +} + +.map-panel-card { + border: 1px solid var(--line); + border-radius: var(--radius); + background: white; + overflow: hidden; + box-shadow: 0 12px 28px rgba(17, 42, 64, .08); +} +.map-panel-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 14px; + padding: 18px 18px 0; +} +.map-panel-head h3 { margin: 0; font-size: 24px; } +.map-canvas { + position: relative; + min-height: 420px; + margin-top: 16px; + background: + radial-gradient(circle at 28% 16%, rgba(255,255,255,.55), transparent 28%), + linear-gradient(180deg, #a9cee7 0%, #cfe6f4 100%); + overflow: hidden; + box-shadow: inset 0 0 0 1px rgba(255,255,255,.45); +} +.home-live-map { + height: 462px; + margin-top: 16px; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); + background: #e9eef2; +} +.map-panel-card .home-live-map { + margin-top: 16px; +} +.map-canvas::before { + content: ""; + position: absolute; + inset: 7% 8% 12% 10%; + background: + radial-gradient(circle at 30% 44%, rgba(255,255,255,.16), transparent 16%), + radial-gradient(circle at 63% 32%, rgba(255,255,255,.18), transparent 18%), + linear-gradient(180deg, #dccf9d 0%, #d7d39c 48%, #cbd398 100%); + clip-path: polygon(3% 22%, 12% 11%, 27% 10%, 37% 18%, 47% 18%, 55% 26%, 64% 23%, 73% 17%, 80% 24%, 90% 21%, 96% 31%, 90% 46%, 92% 55%, 87% 68%, 81% 77%, 71% 84%, 62% 92%, 45% 92%, 32% 95%, 17% 90%, 9% 76%, 5% 63%, 8% 49%, 3% 37%); + box-shadow: inset 0 0 0 2px rgba(255,255,255,.16); +} +.map-canvas::after { + content: ""; + position: absolute; + inset: 0; + background: + repeating-linear-gradient(28deg, rgba(255,255,255,.22) 0 2px, transparent 2px 58px), + repeating-linear-gradient(-24deg, rgba(255,255,255,.14) 0 1px, transparent 1px 74px); + opacity: .48; + pointer-events: none; +} +.map-controls { + position: absolute; + left: 14px; + top: 14px; + z-index: 3; + display: grid; + gap: 8px; +} +.map-control-btn { + width: 34px; + height: 34px; + border: 1px solid #d4dce5; + border-radius: 4px; + background: #fff; + color: #335a8d; + font-size: 22px; + line-height: 1; + box-shadow: 0 6px 12px rgba(17, 42, 64, .08); +} +.map-attribution { + position: absolute; + right: 14px; + top: 14px; + z-index: 3; + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 34px; + padding: 0 10px; + border-radius: 4px; + background: rgba(255,255,255,.94); + color: #364a60; + font-size: 12px; + font-weight: 800; +} +.map-attribution span { + color: #6b7787; + font-weight: 700; +} +.map-marker { + position: absolute; + width: 34px; + height: 34px; + border: 2px solid white; + border-radius: 999px 999px 999px 0; + background: var(--blue); + color: white; + box-shadow: 0 8px 18px rgba(0,0,0,.22); + transform: translate(-50%, -100%) rotate(-45deg); + cursor: pointer; + z-index: 2; +} +.map-marker span { + display: block; + transform: rotate(45deg); + font-size: 12px; + font-weight: 900; +} +.map-marker.is-active { + background: var(--orange); + color: #243746; +} +.map-marker.is-hidden { display: none; } +.map-hud { + position: absolute; + left: 14px; + right: 14px; + bottom: 14px; + padding: 10px 12px; + border-radius: 999px; + background: rgba(31, 41, 51, .78); + color: white; + font-size: 13px; + font-weight: 700; + z-index: 1; +} +.map-callout { + display: grid; + gap: 8px; + padding: 16px 18px 18px; + border-top: 1px solid var(--line); +} +.map-callout strong { font-size: 20px; line-height: 1.2; } +.map-callout-actions { display: flex; justify-content: space-between; align-items: center; gap: 12px; } + +.promo-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; } +.promo-card { + min-height: 280px; + border-radius: var(--radius); + padding: 28px; + color: white; + background-position: center; + background-size: cover; + display: flex; + flex-direction: column; + justify-content: end; +} +.promo-card h2 { font-size: 30px; margin: 8px 0 10px; } +.promo-card p { max-width: 440px; } +.btn.ghost.light { + border-color: rgba(255,255,255,.45); + color: white; + background: rgba(255,255,255,.08); +} + +.search-stage { min-width: 0; } +.search-results-shell { + display: grid; + grid-template-columns: minmax(0, 1fr) 390px; + gap: 18px; + align-items: start; +} +.search-results-shell[data-layout="list"] { grid-template-columns: 1fr; } +.search-results-shell[data-layout="list"] .search-map-panel { display: none; } +.search-results-shell[data-layout="map"] { grid-template-columns: 1fr; } +.search-results-shell[data-layout="map"] .results-panel { display: none; } +.search-card-list { display: grid; gap: 14px; } +.search-card-row { min-width: 0; } +.search-card { + display: grid; + grid-template-columns: 190px minmax(0, 1fr); +} +.search-card .card-media { height: 100%; } +.search-card .card-img { height: 100%; aspect-ratio: auto; min-height: 200px; } +.search-card .card-body { + display: grid; + gap: 6px; + align-content: start; +} + +.detail-context { padding-top: 0; } +.detail-map-card .map-canvas { min-height: 340px; } + +.detail-hero { display: grid; grid-template-columns: 1.2fr .8fr; gap: 28px; align-items: start; padding: 28px 0; } +.detail-image { border-radius: var(--radius); aspect-ratio: 16 / 10; object-fit: cover; width: 100%; } +.booking-box { border: 1px solid var(--line); border-radius: var(--radius); padding: 18px; box-shadow: 0 6px 20px rgba(17,42,64,.08); background: white; } +.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; } +.list-clean { padding-left: 18px; line-height: 1.8; } +.site-row, .cart-row, .reservation-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 14px; + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 14px; + margin-bottom: 12px; + background: white; +} + +.flash { padding: 10px 14px; border-radius: 4px; margin: 12px 0; background: #eef7ee; border: 1px solid #b7ddb7; } +.flash.danger { background: #fff0f0; border-color: #f0b6b6; } +.flash.warning { background: #fff8e1; border-color: #f5d56c; } +.footer { + background: #1f2933; + color: #d8e1e8; + padding: 0 0 34px; + margin-top: 40px; +} +.footer a { color: white; } +.footer-color-strip { + height: 8px; + background: linear-gradient(90deg, #d36225 0 20%, #e5bf3c 20% 40%, #5a8739 40% 60%, #3b6796 60% 80%, #88c9df 80% 100%); + margin-bottom: 30px; +} +.footer-grid { + display: grid; + grid-template-columns: minmax(0, 1.7fr) repeat(3, minmax(0, 1fr)); + gap: 34px; +} +.footer-grid strong { + display: block; + color: #fff; + margin-bottom: 12px; + font-size: 18px; +} +.footer-grid p { + margin: 0; + max-width: 480px; + line-height: 1.75; +} +.footer-grid a { + display: inline-block; + margin-bottom: 8px; + color: #dce6ef; +} + +/* Recreation.gov fidelity pass: closer header, hero, search, and detail layouts. */ +.wrap { max-width: 1340px; } +.usa-banner { background: #f1f3f5; min-height: 24px; } +.banner-inner { min-height: 24px; font-size: 13px; padding-top: 2px; padding-bottom: 2px; } +.banner-inner a { color: #345d96; text-decoration: underline; } +.topbar { position: relative; } +.nav { min-height: 78px; gap: 22px; } +.brand { flex: 0 0 auto; gap: 18px; } +.brand-word { + color: #5c626a; + font-size: 35px; + letter-spacing: .02em; + font-weight: 300; + line-height: 1; +} +.brand-word span { color: #6aa342; } +.brand-word small { color: #5c626a; font-size: 18px; letter-spacing: 0; } +.freedom-badge { + display: inline-flex; + width: 54px; + height: 34px; + align-items: end; + justify-content: center; + border-left: 1px solid #d9dee5; + padding-left: 16px; + color: #345d96; + font-size: 9px; + line-height: 1; + background: + linear-gradient(#b31942 0 14%, #fff 14% 28%, #b31942 28% 42%, #fff 42% 56%, #b31942 56% 70%, #fff 70%) top 0 left 16px/44px 18px no-repeat; +} +.header-search { + margin-left: auto; + width: min(280px, 28vw); + position: relative; +} +.header-search span { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + color: #6a7380; + z-index: 1; +} +.header-search input { + min-height: 40px; + padding-left: 36px !important; + border-color: #d5dce4 !important; +} +.nav-links { display: none; } +.nav-actions { flex: 0 0 auto; gap: 24px; } +.nav-actions a { color: #0066b3; font-weight: 800; } +.menu-button { + width: 32px; + height: 32px; + border: 0; + background: transparent; + display: grid; + gap: 5px; + align-content: center; + cursor: pointer; +} +.menu-button span { + display: block; + height: 3px; + background: #345d96; +} + +.real-home-hero { + min-height: 336px; + align-items: flex-end; + padding-bottom: 0; + background: + linear-gradient(rgba(255,255,255,0), rgba(255,255,255,0)), + url("../images/real-hero-spring-26.webp") center/cover no-repeat; + position: relative; +} +.photo-credit { + position: absolute; + right: 12px; + top: 14px; + width: 42px; + height: 42px; + border: 0; + background: rgba(31,41,51,.72); + color: #fff; + font-size: 18px; +} +.hero-card-wrap { + display: flex; + justify-content: center; + transform: translateY(92px); + position: relative; + z-index: 2; +} +.home-search-card { + width: min(1090px, calc(100vw - 48px)); + background: #fff; + border-radius: 6px; + box-shadow: 0 18px 36px rgba(17, 42, 64, .15); + padding: 0 22px 24px; +} +.home-tabs { + display: flex; + align-items: center; + gap: 0; + border-bottom: 1px solid #dde3ea; + min-height: 72px; + overflow-x: auto; + scrollbar-width: none; +} +.home-tabs::-webkit-scrollbar { display: none; } +.home-tab { + border: 0; + background: transparent; + color: #536071; + font-weight: 800; + min-height: 72px; + padding: 0 14px; + display: inline-flex; + align-items: center; + white-space: nowrap; + border-bottom: 3px solid transparent; + cursor: pointer; +} +.home-tab.is-active { + color: #1a2b3c; + border-bottom-color: #345d96; +} +.beta-tab b { + margin-left: 8px; + background: #dff4f7; + color: #0b6375; + border-radius: 999px; + padding: 2px 7px; + font-size: 10px; + text-transform: uppercase; +} +.accessible-check { + margin: 0 0 0 auto; + display: inline-flex; + align-items: center; + padding-left: 14px; + flex: 0 0 auto; + white-space: nowrap; + color: #4a5563; +} +.home-search-form { + display: grid; + grid-template-columns: minmax(0, 1fr) 138px; + gap: 8px; + padding: 20px 0 22px; + border-bottom: 1px solid #dde3ea; +} +.home-search-card.is-ai-mode { + box-shadow: 0 18px 36px rgba(18, 67, 106, .22); +} +.home-search-card.is-ai-mode .search-submit { + background: #0b6375; +} +.home-search-card.is-ai-mode .beta-tab.is-active { + color: #0b6375; + border-bottom-color: #0b6375; +} +.home-tab-panel { display: none; } +.home-tab-panel.is-active { display: block; } +.tabbed-search-form { + display: grid; + grid-template-columns: minmax(0, 1fr) 180px 180px 138px; + gap: 8px; + padding: 20px 0 22px; + border-bottom: 1px solid #dde3ea; +} +.tabbed-search-form .home-search-input input { + min-height: 46px; +} +.tabbed-input { + min-height: 46px; + justify-content: flex-start; + border: 1px solid #d5dce4; + border-radius: 4px; + background: #fff; + color: #536071; + font: inherit; + font-weight: 700; + padding: 0 14px; +} +.ai-search-form { + grid-template-columns: minmax(0, 1fr) 138px; +} +.ai-prompt-input textarea { + min-height: 64px; + border: 1px solid #d5dce4; + border-radius: 4px; + padding: 16px 14px; + font: inherit; + resize: none; +} +.home-tab-results { + padding: 20px 0 0; +} +.home-tab-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + margin-bottom: 16px; +} +.home-tab-head h3 { + margin: 0 0 6px; + font-size: 18px; +} +.home-tab-head p { + margin: 0; +} +.hero-carousel-arrows { + display: inline-flex; + gap: 10px; + color: #8190a2; + font-size: 28px; + line-height: 1; +} +.hero-result-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 22px; +} +.hero-result-card { + color: #22384c; +} +.hero-result-card img, +.placeholder-media { + width: 100%; + height: 128px; + border-radius: 6px; + object-fit: cover; + background: #ebeff3; +} +.hero-result-card strong { + display: block; + margin-top: 10px; + font-size: 15px; + line-height: 1.25; +} +.hero-result-card span { + display: block; + margin-top: 4px; + color: #607080; + font-size: 13px; +} +.hero-result-card.is-placeholder { + pointer-events: none; + display: grid; + gap: 12px; +} +.placeholder-line { + height: 12px; + border-radius: 999px; + background: #eef2f5; + margin-top: 12px; +} +.placeholder-line.short { + width: 64%; +} +.ai-examples { + padding-top: 20px; +} +.ai-examples h3 { + margin: 0 0 16px; + font-size: 17px; +} +.ai-example-rail .rail-track { + gap: 14px; + padding: 4px 48px 16px 4px; +} +.ai-example-chip { + flex: 0 0 210px; + min-height: 92px; + border: 1px solid #edf1f4; + border-radius: 4px; + background: #f7f8fa; + color: #22384c; + font: inherit; + font-weight: 700; + line-height: 1.45; + text-align: left; + padding: 14px 12px; + cursor: pointer; +} +.ai-example-chip:hover { + border-color: #0b6375; + background: #eef7f9; +} +.home-search-input, +.toolbar-input { + position: relative; +} +.home-search-input span, +.toolbar-input span { + position: absolute; + left: 14px; + top: 50%; + transform: translateY(-50%); + color: #6a7380; + z-index: 1; +} +.home-search-input input, +.toolbar-input input { + padding-left: 42px !important; +} +.search-submit { text-transform: uppercase; background: #345d96; } +.category-photo-grid { + display: grid; + grid-template-columns: 1.12fr 1fr 1fr 1fr; + grid-template-rows: repeat(2, 120px); + gap: 24px; + padding-top: 24px; +} +.category-photo-tile { + position: relative; + overflow: hidden; + border-radius: 6px; + color: #fff; + background: #203244; +} +.category-photo-tile:first-child { grid-row: span 2; } +.category-photo-tile img { + width: 100%; + height: 100%; + object-fit: cover; + filter: saturate(.95); + transition: transform .2s ease; +} +.category-photo-tile:hover img { transform: scale(1.04); } +.category-photo-tile span { + position: absolute; + left: 0; + bottom: 0; + padding: 12px 18px; + min-width: 58%; + background: rgba(52, 93, 150, .88); + font-size: 18px; + font-weight: 900; +} +.tile-lottery span { background: rgba(132, 72, 84, .88); } +.tile-permits span { background: rgba(150, 94, 70, .88); } +.tile-day_use span { background: rgba(89, 137, 55, .9); } +.tile-passes span { background: rgba(54, 67, 150, .88); } +.real-home-hero + .section { padding-top: 126px; } +.category-row { display: none; } + +.real-nearby-rail .rail-track { gap: 24px; padding: 4px 48px 16px 4px; } +.real-nearby-rail .rail-card { + flex-basis: 200px; + border: 0; + overflow: visible; + background: transparent; +} +.real-nearby-rail .card-img { + border-radius: 6px; + aspect-ratio: 1.55 / 1; +} +.real-nearby-rail .card-body { padding: 8px 0 0; gap: 4px; } +.real-nearby-rail .card-date { + display: inline-block; + margin-top: -36px; + margin-left: 8px; + background: white; + border-radius: 3px; + padding: 4px 8px; + color: #1a2b3c; + position: relative; + z-index: 1; +} +.real-nearby-rail .eyebrow { display: none; } +.real-nearby-rail .card h3 { font-size: 15px; margin: 6px 0 4px; } +.real-nearby-rail .muted { font-size: 13px; } +.stars { + color: #345d96; + font-weight: 900; + letter-spacing: .03em; +} +.stars span, +.stars b { + color: #263849; + font-size: 13px; + letter-spacing: 0; +} +.real-promo-card { + min-height: 240px; + justify-content: center; + position: relative; + overflow: hidden; +} +.real-promo-card > * { + position: relative; + z-index: 1; +} +.real-promo-card:first-child { + background-image: + linear-gradient(rgba(17, 42, 64, .62), rgba(17, 42, 64, .46)), + url("../images/real-mobile-app-featured-background.webp") !important; +} +.real-promo-card:first-child::after { + content: ""; + position: absolute; + right: 24px; + bottom: 0; + width: 250px; + height: 100%; + background: url("../images/real-mobile-app-featured-foreground.webp") right bottom/contain no-repeat; + opacity: .94; +} +.america250-card { + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(260px, .9fr); + align-items: center; + background: #092f5f; + color: #fff; + border-radius: 6px; + overflow: hidden; + min-height: 230px; +} +.america250-card > div { padding: 34px; } +.america250-card img { + width: 100%; + height: 230px; + object-fit: cover; +} +.inspiration-layout { + display: grid; + grid-template-columns: 2fr 1fr 320px; + gap: 24px; +} +.inspiration-feature, +.inspiration-stack a, +.quick-links { + border: 1px solid var(--line); + background: #fff; + color: var(--ink); +} +.inspiration-feature { + position: relative; + min-height: 320px; + display: block; + overflow: hidden; +} +.inspiration-feature img, +.inspiration-stack img { + width: 100%; + height: 100%; + object-fit: cover; +} +.inspiration-feature span, +.inspiration-feature strong, +.inspiration-stack strong { + position: absolute; + left: 24px; + background: rgba(255,255,255,.9); + padding: 10px 16px; +} +.inspiration-feature span { + bottom: 66px; + background: #a6d52a; + text-transform: uppercase; + font-style: italic; +} +.inspiration-feature strong { + bottom: 18px; + font-size: 26px; +} +.inspiration-stack { + display: grid; + gap: 18px; +} +.inspiration-stack a { + position: relative; + min-height: 150px; + overflow: hidden; + display: block; +} +.inspiration-stack strong { + bottom: 12px; + left: 12px; + font-size: 16px; +} +.quick-links { + padding: 22px; +} +.quick-links h3 { margin-top: 0; } +.quick-links a { + display: block; + padding: 13px 0; + color: #596575; + font-weight: 800; +} +.popular-locations-section { + padding-top: 18px; +} +.popular-location-links { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px 26px; +} +.popular-location-links a { + color: #345d96; + font-weight: 700; +} +.article-hero { + width: 100%; + height: 360px; + object-fit: cover; + border-radius: 6px; + margin: 18px 0 18px; +} +.article-deck { + font-size: 18px; + line-height: 1.7; + color: #3d4f60; +} +.article-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 20px; +} +.article-card { + display: grid; + gap: 12px; + color: #22384c; + border: 1px solid var(--line); + border-radius: 6px; + overflow: hidden; + background: #fff; +} +.article-card img { + width: 100%; + height: 200px; + object-fit: cover; +} +.article-card span, +.article-card strong, +.article-card p { + padding: 0 16px; +} +.article-card span { + padding-top: 16px; + color: #335a8d; + font-size: 12px; + font-weight: 900; + text-transform: uppercase; +} +.article-card strong { + font-size: 20px; + line-height: 1.25; +} +.article-card p { + padding-bottom: 18px; + margin: 0; + color: #5f6d7a; + line-height: 1.6; +} +.auth-layout { + max-width: 1040px; + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(280px, .8fr); + gap: 28px; + align-items: start; +} +.auth-panel h1, +.auth-side-card h2 { + margin-top: 0; +} +.auth-form-box { + padding: 22px; +} +.auth-side-card { + border: 1px solid var(--line); + border-radius: 6px; + background: linear-gradient(180deg, #f7fafc 0%, #edf4fa 100%); + padding: 24px; + box-shadow: 0 10px 22px rgba(17, 42, 64, .08); +} +.auth-side-card p { + color: #485867; + line-height: 1.7; +} +.auth-cta { + min-width: 180px; +} + +.search-toolbar { + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); + background: #f4f5f6; + padding: 16px 24px; +} +.real-search-controls { + display: flex; + gap: 10px; + align-items: center; +} +.real-search-controls .toolbar-input { width: 280px; } +.date-pill, +.filter-pill { + min-height: 40px; + border: 1px solid #d3dbe4; + background: #fff; + border-radius: 4px; + padding: 0 16px; + font: inherit; + font-weight: 800; + color: #283845; +} +.filter-pill span { + margin-left: 8px; + background: #345d96; + color: #fff; + border-radius: 50%; + padding: 2px 7px; +} +.real-search-page { + display: grid; + grid-template-columns: 720px minmax(360px, 1fr); + min-height: calc(100vh - 120px); +} +.real-results-pane { + padding: 18px 24px 28px; + background: #fff; +} +.real-result-meta { + align-items: start; + margin-bottom: 18px; +} +.real-result-meta h1 { + margin: 0 0 14px; + font-size: 18px; +} +.filter-chips { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 20px; +} +.filter-chip { + background: #e7f2fb; + border-radius: 4px; + padding: 6px 10px; + color: #22384c; +} +.sort-control { + display: flex; + align-items: center; + gap: 8px; +} +.sort-control label { margin: 0; white-space: nowrap; } +.sort-control select { min-width: 150px; } +.real-map-pane { + background: #e8e2d8; + border-left: 1px solid var(--line); + position: sticky; + top: 0; + height: 100vh; +} +.real-search-map { + height: 100%; + min-height: 720px; + margin: 0; +} +.mapbox-wordmark { + position: absolute; + left: 12px; + bottom: 10px; + z-index: 2; + color: rgba(255,255,255,.9); + font-weight: 900; + text-shadow: 0 1px 3px rgba(0,0,0,.35); +} +.real-search-card-list { gap: 16px; } +.real-search-card-list .search-card { + grid-template-columns: 260px minmax(0, 1fr); + border-radius: 4px; + border-color: #d8dfe7; +} +.real-search-card-list .search-card .card-img { min-height: 254px; } +.real-search-card-list .card-body { + padding: 18px 18px 16px; + gap: 12px; +} +.real-search-card-list .card h3 { font-size: 20px; } +.search-card .card-media { + position: relative; + overflow: hidden; +} +.card-image-badge { + position: absolute; + left: 14px; + top: 12px; + z-index: 1; + min-height: 28px; + padding: 0 10px; + border-radius: 3px; + background: rgba(255,255,255,.94); + color: #1f3852; + display: inline-flex; + align-items: center; + font-size: 12px; + font-weight: 900; + text-transform: uppercase; +} +.card-favorite { + position: absolute; + right: 16px; + top: 12px; + z-index: 1; + width: 30px; + height: 30px; + border-radius: 50%; + background: rgba(255,255,255,.96); + color: #5d6978; + display: grid; + place-items: center; + font-size: 22px; + line-height: 1; +} +.search-card-topline, +.search-card-footer, +.search-card-rating { + display: flex; + align-items: center; +} +.search-card-topline, +.search-card-footer { + justify-content: space-between; + gap: 14px; +} +.search-card-topline .eyebrow { + margin: 0; + color: #335a8d; +} +.search-date { + color: #627183; + font-size: 14px; + font-weight: 800; + white-space: nowrap; +} +.search-parent, +.search-location { + margin: 0; + color: #324354; + line-height: 1.5; +} +.search-card-footer { + margin-top: auto; + padding-top: 14px; + border-top: 1px solid #e3e8ee; +} +.search-card-rating { + gap: 8px; + color: #335a8d; + font-weight: 800; +} +.search-card-rating .stars { + color: #335a8d; +} +.coverage-signal { + color: #8da0b7; + letter-spacing: -1px; + font-size: 18px; + line-height: 1; +} + +.detail-photo-grid { + display: grid; + grid-template-columns: 1fr 2fr .9fr; + gap: 8px; + position: relative; + background: #fff; +} +.detail-photo-grid img { + width: 100%; + height: 340px; + object-fit: cover; +} +.detail-photo-grid img:nth-child(2) { height: 340px; } +.view-photos-btn { + position: absolute; + right: 28px; + bottom: 18px; + border: 0; + border-radius: 4px; + background: #fff; + padding: 10px 14px; + color: #1a2b3c; + font-weight: 900; +} +.real-detail-head { padding-top: 36px; padding-bottom: 34px; } +.detail-badge { + display: inline-block; + background: #eef3f8; + border-radius: 3px; + padding: 4px 8px; + color: #1a2b3c; +} +.real-detail-head h1 { + font-size: 32px; + margin: 10px 0 12px; +} +.detail-subline, +.detail-actions-row { + display: flex; + align-items: center; + gap: 18px; + flex-wrap: wrap; + color: #526071; +} +.detail-actions-row { + margin-top: 22px; + padding-top: 2px; + font-weight: 800; +} +.link-button { + border: 0; + background: transparent; + color: var(--blue); + font: inherit; + font-weight: 800; + padding: 0; + cursor: pointer; +} +.coverage { color: #345d96; } +.detail-summary { + margin-top: 48px; + max-width: 720px; + line-height: 1.7; +} +.detail-tabs-shell { padding-bottom: 30px; } +.detail-tabs { + display: grid; + grid-template-columns: repeat(5, 1fr); + border-bottom: 1px solid #d8dee6; +} +.detail-tabs a { + min-height: 64px; + display: grid; + place-items: center; + color: #536071; + font-weight: 800; + border-bottom: 3px solid transparent; +} +.detail-tabs a.is-active { + color: #1a2b3c; + border-bottom-color: #345d96; +} +.availability-toolbar { + display: flex; + gap: 8px; + align-items: center; + padding: 8px; + background: #f2f3f5; +} +.icon-tab { + min-width: 42px; + min-height: 42px; + border: 1px solid #c8d3df; + background: #fff; + color: #345d96; + font-weight: 900; + border-radius: 4px; +} +.icon-tab.is-active { + background: #345d96; + color: #fff; +} +.date-range { width: 280px; } +.availability-grid { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(360px, .9fr); + gap: 20px; + align-items: start; + padding-top: 20px; +} +.availability-actions { + display: flex; + gap: 8px; + margin-bottom: 12px; + flex-wrap: wrap; +} +.availability-table { + width: 100%; + border-collapse: collapse; + background: white; + font-size: 14px; +} +.availability-table th, +.availability-table td { + border: 1px solid #aeb7c2; + padding: 12px; + text-align: center; +} +.availability-table thead th { + background: #536071; + color: #fff; +} +.availability-table tbody th { + color: var(--blue); + text-align: left; +} +.availability-table td:nth-child(n+3) { + background: #cfe8ff; + color: #214f84; + font-weight: 900; +} +.availability-cart-bar { + display: grid; + grid-template-columns: minmax(220px, 1fr) 1fr 160px; + gap: 12px; + align-items: center; + background: #fff; + border: 1px solid #d8dee6; + border-top: 0; + padding: 14px 18px; +} +.availability-cart-bar span { + text-align: center; + color: #404b58; +} +.detail-map-card { + border: 1px solid var(--line); + background: #fff; +} +.detail-map-card .detail-map { + margin: 0; + min-height: 620px; +} +.detail-context { padding-top: 26px; } +.detail-context-grid { grid-template-columns: minmax(0, .9fr) minmax(360px, .7fr); } + +.help-hero { + padding-bottom: 22px; +} +.help-deck { + max-width: 860px; + color: #4b5c6c; + font-size: 18px; + line-height: 1.7; +} +.help-search-bar { + margin-top: 22px; + display: grid; + grid-template-columns: minmax(0, 1fr) 138px; + gap: 10px; + max-width: 860px; + position: relative; +} +.help-search-bar span { + position: absolute; + left: 14px; + top: 50%; + transform: translateY(-50%); + color: #6a7380; + z-index: 1; +} +.help-search-bar input { + padding-left: 42px !important; +} +.help-topic-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 20px; +} +.help-topic-card, +.help-contact-card, +.help-faq-card { + border: 1px solid var(--line); + border-radius: 6px; + background: #fff; + box-shadow: 0 10px 22px rgba(17, 42, 64, .06); +} +.help-topic-card { + color: #22384c; + padding: 20px; +} +.help-topic-card strong, +.help-contact-card strong, +.help-faq-card strong { + display: block; + margin-bottom: 10px; + font-size: 18px; +} +.help-topic-card p, +.help-contact-card p, +.help-faq-card p { + margin: 0; + color: #516172; + line-height: 1.65; +} +.help-content-grid { + align-items: start; +} +.help-faq-list { + display: grid; + gap: 14px; +} +.help-faq-card { + padding: 18px 20px; +} +.help-aside-card h3 { + margin: 20px 0 10px; +} +.help-contact-band { + display: grid; + gap: 24px; +} +.help-contact-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 18px; +} +.help-contact-card { + padding: 18px; +} + +.tabbed-result-rail .rail-track { + gap: 24px; + padding: 4px 48px 16px 4px; +} +.tabbed-result-rail .rail-card { + flex-basis: 200px; + border: 0; + overflow: visible; + background: transparent; +} +.tabbed-result-rail .card-img { + border-radius: 6px; + aspect-ratio: 1.55 / 1; +} +.tabbed-result-rail .card-body { + padding: 8px 0 0; + display: grid; + gap: 4px; +} +.tabbed-result-rail .card-date { + display: inline-block; + margin-top: -36px; + margin-left: 8px; + background: #fff; + border-radius: 3px; + padding: 4px 8px; + color: #1a2b3c; + position: relative; + z-index: 1; +} +.tabbed-result-rail .eyebrow { display: none; } +.tabbed-result-rail .card h3 { + font-size: 15px; + margin: 6px 0 4px; +} +.tabbed-result-rail .muted { + font-size: 13px; + color: #607080; +} +.tabbed-result-rail .price { + margin-top: 6px; +} +.tabbed-result-rail .hero-result-card.is-placeholder { + flex: 0 0 200px; +} +.state-link-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px 28px; +} +.state-link-grid a { + display: block; + color: #345d96; + font-weight: 700; + padding-bottom: 8px; + border-bottom: 1px solid #e4eaf0; +} + +.detail-panorama { + position: relative; + background: #fff; +} +.detail-panorama img { + width: 100%; + height: 340px; + object-fit: cover; +} +.detail-alert-band { + background: #f7ecd7; + border-top: 1px solid #eadfc5; + border-bottom: 1px solid #eadfc5; +} +.detail-alert-band .wrap { + display: flex; + align-items: center; + gap: 14px; + min-height: 56px; +} +.detail-alert-band p { + margin: 0; + color: #4f4230; +} +.detail-alert-toggle { + margin-left: auto; + min-height: 32px; +} +.detail-page-shell { + padding-top: 0; +} +.detail-breadcrumbs { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + padding-top: 28px; + color: #617183; + font-size: 14px; +} +.detail-top-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(320px, 424px); + gap: 22px; + align-items: start; + margin-top: 16px; +} +.detail-page-title { + margin: 0 0 12px; + font-size: 34px; + line-height: 1.16; +} +.detail-meta-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 18px; + color: #526071; + font-weight: 700; +} +.detail-tabs-inline { + display: flex; + flex-wrap: wrap; + margin-top: 22px; + border-bottom: 1px solid #d8dee6; +} +.detail-tabs-inline a { + min-height: 58px; + padding: 0 18px; + display: inline-flex; + align-items: center; + justify-content: center; +} +.detail-booking-box { + padding: 0; + overflow: hidden; + position: sticky; + top: 18px; + border-color: #d8dee6; +} +.detail-booking-head { + background: #4f6f00; + color: #fff; + padding: 20px; +} +.detail-booking-head h2 { + margin: 0 0 6px; + font-size: 24px; +} +.detail-booking-head .muted { + color: rgba(255,255,255,.92); +} +.detail-booking-form { + padding: 18px; + display: grid; + gap: 14px; +} +.detail-booking-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} +.availability-mini-calendar { + border: 1px solid #dde4eb; + border-radius: 6px; + padding: 14px; + background: #fbfcfd; +} +.calendar-head { + display: flex; + justify-content: space-between; + align-items: center; + color: #627183; + margin-bottom: 12px; +} +.calendar-nav-button { + width: 28px; + height: 28px; + border: 0; + border-radius: 999px; + background: transparent; + color: #8c98a7; + font-size: 20px; + line-height: 1; + cursor: not-allowed; +} +.calendar-nav-button:disabled { + opacity: .65; +} +.calendar-weekdays, +.calendar-days { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 6px; +} +.calendar-weekdays span { + text-align: center; + color: #9aa8b8; + font-size: 12px; +} +.calendar-days button { + min-height: 34px; + border: 0; + border-radius: 4px; + display: grid; + place-items: center; + background: #f2f5f8; + color: #91a2b5; + font-size: 13px; + font: inherit; + cursor: default; + padding: 3px 0; +} +.calendar-days button.is-available { + background: #dcebff; + color: #345d96; + font-weight: 900; + cursor: pointer; +} +.calendar-days button.is-available:hover { + outline: 2px solid #8fb9e3; +} +.calendar-days button.is-selected { + background: #345d96; + color: #fff; + box-shadow: inset 0 0 0 2px #173d68; +} +.calendar-days button.is-in-range { + background: #ebf3ff; + color: #345d96; + box-shadow: inset 0 0 0 1px #c8d9ee; +} +.calendar-days button.is-range-end { + background: #4a6f9c; + color: #fff; + box-shadow: inset 0 0 0 2px #173d68; +} +.calendar-days button:disabled { + opacity: .62; +} +.calendar-days button span { + line-height: 1; +} +.calendar-days button small { + margin-top: 2px; + font-size: 10px; + line-height: 1; +} +.calendar-selected-status { + margin-top: 12px; + padding: 9px 10px; + border-radius: 4px; + background: #eef5fb; + color: #274b6e; + font-size: 13px; + font-weight: 800; +} +.booking-display-field[readonly] { + background: #f4f8fc; + border-color: #d5dfeb; + color: #17324d; + cursor: default; +} +.detail-book-btn, +.detail-secondary-btn { + width: 100%; +} +.detail-content-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(320px, 424px); + gap: 22px; + align-items: start; + margin-top: 28px; +} +.detail-main-column, +.detail-side-column { + display: grid; + gap: 18px; +} +.detail-section-card, +.detail-side-card { + border: 1px solid var(--line); + border-radius: 6px; + background: #fff; + padding: 24px; + box-shadow: 0 10px 22px rgba(17, 42, 64, .06); +} +.detail-section-card h2, +.detail-side-card h3 { + margin-top: 0; +} +.detail-lead { + margin-top: 0; + font-size: 18px; + line-height: 1.72; + color: #31404d; +} +.detail-copy p { + color: #485868; + line-height: 1.74; +} +.detail-bullet-copy p { + margin-top: 0; +} +.detail-pill-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; +} +.detail-info-table { + width: 100%; + border-collapse: collapse; +} +.detail-info-table th, +.detail-info-table td { + padding: 14px 16px; + border: 1px solid #d9e1e8; + text-align: left; + vertical-align: top; +} +.detail-info-table thead th { + background: #596575; + color: #fff; +} +.detail-media-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 20px; +} +.detail-media-grid img { + width: 100%; + height: 180px; + object-fit: cover; +} +.detail-review-head { + display: flex; + justify-content: space-between; + align-items: end; + gap: 16px; + margin-bottom: 18px; +} +.detail-review-rating { + color: #345d96; + font-size: 24px; + font-weight: 900; +} +.detail-reviews-grid { + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + gap: 22px; + align-items: start; +} +.detail-review-summary { + display: grid; + gap: 14px; +} +.review-bar-row { + display: grid; + grid-template-columns: 64px 1fr 48px; + gap: 12px; + align-items: center; +} +.review-bar-row span, +.review-bar-row strong { + font-size: 14px; +} +.review-bar { + height: 10px; + background: #edf2f6; + border-radius: 999px; + overflow: hidden; +} +.review-bar div { + height: 100%; + background: #345d96; +} +.detail-review-list { + display: grid; + gap: 18px; +} +.detail-review-form { + border: 1px solid #dbe3ea; + border-radius: 6px; + background: #f8fafc; + padding: 18px; + display: grid; + gap: 12px; +} +.detail-review-form h3 { + margin: 0; +} +.detail-review-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} +.detail-review-card { + border-top: 1px solid #e3e8ee; + padding-top: 18px; + display: grid; + gap: 12px; +} +.detail-review-card.is-hidden-review { + display: none; +} +.detail-review-card.is-own-review { + border: 1px solid #98c0e8; + border-radius: 6px; + background: #f4f9fe; + padding: 16px; +} +.review-load-more-row { + display: flex; + justify-content: center; + padding-top: 4px; +} +.review-load-more-row.is-hidden { + display: none; +} +.own-review-badge { + display: inline-block; + margin-left: 8px; + padding: 3px 7px; + border-radius: 999px; + background: #dcebff; + color: #274b6e; + font-size: 12px; +} +.detail-review-card-top { + display: flex; + justify-content: space-between; + align-items: start; + gap: 16px; +} +.detail-review-card p { + margin: 0; + color: #394a5a; + line-height: 1.75; +} +.detail-side-links, +.detail-nearby-list { + display: grid; + gap: 12px; +} +.detail-side-links a, +.detail-nearby-list a { + display: block; + color: #22384c; + padding-bottom: 12px; + border-bottom: 1px solid #e5ebf0; +} +.detail-side-links a:last-child, +.detail-nearby-list a:last-child { + border-bottom: 0; + padding-bottom: 0; +} +.detail-nearby-list a span { + display: block; + color: #657587; + margin-top: 6px; + font-size: 14px; +} +.detail-live-map { + height: 520px; + border: 1px solid var(--line); + border-radius: 6px; + overflow: hidden; + background: #e9eef2; +} +.leaflet-popup-content { + font: inherit; + line-height: 1.5; +} + +.sitepass-page { + padding-top: 18px; +} +.sitepass-alert-band { + display: flex; + align-items: center; + gap: 16px; + min-height: 58px; + padding: 0 18px; + border: 1px solid #f0dfc3; + background: #fbf2df; + color: #513f28; +} +.sitepass-alert-band p { + margin: 0; + line-height: 1.45; +} +.sitepass-alert-cta { + margin-left: auto; + min-height: 34px; +} +.sitepass-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 420px; + gap: 22px; + align-items: start; + margin-top: 28px; +} +.sitepass-main { + display: grid; + gap: 22px; +} +.sitepass-main h1 { + margin: 0; + font-size: 52px; + font-weight: 700; + line-height: 1.08; +} +.sitepass-name { + margin: -10px 0 0; + font-size: 24px; + color: #35485b; +} +.sitepass-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 22px; + color: #59697a; + font-weight: 700; +} +.sitepass-info-banner { + display: flex; + align-items: center; + gap: 14px; + padding: 18px; + border: 1px solid #bad6ea; + background: #edf6fc; + color: #274b6e; +} +.sitepass-info-banner strong { + width: 22px; + height: 22px; + border-radius: 50%; + background: #2d7fa5; + color: #fff; + display: grid; + place-items: center; + font-size: 14px; +} +.sitepass-primary-card { + padding-right: 20px; +} +.sitepass-primary-card h2, +.sitepass-about-card h2, +.sitepass-interagency-card h2 { + margin-top: 0; + font-size: 24px; + line-height: 1.25; +} +.sitepass-copy-lead { + margin-bottom: 10px; + font-size: 18px; +} +.sitepass-checklist { + list-style: none; + padding: 0; + margin: 0 0 16px; + display: grid; + gap: 12px; +} +.sitepass-checklist li { + position: relative; + padding-left: 28px; + line-height: 1.55; +} +.sitepass-checklist li::before { + content: "✓"; + position: absolute; + left: 0; + top: 0; + width: 20px; + height: 20px; + border-radius: 50%; + background: #50760d; + color: #fff; + display: grid; + place-items: center; + font-size: 13px; + font-weight: 900; +} +.sitepass-copy-strong { + line-height: 1.65; +} +.sitepass-cta-row { + display: flex; + gap: 16px; + flex-wrap: wrap; + margin-top: 24px; +} +.sitepass-interagency-card { + border: 1px solid #e0e5ea; + border-radius: 6px; + background: #f3f5f7; + padding: 20px; + display: grid; + gap: 18px; +} +.sitepass-interagency-card p { + margin: 0 0 12px; + line-height: 1.65; +} +.sitepass-interagency-card img { + width: 100%; + max-width: 640px; + height: auto; +} +.sitepass-side { + display: grid; + gap: 18px; + align-self: start; +} +.sitepass-hero-image { + width: 100%; + height: 260px; + object-fit: cover; +} +.sitepass-about-card { + border: 1px solid var(--line); + border-radius: 6px; + background: #fff; + padding: 22px; +} +.sitepass-about-card p { + line-height: 1.7; + color: #445667; +} + +@media (min-width: 901px) { + .detail-content-grid { + margin-top: -600px; + } + .detail-side-column { + padding-top: 600px; + } +} + +@media (max-width: 900px) { + .search-grid, .layout, .detail-hero, .two-col, .experience-browser, .detail-context-grid, .promo-grid, .search-results-shell, .real-search-page, .availability-grid, .inspiration-layout, .america250-card, .article-grid, .hero-result-grid, .ai-example-grid, .popular-location-links, .auth-layout, .help-topic-grid, .help-contact-grid, .state-link-grid, .detail-top-grid, .detail-content-grid, .detail-media-grid, .detail-reviews-grid, .detail-review-form-grid, .detail-booking-grid, .sitepass-layout { grid-template-columns: 1fr; } + .cards, .category-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .filters { position: static; } + .map-panel-head, .result-meta { align-items: flex-start; } + .search-card { grid-template-columns: 1fr; } + .search-card .card-img { min-height: 0; aspect-ratio: 4 / 3; } + .header-search { display: none; } + .real-map-pane { position: static; height: 420px; } + .real-search-map { min-height: 420px; } + .category-photo-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: repeat(3, 120px); } + .category-photo-tile:first-child { grid-row: span 1; } + .tabbed-search-form { grid-template-columns: 1fr; } + .help-search-bar { grid-template-columns: 1fr; } + .detail-photo-grid { grid-template-columns: 1fr; } + .detail-photo-grid img, .detail-panorama img { height: 220px; } + .detail-tabs { grid-template-columns: 1fr; } + .detail-tabs-inline { border-bottom: 0; } + .detail-tabs-inline a { justify-content: flex-start; padding-left: 0; } + .availability-cart-bar { grid-template-columns: 1fr; } + .sitepass-main h1 { font-size: 38px; } + .sitepass-cta-row { flex-direction: column; } + .sitepass-cta-row .btn { width: 100%; } +} +@media (max-width: 620px) { + .cards, .category-row { grid-template-columns: 1fr; } + .nav { align-items: flex-start; flex-direction: column; padding: 14px 0; } + .nav-links { gap: 10px; } + .promo-card { min-height: 220px; padding: 22px; } + .map-callout-actions, .search-card-price { align-items: flex-start; flex-direction: column; } + .rail-nav { display: none; } + .brand-word { font-size: 28px; } + .real-search-controls { flex-direction: column; align-items: stretch; } + .real-search-controls .toolbar-input { width: 100%; } + .home-search-form { grid-template-columns: 1fr; } + .home-search-card { width: calc(100vw - 24px); } + .home-tabs { align-items: flex-start; } + .footer-grid { grid-template-columns: 1fr; } +} diff --git a/sites/recreation_gov/static/icons/.gitkeep b/sites/recreation_gov/static/icons/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sites/recreation_gov/static/js/.gitkeep b/sites/recreation_gov/static/js/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sites/recreation_gov/static/js/main.js b/sites/recreation_gov/static/js/main.js new file mode 100644 index 0000000..c4f7715 --- /dev/null +++ b/sites/recreation_gov/static/js/main.js @@ -0,0 +1,380 @@ +document.addEventListener("DOMContentLoaded", () => { + const createBaseLayer = () => ( + L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { + attribution: "© OpenStreetMap contributors", + maxZoom: 19, + }) + ); + + document.querySelectorAll("[data-dismiss]").forEach((button) => { + button.addEventListener("click", () => { + const target = button.closest(".flash"); + if (target) target.remove(); + }); + }); + + document.querySelectorAll("[data-home-tabs]").forEach((tabRoot) => { + const tabs = tabRoot.querySelectorAll("[data-home-tab]"); + const card = tabRoot.closest(".home-search-card"); + const panels = card?.querySelectorAll("[data-home-panel]") || []; + const setActiveTab = (tab) => { + tabs.forEach((item) => item.classList.toggle("is-active", item === tab)); + const key = tab.dataset.homeTab || "all"; + if (card) card.classList.toggle("is-ai-mode", key === "ai"); + panels.forEach((panel) => panel.classList.toggle("is-active", panel.dataset.homePanel === key)); + window.requestAnimationFrame(() => window.dispatchEvent(new Event("resize"))); + }; + tabs.forEach((tab) => { + tab.addEventListener("click", () => setActiveTab(tab)); + }); + const initial = tabRoot.querySelector("[data-home-tab].is-active") || tabs[0]; + if (initial) setActiveTab(initial); + }); + + document.querySelectorAll("[data-example-prompt]").forEach((button) => { + button.addEventListener("click", () => { + const panel = button.closest("[data-home-panel='ai']"); + const field = panel?.querySelector("textarea[name='q']"); + if (!field) return; + field.value = button.dataset.examplePrompt || ""; + field.focus(); + }); + }); + + document.querySelectorAll("[data-copy-code]").forEach((button) => { + button.addEventListener("click", async () => { + const code = button.getAttribute("data-copy-code") || ""; + try { + await navigator.clipboard.writeText(code); + button.textContent = "Copied"; + setTimeout(() => { button.textContent = "Copy"; }, 1200); + } catch (_) { + button.textContent = "Copy failed"; + } + }); + }); + + document.querySelectorAll("[data-rail]").forEach((rail) => { + const track = rail.querySelector("[data-rail-track]"); + const prev = rail.querySelector('[data-rail-dir="prev"]'); + const next = rail.querySelector('[data-rail-dir="next"]'); + if (!track || !prev || !next) return; + + const syncRailButtons = () => { + prev.disabled = track.scrollLeft <= 8; + next.disabled = track.scrollLeft + track.clientWidth >= track.scrollWidth - 8; + }; + const jump = () => { + const firstCard = track.querySelector("[data-rail-card], .rail-card, .card"); + const gap = parseFloat(getComputedStyle(track).gap || 18); + return firstCard ? firstCard.getBoundingClientRect().width + gap : track.clientWidth * 0.85; + }; + + prev.addEventListener("click", () => track.scrollBy({ left: -jump(), behavior: "smooth" })); + next.addEventListener("click", () => track.scrollBy({ left: jump(), behavior: "smooth" })); + track.addEventListener("scroll", syncRailButtons, { passive: true }); + window.addEventListener("resize", syncRailButtons); + syncRailButtons(); + }); + + document.querySelectorAll("[data-review-load-more]").forEach((button) => { + const list = button.closest(".detail-review-list"); + const row = button.closest("[data-review-load-row]"); + if (!list) return; + const revealBatch = () => { + const hiddenCards = Array.from(list.querySelectorAll("[data-review-card].is-hidden-review")); + hiddenCards.slice(0, 10).forEach((card) => card.classList.remove("is-hidden-review")); + const remaining = list.querySelectorAll("[data-review-card].is-hidden-review").length; + if (remaining <= 0) { + if (row) row.classList.add("is-hidden"); + } else { + button.textContent = `Show More Reviews (${remaining})`; + } + }; + const initialRemaining = list.querySelectorAll("[data-review-card].is-hidden-review").length; + button.textContent = `Show More Reviews (${initialRemaining})`; + button.addEventListener("click", revealBatch); + }); + + const updateCallout = (root, source) => { + const callout = root.querySelector("[data-map-callout]"); + if (!callout || !source) return; + const setText = (selector, value) => { + const target = callout.querySelector(selector); + if (target) target.textContent = value || ""; + }; + setText("[data-callout-label]", source.dataset.mapLabel); + setText("[data-callout-name]", source.dataset.mapName); + setText("[data-callout-location]", source.dataset.mapLocation); + setText("[data-callout-distance]", source.dataset.mapDistance); + setText("[data-callout-rating]", source.dataset.mapRating); + setText("[data-callout-reviews]", source.dataset.mapReviews); + setText("[data-callout-price]", source.dataset.mapPrice); + const link = callout.querySelector("[data-callout-link]"); + if (link && source.dataset.mapLink) link.setAttribute("href", source.dataset.mapLink); + }; + + const activateMapItem = (root, id, panelKey) => { + root.querySelectorAll("[data-map-target]").forEach((row) => { + const match = row.dataset.mapTarget === id && row.dataset.panelKey === panelKey; + row.classList.toggle("is-active", match); + }); + let activeSource = null; + root.querySelectorAll("[data-marker-id]").forEach((marker) => { + const samePanel = !panelKey || marker.dataset.panelKey === panelKey; + const match = samePanel && marker.dataset.markerId === id; + marker.classList.toggle("is-active", match); + if (match) activeSource = marker; + }); + if (!activeSource) { + activeSource = root.querySelector(`[data-map-target="${id}"][data-panel-key="${panelKey}"]`); + } + updateCallout(root, activeSource); + }; + + document.querySelectorAll("[data-map-root]").forEach((root) => { + const tabs = root.querySelectorAll("[data-tab]"); + const panels = root.querySelectorAll("[data-panel]"); + const markers = root.querySelectorAll("[data-marker-id]"); + const setActivePanel = (panelKey) => { + tabs.forEach((tab) => tab.classList.toggle("is-active", tab.dataset.tab === panelKey)); + panels.forEach((panel) => panel.classList.toggle("is-active", panel.dataset.panel === panelKey)); + markers.forEach((marker) => marker.classList.toggle("is-hidden", marker.dataset.panelKey !== panelKey)); + const firstVisible = root.querySelector(`[data-marker-id]:not(.is-hidden)`) || root.querySelector(`[data-map-target][data-panel-key="${panelKey}"]`); + if (firstVisible) { + const id = firstVisible.dataset.markerId || firstVisible.dataset.mapTarget; + activateMapItem(root, id, panelKey); + } + }; + + tabs.forEach((tab) => { + tab.addEventListener("click", () => setActivePanel(tab.dataset.tab)); + }); + + root.querySelectorAll("[data-map-target]").forEach((row) => { + row.addEventListener("mouseenter", () => activateMapItem(root, row.dataset.mapTarget, row.dataset.panelKey)); + row.addEventListener("focusin", () => activateMapItem(root, row.dataset.mapTarget, row.dataset.panelKey)); + }); + + markers.forEach((marker) => { + marker.addEventListener("click", () => activateMapItem(root, marker.dataset.markerId, marker.dataset.panelKey)); + }); + + const firstTab = root.querySelector("[data-tab].is-active"); + if (firstTab) { + setActivePanel(firstTab.dataset.tab); + } else { + const firstMarker = root.querySelector("[data-marker-id]"); + if (firstMarker) activateMapItem(root, firstMarker.dataset.markerId, firstMarker.dataset.panelKey); + } + }); + + document.querySelectorAll("[data-leaflet-map]").forEach((node) => { + if (typeof window.L === "undefined") return; + const centerLat = parseFloat(node.dataset.mapLat || "39.5"); + const centerLng = parseFloat(node.dataset.mapLng || "-98.35"); + let markers = []; + try { + markers = JSON.parse(node.dataset.mapMarkers || "[]"); + } catch (_) { + markers = []; + } + + const map = L.map(node, { scrollWheelZoom: true }).setView([centerLat, centerLng], 9); + createBaseLayer().addTo(map); + + const leafletMarkers = []; + markers.forEach((marker) => { + const leafletMarker = L.marker([marker.lat, marker.lng]).addTo(map); + const popup = ` + ${marker.name}
+ ${marker.location}
+ ${marker.price_display}
+ Open details + `; + leafletMarker.bindPopup(popup); + leafletMarkers.push(leafletMarker); + }); + + if (leafletMarkers.length > 1) { + const group = L.featureGroup(leafletMarkers); + map.fitBounds(group.getBounds().pad(0.22)); + } else if (leafletMarkers[0]) { + leafletMarkers[0].openPopup(); + } + }); + + document.querySelectorAll("[data-booking-calendar]").forEach((calendar) => { + const form = calendar.closest("form"); + const selectionField = form?.querySelector('select[name="selection"]'); + const startField = form?.querySelector("[data-calendar-start-field]") || form?.querySelector('[name="start_date"]'); + const endField = form?.querySelector("[data-calendar-end-field]") || form?.querySelector('[name="end_date"]'); + const startDisplay = form?.querySelector("[data-calendar-start-display]"); + const windowDisplay = form?.querySelector("[data-calendar-window-display]"); + const status = calendar.querySelector("[data-calendar-status]"); + const buttons = calendar.querySelectorAll("[data-calendar-date]"); + const defaultSpanDays = Math.max(parseInt(calendar.dataset.calendarSpan || "2", 10), 0); + + const parseIsoDate = (value) => { + const [year, month, day] = (value || "").split("-").map(Number); + if (!year || !month || !day) return null; + return new Date(year, month - 1, day); + }; + + const toIsoDate = (value) => { + const year = value.getFullYear(); + const month = `${value.getMonth() + 1}`.padStart(2, "0"); + const day = `${value.getDate()}`.padStart(2, "0"); + return `${year}-${month}-${day}`; + }; + + const addDays = (value, days) => { + const next = new Date(value); + next.setDate(next.getDate() + days); + return next; + }; + + const diffDays = (startDate, endDate) => { + const oneDay = 24 * 60 * 60 * 1000; + return Math.round((endDate - startDate) / oneDay); + }; + + const sameDay = (left, right) => ( + Boolean(left) && + Boolean(right) && + left.getFullYear() === right.getFullYear() && + left.getMonth() === right.getMonth() && + left.getDate() === right.getDate() + ); + + const shortFormatter = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric" }); + const longFormatter = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric" }); + + const selectedOption = () => selectionField?.selectedOptions?.[0] || null; + + const selectedOptionEndDate = () => { + const explicitEnd = selectedOption()?.dataset.endDate; + return explicitEnd ? parseIsoDate(explicitEnd) : null; + }; + + const getSpanDays = () => { + const optionSpan = parseInt(selectedOption()?.dataset.spanDays || "", 10); + if (Number.isFinite(optionSpan) && optionSpan >= 0) { + return optionSpan; + } + if (endField?.type === "date" && startField?.value && endField.value) { + const typedStart = parseIsoDate(startField.value); + const typedEnd = parseIsoDate(endField.value); + if (typedStart && typedEnd) { + const typedSpan = diffDays(typedStart, typedEnd); + if (typedSpan > 0) return typedSpan; + } + } + return defaultSpanDays; + }; + + const windowLabel = (startDate, endDate) => { + const spanDays = diffDays(startDate, endDate); + if (spanDays <= 0) return shortFormatter.format(startDate); + if ( + startDate.getFullYear() === endDate.getFullYear() && + startDate.getMonth() === endDate.getMonth() + ) { + return `${shortFormatter.format(startDate)} - ${endDate.getDate()}`; + } + return `${shortFormatter.format(startDate)} - ${shortFormatter.format(endDate)}`; + }; + + const updateButtonState = (selectedStart, selectedEnd) => { + buttons.forEach((item) => { + const itemDate = parseIsoDate(item.dataset.calendarDate); + const isSelected = sameDay(itemDate, selectedStart); + const isRangeEnd = !isSelected && sameDay(itemDate, selectedEnd); + const isInRange = Boolean( + itemDate && + selectedStart && + selectedEnd && + itemDate > selectedStart && + itemDate < selectedEnd + ); + item.classList.toggle("is-selected", isSelected); + item.classList.toggle("is-range-end", isRangeEnd); + item.classList.toggle("is-in-range", isInRange); + }); + }; + + const applySelection = (selectedStart, selectedEnd, fallbackLabel = "") => { + if (!selectedStart) return; + const optionEnd = selectedOptionEndDate(); + const normalizedEnd = optionEnd || ( + selectedEnd && selectedEnd > selectedStart + ? selectedEnd + : addDays(selectedStart, Math.max(getSpanDays(), 0)) + ); + updateButtonState(selectedStart, normalizedEnd); + if (startField) startField.value = toIsoDate(selectedStart); + if (startDisplay) startDisplay.value = longFormatter.format(selectedStart); + if (endField) { + endField.value = toIsoDate(normalizedEnd); + } + if (windowDisplay) { + windowDisplay.value = windowLabel(selectedStart, normalizedEnd); + } + if (status) { + if (diffDays(selectedStart, normalizedEnd) > 0) { + status.textContent = `${windowLabel(selectedStart, normalizedEnd)} selected`; + } else { + status.textContent = `${fallbackLabel || longFormatter.format(selectedStart)} selected`; + } + } + }; + + const selectDate = (button) => { + const selectedStart = parseIsoDate(button.dataset.calendarDate); + if (!selectedStart) return; + applySelection( + selectedStart, + addDays(selectedStart, getSpanDays()), + button.dataset.calendarLabel || longFormatter.format(selectedStart), + ); + }; + + const syncFromFields = () => { + const selectedStart = parseIsoDate(startField?.value); + if (!selectedStart) return; + const selectedEnd = endField?.type === "date" + ? parseIsoDate(endField.value) || addDays(selectedStart, getSpanDays()) + : addDays(selectedStart, getSpanDays()); + applySelection(selectedStart, selectedEnd, longFormatter.format(selectedStart)); + }; + + buttons.forEach((button) => { + button.addEventListener("click", () => selectDate(button)); + }); + if (startField?.type === "date") { + startField.addEventListener("change", syncFromFields); + } + if (endField?.type === "date") { + endField.addEventListener("change", syncFromFields); + } + if (selectionField) { + selectionField.addEventListener("change", syncFromFields); + } + const initial = calendar.querySelector("[data-calendar-date].is-selected"); + if (initial) { + selectDate(initial); + } else { + syncFromFields(); + } + }); + + document.querySelectorAll("[data-layout-shell]").forEach((shell) => { + const buttons = document.querySelectorAll("[data-layout-mode]"); + const setMode = (mode) => { + shell.dataset.layout = mode; + buttons.forEach((button) => button.classList.toggle("is-active", button.dataset.layoutMode === mode)); + }; + buttons.forEach((button) => button.addEventListener("click", () => setMode(button.dataset.layoutMode))); + setMode(shell.dataset.layout || "split"); + }); +}); diff --git a/sites/recreation_gov/tasks.jsonl b/sites/recreation_gov/tasks.jsonl new file mode 100644 index 0000000..cf5989e --- /dev/null +++ b/sites/recreation_gov/tasks.jsonl @@ -0,0 +1,20 @@ +{"web_name":"Recreation.gov","id":"RecreationGov--0","ques":"From the homepage, use the Camping & Lodging tab to search Yosemite campgrounds. Open both Yosemite Creek Campground and Porcupine Flat Campground, determine which one is currently open for the featured trip window, and report one activity from the available campground.","web":"http://localhost:40015/","upstream_url":"https://www.recreation.gov/"} +{"web_name":"Recreation.gov","id":"RecreationGov--1","ques":"Search for Point Reyes camping and open Point Reyes National Seashore Campground. In the Media Gallery, report two different scenes shown in the gallery images.","web":"http://localhost:40015/","upstream_url":"https://www.recreation.gov/"} +{"web_name":"Recreation.gov","id":"RecreationGov--2","ques":"Search California wilderness permits and compare the likely permit results until you find the listing whose detail page combines Fishing in the activity pills with Trailhead Entry in the listing details. Report the permit name and the parent area.","web":"http://localhost:40015/","upstream_url":"https://www.recreation.gov/"} +{"web_name":"Recreation.gov","id":"RecreationGov--3","ques":"Search for San Francisco tours and compare San Francisco Maritime Historic Park Tours with Fort Point National Historic Site Tours. Which listing highlights Historic Ships, and which one is part of Golden Gate National Recreation Area?","web":"http://localhost:40015/","upstream_url":"https://www.recreation.gov/"} +{"web_name":"Recreation.gov","id":"RecreationGov--4","ques":"Use the Explore By State section on the homepage to browse Alaska inventory. Find the reservable cabin whose detail page lists both Boat Access and Wood Stove, and report its name.","web":"http://localhost:40015/","upstream_url":"https://www.recreation.gov/"} +{"web_name":"Recreation.gov","id":"RecreationGov--5","ques":"From Explore Most Popular Locations on the homepage, open the listing for Apostle Islands National Lakeshore. Report one activity shown on that detail page and whether the listing is a permit or a campground.","web":"http://localhost:40015/","upstream_url":"https://www.recreation.gov/"} +{"web_name":"Recreation.gov","id":"RecreationGov--6","ques":"Open the Help Center and find the policy point about online reservation fees. Which kind of reservation typically includes a $1 online reservation fee?","web":"http://localhost:40015/","upstream_url":"https://www.recreation.gov/"} +{"web_name":"Recreation.gov","id":"RecreationGov--7","ques":"Use Inspiration & Information to open Plan Ahead and Play It Safe for Your Next Outdoor Adventure. According to the article, what are the three checks travelers should make before committing to a long drive?","web":"http://localhost:40015/","upstream_url":"https://www.recreation.gov/"} +{"web_name":"Recreation.gov","id":"RecreationGov--8","ques":"Open the America250 inspiration article and list any three places it mentions.","web":"http://localhost:40015/","upstream_url":"https://www.recreation.gov/"} +{"web_name":"Recreation.gov","id":"RecreationGov--9","ques":"Browse passes and compare the Yosemite, Denali, and Grand Teton site pass pages. Which one includes the extra note that non-U.S. residents must pay an additional fee?","web":"http://localhost:40015/","upstream_url":"https://www.recreation.gov/"} +{"web_name":"Recreation.gov","id":"RecreationGov--10","ques":"Use Tickets & Tours to find the listing whose detail page includes Accessible Seating instead of Accessible Route. Report the listing name and parent area.","web":"http://localhost:40015/","upstream_url":"https://www.recreation.gov/"} +{"web_name":"Recreation.gov","id":"RecreationGov--11","ques":"Log in as alice.j@test.com with password TestPass123!, find Fort Point National Historic Site Tours, save it, and confirm it appears in Saved locations.","web":"http://localhost:40015/","upstream_url":"https://www.recreation.gov/"} +{"web_name":"Recreation.gov","id":"RecreationGov--12","ques":"Log in as bob.c@test.com with password TestPass123! and complete checkout for the item already in the cart. Then open Reservations and report the new confirmation code.","web":"http://localhost:40015/","upstream_url":"https://www.recreation.gov/"} +{"web_name":"Recreation.gov","id":"RecreationGov--13","ques":"Log in as alice.j@test.com with password TestPass123! and cancel only the upcoming Kirby Cove Campground reservation, leaving the Yellowstone National Park Fishing Permit reservation untouched. Report the confirmation code that was cancelled.","web":"http://localhost:40015/","upstream_url":"https://www.recreation.gov/"} +{"web_name":"Recreation.gov","id":"RecreationGov--14","ques":"Log in as carol.d@test.com with password TestPass123!, update the account phone number to 555-0199 and the home city to Boulder, then confirm both updated values on the account page.","web":"http://localhost:40015/","upstream_url":"https://www.recreation.gov/"} +{"web_name":"Recreation.gov","id":"RecreationGov--15","ques":"Create a new account using display name River Stone, username river_stone, email river.stone@example.com, and password TrailPass2026. After the account is created, report the default card ending and the default address city shown on the account page.","web":"http://localhost:40015/","upstream_url":"https://www.recreation.gov/"} +{"web_name":"Recreation.gov","id":"RecreationGov--16","ques":"Log in as david.k@test.com with password TestPass123!, open Fort Point National Historic Site Tours, submit a 4-star review with visit date May 2026 and the note 'Accessible route was easy to follow.', and confirm that your review appears with the Your review badge.","web":"http://localhost:40015/","upstream_url":"https://www.recreation.gov/"} +{"web_name":"Recreation.gov","id":"RecreationGov--17","ques":"Search for beach camping and find a reservable option that is not in California. Open its detail page and report the state and one activity listed there.","web":"http://localhost:40015/","upstream_url":"https://www.recreation.gov/"} +{"web_name":"Recreation.gov","id":"RecreationGov--18","ques":"Search for BLM-managed permits and find the wilderness listing near Winkelman. Open the detail page and report one activity plus whether the listing mentions Day Use Permit, Overnight Permit, or both.","web":"http://localhost:40015/","upstream_url":"https://www.recreation.gov/"} +{"web_name":"Recreation.gov","id":"RecreationGov--19","ques":"Log in as alice.j@test.com with password TestPass123!, find Yellowstone National Park Fishing Permit, open its detail page, add it to the cart, and confirm the pass appears in Your cart.","web":"http://localhost:40015/","upstream_url":"https://www.recreation.gov/"} diff --git a/sites/recreation_gov/templates/.gitkeep b/sites/recreation_gov/templates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sites/recreation_gov/templates/404.html b/sites/recreation_gov/templates/404.html new file mode 100644 index 0000000..eba6922 --- /dev/null +++ b/sites/recreation_gov/templates/404.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block title %}Not found - Recreation.gov{% endblock %} +{% block content %} +
+
+

Page not found

+

Try searching for campgrounds, permits, tours, passes, or day-use venues.

+ Search Recreation.gov +
+
+{% endblock %} diff --git a/sites/recreation_gov/templates/_facility_card.html b/sites/recreation_gov/templates/_facility_card.html new file mode 100644 index 0000000..6dc01e4 --- /dev/null +++ b/sites/recreation_gov/templates/_facility_card.html @@ -0,0 +1,54 @@ +{% set mode = card_mode|default('default') %} +
+ + Photo of {{ facility.name }} + {% if mode == 'search' %} + {% if facility.available %}Available{% else %}Enter dates{% endif %} + + {% endif %} + +
+ {% if mode == 'rail' %} +
{{ facility.trip_window }}
+ {% elif mode == 'search' %} +
+
{{ facility.label }}
+
▣ {{ facility.trip_window.title() }}
+
+ {% endif %} + {% if mode != 'search' %} +
{{ facility.label }}{% if mode != 'rail' %} · {{ facility.agency }}{% endif %}
+ {% endif %} +

{{ facility.name }}

+ {% if mode == 'search' %} +

{{ facility.agency }}{% if facility.parent_area %} · {{ facility.parent_area }}{% endif %}

+

Near {{ facility.location }}, {{ facility.state }} · {{ "%.0f"|format(facility.distance_miles) }} miles point-to-point

+ {% else %} +

{{ facility.parent_area }} near {{ facility.location }}, {{ facility.state }}

+ {% endif %} + {% if mode == 'rail' %} +
★★★★☆ ({{ "{:,}".format(facility.review_count) }})
+ {% elif mode == 'search' %} + + {% else %} +
+ {{ "%.0f"|format(facility.distance_miles) }} miles + {{ "%.1f"|format(facility.rating) }} rating + {{ facility.review_count }} reviews + {% if facility.accessible %}Accessible{% endif %} + {% if facility.available %}Available{% endif %} +
+

{{ facility.short_description }}

+ {% endif %} + {% if mode != 'search' %} +

{{ facility.price_display }}

+ {% endif %} +
+
diff --git a/sites/recreation_gov/templates/_reviews_section.html b/sites/recreation_gov/templates/_reviews_section.html new file mode 100644 index 0000000..83e6866 --- /dev/null +++ b/sites/recreation_gov/templates/_reviews_section.html @@ -0,0 +1,75 @@ +
+
+
+

Guest Reviews ({{ "{:,}".format(facility.review_count) }})

+
{{ "%.1f"|format(facility.rating) }} ★★★★★
+
+
Sort by: Most Recent
+
+ +
+ + +
+ {% if current_user.is_authenticated %} +
+

Write a Review

+
+
+ + +
+
+ + +
+
+ + + +
+ {% else %} + + {% endif %} + + {% for review in recent_reviews %} + {% set is_own_review = current_review_author and review.author == current_review_author %} +
+
+
+
{{ "★" * review.rating }}{{ "☆" * (5 - review.rating) }}
+ {{ review.author }}{% if is_own_review %} Your review{% endif %} +
Reservation Dates: {{ review.visit_date }}
+
+ Submitted {{ review.visit_date }} +
+

{{ review.body }}

+
+ {% endfor %} + + {% if recent_reviews|length > 20 %} +
+ +
+ {% endif %} +
+
+
diff --git a/sites/recreation_gov/templates/account.html b/sites/recreation_gov/templates/account.html new file mode 100644 index 0000000..a6aa6f8 --- /dev/null +++ b/sites/recreation_gov/templates/account.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}Account - Recreation.gov{% endblock %} +{% block content %} +
+
+
+

Your account

+
+ + + + +
+
+
+

Saved checkout details

+ {% for address in addresses %} +
{{ address.label }}{{ address.street }}, {{ address.city }}, {{ address.state }} {{ address.zip_code }}
+ {% endfor %} + {% for payment in payments %} +
{{ payment.card_type }} ending {{ payment.last4 }}Expires {{ payment.expiry }}
+ {% endfor %} +

View reservations · Log out

+
+
+
+{% endblock %} diff --git a/sites/recreation_gov/templates/article.html b/sites/recreation_gov/templates/article.html new file mode 100644 index 0000000..27f56f5 --- /dev/null +++ b/sites/recreation_gov/templates/article.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}{{ title }} - Recreation.gov{% endblock %} +{% block content %} +
+
+ {% if kind == 'editorial' %} +

{{ article.kicker }}

+

{{ article.title }}

+ +

{{ article.summary }}

+ {% for paragraph in article.paragraphs %} +

{{ paragraph }}

+ {% endfor %} + {% elif kind == 'america250' %} +

Recreation.gov article

+

{{ title }}

+

Explore historic parks, cultural sites, scenic landscapes, and public lands connected to America's 250th anniversary. The mirror highlights places like San Francisco Maritime National Historical Park, Fort Point, Golden Gate, Yosemite, Yellowstone, Denali, and Cumberland Island.

+

Trip ideas include pairing a historic tour with a nearby campground, reserving a timed-entry pass for a national park, or comparing wilderness permits before a backpacking route.

+ {% else %} +

Recreation.gov article

+

{{ title }}

+

Recreation.gov helps visitors discover and reserve experiences across federal lands and waters. This local WebHarbor mirror focuses on the workflows agents need: search, filtering, detail reading, saving, cart, checkout, reservations, and account management.

+

The real site integrates inventory from agencies such as NPS, USFS, BLM, FWS, USACE, and other public land partners.

+ {% endif %} +
+
+{% endblock %} diff --git a/sites/recreation_gov/templates/articles.html b/sites/recreation_gov/templates/articles.html new file mode 100644 index 0000000..1dcf9ff --- /dev/null +++ b/sites/recreation_gov/templates/articles.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block title %}Articles - Recreation.gov{% endblock %} +{% block content %} +
+
+

Inspiration & Information

+

Articles

+

Editorial trip-planning guidance, safety information, and destination inspiration tied to recreation inventory.

+
+
+
+
+ {% for article in articles %} + + + {{ article.kicker }} + {{ article.title }} +

{{ article.summary }}

+
+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/recreation_gov/templates/base.html b/sites/recreation_gov/templates/base.html new file mode 100644 index 0000000..d891908 --- /dev/null +++ b/sites/recreation_gov/templates/base.html @@ -0,0 +1,99 @@ + + + + + + {% block title %}Recreation.gov{% endblock %} + + {% block extra_head %}{% endblock %} + + + +
+ +
+
+ +
+ +
+
+ {% for category, message in get_flashed_messages(with_categories=true) %} +
+ {{ message }} + +
+ {% endfor %} +
+ {% block content %}{% endblock %} +
+ + + {% block extra_scripts %}{% endblock %} + + diff --git a/sites/recreation_gov/templates/cart.html b/sites/recreation_gov/templates/cart.html new file mode 100644 index 0000000..2c70f83 --- /dev/null +++ b/sites/recreation_gov/templates/cart.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block title %}Cart - Recreation.gov{% endblock %} +{% block content %} +
+
+

Your cart

+ {% for item in items %} +
+
{{ item.facility.name }}
{{ (item.start_date, item.end_date)|trip_window }} · {{ item.guests }} guests{% if item.campsite %} · {{ item.campsite.name }}{% endif %}
+
+ ${{ "%.0f"|format(item.total) }} +
+
+
+ {% else %} +

Your cart is empty. Search available inventory.

+ {% endfor %} + {% if items %} +

Total: ${{ "%.0f"|format(total) }}

Proceed to Checkout
+ {% endif %} +
+
+{% endblock %} diff --git a/sites/recreation_gov/templates/category.html b/sites/recreation_gov/templates/category.html new file mode 100644 index 0000000..2e7bce2 --- /dev/null +++ b/sites/recreation_gov/templates/category.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}{{ inventory_labels[inventory_type] }} - Recreation.gov{% endblock %} +{% block content %} +
+
+

Explore

+

{{ inventory_labels[inventory_type] }}

+

Search, compare, save, and reserve {{ inventory_labels[inventory_type].lower() }} from the local Recreation.gov mirror.

+
+
+
+
+
+

{{ results|length }} options

+ Show available only +
+
+ {% for facility in results %} + {% include "_facility_card.html" %} + {% endfor %} +
+
+
+{% endblock %} diff --git a/sites/recreation_gov/templates/checkout.html b/sites/recreation_gov/templates/checkout.html new file mode 100644 index 0000000..fef5e01 --- /dev/null +++ b/sites/recreation_gov/templates/checkout.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block title %}Checkout - Recreation.gov{% endblock %} +{% block content %} +
+
+
+

Checkout

+ {% for item in items %} +
{{ item.facility.name }}
{{ (item.start_date, item.end_date)|trip_window }}
${{ "%.0f"|format(item.total) }}
+ {% endfor %} +
+
+

Total: ${{ "%.0f"|format(total) }}

+ {% if addresses %}

Address: {{ addresses[0].street }}, {{ addresses[0].city }}

{% endif %} + {% if payments %}

Payment: {{ payments[0].card_type }} ending {{ payments[0].last4 }}

{% endif %} + + +
+
+
+{% endblock %} diff --git a/sites/recreation_gov/templates/facility_detail.html b/sites/recreation_gov/templates/facility_detail.html new file mode 100644 index 0000000..3dc3b6c --- /dev/null +++ b/sites/recreation_gov/templates/facility_detail.html @@ -0,0 +1,296 @@ +{% extends "base.html" %} +{% block title %}{{ facility.name }} - Recreation.gov{% endblock %} +{% block extra_head %} + +{% endblock %} +{% block extra_scripts %} + +{% endblock %} +{% block content %} +
+ {{ gallery_images[0].alt }} + +
+ +{% if detail_alert %} +
+
+ +

{{ detail_alert }}

+ +
+
+{% endif %} + +
+
+ + +
+
+

{{ facility.name }}

+
+ Part of {{ facility.parent_area or facility.agency }} + ★ {{ "%.1f"|format(facility.rating) }} · {{ "{:,}".format(facility.review_count) }} Reviews + ▁▂▃▅ Major Mobile Issues + {% if current_user.is_authenticated %} +
+ +
+ {% else %} + ♡ Favorite + {% endif %} +
+ +
+ + +
+ +
+
+
+

{{ facility.long_description }}

+
+

{{ facility.name }} is located near {{ facility.location }}, {{ state_name }} and mirrors the key planning information travelers expect on Recreation.gov: current availability, fees, permit or campsite context, and nearby alternatives.

+

Use the trip window, activities, and rules together before booking. For this inventory, the highest-signal planning details are the access window, on-site constraints, and how the reservation type changes what you need to bring or print before arrival.

+
+
+ +
+

{% if facility.inventory_type == 'permits' %}Permit & Season Information{% else %}Availability & Trip Details{% endif %}

+
+ {% if facility.inventory_type == 'permits' %} +

Overnight / entry permits:

+
    +
  • Permits are required to access this inventory and are tied to the selected trip window.
  • +
  • High-demand periods can use quotas, zone selection, or limited daily release windows.
  • +
  • Carry your reservation details and review area-specific rules before departure.
  • +
+ {% else %} +

Current trip window: {{ facility.trip_window.title() }}

+
    +
  • Agency: {{ facility.agency }}
  • +
  • Price: {{ facility.price_display }}
  • +
  • Capacity: up to {{ facility.capacity }} people
  • +
  • {% if facility.available %}Availability is currently open for this featured window.{% else %}This listing is visible, but not currently open for the featured window.{% endif %}
  • +
+ {% endif %} +
+
+ +
+

Important Dates

+ + + + + + + + + {% for item in important_dates %} + + + + + {% endfor %} + +
DatesInformation
{{ item.date }}{{ item.info }}
+
+ +
+

Need to Know

+
+ {% for activity in facility.activities %} + {{ activity }} + {% endfor %} + {% for amenity in facility.amenities %} + {{ amenity }} + {% endfor %} +
+
    + {% for rule in facility.rules %} +
  • {{ rule }}
  • + {% endfor %} + {% if facility.accessible %} +
  • Accessible accommodations are noted in this listing and should still be verified before arrival.
  • + {% endif %} +
+
+ +
+

Fees & Cancellations

+

{{ facility.price_display }}

+
    +
  • Reservation and change fees can vary by inventory type and how close you are to the entry date.
  • +
  • Late changes may reduce refund eligibility, especially for permits and high-demand camping inventory.
  • +
  • Review the linked policy guidance before checkout if the trip depends on a precise arrival window.
  • +
+
+ +
+

Getting Here

+
+

{{ facility.name }} is part of {{ facility.parent_area or facility.agency }} near {{ facility.location }}, {{ state_name }}.

+

Before driving, compare nearby inventory and confirm whether your reservation requires printed paperwork, mobile confirmation, or timed entry.

+
+
+ + + + {% include "_reviews_section.html" %} +
+ + +
+
+
+ +
+
+
+
+

Map

+

Zoom and compare the destination with nearby alternatives.

+
+ Search Nearby +
+
+
+
+
+ +
+
+

Related options

+
{% for facility in related %}{% include "_facility_card.html" %}{% endfor %}
+
+
+{% endblock %} diff --git a/sites/recreation_gov/templates/help.html b/sites/recreation_gov/templates/help.html new file mode 100644 index 0000000..70c806e --- /dev/null +++ b/sites/recreation_gov/templates/help.html @@ -0,0 +1,99 @@ +{% extends "base.html" %} +{% block title %}Help - Recreation.gov{% endblock %} +{% block content %} +
+
+

Support

+

Help Center

+

Find account guidance, reservation policy summaries, accessibility support details, and common trip-planning answers modeled after current Recreation.gov help topics.

+ +
+
+ +
+
+ {% for topic in topic_cards %} + + {{ topic.title }} +

{{ topic.body }}

+
+ {% endfor %} +
+
+ +
+
+
+

Reservation Questions

+
+ {% for entry in faq_entries %} +
+ {{ entry.question }} +

{{ entry.answer }}

+
+ {% endfor %} +
+
+ +
+
+ +
+
+
+

Rules & Reservation Policies

+
    + {% for point in policy_points %} +
  • {{ point }}
  • + {% endfor %} +
+

Policy details vary by facility. Local rules on individual listings override site-wide defaults.

+
+
+

Accessibility Support

+

Recreation.gov states that the service is designed to meet or exceed Section 508 accessibility requirements and that the Help Center team can assist with reservations, website questions, and alternate-format support.

+

If you require accessibility assistance, use the contact pathway below or the TDD line 877-833-6777 from 10 a.m. to 12 a.m. Eastern Time.

+

Use the Accessible filter in search to find campgrounds, tours, and day-use inventory with accessibility-related features.

+
+
+
+ +
+
+
+

Need More Help?

+

Contact & Support Paths

+

Use your account for self-service first, then escalate to support for reservation changes, accessibility assistance, or post-trip refund questions.

+
+
+
+ Reservations & Changes +

1-877-444-6777

+
+
+ TDD Line +

877-833-6777

+
+
+ Self-Service +

My Reservations

+
+ +
+
+
+{% endblock %} diff --git a/sites/recreation_gov/templates/index.html b/sites/recreation_gov/templates/index.html new file mode 100644 index 0000000..4335f26 --- /dev/null +++ b/sites/recreation_gov/templates/index.html @@ -0,0 +1,333 @@ +{% extends "base.html" %} +{% block title %}Recreation.gov - Camping, Cabins, Permits, Passes & More{% endblock %} +{% block extra_head %} + +{% endblock %} +{% block extra_scripts %} + +{% endblock %} +{% block content %} +
+ +
+
+
+ + + + + + + +
+ {% for panel in home_tab_panels %} +
+ {% if panel.type == 'explore_all' %} +
+
+ + +
+ +
+
+ {% for tile in category_tiles %} + + + {{ tile.label }} + + {% endfor %} +
+ {% elif panel.type == 'inventory' %} +
+ +
+ + +
+ {% if panel.secondary %} + + {% endif %} + {% if panel.tertiary %} + + {% endif %} + +
+
+
+
+

{{ panel.title }}

+

{{ panel.description }} Explore More →

+
+
+
+ +
+ {% for facility in panel.entries %} + {% set card_mode = 'rail' %} + {% include "_facility_card.html" %} + {% set card_mode = 'default' %} + {% endfor %} + {% for _ in range(8 - panel.entries|length) %} + + {% endfor %} +
+ +
+
+ {% else %} +
+
+ +
+ +
+
+

Example Searches:

+
+ +
+ {% for example in panel.examples %} + + {% endfor %} +
+ +
+
+ {% endif %} +
+ {% endfor %} +
+
+
+ +
+
+
+
+

Available Nearby

+

Campgrounds with upcoming availability.

+
+ Explore More +
+
+ +
+ {% for facility in nearby %} + {% set card_mode = 'rail' %} + {% include "_facility_card.html" %} + {% set card_mode = 'default' %} + {% endfor %} +
+ +
+
+
+ +
+
+ {% for feature in promo_features %} +
+

{{ feature.eyebrow }}

+

{{ feature.title }}

+

{{ feature.body }}

+ Learn More +
+ {% endfor %} +
+
+ +
+
+
+
+

Explore Destinations & Activities

+

{{ anchor_city }} Open location picker

+
+
+
+
+
+ {% for panel in experience_panels %} + + {% endfor %} +
+ {% for panel in experience_panels %} +
+
+ {{ panel.count }} experiences near {{ anchor_city }} + {{ panel.description }} +
+ +
+ {% endfor %} +
+ +
+
+
+ +
+
+
+
+

Celebrate 250 Years of American Discovery

+

Explore historic parks, cultural sites, scenic landscapes, and more as you join the nationwide celebration of America's 250th anniversary.

+ Learn More +
+ +
+
+
+ +
+ +
+ +
+
+
+

Explore By State

+ See all destinations → +
+ +
+
+ + + +
+
+
+

Highly rated places

+ Sort by rating +
+
+ {% for facility in featured %} + {% include "_facility_card.html" %} + {% endfor %} +
+
+
+ +
+
+
+

Popular permits and passes

+ View permits +
+
+ {% for facility in passes %} + {% include "_facility_card.html" %} + {% endfor %} +
+
+
+{% endblock %} diff --git a/sites/recreation_gov/templates/login.html b/sites/recreation_gov/templates/login.html new file mode 100644 index 0000000..c72ea50 --- /dev/null +++ b/sites/recreation_gov/templates/login.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}Log In - Recreation.gov{% endblock %} +{% block content %} +
+
+
+

Account Access

+

Log In

+
+ + + +
+

Benchmark users use password TestPass123!.

+
+ +
+
+{% endblock %} diff --git a/sites/recreation_gov/templates/register.html b/sites/recreation_gov/templates/register.html new file mode 100644 index 0000000..98e5b8f --- /dev/null +++ b/sites/recreation_gov/templates/register.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}Register - Recreation.gov{% endblock %} +{% block content %} +
+
+
+

Account Setup

+

Create an account

+
+ + + + + +
+
+ +
+
+{% endblock %} diff --git a/sites/recreation_gov/templates/reservations.html b/sites/recreation_gov/templates/reservations.html new file mode 100644 index 0000000..402805c --- /dev/null +++ b/sites/recreation_gov/templates/reservations.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Reservations - Recreation.gov{% endblock %} +{% block content %} +
+
+

Your reservations

+ {% for reservation in reservations %} +
+
{{ reservation.facility.name }}
{{ reservation.confirmation_code }} · {{ (reservation.start_date, reservation.end_date)|trip_window }} · {{ reservation.status }}
+
${{ "%.0f"|format(reservation.total_cost) }}{% if reservation.status == 'Upcoming' %}
{% endif %}
+
+ {% else %} +

No reservations yet.

+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/recreation_gov/templates/saved.html b/sites/recreation_gov/templates/saved.html new file mode 100644 index 0000000..4fbe05e --- /dev/null +++ b/sites/recreation_gov/templates/saved.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% block title %}Saved - Recreation.gov{% endblock %} +{% block content %} +
+
+

Saved locations

+
{% for item in items %}{% set facility = item.facility %}{% include "_facility_card.html" %}{% else %}

No saved locations yet.

{% endfor %}
+
+
+{% endblock %} diff --git a/sites/recreation_gov/templates/search.html b/sites/recreation_gov/templates/search.html new file mode 100644 index 0000000..b8e5976 --- /dev/null +++ b/sites/recreation_gov/templates/search.html @@ -0,0 +1,97 @@ +{% extends "base.html" %} +{% block title %}Search Recreation.gov{% endblock %} +{% block content %} +{% set visible_count = results|length if results|length < 20 else 20 %} +
+
+
+ + +
+ + + +
+
+
+
+
+
+

Search Results{% if args.get('q') %} near {{ args.get('q') }}{% endif %}

+ + 1 - {{ visible_count }} results of {{ results|length }} +
+
+ +
+ + + +
+
+
+
+ {% for entry in search_entries %} + {% set facility = entry.facility %} + {% set point = entry.point %} +
+ {% set card_mode = 'search' %} + {% include "_facility_card.html" %} + {% set card_mode = 'default' %} +
+ {% else %} +

No inventory matched those filters. Try a broader query.

+ {% endfor %} +
+
+ +
+{% endblock %} diff --git a/sites/recreation_gov/templates/site_pass_detail.html b/sites/recreation_gov/templates/site_pass_detail.html new file mode 100644 index 0000000..21a75fe --- /dev/null +++ b/sites/recreation_gov/templates/site_pass_detail.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} +{% block title %}{{ facility.name }} - Recreation.gov{% endblock %} +{% block content %} +
+
+
+ +

{{ site_pass_context.alert }} Learn More

+ +
+ +
+
+

{{ site_pass_context.page_title }}

+

{{ facility.name }}

+
+ Part of {{ facility.parent_area or facility.agency }} + {% if current_user.is_authenticated %} +
+ +
+ {% else %} + ♡ Favorite + {% endif %} +
+ + {% if site_pass_context.notice %} +
+ i + {{ site_pass_context.notice }} Learn More +
+ {% endif %} + +
+

An entrance pass is required at this location

+

A digital Site Pass:

+
    +
  • Covers the entrance fee for {{ facility.name }}
  • +
  • Is available for download on a phone or tablet
  • +
+

Site Passes are non-refundable. Before purchasing, review these additional options you might be eligible for.

+ +
+ +
+
+

America the Beautiful Passes cover the entrance fee at this location.

+

If you have a pass, bring it with you. Frequent visitors, seniors, military, and others may save money on entrance fees with an America the Beautiful Pass.

+ Learn more about the Digital America the Beautiful Pass +
+ Multiple America the Beautiful interagency passes +
+ + {% include "_reviews_section.html" %} +
+ + +
+
+
+{% endblock %} diff --git a/websyn_start.sh b/websyn_start.sh index 72defad..4b9f871 100644 --- a/websyn_start.sh +++ b/websyn_start.sh @@ -1,11 +1,11 @@ #!/bin/bash -# WebSyn startup: launch all 12 mirror sites, then exec the original CMD. +# WebSyn startup: launch all mirror sites, then exec the original CMD. # This preserves the base image's browser env server (port 8100) as PID 1. set -e SITES=(allrecipes amazon apple arxiv bbc_news booking github google_flights google_map google_search huggingface wolfram_alpha - cambridge_dictionary coursera espn) + cambridge_dictionary coursera espn recreation_gov) BASE_PORT=40000 PID_DIR=/tmp/websyn_pids mkdir -p "$PID_DIR" @@ -17,7 +17,7 @@ for d in "${SITES[@]}"; do cp -a "/opt/WebSyn/$d/instance_seed" "/opt/WebSyn/$d/instance" done -echo "[WebSyn] Starting 15 sites on ports ${BASE_PORT}-$((BASE_PORT + 14))..." +echo "[WebSyn] Starting ${#SITES[@]} sites on ports ${BASE_PORT}-$((BASE_PORT + ${#SITES[@]} - 1))..." for i in "${!SITES[@]}"; do site="${SITES[$i]}" port=$((BASE_PORT + i)) @@ -51,8 +51,8 @@ except Exception: exit(1) ready=$((ready + 1)) fi done - echo " [${elapsed}/${max_wait}s] ${ready}/15 sites ready" - if [ $ready -eq 15 ]; then + echo " [${elapsed}/${max_wait}s] ${ready}/${#SITES[@]} sites ready" + if [ $ready -eq ${#SITES[@]} ]; then break fi done @@ -78,6 +78,6 @@ done echo "[WebSyn] Starting control server on :8101 (PID 1)..." # Control server becomes PID 1 — receives SIGTERM on `docker stop`, -# keeps the container alive as long as it's running. The 15 site +# keeps the container alive as long as it's running. The site # subprocesses are managed via /tmp/websyn_pids/.pid. exec python3 /opt/control_server.py --port 8101