From 1c1dd0206958b19c3bd402777cd4b4d8c6520f97 Mon Sep 17 00:00:00 2001 From: XuanRui LI Date: Thu, 4 Jun 2026 20:44:12 +0800 Subject: [PATCH] Add FedEx WebHarbor mirror --- .claude/skills/clone-website/SKILL.md | 4 +- .claude/skills/evolve-env/SKILL.md | 6 +- .claude/skills/harden-env/SKILL.md | 2 +- .claude/skills/review-env/SKILL.md | 6 +- .claude/skills/seed-database/SKILL.md | 6 +- .gitignore | 8 +- AGENTS.md | 10 +- CONTRIBUTING.md | 4 +- Dockerfile | 6 +- README.md | 10 +- control_server.py | 2 +- sites/fedex/_health.py | 3 + sites/fedex/app.py | 873 +++++++++++++++++++ sites/fedex/requirements.txt | 5 + sites/fedex/seed_data.py | 688 +++++++++++++++ sites/fedex/static/css/.gitkeep | 0 sites/fedex/static/css/main.css | 537 ++++++++++++ sites/fedex/static/icons/.gitkeep | 0 sites/fedex/static/js/.gitkeep | 0 sites/fedex/static/js/main.js | 11 + sites/fedex/tasks.jsonl | 18 + sites/fedex/templates/.gitkeep | 0 sites/fedex/templates/account.html | 78 ++ sites/fedex/templates/account_edit.html | 28 + sites/fedex/templates/account_shipments.html | 34 + sites/fedex/templates/base.html | 67 ++ sites/fedex/templates/claims.html | 26 + sites/fedex/templates/index.html | 165 ++++ sites/fedex/templates/invoices.html | 36 + sites/fedex/templates/location_detail.html | 43 + sites/fedex/templates/locations.html | 41 + sites/fedex/templates/login.html | 32 + sites/fedex/templates/pickup.html | 44 + sites/fedex/templates/rate_estimate.html | 73 ++ sites/fedex/templates/register.html | 39 + sites/fedex/templates/search.html | 64 ++ sites/fedex/templates/ship.html | 82 ++ sites/fedex/templates/ship_confirmation.html | 22 + sites/fedex/templates/ship_review.html | 37 + sites/fedex/templates/ship_service.html | 34 + sites/fedex/templates/support.html | 30 + sites/fedex/templates/support_article.html | 26 + sites/fedex/templates/track.html | 34 + sites/fedex/templates/track_results.html | 39 + sites/fedex/templates/tracking_detail.html | 51 ++ websyn_start.sh | 13 +- 46 files changed, 3300 insertions(+), 37 deletions(-) create mode 100644 sites/fedex/_health.py create mode 100644 sites/fedex/app.py create mode 100644 sites/fedex/requirements.txt create mode 100644 sites/fedex/seed_data.py create mode 100644 sites/fedex/static/css/.gitkeep create mode 100644 sites/fedex/static/css/main.css create mode 100644 sites/fedex/static/icons/.gitkeep create mode 100644 sites/fedex/static/js/.gitkeep create mode 100644 sites/fedex/static/js/main.js create mode 100644 sites/fedex/tasks.jsonl create mode 100644 sites/fedex/templates/.gitkeep create mode 100644 sites/fedex/templates/account.html create mode 100644 sites/fedex/templates/account_edit.html create mode 100644 sites/fedex/templates/account_shipments.html create mode 100644 sites/fedex/templates/base.html create mode 100644 sites/fedex/templates/claims.html create mode 100644 sites/fedex/templates/index.html create mode 100644 sites/fedex/templates/invoices.html create mode 100644 sites/fedex/templates/location_detail.html create mode 100644 sites/fedex/templates/locations.html create mode 100644 sites/fedex/templates/login.html create mode 100644 sites/fedex/templates/pickup.html create mode 100644 sites/fedex/templates/rate_estimate.html create mode 100644 sites/fedex/templates/register.html create mode 100644 sites/fedex/templates/search.html create mode 100644 sites/fedex/templates/ship.html create mode 100644 sites/fedex/templates/ship_confirmation.html create mode 100644 sites/fedex/templates/ship_review.html create mode 100644 sites/fedex/templates/ship_service.html create mode 100644 sites/fedex/templates/support.html create mode 100644 sites/fedex/templates/support_article.html create mode 100644 sites/fedex/templates/track.html create mode 100644 sites/fedex/templates/track_results.html create mode 100644 sites/fedex/templates/tracking_detail.html diff --git a/.claude/skills/clone-website/SKILL.md b/.claude/skills/clone-website/SKILL.md index de4ba461..c1ba59c0 100644 --- a/.claude/skills/clone-website/SKILL.md +++ b/.claude/skills/clone-website/SKILL.md @@ -204,7 +204,7 @@ Then run the site once locally to produce `instance/.db`, copy it to ```bash ./scripts/build.sh webharbor:dev docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41014:40000-40014 webharbor:dev + -p 8201:8101 -p 41000-41015:40000-40015 webharbor:dev # your new site is on port 41000 + its index curl -so /dev/null -w "%{http_code}\n" http://localhost:41000NN/ @@ -228,7 +228,7 @@ After Phase 1, you should have: - `sites//static/` with real CSS/JS/icons (and images under HF assets) - `sites//instance_seed/.db` with seeded data - Site registered in `websyn_start.sh`, `control_server.py`, `Dockerfile` -- All 15 sites still return 200 on the alt-port container +- All 16 sites still return 200 on the alt-port container - Byte-identical reset passes ## Next step diff --git a/.claude/skills/evolve-env/SKILL.md b/.claude/skills/evolve-env/SKILL.md index 091465c4..da1605ba 100644 --- a/.claude/skills/evolve-env/SKILL.md +++ b/.claude/skills/evolve-env/SKILL.md @@ -65,7 +65,7 @@ through `BASE_DIR = os.path.dirname(os.path.abspath(__file__))`. After any DB-affecting change: ```bash -# 1. Stop test container (don't touch user's working container on :40000-40014) +# 1. Stop test container (don't touch user's working container on :40000-40015) docker stop wh-test 2>/dev/null || true # 2. Rebuild @@ -73,7 +73,7 @@ docker stop wh-test 2>/dev/null || true # 3. Run on alt ports docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41014:40000-40014 webharbor:dev + -p 8201:8101 -p 41000-41015:40000-40015 webharbor:dev # 4. Reset your site and confirm byte-identity curl -X POST http://localhost:8201/reset/ @@ -149,7 +149,7 @@ After Phase 3: - Every task in `tasks.jsonl` is hand-verified to work end-to-end - The mirror has no obvious leaks, broken forms, or empty pages - Byte-identical reset passes -- All 15 sites still return 200 +- All 16 sites still return 200 ## Next step diff --git a/.claude/skills/harden-env/SKILL.md b/.claude/skills/harden-env/SKILL.md index 8d2e1b63..199fd57c 100644 --- a/.claude/skills/harden-env/SKILL.md +++ b/.claude/skills/harden-env/SKILL.md @@ -130,7 +130,7 @@ Hardening changes seed data. After any DB change: ```bash ./scripts/build.sh webharbor:dev docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41014:40000-40014 webharbor:dev + -p 8201:8101 -p 41000-41015:40000-40015 webharbor:dev curl -X POST http://localhost:8201/reset/ docker exec wh-test md5sum \ /opt/WebSyn//instance/.db \ diff --git a/.claude/skills/review-env/SKILL.md b/.claude/skills/review-env/SKILL.md index 6542aeb1..d512194a 100644 --- a/.claude/skills/review-env/SKILL.md +++ b/.claude/skills/review-env/SKILL.md @@ -26,7 +26,7 @@ gh pr checkout ./scripts/fetch_assets.sh # pull the pinned HF revision ./scripts/build.sh webharbor:dev docker run -d --rm --name wh-review \ - -p 8201:8101 -p 41000-41014:40000-40014 webharbor:dev + -p 8201:8101 -p 41000-41015:40000-40015 webharbor:dev ``` Confirm the new/changed site is on the expected port (40000 + index). @@ -36,7 +36,7 @@ Confirm the new/changed site is on the expected port (40000 + index). Run the same Pre-PR checks the contributor was supposed to run. ```bash -# 1. all 15 sites return 200 +# 1. all 16 sites return 200 for p in $(seq 41000 41014); do curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ done @@ -181,7 +181,7 @@ Leave a structured comment on the PR: ## Review: ### Mechanical checks: PASS / FAIL -- [x] All 15 sites return 200 +- [x] All 16 sites return 200 - [x] Control plane healthy - [x] Byte-identical reset (md5 match) - [x] Parallel reset <10s diff --git a/.claude/skills/seed-database/SKILL.md b/.claude/skills/seed-database/SKILL.md index 8f0b61e8..5346d320 100644 --- a/.claude/skills/seed-database/SKILL.md +++ b/.claude/skills/seed-database/SKILL.md @@ -169,9 +169,9 @@ gh pr create ./scripts/check_assets.sh # every site has instance_seed/ ./scripts/build.sh webharbor:dev # docker build succeeds docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41014:40000-40014 webharbor:dev + -p 8201:8101 -p 41000-41015:40000-40015 webharbor:dev -# all 15 sites return 200 +# all 16 sites return 200 for p in $(seq 41000 41014); do curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ done @@ -182,7 +182,7 @@ time curl -X POST http://localhost:8201/reset-all # byte-identity for every site for s in allrecipes amazon apple arxiv bbc_news booking github \ google_flights google_map google_search huggingface \ - wolfram_alpha cambridge_dictionary coursera espn; do + wolfram_alpha cambridge_dictionary coursera espn fedex; do docker exec wh-test md5sum \ /opt/WebSyn/$s/instance/$s.db \ /opt/WebSyn/$s/instance_seed/$s.db diff --git a/.gitignore b/.gitignore index c2efc04c..e7899232 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/AGENTS.md b/AGENTS.md index 8b24944f..237ad850 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ A coding agent (Claude Code, Cursor, Aider, Codex, ...) is reading this. Read on ## What it is -15 Flask mirror websites (Amazon, GitHub, BBC News, ...) packaged into one Docker image, plus a control plane on `:8101` for resetting per-site state. Used as a deterministic offline environment for web-agent benchmarks. ~3 GB image. +16 Flask mirror websites (Amazon, GitHub, BBC News, ...) packaged into one Docker image, plus a control plane on `:8101` for resetting per-site state. Used as a deterministic offline environment for web-agent benchmarks. ~3 GB image. Two repos: - **code** (this one) — Flask apps, control plane, scripts. @@ -48,17 +48,17 @@ Inside the image, sites live at `/opt/WebSyn//`. The path predates the ren # fresh clone ./scripts/fetch_assets.sh # pulls assets from HF ./scripts/build.sh # docker build -t webharbor:dev . -docker run -d -p 8101:8101 -p 40000-40014:40000-40014 webharbor:dev +docker run -d -p 8101:8101 -p 40000-40015:40000-40015 webharbor:dev ``` Or use the published image directly: ```bash -docker run -d -p 8101:8101 -p 40000-40014:40000-40014 \ +docker run -d -p 8101:8101 -p 40000-40015:40000-40015 \ battalion7244/webharbor:latest ``` -Sites are on `40000`-`40014` in the order declared by `SITES=( ... )` in `websyn_start.sh`. Control plane: +Sites are on `40000`-`40015` in the order declared by `SITES=( ... )` in `websyn_start.sh`. Control plane: | Method | Path | Purpose | |--------|---------------------|-------------------------------------------| @@ -129,7 +129,7 @@ python3 -m py_compile sites//app.py # 3. run on alt ports (don't collide with anything you already have running) docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41014:40000-40014 webharbor:dev + -p 8201:8101 -p 41000-41015:40000-40015 webharbor:dev # 4. control plane healthy, all sites alive curl -s http://localhost:8201/health | python3 -m json.tool | head diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 17c6a060..cdc45125 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ git clone https://github.com//webharbor && cd webharbor ./scripts/fetch_assets.sh # pull current assets ./scripts/new_site.py mywebsite # OR edit an existing site ./scripts/build.sh && docker run -d --rm \ - -p 8101:8101 -p 40000-40014:40000-40014 webharbor:dev + -p 8101:8101 -p 40000-40015:40000-40015 webharbor:dev # iterate locally... ./scripts/extract_assets.sh ../webharbor-static-pr/ # split assets out @@ -186,4 +186,4 @@ Sites must not import from one another. The image launches each as an independen ### Don't hard-code secrets -Each site sets `SECRET_KEY` to a deterministic dev value. Acceptable for a benchmark image (resets blow away sessions anyway). If a contrib ever needs real secrets, raise it in an issue first. \ No newline at end of file +Each site sets `SECRET_KEY` to a deterministic dev value. Acceptable for a benchmark image (resets blow away sessions anyway). If a contrib ever needs real secrets, raise it in an issue first. diff --git a/Dockerfile b/Dockerfile index 991e5ab6..3c9a7d24 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # WebHarbor — slim, self-contained image. -# 15 Flask mirror sites + control plane on :8101. +# 16 Flask mirror sites + control plane on :8101. FROM python:3.12-slim-bookworm @@ -31,8 +31,8 @@ COPY sites/ /opt/WebSyn/ COPY websyn_start.sh /opt/websyn_start.sh COPY control_server.py /opt/control_server.py COPY site_runner.py /opt/site_runner.py -RUN chmod +x /opt/websyn_start.sh +RUN sed -i 's/\r$//' /opt/websyn_start.sh && chmod +x /opt/websyn_start.sh -EXPOSE 8101 40000-40014 +EXPOSE 8101 40000-40015 CMD ["/opt/websyn_start.sh"] diff --git a/README.md b/README.md index dce3f934..ebad0d47 100644 --- a/README.md +++ b/README.md @@ -36,17 +36,17 @@ WebHarbor takes a different approach. We leverage coding agent (e.g., Claude Cod - **Deep features unlocked** — carts, checkouts, accounts, all fully testable - **Evolving** — harder tasks drive richer mirrors; the environment grows with agents - **RL-ready** — sub-second database resets between rollouts -- **Community-driven** — 15 sites today, scaling to 100+ together +- **Community-driven** — 16 sites today, scaling to 100+ together ## 🚀 Quickstart One command to run all web environments: ```bash -docker run -p 8101:8101 -p 40000-40014:40000-40014 battalion7244/webharbor:latest +docker run -p 8101:8101 -p 40000-40015:40000-40015 battalion7244/webharbor:latest ``` -Then point your agent at `http://localhost:40000` through `http://localhost:40014` to explore 15 local mirrors of webvoyager sites: `Allrecipes, Amazon, Apple, ArXiv, BBC News, Booking, GitHub, Google Flights, Google Maps, Google Search, Hugging Face, Wolfram Alpha, Cambridge Dictionary, Coursera, and ESPN`. +Then point your agent at `http://localhost:40000` through `http://localhost:40015` to explore 16 local mirrors of webvoyager sites: `Allrecipes, Amazon, Apple, ArXiv, BBC News, Booking, GitHub, Google Flights, Google Maps, Google Search, Hugging Face, Wolfram Alpha, Cambridge Dictionary, Coursera, ESPN, and FedEx`. For sub-second reset between rollouts, expose the control plane and call `/reset/`: @@ -65,7 +65,7 @@ git clone https://github.com/aiming-lab/WebHarbor && cd WebHarbor ## 🤝 Contribute -We have built 15 high-quality mirrors covering the [WebVoyager](https://github.com/MinorJerry/WebVoyager) benchmark. The next goal is **100+ sites**, covering everything in [Online-Mind2Web](https://huggingface.co/datasets/osunlp/Online-Mind2Web). We are inviting the community to build this together. +We have built 16 high-quality mirrors covering the [WebVoyager](https://github.com/MinorJerry/WebVoyager) benchmark. The next goal is **100+ sites**, covering everything in [Online-Mind2Web](https://huggingface.co/datasets/osunlp/Online-Mind2Web). We are inviting the community to build this together. There are two ways to join the author list: @@ -111,4 +111,4 @@ WebHarbor is initiated by UNC-Chapel Hill and Microsoft, with contributions from url = {https://aiming-lab.github.io/webharbor.github.io}, note = {Project website.} } -``` \ No newline at end of file +``` diff --git a/control_server.py b/control_server.py index c255253c..e81b3602 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', 'fedex', ] BASE_PORT = 40000 WEBSYN_DIR = '/opt/WebSyn' diff --git a/sites/fedex/_health.py b/sites/fedex/_health.py new file mode 100644 index 00000000..ed14b5bb --- /dev/null +++ b/sites/fedex/_health.py @@ -0,0 +1,3 @@ +"""Per-site health probe (optional, called by control_server).""" +def health(): + return {"ok": True, "site": "fedex"} diff --git a/sites/fedex/app.py b/sites/fedex/app.py new file mode 100644 index 00000000..ce3f0dbd --- /dev/null +++ b/sites/fedex/app.py @@ -0,0 +1,873 @@ +"""FedEx local demo mirror for WebHarbor.""" +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +from flask import ( + Flask, + flash, + jsonify, + redirect, + render_template, + request, + session, + 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 = Path(__file__).resolve().parent +INSTANCE_DIR = BASE_DIR / "instance" +DB_PATH = INSTANCE_DIR / "fedex.db" +INSTANCE_DIR.mkdir(parents=True, exist_ok=True) + +app = Flask(__name__, instance_path=str(INSTANCE_DIR)) +app.config["SECRET_KEY"] = "webharbor-fedex-demo-key" +app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{DB_PATH}" +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + +db = SQLAlchemy(app) +login_manager = LoginManager(app) +login_manager.login_view = "login" +login_manager.login_message = "Sign in to use this local FedEx demo." +login_manager.login_message_category = "info" + +DEMO_PASSWORD = "TestPass123!" +STATE_LABELS = [ + "CA", "WA", "TX", "FL", "NY", "GA", "IL", "PA", + "MA", "CO", "AZ", "OR", "NC", "OH", "MI", "VA", "DC", +] + + +def dumps_json(value: Any) -> str: + return json.dumps(value, ensure_ascii=True) + + +def loads_json(value: str | None, default: Any) -> Any: + if not value: + return default + try: + return json.loads(value) + except json.JSONDecodeError: + return default + + +class User(db.Model, UserMixin): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(120), unique=True, nullable=False, index=True) + password_hash = db.Column(db.String(255), nullable=False) + first_name = db.Column(db.String(80), nullable=False) + last_name = db.Column(db.String(80), nullable=False) + phone = db.Column(db.String(40), default="") + company = db.Column(db.String(120), default="") + city = db.Column(db.String(100), default="") + state = db.Column(db.String(40), default="") + zip_code = db.Column(db.String(20), default="") + account_number = db.Column(db.String(30), unique=True, nullable=False) + preferred_location_slug = db.Column(db.String(120), default="") + invoicing_email = db.Column(db.String(120), default="") + + shipments = db.relationship("Shipment", backref="user", cascade="all, delete-orphan") + tracking_records = db.relationship("TrackingRecord", backref="user", cascade="all, delete-orphan") + invoices = db.relationship("Invoice", backref="user", cascade="all, delete-orphan") + claims = db.relationship("Claim", backref="user", cascade="all, delete-orphan") + pickup_requests = db.relationship("PickupRequest", backref="user", cascade="all, delete-orphan") + + @property + def full_name(self) -> str: + return f"{self.first_name} {self.last_name}" + + 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 ServiceLevel(db.Model): + __tablename__ = "service_levels" + + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(60), unique=True, nullable=False, index=True) + name = db.Column(db.String(120), nullable=False) + summary = db.Column(db.String(180), nullable=False) + speed_label = db.Column(db.String(120), nullable=False) + base_rate = db.Column(db.Float, nullable=False) + per_lb_rate = db.Column(db.Float, nullable=False) + zone_surcharge = db.Column(db.Float, nullable=False) + weekend_delivery = db.Column(db.Boolean, default=False) + money_back_label = db.Column(db.String(120), default="") + icon_path = db.Column(db.String(255), default="") + sort_order = db.Column(db.Integer, default=0) + + +class Location(db.Model): + __tablename__ = "locations" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(140), nullable=False) + slug = db.Column(db.String(140), unique=True, nullable=False, index=True) + city = db.Column(db.String(120), nullable=False) + state = db.Column(db.String(50), nullable=False) + address = db.Column(db.String(200), nullable=False) + phone = db.Column(db.String(30), default="") + location_type = db.Column(db.String(80), nullable=False) + hours = db.Column(db.String(120), default="") + services_json = db.Column(db.Text, default="[]") + amenities_json = db.Column(db.Text, default="[]") + image_path = db.Column(db.String(255), default="") + pickup_note = db.Column(db.String(180), default="") + + pickup_slots = db.relationship("PickupSlot", backref="location", cascade="all, delete-orphan") + pickup_requests = db.relationship("PickupRequest", backref="location", cascade="all, delete-orphan") + + @property + def services(self) -> list[str]: + return loads_json(self.services_json, []) + + @property + def amenities(self) -> list[str]: + return loads_json(self.amenities_json, []) + + +class TrackingRecord(db.Model): + __tablename__ = "tracking_records" + + id = db.Column(db.Integer, primary_key=True) + tracking_number = db.Column(db.String(30), unique=True, nullable=False, index=True) + shipment_id = db.Column(db.Integer, db.ForeignKey("shipments.id")) + user_id = db.Column(db.Integer, db.ForeignKey("users.id")) + recipient_name = db.Column(db.String(120), nullable=False) + sender_name = db.Column(db.String(120), nullable=False) + origin_city = db.Column(db.String(100), nullable=False) + origin_state = db.Column(db.String(40), nullable=False) + destination_city = db.Column(db.String(100), nullable=False) + destination_state = db.Column(db.String(40), nullable=False) + service_slug = db.Column(db.String(60), nullable=False) + package_type = db.Column(db.String(80), nullable=False) + weight_lb = db.Column(db.Float, nullable=False) + status_stage = db.Column(db.String(80), nullable=False) + status_summary = db.Column(db.String(180), nullable=False) + ship_date = db.Column(db.String(20), nullable=False) + estimated_delivery = db.Column(db.String(40), nullable=False) + latest_scan = db.Column(db.String(180), default="") + package_count = db.Column(db.Integer, default=1) + signature_required = db.Column(db.Boolean, default=False) + dropoff_location_slug = db.Column(db.String(140), default="") + + shipment = db.relationship("Shipment", backref="tracking_record", uselist=False) + events = db.relationship("TrackingEvent", backref="tracking_record", cascade="all, delete-orphan") + + +class TrackingEvent(db.Model): + __tablename__ = "tracking_events" + + id = db.Column(db.Integer, primary_key=True) + tracking_record_id = db.Column(db.Integer, db.ForeignKey("tracking_records.id"), nullable=False) + sequence = db.Column(db.Integer, nullable=False) + event_time = db.Column(db.String(30), nullable=False) + location_label = db.Column(db.String(160), nullable=False) + status_label = db.Column(db.String(120), nullable=False) + details = db.Column(db.String(220), nullable=False) + + +class Shipment(db.Model): + __tablename__ = "shipments" + + id = db.Column(db.Integer, primary_key=True) + shipment_code = db.Column(db.String(30), unique=True, nullable=False, index=True) + tracking_number = db.Column(db.String(30), unique=True, nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + service_slug = db.Column(db.String(60), nullable=False) + package_type = db.Column(db.String(80), nullable=False) + package_weight = db.Column(db.Float, nullable=False) + origin_city = db.Column(db.String(100), nullable=False) + origin_state = db.Column(db.String(40), nullable=False) + destination_city = db.Column(db.String(100), nullable=False) + destination_state = db.Column(db.String(40), nullable=False) + recipient_name = db.Column(db.String(120), nullable=False) + declared_value = db.Column(db.Float, default=0.0) + total_cost = db.Column(db.Float, nullable=False) + fulfillment_mode = db.Column(db.String(60), nullable=False) + pickup_location_slug = db.Column(db.String(140), default="") + pickup_window = db.Column(db.String(120), default="") + status = db.Column(db.String(80), nullable=False) + created_on = db.Column(db.String(20), nullable=False) + invoice_number = db.Column(db.String(30), default="") + reference_label = db.Column(db.String(160), default="") + + +class PickupSlot(db.Model): + __tablename__ = "pickup_slots" + + id = db.Column(db.Integer, primary_key=True) + location_id = db.Column(db.Integer, db.ForeignKey("locations.id"), nullable=False) + slot_date = db.Column(db.String(20), nullable=False) + time_window = db.Column(db.String(80), nullable=False) + remaining_capacity = db.Column(db.Integer, default=0) + cutoff_note = db.Column(db.String(120), default="") + + +class PickupRequest(db.Model): + __tablename__ = "pickup_requests" + + id = db.Column(db.Integer, primary_key=True) + confirmation_code = db.Column(db.String(30), unique=True, nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + location_id = db.Column(db.Integer, db.ForeignKey("locations.id"), nullable=False) + slot_date = db.Column(db.String(20), nullable=False) + time_window = db.Column(db.String(80), nullable=False) + package_count = db.Column(db.Integer, nullable=False) + status = db.Column(db.String(80), nullable=False) + created_on = db.Column(db.String(20), nullable=False) + + +class Invoice(db.Model): + __tablename__ = "invoices" + + id = db.Column(db.Integer, primary_key=True) + invoice_number = db.Column(db.String(30), unique=True, nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + shipment_id = db.Column(db.Integer, db.ForeignKey("shipments.id"), nullable=False) + billed_on = db.Column(db.String(20), nullable=False) + due_date = db.Column(db.String(20), nullable=False) + amount = db.Column(db.Float, nullable=False) + status = db.Column(db.String(80), nullable=False) + + shipment = db.relationship("Shipment") + + +class Claim(db.Model): + __tablename__ = "claims" + + id = db.Column(db.Integer, primary_key=True) + claim_number = db.Column(db.String(30), unique=True, nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + tracking_number = db.Column(db.String(30), nullable=False) + claim_type = db.Column(db.String(80), nullable=False) + amount = db.Column(db.Float, nullable=False) + status = db.Column(db.String(80), nullable=False) + opened_on = db.Column(db.String(20), nullable=False) + note = db.Column(db.Text, default="") + + +class SupportArticle(db.Model): + __tablename__ = "support_articles" + + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(180), nullable=False) + slug = db.Column(db.String(180), unique=True, nullable=False, index=True) + category = db.Column(db.String(120), nullable=False) + summary = db.Column(db.String(240), nullable=False) + body = db.Column(db.Text, nullable=False) + related_topics_json = db.Column(db.Text, default="[]") + + @property + def related_topics(self) -> list[str]: + return loads_json(self.related_topics_json, []) + + +class SearchLog(db.Model): + __tablename__ = "search_logs" + + id = db.Column(db.Integer, primary_key=True) + query = db.Column(db.String(180), nullable=False) + search_type = db.Column(db.String(80), nullable=False) + created_on = db.Column(db.String(20), nullable=False) + + +@login_manager.user_loader +def load_user(user_id: str) -> User | None: + return db.session.get(User, int(user_id)) + + +@app.template_filter("money") +def money(value: float) -> str: + return f"${value:,.2f}" + + +def ship_state() -> dict[str, Any]: + return session.setdefault("ship_state", {}) + + +def clear_ship_state() -> None: + session.pop("ship_state", None) + + +def support_query(query_text: str): + token_like = f"%{query_text}%" + return SupportArticle.query.filter( + or_( + SupportArticle.title.ilike(token_like), + SupportArticle.summary.ilike(token_like), + SupportArticle.body.ilike(token_like), + SupportArticle.category.ilike(token_like), + ) + ) + + +def normalize_tracking_inputs(raw: str) -> list[str]: + tokens = [piece.strip().upper() for piece in raw.replace("\n", ",").split(",")] + return [token for token in tokens if token] + + +def shipment_zone(origin_state: str, destination_state: str) -> int: + if origin_state == destination_state: + return 1 + west = {"CA", "WA", "OR", "AZ", "CO"} + east = {"NY", "MA", "PA", "FL", "GA", "NC", "VA"} + central = {"TX", "IL", "OH", "MI"} + if origin_state in west and destination_state in west: + return 2 + if origin_state in east and destination_state in east: + return 2 + if origin_state in central and destination_state in central: + return 2 + return 4 + + +def build_rate_quotes(origin_state: str, destination_state: str, weight_lb: float, package_type: str) -> list[dict[str, Any]]: + zone = shipment_zone(origin_state, destination_state) + package_fee = {"Envelope": 0, "Box": 8, "Tube": 10, "Freight pallet": 48}.get(package_type, 6) + quotes = [] + for service in ServiceLevel.query.order_by(ServiceLevel.sort_order.asc()).all(): + price = round(service.base_rate + service.per_lb_rate * weight_lb + service.zone_surcharge * zone + package_fee, 2) + quotes.append( + { + "service": service, + "price": price, + "zone": zone, + "commitment": service.speed_label, + } + ) + return quotes + + +def current_pickups() -> list[PickupRequest]: + if not current_user.is_authenticated: + return [] + return ( + PickupRequest.query.filter_by(user_id=current_user.id) + .order_by(PickupRequest.slot_date.asc(), PickupRequest.time_window.asc()) + .all() + ) + + +@app.context_processor +def inject_globals() -> dict[str, Any]: + locations = Location.query.order_by(Location.city.asc()).all() + services = ServiceLevel.query.order_by(ServiceLevel.sort_order.asc()).all() + account_links = [] + if current_user.is_authenticated: + account_links = current_pickups()[:2] + return { + "demo_password": DEMO_PASSWORD, + "state_labels": STATE_LABELS, + "nav_locations": locations[:5], + "nav_services": services, + "nav_pickups": account_links, + } + + +@app.route("/") +@app.route("/home") +def index(): + featured_tracking = TrackingRecord.query.order_by(TrackingRecord.ship_date.desc()).limit(4).all() + locations = Location.query.order_by(Location.city.asc()).limit(4).all() + services = ServiceLevel.query.order_by(ServiceLevel.sort_order.asc()).all() + articles = SupportArticle.query.order_by(SupportArticle.category.asc(), SupportArticle.title.asc()).limit(6).all() + return render_template( + "index.html", + featured_tracking=featured_tracking, + locations=locations, + services=services, + articles=articles, + ) + + +@app.route("/track", methods=["GET", "POST"]) +def track(): + if request.method == "POST": + numbers = request.form.get("tracking_numbers", "").strip() + return redirect(url_for("track_results", numbers=numbers)) + return render_template("track.html") + + +@app.route("/track/results") +def track_results(): + numbers = normalize_tracking_inputs(request.args.get("numbers", "")) + records = [] + if numbers: + records = TrackingRecord.query.filter(TrackingRecord.tracking_number.in_(numbers)).all() + records.sort(key=lambda record: numbers.index(record.tracking_number)) + return render_template("track_results.html", numbers=numbers, records=records) + + +@app.route("/tracking/") +def tracking_detail(tracking_number: str): + record = TrackingRecord.query.filter_by(tracking_number=tracking_number.upper()).first_or_404() + service = ServiceLevel.query.filter_by(slug=record.service_slug).first() + events = TrackingEvent.query.filter_by(tracking_record_id=record.id).order_by(TrackingEvent.sequence.asc()).all() + return render_template("tracking_detail.html", record=record, service=service, events=events) + + +@app.route("/rate-estimate", methods=["GET", "POST"]) +def rate_estimate(): + quotes = None + form_state = { + "origin_state": request.values.get("origin_state", "CA"), + "destination_state": request.values.get("destination_state", "TX"), + "weight_lb": request.values.get("weight_lb", "8"), + "package_type": request.values.get("package_type", "Box"), + } + if request.method == "POST": + weight = float(form_state["weight_lb"]) + quotes = build_rate_quotes( + form_state["origin_state"], + form_state["destination_state"], + weight, + form_state["package_type"], + ) + return render_template("rate_estimate.html", quotes=quotes, form_state=form_state) + + +@app.route("/ship", methods=["GET", "POST"]) +@login_required +def ship(): + state = ship_state() + if request.method == "POST": + state["recipient_name"] = request.form.get("recipient_name", "").strip() + state["origin_city"] = request.form.get("origin_city", current_user.city).strip() + state["origin_state"] = request.form.get("origin_state", current_user.state).strip() + state["destination_city"] = request.form.get("destination_city", "").strip() + state["destination_state"] = request.form.get("destination_state", "").strip() + state["package_type"] = request.form.get("package_type", "Box") + state["weight_lb"] = request.form.get("weight_lb", "8").strip() + state["declared_value"] = request.form.get("declared_value", "150").strip() + state["pickup_mode"] = request.form.get("pickup_mode", "dropoff") + session.modified = True + return redirect(url_for("ship_service")) + return render_template("ship.html", state=state) + + +@app.route("/ship/service", methods=["GET", "POST"]) +@login_required +def ship_service(): + state = ship_state() + if not state.get("recipient_name"): + flash("Start with shipment details first.", "warning") + return redirect(url_for("ship")) + quotes = build_rate_quotes( + state.get("origin_state", current_user.state or "CA"), + state.get("destination_state", "TX"), + float(state.get("weight_lb", "8")), + state.get("package_type", "Box"), + ) + if request.method == "POST": + state["service_slug"] = request.form.get("service_slug", "priority-overnight") + session.modified = True + return redirect(url_for("ship_review")) + return render_template("ship_service.html", state=state, quotes=quotes) + + +@app.route("/ship/review", methods=["GET", "POST"]) +@login_required +def ship_review(): + state = ship_state() + if not state.get("service_slug"): + flash("Choose a service level first.", "warning") + return redirect(url_for("ship_service")) + service = ServiceLevel.query.filter_by(slug=state["service_slug"]).first_or_404() + quotes = build_rate_quotes( + state.get("origin_state", current_user.state or "CA"), + state.get("destination_state", "TX"), + float(state.get("weight_lb", "8")), + state.get("package_type", "Box"), + ) + selected_quote = next((quote for quote in quotes if quote["service"].slug == service.slug), None) + if request.method == "POST": + next_index = (db.session.query(db.func.count(Shipment.id)).scalar() or 0) + 1 + shipment_code = f"SH-{260000 + next_index}" + tracking_number = f"FDX{260000000 + next_index:09d}" + invoice_number = f"INV-{260000 + next_index}" + total_cost = selected_quote["price"] if selected_quote else 0.0 + shipment = Shipment( + shipment_code=shipment_code, + tracking_number=tracking_number, + user_id=current_user.id, + service_slug=service.slug, + package_type=state.get("package_type", "Box"), + package_weight=float(state.get("weight_lb", "8")), + origin_city=state.get("origin_city", current_user.city), + origin_state=state.get("origin_state", current_user.state), + destination_city=state.get("destination_city", "Dallas"), + destination_state=state.get("destination_state", "TX"), + recipient_name=state.get("recipient_name", "Demo Recipient"), + declared_value=float(state.get("declared_value", "150")), + total_cost=total_cost, + fulfillment_mode=state.get("pickup_mode", "dropoff"), + pickup_location_slug=state.get("pickup_location_slug", current_user.preferred_location_slug), + pickup_window=state.get("pickup_window", ""), + status="Label created", + created_on="2026-06-04", + invoice_number=invoice_number, + reference_label="Local demo shipment", + ) + db.session.add(shipment) + db.session.flush() + tracking = TrackingRecord( + tracking_number=tracking_number, + shipment_id=shipment.id, + user_id=current_user.id, + recipient_name=shipment.recipient_name, + sender_name=current_user.full_name, + origin_city=shipment.origin_city, + origin_state=shipment.origin_state, + destination_city=shipment.destination_city, + destination_state=shipment.destination_state, + service_slug=shipment.service_slug, + package_type=shipment.package_type, + weight_lb=shipment.package_weight, + status_stage="Label created", + status_summary="Shipment information sent to local FedEx demo systems.", + ship_date="2026-06-04", + estimated_delivery="2026-06-06 by 8:00 PM", + latest_scan="Label created", + package_count=1, + signature_required=False, + dropoff_location_slug=shipment.pickup_location_slug, + ) + db.session.add(tracking) + db.session.flush() + db.session.add_all( + [ + TrackingEvent( + tracking_record_id=tracking.id, + sequence=1, + event_time="2026-06-04 09:00", + location_label=f"{shipment.origin_city}, {shipment.origin_state}", + status_label="Label created", + details="Shipment information sent to local demo systems.", + ), + TrackingEvent( + tracking_record_id=tracking.id, + sequence=2, + event_time="2026-06-04 11:30", + location_label=f"{shipment.origin_city}, {shipment.origin_state}", + status_label="Picked up", + details="Package picked up in the demo handoff flow.", + ), + ] + ) + invoice = Invoice( + invoice_number=invoice_number, + user_id=current_user.id, + shipment_id=shipment.id, + billed_on="2026-06-04", + due_date="2026-06-18", + amount=total_cost, + status="Open", + ) + db.session.add(invoice) + db.session.commit() + session["shipment_confirmation_code"] = shipment_code + clear_ship_state() + session["shipment_confirmation_code"] = shipment_code + return redirect(url_for("ship_confirmation")) + return render_template( + "ship_review.html", + state=state, + service=service, + selected_quote=selected_quote, + ) + + +@app.route("/ship/confirmation") +@login_required +def ship_confirmation(): + shipment_code = session.get("shipment_confirmation_code") + if not shipment_code: + flash("No recent mock shipment is available.", "warning") + return redirect(url_for("account_shipments")) + shipment = Shipment.query.filter_by(shipment_code=shipment_code, user_id=current_user.id).first_or_404() + return render_template("ship_confirmation.html", shipment=shipment) + + +@app.route("/pickup", methods=["GET", "POST"]) +@login_required +def pickup(): + locations = Location.query.order_by(Location.city.asc()).all() + selected_slug = request.values.get("location_slug", current_user.preferred_location_slug or locations[0].slug) + location = Location.query.filter_by(slug=selected_slug).first() + slots = [] + if location: + slots = PickupSlot.query.filter_by(location_id=location.id).order_by(PickupSlot.slot_date.asc()).all() + if request.method == "POST": + request_id = (db.session.query(db.func.count(PickupRequest.id)).scalar() or 0) + 1 + slot = PickupSlot.query.filter_by(id=int(request.form.get("pickup_slot_id", "0"))).first_or_404() + pickup_request = PickupRequest( + confirmation_code=f"PU-{2600 + request_id:04d}", + user_id=current_user.id, + location_id=slot.location_id, + slot_date=slot.slot_date, + time_window=slot.time_window, + package_count=max(int(request.form.get("package_count", "1")), 1), + status="Scheduled", + created_on="2026-06-04", + ) + db.session.add(pickup_request) + db.session.commit() + flash(f"Pickup {pickup_request.confirmation_code} scheduled in this local demo.", "success") + return redirect(url_for("account")) + return render_template("pickup.html", locations=locations, selected_slug=selected_slug, slots=slots) + + +@app.route("/locations") +def locations(): + query = request.args.get("q", "").strip() + locations_query = Location.query + if query: + token_like = f"%{query}%" + locations_query = locations_query.filter( + or_( + Location.name.ilike(token_like), + Location.city.ilike(token_like), + Location.state.ilike(token_like), + Location.location_type.ilike(token_like), + ) + ) + locations_list = locations_query.order_by(Location.state.asc(), Location.city.asc()).all() + return render_template("locations.html", locations=locations_list, query=query) + + +@app.route("/locations/") +def location_detail(location_slug: str): + location = Location.query.filter_by(slug=location_slug).first_or_404() + slots = PickupSlot.query.filter_by(location_id=location.id).order_by(PickupSlot.slot_date.asc()).all() + return render_template("location_detail.html", location=location, slots=slots) + + +@app.route("/support") +def support(): + query = request.args.get("q", "").strip() + articles_query = SupportArticle.query + if query: + articles_query = support_query(query) + articles = articles_query.order_by(SupportArticle.category.asc(), SupportArticle.title.asc()).all() + return render_template("support.html", articles=articles, query=query) + + +@app.route("/support/") +def support_article(article_slug: str): + article = SupportArticle.query.filter_by(slug=article_slug).first_or_404() + related = ( + SupportArticle.query.filter( + SupportArticle.category == article.category, + SupportArticle.slug != article.slug, + ) + .order_by(SupportArticle.title.asc()) + .limit(4) + .all() + ) + return render_template("support_article.html", article=article, related=related) + + +@app.route("/search") +def search(): + query = request.args.get("q", "").strip() + articles = [] + locations = [] + tracking_matches = [] + if query: + db.session.add(SearchLog(query=query, search_type="global", created_on="2026-06-04")) + db.session.commit() + token_like = f"%{query}%" + articles = support_query(query).limit(8).all() + locations = Location.query.filter( + or_(Location.city.ilike(token_like), Location.state.ilike(token_like), Location.name.ilike(token_like)) + ).limit(8).all() + tracking_matches = TrackingRecord.query.filter( + or_( + TrackingRecord.tracking_number.ilike(token_like), + TrackingRecord.recipient_name.ilike(token_like), + TrackingRecord.status_summary.ilike(token_like), + ) + ).limit(8).all() + return render_template( + "search.html", + query=query, + articles=articles, + locations=locations, + tracking_matches=tracking_matches, + ) + + +@app.route("/claims") +@login_required +def claims(): + user_claims = Claim.query.filter_by(user_id=current_user.id).order_by(Claim.opened_on.desc()).all() + return render_template("claims.html", claims=user_claims) + + +@app.route("/invoices") +@login_required +def invoices(): + user_invoices = Invoice.query.filter_by(user_id=current_user.id).order_by(Invoice.billed_on.desc()).all() + return render_template("invoices.html", invoices=user_invoices) + + +@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", "").strip().lower() + 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 to the local FedEx demo.", "success") + return redirect(url_for("account")) + flash("That demo sign-in did not match any seeded account.", "danger") + return render_template("login.html") + + +@app.route("/register", methods=["GET", "POST"]) +def register(): + if current_user.is_authenticated: + return redirect(url_for("account")) + if request.method == "POST": + email = request.form.get("email", "").strip().lower() + if User.query.filter_by(email=email).first(): + flash("That email already exists in the local demo.", "warning") + return redirect(url_for("login")) + next_index = (db.session.query(db.func.count(User.id)).scalar() or 0) + 1 + user = User( + email=email, + first_name=request.form.get("first_name", "Demo").strip() or "Demo", + last_name=request.form.get("last_name", "User").strip() or "User", + phone=request.form.get("phone", "").strip(), + company=request.form.get("company", "").strip(), + city=request.form.get("city", "").strip(), + state=request.form.get("state", "").strip(), + zip_code=request.form.get("zip_code", "").strip(), + account_number=f"5100{next_index:05d}", + preferred_location_slug=request.form.get("preferred_location_slug", "").strip(), + invoicing_email=email, + ) + user.set_password(request.form.get("password", DEMO_PASSWORD)) + db.session.add(user) + db.session.commit() + login_user(user) + flash("Created a new local FedEx demo account.", "success") + return redirect(url_for("account")) + return render_template("register.html") + + +@app.route("/logout") +@login_required +def logout(): + logout_user() + flash("Signed out of the FedEx demo.", "info") + return redirect(url_for("index")) + + +@app.route("/account") +@login_required +def account(): + shipments = Shipment.query.filter_by(user_id=current_user.id).order_by(Shipment.created_on.desc()).limit(4).all() + invoices = Invoice.query.filter_by(user_id=current_user.id).order_by(Invoice.billed_on.desc()).limit(4).all() + claims = Claim.query.filter_by(user_id=current_user.id).order_by(Claim.opened_on.desc()).limit(4).all() + pickups = current_pickups()[:3] + return render_template( + "account.html", + shipments=shipments, + invoices=invoices, + claims=claims, + pickups=pickups, + ) + + +@app.route("/account/edit", methods=["GET", "POST"]) +@login_required +def account_edit(): + if request.method == "POST": + current_user.first_name = request.form.get("first_name", current_user.first_name).strip() + current_user.last_name = request.form.get("last_name", current_user.last_name).strip() + current_user.phone = request.form.get("phone", current_user.phone).strip() + current_user.company = request.form.get("company", current_user.company).strip() + current_user.city = request.form.get("city", current_user.city).strip() + current_user.state = request.form.get("state", current_user.state).strip() + current_user.zip_code = request.form.get("zip_code", current_user.zip_code).strip() + current_user.preferred_location_slug = request.form.get( + "preferred_location_slug", current_user.preferred_location_slug + ).strip() + current_user.invoicing_email = request.form.get("invoicing_email", current_user.invoicing_email).strip() + db.session.commit() + flash("Saved your local FedEx profile updates.", "success") + return redirect(url_for("account")) + return render_template("account_edit.html") + + +@app.route("/account/shipments") +@login_required +def account_shipments(): + shipments = Shipment.query.filter_by(user_id=current_user.id).order_by(Shipment.created_on.desc()).all() + return render_template("account_shipments.html", shipments=shipments) + + +@app.route("/account/invoices") +@login_required +def account_invoices(): + return redirect(url_for("invoices")) + + +@app.route("/account/claims") +@login_required +def account_claims(): + return redirect(url_for("claims")) + + +@app.route("/_health") +def health(): + return jsonify( + { + "ok": True, + "site": "fedex", + "tracking_records": TrackingRecord.query.count(), + "shipments": Shipment.query.count(), + "locations": Location.query.count(), + } + ) + + +def bootstrap_site() -> None: + from seed_data import seed_benchmark_users, seed_database + + with app.app_context(): + db.create_all() + seed_database() + seed_benchmark_users() + + +if os.environ.get("WEBSYN_SKIP_BOOTSTRAP") != "1": + bootstrap_site() + + +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/fedex/requirements.txt b/sites/fedex/requirements.txt new file mode 100644 index 00000000..f197e20d --- /dev/null +++ b/sites/fedex/requirements.txt @@ -0,0 +1,5 @@ +Flask==3.1.0 +Flask-Login==0.6.3 +Flask-SQLAlchemy==3.1.1 +SQLAlchemy==2.0.36 +Werkzeug==3.1.3 diff --git a/sites/fedex/seed_data.py b/sites/fedex/seed_data.py new file mode 100644 index 00000000..85d0b00e --- /dev/null +++ b/sites/fedex/seed_data.py @@ -0,0 +1,688 @@ +#!/usr/bin/env python3 +"""Deterministic seed data and lightweight local assets for the FedEx mirror.""" +from __future__ import annotations + +import hashlib +import os +import shutil +from pathlib import Path + +os.environ.setdefault("WEBSYN_SKIP_BOOTSTRAP", "1") + +from app import ( # noqa: E402 + BASE_DIR, + DB_PATH, + DEMO_PASSWORD, + INSTANCE_DIR, + Claim, + Invoice, + Location, + PickupRequest, + PickupSlot, + SearchLog, + ServiceLevel, + Shipment, + SupportArticle, + TrackingEvent, + TrackingRecord, + User, + app, + db, + dumps_json, +) + +INSTANCE_SEED_DIR = BASE_DIR / "instance_seed" +INSTANCE_SEED_DB = INSTANCE_SEED_DIR / "fedex.db" +STATIC_DIR = BASE_DIR / "static" +IMAGE_DIR = STATIC_DIR / "images" +EXTERNAL_CACHE_DIR = STATIC_DIR / "external_cache" + +BENCHMARK_USERS = [ + { + "email": "alice.j@test.com", + "first_name": "Alice", + "last_name": "Johnson", + "phone": "206-555-0140", + "company": "North Ridge Studio", + "city": "Seattle", + "state": "WA", + "zip_code": "98101", + "account_number": "510000001", + "preferred_location_slug": "seattle-downtown-wa", + "invoicing_email": "billing+alice@test.com", + }, + { + "email": "bob.c@test.com", + "first_name": "Bob", + "last_name": "Carter", + "phone": "404-555-0148", + "company": "Peachtree Parts Co.", + "city": "Atlanta", + "state": "GA", + "zip_code": "30303", + "account_number": "510000002", + "preferred_location_slug": "atlanta-midtown-ga", + "invoicing_email": "billing+bob@test.com", + }, + { + "email": "carol.d@test.com", + "first_name": "Carol", + "last_name": "Diaz", + "phone": "305-555-0116", + "company": "South Bay Labs", + "city": "Miami", + "state": "FL", + "zip_code": "33131", + "account_number": "510000003", + "preferred_location_slug": "miami-brickell-fl", + "invoicing_email": "billing+carol@test.com", + }, + { + "email": "david.k@test.com", + "first_name": "David", + "last_name": "Kim", + "phone": "312-555-0157", + "company": "Lakefront Supply", + "city": "Chicago", + "state": "IL", + "zip_code": "60601", + "account_number": "510000004", + "preferred_location_slug": "chicago-loop-il", + "invoicing_email": "billing+david@test.com", + }, +] + +SERVICE_LEVELS = [ + { + "slug": "priority-overnight", + "name": "FedEx Priority Overnight", + "summary": "Early next-business-day delivery for urgent demo parcels.", + "speed_label": "Next business day by 10:30 AM", + "base_rate": 48.0, + "per_lb_rate": 1.95, + "zone_surcharge": 3.4, + "weekend_delivery": True, + "money_back_label": "Money-back demo guarantee", + "sort_order": 1, + }, + { + "slug": "standard-overnight", + "name": "FedEx Standard Overnight", + "summary": "Reliable overnight delivery with evening commitment.", + "speed_label": "Next business day by 8:00 PM", + "base_rate": 34.0, + "per_lb_rate": 1.65, + "zone_surcharge": 2.8, + "weekend_delivery": False, + "money_back_label": "Priority hold-at-location available", + "sort_order": 2, + }, + { + "slug": "fedex-2day", + "name": "FedEx 2Day", + "summary": "Two-day shipping for mid-priority demo deliveries.", + "speed_label": "2 business days by 4:30 PM", + "base_rate": 22.0, + "per_lb_rate": 1.3, + "zone_surcharge": 2.2, + "weekend_delivery": False, + "money_back_label": "Saturday delivery on select lanes", + "sort_order": 3, + }, + { + "slug": "ground-home", + "name": "FedEx Ground Home Delivery", + "summary": "Residential ground shipping with delivery manager style updates.", + "speed_label": "1 to 5 business days", + "base_rate": 15.0, + "per_lb_rate": 0.95, + "zone_surcharge": 1.7, + "weekend_delivery": True, + "money_back_label": "Pickup and drop-off routing supported", + "sort_order": 4, + }, + { + "slug": "freight-economy", + "name": "FedEx Freight Economy", + "summary": "Less-than-truckload demo freight with pallet support.", + "speed_label": "3 to 6 business days", + "base_rate": 96.0, + "per_lb_rate": 2.45, + "zone_surcharge": 4.5, + "weekend_delivery": False, + "money_back_label": "Liftgate service optional", + "sort_order": 5, + }, +] + +LOCATION_DATA = [ + ("Seattle Downtown Ship Center", "seattle-downtown-wa", "Seattle", "WA", "1401 4th Ave", "206-555-0140", "Ship Center", "7:00 AM - 9:00 PM", ["Drop off", "Hold at location", "Packing help"], ["Parking garage", "Print station"], "Late pickup until 7:30 PM"), + ("Bellevue Office Print & Ship", "bellevue-office-wa", "Bellevue", "WA", "500 Bellevue Way NE", "425-555-0132", "Office Print & Ship", "8:00 AM - 8:00 PM", ["Drop off", "Passport photo", "Returns"], ["Copy center", "Metered street parking"], "Small parcel cutoff 6:45 PM"), + ("Portland River District Ship Center", "portland-river-or", "Portland", "OR", "412 NW Glisan St", "503-555-0150", "Ship Center", "7:30 AM - 8:30 PM", ["Drop off", "Pack and ship", "Ground pickup"], ["Bike racks", "Self-service kiosk"], "Ground trailer closes 6:15 PM"), + ("San Francisco Market Hub", "san-francisco-market-ca", "San Francisco", "CA", "210 Market St", "415-555-0161", "Ship Center", "7:00 AM - 8:00 PM", ["Express drop off", "Hold at location", "Dangerous goods desk"], ["Lobby lockers", "Wheelchair access"], "Priority Overnight cutoff 6:00 PM"), + ("Los Angeles Arts District Office", "los-angeles-arts-ca", "Los Angeles", "CA", "777 Alameda St", "213-555-0172", "Office Print & Ship", "8:00 AM - 9:00 PM", ["Print & ship", "Returns", "Package hold"], ["On-site parking", "Photo services"], "Same-day courier handoff 5:30 PM"), + ("Phoenix Camelback Ground Center", "phoenix-camelback-az", "Phoenix", "AZ", "1900 E Camelback Rd", "602-555-0144", "Ship Center", "7:00 AM - 8:00 PM", ["Ground drop off", "Packaging", "Dry ice acceptance"], ["Drive-up bays", "Truck access"], "Freight dock opens at 9:00 AM"), + ("Denver Union Station Ship Center", "denver-union-co", "Denver", "CO", "1701 Wynkoop St", "303-555-0180", "Ship Center", "7:00 AM - 8:30 PM", ["Express drop off", "Hold at location", "Saturday pickup"], ["Transit access", "Bike storage"], "Weekend handoff by noon"), + ("Dallas Arts District Hub", "dallas-arts-tx", "Dallas", "TX", "2200 Ross Ave", "214-555-0192", "Ship Center", "7:00 AM - 9:00 PM", ["Drop off", "Ground pickup", "Freight consult"], ["Loading zone", "Label printer"], "Freight cutoff 4:45 PM"), + ("Houston Midtown Ship Center", "houston-midtown-tx", "Houston", "TX", "3040 Main St", "713-555-0184", "Ship Center", "7:30 AM - 8:30 PM", ["Express drop off", "Hold at location", "Returns"], ["Covered parking", "Lobby lockers"], "Medical cold-pack prep until 5:00 PM"), + ("Chicago Loop Ship Center", "chicago-loop-il", "Chicago", "IL", "120 W Jackson Blvd", "312-555-0157", "Ship Center", "7:00 AM - 9:00 PM", ["Drop off", "Pack and ship", "Passport photo"], ["Elevator access", "Copy center"], "Priority cutoff 6:30 PM"), + ("Atlanta Midtown Ship Center", "atlanta-midtown-ga", "Atlanta", "GA", "880 Peachtree St NE", "404-555-0148", "Ship Center", "7:00 AM - 8:30 PM", ["Express drop off", "Pickup counter", "Ground pickup"], ["Garage parking", "Self-service kiosk"], "Saturday pickup until 11:30 AM"), + ("Miami Brickell Print & Ship", "miami-brickell-fl", "Miami", "FL", "1200 Brickell Ave", "305-555-0116", "Office Print & Ship", "8:00 AM - 8:00 PM", ["Print & ship", "Returns", "Hold at location"], ["Lobby seating", "Photo services"], "International docs accepted until 5:45 PM"), + ("Charlotte South End Ship Center", "charlotte-southend-nc", "Charlotte", "NC", "1425 S Tryon St", "704-555-0168", "Ship Center", "7:00 AM - 8:30 PM", ["Drop off", "Packing help", "Ground pickup"], ["Free parking", "Large parcel scale"], "Ground dispatch at 6:00 PM"), + ("Washington Navy Yard Office", "washington-navy-yard-dc", "Washington", "DC", "50 M St SE", "202-555-0145", "Office Print & Ship", "8:00 AM - 8:00 PM", ["Express drop off", "Returns", "Shipping supplies"], ["Metro access", "Elevator access"], "No freight service"), + ("Boston Back Bay Ship Center", "boston-back-bay-ma", "Boston", "MA", "699 Boylston St", "617-555-0136", "Ship Center", "7:00 AM - 8:00 PM", ["Drop off", "Hold at location", "Saturday pickup"], ["Bike racks", "Copy center"], "Express cutoff 6:15 PM"), +] + +SUPPORT_ARTICLES = [ + ("Track by multiple numbers", "track-multiple-numbers", "Tracking", "Paste several tracking numbers separated by commas or line breaks to monitor multi-piece demo shipments in one view."), + ("What does shipment exception mean?", "shipment-exception-status", "Tracking", "Review common synthetic exception states such as weather delay, address review, or consignee unavailable."), + ("How local pickup scheduling works", "demo-pickup-scheduling", "Pickup", "See how this local mirror books a pickup window without creating a real courier request."), + ("Rate estimate zones explained", "rate-estimate-zones", "Shipping rates", "Understand how the demo calculates zone surcharges between origin and destination states."), + ("Hold at location workflow", "hold-at-location", "Locations", "Learn when a demo package can stay at a staffed hold location for later collection."), + ("Freight pallet requirements", "freight-pallet-guidance", "Freight", "Box dimensions, pallet notes, and liftgate reminders for local freight quotes."), + ("Invoice due dates in the demo", "invoice-due-dates", "Billing", "How synthetic invoice due dates and open balances appear in seeded account history."), + ("File a missing package claim", "missing-package-claim", "Claims", "What claim stages mean inside this deterministic claims dashboard."), + ("Proof of delivery and signatures", "proof-of-delivery", "Tracking", "Understand signature-required tracking events and delivery handoff notes."), + ("Drop-off locations and amenities", "dropoff-location-amenities", "Locations", "Search nearby locations by city, services, and lobby amenities."), + ("Ground vs overnight services", "ground-vs-overnight", "Shipping rates", "Compare the speed, weekend handling, and rate structure for seeded service levels."), + ("International paperwork in the demo", "international-paperwork-demo", "Shipping", "Synthetic documentation reminders for customs-style paperwork flows."), + ("Packaging supplies guide", "packaging-supplies-guide", "Packaging", "Recommended envelope, box, tube, and pallet choices for different product categories."), + ("Weekend delivery commitments", "weekend-delivery-commitments", "Shipping rates", "See which service levels expose weekend delivery copy in the local mirror."), + ("Claims status timeline", "claims-status-timeline", "Claims", "Interpret submitted, review, info requested, and closed claim milestones."), + ("Account invoices export", "account-invoices-export", "Billing", "Where invoicing email and historical billing records appear for seeded users."), + ("Address correction hold", "address-correction-hold", "Tracking", "Why a package may pause for address clarification in the demo timeline."), + ("Weather delay guidance", "weather-delay-guidance", "Tracking", "Suggested next steps when the seeded timeline includes weather disruptions."), +] + +PALETTE = [ + ("#4d148c", "#ff6600", "#f4f0fb"), + ("#5b1aa3", "#ff8f1f", "#f7f2ff"), + ("#472f92", "#f8c471", "#f5f4fb"), + ("#3c1053", "#ff7f32", "#fff4eb"), +] + + +def ensure_dirs() -> None: + for path in [INSTANCE_DIR, INSTANCE_SEED_DIR, IMAGE_DIR, EXTERNAL_CACHE_DIR]: + path.mkdir(parents=True, exist_ok=True) + + +def write_svg(path: Path, title: str, accent: str, secondary: str, background: str, lines: list[str]) -> None: + bars = "".join( + f'' + for i in range(len(lines)) + ) + labels = "".join( + f'{line}' + for i, line in enumerate(lines) + ) + path.write_text( + f""" + + + + + + + + + + + + {bars} + {labels} + {title} + +""", + encoding="utf-8", + ) + + +def ensure_visual_assets() -> None: + write_svg( + IMAGE_DIR / "hero-tracking.svg", + "FedEx demo tracker", + "#4d148c", + "#ff6600", + "#f8f4ff", + ["Track package", "Estimate rates", "Schedule pickup"], + ) + for index, service in enumerate(SERVICE_LEVELS): + accent, secondary, background = PALETTE[index % len(PALETTE)] + write_svg( + IMAGE_DIR / f"service-{service['slug']}.svg", + service["name"].replace("FedEx ", ""), + accent, + secondary, + background, + [service["speed_label"], service["summary"], service["money_back_label"]], + ) + for index, location in enumerate(LOCATION_DATA): + accent, secondary, background = PALETTE[index % len(PALETTE)] + write_svg( + IMAGE_DIR / f"location-{location[1]}.svg", + location[2], + accent, + secondary, + background, + [location[0], location[5], location[6]], + ) + + +def service_price(service_slug: str, weight_lb: float, lane_index: int) -> float: + service = next(item for item in SERVICE_LEVELS if item["slug"] == service_slug) + zone = 1 + (lane_index % 4) + package_fee = [0.0, 6.0, 9.5, 18.0, 52.0][lane_index % 5] + return round(service["base_rate"] + service["per_lb_rate"] * weight_lb + service["zone_surcharge"] * zone + package_fee, 2) + + +def tracking_timeline(record_id: int, origin_city: str, origin_state: str, destination_city: str, destination_state: str, stage: str) -> list[dict[str, str]]: + stages = [ + { + "event_time": "2026-06-01 08:15", + "location_label": f"{origin_city}, {origin_state}", + "status_label": "Label created", + "details": "Shipment information sent to the local demo network.", + }, + { + "event_time": "2026-06-01 13:40", + "location_label": f"{origin_city}, {origin_state}", + "status_label": "Picked up", + "details": "Driver completed the scheduled pickup in this local benchmark flow.", + }, + { + "event_time": "2026-06-02 05:20", + "location_label": "Memphis, TN", + "status_label": "In transit", + "details": "Package reached the national sort hub used by the demo timeline.", + }, + { + "event_time": "2026-06-02 18:05", + "location_label": f"{destination_city}, {destination_state}", + "status_label": "At local facility", + "details": "Shipment arrived at the destination station for final handling.", + }, + ] + if stage == "Delivered": + stages.append( + { + "event_time": "2026-06-03 11:02", + "location_label": f"{destination_city}, {destination_state}", + "status_label": "Delivered", + "details": "Delivered to front desk in the local demo environment.", + } + ) + elif stage == "Out for delivery": + stages.append( + { + "event_time": "2026-06-03 08:10", + "location_label": f"{destination_city}, {destination_state}", + "status_label": "Out for delivery", + "details": "Courier is en route for final delivery today.", + } + ) + elif stage == "Weather delay": + stages.append( + { + "event_time": "2026-06-03 07:35", + "location_label": f"{destination_city}, {destination_state}", + "status_label": "Shipment exception", + "details": "Weather conditions paused the last-mile handoff in this seeded timeline.", + } + ) + elif stage == "Address review": + stages.append( + { + "event_time": "2026-06-03 09:25", + "location_label": f"{destination_city}, {destination_state}", + "status_label": "Operational delay", + "details": "Address details are under review before a new delivery attempt.", + } + ) + else: + stages.append( + { + "event_time": "2026-06-03 09:00", + "location_label": f"{destination_city}, {destination_state}", + "status_label": "In transit", + "details": "Package is on the final transfer leg toward destination.", + } + ) + return [ + {"tracking_record_id": record_id, "sequence": index + 1, **event} + for index, event in enumerate(stages) + ] + + +def stage_copy(stage: str) -> tuple[str, str, str]: + mapping = { + "Delivered": ( + "Delivered", + "Delivered to the recipient in this local demo mirror.", + "Delivered on 2026-06-03 by 11:02 AM", + ), + "Out for delivery": ( + "Out for delivery", + "Courier is en route for the final handoff.", + "Expected by 8:00 PM today", + ), + "Weather delay": ( + "Shipment exception", + "Weather conditions affected the final leg of the trip.", + "Updated delivery date pending weather clearance", + ), + "Address review": ( + "Operational delay", + "Address details are being reviewed before another attempt.", + "Customer action may be required", + ), + "In transit": ( + "In transit", + "Package is moving through the local network toward destination.", + "Expected delivery by end of next business day", + ), + } + return mapping[stage] + + +def seed_database() -> None: + if ServiceLevel.query.count() > 0: + return + + ensure_dirs() + ensure_visual_assets() + + services: dict[str, ServiceLevel] = {} + for service in SERVICE_LEVELS: + row = ServiceLevel( + slug=service["slug"], + name=service["name"], + summary=service["summary"], + speed_label=service["speed_label"], + base_rate=service["base_rate"], + per_lb_rate=service["per_lb_rate"], + zone_surcharge=service["zone_surcharge"], + weekend_delivery=service["weekend_delivery"], + money_back_label=service["money_back_label"], + icon_path=f"/static/images/service-{service['slug']}.svg", + sort_order=service["sort_order"], + ) + db.session.add(row) + services[row.slug] = row + + locations: list[Location] = [] + for entry in LOCATION_DATA: + name, slug, city, state, address, phone, location_type, hours, services_list, amenities_list, pickup_note = entry + row = Location( + name=name, + slug=slug, + city=city, + state=state, + address=address, + phone=phone, + location_type=location_type, + hours=hours, + services_json=dumps_json(services_list), + amenities_json=dumps_json(amenities_list), + image_path=f"/static/images/location-{slug}.svg", + pickup_note=pickup_note, + ) + db.session.add(row) + locations.append(row) + + for title, slug, category, summary in SUPPORT_ARTICLES: + db.session.add( + SupportArticle( + title=title, + slug=slug, + category=category, + summary=summary, + body=( + f"{summary} This page is part of a deterministic local FedEx-style demo. " + "It uses synthetic shipping records, seeded route milestones, and fixed support guidance so benchmark agents can practice tracking, billing, and pickup workflows without contacting any live carrier service." + ), + related_topics_json=dumps_json([category, "demo workflow", "tracking help"]), + ) + ) + + db.session.flush() + + for index, location in enumerate(locations): + for slot_index, slot_date in enumerate(["2026-06-05", "2026-06-06", "2026-06-07"]): + db.session.add( + PickupSlot( + location_id=location.id, + slot_date=slot_date, + time_window=["9:00 AM - 11:00 AM", "12:30 PM - 2:30 PM", "4:00 PM - 6:00 PM"][slot_index], + remaining_capacity=max(3, 12 - ((index + slot_index) % 7)), + cutoff_note=[ + "Book 90 minutes ahead", + "Same-day requests close at noon", + "Weekend pickup requires staffed counter", + ][slot_index], + ) + ) + + db.session.commit() + + +def seed_benchmark_users() -> None: + if User.query.filter_by(email="alice.j@test.com").first(): + return + + locations = {location.slug: location for location in Location.query.order_by(Location.slug.asc()).all()} + users: list[User] = [] + for entry in BENCHMARK_USERS: + user = User(**entry) + user.set_password(DEMO_PASSWORD) + db.session.add(user) + users.append(user) + db.session.flush() + + slots = PickupSlot.query.order_by(PickupSlot.slot_date.asc(), PickupSlot.id.asc()).all() + stages = ["Delivered", "In transit", "Out for delivery", "Weather delay", "Address review"] + location_list = list(locations.values()) + + shipment_counter = 0 + tracking_counter = 0 + for user_index, user in enumerate(users): + for shipment_index in range(15): + shipment_counter += 1 + origin = location_list[(user_index * 3 + shipment_index) % len(location_list)] + destination = location_list[(user_index * 3 + shipment_index + 5) % len(location_list)] + service = SERVICE_LEVELS[(shipment_index + user_index) % len(SERVICE_LEVELS)] + package_type = ["Box", "Envelope", "Tube", "Pak", "Freight pallet"][shipment_index % 5] + weight_lb = round(1.5 + (shipment_index % 6) * 2.75 + user_index * 0.4, 1) + total_cost = service_price(service["slug"], weight_lb, shipment_counter) + stage = stages[(shipment_index + user_index) % len(stages)] + shipment_code = f"SH-{260000 + shipment_counter}" + tracking_number = f"FDX{260000000 + shipment_counter:09d}" + invoice_number = f"INV-{260000 + shipment_counter}" + + shipment = Shipment( + shipment_code=shipment_code, + tracking_number=tracking_number, + user_id=user.id, + service_slug=service["slug"], + package_type=package_type, + package_weight=weight_lb, + origin_city=origin.city, + origin_state=origin.state, + destination_city=destination.city, + destination_state=destination.state, + recipient_name=["Maya Harper", "Noah Bennett", "Priya Shah", "Leo Kim", "Jules Chen"][shipment_index % 5], + declared_value=round(80 + shipment_index * 22 + user_index * 18, 2), + total_cost=total_cost, + fulfillment_mode=["dropoff", "pickup", "dropbox"][shipment_index % 3], + pickup_location_slug=origin.slug, + pickup_window=["", "12:30 PM - 2:30 PM", "4:00 PM - 6:00 PM"][shipment_index % 3], + status=stage_copy(stage)[0], + created_on=f"2026-05-{10 + ((shipment_index + user_index) % 18):02d}", + invoice_number=invoice_number, + reference_label=[ + "Demo replacement parts", + "Client presentation materials", + "Prototype samples", + "Signed documents", + "Warehouse replenishment", + ][shipment_index % 5], + ) + db.session.add(shipment) + db.session.flush() + + status_stage, status_summary, estimated_delivery = stage_copy(stage) + tracking = TrackingRecord( + tracking_number=tracking_number, + shipment_id=shipment.id, + user_id=user.id, + recipient_name=shipment.recipient_name, + sender_name=user.full_name, + origin_city=shipment.origin_city, + origin_state=shipment.origin_state, + destination_city=shipment.destination_city, + destination_state=shipment.destination_state, + service_slug=shipment.service_slug, + package_type=shipment.package_type, + weight_lb=shipment.package_weight, + status_stage=status_stage, + status_summary=status_summary, + ship_date=shipment.created_on, + estimated_delivery=estimated_delivery, + latest_scan=status_stage, + package_count=1 + (shipment_index % 3 == 0), + signature_required=shipment_index % 4 == 0, + dropoff_location_slug=origin.slug, + ) + db.session.add(tracking) + db.session.flush() + + for event in tracking_timeline( + tracking.id, + shipment.origin_city, + shipment.origin_state, + shipment.destination_city, + shipment.destination_state, + stage, + ): + db.session.add(TrackingEvent(**event)) + + db.session.add( + Invoice( + invoice_number=invoice_number, + user_id=user.id, + shipment_id=shipment.id, + billed_on=shipment.created_on, + due_date=f"2026-06-{8 + ((shipment_index + user_index) % 16):02d}", + amount=shipment.total_cost, + status=["Paid", "Open", "Open", "Processing"][shipment_index % 4], + ) + ) + + if shipment_index in {2, 7, 11}: + db.session.add( + Claim( + claim_number=f"CLM-{2600 + shipment_counter}", + user_id=user.id, + tracking_number=tracking_number, + claim_type=["Delay reimbursement", "Damage review", "Missing package"][shipment_index % 3], + amount=round(shipment.total_cost * [0.35, 0.55, 0.8][shipment_index % 3], 2), + status=["Under review", "Info requested", "Closed"][shipment_index % 3], + opened_on=f"2026-06-{2 + ((shipment_index + user_index) % 9):02d}", + note="Synthetic claim created for benchmark review flows.", + ) + ) + + for pickup_index in range(2): + slot = slots[(user_index * 4 + pickup_index * 3) % len(slots)] + db.session.add( + PickupRequest( + confirmation_code=f"PU-{2600 + user_index * 10 + pickup_index:04d}", + user_id=user.id, + location_id=slot.location_id, + slot_date=slot.slot_date, + time_window=slot.time_window, + package_count=pickup_index + 1, + status=["Scheduled", "Ready for driver"][pickup_index], + created_on=f"2026-06-0{pickup_index + 2}", + ) + ) + + guest_statuses = ["Delivered", "In transit", "Weather delay", "Address review"] + for extra_index in range(12): + tracking_counter += 1 + origin = location_list[(extra_index + 2) % len(location_list)] + destination = location_list[(extra_index + 8) % len(location_list)] + service = SERVICE_LEVELS[extra_index % len(SERVICE_LEVELS)] + stage = guest_statuses[extra_index % len(guest_statuses)] + status_stage, status_summary, estimated_delivery = stage_copy(stage) + tracking = TrackingRecord( + tracking_number=f"FDX{260000500 + extra_index:09d}", + recipient_name=["Avery Stone", "Harper Lee", "Jordan Park", "Taylor Moss"][extra_index % 4], + sender_name=["Retail Returns", "Warehouse Dock", "Studio Supply", "Medical Lab"][extra_index % 4], + origin_city=origin.city, + origin_state=origin.state, + destination_city=destination.city, + destination_state=destination.state, + service_slug=service["slug"], + package_type=["Box", "Pak", "Envelope", "Tube"][extra_index % 4], + weight_lb=round(2.2 + extra_index * 0.6, 1), + status_stage=status_stage, + status_summary=status_summary, + ship_date=f"2026-05-{20 + (extra_index % 8):02d}", + estimated_delivery=estimated_delivery, + latest_scan=status_stage, + package_count=1 + (extra_index % 3 == 0), + signature_required=extra_index % 4 == 0, + dropoff_location_slug=origin.slug, + ) + db.session.add(tracking) + db.session.flush() + for event in tracking_timeline( + tracking.id, + tracking.origin_city, + tracking.origin_state, + tracking.destination_city, + tracking.destination_state, + stage, + ): + db.session.add(TrackingEvent(**event)) + + db.session.add_all( + [ + SearchLog(query="weather delay", search_type="support", created_on="2026-06-04"), + SearchLog(query="FDX260000001", search_type="tracking", created_on="2026-06-04"), + SearchLog(query="Seattle location", search_type="global", created_on="2026-06-04"), + ] + ) + + db.session.commit() + + +def rebuild_seed_database() -> None: + ensure_dirs() + db.session.remove() + db.engine.dispose() + for db_file in [DB_PATH, INSTANCE_SEED_DB]: + if db_file.exists(): + db_file.unlink() + db.drop_all() + db.create_all() + seed_database() + seed_benchmark_users() + db.session.remove() + shutil.copy2(DB_PATH, INSTANCE_SEED_DB) + + +def current_md5(path: Path) -> str: + digest = hashlib.md5() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(8192), b""): + digest.update(chunk) + return digest.hexdigest() + + +if __name__ == "__main__": + with app.app_context(): + rebuild_seed_database() + print(f"seed db -> {INSTANCE_SEED_DB}") + print(f"md5 -> {current_md5(INSTANCE_SEED_DB)}") diff --git a/sites/fedex/static/css/.gitkeep b/sites/fedex/static/css/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/fedex/static/css/main.css b/sites/fedex/static/css/main.css new file mode 100644 index 00000000..db4c035f --- /dev/null +++ b/sites/fedex/static/css/main.css @@ -0,0 +1,537 @@ +:root { + --fedex-purple: #4d148c; + --fedex-purple-deep: #38106a; + --fedex-orange: #ff6600; + --fedex-orange-soft: #ffeddc; + --ink: #1f2937; + --muted: #6b7280; + --line: #d7d8df; + --surface: #ffffff; + --page: #f6f4fb; + --success: #157347; + --warning: #ad6700; + --danger: #b42318; + --shadow: 0 18px 36px rgba(33, 20, 61, 0.08); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif; + color: var(--ink); + background: linear-gradient(180deg, #efe8fb 0%, var(--page) 240px); +} + +a { + color: inherit; + text-decoration: none; +} + +img { + max-width: 100%; + display: block; +} + +.site-shell { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.topbar { + background: var(--fedex-purple); + color: #fff; + padding: 0.85rem 0; + box-shadow: 0 2px 0 rgba(0, 0, 0, 0.12); +} + +.inner { + width: min(1180px, calc(100% - 2rem)); + margin: 0 auto; +} + +.topbar .inner, +.main-nav .inner { + display: flex; + align-items: center; + gap: 1rem; +} + +.brand { + font-size: 1.35rem; + font-weight: 800; + letter-spacing: 0.02em; +} + +.brand span { + color: var(--fedex-orange); +} + +.eyebrow { + margin-left: auto; + font-size: 0.92rem; + opacity: 0.88; +} + +.main-nav { + background: rgba(255, 255, 255, 0.9); + border-bottom: 1px solid rgba(77, 20, 140, 0.08); + backdrop-filter: blur(12px); +} + +.main-nav .inner { + flex-wrap: wrap; + padding: 0.9rem 0; +} + +.nav-links { + display: flex; + gap: 0.85rem; + flex-wrap: wrap; + align-items: center; +} + +.nav-links a { + padding: 0.45rem 0.7rem; + border-radius: 999px; + font-size: 0.95rem; + color: var(--fedex-purple-deep); +} + +.nav-links a:hover, +.nav-links a.active { + background: rgba(77, 20, 140, 0.08); +} + +.nav-utility { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.nav-utility .pill { + background: var(--fedex-orange-soft); + color: var(--fedex-purple-deep); + font-size: 0.88rem; + padding: 0.35rem 0.7rem; + border-radius: 999px; +} + +.page { + padding: 2rem 0 3rem; +} + +.hero { + display: grid; + grid-template-columns: 1.2fr 0.8fr; + gap: 1.5rem; + align-items: stretch; + margin-bottom: 1.5rem; +} + +.hero-copy, +.hero-side, +.card, +.flash-list li, +.surface { + background: rgba(255, 255, 255, 0.96); + border: 1px solid rgba(77, 20, 140, 0.08); + border-radius: 24px; + box-shadow: var(--shadow); +} + +.hero-copy { + padding: 2rem; +} + +.hero-copy h1 { + margin: 0 0 0.8rem; + font-size: clamp(2rem, 3vw, 3rem); + line-height: 1.05; +} + +.hero-copy p { + color: var(--muted); + font-size: 1rem; + line-height: 1.6; +} + +.hero-actions, +.stack-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + margin-top: 1.15rem; +} + +.hero-side { + padding: 1rem; + display: grid; + gap: 0.9rem; +} + +.hero-stat { + padding: 1rem; + background: linear-gradient(135deg, rgba(77, 20, 140, 0.05), rgba(255, 102, 0, 0.08)); + border-radius: 18px; +} + +.hero-stat strong { + display: block; + font-size: 1.55rem; + color: var(--fedex-purple); +} + +.hero-stat span { + display: block; + color: var(--muted); + margin-top: 0.25rem; +} + +.button, +button, +input[type="submit"] { + border: 0; + border-radius: 999px; + padding: 0.8rem 1.15rem; + font-size: 0.96rem; + font-weight: 700; + cursor: pointer; + transition: transform 0.16s ease, box-shadow 0.16s ease, background 0.16s ease; +} + +.button:hover, +button:hover, +input[type="submit"]:hover { + transform: translateY(-1px); +} + +.button-primary, +button, +input[type="submit"] { + background: linear-gradient(135deg, var(--fedex-purple), #6525b7); + color: #fff; + box-shadow: 0 12px 24px rgba(77, 20, 140, 0.22); +} + +.button-secondary { + background: #fff; + color: var(--fedex-purple); + border: 1px solid rgba(77, 20, 140, 0.18); +} + +.button-accent { + background: linear-gradient(135deg, var(--fedex-orange), #ff8a23); + color: #fff; + box-shadow: 0 12px 24px rgba(255, 102, 0, 0.2); +} + +.grid { + display: grid; + gap: 1rem; +} + +.grid.two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.grid.three { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.grid.four { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.card, +.surface { + padding: 1.2rem; +} + +.card h2, +.surface h2, +.surface h3 { + margin-top: 0; +} + +.kicker { + color: var(--fedex-orange); + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.75rem; +} + +.muted { + color: var(--muted); +} + +.badge-row, +.chip-row { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.badge, +.chip { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.35rem 0.6rem; + border-radius: 999px; + font-size: 0.82rem; +} + +.badge { + background: rgba(77, 20, 140, 0.08); + color: var(--fedex-purple); +} + +.badge.orange { + background: rgba(255, 102, 0, 0.12); + color: #8f4600; +} + +.status-good { color: var(--success); } +.status-warn { color: var(--warning); } +.status-risk { color: var(--danger); } + +.form-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.form-grid.three { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.field { + display: grid; + gap: 0.4rem; +} + +label { + font-weight: 700; + color: var(--fedex-purple-deep); +} + +input, +select, +textarea { + width: 100%; + padding: 0.85rem 0.95rem; + border-radius: 14px; + border: 1px solid rgba(77, 20, 140, 0.16); + background: #fff; + font: inherit; + color: var(--ink); +} + +textarea { + min-height: 120px; + resize: vertical; +} + +.section-title { + display: flex; + justify-content: space-between; + align-items: end; + gap: 1rem; + margin: 1.8rem 0 1rem; +} + +.section-title h2 { + margin: 0; +} + +.flash-list { + list-style: none; + padding: 0; + margin: 0 0 1rem; + display: grid; + gap: 0.7rem; +} + +.flash-list li { + padding: 0.9rem 1rem; +} + +.flash-success { border-left: 6px solid var(--success); } +.flash-warning { border-left: 6px solid var(--warning); } +.flash-danger { border-left: 6px solid var(--danger); } +.flash-info { border-left: 6px solid var(--fedex-purple); } + +.split { + display: grid; + gap: 1rem; + grid-template-columns: 1.25fr 0.75fr; +} + +.timeline { + display: grid; + gap: 0.8rem; +} + +.timeline-item { + position: relative; + padding-left: 1.5rem; + border-left: 2px solid rgba(77, 20, 140, 0.14); +} + +.timeline-item::before { + content: ""; + position: absolute; + left: -0.45rem; + top: 0.2rem; + width: 0.75rem; + height: 0.75rem; + border-radius: 50%; + background: var(--fedex-orange); +} + +.timeline-item strong { + display: block; + color: var(--fedex-purple); +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.8rem; +} + +.stat { + padding: 1rem; + border-radius: 18px; + background: linear-gradient(180deg, rgba(77, 20, 140, 0.05), rgba(255, 255, 255, 0.95)); +} + +.stat strong { + display: block; + font-size: 1.35rem; + color: var(--fedex-purple); +} + +.table-wrap { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + text-align: left; + padding: 0.85rem 0.7rem; + border-bottom: 1px solid rgba(77, 20, 140, 0.09); + vertical-align: top; +} + +th { + color: var(--fedex-purple); + font-size: 0.86rem; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.media { + display: grid; + grid-template-columns: 140px 1fr; + gap: 1rem; + align-items: start; +} + +.media img { + border-radius: 18px; + border: 1px solid rgba(77, 20, 140, 0.1); +} + +.location-list, +.article-list, +.record-list { + display: grid; + gap: 0.9rem; +} + +.stack { + display: grid; + gap: 0.9rem; +} + +.footer { + margin-top: auto; + padding: 1.2rem 0 2rem; + color: var(--muted); + font-size: 0.92rem; +} + +.footer .inner { + display: flex; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.empty-state { + padding: 1.2rem; + border-radius: 18px; + background: rgba(77, 20, 140, 0.04); + color: var(--muted); +} + +@media (max-width: 980px) { + .hero, + .split, + .grid.three, + .grid.four, + .stats-grid, + .media { + grid-template-columns: 1fr; + } + + .form-grid, + .form-grid.three, + .grid.two { + grid-template-columns: 1fr; + } + + .nav-utility { + margin-left: 0; + } +} + +@media (max-width: 640px) { + .inner { + width: min(100% - 1rem, 1180px); + } + + .topbar .inner, + .main-nav .inner { + align-items: flex-start; + } + + .hero-copy { + padding: 1.35rem; + } + + .card, + .surface { + padding: 1rem; + } + + .button, + button, + input[type="submit"] { + width: 100%; + justify-content: center; + } +} diff --git a/sites/fedex/static/icons/.gitkeep b/sites/fedex/static/icons/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/fedex/static/js/.gitkeep b/sites/fedex/static/js/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/fedex/static/js/main.js b/sites/fedex/static/js/main.js new file mode 100644 index 00000000..c55b9d07 --- /dev/null +++ b/sites/fedex/static/js/main.js @@ -0,0 +1,11 @@ +document.addEventListener("DOMContentLoaded", () => { + const sampleButtons = document.querySelectorAll("[data-fill-tracking]"); + sampleButtons.forEach((button) => { + button.addEventListener("click", () => { + const target = document.querySelector(button.dataset.fillTracking); + if (!target) return; + target.value = button.dataset.value || ""; + target.focus(); + }); + }); +}); diff --git a/sites/fedex/tasks.jsonl b/sites/fedex/tasks.jsonl new file mode 100644 index 00000000..21698639 --- /dev/null +++ b/sites/fedex/tasks.jsonl @@ -0,0 +1,18 @@ +{"web_name":"FedEx","id":"FedEx--0","ques":"Track the package FDX260000004 and identify why it is delayed.","web":"http://localhost:40015/","upstream_url":"https://www.fedex.com/en-us/tracking.html"} +{"web_name":"FedEx","id":"FedEx--1","ques":"Track FDX260000001, FDX260000002, and FDX260000003 together. Which one is already delivered?","web":"http://localhost:40015/","upstream_url":"https://www.fedex.com/en-us/tracking.html"} +{"web_name":"FedEx","id":"FedEx--2","ques":"Use support search to find the article that explains what a shipment exception means.","web":"http://localhost:40015/","upstream_url":"https://www.fedex.com/en-us/customer-support.html"} +{"web_name":"FedEx","id":"FedEx--3","ques":"Estimate a shipment from California to Texas for an 8 lb box. Which seeded service level is the cheapest?","web":"http://localhost:40015/","upstream_url":"https://www.fedex.com/en-us/online/rating.html"} +{"web_name":"FedEx","id":"FedEx--4","ques":"Estimate a shipment from Washington to Florida for a 4 lb envelope. Which service appears first as the fastest option?","web":"http://localhost:40015/","upstream_url":"https://www.fedex.com/en-us/online/rating.html"} +{"web_name":"FedEx","id":"FedEx--5","ques":"Sign in as alice.j@test.com and find the invoice number tied to her Dallas delivery shipment.","web":"http://localhost:40015/","upstream_url":"https://www.fedex.com/en-us/account/"} +{"web_name":"FedEx","id":"FedEx--6","ques":"Sign in as bob.c@test.com and check his claims. Which claim is waiting for more information?","web":"http://localhost:40015/","upstream_url":"https://www.fedex.com/en-us/customer-support/claims.html"} +{"web_name":"FedEx","id":"FedEx--7","ques":"Sign in as carol.d@test.com and inspect her scheduled pickups. Which pickup is already marked ready for driver?","web":"http://localhost:40015/","upstream_url":"https://www.fedex.com/en-us/shipping/schedule-manage-pickups.html"} +{"web_name":"FedEx","id":"FedEx--8","ques":"Sign in as david.k@test.com and open his shipment history. Which city is the destination for his shipment that is out for delivery?","web":"http://localhost:40015/","upstream_url":"https://www.fedex.com/en-us/manage-delivery.html"} +{"web_name":"FedEx","id":"FedEx--9","ques":"Search the locations page for Dallas and find the pickup cutoff note shown for the Dallas Arts District Hub.","web":"http://localhost:40015/","upstream_url":"https://www.fedex.com/en-us/shipping/drop-off-package.html"} +{"web_name":"FedEx","id":"FedEx--10","ques":"Open the Miami Brickell Print & Ship location page and identify the note about international documents.","web":"http://localhost:40015/","upstream_url":"https://www.fedex.com/en-us/shipping/drop-off-package.html"} +{"web_name":"FedEx","id":"FedEx--11","ques":"Use global search for the word delay and open the article specifically about weather-related disruptions.","web":"http://localhost:40015/","upstream_url":"https://www.fedex.com/en-us/customer-support.html"} +{"web_name":"FedEx","id":"FedEx--12","ques":"Sign in as alice.j@test.com, create a mock shipment for Alex Brown from Seattle, WA to Boston, MA using a 6 lb box with declared value 240, choose FedEx 2Day, and report the generated tracking number.","web":"http://localhost:40015/","upstream_url":"https://www.fedex.com/en-us/shipping/create-shipment.html"} +{"web_name":"FedEx","id":"FedEx--13","ques":"Sign in as alice.j@test.com, schedule a demo pickup at the Seattle Downtown Ship Center using the earliest available slot, and report the confirmation code.","web":"http://localhost:40015/","upstream_url":"https://www.fedex.com/en-us/shipping/schedule-manage-pickups.html"} +{"web_name":"FedEx","id":"FedEx--14","ques":"Use global search for Seattle and open the Seattle Downtown Ship Center page. What are the posted hours?","web":"http://localhost:40015/","upstream_url":"https://www.fedex.com/en-us/shipping/drop-off-package.html"} +{"web_name":"FedEx","id":"FedEx--15","ques":"Sign in as david.k@test.com and find the damage review claim in his account. What is the claim number?","web":"http://localhost:40015/","upstream_url":"https://www.fedex.com/en-us/customer-support/claims.html"} +{"web_name":"FedEx","id":"FedEx--16","ques":"Track FDX260000500 and determine whether the package is already delivered or still moving through the network.","web":"http://localhost:40015/","upstream_url":"https://www.fedex.com/en-us/tracking.html"} +{"web_name":"FedEx","id":"FedEx--17","ques":"Estimate a freight pallet shipment from Texas to Florida weighing 12 lb. Which service is the most expensive in the seeded quote list?","web":"http://localhost:40015/","upstream_url":"https://www.fedex.com/en-us/online/rating.html"} diff --git a/sites/fedex/templates/.gitkeep b/sites/fedex/templates/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/fedex/templates/account.html b/sites/fedex/templates/account.html new file mode 100644 index 00000000..2ba6dabf --- /dev/null +++ b/sites/fedex/templates/account.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} +{% block title %}Account{% endblock %} +{% block content %} +
+
+
Account dashboard
+

