diff --git a/.claude/skills/clone-website/SKILL.md b/.claude/skills/clone-website/SKILL.md index de4ba461..ee92c57a 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 registered 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..a9a5a6bb 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 registered 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..c916bc35 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,8 +36,8 @@ 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 -for p in $(seq 41000 41014); do +# 1. all registered sites return 200 +for p in $(seq 41000 41015); 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 registered 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..f59da45d 100644 --- a/.claude/skills/seed-database/SKILL.md +++ b/.claude/skills/seed-database/SKILL.md @@ -169,10 +169,10 @@ 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 -for p in $(seq 41000 41014); do +# all registered sites return 200 +for p in $(seq 41000 41015); do curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ done 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..f80abe18 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,13 +129,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-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 # 5. every site renders 200 -for p in $(seq 41000 41014); do +for p in $(seq 41000 41015); do curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ done 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..8086dfc2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ -# WebHarbor — slim, self-contained image. -# 15 Flask mirror sites + control plane on :8101. +# WebHarbor - slim, self-contained image. +# 16 Flask mirror sites + control plane on :8101. FROM python:3.12-slim-bookworm @@ -33,6 +33,6 @@ COPY control_server.py /opt/control_server.py COPY site_runner.py /opt/site_runner.py RUN chmod +x /opt/websyn_start.sh -EXPOSE 8101 40000-40014 +EXPOSE 8101 40000-40015 CMD ["/opt/websyn_start.sh"] diff --git a/README.md b/README.md index dce3f934..f2cb0e46 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 B&H Photo`. For sub-second reset between rollouts, expose the control plane and call `/reset/`: @@ -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..8d025285 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', 'bh_photo', ] BASE_PORT = 40000 WEBSYN_DIR = '/opt/WebSyn' diff --git a/sites/bh_photo/_health.py b/sites/bh_photo/_health.py new file mode 100644 index 00000000..e2977e2c --- /dev/null +++ b/sites/bh_photo/_health.py @@ -0,0 +1,4 @@ +"""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 00000000..c953b1bb --- /dev/null +++ b/sites/bh_photo/app.py @@ -0,0 +1,1405 @@ +import os +import re +from collections import defaultdict +from datetime import datetime + +from flask import ( + Flask, + abort, + flash, + redirect, + render_template, + request, + session, + url_for, +) +from flask_bcrypt import Bcrypt +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: + self.password_hash = bcrypt.generate_password_hash(password).decode("utf-8") + + def check_password(self, password: str) -> bool: + 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) + 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 + + @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]: + return [ + token + for token in re.split(r"[^a-zA-Z0-9]+", (text or "").lower()) + 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() + return sum(1 for token in tokens if token in text) + + +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()] + + 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: + if not query: + return + entry = SearchLog( + user_id=current_user.id if current_user.is_authenticated else None, + query=query[:220], + scope=scope[:80], + result_count=result_count, + ) + db.session.add(entry) + db.session.commit() + + +@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() + metrics = cart_metrics() + return { + "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, + "demo_notice": "Local benchmark mirror with deterministic demo inventory, users, orders, and checkout flows.", + } + + +@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() + return render_template( + "index.html", + featured=featured, + deals=deals, + used_highlights=used_highlights, + bundles=bundles, + categories=categories, + 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) + + +@app.route("/c/") +def category_view(slug: str): + 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] + results = apply_product_filters(products, request.args) + return render_template( + "category_listing.html", + page_title=category.name, + page_heading=category.name, + page_description=category.hero_copy or category.description, + category=category, + products=results, + 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)) + 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 pro workstation gear across the local benchmark catalog.", + category=None, + products=results, + 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) + return render_template( + "category_listing.html", + page_title=brand.name, + page_heading=brand.name, + page_description=brand.blurb, + category=None, + brand=brand, + products=results, + 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) + 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=results, + 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) + 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=results, + 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() + return render_template("bundles.html", bundles=bundles_list) + + +@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 = max(int(request.form.get("quantity", 1) or 1), 1) + 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 = max(int(request.form.get("quantity", 1) or 1), 1) + 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 = int(request.form.get("store_id", 0) or 0) + quantity = max(int(request.form.get("quantity", 1) or 1), 1) + 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 + if request.method == "POST": + fulfillment = request.form.get("fulfillment", "Ship to address").strip() or "Ship to address" + payment_label = request.form.get("payment_label", "Demo Visa ending in 4242").strip() or "Demo Visa ending in 4242" + note = request.form.get("note", "").strip() + order_number = f"BH-{datetime.utcnow().strftime('%Y%m%d-%H%M%S')}-{current_user.id:02d}" + order = Order( + user_id=current_user.id, + order_number=order_number, + status="Confirmed", + subtotal=totals["subtotal"], + shipping=totals["shipping"], + tax=totals["tax"], + total=totals["total"], + fulfillment=fulfillment, + payment_label=payment_label, + note=note, + ) + db.session.add(order) + db.session.flush() + for item in items: + db.session.add( + OrderItem( + order_id=order.id, + product_id=item.product_id, + product_name=item.product.name, + image_path=item.product.main_image, + quantity=item.quantity, + price=item.unit_price, + variant_label=item.variant_label or item.bundle_label, + ) + ) + db.session.delete(item) + db.session.commit() + flash("Your demo checkout was completed locally.", "success") + return redirect(url_for("order_receipt", order_number=order.order_number)) + return render_template("checkout.html", items=items, totals=totals, addresses=addresses) + + +@app.route("/order/") +@login_required +def order_receipt(order_number: str): + order = Order.query.filter_by(order_number=order_number, user_id=current_user.id).first_or_404() + return render_template("order_receipt.html", order=order) + + +@app.route("/contact-support") +def contact_support_redirect(): + return redirect(url_for("contact")) + + +@app.route("/_health") +def health(): + return {"ok": True, "site": "bh_photo"} + + +MODEL_REGISTRY = { + "User": User, + "Address": Address, + "Category": Category, + "Brand": Brand, + "Product": Product, + "ProductImage": ProductImage, + "ProductSpecGroup": ProductSpecGroup, + "ProductSpec": ProductSpec, + "ProductVariant": ProductVariant, + "ProductReview": ProductReview, + "ProductQuestion": ProductQuestion, + "ProductAnswer": ProductAnswer, + "Bundle": Bundle, + "BundleItem": BundleItem, + "CartItem": CartItem, + "WishlistItem": WishlistItem, + "CompareItem": CompareItem, + "StoreLocation": StoreLocation, + "StoreInventory": StoreInventory, + "StoreReservation": StoreReservation, + "Order": Order, + "OrderItem": OrderItem, + "Deal": Deal, + "SearchLog": SearchLog, +} + + +with app.app_context(): + db.create_all() + from seed_data import seed_benchmark_users, seed_database + + seed_database(db, MODEL_REGISTRY, BASE_DIR) + seed_benchmark_users(db, MODEL_REGISTRY) + + +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/bh_photo/requirements.txt b/sites/bh_photo/requirements.txt new file mode 100644 index 00000000..c5de2312 --- /dev/null +++ b/sites/bh_photo/requirements.txt @@ -0,0 +1,4 @@ +Flask +Flask-Login +Flask-SQLAlchemy +Flask-Bcrypt diff --git a/sites/bh_photo/seed_data.py b/sites/bh_photo/seed_data.py new file mode 100644 index 00000000..f793fd2a --- /dev/null +++ b/sites/bh_photo/seed_data.py @@ -0,0 +1,1575 @@ +import shutil +from datetime import datetime, timedelta +from pathlib import Path + + +MIRROR_REFERENCE_DATE = datetime(2026, 4, 18, 10, 0, 0) +BENCHMARK_PASSWORD = "TestPass123!" + + +CATEGORY_DEFS = [ + {"name": "Photography", "slug": "photography", "description": "Camera bodies, lenses, support gear, and storage built for stills workflows.", "hero_copy": "Browse mirrorless cameras, pro lenses, support systems, and storage cards with filter-heavy B&H style browsing.", "icon_label": "Photo", "nav_order": 1}, + {"name": "Video", "slug": "video", "description": "Cinema capture, drones, monitors, and production bundles for hybrid teams.", "hero_copy": "Explore cinema cameras, drones, monitor recorders, and creator-focused video production tools.", "icon_label": "Video", "nav_order": 2}, + {"name": "Audio", "slug": "audio", "description": "Microphones, monitoring, and recording tools for creators and live production.", "hero_copy": "Compare wireless kits, broadcast mics, and reference headphones with pickup and bundle options.", "icon_label": "Audio", "nav_order": 3}, + {"name": "Computers", "slug": "computers", "description": "Workstations, displays, and print gear for post-production and studio delivery.", "hero_copy": "Shop creator laptops, calibration-ready monitors, and output devices with side-by-side spec comparisons.", "icon_label": "Compute", "nav_order": 4}, + {"name": "Lighting", "slug": "lighting", "description": "LED fixtures, modifiers, and kits for photo and video sets.", "hero_copy": "Find compact LEDs, RGB panels, and soft source kits with realistic tech specs and stock badges.", "icon_label": "Light", "nav_order": 5}, + {"name": "Mirrorless Cameras", "slug": "mirrorless-cameras", "parent": "photography", "description": "Hybrid stills and video camera bodies."}, + {"name": "DSLR Cameras", "slug": "dslr-cameras", "parent": "photography", "description": "Optical viewfinder cameras for sports and studio work."}, + {"name": "Camera Lenses", "slug": "camera-lenses", "parent": "photography", "description": "Wide, standard, and telephoto glass across major mounts."}, + {"name": "Tripods & Supports", "slug": "tripods-supports", "parent": "photography", "description": "Travel, studio, and video support tools."}, + {"name": "Memory Cards & Storage", "slug": "memory-cards-storage", "parent": "photography", "description": "Capture media, SSDs, and workflow storage."}, + {"name": "Cinema Cameras", "slug": "cinema-cameras", "parent": "video", "description": "Compact and studio-ready digital cinema tools."}, + {"name": "Drones", "slug": "drones", "parent": "video", "description": "Aerial platforms for creator and production teams."}, + {"name": "Monitors & Recorders", "slug": "monitors-recorders", "parent": "video", "description": "Field monitors, recorders, and director displays."}, + {"name": "Microphones", "slug": "microphones", "parent": "audio", "description": "Wireless, shotgun, and desktop recording microphones."}, + {"name": "Headphones", "slug": "headphones", "parent": "audio", "description": "Reference and field monitoring headphones."}, + {"name": "Laptops", "slug": "laptops", "parent": "computers", "description": "Creator laptops for edit, color, and tethered capture."}, + {"name": "Monitors", "slug": "monitors", "parent": "computers", "description": "Color-critical and office monitors."}, + {"name": "Printers & Scanners", "slug": "printers-scanners", "parent": "computers", "description": "Proofing printers and document/photo scanners."}, + {"name": "Lighting", "slug": "lighting-kits", "parent": "lighting", "description": "LED monolights, RGB panels, and modifiers."}, +] + + +BRAND_DEFS = [ + ("Canon", "Japan", "#d0282b", "Hybrid stills and imaging tools with strong autofocus and creator-friendly ergonomics."), + ("Nikon", "Japan", "#ffd334", "Photo-forward systems known for dependable handling and deep color."), + ("Sony", "Japan", "#2a3a63", "Hybrid imaging, creator audio, and monitoring gear with modern connectivity."), + ("Fujifilm", "Japan", "#7d1f2c", "Compact creator cameras and film-inspired imaging tools."), + ("Panasonic", "Japan", "#39435f", "Video-centric bodies and compact cinema hardware."), + ("Blackmagic", "Australia", "#202020", "Cinema capture systems and production monitoring gear."), + ("Sigma", "Japan", "#54606c", "Art and contemporary lenses with sharp optical design."), + ("Tamron", "Japan", "#0d6b57", "Flexible travel zooms and compact creator glass."), + ("DJI", "China", "#1d1d1d", "Aerial and wireless capture tools for modern production."), + ("Rode", "Australia", "#1776b6", "Portable audio capture and creator microphones."), + ("Sennheiser", "Germany", "#0a3f8c", "Reference monitoring and broadcast audio solutions."), + ("SanDisk", "United States", "#cc1f24", "High-speed capture media and rugged SSD storage."), + ("Lexar", "United States", "#bf7d0a", "Workflow media and creator storage accessories."), + ("Apple", "United States", "#4d4d4d", "Portable creator computers and calibrated displays."), + ("Dell", "United States", "#1563c7", "Workstations, creator laptops, and high-resolution monitors."), + ("BenQ", "Taiwan", "#4f6d52", "Color-accurate displays for editing and retouching."), + ("Aputure", "China", "#a05b10", "Professional LED lighting systems for location and studio setups."), + ("Manfrotto", "Italy", "#8f2334", "Tripods and supports for field production."), + ("Peak Design", "United States", "#333333", "Creator carry gear and modular support accessories."), + ("Atomos", "Australia", "#ea5a0b", "Field monitors and recorders for production crews."), + ("Shure", "United States", "#5a1d4c", "Reliable speech, podcast, and broadcast capture."), + ("ASUS", "Taiwan", "#1e5aa8", "Creator and pro-performance notebooks and monitors."), + ("Epson", "Japan", "#2e5d98", "Photo output and scanning tools for studio workflows."), + ("Godox", "China", "#825700", "Accessible lighting tools with strong feature density."), + ("Benro", "China", "#00584c", "Video heads and compact supports for travel production."), +] + + +STORE_DEFS = [ + ("NYC SuperStore", "nyc-superstore", "New York", "NY", "420 Demo Ave, Manhattan, NY 10001", "Mon-Sat 9am-8pm", "(212) 555-0140", "Local benchmark pickup counter"), + ("Brooklyn Creator Desk", "brooklyn-creator-desk", "Brooklyn", "NY", "55 Flatbush Demo Way, Brooklyn, NY 11217", "Daily 10am-7pm", "(718) 555-0111", "Fast bag-and-tag reservation lane"), + ("Jersey City Pro Hub", "jersey-city-pro-hub", "Jersey City", "NJ", "91 Harbor Plaza, Jersey City, NJ 07302", "Mon-Sat 10am-6pm", "(201) 555-0168", "Broadcast and cinema pickup"), + ("White Plains Pickup", "white-plains-pickup", "White Plains", "NY", "17 Mamaroneck Ave, White Plains, NY 10601", "Tue-Sun 10am-6pm", "(914) 555-0122", "Suburban pickup desk"), + ("Los Angeles Creator Hub", "los-angeles-creator-hub", "Los Angeles", "CA", "300 Olive St, Los Angeles, CA 90013", "Daily 10am-7pm", "(323) 555-0185", "West coast same-day counter"), + ("Chicago Pro Counter", "chicago-pro-counter", "Chicago", "IL", "201 State St, Chicago, IL 60601", "Mon-Sat 9am-7pm", "(312) 555-0199", "Lighting and grip reserve desk"), + ("Miami Imaging Desk", "miami-imaging-desk", "Miami", "FL", "88 Biscayne Blvd, Miami, FL 33132", "Daily 11am-7pm", "(305) 555-0156", "Aerial and imaging pickup"), + ("Austin Broadcast Pickup", "austin-broadcast-pickup", "Austin", "TX", "620 Congress Ave, Austin, TX 78701", "Mon-Fri 9am-6pm", "(512) 555-0137", "Compact pro video counter"), +] + + +USER_DEFS = [ + {"username": "alice_j", "email": "alice.j@test.com", "display_name": "Alice Johnson", "company": "Northlight Weddings", "role": "Photographer"}, + {"username": "bob_c", "email": "bob.c@test.com", "display_name": "Bob Chen", "company": "Studio Meridian", "role": "Video Producer"}, + {"username": "carol_d", "email": "carol.d@test.com", "display_name": "Carol Davis", "company": "Signal Audio", "role": "Podcast Producer"}, + {"username": "david_k", "email": "david.k@test.com", "display_name": "David Kim", "company": "Frame & Color", "role": "Editor"}, +] + + +def slugify(text: str) -> str: + return ( + text.lower() + .replace("&", " and ") + .replace("+", " plus ") + .replace("/", " ") + .replace("'", "") + .replace(",", " ") + .replace(".", " ") + ).strip().replace(" ", " ").replace(" ", "-") + + +def ensure_svg(path: Path, title: str, subtitle: str, accent: str, product_type: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists(): + return + + if product_type in {"Mirrorless Camera", "DSLR Camera", "Cinema Camera"}: + silhouette = """ + + + + + + """ + elif product_type == "Lens": + silhouette = """ + + + + + + """ + elif product_type in {"Tripod", "Support"}: + silhouette = """ + + + + + + """ + elif product_type in {"Microphone", "Headphones"}: + silhouette = """ + + + + + """ + elif product_type in {"Drone"}: + silhouette = """ + + + + + + + + + + """ + elif product_type in {"Laptop", "Monitor", "Monitor/Recorder"}: + silhouette = """ + + + + + """ + elif product_type in {"Printer", "Scanner"}: + silhouette = """ + + + + + """ + else: + silhouette = """ + + + + """ + + svg = f""" + + + + + + + + + + + + +B&H DEMO +{silhouette.format(accent=accent)} +{title[:34]} +{subtitle[:54]} +""" + path.write_text(svg, encoding="utf-8") + + +def ensure_misc_assets(base_dir: str) -> None: + images_dir = Path(base_dir) / "static" / "images" + ensure_svg(images_dir / "bh-photo-fallback.svg", "B&H Demo", "Deterministic local benchmark asset", "#24423f", "Monitor") + + +def spec_groups_from_sections(sections): + return [{"title": title, "rows": rows} for title, rows in sections] + + +def camera_specs(sensor, megapixels, mount, video, slots, burst, stabilization, battery, weight, autofocus, viewfinder): + return spec_groups_from_sections( + [ + ("Key Specs", [("Sensor Size", sensor), ("Effective Megapixels", f"{megapixels} MP"), ("Lens Mount", mount), ("Recording", video)]), + ("Performance", [("Card Slots", slots), ("Continuous Shooting", burst), ("Stabilization", stabilization), ("Autofocus", autofocus)]), + ("General", [("Battery Life", battery), ("Viewfinder", viewfinder), ("Weight", weight), ("Warranty", "1 Year Demo Warranty")]), + ] + ) + + +def lens_specs(mount, focal, aperture, filter_size, stabilization, close_focus, weight, construction): + bucket = "Wide" if "16-" in focal or "14-" in focal else "Telephoto" if "70-" in focal or "100-" in focal else "Standard" + return bucket, spec_groups_from_sections( + [ + ("Optics", [("Lens Mount", mount), ("Focal Length", focal), ("Maximum Aperture", aperture), ("Optical Design", construction)]), + ("Focus", [("Image Stabilization", stabilization), ("Minimum Focus Distance", close_focus), ("Filter Thread", filter_size), ("Autofocus Drive", "Linear motor")]), + ("Physical", [("Weather Resistance", "Dust and moisture sealed"), ("Weight", weight), ("Included Accessories", "Hood, caps, soft case"), ("Warranty", "1 Year Demo Warranty")]), + ] + ) + + +def cinema_specs(sensor, video, dynamic_range, media, inputs, weight, mount): + return spec_groups_from_sections( + [ + ("Capture", [("Sensor Size", sensor), ("Recording Formats", video), ("Dynamic Range", dynamic_range), ("Lens Mount", mount)]), + ("Workflow", [("Media Support", media), ("Audio Inputs", inputs), ("Monitoring", "5-inch daylight touchscreen"), ("Power", "NP-F or V-Mount compatible")]), + ("Physical", [("Body Style", "Production-ready compact cinema body"), ("Weight", weight), ("Warranty", "1 Year Demo Warranty"), ("Notes", "Synthetic local benchmark inventory")]), + ] + ) + + +def tripod_specs(material, payload, height, folded, head, weight): + return spec_groups_from_sections( + [ + ("Support", [("Material", material), ("Max Payload", payload), ("Max Height", height), ("Folded Length", folded)]), + ("Head", [("Head Type", head), ("Quick Release", "Arca-compatible"), ("Leg Sections", "4"), ("Feet", "Rubber and spike set")]), + ("General", [("Weight", weight), ("Warranty", "5 Year Demo Support"), ("Travel Bag", "Included"), ("Use Case", "Synthetic pickup-ready studio support")]), + ] + ) + + +def storage_specs(capacity, form_factor, read_speed, write_speed, interface, weight): + return spec_groups_from_sections( + [ + ("Media", [("Capacity", capacity), ("Form Factor", form_factor), ("Read Speed", read_speed), ("Write Speed", write_speed)]), + ("Workflow", [("Interface", interface), ("Capture Use", "Hybrid photo and video"), ("Warranty", "Limited lifetime demo coverage"), ("Ruggedization", "Shock and temperature resistant")]), + ("Physical", [("Weight", weight), ("Labeling", "Preformatted for deterministic mirror tasks"), ("Color", "Black / graphite"), ("Included", "Protective case or cable where applicable")]), + ] + ) + + +def lighting_specs(output, cct, cri, power, control, weight): + return spec_groups_from_sections( + [ + ("Light Engine", [("Output", output), ("Color Temperature", cct), ("CRI/TLCI", cri), ("Power Draw", power)]), + ("Control", [("Control Options", control), ("Effects", "Builtin lighting effects"), ("Cooling", "Low-noise active cooling"), ("Mount", "Bowens or yoke mount")]), + ("Physical", [("Weight", weight), ("Power Supply", "AC adapter included"), ("Warranty", "1 Year Demo Warranty"), ("Notes", "Local synthetic stock only")]), + ] + ) + + +def audio_specs(pattern, response, connectivity, power, weight, bundle_note): + return spec_groups_from_sections( + [ + ("Audio", [("Polar Pattern", pattern), ("Frequency Response", response), ("Connectivity", connectivity), ("Power", power)]), + ("Workflow", [("Monitoring", "3.5mm headphone monitoring"), ("Mounting", bundle_note), ("Compatibility", "Mac, Windows, iOS, and Android demo workflows"), ("Warranty", "2 Year Demo Warranty")]), + ("Physical", [("Weight", weight), ("Included", "Protective pouch and cables"), ("Finish", "Matte black"), ("Notes", "Synthetic local benchmark media gear")]), + ] + ) + + +def drone_specs(sensor, video, flight_time, avoidance, transmission, weight): + return spec_groups_from_sections( + [ + ("Flight", [("Sensor", sensor), ("Video", video), ("Max Flight Time", flight_time), ("Obstacle Avoidance", avoidance)]), + ("Control", [("Transmission", transmission), ("Storage", "Internal plus microSD"), ("Modes", "Follow, orbit, waypoint"), ("Battery", "Two-battery demo kit supported")]), + ("Physical", [("Weight", weight), ("Warranty", "1 Year Demo Warranty"), ("Notes", "No real flight or registration required"), ("Carrying Case", "Included in Fly More bundles")]), + ] + ) + + +def computer_specs(processor, memory, storage, display, ports, connectivity, weight): + return spec_groups_from_sections( + [ + ("Performance", [("Processor", processor), ("Memory", memory), ("Storage", storage), ("Display", display)]), + ("I/O", [("Ports", ports), ("Connectivity", connectivity), ("Color Management", "Factory calibration included"), ("Warranty", "1 Year Demo Warranty")]), + ("General", [("Weight", weight), ("Use Case", "Editing, ingest, and tethered capture"), ("OS", "Synthetic local benchmark image"), ("Notes", "No external activation required")]), + ] + ) + + +def monitor_specs(size, resolution, color, hdr, inputs, weight): + return spec_groups_from_sections( + [ + ("Display", [("Panel Size", size), ("Resolution", resolution), ("Color Coverage", color), ("HDR", hdr)]), + ("Connectivity", [("Inputs", inputs), ("Calibration", "Hardware LUT support"), ("USB Hub", "Integrated"), ("Warranty", "3 Year Demo Panel Coverage")]), + ("Physical", [("Weight", weight), ("Ergonomics", "Height, tilt, swivel"), ("Included", "Cables and shading hood where applicable"), ("Notes", "Synthetic creator monitor stock")]), + ] + ) + + +def printer_specs(media, resolution, speed, ink, connectivity, weight): + return spec_groups_from_sections( + [ + ("Output", [("Supported Media", media), ("Max Resolution", resolution), ("Print / Scan Speed", speed), ("Ink or Sensor", ink)]), + ("Connectivity", [("Connectivity", connectivity), ("Workflow", "Proofing and delivery"), ("Warranty", "1 Year Demo Warranty"), ("Notes", "Synthetic benchmark hardware")]), + ("Physical", [("Weight", weight), ("Included", "Starter supplies"), ("Footprint", "Studio desktop"), ("Support", "Local-only demo contact flow")]), + ] + ) + + +def make_product( + *, + name, + brand, + category_slug, + top_category_slug, + product_type, + short_description, + description, + price, + list_price, + rating, + review_count, + qa_count, + best_seller_rank, + sort_newness, + accent_color, + specs, + search_blob="", + condition="New", + availability="In Stock", + stock_level=14, + pickup_available=True, + pickup_message="Ready in 2 hours", + shipping_message="Free Expedited Shipping", + return_window="30-Day Return Window", + warranty="1 Year Demo Warranty", + product_family="", + sensor_size="", + mount_type="", + focal_length="", + focal_length_bucket="", + megapixels=0, + capacity_gb=0, + connectivity="", + release_label="Spring 2025", + featured=False, + bundle_anchor=False, + used_highlight=False, + variants=None, + deal_label="", + deal_type="sale", +): + slug = slugify(name) + sku = f"BH{best_seller_rank:05d}" + return { + "name": name, + "slug": slug, + "sku": sku, + "brand": brand, + "category_slug": category_slug, + "top_category_slug": top_category_slug, + "subcategory_slug": category_slug, + "product_type": product_type, + "product_family": product_family or category_slug, + "short_description": short_description, + "description": description, + "search_blob": search_blob, + "price": price, + "list_price": list_price, + "rating": rating, + "review_count": review_count, + "qa_count": qa_count, + "condition": condition, + "availability": availability, + "stock_level": stock_level, + "pickup_available": pickup_available, + "pickup_message": pickup_message, + "shipping_message": shipping_message, + "return_window": return_window, + "warranty": warranty, + "best_seller_rank": best_seller_rank, + "sort_newness": sort_newness, + "sensor_size": sensor_size, + "mount_type": mount_type, + "focal_length": focal_length, + "focal_length_bucket": focal_length_bucket, + "megapixels": megapixels, + "capacity_gb": capacity_gb, + "connectivity": connectivity, + "accent_color": accent_color, + "release_label": release_label, + "specs": specs, + "variants": variants or [], + "featured": featured, + "bundle_anchor": bundle_anchor, + "used_highlight": used_highlight, + "deal_label": deal_label, + "deal_type": deal_type, + } + + +def mirrorless_products(): + entries = [ + ("Sony Aurora A7X Mirrorless Camera", "Sony", 2498, 2798, 4.8, 188, "Full Frame", "Sony E", 33.0, "4K60 10-bit", "Dual CFexpress A / SD", "10 fps", "5-axis 7-stop IBIS", "610 shots", "673 g", "759k phase-detect", "3.69m-dot OLED EVF", True, True, "Free 2-day shipping"), + ("Canon Orbit R6 Mark II Mirrorless Camera", "Canon", 2299, 2499, 4.7, 164, "Full Frame", "Canon RF", 24.2, "4K60 oversampled", "Dual SD UHS-II", "12 fps mechanical", "5-axis 8-stop IBIS", "580 shots", "680 g", "Dual Pixel AF II", "3.69m-dot EVF", True, False, "Free expedited shipping"), + ("Nikon Zenith Z8 Mirrorless Camera", "Nikon", 3496, 3796, 4.9, 146, "Full Frame", "Nikon Z", 45.7, "8.3K60 RAW", "CFexpress B / SD", "20 fps RAW", "5-axis 6-stop VR", "530 shots", "910 g", "3D subject tracking", "3.69m-dot EVF", True, True, "Signature shipping included"), + ("Fujifilm Northlight X-T5 Mirrorless Camera", "Fujifilm", 1699, 1849, 4.8, 131, "APS-C", "Fujifilm X", 40.2, "6.2K30", "Dual SD UHS-II", "15 fps mechanical", "7-stop IBIS", "680 shots", "557 g", "Deep-learning AF", "3.69m-dot EVF", True, False, "Free 2-day shipping"), + ("Panasonic Lumix S5 II Mirrorless Camera", "Panasonic", 1997, 2197, 4.7, 119, "Full Frame", "L Mount", 24.2, "6K30 / 4K60", "Dual SD UHS-II", "9 fps mechanical", "5-axis 6.5-stop IBIS", "470 shots", "740 g", "Phase hybrid AF", "3.68m-dot OLED", True, False, "Free shipping"), + ("Sony Aurora A6700 Mirrorless Camera", "Sony", 1398, 1498, 4.7, 101, "APS-C", "Sony E", 26.0, "4K120", "Single SD UHS-II", "11 fps", "5-axis stabilization", "570 shots", "493 g", "AI subject recognition", "2.36m-dot OLED", True, False, "Fast creator shipping"), + ("Canon Orbit R8 Mirrorless Camera", "Canon", 1299, 1399, 4.6, 96, "Full Frame", "Canon RF", 24.2, "4K60", "Single SD UHS-II", "6 fps", "Digital stabilization", "420 shots", "461 g", "Dual Pixel AF II", "2.36m-dot EVF", True, False, "Free shipping"), + ("Nikon Zenith Zf Mirrorless Camera", "Nikon", 1896, 2046, 4.8, 88, "Full Frame", "Nikon Z", 24.5, "4K60", "Dual SD / microSD", "14 fps", "5-axis 8-stop VR", "410 shots", "710 g", "3D tracking", "3.69m-dot EVF", True, False, "Free expedited shipping"), + ("Fujifilm Northlight X-S20 Mirrorless Camera", "Fujifilm", 1299, 1399, 4.7, 82, "APS-C", "Fujifilm X", 26.1, "6.2K30", "Single SD UHS-II", "8 fps", "7-stop IBIS", "750 shots", "491 g", "Vlog-friendly AF", "2.36m-dot EVF", True, False, "Free shipping"), + ("Panasonic Lumix GH7 Mirrorless Camera", "Panasonic", 2197, 2397, 4.8, 76, "Micro Four Thirds", "MFT", 25.2, "5.7K60 ProRes", "CFexpress B / SD", "12 fps", "7.5-stop IBIS", "420 shots", "805 g", "Phase hybrid AF", "3.68m-dot OLED", True, False, "Pro video shipping"), + ("Sony Aurora A1C Mirrorless Camera", "Sony", 4498, 4798, 4.9, 65, "Full Frame", "Sony E", 50.1, "8K30 / 4K120", "Dual CFexpress A / SD", "30 fps", "5-axis 8-stop IBIS", "530 shots", "737 g", "AI subject recognition", "9.44m-dot EVF", True, True, "Priority insured shipping"), + ("Canon Orbit R50 V Mirrorless Camera", "Canon", 899, 999, 4.5, 73, "APS-C", "Canon RF", 24.2, "4K30", "Single SD UHS-II", "15 fps electronic", "Digital stabilization", "460 shots", "417 g", "Dual Pixel AF II", "2.36m-dot EVF", False, False, "Fast creator shipping"), + ] + records = [] + for idx, entry in enumerate(entries, start=1): + ( + name, brand, price, list_price, rating, review_count, sensor, mount, megapixels, video, slots, + burst, stabilization, battery, weight, autofocus, viewfinder, featured, bundle_anchor, ship_msg, + ) = entry + records.append( + make_product( + name=name, + brand=brand, + category_slug="mirrorless-cameras", + top_category_slug="photography", + product_type="Mirrorless Camera", + short_description="Hybrid mirrorless camera body for stills, creator video, and controlled studio work.", + description=f"{name} is part of the local B&H benchmark mirror. It combines responsive autofocus, dependable color, and pro-friendly media options for synthetic creator workflows.", + price=price, + list_price=list_price, + rating=rating, + review_count=review_count, + qa_count=9 + idx, + best_seller_rank=idx, + sort_newness=300 - idx, + accent_color=next(color for b, _origin, color, _blurb in BRAND_DEFS if b == brand), + specs=camera_specs(sensor, megapixels, mount, video, slots, burst, stabilization, battery, weight, autofocus, viewfinder), + search_blob=f"{brand} mirrorless {sensor} {mount} {video} creator camera photo video", + sensor_size=sensor, + mount_type=mount, + megapixels=megapixels, + connectivity="USB-C, HDMI, Wi-Fi, Bluetooth", + release_label="Spring 2025" if idx < 6 else "Fall 2024", + featured=featured, + bundle_anchor=bundle_anchor, + variants=[("Body Only", "Kit", "Body Only", 0), ("Creator Kit", "Kit", "Creator Kit", 299)], + deal_label="Deal Zone" if list_price > price else "", + ) + ) + return records + + +def dslr_products(start_rank): + entries = [ + ("Canon Terra 90D DSLR Camera", "Canon", 1149, 1299, 4.6, 118, 32.5, "Canon EF/EF-S", "4K30", "Single SD UHS-II", "10 fps", "Optical IS via lens", "1300 shots", "701 g"), + ("Nikon D780 Studio DSLR Camera", "Nikon", 1796, 1946, 4.7, 104, 24.5, "Nikon F", "4K30", "Dual SD UHS-II", "7 fps", "Optical VR via lens", "2260 shots", "840 g"), + ("Canon Terra Rebel T8 Creator DSLR", "Canon", 749, 829, 4.4, 92, 24.1, "Canon EF/EF-S", "4K24", "Single SD UHS-I", "7 fps", "Lens-based stabilization", "1240 shots", "515 g"), + ("Nikon D7500 Action DSLR", "Nikon", 896, 996, 4.6, 86, 20.9, "Nikon F", "4K30", "Single SD UHS-I", "8 fps", "Lens-based stabilization", "950 shots", "720 g"), + ("Canon Terra 5D Classic Refresh DSLR", "Canon", 1499, 1699, 4.5, 66, 30.4, "Canon EF", "4K30", "CF / SD dual", "7 fps", "Lens-based stabilization", "900 shots", "890 g"), + ("Nikon D850 Heritage DSLR", "Nikon", 2396, 2596, 4.8, 77, 45.7, "Nikon F", "4K30", "XQD / SD dual", "9 fps", "Lens-based stabilization", "1840 shots", "1005 g"), + ("Canon EOS 6D Mark Lite DSLR", "Canon", 999, 1129, 4.4, 59, 26.2, "Canon EF", "4K24", "Single SD UHS-I", "6.5 fps", "Lens-based stabilization", "1200 shots", "765 g"), + ("Nikon D5600 Travel DSLR", "Nikon", 699, 799, 4.3, 53, 24.2, "Nikon F", "Full HD", "Single SD UHS-I", "5 fps", "Lens-based stabilization", "970 shots", "465 g"), + ] + records = [] + for offset, entry in enumerate(entries): + name, brand, price, list_price, rating, review_count, mp, mount, video, slots, burst, stabilization, battery, weight = entry + records.append( + make_product( + name=name, + brand=brand, + category_slug="dslr-cameras", + top_category_slug="photography", + product_type="DSLR Camera", + short_description="Optical viewfinder camera body for still photography, event coverage, and classroom-friendly capture workflows.", + description=f"{name} mirrors a DSLR-focused B&H catalog entry with clear media, autofocus, and support notes for offline benchmark browsing.", + price=price, + list_price=list_price, + rating=rating, + review_count=review_count, + qa_count=6 + offset, + best_seller_rank=start_rank + offset, + sort_newness=250 - offset, + accent_color=next(color for b, _origin, color, _blurb in BRAND_DEFS if b == brand), + specs=camera_specs("APS-C" if mp < 25 else "Full Frame", mp, mount, video, slots, burst, stabilization, battery, weight, "Optical phase AF", "Optical pentaprism"), + search_blob=f"{brand} dslr optical viewfinder {mount} photography camera", + sensor_size="APS-C" if mp < 25 else "Full Frame", + mount_type=mount, + megapixels=mp, + connectivity="USB, HDMI, Wi-Fi", + release_label="Heritage Stock", + variants=[("Body Only", "Kit", "Body Only", 0), ("Starter Lens Kit", "Kit", "Starter Lens Kit", 179)], + deal_label="Photo Special" if price < list_price else "", + ) + ) + return records + + +def lens_products(start_rank): + entries = [ + ("Sigma ArtLine 24-70mm f/2.8 DG DN Lens", "Sigma", "Sony E", 1099, 1199, 4.8, 142, "24-70mm", "f/2.8", "82mm", "Optical stabilization", "0.18 m", "835 g", "19 elements in 15 groups", "Standard", "New"), + ("Tamron Travel 70-180mm f/2.8 Di III Lens", "Tamron", "Sony E", 1099, 1179, 4.7, 124, "70-180mm", "f/2.8", "67mm", "VC stabilization", "0.85 m", "810 g", "19 elements in 14 groups", "Telephoto", "New"), + ("Sony GM 16-35mm f/2.8 II Lens", "Sony", "Sony E", 2098, 2298, 4.9, 97, "16-35mm", "f/2.8", "82mm", "No optical stabilization", "0.22 m", "547 g", "15 elements in 12 groups", "Wide", "New"), + ("Canon RF 100-500mm f/4.5-7.1L Lens", "Canon", "Canon RF", 2699, 2899, 4.8, 93, "100-500mm", "f/4.5-7.1", "77mm", "Optical stabilization", "0.9 m", "1365 g", "20 elements in 14 groups", "Telephoto", "New"), + ("Nikon Z 24-120mm f/4 S Lens", "Nikon", "Nikon Z", 1096, 1196, 4.8, 88, "24-120mm", "f/4", "77mm", "No optical stabilization", "0.35 m", "630 g", "16 elements in 13 groups", "Standard", "New"), + ("Fujifilm XF 33mm f/1.4 R LM Lens", "Fujifilm", "Fujifilm X", 799, 899, 4.7, 82, "33mm", "f/1.4", "58mm", "No optical stabilization", "0.3 m", "360 g", "15 elements in 10 groups", "Standard", "New"), + ("Sigma ArtLine 85mm f/1.4 DG DN Lens", "Sigma", "Sony E", 999, 1099, 4.8, 75, "85mm", "f/1.4", "77mm", "No optical stabilization", "0.85 m", "625 g", "15 elements in 11 groups", "Telephoto", "Open-Box"), + ("Tamron Travel 28-75mm f/2.8 G2 Lens", "Tamron", "Sony E", 799, 899, 4.6, 120, "28-75mm", "f/2.8", "67mm", "No optical stabilization", "0.18 m", "540 g", "17 elements in 15 groups", "Standard", "New"), + ("Sony PZ 16-35mm f/4 G Lens", "Sony", "Sony E", 1198, 1298, 4.7, 71, "16-35mm", "f/4", "72mm", "Power zoom stabilization", "0.24 m", "353 g", "13 elements in 12 groups", "Wide", "New"), + ("Canon RF 24-105mm f/4L IS Lens", "Canon", "Canon RF", 1299, 1399, 4.7, 116, "24-105mm", "f/4", "77mm", "Optical stabilization", "0.45 m", "700 g", "18 elements in 14 groups", "Standard", "New"), + ("Nikon Z 70-200mm f/2.8 VR S Lens", "Nikon", "Nikon Z", 2396, 2496, 4.9, 68, "70-200mm", "f/2.8", "77mm", "VR stabilization", "0.5 m", "1360 g", "21 elements in 18 groups", "Telephoto", "New"), + ("Fujifilm XF 16-55mm f/2.8 WR II Lens", "Fujifilm", "Fujifilm X", 1249, 1349, 4.8, 59, "16-55mm", "f/2.8", "72mm", "No optical stabilization", "0.3 m", "660 g", "15 elements in 11 groups", "Wide", "New"), + ("Sigma Contemporary 18-50mm f/2.8 DC DN Lens", "Sigma", "Fujifilm X", 549, 629, 4.6, 64, "18-50mm", "f/2.8", "55mm", "No optical stabilization", "0.12 m", "285 g", "13 elements in 10 groups", "Wide", "Used"), + ("Tamron 17-70mm f/2.8 VC Lens", "Tamron", "Fujifilm X", 749, 849, 4.7, 66, "17-70mm", "f/2.8", "67mm", "VC stabilization", "0.19 m", "525 g", "16 elements in 12 groups", "Standard", "New"), + ("Sony FE 70-300mm f/4.5-5.6 G Lens", "Sony", "Sony E", 1248, 1348, 4.5, 52, "70-300mm", "f/4.5-5.6", "72mm", "Optical stabilization", "0.9 m", "854 g", "16 elements in 13 groups", "Telephoto", "Open-Box"), + ("Canon RF 35mm f/1.8 Macro IS Lens", "Canon", "Canon RF", 479, 549, 4.6, 97, "35mm", "f/1.8", "52mm", "Optical stabilization", "0.17 m", "305 g", "11 elements in 9 groups", "Standard", "New"), + ("Nikon Z 14-30mm f/4 S Lens", "Nikon", "Nikon Z", 1196, 1296, 4.8, 57, "14-30mm", "f/4", "82mm", "No optical stabilization", "0.28 m", "485 g", "14 elements in 12 groups", "Wide", "New"), + ("Panasonic Lumix 24-105mm f/4 Macro Lens", "Panasonic", "L Mount", 998, 1098, 4.6, 54, "24-105mm", "f/4", "77mm", "Optical stabilization", "0.3 m", "680 g", "16 elements in 13 groups", "Standard", "New"), + ("Sigma Sports 150-600mm f/5-6.3 DG DN Lens", "Sigma", "Sony E", 1499, 1599, 4.7, 49, "150-600mm", "f/5-6.3", "95mm", "Optical stabilization", "0.58 m", "2100 g", "25 elements in 15 groups", "Telephoto", "Used"), + ("Canon RF 16mm f/2.8 STM Lens", "Canon", "Canon RF", 279, 329, 4.4, 88, "16mm", "f/2.8", "43mm", "No optical stabilization", "0.13 m", "165 g", "9 elements in 7 groups", "Wide", "New"), + ("Sony FE 40mm f/2.5 G Lens", "Sony", "Sony E", 548, 598, 4.6, 46, "40mm", "f/2.5", "49mm", "No optical stabilization", "0.28 m", "173 g", "9 elements in 9 groups", "Standard", "Open-Box"), + ("Nikon Z 50mm f/1.8 S Lens", "Nikon", "Nikon Z", 526, 576, 4.8, 63, "50mm", "f/1.8", "62mm", "No optical stabilization", "0.4 m", "415 g", "12 elements in 9 groups", "Standard", "New"), + ("Tamron 150-500mm f/5-6.7 Di III VC Lens", "Tamron", "Sony E", 1299, 1399, 4.7, 43, "150-500mm", "f/5-6.7", "82mm", "VC stabilization", "0.6 m", "1725 g", "25 elements in 16 groups", "Telephoto", "New"), + ("Fujifilm XF 70-300mm f/4-5.6 OIS Lens", "Fujifilm", "Fujifilm X", 749, 829, 4.7, 58, "70-300mm", "f/4-5.6", "67mm", "Optical stabilization", "0.83 m", "580 g", "17 elements in 12 groups", "Telephoto", "New"), + ] + records = [] + for offset, entry in enumerate(entries): + ( + name, brand, mount, price, list_price, rating, review_count, focal, aperture, filter_size, + stabilization, close_focus, weight, construction, bucket, condition, + ) = entry + focal_bucket, specs = lens_specs(mount, focal, aperture, filter_size, stabilization, close_focus, weight, construction) + records.append( + make_product( + name=name, + brand=brand, + category_slug="camera-lenses", + top_category_slug="photography", + product_type="Lens", + short_description="Fast interchangeable lens with category-specific optical specs and comparison-friendly rows.", + description=f"{name} appears in the local B&H Photo mirror with focal length, stabilization, and compatibility details grounded in deterministic demo data.", + price=price, + list_price=list_price, + rating=rating, + review_count=review_count, + qa_count=7 + (offset % 6), + best_seller_rank=start_rank + offset, + sort_newness=220 - offset, + accent_color=next(color for b, _origin, color, _blurb in BRAND_DEFS if b == brand), + specs=specs, + search_blob=f"{brand} lens {mount} {focal} {aperture} {bucket.lower()} photography", + mount_type=mount, + focal_length=focal, + focal_length_bucket=focal_bucket, + connectivity="None", + condition=condition, + availability="In Stock" if condition != "Used" else "Limited Stock", + stock_level=6 if condition != "Used" else 2, + pickup_message="Ready today" if condition == "New" else "Ready tomorrow", + shipping_message="Insured lens delivery", + warranty="1 Year Demo Optics Coverage", + release_label="Spring 2024", + variants=[("Standard Finish", "Finish", "Standard Finish", 0), ("Protection Filter Bundle", "Bundle", "Protection Filter Bundle", 39)], + deal_label="Open-Box" if condition == "Open-Box" else ("Used Deal" if condition == "Used" else "Lens Sale"), + deal_type="open-box" if condition == "Open-Box" else ("used" if condition == "Used" else "sale"), + used_highlight=condition != "New", + ) + ) + return records + + +def cinema_products(start_rank): + entries = [ + ("Blackmagic Scout 6K Pro Cinema Camera", "Blackmagic", 2495, 2695, 4.8, 79, "Super 35", "6K Open Gate / 4K120", "13 stops", "CFexpress / USB-C SSD", "Mini XLR dual", "900 g", "Canon EF", True), + ("Panasonic EVA Core 6K Cinema Camera", "Panasonic", 3299, 3499, 4.7, 51, "Full Frame", "6K60 / 4K120", "14+ stops", "CFexpress B / SD", "Mini XLR dual", "1200 g", "L Mount", True), + ("Sony FX3 Creator Cinema Camera", "Sony", 3898, 4098, 4.9, 102, "Full Frame", "4K120", "15+ stops", "CFexpress A / SD", "XLR handle included", "715 g", "Sony E", True), + ("Canon C70 Motion Cinema Camera", "Canon", 4799, 4999, 4.8, 70, "Super 35 DGO", "4K120", "16+ stops", "Dual SD UHS-II", "Mini XLR dual", "1170 g", "Canon RF", True), + ("Blackmagic Pocket Forge 4K Cinema Camera", "Blackmagic", 1295, 1395, 4.6, 95, "Micro Four Thirds", "4K60", "13 stops", "CFast 2.0 / SD UHS-II", "Mini XLR", "722 g", "MFT", False), + ("Panasonic BoxCam BGH2 Pro Cinema Camera", "Panasonic", 1697, 1797, 4.5, 48, "Micro Four Thirds", "4K60", "13+ stops", "SD UHS-II", "3.5mm stereo", "545 g", "MFT", False), + ("Sony FX30 Studio Cinema Camera", "Sony", 1798, 1898, 4.8, 81, "APS-C", "4K120", "14+ stops", "CFexpress A / SD", "XLR via handle", "646 g", "Sony E", True), + ("Canon R5 C Motion Cinema Camera", "Canon", 3799, 3999, 4.7, 58, "Full Frame", "8K60 RAW", "14+ stops", "CFexpress B / SD", "3.5mm + digital hot shoe", "680 g", "Canon RF", True), + ("Blackmagic Studio Rack 12K Cinema Camera", "Blackmagic", 5995, 6295, 4.9, 35, "Full Frame", "12K60 RAW", "16 stops", "Dual CFexpress", "Mini XLR quad", "1800 g", "PL Mount", False), + ("Sony VENI Micro Cinema Unit", "Sony", 6995, 7295, 4.8, 27, "Full Frame", "6K60 / 4K120", "15+ stops", "CFexpress A", "XLR via adapter", "1350 g", "Sony E", False), + ] + records = [] + for offset, entry in enumerate(entries): + name, brand, price, list_price, rating, review_count, sensor, video, dynamic_range, media, inputs, weight, mount, pickup = entry + records.append( + make_product( + name=name, + brand=brand, + category_slug="cinema-cameras", + top_category_slug="video", + product_type="Cinema Camera", + short_description="Compact or production-ready cinema body with media, audio, and DR specs surfaced for comparison tasks.", + description=f"{name} is mirrored as a deterministic B&H pro video listing with no live services, no firmware checks, and no outside rental or reservation APIs.", + price=price, + list_price=list_price, + rating=rating, + review_count=review_count, + qa_count=8 + offset, + best_seller_rank=start_rank + offset, + sort_newness=200 - offset, + accent_color=next(color for b, _origin, color, _blurb in BRAND_DEFS if b == brand), + specs=cinema_specs(sensor, video, dynamic_range, media, inputs, weight, mount), + search_blob=f"{brand} cinema camera {sensor} {video} {mount}", + sensor_size=sensor, + mount_type=mount, + connectivity="USB-C, HDMI, Timecode", + availability="In Stock" if pickup else "Pre-Order", + stock_level=5 if pickup else 0, + pickup_available=pickup, + pickup_message="Pickup at flagship stores" if pickup else "Reserve for first shipment", + shipping_message="Signature video delivery", + release_label="Summer 2025", + bundle_anchor=offset < 4, + variants=[("Body Only", "Kit", "Body Only", 0), ("Production Bundle", "Kit", "Production Bundle", 499)], + deal_label="Video Event", + ) + ) + return records + + +def support_products(start_rank): + entries = [ + ("Manfrotto Carbon Travel Tripod Pro", "Manfrotto", 399, 469, 4.7, 94, "Carbon Fiber", "18 lb", "61.8 in", "17.3 in", "Ball Head", "3.2 lb"), + ("Benro Aero Video Tripod 8X", "Benro", 349, 399, 4.6, 71, "Aluminum", "13.2 lb", "65.1 in", "30.5 in", "Fluid Head", "5.1 lb"), + ("Peak Design Travel Support Carbon", "Peak Design", 599, 649, 4.8, 85, "Carbon Fiber", "20 lb", "60.2 in", "15.4 in", "Ball Head", "2.8 lb"), + ("Manfrotto Studio 055 Column Tripod", "Manfrotto", 289, 329, 4.5, 63, "Aluminum", "19.8 lb", "67.2 in", "25.6 in", "Column Head", "5.4 lb"), + ("Benro Slim Photo Tripod Kit", "Benro", 149, 179, 4.4, 58, "Aluminum", "8.8 lb", "57.5 in", "20.7 in", "Ball Head", "2.9 lb"), + ("Peak Design Mobile Creator Stand", "Peak Design", 119, 139, 4.3, 49, "Aluminum", "6 lb", "18.3 in", "11.0 in", "Phone Clamp", "1.2 lb"), + ("Manfrotto Nano Light Stand Duo", "Manfrotto", 99, 119, 4.4, 55, "Aluminum", "3 lb", "73.0 in", "19.0 in", "Stud Mount", "2.1 lb"), + ("Benro Hydra Adventure Tripod", "Benro", 279, 319, 4.6, 42, "Carbon Fiber", "15.4 lb", "60.1 in", "15.0 in", "Ball Head", "3.4 lb"), + ("Peak Design Everyday Sling 10L", "Peak Design", 149, 169, 4.7, 88, "Weatherproof Nylon", "10L carry", "n/a", "n/a", "Carry System", "1.9 lb"), + ("Manfrotto One-Step Monopod Pro", "Manfrotto", 189, 229, 4.5, 39, "Carbon Fiber", "15 lb", "63.0 in", "21.0 in", "Monopod Foot", "2.4 lb"), + ] + records = [] + for offset, entry in enumerate(entries): + name, brand, price, list_price, rating, review_count, material, payload, height, folded, head, weight = entry + product_type = "Support" if "Sling" in name else "Tripod" + specs = tripod_specs(material, payload, height, folded, head, weight) + records.append( + make_product( + name=name, + brand=brand, + category_slug="tripods-supports", + top_category_slug="photography", + product_type=product_type, + short_description="Support or carry solution with payload, travel, and pickup details surfaced for quick comparison.", + description=f"{name} is a deterministic support listing used for benchmark tasks around payload, travel size, and pickup availability.", + price=price, + list_price=list_price, + rating=rating, + review_count=review_count, + qa_count=4 + offset, + best_seller_rank=start_rank + offset, + sort_newness=180 - offset, + accent_color=next(color for b, _origin, color, _blurb in BRAND_DEFS if b == brand), + specs=specs, + search_blob=f"{brand} support tripod bag payload travel creator", + connectivity="None", + availability="In Stock" if offset < 8 else "Limited Stock", + stock_level=9 - (offset % 4), + pickup_message="Ready in 90 minutes", + shipping_message="Oversize support shipping" if "Studio" in name else "Free shipping", + release_label="Creator Support 2025", + deal_label="Support Sale", + used_highlight="Sling" in name, + condition="Used" if "Sling" in name else "New", + ) + ) + return records + + +def storage_products(start_rank): + entries = [ + ("SanDisk Pro Cinema CFexpress Type B 512GB Card", "SanDisk", 279, 319, 4.8, 147, 512, "Card", "1700 MB/s", "1400 MB/s", "CFexpress Type B", "0.03 lb", "New"), + ("Lexar Gold SDXC 256GB V90 Card", "Lexar", 199, 229, 4.7, 134, 256, "Card", "300 MB/s", "260 MB/s", "SDXC UHS-II", "0.01 lb", "New"), + ("SanDisk Extreme Portable SSD 2TB", "SanDisk", 189, 219, 4.7, 121, 2048, "Portable SSD", "1050 MB/s", "1000 MB/s", "USB-C 3.2 Gen 2", "0.17 lb", "New"), + ("Lexar Workflow Dock 4-Bay", "Lexar", 139, 159, 4.5, 76, 0, "Workflow Dock", "n/a", "n/a", "USB-C Dock", "1.5 lb", "New"), + ("SanDisk Ultra Luxe SDXC 128GB Card", "SanDisk", 39, 49, 4.4, 112, 128, "Card", "150 MB/s", "90 MB/s", "SDXC UHS-I", "0.01 lb", "New"), + ("Lexar Armor Gold CFexpress 1TB Card", "Lexar", 389, 419, 4.8, 64, 1024, "Card", "1900 MB/s", "1500 MB/s", "CFexpress Type B", "0.03 lb", "New"), + ("SanDisk ProBlade 4TB SSD Mag", "SanDisk", 349, 399, 4.6, 41, 4096, "SSD Mag", "2000 MB/s", "2000 MB/s", "USB-C Blade", "0.25 lb", "Open-Box"), + ("Lexar Pro Go Portable SSD 1TB", "Lexar", 119, 139, 4.5, 52, 1024, "Portable SSD", "1050 MB/s", "1000 MB/s", "USB-C 3.2 Gen 2", "0.2 lb", "New"), + ("SanDisk Creator microSD 512GB Card", "SanDisk", 69, 79, 4.4, 87, 512, "Card", "190 MB/s", "130 MB/s", "microSD UHS-I", "0.01 lb", "New"), + ("Lexar Silver SDXC 512GB Card", "Lexar", 69, 79, 4.3, 60, 512, "Card", "205 MB/s", "140 MB/s", "SDXC UHS-I", "0.01 lb", "Used"), + ("SanDisk Rugged Studio SSD 4TB", "SanDisk", 499, 549, 4.7, 34, 4096, "Desktop SSD", "3000 MB/s", "2500 MB/s", "Thunderbolt 4", "1.8 lb", "New"), + ("Lexar Capture Hub Reader", "Lexar", 79, 89, 4.2, 29, 0, "Card Reader", "n/a", "n/a", "USB-C Reader", "0.25 lb", "New"), + ] + records = [] + for offset, entry in enumerate(entries): + name, brand, price, list_price, rating, review_count, capacity_gb, form_factor, read_speed, write_speed, interface, weight, condition = entry + records.append( + make_product( + name=name, + brand=brand, + category_slug="memory-cards-storage", + top_category_slug="photography", + product_type="Storage", + short_description="Capture media or SSD workflow hardware with capacity, speed, and interface filters baked in.", + description=f"{name} is mirrored as deterministic media inventory with readable throughput specs and creator workflow metadata.", + price=price, + list_price=list_price, + rating=rating, + review_count=review_count, + qa_count=5 + offset, + best_seller_rank=start_rank + offset, + sort_newness=160 - offset, + accent_color=next(color for b, _origin, color, _blurb in BRAND_DEFS if b == brand), + specs=storage_specs(f"{capacity_gb} GB" if capacity_gb and capacity_gb < 1024 else (f"{capacity_gb // 1024} TB" if capacity_gb else "Accessory"), form_factor, read_speed, write_speed, interface, weight), + search_blob=f"{brand} storage media {form_factor} {capacity_gb}gb {interface}", + capacity_gb=capacity_gb, + connectivity=interface, + availability="In Stock" if condition == "New" else "Limited Stock", + stock_level=8 if condition == "New" else 2, + pickup_message="Ready in 1 hour", + shipping_message="Free media shipping", + release_label="Workflow 2025", + condition=condition, + used_highlight=condition != "New", + deal_label="Storage Deal" if condition == "New" else condition, + deal_type="sale" if condition == "New" else condition.lower(), + ) + ) + return records + + +def lighting_products(start_rank): + entries = [ + ("Aputure Storm 300x Bi-Color LED", "Aputure", 949, 1049, 4.8, 96, "58,000 lux at 1m", "2700-6500K", "CRI 96 / TLCI 97", "300W", "Sidus Link app, DMX, onboard", "6.4 lb"), + ("Godox LiteMax 200 RGB Fixture", "Godox", 499, 569, 4.6, 72, "26,000 lux at 1m", "2800-10,000K RGB", "CRI 95 / TLCI 95", "200W", "Bluetooth app, onboard", "4.8 lb"), + ("Aputure Nova Slim 1x1 Panel", "Aputure", 699, 779, 4.7, 58, "18,000 lux at 1m", "2700-6500K", "CRI 96 / TLCI 97", "200W", "Sidus Link app, DMX, CRMX", "7.5 lb"), + ("Godox FlexBeam 100 Portable LED", "Godox", 229, 259, 4.5, 63, "8,500 lux at 1m", "2700-6500K", "CRI 96 / TLCI 96", "100W", "App control, onboard", "2.8 lb"), + ("Aputure Infinibar 4-Light Kit", "Aputure", 1129, 1249, 4.8, 41, "RGB pixel bar set", "2000-10,000K", "CRI 95 / TLCI 98", "400W total", "App control, DMX, CRMX", "12.0 lb"), + ("Godox SL150III Daylight Monolight", "Godox", 329, 369, 4.4, 74, "74,300 lux at 1m", "5600K", "CRI 96 / TLCI 97", "160W", "App control, onboard", "4.2 lb"), + ("Aputure MC Pro Pocket RGB Light", "Aputure", 199, 229, 4.6, 83, "Pocket accent light", "2000-10,000K", "CRI 96 / TLCI 97", "5W", "App control", "0.6 lb"), + ("Godox TubeFlow RGB Pair", "Godox", 279, 319, 4.5, 34, "RGB tube pair", "2700-8500K", "CRI 95 / TLCI 96", "50W total", "App control", "2.4 lb"), + ("Aputure Fresnel 2x Modifier", "Aputure", 149, 169, 4.6, 44, "Modifier", "n/a", "n/a", "Passive", "Manual focus beam", "2.1 lb"), + ("Godox Lantern Dome Softbox 65cm", "Godox", 89, 99, 4.3, 57, "Modifier", "n/a", "n/a", "Passive", "Quick-release mount", "1.8 lb"), + ("Aputure Amaran 150c RGB Monolight", "Aputure", 359, 399, 4.6, 52, "15,610 lux at 1m", "2500-7500K RGB", "CRI 95 / TLCI 96", "180W", "App control, onboard", "6.0 lb"), + ("Godox Knowled M300Bi", "Godox", 879, 949, 4.7, 29, "85,700 lux at 1m", "2800-6500K", "CRI 97 / TLCI 98", "330W", "App, DMX, CRMX", "7.4 lb"), + ] + records = [] + for offset, entry in enumerate(entries): + name, brand, price, list_price, rating, review_count, output, cct, cri, power, control, weight = entry + product_type = "Lighting" + records.append( + make_product( + name=name, + brand=brand, + category_slug="lighting-kits", + top_category_slug="lighting", + product_type=product_type, + short_description="Lighting fixture or modifier with output, color, and control specs surfaced for studio shopping tasks.", + description=f"{name} appears in the local mirror with enough tech detail to compare output, control options, and bundle readiness without contacting any live vendor system.", + price=price, + list_price=list_price, + rating=rating, + review_count=review_count, + qa_count=4 + offset, + best_seller_rank=start_rank + offset, + sort_newness=140 - offset, + accent_color=next(color for b, _origin, color, _blurb in BRAND_DEFS if b == brand), + specs=lighting_specs(output, cct, cri, power, control, weight), + search_blob=f"{brand} lighting led rgb bi-color output app control", + connectivity="Bluetooth, DMX" if "DMX" in control else "Bluetooth", + availability="In Stock", + stock_level=8, + pickup_message="Pickup ready by afternoon", + shipping_message="Studio gear shipping", + release_label="Studio Light 2025", + featured=offset < 2, + deal_label="Lighting Event", + ) + ) + return records + + +def microphone_products(start_rank): + entries = [ + ("Rode Wireless Creator Duo", "Rode", 299, 329, 4.8, 155, "Omnidirectional", "20 Hz - 20 kHz", "2.4 GHz, USB-C, 3.5mm", "Internal battery", "0.3 lb", "Dual transmitter kit"), + ("DJI Mic Air 2TX Kit", "DJI", 349, 379, 4.7, 101, "Omnidirectional", "50 Hz - 20 kHz", "2.4 GHz, USB-C, Lightning", "Internal battery", "0.35 lb", "Dual transmitter kit"), + ("Shure MVX2 USB Stream Mic", "Shure", 229, 249, 4.6, 88, "Cardioid", "50 Hz - 16 kHz", "USB-C, 3.5mm", "USB bus power", "0.9 lb", "Desktop yoke mount"), + ("Rode NTG Creator Shotgun", "Rode", 249, 279, 4.6, 65, "Supercardioid", "20 Hz - 20 kHz", "XLR, 3.5mm", "Phantom / internal battery", "0.55 lb", "Cold-shoe mount"), + ("Sennheiser MKE Field 600 Shotgun", "Sennheiser", 329, 359, 4.7, 54, "Supercardioid", "40 Hz - 20 kHz", "XLR", "Phantom power", "0.6 lb", "Shock mount included"), + ("Shure MV7X Voice Broadcast Mic", "Shure", 179, 199, 4.6, 119, "Cardioid", "50 Hz - 16 kHz", "XLR", "Passive", "1.2 lb", "Desktop or boom mount"), + ("Rode PodMic USB Dynamic Mic", "Rode", 199, 219, 4.7, 82, "Cardioid", "20 Hz - 20 kHz", "USB-C, XLR", "USB bus / XLR", "2.0 lb", "Boom or desk mount"), + ("DJI Pocket Interview Mic", "DJI", 149, 169, 4.5, 39, "Cardioid", "50 Hz - 18 kHz", "USB-C", "Rechargeable", "0.2 lb", "Handheld interview kit"), + ("Sennheiser Profile USB Mic", "Sennheiser", 149, 169, 4.5, 61, "Cardioid", "20 Hz - 20 kHz", "USB-C", "USB bus power", "0.77 lb", "Desk stand included"), + ("Shure MoveMic Reporter Kit", "Shure", 269, 299, 4.4, 28, "Omnidirectional", "50 Hz - 18 kHz", "Bluetooth, USB-C", "Internal battery", "0.42 lb", "Mobile reporter kit"), + ("Rode Streamer Lavalier Twin", "Rode", 189, 209, 4.4, 31, "Omnidirectional", "20 Hz - 20 kHz", "USB-C adapter", "Plug-in power", "0.18 lb", "Dual clip-on set"), + ("Sennheiser Accent Podcast Pack", "Sennheiser", 399, 429, 4.6, 26, "Cardioid", "50 Hz - 16 kHz", "USB interface, XLR", "USB + phantom", "2.5 lb", "Interface plus mic bundle"), + ] + records = [] + for offset, entry in enumerate(entries): + name, brand, price, list_price, rating, review_count, pattern, response, connectivity, power, weight, bundle_note = entry + records.append( + make_product( + name=name, + brand=brand, + category_slug="microphones", + top_category_slug="audio", + product_type="Microphone", + short_description="Microphone or wireless capture kit with connectivity and monitoring data ready for compare and Q&A tasks.", + description=f"{name} mirrors an audio-focused B&H listing with demo-only purchasing, local pickup, and no real device activation flow.", + price=price, + list_price=list_price, + rating=rating, + review_count=review_count, + qa_count=6 + offset, + best_seller_rank=start_rank + offset, + sort_newness=120 - offset, + accent_color=next(color for b, _origin, color, _blurb in BRAND_DEFS if b == brand), + specs=audio_specs(pattern, response, connectivity, power, weight, bundle_note), + search_blob=f"{brand} microphone wireless usb xlr creator audio", + connectivity=connectivity, + availability="In Stock", + stock_level=11 - (offset % 4), + pickup_message="Ready at audio desks", + shipping_message="Free audio shipping", + release_label="Audio 2025", + featured=offset < 2, + deal_label="Audio Special", + ) + ) + return records + + +def headphone_products(start_rank): + entries = [ + ("Sennheiser HD 490 Pro Reference Headphones", "Sennheiser", 399, 429, 4.8, 68, "Open-back", "5 Hz - 36 kHz", "Wired 3.5mm / 1/4in", "Passive", "0.72 lb", "Reference mixing"), + ("Sony Studio MDR-M1 Headphones", "Sony", 249, 279, 4.6, 57, "Closed-back", "5 Hz - 80 kHz", "Wired 3.5mm / 1/4in", "Passive", "0.55 lb", "Recording and monitoring"), + ("Sennheiser HD 280 Broadcast Headphones", "Sennheiser", 99, 119, 4.5, 122, "Closed-back", "8 Hz - 25 kHz", "Wired 3.5mm / 1/4in", "Passive", "0.63 lb", "Tracking"), + ("Sony Monitor ZX Pro Headphones", "Sony", 129, 149, 4.4, 74, "Closed-back", "10 Hz - 40 kHz", "Wired 3.5mm", "Passive", "0.47 lb", "Creator desk monitoring"), + ("Sennheiser Accent BT Creator Headset", "Sennheiser", 229, 249, 4.3, 43, "Closed-back", "18 Hz - 22 kHz", "Bluetooth 5.3, USB-C", "Internal battery", "0.62 lb", "Portable editing"), + ("Sony OpenMix Spatial Headphones", "Sony", 299, 329, 4.5, 28, "Open-back", "8 Hz - 50 kHz", "Wired and USB-C DAC", "Passive", "0.59 lb", "Spatial reference"), + ("Sennheiser FieldFold On-Ear Headphones", "Sennheiser", 89, 99, 4.2, 31, "On-ear", "20 Hz - 20 kHz", "Wired 3.5mm", "Passive", "0.36 lb", "Compact field kit"), + ("Sony Creator ANC Monitor Headphones", "Sony", 349, 379, 4.6, 36, "Closed-back ANC", "10 Hz - 30 kHz", "Bluetooth 5.2, 3.5mm, USB-C", "Internal battery", "0.64 lb", "Travel and edit"), + ] + records = [] + for offset, entry in enumerate(entries): + name, brand, price, list_price, rating, review_count, pattern, response, connectivity, power, weight, bundle_note = entry + records.append( + make_product( + name=name, + brand=brand, + category_slug="headphones", + top_category_slug="audio", + product_type="Headphones", + short_description="Monitoring headphones with connection, isolation, and workflow notes suitable for side-by-side comparison tasks.", + description=f"{name} is a deterministic audio monitoring product in the local B&H mirror with pickup badges and no live warranty lookup.", + price=price, + list_price=list_price, + rating=rating, + review_count=review_count, + qa_count=3 + offset, + best_seller_rank=start_rank + offset, + sort_newness=105 - offset, + accent_color=next(color for b, _origin, color, _blurb in BRAND_DEFS if b == brand), + specs=audio_specs(pattern, response, connectivity, power, weight, bundle_note), + search_blob=f"{brand} headphones monitor {connectivity} studio", + connectivity=connectivity, + availability="In Stock", + stock_level=10, + pickup_message="Ready today", + shipping_message="Free shipping", + release_label="Monitoring 2025", + deal_label="Audio Special", + ) + ) + return records + + +def drone_products(start_rank): + entries = [ + ("DJI Airframe 4S Fly More Drone", "DJI", 1399, 1499, 4.8, 92, "1-inch CMOS", "5.4K60", "46 min", "Omnidirectional", "O4 transmission", "1.58 lb"), + ("DJI Neo Cinema Mini Drone", "DJI", 699, 749, 4.5, 81, "1/1.3-inch CMOS", "4K60 vertical", "31 min", "Forward / downward", "O4 Lite", "0.54 lb"), + ("DJI Inspire Mini Pro Drone", "DJI", 2199, 2399, 4.7, 49, "4/3 CMOS", "6K30", "38 min", "Omnidirectional", "O4 transmission", "2.4 lb"), + ("DJI Adventure FPV Combo", "DJI", 999, 1099, 4.4, 36, "1/1.7-inch CMOS", "4K120", "22 min", "Forward sensing", "Low-latency FPV", "1.7 lb"), + ("DJI Mini Air Travel Drone", "DJI", 499, 549, 4.4, 77, "1/1.3-inch CMOS", "4K30", "34 min", "Tri-directional", "O3 transmission", "0.55 lb"), + ("DJI Mavic Survey Drone", "DJI", 1899, 1999, 4.6, 28, "4/3 CMOS", "5.1K50", "43 min", "Omnidirectional", "O4 transmission", "2.0 lb"), + ("DJI Pocket FPV Neo", "DJI", 399, 449, 4.2, 24, "1/2-inch CMOS", "4K30", "18 min", "Downward", "FPV transmission", "0.46 lb"), + ("DJI Creator Airframe SE", "DJI", 849, 899, 4.5, 31, "1-inch CMOS", "4K60", "39 min", "Forward / backward", "O4 Lite", "0.95 lb"), + ] + records = [] + for offset, entry in enumerate(entries): + name, brand, price, list_price, rating, review_count, sensor, video, flight_time, avoidance, transmission, weight = entry + records.append( + make_product( + name=name, + brand=brand, + category_slug="drones", + top_category_slug="video", + product_type="Drone", + short_description="Aerial creator platform with flight time, sensing, and transmission rows designed for filter and comparison tasks.", + description=f"{name} is part of the local benchmark catalog and never connects to live flight services, geofencing, or registration systems.", + price=price, + list_price=list_price, + rating=rating, + review_count=review_count, + qa_count=7 + offset, + best_seller_rank=start_rank + offset, + sort_newness=92 - offset, + accent_color=next(color for b, _origin, color, _blurb in BRAND_DEFS if b == brand), + specs=drone_specs(sensor, video, flight_time, avoidance, transmission, weight), + search_blob=f"{brand} drone aerial {video} {sensor} creator", + connectivity=transmission, + availability="In Stock" if offset < 6 else "Limited Stock", + stock_level=4 if offset < 6 else 1, + pickup_message="Pickup by end of day", + shipping_message="Adult signature shipping", + release_label="Aerial 2025", + featured=offset < 2, + deal_label="Drone Event", + ) + ) + return records + + +def monitor_recorder_products(start_rank): + entries = [ + ("Atomos Ninja Creator Monitor 5", "Atomos", 699, 759, 4.7, 54, "5-inch 1000 nit touchscreen", "4K60 ProRes", "HDMI in/out, USB-C", "2.5-inch SSD or USB-C media", "0.8 lb"), + ("Atomos Shogun Studio 7 Monitor Recorder", "Atomos", 1199, 1299, 4.6, 32, "7-inch 2000 nit HDR", "6K30 / 4K120", "HDMI, 12G-SDI, USB-C", "SSD media", "1.9 lb"), + ("Sony Creator Field Monitor 5", "Sony", 499, 549, 4.5, 41, "5.5-inch daylight display", "4K30 monitoring", "HDMI, USB-C power", "microSD LUT import", "0.9 lb"), + ("Blackmagic Video Assist 12G 7", "Blackmagic", 995, 1045, 4.7, 36, "7-inch HDR touchscreen", "4K60 BRAW", "12G-SDI, HDMI, USB-C", "Dual SD UHS-II", "1.7 lb"), + ("Atomos Neon Mobile Director Monitor", "Atomos", 1499, 1599, 4.6, 18, "17-inch on-set display", "4K60 monitoring", "HDMI, 12G-SDI", "External media module", "7.9 lb"), + ("Blackmagic Pocket View 5 HDR Monitor", "Blackmagic", 429, 469, 4.4, 22, "5-inch HDR touch display", "4K monitoring", "HDMI, USB-C power", "LUTs via SD card", "0.7 lb"), + ] + records = [] + for offset, entry in enumerate(entries): + name, brand, price, list_price, rating, review_count, panel, recording, connectivity, media, weight = entry + records.append( + make_product( + name=name, + brand=brand, + category_slug="monitors-recorders", + top_category_slug="video", + product_type="Monitor/Recorder", + short_description="Field monitor or recorder with brightness, recording, and I/O data tailored for compare-heavy production tasks.", + description=f"{name} is mirrored as a local-only monitoring listing with no firmware downloads, live media activation, or external service calls.", + price=price, + list_price=list_price, + rating=rating, + review_count=review_count, + qa_count=5 + offset, + best_seller_rank=start_rank + offset, + sort_newness=88 - offset, + accent_color=next(color for b, _origin, color, _blurb in BRAND_DEFS if b == brand), + specs=monitor_specs(panel, recording, "LUT and waveform tools", "HDR-ready", connectivity, weight), + search_blob=f"{brand} monitor recorder field display {recording} {connectivity}", + connectivity=connectivity, + availability="In Stock", + stock_level=5, + pickup_message="Ready at pro video counters", + shipping_message="Protected display shipping", + release_label="Production Monitor 2025", + featured=offset < 2, + deal_label="Video Special", + ) + ) + return records + + +def laptop_products(start_rank): + entries = [ + ("Apple CreatorBook 14 M3 Pro Laptop", "Apple", 2399, 2499, 4.8, 87, "M3 Pro 12-core", "36GB unified memory", "1TB SSD", "14.2-inch 3024x1964 mini-LED", "Thunderbolt 4 x3, HDMI, SDXC", "Wi-Fi 6E, Bluetooth 5.3", "3.5 lb"), + ("Dell Precision Edge 16 Laptop", "Dell", 2699, 2849, 4.7, 63, "Intel Core Ultra 9", "32GB DDR5", "2TB SSD", "16-inch 3840x2400 OLED", "Thunderbolt 4 x2, HDMI, SD", "Wi-Fi 7, Bluetooth 5.4", "4.4 lb"), + ("ASUS ProArt Studio 13 OLED Laptop", "ASUS", 1899, 1999, 4.6, 58, "AMD Ryzen AI 9", "32GB LPDDR5X", "1TB SSD", "13.3-inch 2880x1800 OLED", "USB4, HDMI, USB-A, SD", "Wi-Fi 6E, Bluetooth 5.3", "3.3 lb"), + ("Apple CreatorBook 16 Max Laptop", "Apple", 3199, 3349, 4.9, 51, "M3 Max 16-core", "48GB unified memory", "1TB SSD", "16.2-inch 3456x2234 mini-LED", "Thunderbolt 4 x3, HDMI, SDXC", "Wi-Fi 6E, Bluetooth 5.3", "4.7 lb"), + ("Dell Mobile Color 14 Laptop", "Dell", 1599, 1699, 4.5, 44, "Intel Core Ultra 7", "16GB DDR5", "1TB SSD", "14-inch 2880x1800 OLED", "Thunderbolt 4 x2, HDMI", "Wi-Fi 6E, Bluetooth 5.3", "3.2 lb"), + ("ASUS ProArt Studio 16 Laptop", "ASUS", 2299, 2399, 4.7, 39, "AMD Ryzen 9", "32GB DDR5", "2TB SSD", "16-inch 3840x2400 OLED", "USB4, HDMI, USB-A, SD Express", "Wi-Fi 6E, Bluetooth 5.3", "4.5 lb"), + ("Apple CreatorBook Air 15 Laptop", "Apple", 1499, 1599, 4.6, 72, "M3 8-core", "24GB unified memory", "512GB SSD", "15.3-inch 2880x1864 Liquid Retina", "MagSafe, USB-C x2", "Wi-Fi 6E, Bluetooth 5.3", "3.3 lb"), + ("Dell Creator Compact 13 Laptop", "Dell", 1399, 1499, 4.4, 33, "Intel Core Ultra 5", "16GB LPDDR5", "512GB SSD", "13.4-inch 1920x1200 IPS", "USB-C x2, USB-A", "Wi-Fi 6E, Bluetooth 5.3", "2.9 lb"), + ] + records = [] + for offset, entry in enumerate(entries): + name, brand, price, list_price, rating, review_count, processor, memory, storage, display, ports, connectivity, weight = entry + records.append( + make_product( + name=name, + brand=brand, + category_slug="laptops", + top_category_slug="computers", + product_type="Laptop", + short_description="Creator laptop with performance, display, and port detail rows designed for real shopping-style filtering and compare tasks.", + description=f"{name} is a deterministic creator notebook listing with no live financing, warranty registration, or software activation.", + price=price, + list_price=list_price, + rating=rating, + review_count=review_count, + qa_count=5 + offset, + best_seller_rank=start_rank + offset, + sort_newness=80 - offset, + accent_color=next(color for b, _origin, color, _blurb in BRAND_DEFS if b == brand), + specs=computer_specs(processor, memory, storage, display, ports, connectivity, weight), + search_blob=f"{brand} laptop creator {processor} {memory} {storage} {display}", + capacity_gb=2048 if "2TB" in storage else (1024 if "1TB" in storage else 512), + connectivity=connectivity, + availability="In Stock", + stock_level=6, + pickup_message="Ready in 2 hours", + shipping_message="Free insured shipping", + release_label="Compute 2025", + featured=offset < 2, + deal_label="Creator Laptop Sale", + ) + ) + return records + + +def monitor_products(start_rank): + entries = [ + ("BenQ ColorPro 32 PD3225U Monitor", "BenQ", 1199, 1299, 4.8, 58, "31.5-inch", "3840x2160", "95% P3 / 99% Rec.709", "DisplayHDR 400", "Thunderbolt 4, HDMI, DP, USB-C", "18.5 lb"), + ("Dell UltraSharp 32 Thunderbolt Monitor", "Dell", 1399, 1499, 4.6, 43, "32-inch", "3840x2160", "98% DCI-P3", "DisplayHDR 600", "Thunderbolt 4, HDMI, DP, USB hub", "20.0 lb"), + ("Apple Studio Panel 27 Monitor", "Apple", 1599, 1599, 4.7, 64, "27-inch", "5120x2880", "P3 wide color", "600 nits", "Thunderbolt 3, USB-C x3", "13.9 lb"), + ("ASUS ProArt 27 PA279CRV Monitor", "ASUS", 499, 549, 4.5, 72, "27-inch", "3840x2160", "99% DCI-P3 / Adobe RGB", "HDR10", "USB-C, HDMI, DP, USB hub", "15.2 lb"), + ("BenQ SW272U Photo Monitor", "BenQ", 799, 879, 4.7, 37, "27-inch", "3840x2160", "99% Adobe RGB", "HDR10", "USB-C, HDMI, DP", "17.1 lb"), + ("Dell DualView 40 Creator Monitor", "Dell", 1699, 1799, 4.6, 28, "40-inch", "5120x2160", "98% DCI-P3", "DisplayHDR 600", "Thunderbolt 4, HDMI, DP, Ethernet", "31.0 lb"), + ("Apple Studio Panel Nano 27 Monitor", "Apple", 1899, 1899, 4.8, 25, "27-inch", "5120x2880", "P3 wide color", "600 nits", "Thunderbolt 3, USB-C x3", "14.1 lb"), + ("ASUS CreatorView 32 OLED Monitor", "ASUS", 1299, 1399, 4.6, 23, "31.5-inch", "3840x2160 OLED", "99% DCI-P3", "HDR10", "USB-C, HDMI, DP", "18.8 lb"), + ] + records = [] + for offset, entry in enumerate(entries): + name, brand, price, list_price, rating, review_count, size, resolution, color, hdr, inputs, weight = entry + condition = "Open-Box" if "SW272U" in name else "New" + records.append( + make_product( + name=name, + brand=brand, + category_slug="monitors", + top_category_slug="computers", + product_type="Monitor", + short_description="Creator display with clear panel, color, and input data for side-by-side compare workflows.", + description=f"{name} is mirrored with a spec-heavy retailer-style presentation for color, connectivity, and pickup benchmarking.", + price=price, + list_price=list_price, + rating=rating, + review_count=review_count, + qa_count=4 + offset, + best_seller_rank=start_rank + offset, + sort_newness=72 - offset, + accent_color=next(color for b, _origin, color, _blurb in BRAND_DEFS if b == brand), + specs=monitor_specs(size, resolution, color, hdr, inputs, weight), + search_blob=f"{brand} monitor color {resolution} {size} {inputs}", + connectivity=inputs, + availability="In Stock" if condition == "New" else "Limited Stock", + stock_level=5 if condition == "New" else 1, + pickup_message="Pickup available in select stores", + shipping_message="Oversize monitor shipping", + release_label="Display 2025", + condition=condition, + used_highlight=condition != "New", + deal_label="Open-Box" if condition != "New" else "Monitor Sale", + deal_type="open-box" if condition != "New" else "sale", + ) + ) + return records + + +def printer_products(start_rank): + entries = [ + ("Epson P900 Fine Art Photo Printer", "Epson", 1195, 1295, 4.7, 52, "13-inch sheets and roll media", "5760 x 1440 dpi", "1.5 min A3+", "UltraChrome PRO10", "USB, Ethernet, Wi-Fi", "35.7 lb"), + ("Canon ImagePRO ScanLite 400", "Canon", 699, 779, 4.4, 29, "A4 document and photo scanning", "4800 dpi", "4 sec per photo", "CIS scanner", "USB 3.0", "9.8 lb"), + ("Epson V850 Archival Photo Scanner", "Epson", 999, 1099, 4.7, 41, "Film and reflective originals", "6400 dpi", "1 min per 35mm strip", "Dual-lens CCD", "USB 2.0", "14.0 lb"), + ("Canon PIXStudio Pro 200 Printer", "Canon", 599, 649, 4.5, 38, "13-inch sheets", "4800 x 2400 dpi", "90 sec A3+", "Dye ink 8-color", "USB, Wi-Fi, Ethernet", "32.0 lb"), + ("Epson SureColor P700 Compact Printer", "Epson", 799, 869, 4.6, 34, "13-inch sheets and roll media", "5760 x 1440 dpi", "2 min A3+", "UltraChrome PRO10", "USB, Wi-Fi, Ethernet", "28.0 lb"), + ("Canon ScanGear Film 900", "Canon", 349, 399, 4.3, 19, "Film and photo scanning", "4800 dpi", "35 sec 35mm frame", "CIS scanner", "USB-C", "7.1 lb"), + ("Epson EcoProof Label Printer", "Epson", 249, 279, 4.2, 24, "Shipping and asset labels", "600 dpi", "4 ips", "Pigment ink", "USB, Ethernet", "11.3 lb"), + ("Canon DocumentFlow D120 Scanner", "Canon", 429, 479, 4.4, 21, "A4 office scanning", "600 dpi", "45 ppm", "CIS scanner", "USB 3.0", "8.2 lb"), + ] + records = [] + for offset, entry in enumerate(entries): + name, brand, price, list_price, rating, review_count, media, resolution, speed, ink, connectivity, weight = entry + product_type = "Scanner" if "Scanner" in name or "Scan" in name else "Printer" + records.append( + make_product( + name=name, + brand=brand, + category_slug="printers-scanners", + top_category_slug="computers", + product_type=product_type, + short_description="Output or scanning hardware with media, resolution, and workflow spec rows for benchmark product tasks.", + description=f"{name} mirrors a pro output listing with deterministic shipping, stock, and support flows inside the local benchmark environment.", + price=price, + list_price=list_price, + rating=rating, + review_count=review_count, + qa_count=3 + offset, + best_seller_rank=start_rank + offset, + sort_newness=64 - offset, + accent_color=next(color for b, _origin, color, _blurb in BRAND_DEFS if b == brand), + specs=printer_specs(media, resolution, speed, ink, connectivity, weight), + search_blob=f"{brand} printer scanner photo output {media}", + connectivity=connectivity, + availability="In Stock", + stock_level=3 + (offset % 3), + pickup_message="Pickup at print workflow counters", + shipping_message="Freight-safe shipping", + release_label="Output 2025", + deal_label="Print Studio Sale", + ) + ) + return records + + +def build_products(): + products = [] + products.extend(mirrorless_products()) + products.extend(dslr_products(len(products) + 1)) + products.extend(lens_products(len(products) + 1)) + products.extend(cinema_products(len(products) + 1)) + products.extend(support_products(len(products) + 1)) + products.extend(storage_products(len(products) + 1)) + products.extend(lighting_products(len(products) + 1)) + products.extend(microphone_products(len(products) + 1)) + products.extend(headphone_products(len(products) + 1)) + products.extend(drone_products(len(products) + 1)) + products.extend(monitor_recorder_products(len(products) + 1)) + products.extend(laptop_products(len(products) + 1)) + products.extend(monitor_products(len(products) + 1)) + products.extend(printer_products(len(products) + 1)) + return products + + +def create_reviews(product, ProductReview): + templates = [ + ("Reliable for paid work", "Used this on three recent shoots and the deterministic B&H mirror details lined up perfectly with the specs and pickup messaging."), + ("Great balance of value and features", "The spec presentation makes it easy to compare against similar options without the page hiding the important workflow details."), + ("Solid creator workflow fit", "Shipping notes, accessories, and compatibility sections felt like a real retailer experience while staying fully local and synthetic."), + ] + reviews = [] + desired = 2 if product.best_seller_rank <= 40 else 1 + for index in range(desired): + headline, body = templates[(product.id + index) % len(templates)] + reviews.append( + ProductReview( + product_id=product.id, + author_name=["Alex M.", "Jordan P.", "Samira L.", "Miles C."][(product.id + index) % 4], + headline=headline, + body=f"{body} {product.name} handled as expected in this demo environment.", + rating=5 if index == 0 or product.rating >= 4.7 else 4, + verified_purchase=True, + created_at=MIRROR_REFERENCE_DATE - timedelta(days=(product.id + index) % 120), + ) + ) + return reviews + + +def create_questions(product, ProductQuestion, ProductAnswer): + prompts = [ + ("Does this include a charger or power supply in the box?", "Yes. The demo listing includes the default power accessories noted in the spec and package section."), + ("Is local store pickup supported for this item?", "Pickup is available anywhere the store inventory block shows stock. This mirror uses deterministic synthetic availability."), + ("Will this work for a hybrid photo and video workflow?", "It is positioned for hybrid creator use in the local mirror and the technical specs reflect that workflow emphasis."), + ("Does this ship with cables or core accessories?", "Yes. Included accessories are listed in the spec groups and remain stable for benchmark tasks."), + ] + qa_pairs = [] + count = 2 if product.best_seller_rank <= 60 else 1 + for index in range(count): + question_text, answer_text = prompts[(product.id + index) % len(prompts)] + question = ProductQuestion( + product_id=product.id, + question=question_text, + asker_name=["Priya", "Ethan", "Nina", "Oscar"][(product.id + index) % 4], + created_at=MIRROR_REFERENCE_DATE - timedelta(days=(product.id + index) % 90), + ) + qa_pairs.append((question, answer_text)) + return qa_pairs + + +def bundle_definitions(): + return [ + ("Sony Hybrid Creator Kit", ["sony-aurora-a7x-mirrorless-camera", "sigma-artline-24-70mm-f-2-8-dg-dn-lens", "sandisk-pro-cinema-cfexpress-type-b-512gb-card"], 3499, 3796, "Featured", "Photo/video hybrid creators"), + ("Canon Wedding Coverage Bundle", ["canon-orbit-r6-mark-ii-mirrorless-camera", "canon-rf-24-105mm-f-4l-is-lens", "peak-design-everyday-sling-10l"], 3449, 3667, "Bundle", "Event photographers"), + ("Nikon Long Zoom Field Kit", ["nikon-zenith-z8-mirrorless-camera", "nikon-z-70-200mm-f-2-8-vr-s-lens", "manfrotto-carbon-travel-tripod-pro"], 6099, 6291, "Bundle", "Sports and wildlife"), + ("Fujifilm Street Story Pack", ["fujifilm-northlight-x-t5-mirrorless-camera", "fujifilm-xf-33mm-f-1-4-r-lm-lens", "peak-design-everyday-sling-10l"], 2499, 2647, "Bundle", "Travel creators"), + ("Panasonic Video Starter Rig", ["panasonic-lumix-s5-ii-mirrorless-camera", "panasonic-lumix-24-105mm-f-4-macro-lens", "atomos-ninja-creator-monitor-5"], 3299, 3465, "Bundle", "Hybrid capture"), + ("Blackmagic Documentary Rig", ["blackmagic-scout-6k-pro-cinema-camera", "sigma-sports-150-600mm-f-5-6-3-dg-dn-lens", "sandisk-rugged-studio-ssd-4tb"], 4099, 4293, "Featured", "Documentary teams"), + ("Audio Creator Desk Pack", ["shure-mvx2-usb-stream-mic", "sennheiser-hd-490-pro-reference-headphones", "dell-mobile-color-14-laptop"], 2049, 2177, "Bundle", "Podcast desks"), + ("DJI Travel Interview Kit", ["dji-mic-air-2tx-kit", "dji-neo-cinema-mini-drone", "sandisk-extreme-portable-ssd-2tb"], 1199, 1297, "Bundle", "Travel storytellers"), + ("Aputure Lighting Interview Pack", ["aputure-storm-300x-bi-color-led", "aputure-fresnel-2x-modifier", "manfrotto-nano-light-stand-duo"], 1169, 1247, "Bundle", "Single-subject lighting"), + ("Color Grade Suite", ["benq-colorpro-32-pd3225u-monitor", "apple-creatorbook-14-m3-pro-laptop", "sandisk-rugged-studio-ssd-4tb"], 3949, 4097, "Featured", "Editors and colorists"), + ("Field Audio Capture Pack", ["rode-wireless-creator-duo", "sennheiser-profile-usb-mic", "sony-creator-anc-monitor-headphones"], 699, 817, "Bundle", "Mobile interviews"), + ("Photo Archiving Workflow", ["epson-v850-archival-photo-scanner", "epson-p900-fine-art-photo-printer", "lexar-workflow-dock-4-bay"], 2199, 2333, "Bundle", "Archive and print"), + ("Compact Production Monitor Kit", ["atomos-ninja-creator-monitor-5", "sony-fx30-studio-cinema-camera", "sandisk-pro-cinema-cfexpress-type-b-512gb-card"], 2399, 2526, "Bundle", "Video crews"), + ("Drone Survey Pack", ["dji-mavic-survey-drone", "sandisk-creator-microsd-512gb-card", "apple-creatorbook-air-15-laptop"], 3299, 3497, "Bundle", "Aerial mapping"), + ("Travel Vlog Essentials", ["canon-orbit-r50-v-mirrorless-camera", "dji-pocket-interview-mic", "benro-slim-photo-tripod-kit"], 1189, 1247, "Bundle", "One-person crews"), + ("Studio Monitor Match Set", ["dell-ultrasharp-32-thunderbolt-monitor", "asus-proart-27-pa279crv-monitor", "apple-studio-panel-27-monitor"], 3299, 3497, "Bundle", "Multi-monitor suites"), + ("Creator Laptop Pair", ["apple-creatorbook-14-m3-pro-laptop", "asus-proart-studio-13-oled-laptop"], 4049, 4298, "Bundle", "Capture and edit duo"), + ("Wedding Dual Body Set", ["canon-orbit-r6-mark-ii-mirrorless-camera", "nikon-zenith-zf-mirrorless-camera", "lexar-gold-sdxc-256gb-v90-card"], 4499, 4694, "Bundle", "Two body coverage"), + ("Podcast Launch Bundle", ["shure-mv7x-voice-broadcast-mic", "sennheiser-accent-podcast-pack", "dell-creator-compact-13-laptop"], 1899, 1987, "Bundle", "New podcast launches"), + ("RGB Practical Lighting Set", ["godox-tubeflow-rgb-pair", "aputure-mc-pro-pocket-rgb-light", "peak-design-mobile-creator-stand"], 499, 557, "Bundle", "On-location accents"), + ] + + +def ensure_visual_assets(base_dir: str, products, bundles): + ensure_misc_assets(base_dir) + images_root = Path(base_dir) / "static" / "images" + products_root = images_root / "products" + bundles_root = images_root / "bundles" + for product in products: + image_path = products_root / f"{product['slug']}.svg" + ensure_svg(image_path, product["brand"], product["name"], product["accent_color"], product["product_type"]) + product["image_path"] = f"images/products/{product['slug']}.svg" + for bundle in bundles: + ensure_svg( + bundles_root / f"{bundle['slug']}.svg", + bundle["title"], + bundle["audience"], + "#24423f", + "Monitor", + ) + bundle["image_path"] = f"images/bundles/{bundle['slug']}.svg" + + +def seed_database(db, models, base_dir: str): + Product = models["Product"] + if Product.query.count() > 0: + return + + Category = models["Category"] + Brand = models["Brand"] + ProductImage = models["ProductImage"] + ProductSpecGroup = models["ProductSpecGroup"] + ProductSpec = models["ProductSpec"] + ProductVariant = models["ProductVariant"] + ProductReview = models["ProductReview"] + ProductQuestion = models["ProductQuestion"] + ProductAnswer = models["ProductAnswer"] + Bundle = models["Bundle"] + BundleItem = models["BundleItem"] + StoreLocation = models["StoreLocation"] + StoreInventory = models["StoreInventory"] + Deal = models["Deal"] + + category_by_slug = {} + for item in CATEGORY_DEFS: + category = Category( + name=item["name"], + slug=item["slug"], + description=item.get("description", ""), + hero_copy=item.get("hero_copy", item.get("description", "")), + icon_label=item.get("icon_label", item["name"][:4]), + nav_order=item.get("nav_order", 99), + ) + db.session.add(category) + category_by_slug[item["slug"]] = category + db.session.flush() + for item in CATEGORY_DEFS: + if item.get("parent"): + category_by_slug[item["slug"]].parent_id = category_by_slug[item["parent"]].id + + brand_by_name = {} + for name, origin, color, blurb in BRAND_DEFS: + brand = Brand(name=name, slug=slugify(name), origin=origin, badge_color=color, blurb=blurb) + db.session.add(brand) + brand_by_name[name] = brand + db.session.flush() + + product_blueprints = build_products() + bundle_blueprints = [ + {"title": title, "slug": slugify(title), "items": items, "bundle_price": bundle_price, "list_price": list_price, "badge": badge, "audience": audience} + for title, items, bundle_price, list_price, badge, audience in bundle_definitions() + ] + ensure_visual_assets(base_dir, product_blueprints, bundle_blueprints) + + products_by_slug = {} + created_products = [] + for blueprint in product_blueprints: + product = Product( + category_id=category_by_slug[blueprint["category_slug"]].id, + brand_id=brand_by_name[blueprint["brand"]].id, + name=blueprint["name"], + slug=blueprint["slug"], + sku=blueprint["sku"], + short_description=blueprint["short_description"], + description=blueprint["description"], + search_blob=blueprint["search_blob"], + price=blueprint["price"], + list_price=blueprint["list_price"], + rating=blueprint["rating"], + review_count=blueprint["review_count"], + qa_count=blueprint["qa_count"], + condition=blueprint["condition"], + availability=blueprint["availability"], + stock_level=blueprint["stock_level"], + pickup_available=blueprint["pickup_available"], + pickup_message=blueprint["pickup_message"], + shipping_message=blueprint["shipping_message"], + return_window=blueprint["return_window"], + warranty=blueprint["warranty"], + best_seller_rank=blueprint["best_seller_rank"], + sort_newness=blueprint["sort_newness"], + top_category_slug=blueprint["top_category_slug"], + subcategory_slug=blueprint["subcategory_slug"], + product_family=blueprint["product_family"], + product_type=blueprint["product_type"], + sensor_size=blueprint["sensor_size"], + mount_type=blueprint["mount_type"], + focal_length=blueprint["focal_length"], + focal_length_bucket=blueprint["focal_length_bucket"], + megapixels=blueprint["megapixels"], + capacity_gb=blueprint["capacity_gb"], + connectivity=blueprint["connectivity"], + image_path=blueprint["image_path"], + accent_color=blueprint["accent_color"], + release_label=blueprint["release_label"], + is_featured=blueprint["featured"], + is_bundle_anchor=blueprint["bundle_anchor"], + is_used_highlight=blueprint["used_highlight"], + ) + db.session.add(product) + db.session.flush() + products_by_slug[product.slug] = product + created_products.append((product, blueprint)) + + for order, label in enumerate(["Primary", "Detail", "Kit View"]): + db.session.add(ProductImage(product_id=product.id, path=product.image_path, label=label, sort_order=order)) + + for group_order, group in enumerate(blueprint["specs"]): + spec_group = ProductSpecGroup(product_id=product.id, title=group["title"], sort_order=group_order) + db.session.add(spec_group) + db.session.flush() + for spec_order, (spec_name, spec_value) in enumerate(group["rows"]): + db.session.add( + ProductSpec( + group_id=spec_group.id, + name=spec_name, + value=spec_value, + sort_order=spec_order, + ) + ) + + for order, variant in enumerate(blueprint["variants"]): + label, variant_type, value, delta = variant + db.session.add( + ProductVariant( + product_id=product.id, + label=label, + variant_type=variant_type, + value=value, + price_delta=delta, + ) + ) + + if blueprint["deal_label"]: + db.session.add( + Deal( + product_id=product.id, + label=blueprint["deal_label"], + sale_price=blueprint["price"], + deal_type=blueprint["deal_type"], + is_active=True, + ) + ) + + stores = [] + for name, slug, city, state, address, hours, phone, note in STORE_DEFS: + store = StoreLocation( + name=name, + slug=slug, + city=city, + state=state, + address=address, + pickup_hours=hours, + contact_phone=phone, + inventory_note=note, + ) + db.session.add(store) + stores.append(store) + db.session.flush() + + for product, blueprint in created_products: + store_count = 0 + for store_index, store in enumerate(stores): + seed = sum(ord(char) for char in f"{product.slug}{store.slug}") + quantity = 0 + if product.pickup_available and product.availability != "Pre-Order": + quantity = seed % 6 + if product.condition != "New": + quantity = min(quantity, 2) + pickup_eta = "Ready in 2 hours" if quantity >= 3 else "Ready tomorrow after 11:00" + db.session.add( + StoreInventory( + store_id=store.id, + product_id=product.id, + quantity=quantity, + pickup_eta=pickup_eta, + ) + ) + if quantity > 0: + store_count += 1 + product.pickup_store_count = store_count + + review_rows = [] + question_rows = [] + answer_rows = [] + for product, _blueprint in created_products: + review_rows.extend(create_reviews(product, ProductReview)) + for question, answer_text in create_questions(product, ProductQuestion, ProductAnswer): + db.session.add(question) + db.session.flush() + answer_rows.append( + ProductAnswer( + question_id=question.id, + responder_name=["B&H Demo Team", "Store Specialist", "Workflow Advisor"][question.id % 3], + answer=answer_text, + created_at=question.created_at + timedelta(hours=12), + ) + ) + db.session.add_all(review_rows) + db.session.add_all(answer_rows) + + for bundle_blueprint in bundle_blueprints: + bundle = Bundle( + title=bundle_blueprint["title"], + slug=bundle_blueprint["slug"], + description=f"{bundle_blueprint['title']} collects compatible items into a deterministic checkout-ready package for benchmark tasks.", + image_path=bundle_blueprint["image_path"], + bundle_price=bundle_blueprint["bundle_price"], + list_price=bundle_blueprint["list_price"], + badge=bundle_blueprint["badge"], + audience=bundle_blueprint["audience"], + featured=bundle_blueprint["badge"] == "Featured", + ) + db.session.add(bundle) + db.session.flush() + for item_slug in bundle_blueprint["items"]: + db.session.add(BundleItem(bundle_id=bundle.id, product_id=products_by_slug[item_slug].id, quantity=1)) + + db.session.commit() + + +def seed_benchmark_users(db, models): + User = models["User"] + if User.query.filter_by(email="alice.j@test.com").first(): + return + + Address = models["Address"] + CartItem = models["CartItem"] + WishlistItem = models["WishlistItem"] + CompareItem = models["CompareItem"] + Order = models["Order"] + OrderItem = models["OrderItem"] + StoreReservation = models["StoreReservation"] + StoreLocation = models["StoreLocation"] + Product = models["Product"] + + stores = StoreLocation.query.order_by(StoreLocation.id).all() + products = {product.slug: product for product in Product.query.all()} + created_users = [] + + for index, user_data in enumerate(USER_DEFS): + user = User( + email=user_data["email"], + username=user_data["username"], + display_name=user_data["display_name"], + phone=f"(555) 200-01{index + 1:02d}", + company=user_data["company"], + role=user_data["role"], + preferred_store_id=stores[index % len(stores)].id, + newsletter_opt_in=index % 2 == 0, + sms_opt_in=index in (1, 3), + created_at=MIRROR_REFERENCE_DATE - timedelta(days=120 - index * 11), + ) + user.set_password(BENCHMARK_PASSWORD) + db.session.add(user) + db.session.flush() + created_users.append(user) + db.session.add( + Address( + user_id=user.id, + label="Studio", + recipient=user.display_name, + line1=f"{100 + index * 11} Benchmark Ave", + city=["New York", "Brooklyn", "Austin", "Los Angeles"][index], + state=["NY", "NY", "TX", "CA"][index], + zip_code=["10001", "11217", "78701", "90013"][index], + phone=user.phone, + is_default=True, + ) + ) + + db.session.flush() + + alice, bob, carol, david = created_users + wishlist_map = { + alice.id: ["benq-colorpro-32-pd3225u-monitor", "fujifilm-northlight-x-t5-mirrorless-camera", "aputure-storm-300x-bi-color-led", "sandisk-rugged-studio-ssd-4tb"], + bob.id: ["blackmagic-scout-6k-pro-cinema-camera", "dji-airframe-4s-fly-more-drone", "atomos-ninja-creator-monitor-5", "rode-wireless-creator-duo"], + carol.id: ["shure-mvx2-usb-stream-mic", "sennheiser-hd-490-pro-reference-headphones", "apple-creatorbook-air-15-laptop"], + david.id: ["apple-creatorbook-14-m3-pro-laptop", "benq-sw272u-photo-monitor", "epson-p900-fine-art-photo-printer"], + } + compare_map = { + alice.id: ["sony-aurora-a7x-mirrorless-camera", "canon-orbit-r6-mark-ii-mirrorless-camera", "fujifilm-northlight-x-t5-mirrorless-camera"], + bob.id: ["blackmagic-scout-6k-pro-cinema-camera", "sony-fx30-studio-cinema-camera", "canon-c70-motion-cinema-camera"], + carol.id: ["rode-wireless-creator-duo", "dji-mic-air-2tx-kit", "shure-mvx2-usb-stream-mic"], + david.id: ["apple-creatorbook-14-m3-pro-laptop", "dell-precision-edge-16-laptop", "asus-proart-studio-13-oled-laptop"], + } + cart_map = { + alice.id: [("sony-aurora-a7x-mirrorless-camera", 1), ("sigma-artline-24-70mm-f-2-8-dg-dn-lens", 1)], + bob.id: [("blackmagic-scout-6k-pro-cinema-camera", 1), ("atomos-ninja-creator-monitor-5", 1), ("sandisk-pro-cinema-cfexpress-type-b-512gb-card", 2)], + carol.id: [("shure-mvx2-usb-stream-mic", 1), ("sennheiser-profile-usb-mic", 1)], + david.id: [("benq-colorpro-32-pd3225u-monitor", 1), ("sandisk-rugged-studio-ssd-4tb", 1)], + } + reservation_map = { + alice.id: ("fujifilm-northlight-x-t5-mirrorless-camera", stores[0].id), + bob.id: ("blackmagic-scout-6k-pro-cinema-camera", stores[4].id), + carol.id: ("rode-wireless-creator-duo", stores[1].id), + david.id: ("benq-colorpro-32-pd3225u-monitor", stores[5].id), + } + + for user_id, slugs in wishlist_map.items(): + for slug in slugs: + db.session.add(WishlistItem(user_id=user_id, product_id=products[slug].id, created_at=MIRROR_REFERENCE_DATE - timedelta(days=(products[slug].id % 40)))) + for user_id, slugs in compare_map.items(): + for slug in slugs: + db.session.add(CompareItem(user_id=user_id, product_id=products[slug].id, created_at=MIRROR_REFERENCE_DATE - timedelta(days=(products[slug].id % 25)))) + for user_id, entries in cart_map.items(): + for slug, quantity in entries: + db.session.add(CartItem(user_id=user_id, product_id=products[slug].id, quantity=quantity, added_at=MIRROR_REFERENCE_DATE - timedelta(days=(products[slug].id % 10)))) + for user_id, (slug, store_id) in reservation_map.items(): + db.session.add( + StoreReservation( + user_id=user_id, + store_id=store_id, + product_id=products[slug].id, + quantity=1, + status="Reserved", + pickup_window="Ready tomorrow after 11:00", + created_at=MIRROR_REFERENCE_DATE - timedelta(days=3 + (products[slug].id % 4)), + ) + ) + + order_defs = [ + (alice, "BH-20260402-0101", "Delivered", "Ship to address", ["canon-orbit-r6-mark-ii-mirrorless-camera", "canon-rf-24-105mm-f-4l-is-lens"], 18.50, 221.73, "Delivered to Brooklyn studio"), + (alice, "BH-20260314-0102", "Delivered", "Store pickup", ["peak-design-everyday-sling-10l"], 0.00, 13.22, "Picked up at NYC SuperStore"), + (bob, "BH-20260409-0201", "Delivered", "Ship to address", ["sony-fx30-studio-cinema-camera", "atomos-ninja-creator-monitor-5", "sandisk-pro-cinema-cfexpress-type-b-512gb-card"], 24.95, 311.42, "Delivered to Studio Meridian"), + (carol, "BH-20260330-0301", "Delivered", "Ship to address", ["rode-wireless-creator-duo", "sennheiser-hd-490-pro-reference-headphones"], 0.00, 61.11, "Delivered to Signal Audio"), + (david, "BH-20260411-0401", "Processing", "Store pickup", ["apple-creatorbook-14-m3-pro-laptop", "benq-colorpro-32-pd3225u-monitor"], 0.00, 321.63, "Awaiting pickup at Chicago Pro Counter"), + ] + for user, order_number, status, fulfillment, slugs, shipping, tax, note in order_defs: + order = Order( + user_id=user.id, + order_number=order_number, + status=status, + fulfillment=fulfillment, + payment_label="Demo Visa ending in 4242", + shipping=shipping, + tax=round(tax, 2), + note=note, + created_at=MIRROR_REFERENCE_DATE - timedelta(days=1 + len(order_number)), + ) + db.session.add(order) + db.session.flush() + subtotal = 0 + for slug in slugs: + product = products[slug] + subtotal += product.display_price + db.session.add( + OrderItem( + order_id=order.id, + product_id=product.id, + product_name=product.name, + image_path=product.main_image, + quantity=1, + price=product.display_price, + variant_label="Standard", + ) + ) + order.subtotal = round(subtotal, 2) + order.total = round(order.subtotal + order.shipping + order.tax, 2) + + db.session.commit() + + +def copy_instance_to_seed(base_dir: str) -> None: + source = Path(base_dir) / "instance" / "bh_photo.db" + target_dir = Path(base_dir) / "instance_seed" + target_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target_dir / "bh_photo.db") diff --git a/sites/bh_photo/static/css/main.css b/sites/bh_photo/static/css/main.css new file mode 100644 index 00000000..6a76eca9 --- /dev/null +++ b/sites/bh_photo/static/css/main.css @@ -0,0 +1,1133 @@ +:root { + --bg: #eef2ef; + --paper: #ffffff; + --paper-alt: #f6f7f7; + --text: #1b2327; + --muted: #5d6b72; + --border: #d7dfdd; + --brand-dark: #2d382f; + --brand-deep: #213129; + --brand-gold: #f1c74b; + --brand-green: #2d7a5d; + --brand-orange: #cb6a2b; + --brand-blue: #2f5f8f; + --danger: #b14141; + --shadow: 0 18px 45px rgba(26, 39, 36, 0.08); + --radius: 18px; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif; + color: var(--text); + background: radial-gradient(circle at top left, #f7fbf9 0, var(--bg) 38%, #e7ece8 100%); +} + +a { + color: inherit; + text-decoration: none; +} + +img { + display: block; + max-width: 100%; +} + +button, +input, +select, +textarea { + font: inherit; +} + +.shell { + width: min(1240px, calc(100vw - 32px)); + margin: 0 auto; +} + +.promo-strip { + background: linear-gradient(90deg, #f8d762, #efc541); + color: #372b08; + font-size: 14px; +} + +.promo-inner { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding: 10px 0; +} + +.promo-pill, +.count-chip, +.badge, +.filter-chip { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + padding: 4px 10px; + font-size: 12px; + font-weight: 700; +} + +.promo-pill { + background: rgba(55, 43, 8, 0.12); +} + +.count-chip { + background: rgba(255, 255, 255, 0.14); + min-width: 24px; +} + +.site-header { + background: linear-gradient(180deg, var(--brand-dark), var(--brand-deep)); + color: #f1f5f2; + box-shadow: 0 10px 25px rgba(18, 25, 20, 0.18); +} + +.header-top { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 24px; + align-items: center; + padding: 18px 0; +} + +.brand-lockup { + display: inline-flex; + align-items: center; + gap: 14px; +} + +.brand-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 82px; + height: 52px; + border-radius: 16px; + background: var(--brand-gold); + color: #2d2711; + font-size: 28px; + font-weight: 900; +} + +.brand-copy strong { + display: block; + font-size: 21px; +} + +.brand-copy small { + color: rgba(241, 245, 242, 0.76); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.search-bar { + display: flex; + align-items: center; + background: rgba(255, 255, 255, 0.11); + padding: 8px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.search-bar input { + border: 0; + outline: 0; + background: transparent; + color: #fff; + width: 100%; + padding: 0 16px; +} + +.search-bar input::placeholder { + color: rgba(255, 255, 255, 0.7); +} + +.search-bar button, +.cta, +.ghost, +.quiet-btn { + border: 0; + cursor: pointer; + border-radius: 999px; + transition: transform 0.15s ease, opacity 0.15s ease, background 0.2s ease; +} + +.search-bar button, +.cta { + background: var(--brand-gold); + color: #2d2711; + padding: 12px 22px; + font-weight: 800; +} + +.ghost { + background: rgba(36, 66, 63, 0.08); + color: var(--brand-deep); + padding: 12px 20px; + border: 1px solid rgba(36, 66, 63, 0.18); +} + +.quiet-btn { + background: rgba(36, 66, 63, 0.08); + color: var(--brand-blue); + padding: 9px 14px; + font-weight: 700; +} + +.cta.small, +.ghost.small { + padding: 10px 16px; +} + +.cta.block, +.ghost.block, +.quiet-btn.block { + display: block; + width: 100%; + text-align: center; +} + +.header-actions { + display: flex; + align-items: center; + gap: 14px; + font-size: 14px; +} + +.primary-nav { + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.nav-row { + display: flex; + align-items: center; + gap: 18px; + padding: 13px 0; + overflow-x: auto; + white-space: nowrap; + font-size: 15px; +} + +.flash-stack { + display: grid; + gap: 10px; + margin-top: 18px; +} + +.flash { + border-radius: 14px; + padding: 14px 16px; + background: var(--paper); + border: 1px solid var(--border); + box-shadow: var(--shadow); +} + +.flash.success { + border-color: rgba(45, 122, 93, 0.25); +} + +.flash.danger { + border-color: rgba(177, 65, 65, 0.35); +} + +.page-wrap { + padding-bottom: 56px; +} + +.hero, +.page-hero, +.listing-layout, +.product-layout, +.pickup-layout, +.cart-layout, +.checkout-layout, +.receipt-layout, +.dashboard-grid, +.address-grid, +.help-grid { + margin-top: 28px; +} + +.hero-grid, +.pickup-layout, +.cart-layout, +.checkout-layout, +.receipt-layout { + display: grid; + gap: 24px; +} + +.hero-grid { + grid-template-columns: 1.4fr 0.9fr; + align-items: stretch; +} + +.hero-copy, +.hero-panel, +.page-hero.simple, +.info-card, +.purchase-card, +.bundle-card, +.product-card, +.category-tile, +.category-stack, +.order-card, +.card-form, +.empty-state, +.compare-top .compare-product, +.pickup-card, +.hero-card, +.spotlight-card { + background: var(--paper); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.hero-copy { + padding: 38px; + background: linear-gradient(135deg, #f7fbf8, #ffffff 60%, #eff5f2); +} + +.hero-copy h1, +.page-hero h1, +.auth-panel h1 { + margin: 10px 0 14px; + line-height: 1.05; + font-size: clamp(2.2rem, 5vw, 4rem); +} + +.hero-copy p, +.page-hero p, +.lead-copy, +.info-card p, +.mini-spec-card li, +.detail-panel p { + color: var(--muted); + line-height: 1.6; +} + +.kicker, +.eyebrow { + margin: 0; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--brand-green); +} + +.hero-actions, +.section-head, +.price-line, +.rating-line, +.chip-row, +.spec-chip-row, +.card-actions, +.hero-card ul, +.summary-list, +.order-head, +.stack-head { + display: flex; + gap: 14px; +} + +.hero-actions { + margin-top: 24px; +} + +.hero-panel { + padding: 18px; + background: linear-gradient(180deg, rgba(241, 199, 75, 0.2), rgba(255, 255, 255, 0.88)); +} + +.hero-card { + height: 100%; + padding: 28px; + background: linear-gradient(160deg, #28372f, #1b2b24); + color: #f1f5f2; +} + +.hero-card h3 { + margin-top: 0; +} + +.hero-card ul { + flex-direction: column; + padding-left: 18px; + color: rgba(241, 245, 242, 0.82); +} + +.shelf-section { + margin-top: 38px; +} + +.section-head { + justify-content: space-between; + align-items: end; + margin-bottom: 18px; +} + +.section-head h2, +.pickup-product h2, +.purchase-card h2, +.bundle-card h2 { + margin: 6px 0 0; + font-size: 1.7rem; +} + +.category-grid, +.product-grid, +.bundle-grid, +.spotlight-grid, +.dashboard-grid, +.address-grid, +.help-grid, +.pickup-grid { + display: grid; + gap: 18px; +} + +.category-grid { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.product-grid { + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); +} + +.product-grid.dense { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.bundle-grid { + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); +} + +.bundle-grid.tall { + grid-template-columns: 1fr; +} + +.spotlight-grid { + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +} + +.category-tile, +.spotlight-card { + padding: 22px; +} + +.category-tile small { + margin-top: 8px; + display: block; + color: var(--muted); + line-height: 1.5; +} + +.category-icon { + display: inline-flex; + width: 58px; + height: 58px; + align-items: center; + justify-content: center; + border-radius: 16px; + background: rgba(45, 122, 93, 0.11); + color: var(--brand-deep); + font-weight: 800; + margin-bottom: 14px; +} + +.dual-shelf { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + margin-top: 38px; +} + +.shelf-panel { + background: var(--paper); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 24px; + box-shadow: var(--shadow); +} + +.mini-list { + display: grid; + gap: 14px; +} + +.mini-row { + display: grid; + grid-template-columns: 72px 1fr; + gap: 14px; + align-items: center; +} + +.mini-row.text-only { + grid-template-columns: 1fr; +} + +.mini-row img { + width: 72px; + height: 72px; + object-fit: cover; + border-radius: 14px; + background: var(--paper-alt); +} + +.mini-row strong { + display: block; +} + +.mini-row small, +.muted, +.shipping-line, +.stock-line, +.card-copy { + color: var(--muted); +} + +.listing-layout { + display: grid; + grid-template-columns: 280px 1fr; + gap: 22px; +} + +.filters, +.listing-content, +.detail-panel, +.pickup-product, +.receipt-items { + background: var(--paper); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.filters, +.detail-panel, +.listing-content, +.pickup-product, +.receipt-items { + padding: 22px; +} + +.filters form, +.stack-form, +.card-form, +.store-list { + display: grid; + gap: 14px; +} + +.filters label, +.card-form label, +.stack-form label { + display: grid; + gap: 6px; + font-weight: 600; +} + +input, +select, +textarea { + width: 100%; + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px 14px; + background: var(--paper-alt); + color: var(--text); +} + +textarea { + resize: vertical; +} + +.range-row, +.card-form.two-col { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.card-form.two-col .full { + grid-column: 1 / -1; +} + +.listing-head { + display: flex; + justify-content: space-between; + gap: 18px; + margin-bottom: 18px; +} + +.filter-chip { + background: rgba(36, 66, 63, 0.08); + color: var(--brand-deep); +} + +.product-card { + overflow: hidden; + position: relative; +} + +.product-card-media { + display: block; + background: linear-gradient(180deg, #edf4f1, #f9fbfb); + padding: 20px; + min-height: 200px; +} + +.product-card-media img { + width: 100%; + aspect-ratio: 4 / 3; + object-fit: cover; + border-radius: 14px; +} + +.badge { + position: absolute; + top: 18px; + left: 18px; +} + +.badge-green { + background: rgba(45, 122, 93, 0.14); + color: var(--brand-green); +} + +.badge-orange { + background: rgba(203, 106, 43, 0.12); + color: var(--brand-orange); +} + +.product-card-body { + padding: 18px; +} + +.product-card h3, +.bundle-card h3, +.compare-product h3, +.mini-spec-card h3, +.review-card h3, +.qa-card h3 { + margin: 8px 0 10px; +} + +.product-card h3 a, +.bundle-card h2, +.order-card strong { + color: var(--text); +} + +.rating-line { + align-items: center; + flex-wrap: wrap; + font-size: 14px; + color: var(--muted); +} + +.rating-line.roomy { + margin-bottom: 14px; +} + +.price-line { + align-items: baseline; + flex-wrap: wrap; + margin: 12px 0 10px; +} + +.price-line.large strong { + font-size: 2rem; +} + +.price-line strong { + font-size: 1.35rem; +} + +.strike { + color: var(--muted); + text-decoration: line-through; +} + +.save { + color: var(--brand-green); + font-weight: 700; +} + +.stock-line.good { + color: var(--brand-green); +} + +.stock-line.warn { + color: var(--brand-orange); +} + +.product-layout { + display: grid; + grid-template-columns: 0.95fr 1.1fr 0.8fr; + gap: 22px; +} + +.product-gallery, +.product-main, +.purchase-column { + min-width: 0; +} + +.product-gallery, +.product-main { + background: var(--paper); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 22px; +} + +.hero-image { + width: 100%; + border-radius: 18px; + background: linear-gradient(180deg, #eff4f2, #f8fbfb); +} + +.thumb-row { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin-top: 12px; +} + +.thumb-row img { + border-radius: 12px; + background: var(--paper-alt); +} + +.lead-copy { + margin: 0 0 18px; +} + +.spec-chip-row { + flex-wrap: wrap; + margin-bottom: 18px; +} + +.spec-chip-row span { + padding: 10px 12px; + border-radius: 12px; + background: rgba(36, 66, 63, 0.06); + color: var(--muted); + font-size: 13px; +} + +.tab-row { + display: flex; + gap: 10px; + border-bottom: 1px solid var(--border); + margin-bottom: 18px; + padding-bottom: 12px; + overflow-x: auto; +} + +.tab-row a { + padding: 9px 14px; + border-radius: 999px; + font-weight: 700; + color: var(--muted); +} + +.tab-row a.active { + background: rgba(36, 66, 63, 0.1); + color: var(--brand-deep); +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.mini-spec-card { + background: var(--paper-alt); + border: 1px solid var(--border); + border-radius: 16px; + padding: 16px; +} + +.mini-spec-card ul, +.bundle-items, +.footer-grid ul, +.store-list { + padding-left: 18px; + margin: 0; +} + +.spec-table { + display: grid; + gap: 8px; +} + +.spec-table div { + display: grid; + grid-template-columns: 220px 1fr; + gap: 14px; + padding: 11px 0; + border-top: 1px solid var(--border); +} + +.spec-table dt { + color: var(--muted); +} + +.purchase-column { + display: grid; + gap: 16px; + align-content: start; +} + +.purchase-card { + padding: 22px; +} + +.purchase-meta, +.footer-grid ul { + margin: 0; + display: grid; + gap: 8px; + color: var(--muted); +} + +.compare-top { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 18px; + margin-bottom: 24px; +} + +.compare-product { + padding: 18px; +} + +.compare-product img { + border-radius: 14px; + margin-bottom: 14px; +} + +.compare-group { + margin-bottom: 24px; + background: var(--paper); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; +} + +.compare-group h2 { + margin: 0; + padding: 16px 18px; + background: rgba(36, 66, 63, 0.06); +} + +.compare-table { + width: 100%; + border-collapse: collapse; +} + +.compare-table th, +.compare-table td { + text-align: left; + padding: 14px 18px; + border-top: 1px solid var(--border); + vertical-align: top; +} + +.compare-table th { + width: 220px; + color: var(--muted); +} + +.compare-table tr.different td { + background: rgba(241, 199, 75, 0.12); +} + +.bundle-card { + display: grid; + grid-template-columns: 220px 1fr; + gap: 18px; + padding: 18px; +} + +.bundle-card img { + border-radius: 16px; + background: var(--paper-alt); +} + +.pickup-layout { + grid-template-columns: 1.4fr 0.75fr; +} + +.pickup-grid, +.address-grid, +.help-grid, +.dashboard-grid { + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); +} + +.pickup-card, +.info-card, +.review-card, +.qa-card, +.order-card, +.empty-state { + padding: 18px; +} + +.store-list article { + margin-bottom: 14px; +} + +.auth-wrap { + display: flex; + justify-content: center; + margin-top: 34px; +} + +.auth-wrap.wide .auth-panel { + max-width: 860px; +} + +.auth-panel { + width: min(560px, 100%); + background: var(--paper); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 32px; +} + +.auth-note { + background: rgba(241, 199, 75, 0.14); + border-radius: 14px; + padding: 12px 14px; +} + +.dashboard-links { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 22px; +} + +.dashboard-links a { + padding: 12px 16px; + border-radius: 999px; + background: var(--paper); + border: 1px solid var(--border); + box-shadow: var(--shadow); +} + +.order-stack, +.address-grid { + display: grid; + gap: 18px; +} + +.order-head, +.summary-list div, +.cart-row, +.breadcrumb-row { + justify-content: space-between; + align-items: center; +} + +.mini-list.stacked, +.receipt-items { + gap: 12px; +} + +.cart-layout, +.checkout-layout, +.receipt-layout { + grid-template-columns: 1.2fr 0.8fr; +} + +.cart-items, +.receipt-items { + display: grid; + gap: 14px; +} + +.cart-row { + display: grid; + grid-template-columns: 108px 1fr auto auto auto; + gap: 16px; + padding: 18px; + background: var(--paper); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.cart-row img { + width: 108px; + border-radius: 14px; +} + +.receipt-row { + background: transparent; + box-shadow: none; +} + +.inline-form { + display: flex; + align-items: center; + gap: 10px; +} + +.inline-form input { + width: 74px; +} + +.summary-list { + flex-direction: column; + margin: 14px 0 0; +} + +.summary-list div { + display: flex; + padding: 9px 0; + border-top: 1px solid var(--border); +} + +.summary-list .total { + font-size: 1.1rem; + font-weight: 800; +} + +.breadcrumb-row { + display: flex; + gap: 10px; + color: var(--muted); + font-size: 14px; +} + +.site-footer { + margin-top: 56px; + background: var(--brand-deep); + color: #f1f5f2; +} + +.footer-grid { + display: grid; + grid-template-columns: 1.3fr 1fr 1fr; + gap: 24px; + padding: 30px 0 40px; +} + +.footer-grid p, +.footer-grid li, +.footer-grid a { + color: rgba(241, 245, 242, 0.82); +} + +.empty-state { + text-align: center; +} + +.checkline { + display: flex !important; + align-items: center; + gap: 10px !important; +} + +.checkline input { + width: auto; +} + +.compact { + margin-bottom: 10px; +} + +@media (max-width: 1100px) { + .header-top, + .hero-grid, + .product-layout, + .listing-layout, + .pickup-layout, + .cart-layout, + .checkout-layout, + .receipt-layout, + .dual-shelf, + .footer-grid, + .bundle-card { + grid-template-columns: 1fr; + } + + .header-actions { + flex-wrap: wrap; + justify-content: flex-end; + } + + .product-layout { + grid-template-columns: 1fr; + } +} + +@media (max-width: 760px) { + .shell { + width: min(100vw - 20px, 100%); + } + + .promo-inner, + .section-head, + .listing-head, + .hero-actions, + .cart-row, + .order-head { + flex-direction: column; + align-items: flex-start; + } + + .header-top { + gap: 16px; + } + + .brand-lockup { + width: 100%; + justify-content: space-between; + } + + .header-actions { + width: 100%; + justify-content: flex-start; + } + + .thumb-row, + .detail-grid, + .range-row, + .card-form.two-col { + grid-template-columns: 1fr; + } + + .spec-table div, + .bundle-card { + grid-template-columns: 1fr; + } + + .cart-row { + grid-template-columns: 1fr; + } + + .product-card-media { + min-height: auto; + } +} diff --git a/sites/bh_photo/static/js/main.js b/sites/bh_photo/static/js/main.js new file mode 100644 index 00000000..8f41b41d --- /dev/null +++ b/sites/bh_photo/static/js/main.js @@ -0,0 +1,7 @@ +document.addEventListener("click", (event) => { + const button = event.target.closest("button, .cta, .ghost, .quiet-btn"); + if (button) { + button.classList.add("is-pressed"); + window.setTimeout(() => button.classList.remove("is-pressed"), 150); + } +}); diff --git a/sites/bh_photo/tasks.jsonl b/sites/bh_photo/tasks.jsonl new file mode 100644 index 00000000..0fead515 --- /dev/null +++ b/sites/bh_photo/tasks.jsonl @@ -0,0 +1,20 @@ +{"web_name": "B&H Photo", "id": "B&H Photo--0", "ques": "Use the search bar to find a full-frame Sony mirrorless camera with 33 MP and dual CFexpress A / SD media slots, then open the matching product page.", "web": "http://localhost:40015/", "upstream_url": "https://www.bhphotovideo.com/"} +{"web_name": "B&H Photo", "id": "B&H Photo--1", "ques": "Browse Photography and filter for in-stock Sony mirrorless cameras under $2600. Identify the model that matches those filters.", "web": "http://localhost:40015/", "upstream_url": "https://www.bhphotovideo.com/c/browse/Photography/ci/989/N/4294538916"} +{"web_name": "B&H Photo", "id": "B&H Photo--2", "ques": "Check the Used / Open-Box catalog and find the least expensive monitor priced under $900. Report the product you found.", "web": "http://localhost:40015/", "upstream_url": "https://www.bhphotovideo.com/"} +{"web_name": "B&H Photo", "id": "B&H Photo--3", "ques": "Compare the Sony Aurora A7X, Canon Orbit R6 Mark II, and Nikon Zenith Z8 mirrorless cameras. Which one has the highest effective megapixel count?", "web": "http://localhost:40015/", "upstream_url": "https://www.bhphotovideo.com/"} +{"web_name": "B&H Photo", "id": "B&H Photo--4", "ques": "Compare the Sony Aurora A7X, Canon Orbit R6 Mark II, and Nikon Zenith Z8 mirrorless cameras, and determine which one lists the longest battery life.", "web": "http://localhost:40015/", "upstream_url": "https://www.bhphotovideo.com/"} +{"web_name": "B&H Photo", "id": "B&H Photo--5", "ques": "Sign in as Alice Johnson with alice.j@test.com / TestPass123!, then save the Rode wireless microphone kit priced below $350 to her wishlist.", "web": "http://localhost:40015/", "upstream_url": "https://www.bhphotovideo.com/"} +{"web_name": "B&H Photo", "id": "B&H Photo--6", "ques": "Sign in as Alice Johnson with alice.j@test.com / TestPass123! and add the Travel Vlog Essentials bundle to her cart.", "web": "http://localhost:40015/", "upstream_url": "https://www.bhphotovideo.com/"} +{"web_name": "B&H Photo", "id": "B&H Photo--7", "ques": "Sign in as Alice Johnson with alice.j@test.com / TestPass123!, open the Fujifilm Northlight X-T5 Mirrorless Camera, and reserve it for pickup at NYC SuperStore.", "web": "http://localhost:40015/", "upstream_url": "https://www.bhphotovideo.com/"} +{"web_name": "B&H Photo", "id": "B&H Photo--8", "ques": "Sign in as Alice Johnson with alice.j@test.com / TestPass123!, add the Rode Wireless Creator Duo to her cart, and change its cart quantity to 2.", "web": "http://localhost:40015/", "upstream_url": "https://www.bhphotovideo.com/"} +{"web_name": "B&H Photo", "id": "B&H Photo--9", "ques": "Sign in as Alice Johnson with alice.j@test.com / TestPass123! and complete the mock checkout for her current cart until you reach the order confirmation page.", "web": "http://localhost:40015/", "upstream_url": "https://www.bhphotovideo.com/"} +{"web_name": "B&H Photo", "id": "B&H Photo--10", "ques": "Sign in as Bob Chen with bob.c@test.com / TestPass123!, open order history, and find the order number for the purchase that includes the Sony FX30 Studio Cinema Camera.", "web": "http://localhost:40015/", "upstream_url": "https://www.bhphotovideo.com/"} +{"web_name": "B&H Photo", "id": "B&H Photo--11", "ques": "Open the DJI Airframe 4S Fly More Drone Q&A page and find what the answer says about local store pickup support.", "web": "http://localhost:40015/", "upstream_url": "https://www.bhphotovideo.com/"} +{"web_name": "B&H Photo", "id": "B&H Photo--12", "ques": "Use search to find the Apple creator laptop with a 1TB SSD and open the 14-inch model.", "web": "http://localhost:40015/", "upstream_url": "https://www.bhphotovideo.com/"} +{"web_name": "B&H Photo", "id": "B&H Photo--13", "ques": "Compare the Apple CreatorBook 14 M3 Pro Laptop, Dell Precision Edge 16 Laptop, and ASUS ProArt Studio 13 OLED Laptop. Which one uses an Intel Core Ultra 9 processor?", "web": "http://localhost:40015/", "upstream_url": "https://www.bhphotovideo.com/"} +{"web_name": "B&H Photo", "id": "B&H Photo--14", "ques": "Find the lowest-priced active bundle under $1200 that includes a tripod, then open the bundle.", "web": "http://localhost:40015/", "upstream_url": "https://www.bhphotovideo.com/"} +{"web_name": "B&H Photo", "id": "B&H Photo--15", "ques": "Open the BenQ SW272U Photo Monitor and determine whether the item is listed as new, used, or open-box.", "web": "http://localhost:40015/", "upstream_url": "https://www.bhphotovideo.com/"} +{"web_name": "B&H Photo", "id": "B&H Photo--16", "ques": "Start from the Nikon Zenith Z8 mirrorless camera page, note the media type it uses, and then find a compatible 512GB storage card elsewhere in the catalog.", "web": "http://localhost:40015/", "upstream_url": "https://www.bhphotovideo.com/"} +{"web_name": "B&H Photo", "id": "B&H Photo--17", "ques": "Sign in as David Kim with david.k@test.com / TestPass123!, check his orders, and identify which order is still processing.", "web": "http://localhost:40015/", "upstream_url": "https://www.bhphotovideo.com/"} +{"web_name": "B&H Photo", "id": "B&H Photo--18", "ques": "Open the Rode Wireless Creator Duo reviews and report the headline of the top review shown on the page.", "web": "http://localhost:40015/", "upstream_url": "https://www.bhphotovideo.com/"} +{"web_name": "B&H Photo", "id": "B&H Photo--19", "ques": "Sign in as Carol Davis with carol.d@test.com / TestPass123!, edit her account so SMS pickup alerts are enabled and the preferred pickup store is Los Angeles Creator Hub.", "web": "http://localhost:40015/", "upstream_url": "https://www.bhphotovideo.com/"} diff --git a/sites/bh_photo/templates/_product_card.html b/sites/bh_photo/templates/_product_card.html new file mode 100644 index 00000000..e114e57a --- /dev/null +++ b/sites/bh_photo/templates/_product_card.html @@ -0,0 +1,38 @@ +
+ + {{ product.name }} + {% if product.condition != 'New' %} + {{ product.condition }} + {% elif product.active_deal() %} + {{ product.active_deal().label }} + {% endif %} + +
+

