diff --git a/.assets-revision b/.assets-revision index f14521a1c..f7140d2b1 100644 --- a/.assets-revision +++ b/.assets-revision @@ -1,72 +1,18 @@ -# Hugging Face dataset holding webharbor's heavy static assets -# (instance_seed/*.db, static/images/, static/external_cache/). -# -# fetch_assets.sh uses the `hf download` CLI. The pin below -# is a git revision (branch name like `main`, a tag, or a specific commit -# sha). Override at runtime with the ASSETS_REVISION env var. -# -# The revision is a commit sha, never a moving branch name. -# -# Current pin = c32018ca3b3d67e7b858b1b85fb101aea5090cd7, the head commit of HF -# dataset `main` (verified 2026-09-15): the squash-merge commit of HF dataset PR -# #91 "berkeley: synthetic imagery bundle (164 files)" (merged 2026-09-15T04:00:54Z), -# which added berkeley.tar.gz (sha256 -# ab9d2716ae8d06540a181b5e60c37f613d87b103864b467511da546b1b173789, -# 6951483 bytes, 171 managed members) — the site's first archive, so that -# fetch_assets.sh can install sites/berkeley/static/images/. -# -# The berkeley bundle first shipped through the interim pin `refs/pr/91` while that -# dataset PR was open; the pin was moved to this merged commit once the PR merged, -# because a `refs/pr/` ref moves whenever its branch is updated. Checked at both -# pin changes (2026-09-14 and 2026-09-15): for all 29 sites registered before -# berkeley, the archive size and LFS oid on this commit are identical to the -# b7e605c0 pin below, and all 30 site archives on this commit are byte-identical to -# the ones on `refs/pr/91` (verified by LFS oid and by comparing the downloaded -# berkeley.tar.gz byte for byte), so the pin change alters no site's assets. This -# revision carries an archive for all 30 registered sites. -# -# Previous pin = b7e605c0ec5fc47de85b09e7427162cc50e38980, the commit HF dataset -# `main` pointed at before this pin (verified 2026-09-14): the squash-merge commit -# of HF dataset PR #85 ("Upload nvidia.tar.gz with distinct official product and -# hero images (#107 follow-up)"), which sits on top of the merged PR #84 ("Upload -# repacked nvidia.tar.gz with de-duplicated official product images (#107)"), PR -# #75 ("Add reviewed NVIDIA asset bundle") and PR #74 ("Upload kaggle.tar.gz with -# huggingface_hub"). -# -# Its nvidia.tar.gz is the repacked bundle (sha256 -# 617a3e3740ba6706bcab786c8a5c3f9a22ecbb39eff5728ad2c12e4992cb098b, 16340955 bytes, -# 37 file members and no directory entries), which carries the official NVIDIA -# renders for 33 product/news images plus three dedicated hero images under -# static/images/heroes/: 36 images with 36 distinct sha256, so -# sites/nvidia/static/images/ has no byte-identical duplicate and no page reuses -# one file twice. It supersedes the 9927312-byte PR #84 archive (sha256 -# ee8c6ba966e7a8f7fb5ad2d7ff0134ab98e7b80d6cc77f3328217405b8b34e2f) and the -# original 6706395-byte bundle (blob 0f1d8068af602748d0dec19ef04b52c7559fef28), -# which carried duplicates. -# -# Pin before that = 64264d065cdb0b7755ee99dab356be5733d9ddef, the commit HF dataset -# `main` pointed at (verified 2026-09-13). It carries the merged HF dataset PR -# #74 ("Upload kaggle.tar.gz with huggingface_hub") and the later PR #75 ("Add -# reviewed NVIDIA asset bundle"). -# -# Why these pins and not the other candidate (refs/pr/37, resolved -# 600a3e1de158ae56dc82a5fab56c2ca25acb1e27): -# * the pinned revision carries an archive for every registered site (30 of 30 for -# c32018ca and for refs/pr/91: the 29 main-site archives plus berkeley.tar.gz; -# 29 of 29 for b7e605c0), and -# the kaggle seed DB it ships was found identical to the seed the site's own -# code generates (13/13 tables, row by row) in the PR #106 audit; -# * refs/pr/37 ships an older kaggle seed (models table has 10 rows instead of -# 11: densenet121-chestxray is absent) and its archives cover only 17 of the -# 30 registered sites, so pinning there would break fetch_assets.sh. -# * the pinned revision's kaggle.tar.gz (sha256 -# dd6f1ab34f99989b300e3366225a8f8b0932d49d033b48f141938d16f1c0607d, -# 11278106 bytes) passes the repository's own -# scripts/validate_asset_archive.py: -# [fetch] validated 73 managed members for kaggle -# That 73-member pack has no directory entries, so the earlier -# `ValueError: unexpected managed path: 'kaggle/static'` rejection (from the -# superseded 11278264-byte pack, sha256 c533c283...) no longer applies and -# `scripts/fetch_assets.sh` installs sites/kaggle's assets from this pin. +# Hugging Face dataset holding WebHarbor's heavy static assets. +# Pin an immutable commit SHA; never a moving branch or refs/pr/. +# +# Merged dataset-main commit from HF asset PR #92. +# https://huggingface.co/datasets/ChilleD/WebHarbor/discussions/92 +# Its complete asset tree matches the tested candidate commit +# f09e586eec8bf1bca0bc0881e08b77f3c2a5508e. +# +# Adds bh_photo.tar.gz: 79,658,793 bytes, 511 managed archive members. +# SHA-256: 867363d5484eb114d647e236991017992d5ac91ae3415996ad43bf654d99bd9a +# All 32 pre-existing archives are unchanged from the previous main pin +# c32018ca3b3d67e7b858b1b85fb101aea5090cd7 (Berkeley, HF PR #91). +# This revision has archives for all 31 registered sites. The two additional +# archives (bandcamp and drugs_com) are ignored by fetch_assets.sh. +# B&H's bundle contains images and external cache; Docker generates its seed +# from tracked catalog data (sites/bh_photo/.build-generated-seed). repo: ChilleD/WebHarbor -revision: c32018ca3b3d67e7b858b1b85fb101aea5090cd7 +revision: fa1e8a5b9e8e5d0e42764cd658825f4dea088d8f diff --git a/AGENTS.md b/AGENTS.md index ed62c98ba..18e449d1c 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 -30 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. +31 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-40029:40000-40029 webharbor:dev +docker run -d -p 8101:8101 -p 40000-40030:40000-40030 webharbor:dev ``` Or use the published image directly: ```bash -docker run -d -p 8101:8101 -p 40000-40029:40000-40029 \ +docker run -d -p 8101:8101 -p 40000-40030:40000-40030 \ battalion7244/webharbor:latest ``` -Sites are on `40000`-`40029` in the order declared by `SITES=( ... )` in `websyn_start.sh`. Control plane: +Sites are on `40000`-`40030` in the order declared by `SITES=( ... )` in `websyn_start.sh`. Control plane: | Method | Path | Purpose | |--------|---------------------|-------------------------------------------| @@ -136,13 +136,13 @@ 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-41029:40000-40029 webharbor:dev + -p 8201:8101 -p 41000-41030:40000-40030 webharbor:dev # 4. control plane healthy, all sites alive curl -s http://localhost:8201/health | python3 -m json.tool | head # 5. every site renders 200 -for p in $(seq 41000 41029); do +for p in $(seq 41000 41030); do curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ done diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 936c7ba5a..6a62995a7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,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-40029:40000-40029 webharbor:dev + -p 8101:8101 -p 40000-40030:40000-40030 webharbor:dev # iterate locally... ./scripts/extract_assets.sh ../webharbor-static-pr/ # split assets out diff --git a/Dockerfile b/Dockerfile index 6d238e1af..aae7ecd60 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # WebHarbor — slim, self-contained image. -# 30 Flask mirror sites + control plane on :8101. +# 31 Flask mirror sites + control plane on :8101. FROM python:3.12-slim-bookworm @@ -103,6 +103,16 @@ os.makedirs('instance_seed', exist_ok=True); \ shutil.copy2('instance/rotten_tomatoes.db', 'instance_seed/rotten_tomatoes.db'); \ print('Rotten Tomatoes seed DB generated at build time.')" && rm -rf /opt/WebSyn/rotten_tomatoes/instance -EXPOSE 8101 40000-40029 +# B&H's asset bundle contains images; generate the reset seed from its tracked +# catalog in the image so a fresh checkout needs no locally prepared database. +RUN python3 /opt/check_asset_inventory.py /opt/WebSyn/bh_photo +RUN cd /opt/WebSyn/bh_photo && rm -rf instance instance_seed && PYTHONHASHSEED=0 python3 -c "\ +import app; \ +import os, shutil; \ +os.makedirs('instance_seed', exist_ok=True); \ +shutil.copy2('instance/bh_photo.db', 'instance_seed/bh_photo.db'); \ +print('B&H Photo seed DB generated at build time.')" && rm -rf instance + +EXPOSE 8101 40000-40030 CMD ["/opt/websyn_start.sh"] diff --git a/README.md b/README.md index b9e214ca4..f3973fec2 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** — 30 sites today, scaling to 100+ together +- **Community-driven** — 31 sites today, scaling to 100+ together ## 🚀 Quickstart One command to run all web environments: ```bash -docker run -p 8101:8101 -p 40000-40029:40000-40029 battalion7244/webharbor:latest +docker run -p 8101:8101 -p 40000-40030:40000-40030 battalion7244/webharbor:latest ``` -Then point your agent at `http://localhost:40000` through `http://localhost:40029` to explore 30 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, Merriam-Webster, IKEA, Phys.org, Target, TED, Ohio State University, Rotten Tomatoes, Compass, Walmart Careers, FedEx, WebMD Doctor, Healthline, Kaggle, NVIDIA, and UC Berkeley`. +Then point your agent at `http://localhost:40000` through `http://localhost:40030` to explore 31 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, Merriam-Webster, IKEA, Phys.org, Target, TED, Ohio State University, Rotten Tomatoes, Compass, Walmart Careers, FedEx, WebMD Doctor, Healthline, Kaggle, NVIDIA, UC Berkeley, and B&H Photo`. For sub-second reset between rollouts, expose the control plane and call `/reset/`: @@ -63,21 +63,21 @@ git clone https://github.com/aiming-lab/WebHarbor && cd WebHarbor ./scripts/build.sh # docker build -t webharbor:dev . ``` -### Local NVIDIA review candidate +### Site registry -This branch registers **30 sites**, the 30 entries listed above; NVIDIA took index 28 -when #107 merged, so UC Berkeley (the site under review here) is the last entry, -registry index 29, container port 40029 (local review host port 48029). The -published-image quickstart above is not a claim that this review candidate has been -published or accepted. +This checkout registers **31 sites**. NVIDIA remains at index 28, UC Berkeley +remains at index 29, and B&H Photo is appended at index 30. Build the image from +this checkout to use this registry; publishing source does not update the +published Docker image automatically. -| Site | Registry position | Container port | Local review host port | +| Site | Registry position | Container port | Example local review host port | | --- | --- | --- | --- | | NVIDIA | 28 | 40028 | 48028 | | UC Berkeley | 29 | 40029 | 48029 | +| B&H Photo | 30 | 40030 | 48030 | `websyn_start.sh`, `control_server.py`, the `Dockerfile` `EXPOSE` line and every -site's `tasks.jsonl` `web` URL agree on 30 sites and `40000-40029`; +site's `tasks.jsonl` `web` URL agree on 31 sites and `40000-40030`; `scripts/check_site_registry.py` (run by `scripts/check_assets.sh`) fails when they drift. @@ -85,7 +85,7 @@ After preparing the candidate assets and building `webharbor:dev`, the local review deployment uses: ```bash -docker run -p 127.0.0.1:48080:8101 -p 127.0.0.1:48000-48029:40000-40029 webharbor:dev +docker run -p 127.0.0.1:48080:8101 -p 127.0.0.1:48000-48030:40000-40030 webharbor:dev ``` NVIDIA inherits the site contribution from @KaKituken @@ -97,28 +97,22 @@ passed. ### Asset delivery status -`.assets-revision` is pinned to `c32018ca3b3d67e7b858b1b85fb101aea5090cd7`, the -head commit of HF dataset `main` and the squash-merge commit of HF dataset PR -[#91](https://huggingface.co/datasets/ChilleD/WebHarbor/discussions/91) -("berkeley: synthetic imagery bundle (164 files)", merged 2026-09-15T04:00:54Z). -Every other registered site's archive on that commit has the same size and LFS oid -as on the previous pin `b7e605c0ec5fc47de85b09e7427162cc50e38980`, and all 30 site -archives are byte-identical to the ones the interim `refs/pr/91` pin served, so the -pin change adds the UC Berkeley bundle without altering any other site's assets: - -- the pinned revision carries 32 `*.tar.gz` (30 registered sites plus - `bandcamp.tar.gz` and `drugs_com.tar.gz`, which `fetch_assets.sh` ignores for - sites this checkout does not register); -- `nvidia.tar.gz` at that revision is the same 37-member archive as at the previous - pin, so `scripts/validate_asset_archive.py nvidia.tar.gz nvidia` prints - `[fetch] validated 37 managed members for nvidia` and exits 0; -- `berkeley.tar.gz` at that revision has 171 managed members, so - `scripts/validate_asset_archive.py berkeley.tar.gz berkeley` prints - `[fetch] validated 171 managed members for berkeley`; -- `./scripts/fetch_assets.sh` at this pin extracts all 30 registered sites - (`[fetch] done — 30 site(s) extracted into sites/`). - -The previous pin `b7e605c0ec5fc47de85b09e7427162cc50e38980` is the squash-merge +`.assets-revision` pins the merged dataset commit `fa1e8a5b9e8e5d0e42764cd658825f4dea088d8f` +from [HF asset PR #92](https://huggingface.co/datasets/ChilleD/WebHarbor/discussions/92). +Its complete asset tree matches the tested candidate commit +`f09e586eec8bf1bca0bc0881e08b77f3c2a5508e`. + +This revision adds `bh_photo.tar.gz` and preserves all 32 existing archives from +`c32018ca3b3d67e7b858b1b85fb101aea5090cd7` byte-for-byte, including Berkeley and +NVIDIA. It carries 33 archives for 31 registered sites plus the unregistered +Bandcamp and Drugs.com archives, which `fetch_assets.sh` ignores. A clean asset +fetch downloaded and extracted all 31 registered sites successfully. + +B&H's archive contains images and external cache. The Docker build validates +its 508 declared assets and generates `instance_seed/bh_photo.db` from the tracked +catalog. No manually prepared B&H database is required for a fresh build. + +The earlier pin `b7e605c0ec5fc47de85b09e7427162cc50e38980` is the squash-merge commit of HF dataset PR [#85](https://huggingface.co/datasets/ChilleD/WebHarbor/discussions/85) on the dataset's `main`. It sits on top of PR @@ -130,6 +124,7 @@ added the first reviewed NVIDIA bundle. | --- | --- | --- | --- | | `nvidia.tar.gz` at the current pin | 37 | 16,340,955 | `617a3e3740ba6706bcab786c8a5c3f9a22ecbb39eff5728ad2c12e4992cb098b` | | `berkeley.tar.gz` at the current pin (HF PR #91) | 171 | 6,951,483 | `ab9d2716ae8d06540a181b5e60c37f613d87b103864b467511da546b1b173789` | +| `bh_photo.tar.gz` at the current pin (HF PR #92) | 511 | 79,658,793 | `867363d5484eb114d647e236991017992d5ac91ae3415996ad43bf654d99bd9a` | | previous pin's `nvidia.tar.gz` (HF PR #84, superseded) | 34 | 9,927,312 | `ee8c6ba966e7a8f7fb5ad2d7ff0134ab98e7b80d6cc77f3328217405b8b34e2f` | PR #85 replaces five product images and adds three dedicated hero images (see diff --git a/control_server.py b/control_server.py index 41dd14ffc..aa0e606ff 100644 --- a/control_server.py +++ b/control_server.py @@ -30,7 +30,7 @@ 'ikea', 'phys_org', 'target', 'ted', 'osu', 'rotten_tomatoes', 'compass', 'walmart_careers', 'fedex', 'webmd_doctor', 'healthline', 'kaggle', - 'nvidia', 'berkeley', + 'nvidia', 'berkeley', 'bh_photo', ] BASE_PORT = 40000 WEBSYN_DIR = '/opt/WebSyn' diff --git a/sites/bh_photo/.build-generated-seed b/sites/bh_photo/.build-generated-seed new file mode 100644 index 000000000..2e684635d --- /dev/null +++ b/sites/bh_photo/.build-generated-seed @@ -0,0 +1 @@ +The Dockerfile generates instance_seed/bh_photo.db from the tracked source_catalog.json and seed_data.py. diff --git a/sites/bh_photo/.gitignore b/sites/bh_photo/.gitignore new file mode 100644 index 000000000..18580aa7c --- /dev/null +++ b/sites/bh_photo/.gitignore @@ -0,0 +1,8 @@ +# Harvested archive HTML and the manifests built from it are build-time +# intermediates; the runtime catalogue lives in instance_seed/bh_photo.db. +sources/cache/ + +# Recreated from instance_seed/ on every boot; not committed. +instance/ +__pycache__/ +*.pyc diff --git a/sites/bh_photo/.requires-images b/sites/bh_photo/.requires-images new file mode 100644 index 000000000..2f3cd7680 --- /dev/null +++ b/sites/bh_photo/.requires-images @@ -0,0 +1 @@ +This site requires static/images from the pinned Hugging Face asset bundle. diff --git a/sites/bh_photo/_health.py b/sites/bh_photo/_health.py new file mode 100644 index 000000000..b3489de72 --- /dev/null +++ b/sites/bh_photo/_health.py @@ -0,0 +1,3 @@ +"""Per-site health probe (optional, called by control_server).""" +def health(): + return {"ok": True, "site": "bh_photo"} diff --git a/sites/bh_photo/app.py b/sites/bh_photo/app.py new file mode 100644 index 000000000..4f981ea24 --- /dev/null +++ b/sites/bh_photo/app.py @@ -0,0 +1,1825 @@ +import os +import re +from collections import defaultdict +from datetime import datetime + +from flask import ( + Flask, + abort, + flash, + redirect, + render_template, + request, + send_from_directory, + session, + url_for, +) +from flask_bcrypt import Bcrypt +from werkzeug.security import check_password_hash +from flask_login import ( + LoginManager, + UserMixin, + current_user, + login_required, + login_user, + logout_user, +) +from flask_sqlalchemy import SQLAlchemy + + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +INSTANCE_DIR = os.path.join(BASE_DIR, "instance") +SITE_DB = os.path.join(INSTANCE_DIR, "bh_photo.db") +os.makedirs(INSTANCE_DIR, exist_ok=True) + +app = Flask(__name__, instance_path=INSTANCE_DIR) +app.config["SECRET_KEY"] = "webharbor-bh-photo-demo-key" +app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{SITE_DB}" +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + +db = SQLAlchemy(app) +bcrypt = Bcrypt(app) +login_manager = LoginManager(app) +login_manager.login_view = "login" +login_manager.login_message = "Please sign in to access saved shopping tools." +login_manager.login_message_category = "info" + +STOP_WORDS = { + "the", + "a", + "an", + "for", + "to", + "of", + "and", + "or", + "in", + "on", + "with", + "by", + "from", + "new", +} + + +class User(db.Model, UserMixin): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(140), unique=True, nullable=False, index=True) + username = db.Column(db.String(80), unique=True, nullable=False, index=True) + password_hash = db.Column(db.String(255), nullable=False) + display_name = db.Column(db.String(120), nullable=False) + phone = db.Column(db.String(40), default="") + company = db.Column(db.String(120), default="") + role = db.Column(db.String(80), default="Creator") + preferred_store_id = db.Column(db.Integer, db.ForeignKey("store_locations.id")) + newsletter_opt_in = db.Column(db.Boolean, default=True) + sms_opt_in = db.Column(db.Boolean, default=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + addresses = db.relationship("Address", backref="user", lazy=True, cascade="all, delete-orphan") + cart_items = db.relationship("CartItem", backref="user", lazy=True, cascade="all, delete-orphan") + wishlist_items = db.relationship("WishlistItem", backref="user", lazy=True, cascade="all, delete-orphan") + compare_items = db.relationship("CompareItem", backref="user", lazy=True, cascade="all, delete-orphan") + orders = db.relationship("Order", backref="user", lazy=True, cascade="all, delete-orphan") + reservations = db.relationship("StoreReservation", backref="user", lazy=True, cascade="all, delete-orphan") + search_logs = db.relationship("SearchLog", backref="user", lazy=True, cascade="all, delete-orphan") + + def set_password(self, password: str) -> None: + """Hash a password chosen at runtime. + + Accounts registered through the site use bcrypt. Benchmark accounts are + written by the seed with a PBKDF2 hash over a derived, fixed salt, so the + shipped database can be regenerated byte for byte from source; bcrypt + salts randomly and made that impossible. + """ + self.password_hash = bcrypt.generate_password_hash(password).decode("utf-8") + + def check_password(self, password: str) -> bool: + if self.password_hash.startswith("pbkdf2:"): + return check_password_hash(self.password_hash, password) + return bcrypt.check_password_hash(self.password_hash, password) + + +class Address(db.Model): + __tablename__ = "addresses" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + label = db.Column(db.String(40), default="Studio") + recipient = db.Column(db.String(120), nullable=False) + line1 = db.Column(db.String(200), nullable=False) + line2 = db.Column(db.String(200), default="") + city = db.Column(db.String(80), nullable=False) + state = db.Column(db.String(40), nullable=False) + zip_code = db.Column(db.String(20), nullable=False) + country = db.Column(db.String(60), default="United States") + phone = db.Column(db.String(40), default="") + is_default = db.Column(db.Boolean, default=False) + + +class Category(db.Model): + __tablename__ = "categories" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), nullable=False) + slug = db.Column(db.String(120), unique=True, nullable=False, index=True) + description = db.Column(db.Text, default="") + hero_copy = db.Column(db.Text, default="") + icon_label = db.Column(db.String(32), default="") + nav_order = db.Column(db.Integer, default=0) + parent_id = db.Column(db.Integer, db.ForeignKey("categories.id")) + + parent = db.relationship("Category", remote_side=[id], backref="children") + products = db.relationship("Product", backref="category", lazy=True) + + +class Brand(db.Model): + __tablename__ = "brands" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), unique=True, nullable=False) + slug = db.Column(db.String(120), unique=True, nullable=False, index=True) + blurb = db.Column(db.Text, default="") + origin = db.Column(db.String(80), default="") + badge_color = db.Column(db.String(16), default="#24423f") + + products = db.relationship("Product", backref="brand", lazy=True) + + +class Product(db.Model): + __tablename__ = "products" + + id = db.Column(db.Integer, primary_key=True) + category_id = db.Column(db.Integer, db.ForeignKey("categories.id"), nullable=False) + brand_id = db.Column(db.Integer, db.ForeignKey("brands.id"), nullable=False) + name = db.Column(db.String(200), nullable=False) + slug = db.Column(db.String(220), unique=True, nullable=False, index=True) + sku = db.Column(db.String(32), unique=True, nullable=False) + # upstream prints both the B&H SKU and the manufacturer part number + mpn = db.Column(db.String(64), default="") + short_description = db.Column(db.String(220), default="") + description = db.Column(db.Text, default="") + search_blob = db.Column(db.Text, default="") + price = db.Column(db.Float, nullable=False) + list_price = db.Column(db.Float, default=0) + rating = db.Column(db.Float, default=4.5) + review_count = db.Column(db.Integer, default=0) + qa_count = db.Column(db.Integer, default=0) + condition = db.Column(db.String(40), default="New") + availability = db.Column(db.String(40), default="In Stock") + stock_level = db.Column(db.Integer, default=0) + pickup_available = db.Column(db.Boolean, default=True) + pickup_message = db.Column(db.String(120), default="") + shipping_message = db.Column(db.String(120), default="") + return_window = db.Column(db.String(80), default="30-Day Return Window") + warranty = db.Column(db.String(120), default="1-Year Demo Warranty") + best_seller_rank = db.Column(db.Integer, default=999) + sort_newness = db.Column(db.Integer, default=0) + top_category_slug = db.Column(db.String(80), default="") + subcategory_slug = db.Column(db.String(80), default="") + product_family = db.Column(db.String(80), default="") + product_type = db.Column(db.String(80), default="") + sensor_size = db.Column(db.String(80), default="") + mount_type = db.Column(db.String(80), default="") + focal_length = db.Column(db.String(80), default="") + focal_length_bucket = db.Column(db.String(40), default="") + megapixels = db.Column(db.Float, default=0) + capacity_gb = db.Column(db.Integer, default=0) + connectivity = db.Column(db.String(120), default="") + pickup_store_count = db.Column(db.Integer, default=0) + image_path = db.Column(db.String(220), default="") + accent_color = db.Column(db.String(16), default="#24504d") + release_label = db.Column(db.String(60), default="") + is_featured = db.Column(db.Boolean, default=False) + is_bundle_anchor = db.Column(db.Boolean, default=False) + is_used_highlight = db.Column(db.Boolean, default=False) + + images = db.relationship("ProductImage", backref="product", lazy=True, cascade="all, delete-orphan") + spec_groups = db.relationship("ProductSpecGroup", backref="product", lazy=True, cascade="all, delete-orphan") + variants = db.relationship("ProductVariant", backref="product", lazy=True, cascade="all, delete-orphan") + reviews = db.relationship("ProductReview", backref="product", lazy=True, cascade="all, delete-orphan") + questions = db.relationship("ProductQuestion", backref="product", lazy=True, cascade="all, delete-orphan") + store_inventory = db.relationship("StoreInventory", backref="product", lazy=True, cascade="all, delete-orphan") + deals = db.relationship("Deal", backref="product", lazy=True, cascade="all, delete-orphan") + + def active_deal(self): + return next((deal for deal in self.deals if deal.is_active), None) + + @property + def display_price(self) -> float: + deal = self.active_deal() + return deal.sale_price if deal else self.price + + @property + def savings_amount(self) -> float: + return max((self.list_price or self.price) - self.display_price, 0) + + @property + def savings_percent(self) -> int: + base = self.list_price or self.price + if not base or self.display_price >= base: + return 0 + return int(round((base - self.display_price) / base * 100)) + + @property + def main_image(self) -> str: + if self.image_path: + return self.image_path + if self.images: + return self.images[0].path + return "images/bh-photo-fallback.svg" + + @property + def top_spec_map(self) -> dict: + spec_map = {} + for group in self.spec_groups: + for spec in group.specs: + spec_map[spec.name] = spec.value + return spec_map + + # packaging rows are the last thing a shopper wants summarised on a listing + SKIP_FEATURE_GROUPS = {"packaging info", "shipping"} + SKIP_FEATURE_LABELS = {"box dimensions (lxwxh)", "package weight", "box dimensions"} + DETAIL_ONLY_FEATURE_LABELS = { + "angle of view", + "battery", + "native resolution", + "read speed", + "weight", + } + DETAIL_ONLY_FEATURE_PAIRS = {("monitor", "resolution")} + + @property + def key_features(self) -> list[tuple[str, str]]: + """The short specification list upstream prints on a listing row.""" + preferred, fallback = [], [] + for group in sorted(self.spec_groups, key=lambda item: item.sort_order): + group_name = (group.title or "").strip().lower() + skip_group = group_name in self.SKIP_FEATURE_GROUPS + for spec in sorted(group.specs, key=lambda item: item.sort_order): + spec_name = (spec.name or "").strip().lower() + if ( + spec_name in self.DETAIL_ONLY_FEATURE_LABELS + or (group_name, spec_name) in self.DETAIL_ONLY_FEATURE_PAIRS + ): + continue + entry = (spec.name, spec.value) + if skip_group or spec_name in self.SKIP_FEATURE_LABELS: + fallback.append(entry) + else: + preferred.append(entry) + if len(preferred) == 4: + return preferred + return (preferred + fallback)[:4] + + @property + def savings_amount(self) -> float: + if self.list_price and self.list_price > self.display_price: + return round(self.list_price - self.display_price, 2) + return 0.0 + + @property + def average_review_label(self) -> str: + return f"{self.rating:.1f}" + + +class ProductImage(db.Model): + __tablename__ = "product_images" + + id = db.Column(db.Integer, primary_key=True) + product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False) + path = db.Column(db.String(220), nullable=False) + label = db.Column(db.String(80), default="Primary") + sort_order = db.Column(db.Integer, default=0) + + +class ProductSpecGroup(db.Model): + __tablename__ = "product_spec_groups" + + id = db.Column(db.Integer, primary_key=True) + product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False) + title = db.Column(db.String(120), nullable=False) + sort_order = db.Column(db.Integer, default=0) + + specs = db.relationship("ProductSpec", backref="group", lazy=True, cascade="all, delete-orphan") + + +class ProductSpec(db.Model): + __tablename__ = "product_specs" + + id = db.Column(db.Integer, primary_key=True) + group_id = db.Column(db.Integer, db.ForeignKey("product_spec_groups.id"), nullable=False) + name = db.Column(db.String(120), nullable=False) + value = db.Column(db.String(240), nullable=False) + sort_order = db.Column(db.Integer, default=0) + + +class ProductVariant(db.Model): + __tablename__ = "product_variants" + + id = db.Column(db.Integer, primary_key=True) + product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False) + label = db.Column(db.String(120), nullable=False) + variant_type = db.Column(db.String(40), default="Kit") + value = db.Column(db.String(120), nullable=False) + price_delta = db.Column(db.Float, default=0) + + +class ProductReview(db.Model): + __tablename__ = "product_reviews" + + id = db.Column(db.Integer, primary_key=True) + product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False) + author_name = db.Column(db.String(120), nullable=False) + headline = db.Column(db.String(180), nullable=False) + body = db.Column(db.Text, nullable=False) + rating = db.Column(db.Integer, default=5) + verified_purchase = db.Column(db.Boolean, default=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + +class ProductQuestion(db.Model): + __tablename__ = "product_questions" + + id = db.Column(db.Integer, primary_key=True) + product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False) + question = db.Column(db.Text, nullable=False) + asker_name = db.Column(db.String(120), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + answers = db.relationship("ProductAnswer", backref="question_ref", lazy=True, cascade="all, delete-orphan") + + +class ProductAnswer(db.Model): + __tablename__ = "product_answers" + + id = db.Column(db.Integer, primary_key=True) + question_id = db.Column(db.Integer, db.ForeignKey("product_questions.id"), nullable=False) + responder_name = db.Column(db.String(120), nullable=False) + answer = db.Column(db.Text, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + +class Bundle(db.Model): + __tablename__ = "bundles" + + 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) + description = db.Column(db.Text, default="") + image_path = db.Column(db.String(220), default="") + bundle_price = db.Column(db.Float, nullable=False) + list_price = db.Column(db.Float, nullable=False) + badge = db.Column(db.String(60), default="Bundle") + audience = db.Column(db.String(120), default="") + featured = db.Column(db.Boolean, default=False) + + items = db.relationship("BundleItem", backref="bundle", lazy=True, cascade="all, delete-orphan") + + @property + def savings(self) -> float: + return max(self.list_price - self.bundle_price, 0) + + +class BundleItem(db.Model): + __tablename__ = "bundle_items" + + id = db.Column(db.Integer, primary_key=True) + bundle_id = db.Column(db.Integer, db.ForeignKey("bundles.id"), nullable=False) + product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False) + quantity = db.Column(db.Integer, default=1) + + product = db.relationship("Product") + + +class CartItem(db.Model): + __tablename__ = "cart_items" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False) + quantity = db.Column(db.Integer, default=1) + variant_label = db.Column(db.String(120), default="") + bundle_label = db.Column(db.String(160), default="") + added_at = db.Column(db.DateTime, default=datetime.utcnow) + + product = db.relationship("Product") + + @property + def unit_price(self) -> float: + return self.product.display_price if self.product else 0 + + @property + def line_total(self) -> float: + return self.unit_price * self.quantity + + +class WishlistItem(db.Model): + __tablename__ = "wishlist_items" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + product = db.relationship("Product") + + +class CompareItem(db.Model): + __tablename__ = "compare_items" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + product = db.relationship("Product") + + +class StoreLocation(db.Model): + __tablename__ = "store_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(80), nullable=False) + state = db.Column(db.String(40), nullable=False) + address = db.Column(db.String(200), nullable=False) + pickup_hours = db.Column(db.String(120), default="") + contact_phone = db.Column(db.String(40), default="") + inventory_note = db.Column(db.String(120), default="") + + inventory = db.relationship("StoreInventory", backref="store_location", lazy=True, cascade="all, delete-orphan") + reservations = db.relationship("StoreReservation", backref="store_location", lazy=True, cascade="all, delete-orphan") + + +class StoreInventory(db.Model): + __tablename__ = "store_inventory" + + id = db.Column(db.Integer, primary_key=True) + store_id = db.Column(db.Integer, db.ForeignKey("store_locations.id"), nullable=False) + product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False) + quantity = db.Column(db.Integer, default=0) + pickup_eta = db.Column(db.String(120), default="") + + +class StoreReservation(db.Model): + __tablename__ = "store_reservations" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey("store_locations.id"), nullable=False) + product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False) + quantity = db.Column(db.Integer, default=1) + status = db.Column(db.String(40), default="Reserved") + pickup_window = db.Column(db.String(120), default="") + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + product = db.relationship("Product") + + +class Order(db.Model): + __tablename__ = "orders" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + order_number = db.Column(db.String(40), unique=True, nullable=False) + status = db.Column(db.String(40), default="Processing") + subtotal = db.Column(db.Float, default=0) + shipping = db.Column(db.Float, default=0) + tax = db.Column(db.Float, default=0) + total = db.Column(db.Float, default=0) + fulfillment = db.Column(db.String(80), default="Ship to address") + payment_label = db.Column(db.String(120), default="Demo Visa ending in 4242") + note = db.Column(db.String(240), default="") + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + items = db.relationship("OrderItem", backref="order", lazy=True, cascade="all, delete-orphan") + + +class OrderItem(db.Model): + __tablename__ = "order_items" + + id = db.Column(db.Integer, primary_key=True) + order_id = db.Column(db.Integer, db.ForeignKey("orders.id"), nullable=False) + product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False) + product_name = db.Column(db.String(200), nullable=False) + image_path = db.Column(db.String(220), default="") + quantity = db.Column(db.Integer, default=1) + price = db.Column(db.Float, default=0) + variant_label = db.Column(db.String(120), default="") + + product = db.relationship("Product") + + +class Deal(db.Model): + __tablename__ = "deals" + + id = db.Column(db.Integer, primary_key=True) + product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False) + label = db.Column(db.String(80), default="Deal") + sale_price = db.Column(db.Float, nullable=False) + deal_type = db.Column(db.String(40), default="sale") + is_active = db.Column(db.Boolean, default=True) + + +class SearchLog(db.Model): + __tablename__ = "search_logs" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id")) + query = db.Column(db.String(220), nullable=False) + scope = db.Column(db.String(80), default="all") + result_count = db.Column(db.Integer, default=0) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + +@login_manager.user_loader +def load_user(user_id: str): + return User.query.get(int(user_id)) + + +def tokenized(text: str) -> list[str]: + text = re.sub(r"\bfibre\b", "fiber", (text or "").lower()) + singular = {"monopods": "monopod", "lenses": "lens", "cameras": "camera", "cards": "card"} + return [ + singular.get(token, token) + for token in re.split(r"[^a-zA-Z0-9]+", text) + if token and token not in STOP_WORDS and len(token) > 1 + ] + + +def currency(value: float) -> str: + return f"${value:,.2f}" + + +def descendant_category_ids(category: Category) -> set[int]: + ids = {category.id} + for child in category.children: + ids.update(descendant_category_ids(child)) + return ids + + +def all_products() -> list[Product]: + return Product.query.options( + db.joinedload(Product.brand), + db.joinedload(Product.category), + db.joinedload(Product.deals), + db.joinedload(Product.images), + db.joinedload(Product.spec_groups).joinedload(ProductSpecGroup.specs), + ).all() + + +def product_search_score(product: Product, query: str) -> int: + tokens = tokenized(query) + if not tokens: + return 0 + text = " ".join( + [ + product.name, + product.short_description, + product.description, + product.search_blob, + product.brand.name if product.brand else "", + product.category.name if product.category else "", + ] + ).lower() + text = re.sub(r"\bfibre\b", "fiber", text) + # Type A and Type B are incompatible formats, not stop words. Keep this + # phrase even though single letters are omitted from ordinary token search. + formats = re.findall(r"\bcfexpress\s+(?:\d+(?:\.\d+)?\s+)?type\s+([ab])\b", query.lower()) + if formats and not all(re.search(rf"\bcfexpress\s+(?:\d+(?:\.\d+)?\s+)?type\s+{kind}\b", product.name.lower()) for kind in formats): + return 0 + words = set(tokenized(text)) + if not all(token in words for token in tokens): + return 0 + name_words = set(tokenized(product.name)) + return len(tokens) + sum(token in name_words for token in tokens) + + +def apply_product_filters(products: list[Product], args, *, query_text: str = "") -> list[Product]: + filtered = list(products) + + if query_text: + scored = [] + for product in filtered: + score = product_search_score(product, query_text) + if score > 0: + scored.append((product, score)) + filtered = [product for product, _score in sorted(scored, key=lambda item: (-item[1], item[0].best_seller_rank, item[0].name))] + + brand_slug = args.get("brand", "").strip() + if brand_slug: + filtered = [product for product in filtered if product.brand and product.brand.slug == brand_slug] + + min_price = args.get("min_price", "").strip() + if min_price: + try: + threshold = float(min_price) + filtered = [product for product in filtered if product.display_price >= threshold] + except ValueError: + pass + + max_price = args.get("max_price", "").strip() + if max_price: + try: + threshold = float(max_price) + filtered = [product for product in filtered if product.display_price <= threshold] + except ValueError: + pass + + rating_filter = args.get("rating", "").strip() + if rating_filter: + try: + threshold = float(rating_filter) + filtered = [product for product in filtered if product.rating >= threshold] + except ValueError: + pass + + availability = args.get("availability", "").strip() + if availability == "in-stock": + filtered = [product for product in filtered if product.availability == "In Stock"] + elif availability == "preorder": + filtered = [product for product in filtered if "Pre-Order" in product.availability] + elif availability == "pickup": + filtered = [product for product in filtered if product.pickup_available and product.pickup_store_count > 0] + + condition = args.get("condition", "").strip() + if condition: + filtered = [product for product in filtered if product.condition.lower() == condition.lower()] + + within = args.get("within", "").strip() + if within: + needle = within.lower() + filtered = [product for product in filtered + if needle in (product.name or "").lower() + or needle in (product.search_blob or "").lower()] + + sensor_size = args.get("sensor_size", "").strip() + if sensor_size: + filtered = [product for product in filtered if product.sensor_size == sensor_size] + + mount = args.get("mount", "").strip() + if mount: + filtered = [product for product in filtered if product.mount_type == mount] + + focal_length = args.get("focal_length", "").strip() + if focal_length: + filtered = [product for product in filtered if product.focal_length_bucket == focal_length] + + megapixels = args.get("megapixels", "").strip() + if megapixels: + try: + threshold = float(megapixels) + filtered = [product for product in filtered if product.megapixels >= threshold] + except ValueError: + pass + + capacity = args.get("capacity", "").strip() + if capacity == "256gb": + filtered = [product for product in filtered if product.capacity_gb >= 256] + elif capacity == "1tb": + filtered = [product for product in filtered if product.capacity_gb >= 1024] + + connectivity = args.get("connectivity", "").strip() + if connectivity: + filtered = [product for product in filtered if connectivity.lower() in (product.connectivity or "").lower()] + + pickup = args.get("pickup", "").strip() + if pickup == "yes": + filtered = [product for product in filtered if product.pickup_available and product.pickup_store_count > 0] + + sort_key = args.get("sort", "relevance") + if sort_key == "price_asc": + filtered.sort(key=lambda product: (product.display_price, product.best_seller_rank, product.name)) + elif sort_key == "price_desc": + filtered.sort(key=lambda product: (-product.display_price, product.best_seller_rank, product.name)) + elif sort_key == "newest": + filtered.sort(key=lambda product: (-product.sort_newness, product.name)) + elif sort_key == "rating": + filtered.sort(key=lambda product: (-product.rating, -product.review_count, product.name)) + elif sort_key == "bestsellers": + filtered.sort(key=lambda product: (product.best_seller_rank, product.name)) + elif sort_key == "name": + filtered.sort(key=lambda product: product.name) + elif not query_text: + filtered.sort(key=lambda product: (product.best_seller_rank, product.name)) + + return filtered + + +def filters_for(products: list[Product]) -> dict: + return { + "brands": sorted({product.brand.slug: product.brand.name for product in products if product.brand}.items(), key=lambda item: item[1]), + "sensor_sizes": sorted({product.sensor_size for product in products if product.sensor_size}), + "mounts": sorted({product.mount_type for product in products if product.mount_type}), + "focal_lengths": sorted({product.focal_length_bucket for product in products if product.focal_length_bucket}), + "connectivity": sorted({product.connectivity for product in products if product.connectivity}), + } + + +def current_compare_products() -> list[Product]: + compare_ids = [] + if current_user.is_authenticated: + compare_ids = [item.product_id for item in current_user.compare_items] + else: + compare_ids = session.get("compare_ids", []) + if not compare_ids: + return [] + products = Product.query.filter(Product.id.in_(compare_ids[:4])).all() + products.sort(key=lambda product: compare_ids.index(product.id)) + return products + + +def cart_metrics(): + if not current_user.is_authenticated: + return {"count": 0, "subtotal": 0.0} + items = current_user.cart_items + return { + "count": sum(item.quantity for item in items), + "subtotal": round(sum(item.line_total for item in items), 2), + } + + +def order_totals_for(items: list[CartItem]) -> dict: + subtotal = round(sum(item.line_total for item in items), 2) + shipping = 0 if subtotal >= 99 else 14.95 + tax = round(subtotal * 0.08875, 2) + total = round(subtotal + shipping + tax, 2) + return {"subtotal": subtotal, "shipping": shipping, "tax": tax, "total": total} + + +def store_options_for(product: Product) -> list[StoreInventory]: + inventory = sorted(product.store_inventory, key=lambda item: (-item.quantity, item.store_location.city)) + return [item for item in inventory if item.quantity > 0] + + +def merge_compare_into_session(product_id: int) -> None: + compare_ids = session.get("compare_ids", []) + if product_id not in compare_ids: + compare_ids.append(product_id) + session["compare_ids"] = compare_ids[:4] + session.modified = True + + +def compare_cell_map(products: list[Product]) -> dict: + grouped = defaultdict(list) + all_names = defaultdict(list) + for product in products: + for group in sorted(product.spec_groups, key=lambda item: item.sort_order): + for spec in sorted(group.specs, key=lambda item: item.sort_order): + grouped[group.title].append((product.id, spec.name, spec.value)) + all_names[group.title].append(spec.name) + + result = {} + for group_name, rows in grouped.items(): + names = [] + seen = set() + for name in all_names[group_name]: + if name not in seen: + names.append(name) + seen.add(name) + group_rows = [] + for spec_name in names: + values = {} + for product in products: + values[product.id] = product.top_spec_map.get(spec_name, "—") + differences = len(set(values.values())) > 1 + group_rows.append({"name": spec_name, "values": values, "different": differences}) + result[group_name] = group_rows + return result + + +def build_compare_rows(products: list[Product]) -> dict: + grouped_names = defaultdict(list) + spec_lookup = {} + + for product in products: + per_product_specs = {} + for group in sorted(product.spec_groups, key=lambda item: item.sort_order): + grouped_names[group.title] + for spec in sorted(group.specs, key=lambda item: item.sort_order): + grouped_names[group.title].append(spec.name) + per_product_specs[spec.name] = spec.value + spec_lookup[product.id] = per_product_specs + + compare_rows = {} + for group_name, spec_names in grouped_names.items(): + ordered_names = [] + seen = set() + for spec_name in spec_names: + if spec_name not in seen: + ordered_names.append(spec_name) + seen.add(spec_name) + rows = [] + for spec_name in ordered_names: + cells = { + product.id: spec_lookup.get(product.id, {}).get(spec_name, "--") + for product in products + } + rows.append( + { + "name": spec_name, + "cells": cells, + "different": len(set(cells.values())) > 1, + } + ) + compare_rows[group_name] = rows + return compare_rows + + +def log_search(query: str, scope: str, result_count: int) -> None: + """Record the search without disturbing what this request already loaded. + + Committing on db.session expires every instance in the identity map. The + search page has just loaded the whole catalogue eagerly, so the next + attribute read after such a commit reloads all 296 products one row at a + time - twenty seconds of the request, spent re-fetching rows it already had. + The log is append-only and nothing in the request reads it back, so it goes + out on its own connection and leaves the session alone. + """ + if not query: + return + with db.engine.begin() as connection: + connection.execute( + SearchLog.__table__.insert().values( + user_id=current_user.id if current_user.is_authenticated else None, + query=query[:220], + scope=scope[:80], + result_count=result_count, + ) + ) + + +@app.template_filter("currency") +def currency_filter(value: float) -> str: + return currency(value) + + +@app.context_processor +def inject_globals(): + top_categories = ( + Category.query.filter_by(parent_id=None).order_by(Category.nav_order, Category.name).all() + ) + featured_brands = Brand.query.order_by(Brand.name).limit(10).all() + def page_url(number: int) -> str: + """Build a page link that keeps the active filters. + + A category listing addresses its pages the way upstream does, as + /c//pn/; every other listing carries a page parameter. + """ + args = {key: value for key, value in request.args.items(multi=False) if key != "page"} + if request.endpoint == "category_view": + slug = (request.view_args or {}).get("slug") + if number == 1: + return url_for("category_view", slug=slug, **args) + return url_for("category_view", slug=slug, page=number, **args) + if number > 1: + args["page"] = number + return url_for(request.endpoint, **{**(request.view_args or {}), **args}) + + def view_url(mode: str) -> str: + """Switch the listing between the list and grid layouts upstream offers.""" + args = {key: value for key, value in request.args.items(multi=False)} + args["view"] = mode + return url_for(request.endpoint, **{**(request.view_args or {}), **args}) + + metrics = cart_metrics() + return { + "page_url": page_url, + "view_url": view_url, + "view_mode": request.args.get("view", "list"), + "top_categories": top_categories, + "featured_brands": featured_brands, + "cart_count": metrics["count"], + "compare_count": len(current_compare_products()), + "wishlist_count": len(current_user.wishlist_items) if current_user.is_authenticated else 0, + "wishlist_product_ids": {item.product_id for item in current_user.wishlist_items} if current_user.is_authenticated else set(), + "demo_notice": "Offline research mirror. Catalog facts are sourced from archived B&H pages; accounts, reviews and orders are generated test data.", + } + + +@app.route("/") +def index(): + featured = Product.query.filter_by(is_featured=True).order_by(Product.best_seller_rank).limit(8).all() + deals = Product.query.join(Deal).filter(Deal.is_active.is_(True)).order_by(Product.best_seller_rank).limit(6).all() + used_highlights = Product.query.filter(Product.condition != "New").order_by(Product.rating.desc()).limit(6).all() + bundles = Bundle.query.order_by(Bundle.featured.desc(), Bundle.title).limit(4).all() + categories = Category.query.filter_by(parent_id=None).order_by(Category.nav_order).all() + spotlight = Product.query.filter_by(is_bundle_anchor=True).order_by(Product.best_seller_rank).limit(4).all() + # upstream fronts each department with a product photo, so pick one archived + # image per department for the department tiles + department_images = {} + for category in categories: + sample = ( + Product.query.filter( + Product.top_category_slug == category.slug, + Product.image_path.like("images/products/%"), + ) + .order_by(Product.best_seller_rank) + .first() + ) + department_images[category.slug] = sample.image_path if sample else None + return render_template( + "index.html", + featured=featured, + deals=deals, + used_highlights=used_highlights, + bundles=bundles, + categories=categories, + department_images=department_images, + spotlight=spotlight, + ) + + +@app.route("/categories") +def categories(): + top_categories = Category.query.filter_by(parent_id=None).order_by(Category.nav_order).all() + return render_template("categories.html", categories=top_categories) + + +PAGE_SIZE = 30 + + +def paginate(results: list, page: int) -> dict: + """Split a result list the way the upstream listing does: 30 rows per page.""" + total = len(results) + pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE) + page = min(max(page, 1), pages) + start = (page - 1) * PAGE_SIZE + window = results[start:start + PAGE_SIZE] + return { + "items": window, + "page": page, + "pages": pages, + "total": total, + "first_index": start + 1 if window else 0, + "last_index": start + len(window), + } + + +@app.route("/c/") +@app.route("/c//pn/") +def category_view(slug: str, page: int = 1): + category = Category.query.filter_by(slug=slug).first_or_404() + ids = descendant_category_ids(category) + products = [product for product in all_products() if product.category_id in ids] + children = Category.query.filter_by(parent_id=category.id).order_by(Category.nav_order).all() + child_tiles = [] + for child in children: + members = [product for product in products if product.subcategory_slug == child.slug] + sample = next((p for p in members if p.image_path.startswith("images/products/")), None) + child_tiles.append({ + "category": child, + "count": len(members), + "image": sample.image_path if sample else "icons/product-placeholder.svg", + }) + + if category.parent_id is None and page == 1 and not request.args: + hero_product = next( + (product for product in products if product.image_path.startswith("images/products/")), + products[0] if products else None, + ) + guide_sections = [] + department_sections = [] + department_stories = [] + if category.slug == "photography": + department_sections = [ + { + "title": "Cameras & Lenses", + "kind": "primary", + "items": [ + { + "label": "Digital Cameras", + "description": "Mirrorless Cameras, DSLR Cameras, Point & Shoot, and Full-Frame Digital Cameras", + "image": "images/department/digital-cameras.jpg", + "href": url_for("category_view", slug="mirrorless-cameras"), + }, + { + "label": "Lenses", + "description": "Prime, Zoom, Wide Angle, Telephoto, Cine, and More", + "image": "images/department/lenses.jpg", + "href": url_for("category_view", slug="camera-lenses"), + }, + { + "label": "Film Cameras", + "description": "35mm, Point & Shoot, Instant, Disposable, and More", + "image": "images/department/film-cameras.jpg", + "href": url_for("search", q="film camera"), + }, + ], + }, + { + "title": "Accessories", + "kind": "catalog", + "items": [ + {"label": "Photo Tripods & Support", "image": "images/department/tripods.jpg", "href": url_for("category_view", slug="tripods-supports")}, + {"label": "Photography Bags & Cases", "image": "images/department/camera-bags.jpg", "href": url_for("search", q="camera bag")}, + {"label": "Memory Cards & Accessories", "image": "images/department/memory-cards.jpg", "href": url_for("category_view", slug="memory-cards-storage")}, + {"label": "Batteries & Power Accessories", "image": "images/department/batteries.jpg", "href": url_for("search", q="camera battery")}, + {"label": "Flashes & On Camera Lighting", "image": "images/department/flashes.jpg", "href": url_for("search", q="flash lighting")}, + {"label": "Lens Filters", "image": "images/department/lens-filters.jpg", "href": url_for("search", q="lens filter")}, + {"label": "Lens Accessories", "image": "images/department/lens-accessories.jpg", "href": url_for("search", q="lens accessory")}, + {"label": "Photo Accessories", "image": "images/department/photo-accessories.jpg", "href": url_for("search", q="photo accessory")}, + {"label": "Medium & Large Format Accessories", "image": "images/department/medium-format.jpg", "href": url_for("search", q="medium format")}, + {"label": "Film", "image": "images/department/film.jpg", "href": url_for("search", q="film")}, + {"label": "All Content Creator Accessories", "image": "images/department/content-creator.jpg", "href": url_for("search", q="content creator")}, + ], + }, + { + "title": "What would you like to shoot?", + "kind": "shooting", + "items": [ + {"label": "Portrait Photography", "image": "images/department/portrait.jpg", "href": url_for("category_view", slug="camera-lenses", focal_length="medium-telephoto")}, + {"label": "Wedding Photography", "image": "images/department/wedding.webp", "href": url_for("search", q="wedding photography")}, + {"label": "Volume Photography", "image": "images/department/volume.webp", "href": url_for("search", q="studio lighting")}, + {"label": "Underwater Photography", "image": "images/department/underwater.jpg", "href": url_for("search", q="underwater photography")}, + ], + }, + { + "title": "More in Photography", + "kind": "compact", + "items": [ + {"label": "Lighting & Studio", "image": "images/department/lighting.jpg", "href": url_for("category_view", slug="lighting")}, + {"label": "Darkroom Equipment", "image": "images/department/darkroom.jpg", "href": url_for("search", q="darkroom")}, + {"label": "Photo Software", "image": "images/department/software.jpg", "href": url_for("search", q="photo software")}, + {"label": "Books & Tutorials", "image": "images/department/books.jpg", "href": url_for("search", q="photography book")}, + ], + }, + { + "title": "Scan, Print & Present", + "kind": "primary", + "items": [ + {"label": "Scanners", "image": "images/department/scanners.jpg", "href": url_for("category_view", slug="printers-scanners")}, + {"label": "Photo Printers", "image": "images/department/printers.jpg", "href": url_for("search", q="photo printer")}, + {"label": "Presentation & Archiving", "image": "images/department/presentation.jpg", "href": url_for("search", q="presentation archiving")}, + ], + }, + ] + department_stories = [ + {"eyebrow": "Tips and Solutions", "title": "The Fundamentals of Wedding Videography for Beginners", "image": "images/department/story-wedding-video.jpg", "href": url_for("search", q="video")}, + {"eyebrow": "Features", "title": "The Logistics of Shooting Destination Weddings", "image": "images/department/story-destination-weddings.jpg", "href": url_for("search", q="wedding")}, + {"eyebrow": "Buying Guide", "title": "Recommended Travel Tripods", "image": "images/department/story-travel-tripods.jpg", "href": url_for("category_view", slug="tripods-supports")}, + {"eyebrow": "Features", "title": "Olympus to OM SYSTEM: The Evolution of the PEN", "image": "images/department/story-pen-evolution.jpg", "href": url_for("category_view", slug="mirrorless-cameras")}, + {"eyebrow": "Tips and Solutions", "title": "Exposing to the Right", "image": "images/department/story-exposing-right.jpg", "href": url_for("category_view", slug="mirrorless-cameras")}, + {"eyebrow": "", "title": "Looking for photo cables? Find the right cable for your photography needs.", "image": "images/department/story-cable-finder.webp", "href": url_for("search", q="cable")}, + ] + return render_template( + "department_landing.html", + page_title=category.name, + category=category, + subcategories=child_tiles, + product_count=len(products), + hero_product=hero_product, + guide_sections=guide_sections, + department_sections=department_sections, + department_stories=department_stories, + ) + + results = apply_product_filters(products, request.args) + window = paginate(results, page) + return render_template( + "category_listing.html", + subcategories=child_tiles if category.parent_id is None else [], + page_title=category.name, + page_heading=category.name, + page_description=category.hero_copy or category.description, + category=category, + products=window["items"], + pagination=window, + filters=filters_for(products), + base_products=products, + active_tab=category.slug, + ) + + +@app.route("/search") +def search(): + query_text = request.args.get("q", "").strip() + products = all_products() + results = apply_product_filters(products, request.args, query_text=query_text) + log_search(query_text, "search", len(results)) + window = paginate(results, request.args.get("page", 1, type=int) or 1) + return render_template( + "category_listing.html", + page_title="Search", + page_heading=f"Search results for “{query_text}”" if query_text else "Search the catalog", + page_description="Find cameras, lenses, lighting, audio and computing gear.", + category=None, + products=window["items"], + pagination=window, + filters=filters_for(products), + base_products=products, + active_tab="search", + search_query=query_text, + ) + + +@app.route("/brand/") +def brand_view(brand_slug: str): + brand = Brand.query.filter_by(slug=brand_slug).first_or_404() + products = [product for product in all_products() if product.brand_id == brand.id] + results = apply_product_filters(products, request.args) + window = paginate(results, request.args.get("page", 1, type=int) or 1) + return render_template( + "category_listing.html", + page_title=brand.name, + page_heading=brand.name, + page_description=brand.blurb, + category=None, + brand=brand, + products=window["items"], + pagination=window, + filters=filters_for(products), + base_products=products, + active_tab="brand", + ) + + +def render_product_tab(product_slug: str, active_tab: str): + product = Product.query.filter_by(slug=product_slug).first_or_404() + related = Product.query.filter( + Product.id != product.id, + Product.subcategory_slug == product.subcategory_slug, + ).order_by(Product.best_seller_rank).limit(4).all() + return render_template( + "product_detail.html", + product=product, + active_tab=active_tab, + related=related, + top_specs=list(product.top_spec_map.items())[:6], + inventory=store_options_for(product), + compare_ids=[item.id for item in current_compare_products()], + ) + + +@app.route("/product/") +def product_detail(product_slug: str): + return render_product_tab(product_slug, "overview") + + +@app.route("/product//specs") +def product_specs(product_slug: str): + return render_product_tab(product_slug, "specs") + + +@app.route("/product//reviews") +def product_reviews(product_slug: str): + return render_product_tab(product_slug, "reviews") + + +@app.route("/product//qa") +def product_qa(product_slug: str): + return render_product_tab(product_slug, "qa") + + +@app.route("/compare") +def compare(): + products = current_compare_products() + if not products and request.args.get("slugs"): + slugs = [slug.strip() for slug in request.args.get("slugs", "").split(",") if slug.strip()] + products = Product.query.filter(Product.slug.in_(slugs[:4])).all() + products.sort(key=lambda product: slugs.index(product.slug)) + return render_template( + "compare.html", + products=products, + compare_rows=build_compare_rows(products), + ) + + +@app.route("/deals") +def deals(): + products = Product.query.join(Deal).filter(Deal.is_active.is_(True)).all() + results = apply_product_filters(products, request.args) + window = paginate(results, request.args.get("page", 1, type=int) or 1) + return render_template( + "category_listing.html", + page_title="Deals", + page_heading="Deals and daily savings", + page_description="Current sale pricing, open-box spotlights, and editor-picked specials across photo, video, and creator tech.", + category=None, + products=window["items"], + pagination=window, + filters=filters_for(products), + base_products=products, + active_tab="deals", + ) + + +@app.route("/used") +def used(): + products = Product.query.filter(Product.condition != "New").all() + results = apply_product_filters(products, request.args) + window = paginate(results, request.args.get("page", 1, type=int) or 1) + return render_template( + "category_listing.html", + page_title="Used", + page_heading="Used and open-box", + page_description="Deterministic demo inventory for pre-owned, open-box, and pro-verified creator gear.", + category=None, + products=window["items"], + pagination=window, + filters=filters_for(products), + base_products=products, + active_tab="used", + ) + + +@app.route("/bundles") +def bundles(): + bundles_list = Bundle.query.order_by(Bundle.featured.desc(), Bundle.title).all() + query_text = request.args.get("q", "").strip() + audience = request.args.get("audience", "").strip() + audiences = sorted({bundle.audience for bundle in bundles_list if bundle.audience}) + if query_text: + needles = tokenized(query_text) + bundles_list = [ + bundle + for bundle in bundles_list + if all( + needle in " ".join( + [ + bundle.title, + bundle.description, + bundle.audience, + " ".join(item.product.name for item in bundle.items), + ] + ).lower() + for needle in needles + ) + ] + if audience: + bundles_list = [bundle for bundle in bundles_list if bundle.audience == audience] + return render_template( + "bundles.html", + bundles=bundles_list, + query_text=query_text, + audiences=audiences, + selected_audience=audience, + ) + + +@app.route("/bundle/") +def bundle_detail(bundle_slug: str): + bundle = Bundle.query.filter_by(slug=bundle_slug).first_or_404() + related = ( + Bundle.query.filter(Bundle.id != bundle.id, Bundle.audience == bundle.audience) + .order_by(Bundle.featured.desc(), Bundle.title) + .limit(3) + .all() + ) + return render_template("bundle_detail.html", bundle=bundle, related=related) + + +@app.route("/bundle//add", methods=["POST"]) +@login_required +def add_bundle_to_cart(bundle_slug: str): + bundle = Bundle.query.filter_by(slug=bundle_slug).first_or_404() + for item in bundle.items: + existing = CartItem.query.filter_by( + user_id=current_user.id, + product_id=item.product_id, + bundle_label=bundle.title, + ).first() + if existing: + existing.quantity += item.quantity + else: + db.session.add( + CartItem( + user_id=current_user.id, + product_id=item.product_id, + quantity=item.quantity, + bundle_label=bundle.title, + ) + ) + db.session.commit() + flash(f"{bundle.title} was added to your cart.", "success") + return redirect(url_for("cart")) + + +@app.route("/store-pickup") +def store_pickup(): + stores = StoreLocation.query.order_by(StoreLocation.city).all() + featured = Product.query.filter_by(pickup_available=True).order_by(Product.best_seller_rank).limit(10).all() + product = None + inventory = [] + if request.args.get("product"): + product = Product.query.filter_by(slug=request.args["product"]).first() + if product: + inventory = store_options_for(product) + reservations = current_user.reservations if current_user.is_authenticated else [] + return render_template( + "store_pickup.html", + stores=stores, + featured=featured, + product=product, + inventory=inventory, + reservations=reservations, + ) + + +@app.route("/help") +def help_page(): + featured_products = Product.query.order_by(Product.best_seller_rank).limit(3).all() + help_cards = [ + { + "title": "Returns and exchanges", + "copy": "Demo orders carry a clear 30-day return window with synthetic RMA language that mirrors a pro retailer workflow.", + }, + { + "title": "Shipping and pickup", + "copy": "Every shipping timeline and pickup ETA on this mirror is deterministic and local-only. Nothing reaches live couriers or stores.", + }, + { + "title": "Trade-in and used gear", + "copy": "Used and open-box catalog pages include condition grading, accessory notes, and limited stock badges for benchmark tasks.", + }, + ] + return render_template("help.html", help_cards=help_cards, featured_products=featured_products) + + +@app.route("/contact", methods=["GET", "POST"]) +def contact(): + submitted = False + if request.method == "POST": + submitted = True + flash("Your demo support request was saved locally for benchmark flow testing.", "success") + return render_template("contact.html", submitted=submitted) + + +@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 your B&H demo account.", "success") + next_url = request.args.get("next") + return redirect(next_url or url_for("account")) + flash("That demo email/password combination did not match.", "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": + display_name = request.form.get("display_name", "").strip() + username = request.form.get("username", "").strip().lower() + email = request.form.get("email", "").strip().lower() + password = request.form.get("password", "") + confirm = request.form.get("confirm_password", "") + if not display_name or not username or not email or not password: + flash("Please complete every required registration field.", "danger") + elif password != confirm: + flash("Passwords must match.", "danger") + elif User.query.filter((User.email == email) | (User.username == username)).first(): + flash("That demo account already exists.", "warning") + else: + user = User( + display_name=display_name, + username=username, + email=email, + phone=request.form.get("phone", "").strip(), + company=request.form.get("company", "").strip(), + role=request.form.get("role", "").strip() or "Creator", + ) + user.set_password(password) + db.session.add(user) + db.session.flush() + db.session.add( + Address( + user_id=user.id, + label="Studio", + recipient=display_name, + line1=request.form.get("line1", "").strip() or "100 Demo Plaza", + city=request.form.get("city", "").strip() or "New York", + state=request.form.get("state", "").strip() or "NY", + zip_code=request.form.get("zip_code", "").strip() or "10001", + phone=user.phone, + is_default=True, + ) + ) + db.session.commit() + login_user(user) + flash("Your B&H demo account is ready.", "success") + return redirect(url_for("account")) + return render_template("register.html") + + +@app.route("/logout") +def logout(): + if current_user.is_authenticated: + logout_user() + flash("You have been signed out of the demo mirror.", "info") + return redirect(url_for("index")) + + +@app.route("/account") +@login_required +def account(): + reservations = current_user.reservations[:4] + return render_template("account.html", reservations=reservations) + + +@app.route("/account/edit", methods=["GET", "POST"]) +@login_required +def account_edit(): + if request.method == "POST": + current_user.display_name = request.form.get("display_name", "").strip() or current_user.display_name + current_user.phone = request.form.get("phone", "").strip() + current_user.company = request.form.get("company", "").strip() + current_user.role = request.form.get("role", "").strip() or current_user.role + current_user.newsletter_opt_in = request.form.get("newsletter_opt_in") == "on" + current_user.sms_opt_in = request.form.get("sms_opt_in") == "on" + preferred_store = request.form.get("preferred_store_id", "").strip() + if preferred_store.isdigit(): + current_user.preferred_store_id = int(preferred_store) + db.session.commit() + flash("Your demo account preferences were updated.", "success") + return redirect(url_for("account")) + stores = StoreLocation.query.order_by(StoreLocation.city).all() + return render_template("account_edit.html", stores=stores) + + +@app.route("/account/orders") +@login_required +def account_orders(): + orders = Order.query.filter_by(user_id=current_user.id).order_by(Order.created_at.desc()).all() + return render_template("orders.html", orders=orders) + + +@app.route("/account/wishlist") +@login_required +def account_wishlist(): + items = WishlistItem.query.filter_by(user_id=current_user.id).order_by(WishlistItem.created_at.desc()).all() + return render_template("wishlist.html", items=items) + + +@app.route("/account/compare") +@login_required +def account_compare(): + products = [item.product for item in CompareItem.query.filter_by(user_id=current_user.id).all()] + return render_template("compare.html", products=products, compare_rows=build_compare_rows(products), account_mode=True) + + +@app.route("/account/addresses") +@login_required +def account_addresses(): + return render_template("addresses.html", addresses=current_user.addresses) + + +@app.route("/wishlist/toggle/", methods=["POST"]) +@login_required +def toggle_wishlist(product_slug: str): + product = Product.query.filter_by(slug=product_slug).first_or_404() + existing = WishlistItem.query.filter_by(user_id=current_user.id, product_id=product.id).first() + if existing: + db.session.delete(existing) + flash(f"{product.name} was removed from your wishlist.", "info") + else: + db.session.add(WishlistItem(user_id=current_user.id, product_id=product.id)) + flash(f"{product.name} was saved to your wishlist.", "success") + db.session.commit() + return redirect(request.referrer or url_for("product_detail", product_slug=product.slug)) + + +@app.route("/compare/add/", methods=["POST"]) +def add_compare(product_slug: str): + product = Product.query.filter_by(slug=product_slug).first_or_404() + if current_user.is_authenticated: + existing = CompareItem.query.filter_by(user_id=current_user.id, product_id=product.id).first() + if not existing: + if CompareItem.query.filter_by(user_id=current_user.id).count() >= 4: + oldest = CompareItem.query.filter_by(user_id=current_user.id).order_by(CompareItem.created_at).first() + if oldest: + db.session.delete(oldest) + db.session.add(CompareItem(user_id=current_user.id, product_id=product.id)) + db.session.commit() + else: + merge_compare_into_session(product.id) + flash(f"{product.name} was added to compare.", "success") + return redirect(request.referrer or url_for("compare")) + + +@app.route("/compare/remove/", methods=["POST"]) +def remove_compare(product_slug: str): + product = Product.query.filter_by(slug=product_slug).first_or_404() + if current_user.is_authenticated: + existing = CompareItem.query.filter_by(user_id=current_user.id, product_id=product.id).first() + if existing: + db.session.delete(existing) + db.session.commit() + else: + compare_ids = session.get("compare_ids", []) + session["compare_ids"] = [pid for pid in compare_ids if pid != product.id] + session.modified = True + flash(f"{product.name} was removed from compare.", "info") + return redirect(request.referrer or url_for("compare")) + + +@app.route("/cart") +@login_required +def cart(): + items = CartItem.query.filter_by(user_id=current_user.id).order_by(CartItem.added_at.desc()).all() + totals = order_totals_for(items) + return render_template("cart.html", items=items, totals=totals) + + +@app.route("/cart/add/", methods=["POST"]) +@login_required +def add_to_cart(product_slug: str): + product = Product.query.filter_by(slug=product_slug).first_or_404() + quantity = parse_positive_int(request.form.get("quantity")) + if quantity is None: + flash("Enter a valid quantity as a whole number.", "danger") + return redirect(request.referrer or url_for("product_detail", product_slug=product.slug)) + variant_label = request.form.get("variant_label", "").strip() + existing = CartItem.query.filter_by( + user_id=current_user.id, + product_id=product.id, + variant_label=variant_label, + bundle_label="", + ).first() + if existing: + existing.quantity += quantity + else: + db.session.add( + CartItem( + user_id=current_user.id, + product_id=product.id, + quantity=quantity, + variant_label=variant_label, + ) + ) + db.session.commit() + flash(f"{product.name} was added to your cart.", "success") + return redirect(request.referrer or url_for("cart")) + + +@app.route("/cart/update/", methods=["POST"]) +@login_required +def update_cart(item_id: int): + item = CartItem.query.filter_by(id=item_id, user_id=current_user.id).first_or_404() + quantity = parse_positive_int(request.form.get("quantity")) + if quantity is None: + flash("Enter a valid quantity as a whole number.", "danger") + return redirect(url_for("cart")) + item.quantity = quantity + db.session.commit() + flash("Your cart quantity was updated.", "success") + return redirect(url_for("cart")) + + +@app.route("/cart/remove/", methods=["POST"]) +@login_required +def remove_cart_item(item_id: int): + item = CartItem.query.filter_by(id=item_id, user_id=current_user.id).first_or_404() + db.session.delete(item) + db.session.commit() + flash("That item was removed from your cart.", "info") + return redirect(url_for("cart")) + + +@app.route("/reserve/", methods=["POST"]) +@login_required +def reserve_store_pickup(product_slug: str): + product = Product.query.filter_by(slug=product_slug).first_or_404() + store_id = parse_positive_int(request.form.get("store_id"), default=0) + if store_id is None: + flash("Choose a pickup store from the list.", "danger") + return redirect(url_for("store_pickup", product=product.slug)) + quantity = parse_positive_int(request.form.get("quantity")) + if quantity is None: + flash("Enter a valid quantity as a whole number.", "danger") + return redirect(url_for("store_pickup", product=product.slug)) + inventory = StoreInventory.query.filter_by(product_id=product.id, store_id=store_id).first() + if not inventory or inventory.quantity < quantity: + flash("That pickup slot is no longer available in this demo inventory.", "danger") + return redirect(url_for("store_pickup", product=product.slug)) + + reservation = StoreReservation( + user_id=current_user.id, + store_id=store_id, + product_id=product.id, + quantity=quantity, + status="Reserved", + pickup_window=inventory.pickup_eta, + ) + db.session.add(reservation) + db.session.commit() + flash(f"{product.name} is reserved for in-store pickup.", "success") + return redirect(url_for("store_pickup", product=product.slug)) + + +@app.route("/checkout", methods=["GET", "POST"]) +@login_required +def checkout(): + items = CartItem.query.filter_by(user_id=current_user.id).order_by(CartItem.added_at.desc()).all() + if not items: + flash("Your cart is empty. Add something before starting checkout.", "warning") + return redirect(url_for("cart")) + totals = order_totals_for(items) + addresses = current_user.addresses + draft = dict(session.get("checkout_draft", {})) + step = request.args.get("step", "shipping") + if step not in {"shipping", "payment", "review"}: + step = "shipping" + + if request.method == "POST": + step = request.form.get("step", "shipping").strip() + if step == "shipping": + fulfillment = request.form.get("fulfillment", "Ship to address").strip() + address_id = parse_positive_int(request.form.get("address_id"), default=0) + address = next((item for item in addresses if item.id == address_id), None) + if fulfillment not in {"Ship to address", "Store pickup"}: + flash("Choose a valid fulfillment option.", "danger") + return redirect(url_for("checkout")) + if fulfillment == "Ship to address" and not address: + flash("Choose one of your saved shipping addresses.", "danger") + return redirect(url_for("checkout")) + draft = { + "fulfillment": fulfillment, + "address_id": address.id if address else None, + "shipping_complete": True, + } + session["checkout_draft"] = draft + session.modified = True + return redirect(url_for("checkout", step="payment")) + + if step == "payment": + if not draft.get("shipping_complete"): + flash("Complete shipping before choosing payment.", "warning") + return redirect(url_for("checkout")) + payment_label = request.form.get("payment_label", "").strip() + if payment_label not in { + "Demo Visa ending in 4242", + "Demo Mastercard ending in 1881", + "PayPal demo account", + }: + flash("Choose one of the available demo payment methods.", "danger") + return redirect(url_for("checkout", step="payment")) + draft["payment_label"] = payment_label + draft["payment_complete"] = True + session["checkout_draft"] = draft + session.modified = True + return redirect(url_for("checkout", step="review")) + + if step != "review" or not ( + draft.get("shipping_complete") and draft.get("payment_complete") + ): + flash("Complete shipping and payment before reviewing the order.", "warning") + return redirect(url_for("checkout")) + if request.form.get("confirmed") != "yes": + flash("Confirm that you reviewed the order before placing it.", "warning") + return redirect(url_for("checkout", step="review")) + + fulfillment = draft["fulfillment"] + payment_label = draft["payment_label"] + note = request.form.get("note", "").strip() + # seeded orders read BH--; a checkout placed on the + # site produced BH--