{{ current_user.full_name }}

+
+ Edit profile +
+
+
{{ shipments|length }}Recent shipments
+
{{ invoices|length }}Recent invoices
+
{{ claims|length }}Active claims
+
+ +
+
+
+

Recent shipments

+ View all +
+ {% for shipment in shipments %} +
+ {{ shipment.shipment_code }} +

{{ shipment.destination_city }}, {{ shipment.destination_state }} · {{ shipment.status }}

+
+ {% else %} +
No shipments yet.
+ {% endfor %} +
+
+
+

Scheduled pickups

+ Schedule another +
+ {% for pickup in pickups %} +
+ {{ pickup.confirmation_code }} +

{{ pickup.slot_date }} · {{ pickup.time_window }} · {{ pickup.status }}

+
+ {% else %} +
No pickups scheduled.
+ {% endfor %} +
+
+ +
+
+
+

Invoices

+ Open billing +
+ {% for invoice in invoices %} +
+ {{ invoice.invoice_number }} +

{{ invoice.amount|money }} · {{ invoice.status }} · due {{ invoice.due_date }}

+
+ {% else %} +
No invoices available.
+ {% endfor %} +
+
+
+

Claims

+ Open claims +
+ {% for claim in claims %} +
+ {{ claim.claim_number }} +