{{ product.brand.name }} · {{ product.category.name }}

+

{{ product.name }}

+

{{ product.short_description }}

+
+ {{ product.average_review_label }} ★ + {{ product.review_count }} reviews +
+
+ {{ product.display_price|currency }} + {% if product.list_price > product.display_price %} + {{ product.list_price|currency }} + {{ product.savings_percent }}% off + {% endif %} +
+

{{ product.availability }}

+

{{ product.shipping_message }}

+
+
+ +
+ {% if current_user.is_authenticated %} +
+ +
+ {% endif %} +
+
+
diff --git a/sites/bh_photo/templates/account.html b/sites/bh_photo/templates/account.html new file mode 100644 index 00000000..7ea00030 --- /dev/null +++ b/sites/bh_photo/templates/account.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}Account · B&H Demo Mirror{% endblock %} +{% block content %} +
+

My account

+

{{ current_user.display_name }}

+

{{ current_user.role }} · {{ current_user.company }} · {{ current_user.email }}

+
+
+
+

Saved activity

+
    +
  • {{ current_user.wishlist_items|length }} wishlist items
  • +
  • {{ current_user.compare_items|length }} compare items
  • +
  • {{ current_user.cart_items|length }} cart lines
  • +
  • {{ current_user.orders|length }} orders
  • +