{{ claim.claim_type }} · {{ claim.status }}

+
+ {% else %} +
No active claims.
+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/fedex/templates/account_edit.html b/sites/fedex/templates/account_edit.html new file mode 100644 index 00000000..4f9d1677 --- /dev/null +++ b/sites/fedex/templates/account_edit.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block title %}Edit Account{% endblock %} +{% block content %} +
+
Profile
+

Edit local account details

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+{% endblock %} diff --git a/sites/fedex/templates/account_shipments.html b/sites/fedex/templates/account_shipments.html new file mode 100644 index 00000000..afa44f9a --- /dev/null +++ b/sites/fedex/templates/account_shipments.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}Account Shipments{% endblock %} +{% block content %} +
+
+
Shipment history
+

All seeded shipments for {{ current_user.first_name }}

+
+
+
+ + + + + + + + + + + + {% for shipment in shipments %} + + + + + + + + {% endfor %} + +
ShipmentTrackingRouteStatusTotal
{{ shipment.shipment_code }}{{ shipment.tracking_number }}{{ shipment.origin_city }}, {{ shipment.origin_state }} → {{ shipment.destination_city }}, {{ shipment.destination_state }}{{ shipment.status }}{{ shipment.total_cost|money }}
+
+{% endblock %} diff --git a/sites/fedex/templates/base.html b/sites/fedex/templates/base.html new file mode 100644 index 00000000..71f11ab9 --- /dev/null +++ b/sites/fedex/templates/base.html @@ -0,0 +1,67 @@ + + + + + + {% block title %}FedEx Demo{% endblock %} + + + +
+
+
+
+ FedEx Demo +
Local benchmark mirror with deterministic synthetic shipment data only
+
+
+ +
+ +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
    + {% for category, message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+
+ +
+
+
FedEx Demo mirror for WebHarbor. No live carrier APIs, payments, pickups, or address validation.
+
Control plane reset target: /reset/fedex
+
+
+
+ + + diff --git a/sites/fedex/templates/claims.html b/sites/fedex/templates/claims.html new file mode 100644 index 00000000..29ae68b4 --- /dev/null +++ b/sites/fedex/templates/claims.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}Claims{% endblock %} +{% block content %} +
+
+
Claims
+

Shipment claim statuses

+
+
+
+ {% for claim in claims %} +
+
+ {{ claim.claim_number }} + {{ claim.status }} +
+

{{ claim.claim_type }}

+

Tracking number {{ claim.tracking_number }}

+

{{ claim.note }}

+ {{ claim.amount|money }} +
+ {% else %} +
No claims found for this account.
+ {% endfor %} +
+{% endblock %} diff --git a/sites/fedex/templates/index.html b/sites/fedex/templates/index.html new file mode 100644 index 00000000..fee1a245 --- /dev/null +++ b/sites/fedex/templates/index.html @@ -0,0 +1,165 @@ +{% extends "base.html" %} +{% block title %}FedEx Demo Home{% endblock %} +{% block content %} +
+
+
Tracking, shipping, pickup, and support
+

Practice end-to-end parcel workflows without touching a live carrier.

+

+ This mirror recreates the feel of a FedEx-style logistics dashboard using synthetic tracking numbers, + fixed route milestones, seeded account history, and deterministic pickup schedules. It never creates + a real shipment or charges a payment method. +

+ +
+
+ FedEx demo tracker art +
+ {{ featured_tracking|length }} + Featured seeded tracking records ready for smoke tests +
+
+ {{ services|length }} + Deterministic service levels with rate estimates and delivery commitments +
+
+
+ +
+
+
+
+
Quick track
+