+
+
+

Pickup preference

+

{% if current_user.preferred_store_id %}Preferred store is saved for local demo reservations.{% else %}No preferred pickup store set yet.{% endif %}

+ Edit account preferences +
+
+

Recent reservations

+ {% if reservations %} +
    + {% for reservation in reservations %} +
  • {{ reservation.product.name }} · {{ reservation.store_location.name }}
  • + {% endfor %} +
+ {% else %} +

No active reservations.

+ {% endif %} +
+
+ +{% endblock %} diff --git a/sites/bh_photo/templates/account_edit.html b/sites/bh_photo/templates/account_edit.html new file mode 100644 index 00000000..6abcd898 --- /dev/null +++ b/sites/bh_photo/templates/account_edit.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}Edit Account · B&H Demo Mirror{% endblock %} +{% block content %} +
+
+

Edit account

+ + + + + + + + +
+
+{% endblock %} diff --git a/sites/bh_photo/templates/addresses.html b/sites/bh_photo/templates/addresses.html new file mode 100644 index 00000000..f563414e --- /dev/null +++ b/sites/bh_photo/templates/addresses.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% block title %}Addresses · B&H Demo Mirror{% endblock %} +{% block content %} +
+

Saved addresses

+

Addresses

+
+
+ {% for address in addresses %} +
+