Track one or several demo packages

+
+
+
+
+ + +
+
+ + +
+
+
+
+
Seeded account sign-in
+

Benchmark users

+

Use these deterministic demo accounts for shipment, invoice, claim, and pickup flows.

+
+ + + + + + + + + + +
EmailPreferred location
alice.j@test.comSeattle Downtown Ship Center
bob.c@test.comAtlanta Midtown Ship Center
carol.d@test.comMiami Brickell Print & Ship
david.k@test.comChicago Loop Ship Center
+
+

Password for all seeded users: {{ demo_password }}

+
+
+ +
+
+
Service menu
+

Compare delivery commitments

+
+ Open rate estimate +
+
+ {% for service in services %} +
+ {{ service.name }} +
+ {{ service.speed_label }} + {% if service.weekend_delivery %}Weekend eligible{% endif %} +
+

{{ service.name }}

+

{{ service.summary }}

+

{{ service.money_back_label }}

+
+ {% endfor %} +
+ +
+
+
Active seeded records
+

Recent package statuses

+
+ Track more +
+
+ {% for record in featured_tracking %} +
+
+
+
+ {{ record.tracking_number }} + {{ record.service_slug|replace('-', ' ')|title }} +
+

{{ record.status_stage }}

+

{{ record.origin_city }}, {{ record.origin_state }} → {{ record.destination_city }}, {{ record.destination_state }}

+

{{ record.status_summary }}

+
+
+ ETA +

{{ record.estimated_delivery }}

+ Latest scan +

{{ record.latest_scan }}

+
+
+
+ {% endfor %} +
+ +
+
+
Nearby staffed counters
+

Pickup and hold-at-location options

+
+ Browse all locations +
+
+ {% for location in locations %} +
+ {{ location.name }} +
+
+ {{ location.location_type }} + {{ location.city }}, {{ location.state }} +
+

{{ location.name }}

+

{{ location.address }}

+

{{ location.pickup_note }}

+
+
+ {% endfor %} +
+ +
+
+
Support articles
+

Benchmark-friendly help content

+
+ See all support topics +
+
+ {% for article in articles %} + + {% endfor %} +
+{% endblock %} diff --git a/sites/fedex/templates/invoices.html b/sites/fedex/templates/invoices.html new file mode 100644 index 00000000..7274ce38 --- /dev/null +++ b/sites/fedex/templates/invoices.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% block title %}Invoices{% endblock %} +{% block content %} +
+
+
Billing
+

Invoice history

+
+
+
+ + + + + + + + + + + + + {% for invoice in invoices %} + + + + + + + + + {% endfor %} + +
InvoiceShipmentBilledDueStatusAmount
{{ invoice.invoice_number }}{{ invoice.shipment.shipment_code if invoice.shipment else "—" }}{{ invoice.billed_on }}{{ invoice.due_date }}{{ invoice.status }}{{ invoice.amount|money }}
+
+{% endblock %} diff --git a/sites/fedex/templates/location_detail.html b/sites/fedex/templates/location_detail.html new file mode 100644 index 00000000..c22f09b8 --- /dev/null +++ b/sites/fedex/templates/location_detail.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block title %}{{ location.name }}{% endblock %} +{% block content %} +
+ {{ location.name }} +
+
+ {{ location.location_type }} + {{ location.city }}, {{ location.state }} +
+