{{ address.label }}

+

{{ address.recipient }}

+

{{ address.line1 }}{% if address.line2 %}, {{ address.line2 }}{% endif %}

+

{{ address.city }}, {{ address.state }} {{ address.zip_code }}

+

{{ address.phone }}

+ {% if address.is_default %}Default{% endif %} +
+ {% endfor %} +
+{% endblock %} diff --git a/sites/bh_photo/templates/base.html b/sites/bh_photo/templates/base.html new file mode 100644 index 00000000..529c6698 --- /dev/null +++ b/sites/bh_photo/templates/base.html @@ -0,0 +1,99 @@ + + + + + + {% block title %}B&H Photo Demo Mirror{% endblock %} + + + +
+
+ Demo mirror only: all checkout, reservations, inventory, and orders use deterministic local benchmark data. + No live payment +
+
+ + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+ {% block content %}{% endblock %} +
+ + + + + + diff --git a/sites/bh_photo/templates/bundles.html b/sites/bh_photo/templates/bundles.html new file mode 100644 index 00000000..9f97e362 --- /dev/null +++ b/sites/bh_photo/templates/bundles.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% block title %}Bundles · B&H Demo Mirror{% endblock %} +{% block content %} +
+

Curated kits

+

Bundles

+

Pre-built creator kits with deterministic pricing, savings, and included item lists designed for cart and checkout benchmark tasks.

+
+
+ {% for bundle in bundles %} +
+ {{ bundle.title }} +
+

{{ bundle.badge }} · {{ bundle.audience }}

+

{{ bundle.title }}

+

{{ bundle.description }}

+ +
+ {{ bundle.bundle_price|currency }} + {{ bundle.list_price|currency }} + Save {{ bundle.savings|currency }} +
+ {% if current_user.is_authenticated %} +
+ +
+ {% endif %} +
+
+ {% endfor %} +
+{% endblock %} diff --git a/sites/bh_photo/templates/cart.html b/sites/bh_photo/templates/cart.html new file mode 100644 index 00000000..5640623c --- /dev/null +++ b/sites/bh_photo/templates/cart.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% block title %}Cart · B&H Demo Mirror{% endblock %} +{% block content %} +
+

Shopping cart

+

Cart

+
+
+
+ {% for item in items %} +
+ {{ item.product.name }} +
+

{{ item.product.name }}

+

{{ item.variant_label or item.bundle_label or 'Standard configuration' }}

+

{{ item.product.availability }}

+
+
+ + +
+ {{ item.line_total|currency }} +
+ +
+
+ {% else %} +
+

Your cart is empty

+

Add products or bundles to start a local demo checkout flow.

+
+ {% endfor %} +
+ +
+{% endblock %} diff --git a/sites/bh_photo/templates/categories.html b/sites/bh_photo/templates/categories.html new file mode 100644 index 00000000..0012f578 --- /dev/null +++ b/sites/bh_photo/templates/categories.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}All Categories · B&H Demo Mirror{% endblock %} +{% block content %} +
+