{{ location.name }}

+

{{ location.address }} · {{ location.phone }}

+

{{ location.pickup_note }}

+
+ {% for amenity in location.amenities %} + {{ amenity }} + {% endfor %} +
+
+
+
+
+

Services

+
+ {% for service in location.services %} + {{ service }} + {% endfor %} +
+
+
+

Pickup windows

+
+ {% for slot in slots %} +
+ {{ slot.slot_date }} · {{ slot.time_window }} + {{ slot.remaining_capacity }} capacity left +

{{ slot.cutoff_note }}

+
+ {% endfor %} +
+
+
+{% endblock %} diff --git a/sites/fedex/templates/locations.html b/sites/fedex/templates/locations.html new file mode 100644 index 00000000..c2ce061e --- /dev/null +++ b/sites/fedex/templates/locations.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% block title %}Locations{% endblock %} +{% block content %} +
+
+
Drop-off locations
+

Find nearby staffed counters and pickup points

+
+
+
+
+
+ + +
+
+ +
+
+
+
+ {% for location in locations %} +
+ {{ location.name }} +
+
+ {{ location.location_type }} + {{ location.hours }} +
+

{{ location.name }}

+

{{ location.address }} · {{ location.city }}, {{ location.state }}

+
+ {% for service in location.services %} + {{ service }} + {% endfor %} +
+
+
+ {% endfor %} +
+{% endblock %} diff --git a/sites/fedex/templates/login.html b/sites/fedex/templates/login.html new file mode 100644 index 00000000..d787e377 --- /dev/null +++ b/sites/fedex/templates/login.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block title %}Sign In{% endblock %} +{% block content %} +
+
+
Sign in
+