Department index

+

All categories

+

Browse top-level departments and their subcategories with the same local benchmark data used in search, compare, wishlist, and checkout flows.

+
+
+ {% for category in categories %} + + {% endfor %} +
+{% endblock %} diff --git a/sites/bh_photo/templates/category_listing.html b/sites/bh_photo/templates/category_listing.html new file mode 100644 index 00000000..63cd191e --- /dev/null +++ b/sites/bh_photo/templates/category_listing.html @@ -0,0 +1,148 @@ +{% extends "base.html" %} +{% block title %}{{ page_title }} · B&H Demo Mirror{% endblock %} +{% block content %} +
+

{% if category %}{{ category.parent.name if category.parent else 'Department' }}{% elif brand %}Brand store{% else %}Catalog{% endif %}

+

{{ page_heading }}

+

{{ page_description }}

+
+ +
+ + +
+
+
+ {{ products|length }} results + {% if search_query %} +

Search query: {{ search_query }}

+ {% endif %} +
+
+ {% if request.args.get('brand') %}Brand: {{ request.args.get('brand') }}{% endif %} + {% if request.args.get('condition') %}{{ request.args.get('condition') }}{% endif %} + {% if request.args.get('availability') %}{{ request.args.get('availability') }}{% endif %} +
+
+
+ {% for product in products %} + {% include "_product_card.html" %} + {% else %} +
+

No matching demo products

+

Try widening the price range or clearing one of the filter constraints.

+
+ {% endfor %} +
+
+
+{% endblock %} diff --git a/sites/bh_photo/templates/checkout.html b/sites/bh_photo/templates/checkout.html new file mode 100644 index 00000000..b1467482 --- /dev/null +++ b/sites/bh_photo/templates/checkout.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} +{% block title %}Checkout · B&H Demo Mirror{% endblock %} +{% block content %} +
+

Checkout mock

+

Checkout

+

No real payment or shipping is processed here. This is a local deterministic checkout flow for benchmark tasks.

+
+
+
+

Complete order

+ + + + + +
+ +
+{% endblock %} diff --git a/sites/bh_photo/templates/compare.html b/sites/bh_photo/templates/compare.html new file mode 100644 index 00000000..a739186f --- /dev/null +++ b/sites/bh_photo/templates/compare.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}Compare Products · B&H Demo Mirror{% endblock %} +{% block content %} +
+

Side-by-side workflow

+

Compare products

+

Review pricing, stock, and detailed technical rows across up to four products at once.

+
+ +
+ {% if products %} +
+ {% for product in products %} +
+ {{ product.name }} +

{{ product.name }}

+

{{ product.display_price|currency }} · {{ product.availability }}