Access seeded shipments, invoices, and claims

+
+
+ + +
+
+ + +
+ +
+
+
+
Benchmark accounts
+

Every seeded user shares the same deterministic password: {{ demo_password }}.

+
    +
  • alice.j@test.com
  • +
  • bob.c@test.com
  • +
  • carol.d@test.com
  • +
  • david.k@test.com
  • +
+ Create another local account +
+
+{% endblock %} diff --git a/sites/fedex/templates/pickup.html b/sites/fedex/templates/pickup.html new file mode 100644 index 00000000..04562e61 --- /dev/null +++ b/sites/fedex/templates/pickup.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}Schedule Pickup{% endblock %} +{% block content %} +
+
+
Pickup scheduling
+

Book a local demo pickup window

+
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+{% endblock %} diff --git a/sites/fedex/templates/rate_estimate.html b/sites/fedex/templates/rate_estimate.html new file mode 100644 index 00000000..c113ee39 --- /dev/null +++ b/sites/fedex/templates/rate_estimate.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} +{% block title %}Rate Estimate{% endblock %} +{% block content %} +
+
+
Rate estimate
+

Compare seeded service levels by lane and package profile

+
+
+
+
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+
+ +
+ + {% if quotes %} +
+

Estimated options

+
+
+ {% for quote in quotes %} +
+ {{ quote.service.name }} +
+ Zone {{ quote.zone }} + {{ quote.commitment }} +
+

{{ quote.service.name }}

+

{{ quote.service.summary }}

+

{{ quote.price|money }}

+
+ {% endfor %} +
+ {% endif %} +{% endblock %} diff --git a/sites/fedex/templates/register.html b/sites/fedex/templates/register.html new file mode 100644 index 00000000..29b1a069 --- /dev/null +++ b/sites/fedex/templates/register.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% block title %}Create Account{% endblock %} +{% block content %} +
+
Register
+

Create a local FedEx demo account

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+ + +
+ +
+
+{% endblock %} diff --git a/sites/fedex/templates/search.html b/sites/fedex/templates/search.html new file mode 100644 index 00000000..f4fb1066 --- /dev/null +++ b/sites/fedex/templates/search.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block title %}Search{% endblock %} +{% block content %} +
+
+
Global search
+

Search tracking, locations, and support

+
+
+
+
+
+ + +
+
+ +
+
+
+ {% if query %} +
+
+

Tracking matches

+
+ {% for record in tracking_matches %} +
+ {{ record.tracking_number }} +

{{ record.status_summary }}

+
+ {% else %} +
No tracking results matched.
+ {% endfor %} +
+
+
+

Locations

+
+ {% for location in locations %} +
+ {{ location.name }} +

{{ location.city }}, {{ location.state }}

+
+ {% else %} +
No location results matched.
+ {% endfor %} +
+
+
+

Support articles

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

{{ article.summary }}

+
+ {% else %} +
No support articles matched.
+ {% endfor %} +
+
+
+ {% endif %} +{% endblock %} diff --git a/sites/fedex/templates/ship.html b/sites/fedex/templates/ship.html new file mode 100644 index 00000000..974e3702 --- /dev/null +++ b/sites/fedex/templates/ship.html @@ -0,0 +1,82 @@ +{% extends "base.html" %} +{% block title %}Create Shipment{% endblock %} +{% block content %} +
+
+
Mock shipment
+

Start a local shipping flow

+
+
+
+
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+{% endblock %} diff --git a/sites/fedex/templates/ship_confirmation.html b/sites/fedex/templates/ship_confirmation.html new file mode 100644 index 00000000..2bdc2089 --- /dev/null +++ b/sites/fedex/templates/ship_confirmation.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}Shipment Confirmation{% endblock %} +{% block content %} +
+
Shipment created
+

{{ shipment.shipment_code }}

+
+ {{ shipment.tracking_number }} + {{ shipment.status }} +
+

This confirmation exists only in the local FedEx demo mirror.

+
+
{{ shipment.total_cost|money }}Mock billed total
+
{{ shipment.invoice_number }}Invoice reference
+
{{ shipment.destination_city }}Destination city
+
+ +
+{% endblock %} diff --git a/sites/fedex/templates/ship_review.html b/sites/fedex/templates/ship_review.html new file mode 100644 index 00000000..d00642a1 --- /dev/null +++ b/sites/fedex/templates/ship_review.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block title %}Review Shipment{% endblock %} +{% block content %} +
+
+
Step 3
+

Review and create your local shipment

+
+
+
+
+
+ + + + + + + + +
Recipient{{ state.get('recipient_name') }}
Route{{ state.get('origin_city') }}, {{ state.get('origin_state') }} → {{ state.get('destination_city') }}, {{ state.get('destination_state') }}
Package{{ state.get('package_type') }} · {{ state.get('weight_lb') }} lb
Declared value{{ state.get('declared_value')|float|money }}
Handoff mode{{ state.get('pickup_mode', 'dropoff')|replace('-', ' ')|title }}
+
+
+ +
+{% endblock %} diff --git a/sites/fedex/templates/ship_service.html b/sites/fedex/templates/ship_service.html new file mode 100644 index 00000000..b4eb5608 --- /dev/null +++ b/sites/fedex/templates/ship_service.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}Choose Service{% endblock %} +{% block content %} +
+
+
Step 2
+

Select a service level

+
+
+
+ {% for quote in quotes %} + + {% endfor %} +
+
Shipment summary
+

{{ state.get('recipient_name') }}

+

{{ state.get('origin_city') }}, {{ state.get('origin_state') }} → {{ state.get('destination_city') }}, {{ state.get('destination_state') }}

+

{{ state.get('package_type') }} · {{ state.get('weight_lb') }} lb · Declared {{ state.get('declared_value')|float|money }}

+ +
+
+{% endblock %} diff --git a/sites/fedex/templates/support.html b/sites/fedex/templates/support.html new file mode 100644 index 00000000..fb109dfe --- /dev/null +++ b/sites/fedex/templates/support.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block title %}Support{% endblock %} +{% block content %} +
+
+
Support center
+

Search synthetic help content

+
+
+
+
+
+ + +
+
+ +
+
+
+
+ {% for article in articles %} + + {% endfor %} +
+{% endblock %} diff --git a/sites/fedex/templates/support_article.html b/sites/fedex/templates/support_article.html new file mode 100644 index 00000000..6b10a88d --- /dev/null +++ b/sites/fedex/templates/support_article.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}{{ article.title }}{% endblock %} +{% block content %} +
+
+
{{ article.category }}
+

{{ article.title }}

+

{{ article.summary }}

+

{{ article.body }}

+
+ {% for topic in article.related_topics %} + {{ topic }} + {% endfor %} +
+
+ +
+{% endblock %} diff --git a/sites/fedex/templates/track.html b/sites/fedex/templates/track.html new file mode 100644 index 00000000..f570801b --- /dev/null +++ b/sites/fedex/templates/track.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}Track Packages{% endblock %} +{% block content %} +
+
+
Tracking
+

Track one or several synthetic packages

+
+
+
+
+
+
+ + +
+

Separate multiple numbers with commas or line breaks. This mirror never calls a real carrier system.

+
+ + +
+
+
+ +
+{% endblock %} diff --git a/sites/fedex/templates/track_results.html b/sites/fedex/templates/track_results.html new file mode 100644 index 00000000..fbd5b91a --- /dev/null +++ b/sites/fedex/templates/track_results.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% block title %}Tracking Results{% endblock %} +{% block content %} +
+
+
Tracking results
+

{{ records|length }} of {{ numbers|length }} packages found

+
+ Track again +
+ {% if numbers and not records %} +
No seeded demo tracking numbers matched your request.
+ {% endif %} + {% if records %} +
+ {% for record in records %} +
+
+
+
+ {{ record.tracking_number }} + {{ record.service_slug|replace('-', ' ')|title }} +
+

{{ record.status_stage }}

+

{{ record.origin_city }}, {{ record.origin_state }} → {{ record.destination_city }}, {{ record.destination_state }}

+

{{ record.status_summary }}

+
+
+ Estimated delivery +

{{ record.estimated_delivery }}

+ Recipient +

{{ record.recipient_name }}

+
+
+
+ {% endfor %} +
+ {% endif %} +{% endblock %} diff --git a/sites/fedex/templates/tracking_detail.html b/sites/fedex/templates/tracking_detail.html new file mode 100644 index 00000000..77ef2c78 --- /dev/null +++ b/sites/fedex/templates/tracking_detail.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}{{ record.tracking_number }}{% endblock %} +{% block content %} +
+
+
Tracking detail
+

{{ record.tracking_number }}

+
+ Back to results +
+ +
+
+
+ {{ record.status_stage }} + {% if service %}{{ service.name }}{% endif %} +
+

{{ record.status_summary }}

+
+
{{ record.origin_city }}{{ record.origin_state }} origin
+
{{ record.destination_city }}{{ record.destination_state }} destination
+
{{ record.estimated_delivery }}Current ETA
+
+
+ + + + + + + + + +
Sender{{ record.sender_name }}
Recipient{{ record.recipient_name }}
Package type{{ record.package_type }}
Weight{{ record.weight_lb }} lb
Package count{{ record.package_count }}
Signature{{ "Required" if record.signature_required else "Not required" }}
+
+
+ + +
+{% endblock %} diff --git a/websyn_start.sh b/websyn_start.sh index 72defad8..5a0686ee 100644 --- a/websyn_start.sh +++ b/websyn_start.sh @@ -1,12 +1,13 @@ #!/bin/bash -# WebSyn startup: launch all 12 mirror sites, then exec the original CMD. +# WebSyn startup: launch every mirror site, 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 fedex) BASE_PORT=40000 +SITE_COUNT=${#SITES[@]} PID_DIR=/tmp/websyn_pids mkdir -p "$PID_DIR" rm -f "$PID_DIR"/*.pid @@ -17,7 +18,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 ${SITE_COUNT} sites on ports ${BASE_PORT}-$((BASE_PORT + SITE_COUNT - 1))..." for i in "${!SITES[@]}"; do site="${SITES[$i]}" port=$((BASE_PORT + i)) @@ -51,8 +52,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}/${SITE_COUNT} sites ready" + if [ $ready -eq $SITE_COUNT ]; then break fi done @@ -78,6 +79,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