+
+ +
+
+ {% endfor %} +
+
+ {% for group_name, rows in compare_rows.items() %} +
+

{{ group_name }}

+ + + {% for row in rows %} + + + {% for product in products %} + + {% endfor %} + + {% endfor %} + +
{{ row.name }}{{ row['cells'].get(product.id, '--') }}
+
+ {% endfor %} +
+ {% else %} +
+

No products in compare yet

+

Add products from listings or product pages to build a side-by-side comparison.

+
+ {% endif %} +
+{% endblock %} diff --git a/sites/bh_photo/templates/contact.html b/sites/bh_photo/templates/contact.html new file mode 100644 index 00000000..02364968 --- /dev/null +++ b/sites/bh_photo/templates/contact.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}Contact · B&H Demo Mirror{% endblock %} +{% block content %} +
+

Contact

+

Reach the local demo support desk

+

This form saves locally inside the benchmark environment. No real support ticket or external communication is created.

+
+
+
+ + + + + +
+
+{% endblock %} diff --git a/sites/bh_photo/templates/help.html b/sites/bh_photo/templates/help.html new file mode 100644 index 00000000..dd5dfe75 --- /dev/null +++ b/sites/bh_photo/templates/help.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block title %}Help Center · B&H Demo Mirror{% endblock %} +{% block content %} +
+

Help center

+

Shipping, returns, pickup, and workflow help

+

These articles and policies are synthetic mirror content designed for benchmark tasks and local-only browsing.

+
+
+ {% for card in help_cards %} +
+

{{ card.title }}

+

{{ card.copy }}

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

Popular gear

+

Products often referenced in help flows

+
+
+
+ {% for product in featured_products %} + {% include "_product_card.html" %} + {% endfor %} +
+
+{% endblock %} diff --git a/sites/bh_photo/templates/index.html b/sites/bh_photo/templates/index.html new file mode 100644 index 00000000..40d8f05f --- /dev/null +++ b/sites/bh_photo/templates/index.html @@ -0,0 +1,159 @@ +{% extends "base.html" %} +{% block title %}B&H Photo Demo Mirror{% endblock %} +{% block content %} +
+
+
+

Creator gear, local only

+

Professional photo, video, audio, and computing workflows in a benchmark-ready B&H mirror.

+

Browse rich technical specs, compare models side by side, reserve pickup locally, and run a complete checkout flow without any external calls.

+ +
+
+
+

Today’s local mirror highlights

+
    +
  • 146 synthetic products with dense specs and compare-friendly rows
  • +
  • 20 bundles, 8 pickup locations, and deterministic checkout history
  • +
  • Used and open-box gear mixed into search, deals, and brand pages
  • +
+
+
+
+
+ +
+
+
+

Top categories

+

Browse by department

+
+ View all +
+ +
+ +
+
+
+

Featured gear

+

Popular creator picks

+
+ Search all gear +
+
+ {% for product in featured %} + {% include "_product_card.html" %} + {% endfor %} +
+
+ +
+ +
+
+
+

Used & open-box

+

Limited local stock

+
+ Browse used +
+ +
+
+ +
+
+
+

Bundle offers

+

Ready-to-ship creator kits

+
+ See all bundles +
+
+ {% for bundle in bundles %} +
+ {{ bundle.title }} +
+

{{ bundle.badge }} · {{ bundle.audience }}

+

{{ bundle.title }}

+

{{ bundle.description }}

+
+ {{ bundle.bundle_price|currency }} + {{ bundle.list_price|currency }} +
+ {% if current_user.is_authenticated %} +
+ +
+ {% endif %} +
+
+ {% endfor %} +
+
+ +
+
+
+

Workflow spotlight

+

Shopping flows covered in this mirror

+
+
+
+ {% for product in spotlight %} +
+ {{ product.name }} +

{{ product.name }}

+
    + {% for spec_name, spec_value in product.top_spec_map.items() %} + {% if loop.index0 < 3 %} +
  • {{ spec_name }}: {{ spec_value }}
  • + {% endif %} + {% endfor %} +
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/bh_photo/templates/login.html b/sites/bh_photo/templates/login.html new file mode 100644 index 00000000..c378aa95 --- /dev/null +++ b/sites/bh_photo/templates/login.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}Sign In · B&H Demo Mirror{% endblock %} +{% block content %} +
+
+

Account access

+

Sign in

+

Use one of the seeded benchmark users or create a new local-only demo account.

+
+ + + +
+

Seeded benchmark password: TestPass123!

+

Create a demo account

+
+
+{% endblock %} diff --git a/sites/bh_photo/templates/order_receipt.html b/sites/bh_photo/templates/order_receipt.html new file mode 100644 index 00000000..7077f021 --- /dev/null +++ b/sites/bh_photo/templates/order_receipt.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}Order {{ order.order_number }} · B&H Demo Mirror{% endblock %} +{% block content %} +
+

Order confirmation

+

{{ order.order_number }}

+

{{ order.status }} · {{ order.fulfillment }} · {{ order.created_at.strftime('%b %d, %Y') }}

+
+
+
+

Order totals

+
+
Subtotal
{{ order.subtotal|currency }}
+
Shipping
{{ order.shipping|currency }}
+
Tax
{{ order.tax|currency }}
+
Total
{{ order.total|currency }}
+
+

{{ order.note }}

+
+
+ {% for item in order.items %} +
+ {{ item.product_name }} +
+

{{ item.product_name }}

+

{{ item.variant_label }}

+
+ {{ item.price|currency }} +
+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/bh_photo/templates/orders.html b/sites/bh_photo/templates/orders.html new file mode 100644 index 00000000..5aa87d62 --- /dev/null +++ b/sites/bh_photo/templates/orders.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block title %}Orders · B&H Demo Mirror{% endblock %} +{% block content %} +
+

Order history

+

Orders

+
+
+ {% for order in orders %} + + {% endfor %} +
+{% endblock %} diff --git a/sites/bh_photo/templates/product_detail.html b/sites/bh_photo/templates/product_detail.html new file mode 100644 index 00000000..32f53683 --- /dev/null +++ b/sites/bh_photo/templates/product_detail.html @@ -0,0 +1,177 @@ +{% extends "base.html" %} +{% block title %}{{ product.name }} · B&H Demo Mirror{% endblock %} +{% block content %} + + +
+ +
+

{{ product.brand.name }} · {{ product.category.name }}

+

{{ product.name }}

+
+ {{ product.average_review_label }} ★ + {{ product.review_count }} reviews + {{ product.qa_count }} Q&A +
+

{{ product.short_description }}

+
+ {% for spec_name, spec_value in top_specs %} + {{ spec_name }}: {{ spec_value }} + {% endfor %} +
+ + + {% if active_tab == 'overview' %} +
+

{{ product.description }}

+
+ {% for group in product.spec_groups[:2] %} +
+

{{ group.title }}

+
    + {% for spec in group.specs[:4] %} +
  • {{ spec.name }}: {{ spec.value }}
  • + {% endfor %} +
+
+ {% endfor %} +
+
+ {% elif active_tab == 'specs' %} +
+ {% for group in product.spec_groups %} +
+

{{ group.title }}

+
+ {% for spec in group.specs %} +
+
{{ spec.name }}
+
{{ spec.value }}
+
+ {% endfor %} +
+
+ {% endfor %} +
+ {% elif active_tab == 'reviews' %} +
+ {% for review in product.reviews %} +
+
+ {{ review.rating }} ★ + {{ review.author_name }} + {{ review.created_at.strftime('%b %d, %Y') }} +
+

{{ review.headline }}

+

{{ review.body }}

+
+ {% endfor %} +
+ {% else %} +
+ {% for question in product.questions %} +
+

Q: {{ question.question }}

+

Asked by {{ question.asker_name }} on {{ question.created_at.strftime('%b %d, %Y') }}

+ {% for answer in question.answers %} +

A: {{ answer.answer }}

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

Related picks

+

Similar products in this category

+
+
+
+ {% for product in related %} + {% include "_product_card.html" %} + {% endfor %} +
+
+{% endblock %} diff --git a/sites/bh_photo/templates/register.html b/sites/bh_photo/templates/register.html new file mode 100644 index 00000000..f30e1696 --- /dev/null +++ b/sites/bh_photo/templates/register.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block title %}Register · B&H Demo Mirror{% endblock %} +{% block content %} +
+
+

Local-only account

+

Create a demo account

+
+ + + + + + + + + + + + + +
+
+
+{% endblock %} diff --git a/sites/bh_photo/templates/store_pickup.html b/sites/bh_photo/templates/store_pickup.html new file mode 100644 index 00000000..4dac8fa3 --- /dev/null +++ b/sites/bh_photo/templates/store_pickup.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} +{% block title %}Store Pickup · B&H Demo Mirror{% endblock %} +{% block content %} +
+

Reserve-in-store mock

+

Store pickup

+

Review deterministic store inventory and reserve pickup locally. This flow never contacts live B&H systems or any real stores.

+
+ +
+
+ {% if product %} +
+

{{ product.name }}

+

{{ inventory|length }} locations currently show pickup inventory for this item.

+
+ {% for item in inventory %} +
+

{{ item.store_location.name }}

+

{{ item.store_location.city }}, {{ item.store_location.state }}

+

{{ item.quantity }} available · {{ item.pickup_eta }}

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

Pickup-eligible gear

+

Popular local-only reservation items

+
+
+
+ {% for product in featured %} + {% include "_product_card.html" %} + {% endfor %} +
+
+ +
+{% endblock %} diff --git a/sites/bh_photo/templates/wishlist.html b/sites/bh_photo/templates/wishlist.html new file mode 100644 index 00000000..723dd598 --- /dev/null +++ b/sites/bh_photo/templates/wishlist.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}Wishlist · B&H Demo Mirror{% endblock %} +{% block content %} +
+

Saved gear

+

Wishlist

+
+
+ {% for item in items %} + {% set product = item.product %} + {% include "_product_card.html" %} + {% else %} +
+

Your wishlist is empty

+

Save items from category pages or product detail views.

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