From b518d60fc755efe9012e99fdadffcf51f88da2b8 Mon Sep 17 00:00:00 2001 From: XuanRui LI Date: Fri, 5 Jun 2026 20:25:41 +0800 Subject: [PATCH] Add Target WebHarbor mirror --- Dockerfile | 4 +- README.md | 6 +- control_server.py | 2 +- sites/target/_health.py | 14 + sites/target/app.py | 1470 ++++++++++++++++ sites/target/requirements.txt | 4 + sites/target/seed_data.py | 1542 +++++++++++++++++ sites/target/static/css/main.css | 1029 +++++++++++ sites/target/static/js/main.js | 2 + sites/target/tasks.jsonl | 20 + sites/target/templates/_account_nav.html | 8 + sites/target/templates/_checkout_steps.html | 11 + sites/target/templates/_product_cards.html | 42 + sites/target/templates/account.html | 87 + sites/target/templates/account_orders.html | 28 + sites/target/templates/account_rewards.html | 32 + sites/target/templates/base.html | 107 ++ sites/target/templates/cart.html | 66 + sites/target/templates/categories.html | 26 + .../templates/checkout_confirmation.html | 19 + sites/target/templates/checkout_mode.html | 48 + sites/target/templates/checkout_payment.html | 30 + sites/target/templates/checkout_pickup.html | 43 + sites/target/templates/checkout_review.html | 47 + sites/target/templates/checkout_shipping.html | 48 + sites/target/templates/compare.html | 57 + sites/target/templates/deals.html | 30 + sites/target/templates/home.html | 112 ++ sites/target/templates/login.html | 33 + sites/target/templates/order_detail.html | 48 + sites/target/templates/order_lookup.html | 29 + sites/target/templates/product_detail.html | 153 ++ sites/target/templates/products.html | 86 + sites/target/templates/register.html | 54 + sites/target/templates/search.html | 61 + sites/target/templates/store_detail.html | 61 + sites/target/templates/stores.html | 35 + sites/target/templates/support.html | 33 + sites/target/templates/support_article.html | 30 + sites/target/templates/wishlist.html | 48 + websyn_start.sh | 12 +- 41 files changed, 5605 insertions(+), 12 deletions(-) create mode 100644 sites/target/_health.py create mode 100644 sites/target/app.py create mode 100644 sites/target/requirements.txt create mode 100644 sites/target/seed_data.py create mode 100644 sites/target/static/css/main.css create mode 100644 sites/target/static/js/main.js create mode 100644 sites/target/tasks.jsonl create mode 100644 sites/target/templates/_account_nav.html create mode 100644 sites/target/templates/_checkout_steps.html create mode 100644 sites/target/templates/_product_cards.html create mode 100644 sites/target/templates/account.html create mode 100644 sites/target/templates/account_orders.html create mode 100644 sites/target/templates/account_rewards.html create mode 100644 sites/target/templates/base.html create mode 100644 sites/target/templates/cart.html create mode 100644 sites/target/templates/categories.html create mode 100644 sites/target/templates/checkout_confirmation.html create mode 100644 sites/target/templates/checkout_mode.html create mode 100644 sites/target/templates/checkout_payment.html create mode 100644 sites/target/templates/checkout_pickup.html create mode 100644 sites/target/templates/checkout_review.html create mode 100644 sites/target/templates/checkout_shipping.html create mode 100644 sites/target/templates/compare.html create mode 100644 sites/target/templates/deals.html create mode 100644 sites/target/templates/home.html create mode 100644 sites/target/templates/login.html create mode 100644 sites/target/templates/order_detail.html create mode 100644 sites/target/templates/order_lookup.html create mode 100644 sites/target/templates/product_detail.html create mode 100644 sites/target/templates/products.html create mode 100644 sites/target/templates/register.html create mode 100644 sites/target/templates/search.html create mode 100644 sites/target/templates/store_detail.html create mode 100644 sites/target/templates/stores.html create mode 100644 sites/target/templates/support.html create mode 100644 sites/target/templates/support_article.html create mode 100644 sites/target/templates/wishlist.html diff --git a/Dockerfile b/Dockerfile index 991e5ab6..1e86b1d0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # WebHarbor — slim, self-contained image. -# 15 Flask mirror sites + control plane on :8101. +# 16 Flask mirror sites + control plane on :8101. FROM python:3.12-slim-bookworm @@ -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..4394b75f 100644 --- a/README.md +++ b/README.md @@ -43,10 +43,10 @@ WebHarbor takes a different approach. We leverage coding agent (e.g., Claude Cod 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 plus community additions: `Allrecipes, Amazon, Apple, ArXiv, BBC News, Booking, GitHub, Google Flights, Google Maps, Google Search, Hugging Face, Wolfram Alpha, Cambridge Dictionary, Coursera, ESPN, and Target`. 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..8059903b 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', 'target', ] BASE_PORT = 40000 WEBSYN_DIR = '/opt/WebSyn' diff --git a/sites/target/_health.py b/sites/target/_health.py new file mode 100644 index 00000000..36688945 --- /dev/null +++ b/sites/target/_health.py @@ -0,0 +1,14 @@ +"""Per-site health probe for the Target mirror.""" + +from app import Product, Store, app + + +def health(): + with app.app_context(): + return { + "ok": True, + "site": "target", + "products": Product.query.count(), + "stores": Store.query.count(), + } + diff --git a/sites/target/app.py b/sites/target/app.py new file mode 100644 index 00000000..43e240a1 --- /dev/null +++ b/sites/target/app.py @@ -0,0 +1,1470 @@ +"""Target demo mirror for WebHarbor.""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +from datetime import datetime +from pathlib import Path +from typing import Any + +from flask import ( + Flask, + abort, + flash, + jsonify, + redirect, + render_template, + request, + session, + url_for, +) +from flask_login import ( + LoginManager, + UserMixin, + current_user, + login_required, + login_user, + logout_user, +) +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import or_ + + +SITE_SLUG = "target" +SITE_NAME = "Target" +SITE_PORT = 40015 +BENCHMARK_PASSWORD = "TestPass123!" +BASE_DIR = Path(__file__).resolve().parent +INSTANCE_DIR = BASE_DIR / "instance" +SEED_DIR = BASE_DIR / "instance_seed" +STATIC_DIR = BASE_DIR / "static" +IMAGE_DIR = STATIC_DIR / "images" +RUNTIME_DB_PATH = INSTANCE_DIR / "target.db" +SEED_DB_PATH = SEED_DIR / "target.db" +PASSWORD_NAMESPACE = "target-webharbor-demo" + + +def _ensure_dirs() -> None: + INSTANCE_DIR.mkdir(parents=True, exist_ok=True) + SEED_DIR.mkdir(parents=True, exist_ok=True) + IMAGE_DIR.mkdir(parents=True, exist_ok=True) + + +_ensure_dirs() + + +app = Flask(__name__, instance_path=str(INSTANCE_DIR)) +app.config["SECRET_KEY"] = "target-demo-session-key" +app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{RUNTIME_DB_PATH}" +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + +db = SQLAlchemy(app) +login_manager = LoginManager(app) +login_manager.login_view = "login" +login_manager.login_message = "Sign in with a benchmark account to use carts, orders, and rewards." + + +def stable_password_hash(raw_password: str) -> str: + digest = hashlib.sha256() + digest.update(f"{PASSWORD_NAMESPACE}:{raw_password}".encode("utf-8")) + return digest.hexdigest() + + +def load_json(raw: str | None, default: Any) -> Any: + if not raw: + return default + try: + return json.loads(raw) + except json.JSONDecodeError: + return default + + +def dump_json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False) + + +def slugify(value: str) -> str: + cleaned = [] + for char in value.lower(): + if char.isalnum(): + cleaned.append(char) + elif cleaned and cleaned[-1] != "-": + cleaned.append("-") + return "".join(cleaned).strip("-") + + +class TimestampMixin: + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + +class User(db.Model, UserMixin, TimestampMixin): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(120), unique=True, nullable=False, index=True) + full_name = db.Column(db.String(120), nullable=False) + password_hash = db.Column(db.String(64), nullable=False) + phone = db.Column(db.String(32), default="") + city = db.Column(db.String(80), default="") + state = db.Column(db.String(40), default="") + preferred_store_slug = db.Column(db.String(80), default="") + member_tier = db.Column(db.String(32), default="Target Circle 360") + rewards_member_id = db.Column(db.String(40), default="") + + cart_items = db.relationship("CartItem", back_populates="user", cascade="all, delete-orphan") + orders = db.relationship("Order", back_populates="user", cascade="all, delete-orphan") + wishlist_items = db.relationship("WishlistItem", back_populates="user", cascade="all, delete-orphan") + compare_items = db.relationship("CompareItem", back_populates="user", cascade="all, delete-orphan") + support_tickets = db.relationship("SupportTicket", back_populates="user", cascade="all, delete-orphan") + reward_account = db.relationship("RewardAccount", back_populates="user", uselist=False, cascade="all, delete-orphan") + reward_activities = db.relationship("RewardActivity", back_populates="user", cascade="all, delete-orphan") + + def set_password(self, raw_password: str) -> None: + self.password_hash = stable_password_hash(raw_password) + + def check_password(self, raw_password: str) -> bool: + return self.password_hash == stable_password_hash(raw_password) + + +class Brand(db.Model): + __tablename__ = "brands" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), nullable=False) + slug = db.Column(db.String(80), unique=True, nullable=False, index=True) + accent_color = db.Column(db.String(16), default="#0046be") + + products = db.relationship("Product", back_populates="brand") + + +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(80), unique=True, nullable=False, index=True) + section = db.Column(db.String(80), default="") + description = db.Column(db.Text, default="") + hero_title = db.Column(db.String(160), default="") + image_path = db.Column(db.String(255), default="") + + products = db.relationship("Product", back_populates="category") + + +class Product(db.Model): + __tablename__ = "products" + + id = db.Column(db.Integer, primary_key=True) + sku = db.Column(db.String(24), unique=True, nullable=False, index=True) + slug = db.Column(db.String(160), unique=True, nullable=False, index=True) + name = db.Column(db.String(255), nullable=False) + short_description = db.Column(db.Text, default="") + long_description = db.Column(db.Text, default="") + price = db.Column(db.Float, nullable=False) + list_price = db.Column(db.Float, nullable=False, default=0.0) + rating = db.Column(db.Float, default=4.5) + review_count = db.Column(db.Integer, default=0) + availability_status = db.Column(db.String(40), default="In stock") + pickup_eligible = db.Column(db.Boolean, default=True) + delivery_eligible = db.Column(db.Boolean, default=True) + featured = db.Column(db.Boolean, default=False) + deal_badge = db.Column(db.String(80), default="") + image_path = db.Column(db.String(255), default="") + highlights_json = db.Column(db.Text, default="[]") + specs_json = db.Column(db.Text, default="[]") + tags_json = db.Column(db.Text, default="[]") + search_keywords = db.Column(db.Text, default="") + stock_count = db.Column(db.Integer, default=0) + category_id = db.Column(db.Integer, db.ForeignKey("categories.id"), nullable=False) + brand_id = db.Column(db.Integer, db.ForeignKey("brands.id"), nullable=False) + + category = db.relationship("Category", back_populates="products") + brand = db.relationship("Brand", back_populates="products") + reviews = db.relationship("Review", back_populates="product", cascade="all, delete-orphan") + inventory_rows = db.relationship("StoreInventory", back_populates="product", cascade="all, delete-orphan") + cart_items = db.relationship("CartItem", back_populates="product", cascade="all, delete-orphan") + wishlist_items = db.relationship("WishlistItem", back_populates="product", cascade="all, delete-orphan") + compare_items = db.relationship("CompareItem", back_populates="product", cascade="all, delete-orphan") + protection_plans = db.relationship("ProtectionPlan", back_populates="product", cascade="all, delete-orphan") + order_items = db.relationship("OrderItem", back_populates="product") + deals = db.relationship("Deal", back_populates="product") + + def highlights(self) -> list[str]: + return load_json(self.highlights_json, []) + + def specs(self) -> list[dict[str, Any]]: + return load_json(self.specs_json, []) + + def tags(self) -> list[str]: + return load_json(self.tags_json, []) + + def discount_percent(self) -> int: + if self.list_price > self.price > 0: + return int(round((self.list_price - self.price) / self.list_price * 100)) + return 0 + + def support_search_blob(self) -> str: + parts = [self.name, self.short_description, self.long_description, self.search_keywords] + parts.extend(self.tags()) + parts.extend(self.highlights()) + return " ".join(part for part in parts if part).lower() + + +class Store(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(80), unique=True, nullable=False, index=True) + name = db.Column(db.String(120), nullable=False) + city = db.Column(db.String(80), nullable=False) + state = db.Column(db.String(40), nullable=False) + address = db.Column(db.String(160), nullable=False) + phone = db.Column(db.String(24), default="") + hours_json = db.Column(db.Text, default="[]") + amenities_json = db.Column(db.Text, default="[]") + services_json = db.Column(db.Text, default="[]") + hero_copy = db.Column(db.Text, default="") + image_path = db.Column(db.String(255), default="") + + inventory_rows = db.relationship("StoreInventory", back_populates="store", cascade="all, delete-orphan") + pickup_slots = db.relationship("PickupSlot", back_populates="store", cascade="all, delete-orphan") + orders = db.relationship("Order", back_populates="store") + + def hours(self) -> list[str]: + return load_json(self.hours_json, []) + + def amenities(self) -> list[str]: + return load_json(self.amenities_json, []) + + def services(self) -> list[str]: + return load_json(self.services_json, []) + + +class StoreInventory(db.Model): + __tablename__ = "store_inventory" + + id = db.Column(db.Integer, primary_key=True) + store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) + product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False) + quantity = db.Column(db.Integer, default=0) + pickup_window = db.Column(db.String(120), default="") + aisle = db.Column(db.String(40), default="") + + store = db.relationship("Store", back_populates="inventory_rows") + product = db.relationship("Product", back_populates="inventory_rows") + + +class Review(db.Model, TimestampMixin): + __tablename__ = "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(80), nullable=False) + title = db.Column(db.String(140), nullable=False) + body = db.Column(db.Text, nullable=False) + rating = db.Column(db.Integer, nullable=False) + verified = db.Column(db.Boolean, default=True) + + product = db.relationship("Product", back_populates="reviews") + + +class CartItem(db.Model, TimestampMixin): + __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) + fulfillment_method = db.Column(db.String(24), default="delivery") + store_id = db.Column(db.Integer, db.ForeignKey("stores.id")) + delivery_option_id = db.Column(db.Integer, db.ForeignKey("delivery_options.id")) + protection_plan_id = db.Column(db.Integer, db.ForeignKey("protection_plans.id")) + + user = db.relationship("User", back_populates="cart_items") + product = db.relationship("Product", back_populates="cart_items") + store = db.relationship("Store") + delivery_option = db.relationship("DeliveryOption") + protection_plan = db.relationship("ProtectionPlan") + + +class WishlistItem(db.Model, TimestampMixin): + __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) + + user = db.relationship("User", back_populates="wishlist_items") + product = db.relationship("Product", back_populates="wishlist_items") + + +class CompareItem(db.Model, TimestampMixin): + __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) + + user = db.relationship("User", back_populates="compare_items") + product = db.relationship("Product", back_populates="compare_items") + + +class ProtectionPlan(db.Model): + __tablename__ = "protection_plans" + + id = db.Column(db.Integer, primary_key=True) + product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False) + name = db.Column(db.String(120), nullable=False) + years = db.Column(db.Integer, default=2) + price = db.Column(db.Float, nullable=False) + coverage_summary = db.Column(db.Text, default="") + accidental = db.Column(db.Boolean, default=False) + priority_support = db.Column(db.Boolean, default=True) + + product = db.relationship("Product", back_populates="protection_plans") + + +class SupportArticle(db.Model): + __tablename__ = "support_articles" + + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(120), unique=True, nullable=False, index=True) + title = db.Column(db.String(180), nullable=False) + topic = db.Column(db.String(80), default="") + summary = db.Column(db.Text, default="") + body = db.Column(db.Text, default="") + upstream_url = db.Column(db.String(255), default="") + keywords_json = db.Column(db.Text, default="[]") + + def keywords(self) -> list[str]: + return load_json(self.keywords_json, []) + + +class SupportTicket(db.Model, TimestampMixin): + __tablename__ = "support_tickets" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + subject = db.Column(db.String(180), nullable=False) + status = db.Column(db.String(40), default="Resolved") + channel = db.Column(db.String(40), default="Chat") + summary = db.Column(db.Text, default="") + + user = db.relationship("User", back_populates="support_tickets") + + +class Deal(db.Model): + __tablename__ = "deals" + + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(120), unique=True, nullable=False, index=True) + title = db.Column(db.String(180), nullable=False) + subtitle = db.Column(db.Text, default="") + badge = db.Column(db.String(80), default="Member Deal") + discount_percent = db.Column(db.Integer, default=0) + ends_label = db.Column(db.String(80), default="") + category_slug = db.Column(db.String(80), default="") + product_id = db.Column(db.Integer, db.ForeignKey("products.id")) + + product = db.relationship("Product", back_populates="deals") + + +class RewardAccount(db.Model): + __tablename__ = "reward_accounts" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), unique=True, nullable=False) + member_id = db.Column(db.String(40), nullable=False) + points_balance = db.Column(db.Integer, default=0) + tier = db.Column(db.String(40), default="Target Circle 360") + available_certificates = db.Column(db.Integer, default=0) + + user = db.relationship("User", back_populates="reward_account") + + +class RewardActivity(db.Model): + __tablename__ = "reward_activities" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + points_delta = db.Column(db.Integer, nullable=False) + title = db.Column(db.String(140), nullable=False) + note = db.Column(db.Text, default="") + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + user = db.relationship("User", back_populates="reward_activities") + + +class DeliveryOption(db.Model): + __tablename__ = "delivery_options" + + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(80), unique=True, nullable=False, index=True) + title = db.Column(db.String(120), nullable=False) + description = db.Column(db.Text, default="") + fee = db.Column(db.Float, default=0.0) + eta_label = db.Column(db.String(120), default="") + + +class PickupSlot(db.Model): + __tablename__ = "pickup_slots" + + id = db.Column(db.Integer, primary_key=True) + store_id = db.Column(db.Integer, db.ForeignKey("stores.id"), nullable=False) + slot_code = db.Column(db.String(40), unique=True, nullable=False) + day_label = db.Column(db.String(80), nullable=False) + time_window = db.Column(db.String(80), nullable=False) + available_capacity = db.Column(db.Integer, default=0) + + store = db.relationship("Store", back_populates="pickup_slots") + + +class Order(db.Model): + __tablename__ = "orders" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id")) + order_number = db.Column(db.String(32), unique=True, nullable=False, index=True) + email = db.Column(db.String(120), nullable=False) + status = db.Column(db.String(60), default="Preparing") + subtotal = db.Column(db.Float, default=0.0) + tax = db.Column(db.Float, default=0.0) + total = db.Column(db.Float, default=0.0) + fulfillment_method = db.Column(db.String(32), default="delivery") + store_id = db.Column(db.Integer, db.ForeignKey("stores.id")) + delivery_option_id = db.Column(db.Integer, db.ForeignKey("delivery_options.id")) + shipping_name = db.Column(db.String(120), default="") + shipping_city = db.Column(db.String(80), default="") + shipping_state = db.Column(db.String(40), default="") + shipping_zip = db.Column(db.String(20), default="") + payment_brand = db.Column(db.String(40), default="Demo Visa") + payment_last4 = db.Column(db.String(4), default="1111") + confirmation_note = db.Column(db.Text, default="") + pickup_slot_label = db.Column(db.String(120), default="") + placed_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + user = db.relationship("User", back_populates="orders") + store = db.relationship("Store", back_populates="orders") + delivery_option = db.relationship("DeliveryOption") + items = db.relationship("OrderItem", back_populates="order", cascade="all, delete-orphan") + payment = db.relationship("PaymentMock", back_populates="order", uselist=False, cascade="all, delete-orphan") + + def item_count(self) -> int: + return sum(item.quantity for item in self.items) + + +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) + item_name = db.Column(db.String(255), nullable=False) + quantity = db.Column(db.Integer, default=1) + unit_price = db.Column(db.Float, default=0.0) + protection_plan_name = db.Column(db.String(140), default="") + + order = db.relationship("Order", back_populates="items") + product = db.relationship("Product", back_populates="order_items") + + +class PaymentMock(db.Model): + __tablename__ = "payment_mocks" + + id = db.Column(db.Integer, primary_key=True) + order_id = db.Column(db.Integer, db.ForeignKey("orders.id"), unique=True, nullable=False) + amount = db.Column(db.Float, nullable=False) + card_label = db.Column(db.String(80), default="Demo Visa") + auth_status = db.Column(db.String(40), default="Approved") + approval_code = db.Column(db.String(20), default="") + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + order = db.relationship("Order", back_populates="payment") + + +class SearchLog(db.Model): + __tablename__ = "search_logs" + + id = db.Column(db.Integer, primary_key=True) + query = db.Column(db.String(255), nullable=False) + scope = db.Column(db.String(40), default="products") + user_id = db.Column(db.Integer, db.ForeignKey("users.id")) + result_count = db.Column(db.Integer, default=0) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + +@login_manager.user_loader +def load_user(user_id: str) -> User | None: + return db.session.get(User, int(user_id)) + + +@app.template_filter("currency") +def currency_filter(value: float) -> str: + return f"${value:,.2f}" + + +@app.template_filter("stars") +def star_filter(value: float) -> str: + return f"{value:.1f}" + + +@app.template_filter("paragraphs") +def paragraphs_filter(value: str) -> list[str]: + return [chunk.strip() for chunk in value.split("\n") if chunk.strip()] + + +def nav_categories() -> list[Category]: + return Category.query.order_by(Category.name.asc()).limit(8).all() + + +def get_preferred_store() -> Store | None: + if current_user.is_authenticated and current_user.preferred_store_slug: + return Store.query.filter_by(slug=current_user.preferred_store_slug).first() + return Store.query.order_by(Store.city.asc()).first() + + +def get_compare_products() -> list[Product]: + if current_user.is_authenticated: + return [item.product for item in current_user.compare_items[:4]] + compare_skus = session.get("compare_skus", []) + if not compare_skus: + return [] + products = Product.query.filter(Product.sku.in_(compare_skus[:4])).all() + order = {sku: index for index, sku in enumerate(compare_skus[:4])} + return sorted(products, key=lambda product: order.get(product.sku, 999)) + + +def get_cart_items() -> list[CartItem]: + if not current_user.is_authenticated: + return [] + return ( + CartItem.query.filter_by(user_id=current_user.id) + .order_by(CartItem.created_at.desc()) + .all() + ) + + +def cart_totals(cart_items: list[CartItem]) -> dict[str, float]: + subtotal = 0.0 + for item in cart_items: + plan_price = item.protection_plan.price if item.protection_plan else 0.0 + subtotal += (item.product.price + plan_price) * item.quantity + return {"subtotal": round(subtotal, 2), "count": sum(item.quantity for item in cart_items)} + + +def get_checkout_state() -> dict[str, Any]: + checkout = session.get("target_checkout", {}) + if not isinstance(checkout, dict): + checkout = {} + return checkout + + +def save_checkout_state(checkout: dict[str, Any]) -> None: + session["target_checkout"] = checkout + session.modified = True + + +def clear_checkout_state() -> None: + session.pop("target_checkout", None) + session.modified = True + + +def merge_compare_session_into_user() -> None: + compare_skus = session.pop("compare_skus", []) + if not compare_skus or not current_user.is_authenticated: + return + existing = {item.product.sku for item in current_user.compare_items} + for sku in compare_skus[:4]: + if sku in existing: + continue + product = Product.query.filter_by(sku=sku).first() + if product: + db.session.add(CompareItem(user_id=current_user.id, product_id=product.id)) + db.session.commit() + + +@app.context_processor +def inject_global_context() -> dict[str, Any]: + cart_items = get_cart_items() + compare_products = get_compare_products() + reward_points = 0 + if current_user.is_authenticated and current_user.reward_account: + reward_points = current_user.reward_account.points_balance + return { + "site_name": SITE_NAME, + "benchmark_password": BENCHMARK_PASSWORD, + "nav_categories": nav_categories(), + "cart_item_count": cart_totals(cart_items)["count"], + "compare_count": len(compare_products), + "preferred_store": get_preferred_store(), + "reward_points": reward_points, + } + + +def log_search(query: str, scope: str, result_count: int) -> None: + if not query: + return + db.session.add( + SearchLog( + query=query, + scope=scope, + user_id=current_user.id if current_user.is_authenticated else None, + result_count=result_count, + ) + ) + db.session.commit() + + +def product_query_from_filters(category_slug: str | None = None): + query = Product.query.join(Brand).join(Category) + if category_slug: + query = query.filter(Category.slug == category_slug) + + q = request.args.get("q", "").strip() + if q: + q_like = f"%{q.lower()}%" + query = query.filter( + or_( + db.func.lower(Product.name).like(q_like), + db.func.lower(Product.search_keywords).like(q_like), + db.func.lower(Brand.name).like(q_like), + db.func.lower(Category.name).like(q_like), + ) + ) + + brand_slug = request.args.get("brand", "").strip() + if brand_slug: + query = query.filter(Brand.slug == brand_slug) + + try: + min_price = float(request.args.get("min_price", "") or 0) + if min_price: + query = query.filter(Product.price >= min_price) + except ValueError: + min_price = None + + try: + max_price = float(request.args.get("max_price", "") or 0) + if max_price: + query = query.filter(Product.price <= max_price) + except ValueError: + max_price = None + + rating_filter = request.args.get("rating", "").strip() + if rating_filter: + try: + rating_value = float(rating_filter) + query = query.filter(Product.rating >= rating_value) + except ValueError: + pass + + availability = request.args.get("availability", "").strip() + if availability == "in-stock": + query = query.filter(Product.stock_count > 0) + elif availability == "pickup": + query = query.filter(Product.pickup_eligible.is_(True)) + + if request.args.get("pickup") == "1": + query = query.filter(Product.pickup_eligible.is_(True)) + if request.args.get("delivery") == "1": + query = query.filter(Product.delivery_eligible.is_(True)) + + sort = request.args.get("sort", "featured") + if sort == "price-asc": + query = query.order_by(Product.price.asc(), Product.rating.desc()) + elif sort == "price-desc": + query = query.order_by(Product.price.desc(), Product.rating.desc()) + elif sort == "rating": + query = query.order_by(Product.rating.desc(), Product.review_count.desc()) + elif sort == "newest": + query = query.order_by(Product.id.desc()) + else: + query = query.order_by(Product.featured.desc(), Product.rating.desc(), Product.review_count.desc()) + + return query, q + + +def order_accessible(order: Order) -> bool: + if current_user.is_authenticated and order.user_id == current_user.id: + return True + return session.get("target_lookup_order") == order.order_number + + +def build_checkout_summary(cart_items: list[CartItem], checkout: dict[str, Any]) -> dict[str, Any]: + totals = cart_totals(cart_items) + subtotal = totals["subtotal"] + mode = checkout.get("mode", "delivery") + delivery_option = None + store = None + pickup_slot = None + shipping_fee = 0.0 + + if mode == "pickup": + if checkout.get("store_id"): + store = db.session.get(Store, int(checkout["store_id"])) + if checkout.get("slot_id"): + pickup_slot = db.session.get(PickupSlot, int(checkout["slot_id"])) + else: + mode = "delivery" + if checkout.get("delivery_option_id"): + delivery_option = db.session.get(DeliveryOption, int(checkout["delivery_option_id"])) + shipping_fee = delivery_option.fee if delivery_option else 0.0 + + tax = round((subtotal + shipping_fee) * 0.086, 2) + total = round(subtotal + shipping_fee + tax, 2) + + return { + "subtotal": subtotal, + "shipping_fee": shipping_fee, + "tax": tax, + "total": total, + "mode": mode, + "delivery_option": delivery_option, + "store": store, + "pickup_slot": pickup_slot, + } + + +def require_cart_items() -> list[CartItem]: + cart_items = get_cart_items() + if not cart_items: + flash("Your cart is empty. Add a demo product before checkout.", "warning") + raise RuntimeError("empty-cart") + return cart_items + + +@app.route("/") +@app.route("/home") +def home(): + featured_products = Product.query.filter_by(featured=True).order_by(Product.rating.desc()).limit(8).all() + deal_cards = Deal.query.order_by(Deal.discount_percent.desc(), Deal.title.asc()).limit(6).all() + stores = Store.query.order_by(Store.city.asc()).limit(4).all() + support_articles = SupportArticle.query.order_by(SupportArticle.id.asc()).limit(4).all() + return render_template( + "home.html", + featured_products=featured_products, + deal_cards=deal_cards, + stores=stores, + support_articles=support_articles, + categories=Category.query.order_by(Category.name.asc()).all(), + ) + + +@app.route("/categories") +def categories_page(): + categories = Category.query.order_by(Category.name.asc()).all() + return render_template("categories.html", categories=categories) + + +@app.route("/category/") +def category_page(category_slug: str): + category = Category.query.filter_by(slug=category_slug).first_or_404() + products_query, q = product_query_from_filters(category_slug) + products = products_query.all() + brands = Brand.query.join(Product).filter(Product.category_id == category.id).order_by(Brand.name.asc()).distinct().all() + return render_template( + "products.html", + page_title=category.name, + page_description=category.description, + category=category, + products=products, + brands=brands, + active_query=q, + ) + + +@app.route("/products") +def products_page(): + products_query, q = product_query_from_filters() + products = products_query.all() + log_search(q, "products", len(products)) + return render_template( + "products.html", + page_title="Shop all products", + page_description="Synthetic products, deterministic stock, and fully local browsing for benchmark tasks.", + category=None, + products=products, + brands=Brand.query.order_by(Brand.name.asc()).all(), + active_query=q, + ) + + +@app.route("/product/") +def product_page(sku: str): + product = Product.query.filter_by(sku=sku).first_or_404() + inventory_rows = ( + StoreInventory.query.filter_by(product_id=product.id) + .join(Store) + .order_by(StoreInventory.quantity.desc(), Store.city.asc()) + .limit(6) + .all() + ) + related_products = ( + Product.query.filter( + Product.category_id == product.category_id, + Product.id != product.id, + ) + .order_by(Product.rating.desc(), Product.review_count.desc()) + .limit(4) + .all() + ) + compare_skus = {item.sku for item in get_compare_products()} + wishlist_product_ids = set() + if current_user.is_authenticated: + wishlist_product_ids = {item.product_id for item in current_user.wishlist_items} + return render_template( + "product_detail.html", + product=product, + inventory_rows=inventory_rows, + related_products=related_products, + compare_skus=compare_skus, + wishlist_product_ids=wishlist_product_ids, + ) + + +@app.route("/compare") +def compare_page(): + products = get_compare_products() + spec_rows: list[tuple[str, list[str]]] = [] + if products: + labels: list[str] = [] + for product in products: + for section in product.specs(): + for spec in section.get("items", []): + label = spec.get("label", "") + if label and label not in labels: + labels.append(label) + for label in labels: + values = [] + for product in products: + value = "-" + for section in product.specs(): + for spec in section.get("items", []): + if spec.get("label") == label: + value = spec.get("value", "-") + values.append(value) + spec_rows.append((label, values)) + return render_template("compare.html", products=products, spec_rows=spec_rows) + + +@app.route("/deals") +def deals_page(): + deals = Deal.query.order_by(Deal.discount_percent.desc(), Deal.title.asc()).all() + return render_template("deals.html", deals=deals) + + +@app.route("/stores") +def stores_page(): + q = request.args.get("q", "").strip().lower() + query = Store.query + if q: + q_like = f"%{q}%" + query = query.filter( + or_( + db.func.lower(Store.name).like(q_like), + db.func.lower(Store.city).like(q_like), + db.func.lower(Store.state).like(q_like), + db.func.lower(Store.address).like(q_like), + ) + ) + stores = query.order_by(Store.state.asc(), Store.city.asc()).all() + return render_template("stores.html", stores=stores, active_query=q) + + +@app.route("/stores/") +def store_page(store_slug: str): + store = Store.query.filter_by(slug=store_slug).first_or_404() + inventory_rows = ( + StoreInventory.query.filter_by(store_id=store.id) + .join(Product) + .order_by(StoreInventory.quantity.desc(), Product.rating.desc()) + .limit(10) + .all() + ) + return render_template("store_detail.html", store=store, inventory_rows=inventory_rows) + + +@app.route("/support") +@app.route("/help") +def support_page(): + q = request.args.get("q", "").strip().lower() + topic = request.args.get("topic", "").strip() + query = SupportArticle.query + if q: + q_like = f"%{q}%" + query = query.filter( + or_( + db.func.lower(SupportArticle.title).like(q_like), + db.func.lower(SupportArticle.summary).like(q_like), + db.func.lower(SupportArticle.body).like(q_like), + db.func.lower(SupportArticle.keywords_json).like(q_like), + ) + ) + if topic: + query = query.filter(SupportArticle.topic == topic) + articles = query.order_by(SupportArticle.title.asc()).all() + if q: + log_search(q, "support", len(articles)) + topics = sorted({article.topic for article in SupportArticle.query.all()}) + return render_template("support.html", articles=articles, active_query=q, topics=topics, active_topic=topic) + + +@app.route("/support/") +def support_article_page(article_slug: str): + article = SupportArticle.query.filter_by(slug=article_slug).first_or_404() + related_articles = ( + SupportArticle.query.filter( + SupportArticle.topic == article.topic, + SupportArticle.id != article.id, + ) + .order_by(SupportArticle.title.asc()) + .limit(4) + .all() + ) + return render_template("support_article.html", article=article, related_articles=related_articles) + + +@app.route("/search") +def search_page(): + q = request.args.get("q", "").strip() + product_results = [] + store_results = [] + article_results = [] + if q: + q_like = f"%{q.lower()}%" + product_results = ( + Product.query.join(Brand).filter( + or_( + db.func.lower(Product.name).like(q_like), + db.func.lower(Product.search_keywords).like(q_like), + db.func.lower(Brand.name).like(q_like), + ) + ) + .order_by(Product.featured.desc(), Product.rating.desc()) + .limit(24) + .all() + ) + store_results = ( + Store.query.filter( + or_( + db.func.lower(Store.name).like(q_like), + db.func.lower(Store.city).like(q_like), + db.func.lower(Store.state).like(q_like), + ) + ) + .order_by(Store.city.asc()) + .limit(8) + .all() + ) + article_results = ( + SupportArticle.query.filter( + or_( + db.func.lower(SupportArticle.title).like(q_like), + db.func.lower(SupportArticle.summary).like(q_like), + db.func.lower(SupportArticle.body).like(q_like), + ) + ) + .order_by(SupportArticle.title.asc()) + .limit(8) + .all() + ) + log_search(q, "global", len(product_results) + len(store_results) + len(article_results)) + return render_template( + "search.html", + active_query=q, + product_results=product_results, + store_results=store_results, + article_results=article_results, + ) + + +@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 not user or not user.check_password(password): + flash("That demo account/password combination was not recognized.", "danger") + else: + login_user(user) + merge_compare_session_into_user() + flash(f"Welcome back, {user.full_name}.", "success") + next_url = request.args.get("next") or url_for("account") + return redirect(next_url) + return render_template("login.html") + + +@app.route("/register", methods=["GET", "POST"]) +def register(): + if current_user.is_authenticated: + return redirect(url_for("account")) + + stores = Store.query.order_by(Store.city.asc()).all() + if request.method == "POST": + email = request.form.get("email", "").strip().lower() + full_name = request.form.get("full_name", "").strip() + password = request.form.get("password", "") + confirm_password = request.form.get("confirm_password", "") + preferred_store_slug = request.form.get("preferred_store_slug", "").strip() + + if not email or not full_name or not password: + flash("Please fill in your name, email, and password.", "warning") + elif password != confirm_password: + flash("The passwords did not match.", "warning") + elif User.query.filter_by(email=email).first(): + flash("That email is already registered in this demo mirror.", "warning") + else: + user = User( + email=email, + full_name=full_name, + phone=request.form.get("phone", "").strip(), + city=request.form.get("city", "").strip(), + state=request.form.get("state", "").strip(), + preferred_store_slug=preferred_store_slug, + member_tier="Target Circle", + rewards_member_id=f"TGTC-{100000 + User.query.count() + 1}", + ) + user.set_password(password) + db.session.add(user) + db.session.flush() + db.session.add( + RewardAccount( + user_id=user.id, + member_id=user.rewards_member_id, + points_balance=120, + tier=user.member_tier, + available_certificates=0, + ) + ) + db.session.commit() + login_user(user) + flash("Your local demo account is ready.", "success") + return redirect(url_for("account")) + + return render_template("register.html", stores=stores) + + +@app.route("/logout") +def logout(): + if current_user.is_authenticated: + logout_user() + flash("You have been signed out of the demo account.", "info") + return redirect(url_for("home")) + + +@app.route("/account") +@login_required +def account(): + recent_orders = Order.query.filter_by(user_id=current_user.id).order_by(Order.placed_at.desc()).limit(3).all() + recent_tickets = SupportTicket.query.filter_by(user_id=current_user.id).order_by(SupportTicket.created_at.desc()).limit(3).all() + wishlist_preview = current_user.wishlist_items[:4] + return render_template( + "account.html", + recent_orders=recent_orders, + recent_tickets=recent_tickets, + wishlist_preview=wishlist_preview, + reward_account=current_user.reward_account, + ) + + +@app.route("/account/orders") +@login_required +def account_orders(): + orders = Order.query.filter_by(user_id=current_user.id).order_by(Order.placed_at.desc()).all() + return render_template("account_orders.html", orders=orders) + + +@app.route("/account/rewards") +@login_required +def account_rewards(): + activities = RewardActivity.query.filter_by(user_id=current_user.id).order_by(RewardActivity.created_at.desc()).all() + return render_template("account_rewards.html", activities=activities, reward_account=current_user.reward_account) + + +@app.route("/account/wishlist") +@login_required +def account_wishlist(): + wishlist_items = WishlistItem.query.filter_by(user_id=current_user.id).order_by(WishlistItem.created_at.desc()).all() + return render_template("wishlist.html", wishlist_items=wishlist_items) + + +@app.route("/account/wishlist/toggle/", methods=["POST"]) +@login_required +def toggle_wishlist(sku: str): + product = Product.query.filter_by(sku=sku).first_or_404() + item = WishlistItem.query.filter_by(user_id=current_user.id, product_id=product.id).first() + if item: + db.session.delete(item) + flash(f"Removed {product.name} from your wishlist.", "info") + else: + db.session.add(WishlistItem(user_id=current_user.id, product_id=product.id)) + flash(f"Saved {product.name} to your wishlist.", "success") + db.session.commit() + return redirect(request.form.get("next") or request.referrer or url_for("product_page", sku=sku)) + + +@app.route("/compare/toggle/", methods=["POST"]) +def toggle_compare(sku: str): + product = Product.query.filter_by(sku=sku).first_or_404() + if current_user.is_authenticated: + item = CompareItem.query.filter_by(user_id=current_user.id, product_id=product.id).first() + if item: + db.session.delete(item) + flash(f"Removed {product.name} from compare.", "info") + else: + 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.asc()).first() + if oldest: + db.session.delete(oldest) + db.session.add(CompareItem(user_id=current_user.id, product_id=product.id)) + flash(f"Added {product.name} to compare.", "success") + db.session.commit() + else: + compare_skus = session.get("compare_skus", []) + if sku in compare_skus: + compare_skus = [value for value in compare_skus if value != sku] + flash(f"Removed {product.name} from compare.", "info") + else: + compare_skus = (compare_skus + [sku])[-4:] + flash(f"Added {product.name} to compare.", "success") + session["compare_skus"] = compare_skus + session.modified = True + return redirect(request.form.get("next") or request.referrer or url_for("compare_page")) + + +@app.route("/cart") +def cart_page(): + if not current_user.is_authenticated: + return render_template("cart.html", cart_items=[], totals={"subtotal": 0.0, "count": 0}, requires_login=True) + cart_items = get_cart_items() + totals = cart_totals(cart_items) + return render_template("cart.html", cart_items=cart_items, totals=totals, requires_login=False) + + +@app.route("/cart/add", methods=["POST"]) +@login_required +def add_to_cart(): + product = Product.query.filter_by(sku=request.form.get("sku", "").strip()).first_or_404() + quantity = max(1, min(5, int(request.form.get("quantity", "1") or 1))) + fulfillment_method = request.form.get("fulfillment_method", "delivery") + store_id = request.form.get("store_id") + delivery_option_id = request.form.get("delivery_option_id") + protection_plan_id = request.form.get("protection_plan_id") + + cart_item = CartItem.query.filter_by(user_id=current_user.id, product_id=product.id).first() + if not cart_item: + cart_item = CartItem(user_id=current_user.id, product_id=product.id) + db.session.add(cart_item) + cart_item.quantity = quantity + cart_item.fulfillment_method = fulfillment_method + cart_item.store_id = int(store_id) if store_id else None + cart_item.delivery_option_id = int(delivery_option_id) if delivery_option_id else None + cart_item.protection_plan_id = int(protection_plan_id) if protection_plan_id else None + db.session.commit() + flash(f"Added {product.name} to your cart.", "success") + return redirect(request.form.get("next") or url_for("cart_page")) + + +@app.route("/cart/update/", methods=["POST"]) +@login_required +def update_cart(item_id: int): + cart_item = CartItem.query.filter_by(id=item_id, user_id=current_user.id).first_or_404() + quantity = int(request.form.get("quantity", cart_item.quantity) or cart_item.quantity) + if quantity <= 0: + db.session.delete(cart_item) + flash(f"Removed {cart_item.product.name} from your cart.", "info") + else: + cart_item.quantity = min(quantity, 5) + db.session.add(cart_item) + flash(f"Updated {cart_item.product.name} in your cart.", "success") + db.session.commit() + return redirect(url_for("cart_page")) + + +@app.route("/cart/remove/", methods=["POST"]) +@login_required +def remove_cart_item(item_id: int): + cart_item = CartItem.query.filter_by(id=item_id, user_id=current_user.id).first_or_404() + product_name = cart_item.product.name + db.session.delete(cart_item) + db.session.commit() + flash(f"Removed {product_name} from your cart.", "info") + return redirect(url_for("cart_page")) + + +@app.route("/checkout") +def checkout(): + if not current_user.is_authenticated: + return render_template("checkout_mode.html", cart_items=[], checkout={}, requires_login=True) + try: + cart_items = require_cart_items() + except RuntimeError: + return redirect(url_for("cart_page")) + return render_template("checkout_mode.html", cart_items=cart_items, checkout=get_checkout_state(), requires_login=False) + + +@app.route("/checkout/shipping", methods=["GET", "POST"]) +@login_required +def checkout_shipping(): + try: + cart_items = require_cart_items() + except RuntimeError: + return redirect(url_for("cart_page")) + + delivery_options = DeliveryOption.query.order_by(DeliveryOption.fee.asc()).all() + checkout = get_checkout_state() + if request.method == "POST": + checkout.update( + { + "mode": "delivery", + "delivery_option_id": request.form.get("delivery_option_id"), + "shipping_name": request.form.get("shipping_name", "").strip(), + "shipping_city": request.form.get("shipping_city", "").strip(), + "shipping_state": request.form.get("shipping_state", "").strip(), + "shipping_zip": request.form.get("shipping_zip", "").strip(), + } + ) + save_checkout_state(checkout) + flash("Delivery details saved.", "success") + return redirect(url_for("checkout_payment")) + return render_template( + "checkout_shipping.html", + cart_items=cart_items, + delivery_options=delivery_options, + checkout=checkout, + ) + + +@app.route("/checkout/pickup", methods=["GET", "POST"]) +@login_required +def checkout_pickup(): + try: + cart_items = require_cart_items() + except RuntimeError: + return redirect(url_for("cart_page")) + + stores = Store.query.order_by(Store.city.asc()).all() + checkout = get_checkout_state() + if request.method == "POST": + checkout.update( + { + "mode": "pickup", + "store_id": request.form.get("store_id"), + "slot_id": request.form.get("slot_id"), + } + ) + save_checkout_state(checkout) + flash("Store pickup details saved.", "success") + return redirect(url_for("checkout_payment")) + slots_by_store = { + store.id: PickupSlot.query.filter_by(store_id=store.id).order_by(PickupSlot.day_label.asc()).all() + for store in stores + } + return render_template( + "checkout_pickup.html", + cart_items=cart_items, + stores=stores, + slots_by_store=slots_by_store, + checkout=checkout, + ) + + +@app.route("/checkout/payment", methods=["GET", "POST"]) +@login_required +def checkout_payment(): + try: + cart_items = require_cart_items() + except RuntimeError: + return redirect(url_for("cart_page")) + + checkout = get_checkout_state() + if request.method == "POST": + checkout.update( + { + "payment_brand": request.form.get("payment_brand", "Demo Visa").strip() or "Demo Visa", + "payment_last4": (request.form.get("payment_last4", "1111").strip() or "1111")[-4:], + } + ) + save_checkout_state(checkout) + flash("Demo payment details saved.", "success") + return redirect(url_for("checkout_review")) + return render_template("checkout_payment.html", cart_items=cart_items, checkout=checkout) + + +@app.route("/checkout/review", methods=["GET", "POST"]) +@login_required +def checkout_review(): + try: + cart_items = require_cart_items() + except RuntimeError: + return redirect(url_for("cart_page")) + + checkout = get_checkout_state() + summary = build_checkout_summary(cart_items, checkout) + if request.method == "POST": + if summary["mode"] == "delivery" and not checkout.get("delivery_option_id"): + flash("Choose a delivery option before placing the order.", "warning") + return redirect(url_for("checkout_shipping")) + if summary["mode"] == "pickup" and not checkout.get("store_id"): + flash("Choose a pickup store before placing the order.", "warning") + return redirect(url_for("checkout_pickup")) + if not checkout.get("payment_last4"): + flash("Enter demo payment details before placing the order.", "warning") + return redirect(url_for("checkout_payment")) + + order_number = f"TGT-{240000 + Order.query.count() + 1}" + order = Order( + user_id=current_user.id, + order_number=order_number, + email=current_user.email, + status="Ready for pickup" if summary["mode"] == "pickup" else "Preparing shipment", + subtotal=summary["subtotal"], + tax=summary["tax"], + total=summary["total"], + fulfillment_method=summary["mode"], + store_id=summary["store"].id if summary["store"] else None, + delivery_option_id=summary["delivery_option"].id if summary["delivery_option"] else None, + shipping_name=checkout.get("shipping_name", current_user.full_name), + shipping_city=checkout.get("shipping_city", current_user.city), + shipping_state=checkout.get("shipping_state", current_user.state), + shipping_zip=checkout.get("shipping_zip", "00000"), + payment_brand=checkout.get("payment_brand", "Demo Visa"), + payment_last4=checkout.get("payment_last4", "1111"), + confirmation_note=( + "Synthetic demo order only. No real payment or fulfillment occurred." + ), + pickup_slot_label=summary["pickup_slot"].time_window if summary["pickup_slot"] else "", + ) + db.session.add(order) + db.session.flush() + + for item in cart_items: + db.session.add( + OrderItem( + order_id=order.id, + product_id=item.product_id, + item_name=item.product.name, + quantity=item.quantity, + unit_price=item.product.price, + protection_plan_name=item.protection_plan.name if item.protection_plan else "", + ) + ) + + db.session.add( + PaymentMock( + order_id=order.id, + amount=summary["total"], + card_label=checkout.get("payment_brand", "Demo Visa"), + approval_code=f"TGTOK{order.id:04d}", + ) + ) + + earned_points = int(summary["subtotal"]) + if current_user.reward_account: + current_user.reward_account.points_balance += earned_points + db.session.add( + RewardActivity( + user_id=current_user.id, + points_delta=earned_points, + title=f"Points from order {order.order_number}", + note="Synthetic demo checkout reward credit.", + ) + ) + + for item in cart_items: + db.session.delete(item) + + db.session.commit() + session["target_last_order"] = order.order_number + session["target_lookup_order"] = order.order_number + clear_checkout_state() + flash("Demo checkout completed.", "success") + return redirect(url_for("checkout_confirmation")) + + return render_template("checkout_review.html", cart_items=cart_items, checkout=checkout, summary=summary) + + +@app.route("/checkout/confirmation") +@login_required +def checkout_confirmation(): + order_number = request.args.get("order_number") or session.get("target_last_order") + if not order_number: + flash("There is no recent checkout to confirm.", "info") + return redirect(url_for("account_orders")) + order = Order.query.filter_by(order_number=order_number).first_or_404() + if order.user_id != current_user.id: + abort(403) + return render_template("checkout_confirmation.html", order=order) + + +@app.route("/order-lookup", methods=["GET", "POST"]) +def order_lookup(): + order = None + if request.method == "POST": + order_number = request.form.get("order_number", "").strip().upper() + email = request.form.get("email", "").strip().lower() + order = Order.query.filter_by(order_number=order_number, email=email).first() + if order: + session["target_lookup_order"] = order.order_number + session.modified = True + flash("Demo order located.", "success") + return redirect(url_for("order_detail", order_number=order.order_number)) + flash("We could not match that synthetic order number and email.", "warning") + return render_template("order_lookup.html", order=order) + + +@app.route("/order/") +def order_detail(order_number: str): + order = Order.query.filter_by(order_number=order_number.upper()).first_or_404() + if not order_accessible(order): + flash("Use order lookup or sign in to view that order.", "warning") + return redirect(url_for("order_lookup")) + return render_template("order_detail.html", order=order) + + +@app.route("/_health") +def health(): + return jsonify( + { + "ok": True, + "site": SITE_SLUG, + "products": Product.query.count(), + "stores": Store.query.count(), + "orders": Order.query.count(), + } + ) + + +def initialize_database() -> None: + seed_exists = SEED_DB_PATH.exists() + runtime_exists = RUNTIME_DB_PATH.exists() + + if not runtime_exists and seed_exists: + shutil.copy2(SEED_DB_PATH, RUNTIME_DB_PATH) + runtime_exists = True + + # Avoid touching a freshly copied runtime DB. SQLite file metadata can + # change even on a no-op schema call, which breaks reset md5 identity. + if not runtime_exists: + db.create_all() + + from seed_data import ensure_seed_data + + ensure_seed_data(force=not seed_exists, runtime_db_path=RUNTIME_DB_PATH, seed_db_path=SEED_DB_PATH, image_root=IMAGE_DIR) + + +with app.app_context(): + initialize_database() + + +if __name__ == "__main__": + port = int(os.environ.get("PORT", SITE_PORT)) + app.run(host="0.0.0.0", port=port, debug=False, use_reloader=False, threaded=True) + diff --git a/sites/target/requirements.txt b/sites/target/requirements.txt new file mode 100644 index 00000000..241e03fa --- /dev/null +++ b/sites/target/requirements.txt @@ -0,0 +1,4 @@ +Flask +Flask-Login +Flask-SQLAlchemy + diff --git a/sites/target/seed_data.py b/sites/target/seed_data.py new file mode 100644 index 00000000..2d30eac4 --- /dev/null +++ b/sites/target/seed_data.py @@ -0,0 +1,1542 @@ +"""Deterministic seed data for the Target WebHarbor mirror.""" + +from __future__ import annotations + +import random +import shutil +from datetime import datetime, timedelta +from pathlib import Path + +from app import ( + BENCHMARK_PASSWORD, + Brand, + CartItem, + Category, + CompareItem, + Deal, + DeliveryOption, + Order, + OrderItem, + PaymentMock, + PickupSlot, + Product, + ProtectionPlan, + Review, + RewardAccount, + RewardActivity, + SearchLog, + Store, + StoreInventory, + SupportArticle, + SupportTicket, + User, + WishlistItem, + db, + dump_json, + slugify, +) + + +SEED_TIMESTAMP = datetime(2026, 3, 18, 10, 0, 0) +RNG = random.Random(20260604) +EXPECTED_COUNTS = { + "users": 4, + "categories": 12, + "brands": 25, + "products": 150, + "stores": 15, + "inventory": 500, + "reviews": 250, + "orders": 60, +} + +CATEGORY_COLORS = { + "laptops": ("#0b2242", "#2878ff", "#fff200"), + "tvs-home-theater": ("#10141d", "#006ce0", "#ffdc47"), + "tablets": ("#08264d", "#0d86ff", "#ffe563"), + "cameras": ("#151a21", "#2a65f7", "#f8cd32"), + "headphones": ("#091828", "#0b74ff", "#f6d74a"), + "gaming": ("#13181f", "#3a6cff", "#ffe44f"), + "smart-home": ("#0f2238", "#1d8cff", "#f3e051"), + "monitors": ("#111825", "#2f6ef8", "#ffe152"), + "storage-networking": ("#10233b", "#2276ff", "#ffe863"), + "appliances": ("#13233a", "#246fff", "#ffdb56"), + "phones-wearables": ("#0a223e", "#146eff", "#ffe35a"), + "printers-office": ("#15202d", "#2f79ff", "#ffe76c"), +} + + +BRANDS = [ + ("Apple", "#888f9c"), + ("Samsung", "#0b6dff"), + ("LG", "#b10659"), + ("Sony", "#1b1b1b"), + ("Dell", "#1273ea"), + ("HP", "#0096d6"), + ("Lenovo", "#e2231a"), + ("Asus", "#0050c8"), + ("Acer", "#6abf4b"), + ("Bose", "#4c5968"), + ("Canon", "#c51f1a"), + ("Nikon", "#d3a400"), + ("Nintendo", "#e60012"), + ("Microsoft", "#737373"), + ("JBL", "#ff6600"), + ("Dyson", "#8c3997"), + ("Belkin", "#3e7dd1"), + ("WD", "#1864ff"), + ("TCL", "#ff4d2d"), + ("Insignia", "#0046be"), + ("GoPro", "#0d6efd"), + ("Garmin", "#007cc3"), + ("Epson", "#1f4aa8"), + ("Brother", "#15284b"), + ("Logitech", "#00b8fc"), +] + + +CATEGORIES = [ + { + "slug": "laptops", + "name": "Tech & Office Laptops", + "section": "Electronics & Office", + "description": "Work-from-home laptops, student notebooks, and creator-ready computers with local demo specs.", + "hero_title": "Upgrade your desk with demo laptops, study essentials, and pickup-friendly tech.", + }, + { + "slug": "tvs-home-theater", + "name": "TVs & Entertainment", + "section": "Electronics", + "description": "4K TVs, streaming-ready displays, and living-room gear with pickup and delivery options.", + "hero_title": "Big screens, bold deal badges, and synthetic store stock that resets instantly.", + }, + { + "slug": "tablets", + "name": "Tablets & Reading", + "section": "Electronics", + "description": "Portable screens for note-taking, streaming, family travel, and reading.", + "hero_title": "Find the right size, storage tier, and family-friendly accessories in one local catalog.", + }, + { + "slug": "cameras", + "name": "Cameras & Content Creation", + "section": "Electronics", + "description": "Mirrorless cameras, action cams, and creator bundles with deep specs.", + "hero_title": "Local demo cameras with pickup-ready bundles and compare-friendly specs.", + }, + { + "slug": "headphones", + "name": "Audio & Headphones", + "section": "Tech", + "description": "Headphones, earbuds, and speakers with review-rich pages and pickup eligibility.", + "hero_title": "Tune into rich product details, reviews, and warranty choices.", + }, + { + "slug": "gaming", + "name": "Video Games & Gear", + "section": "Toys & Entertainment", + "description": "Consoles, controllers, displays, and accessories with pickup eligibility filters.", + "hero_title": "Search for consoles, compare gear, and check store pickup without real inventory.", + }, + { + "slug": "smart-home", + "name": "Smart Home", + "section": "Home", + "description": "Cameras, thermostats, hubs, and connected lighting for support-heavy tasks.", + "hero_title": "Find setup help, curbside pickup advice, and smart-home essentials in one place.", + }, + { + "slug": "monitors", + "name": "Monitors & Desk Setup", + "section": "Electronics & Office", + "description": "Gaming, office, and creator monitors with size, refresh rate, and panel filters.", + "hero_title": "Compare refresh rates, panel types, and store pickup at a glance.", + }, + { + "slug": "storage-networking", + "name": "Storage & Accessories", + "section": "Electronics", + "description": "Portable SSDs, routers, mesh kits, chargers, and tech accessories for spec-heavy tasks.", + "hero_title": "Local demo storage and accessory gear with sharp filters and bundle-like deals.", + }, + { + "slug": "appliances", + "name": "Kitchen & Home Appliances", + "section": "Home", + "description": "Coffee makers, air fryers, vacuums, and kitchen upgrades with support coverage options.", + "hero_title": "Synthetic home upgrades with realistic pricing, pickup options, and plan comparisons.", + }, + { + "slug": "phones-wearables", + "name": "Phones & Wearables", + "section": "Tech", + "description": "Phones, watches, and charging gear with delivery and pickup fulfillment demos.", + "hero_title": "Balance portability, storage, and support plans in a Target-style flow.", + }, + { + "slug": "printers-office", + "name": "Home Office", + "section": "Office", + "description": "Printers, label makers, and office helpers with ink, paper, and speed details.", + "hero_title": "Office setup products with clear specs, easy lookup, and local-only checkout flows.", + }, +] + + +STORE_DEFS = [ + ("seattle-ballard", "Seattle Ballard", "Seattle", "WA", "1448 NW Market St", "Fast curbside pickup, household essentials, and same-day order lanes."), + ("bellevue-square", "Bellevue Square", "Bellevue", "WA", "500 Bellevue Way NE", "Target Circle desk, easy pickups, and family shopping support."), + ("portland-pearl", "Portland Pearl", "Portland", "OR", "910 NW Lovejoy St", "Urban store with compact home, beauty, and kitchen demos."), + ("san-jose-santana", "San Jose Santana", "San Jose", "CA", "3245 Stevens Creek Blvd", "Tech-forward location with pickup counters and home-office focus."), + ("los-angeles-culver", "Los Angeles Culver", "Los Angeles", "CA", "10820 Jefferson Blvd", "Extended evening pickup and flexible delivery coverage."), + ("phoenix-tempe", "Phoenix Tempe", "Tempe", "AZ", "711 S Mill Ave", "Busy college-area store with toy, gaming, and dorm setup selections."), + ("denver-cherry-creek", "Denver Cherry Creek", "Denver", "CO", "201 Detroit St", "Desk setup, storage, and small-appliance inventory leader."), + ("austin-domain", "Austin Domain", "Austin", "TX", "11501 Century Oaks Terrace", "Fast shipping handoff and smart-home specialists."), + ("dallas-plano", "Dallas Plano", "Plano", "TX", "7200 Bishop Rd", "Large pickup zone with kitchen, cleaning, and baby essentials."), + ("chicago-river-north", "Chicago River North", "Chicago", "IL", "700 N Clark St", "Dense same-day delivery footprint and home-office stock."), + ("minneapolis-southdale", "Minneapolis Southdale", "Minneapolis", "MN", "165 Southdale Center", "Flagship-style store with broad household assortment."), + ("atlanta-buckhead", "Atlanta Buckhead", "Atlanta", "GA", "3050 Peachtree Rd NE", "Wearables, beauty, and checkout demo specialists."), + ("miami-dadeland", "Miami Dadeland", "Miami", "FL", "7535 N Kendall Dr", "High-volume pickup lockers and travel-ready essentials endcaps."), + ("boston-back-bay", "Boston Back Bay", "Boston", "MA", "799 Boylston St", "Compact city store with support appointment desks."), + ("new-york-union-square", "New York Union Square", "New York", "NY", "52 E 14th St", "Fast pickup, late hours, and city delivery coverage."), +] + + +SUPPORT_ARTICLES = [ + ("shipping-delivery-store-pickup", "Shipping, delivery, and store pickup overview", "Shipping & Pickup"), + ("same-day-delivery-eligibility", "How same-day delivery works in the demo mirror", "Shipping & Pickup"), + ("curbside-check-in", "Using curbside check-in for synthetic pickup orders", "Shipping & Pickup"), + ("pickup-id-requirements", "What to bring for demo store pickup", "Shipping & Pickup"), + ("tracking-synthetic-orders", "Tracking a synthetic order after checkout", "Orders & Tracking"), + ("order-lookup-by-number", "Finding an order by order number and email", "Orders & Tracking"), + ("gift-return-demo-policy", "Understanding the local demo return policy", "Returns & Exchanges"), + ("protection-plan-differences", "Comparing 2-year and 3-year protection plans", "Protection Plans"), + ("accidental-damage-coverage", "What accidental damage coverage includes", "Protection Plans"), + ("tv-wall-mount-measuring", "Measure your space before buying a TV", "Buying Guides"), + ("laptop-memory-storage-guide", "How to compare laptop memory and storage", "Buying Guides"), + ("camera-lens-kit-guide", "Understanding kit lenses and body-only cameras", "Buying Guides"), + ("headphone-fit-and-noise-control", "Finding the right fit for headphones and earbuds", "Buying Guides"), + ("router-mesh-vs-single-unit", "Router vs. mesh: choosing home networking gear", "Buying Guides"), + ("smart-home-setup-basics", "Getting started with local smart-home setups", "Smart Home"), + ("target-circle-benefits", "How Target Circle rewards and offers work in the demo mirror", "Rewards"), + ("target-circle-360-benefits", "Comparing Target Circle and Target Circle 360 benefits", "Rewards"), + ("same-day-essentials-delivery", "What qualifies for same-day essentials delivery", "Shipping & Pickup"), + ("appliance-installation-demo", "Installation notes for small appliance purchases", "Appliances"), + ("monitor-color-workflow", "Choosing a monitor for design and editing", "Computing"), + ("printer-paper-ink-basics", "Matching printers with the right paper and ink workflow", "Office"), + ("wearable-cellular-vs-gps", "GPS vs. cellular wearables explained", "Mobile"), + ("gaming-accessory-compatibility", "Checking gaming accessory compatibility", "Gaming"), + ("contact-support-options", "Ways to reach demo support in this mirror", "Customer Care"), +] + + +BENCHMARK_USERS = [ + ("alice.j@test.com", "Alice Jordan", "Seattle", "WA", "bellevue-square", "Target Circle 360"), + ("bob.c@test.com", "Bob Chen", "Austin", "TX", "austin-domain", "Target Circle"), + ("carol.d@test.com", "Carol Diaz", "Chicago", "IL", "chicago-river-north", "Target Circle"), + ("david.k@test.com", "David Kim", "Boston", "MA", "boston-back-bay", "Target Circle"), +] + + +PRODUCT_TARGETS = {item["slug"]: 13 for item in CATEGORIES} + + +def _counts_ok() -> bool: + checks = { + "users": User.query.count(), + "categories": Category.query.count(), + "brands": Brand.query.count(), + "products": Product.query.count(), + "stores": Store.query.count(), + "inventory": StoreInventory.query.count(), + "reviews": Review.query.count(), + "orders": Order.query.count(), + } + return all(checks[key] >= EXPECTED_COUNTS[key] for key in EXPECTED_COUNTS) + + +def _write_svg(path: Path, title: str, subtitle: str, palette: tuple[str, str, str], badge: str = "") -> None: + path.parent.mkdir(parents=True, exist_ok=True) + top, mid, accent = palette + badge_markup = "" + if badge: + badge_markup = ( + f'' + f'{badge}' + ) + svg = f""" + + + + + + + + + + +{badge_markup} +LOCAL BENCHMARK MIRROR +{title} +{subtitle} + + + + + + + + +Generated locally for WebHarbor review only +""" + path.write_text(svg, encoding="utf-8") + + +def _brand_lookup() -> dict[str, Brand]: + return {brand.slug: brand for brand in Brand.query.all()} + + +def _category_lookup() -> dict[str, Category]: + return {category.slug: category for category in Category.query.all()} + + +def _add_brand_records() -> None: + for name, accent in BRANDS: + db.session.add(Brand(name=name, slug=slugify(name), accent_color=accent)) + + +def _add_category_records(image_root: Path) -> None: + for category in CATEGORIES: + palette = CATEGORY_COLORS[category["slug"]] + relative_path = f"images/categories/{category['slug']}.svg" + _write_svg(image_root / "categories" / f"{category['slug']}.svg", category["name"], category["hero_title"], palette, "SHOP") + db.session.add( + Category( + name=category["name"], + slug=category["slug"], + section=category["section"], + description=category["description"], + hero_title=category["hero_title"], + image_path=relative_path, + ) + ) + + +def _add_store_records(image_root: Path) -> None: + amenity_sets = [ + ["Curbside pickup", "Household essentials zone", "Order lockers", "Beauty endcap"], + ["Curbside pickup", "Order lockers", "Target Circle desk", "Family essentials wall"], + ["Compact appliance wall", "Seasonal decor zone", "Curbside pickup", "Kitchen feature aisle"], + ["Home office desk", "Tech accessories wall", "Pickup lockers", "Curbside pickup"], + ["Living room inspiration bay", "Order lockers", "Curbside pickup", "Delivery desk"], + ["Toy feature aisle", "Pickup lockers", "Game night endcap", "Dorm essentials wall"], + ["Desk setup wall", "Printer ink shelf", "Order lockers", "Curbside pickup"], + ["Smart-home consultation", "Laptop setup desk", "Housewares feature aisle", "Curbside pickup"], + ["Appliance pickup lane", "Pantry restock wall", "Order lockers", "Protection plan desk"], + ["Same-day delivery zone", "Cleaning supplies aisle", "Curbside pickup", "Office picks wall"], + ["Home refresh bay", "Storage solutions wall", "Order lockers", "Curbside pickup"], + ["Wearables table", "Beauty desk", "Checkout fast lane", "Curbside pickup"], + ["Travel essentials endcap", "Pickup lockers", "Family tech wall", "Curbside pickup"], + ["City pickup window", "Appointment support desk", "Order lockers", "Mini home shop"], + ["Late pickup lane", "School and office wall", "Curbside pickup", "Kitchen basics aisle"], + ] + service_sets = [ + ["Same-day pickup window", "Household bundle guidance", "Mobile setup help"], + ["Accessory setup", "Target Circle help", "Express pickup"], + ["Kitchen bundle guidance", "Seasonal layout help", "Express pickup"], + ["Home office setup help", "Accessory matching", "Express pickup"], + ["TV placement guidance", "Delivery scheduling", "Accessory matching"], + ["Toy and game matching", "Pickup scheduling", "Express pickup"], + ["Router setup guidance", "Printer pairing help", "Express pickup"], + ["Smart-home starter consultation", "Laptop transfer basics", "Delivery scheduling"], + ["Small appliance guidance", "Rewards help", "Pickup scheduling"], + ["Printer and paper matching", "Fast lane pickup", "Rewards assistance"], + ["Room refresh consultation", "Delivery scheduling", "Storage matching"], + ["Wearable setup help", "Beauty and wellness guidance", "Fast lane pickup"], + ["Travel charger guidance", "Fast lane pickup", "Rewards assistance"], + ["Urban pickup scheduling", "Appointment help", "Accessory matching"], + ["Late-evening pickup", "School and office setup help", "Fast lane pickup"], + ] + for index, (slug, name, city, state, address, hero_copy) in enumerate(STORE_DEFS, start=1): + palette = CATEGORY_COLORS[list(CATEGORY_COLORS.keys())[index % len(CATEGORY_COLORS)]] + relative_path = f"images/stores/{slug}.svg" + _write_svg(image_root / "stores" / f"{slug}.svg", name, f"{city}, {state}", palette, "STORE") + db.session.add( + Store( + slug=slug, + name=name, + city=city, + state=state, + address=address, + phone=f"(555) 010-{index:04d}"[-14:], + hours_json=dump_json( + [ + "Mon-Sat: 10:00 AM - 9:00 PM", + "Sun: 11:00 AM - 7:00 PM", + "Pickup desk opens 30 minutes earlier for reserved orders.", + ] + ), + amenities_json=dump_json(amenity_sets[index - 1]), + services_json=dump_json(service_sets[index - 1]), + hero_copy=hero_copy, + image_path=relative_path, + ) + ) + + +def _make_spec_sections(*sections: tuple[str, list[tuple[str, str]]]) -> list[dict[str, object]]: + return [ + {"title": title, "items": [{"label": label, "value": value} for label, value in items]} + for title, items in sections + ] + + +def _product_payload( + sku_value: int, + name: str, + brand_slug: str, + category_slug: str, + price: float, + list_price: float, + rating: float, + review_count: int, + highlights: list[str], + specs: list[dict[str, object]], + tags: list[str], + description: str, + long_description: str, + featured: bool = False, + deal_badge: str = "", + pickup_eligible: bool = True, + delivery_eligible: bool = True, + stock_count: int = 25, +) -> dict[str, object]: + return { + "sku": f"TGT{sku_value}", + "slug": slugify(f"{name}-{sku_value}"), + "name": name, + "brand_slug": brand_slug, + "category_slug": category_slug, + "price": round(price, 2), + "list_price": round(list_price, 2), + "rating": round(rating, 1), + "review_count": review_count, + "highlights": highlights, + "specs": specs, + "tags": tags, + "description": description, + "long_description": long_description, + "featured": featured, + "deal_badge": deal_badge, + "pickup_eligible": pickup_eligible, + "delivery_eligible": delivery_eligible, + "stock_count": stock_count, + } + + +def _manual_products() -> list[dict[str, object]]: + return [ + _product_payload( + 100001, + 'LG Aurora 65" QNED 4K Smart TV', + "lg", + "tvs-home-theater", + 1299.99, + 1499.99, + 4.8, + 642, + ["120Hz panel for sports and gaming", "Filmmaker mode and four HDMI 2.1 ports", "Eligible for same-day delivery in major cities"], + _make_spec_sections( + ("Display", [("Screen Size", '65"'), ("Panel Type", "QNED"), ("Resolution", "4K UHD"), ("Refresh Rate", "120Hz")]), + ("Connectivity", [("HDMI Inputs", "4"), ("Voice Assistant", "Built-in"), ("Wi-Fi", "Wi-Fi 6")]), + ), + ["tv", "65 inch", "4k", "qned", "pickup", "delivery"], + "A bright living-room TV with high refresh and a standout deal badge.", + "This synthetic flagship TV anchors the local demo home theater selection with store pickup eligibility, delivery options, and clear protection plan choices.", + featured=True, + deal_badge="Top Deal", + stock_count=18, + ), + _product_payload( + 100002, + 'Samsung FrameView 55" QLED Art TV', + "samsung", + "tvs-home-theater", + 999.99, + 1199.99, + 4.7, + 481, + ["Art mode gallery rotation", "Slim wall-ready design", "Store pickup available in select metro stores"], + _make_spec_sections( + ("Display", [("Screen Size", '55"'), ("Panel Type", "QLED"), ("Resolution", "4K UHD"), ("Refresh Rate", "120Hz")]), + ("Smart Features", [("Art Mode", "Included"), ("HDR", "HDR10+"), ("Voice Control", "Bixby / Alexa")]), + ), + ["tv", "art mode", "qled", "55 inch", "pickup"], + "A gallery-style TV for shoppers comparing style and specs.", + "Designed for lifestyle-oriented browsing tasks, this synthetic TV offers a polished product page, vivid store pickup labels, and warranty comparisons.", + featured=True, + deal_badge="Member Exclusive", + stock_count=14, + ), + _product_payload( + 100003, + "Dell Horizon 14 OLED Laptop", + "dell", + "laptops", + 1099.99, + 1299.99, + 4.7, + 392, + ["14-inch OLED touch display", "Intel Core Ultra 7 with 16GB memory", "1TB SSD and Wi-Fi 6E"], + _make_spec_sections( + ("Performance", [("Processor", "Intel Core Ultra 7"), ("Memory", "16GB"), ("Storage", "1TB SSD")]), + ("Display", [("Screen Size", '14"'), ("Panel", "OLED Touch"), ("Battery Life", "15 hours")]), + ), + ["laptop", "oled", "1tb", "16gb", "touchscreen"], + "A premium everyday laptop seeded for compare, search, and checkout tasks.", + "The Dell Horizon 14 OLED Laptop is a local anchor product with memorable specs, broad inventory coverage, and strong review signals for deterministic tasks.", + featured=True, + deal_badge="Save $200", + stock_count=22, + ), + _product_payload( + 100004, + "Apple MacBook Air 13 Sky M3", + "apple", + "laptops", + 1249.99, + 1399.99, + 4.9, + 755, + ["Fanless M3 design", "13.6-inch Liquid display", "18-hour battery estimate"], + _make_spec_sections( + ("Performance", [("Processor", "Apple M3"), ("Memory", "16GB Unified"), ("Storage", "512GB SSD")]), + ("Design", [("Screen Size", '13.6"'), ("Weight", "2.7 lb"), ("Color", "Sky")]), + ), + ["macbook", "apple", "m3", "lightweight", "laptop"], + "A lightweight benchmark favorite for search-and-compare tasks.", + "This synthetic MacBook-style laptop is tuned for search, compare, and protection plan tasks in the Target mirror.", + featured=True, + deal_badge="Weekend Savings", + stock_count=19, + ), + _product_payload( + 100005, + "HP EnvyFlex 16 Creator Laptop", + "hp", + "laptops", + 1399.99, + 1599.99, + 4.6, + 268, + ["16-inch color-accurate panel", "Discrete creator graphics", "Pickup today in several stores"], + _make_spec_sections( + ("Performance", [("Processor", "Intel Core i9"), ("Memory", "32GB"), ("Storage", "1TB SSD")]), + ("Display", [("Screen Size", '16"'), ("Panel", "WQXGA"), ("GPU", "RTX 4060")]), + ), + ["creator laptop", "32gb", "rtx", "pickup"], + "Built for editing workflows and task-friendly compare pages.", + "A synthetic creator laptop with distinctive specs for price, stock, and review-based reasoning tasks.", + featured=True, + deal_badge="Open Box Favorite", + stock_count=9, + ), + _product_payload( + 100006, + "Sony QuietWave XM6 Wireless Headphones", + "sony", + "headphones", + 379.99, + 429.99, + 4.8, + 544, + ["Adaptive noise canceling", "40-hour battery", "Two-device multipoint pairing"], + _make_spec_sections( + ("Audio", [("Style", "Over-ear"), ("Battery Life", "40 hours"), ("Noise Canceling", "Adaptive")]), + ("Connectivity", [("Bluetooth", "5.4"), ("Voice Pickup", "Dual beamforming"), ("Wired Mode", "3.5mm")]), + ), + ["headphones", "noise canceling", "wireless", "sony"], + "A standout audio product with strong support and review signals.", + "This synthetic flagship headphone model supports tasks around reviews, ratings, delivery, and protection plan comparison.", + featured=True, + deal_badge="Top Rated", + stock_count=27, + ), + _product_payload( + 100007, + "Bose StudioPulse Ultra Earbuds", + "bose", + "headphones", + 249.99, + 299.99, + 4.6, + 318, + ["Compact ANC earbuds", "Wireless charging case", "Clear voice pickup for calls"], + _make_spec_sections( + ("Audio", [("Style", "In-ear"), ("Battery Life", "8 hours + case"), ("Noise Canceling", "Hybrid ANC")]), + ("Comfort", [("Water Resistance", "IPX4"), ("Tips Included", "4 sizes"), ("Wireless Charging", "Yes")]), + ), + ["earbuds", "wireless charging", "anc", "bose"], + "Compact premium earbuds with memorable pricing for budget tasks.", + "This demo earbud listing complements the flagship over-ear model and gives the support section richer search coverage.", + featured=True, + deal_badge="Bundle Bonus", + stock_count=31, + ), + _product_payload( + 100008, + "Canon Vista R10 Mirrorless Camera Kit", + "canon", + "cameras", + 999.99, + 1099.99, + 4.7, + 205, + ["24.2MP APS-C sensor", "18-45mm starter lens included", "Vertical video guide mode"], + _make_spec_sections( + ("Imaging", [("Sensor", "24.2MP APS-C"), ("Lens Included", "18-45mm"), ("Burst Rate", "15 fps")]), + ("Video", [("4K", "30p oversampled"), ("Stabilization", "Digital + lens"), ("Mic Input", "Yes")]), + ), + ["camera", "mirrorless", "canon", "aps-c", "4k"], + "A seeded mirrorless kit tuned for browse, compare, and pickup tasks.", + "This local mirrorless kit is ideal for multi-page reasoning involving specs, store availability, and protection plans.", + featured=True, + deal_badge="Pickup Today", + stock_count=11, + ), + _product_payload( + 100009, + "Nikon TrailShot Z5 Body", + "nikon", + "cameras", + 1199.99, + 1399.99, + 4.6, + 164, + ["Full-frame 24MP body", "Weather-sealed design", "IBIS stabilization"], + _make_spec_sections( + ("Imaging", [("Sensor", "24MP Full Frame"), ("Body Type", "Mirrorless"), ("Stabilization", "5-axis IBIS")]), + ("Workflow", [("Card Slots", "Dual SD"), ("Viewfinder", "OLED EVF"), ("USB Power", "Supported")]), + ), + ["camera", "nikon", "full frame", "body only"], + "A body-only camera for deliberate spec comparison tasks.", + "This seeded camera gives the site a second photography anchor with clear differentiation from kit-based mirrorless options.", + featured=True, + deal_badge="Creator Pick", + stock_count=7, + ), + _product_payload( + 100010, + "Nintendo Switch Neon OLED Bundle", + "nintendo", + "gaming", + 399.99, + 449.99, + 4.8, + 701, + ["7-inch OLED handheld screen", "Dock included", "Bonus carrying sleeve in bundle"], + _make_spec_sections( + ("Gaming", [("Screen", '7" OLED'), ("Storage", "64GB"), ("Mode", "Handheld / Docked")]), + ("Bundle", [("Included", "Carrying sleeve"), ("Controllers", "Neon Joy-Con"), ("Online Trial", "3 months demo")]), + ), + ["switch", "oled", "bundle", "gaming console"], + "A bundle listing with clear value signals for cart and deal tasks.", + "This product is used for tasks involving bundles, store pickup, and deal detection in gaming search results.", + featured=True, + deal_badge="Bundle Deal", + stock_count=16, + ), + _product_payload( + 100011, + "Microsoft Xbox Carbon Wireless Controller", + "microsoft", + "gaming", + 59.99, + 69.99, + 4.7, + 852, + ["Textured triggers and hybrid D-pad", "Bluetooth + Xbox wireless", "Works with console and PC"], + _make_spec_sections( + ("Controls", [("Connection", "Bluetooth / Xbox Wireless"), ("Battery", "AA or rechargeable pack"), ("Color", "Carbon")]), + ("Compatibility", [("Console", "Xbox Series X|S"), ("PC", "Windows 11"), ("Mobile", "Supported")]), + ), + ["controller", "xbox", "gaming accessory", "wireless"], + "A highly reviewed accessory seeded for price and filter tasks.", + "This demo controller offers a reliable anchor for search, comparison, and wishlist tasks.", + featured=True, + deal_badge="Daily Pick", + stock_count=48, + ), + _product_payload( + 100012, + "Samsung Tab Slate 11 Plus", + "samsung", + "tablets", + 649.99, + 749.99, + 4.5, + 223, + ["11-inch 120Hz display", "256GB storage", "Pen-ready productivity mode"], + _make_spec_sections( + ("Tablet", [("Screen Size", '11"'), ("Storage", "256GB"), ("Battery", "14 hours")]), + ("Productivity", [("Pen Support", "Included"), ("Keyboard Ready", "Yes"), ("Cellular", "Wi-Fi")]), + ), + ["tablet", "11 inch", "samsung", "pen support"], + "A mid-premium tablet with clear productivity positioning.", + "Great for support article tasks about delivery, tablets, and accessory compatibility.", + featured=True, + deal_badge="Online Only", + stock_count=15, + ), + _product_payload( + 100013, + "Logitech PowerGrid Mechanical Keyboard", + "logitech", + "gaming", + 129.99, + 149.99, + 4.6, + 287, + ["Wireless tri-mode connection", "Per-key lighting", "Low-profile tactile switches"], + _make_spec_sections( + ("Keyboard", [("Connection", "Bluetooth / USB / 2.4GHz"), ("Switch Type", "Tactile"), ("Battery", "30 hours with RGB")]), + ("Build", [("Layout", "TKL"), ("Weight", "1.9 lb"), ("Palm Rest", "Optional")]), + ), + ["keyboard", "mechanical", "gaming", "wireless"], + "A gaming keyboard tuned for cart, compare, and support coverage tasks.", + "This seeded keyboard gives the gaming category a high-intent accessory with memorable specifications.", + featured=True, + deal_badge="Member Price", + stock_count=24, + ), + _product_payload( + 100014, + 'TCL CinemaCore 75" Mini-LED TV', + "tcl", + "tvs-home-theater", + 1499.99, + 1799.99, + 4.5, + 198, + ["75-inch mini-LED panel", "240 motion rate", "Game accelerator mode"], + _make_spec_sections( + ("Display", [("Screen Size", '75"'), ("Panel Type", "Mini-LED"), ("Resolution", "4K UHD"), ("Refresh Rate", "144Hz VRR")]), + ("Gaming", [("VRR", "Yes"), ("ALLM", "Yes"), ("Audio", "2.1 channel speakers")]), + ), + ["tv", "75 inch", "mini-led", "gaming tv"], + "A large-format TV seeded for high-price comparison tasks.", + "This TV gives the home theater catalog a second large-screen anchor with a different panel story and price tier.", + featured=True, + deal_badge="Big Screen Event", + stock_count=8, + ), + _product_payload( + 100015, + "Acer NitroMesh 27 Gaming Monitor", + "acer", + "monitors", + 329.99, + 399.99, + 4.7, + 376, + ["27-inch QHD IPS panel", "180Hz refresh", "Height-adjustable stand"], + _make_spec_sections( + ("Display", [("Size", '27"'), ("Resolution", "2560 x 1440"), ("Refresh Rate", "180Hz"), ("Panel", "IPS")]), + ("Gaming", [("Response Time", "1ms"), ("Sync", "FreeSync Premium"), ("Inputs", "HDMI x2 / DP")]), + ), + ["monitor", "gaming", "27 inch", "qhd", "180hz"], + "A memorable gaming display with strong compare-page differentiation.", + "This seeded monitor helps with filter-based tasks for refresh rate, price, and pickup availability.", + featured=True, + deal_badge="Hot Offer", + stock_count=17, + ), + _product_payload( + 100016, + "Apple Watch Tide 44mm GPS", + "apple", + "phones-wearables", + 379.99, + 429.99, + 4.8, + 631, + ["44mm aluminum case", "Sleep and activity tracking", "Fast charging"], + _make_spec_sections( + ("Wearable", [("Case Size", "44mm"), ("Connectivity", "GPS"), ("Battery", "18 hours")]), + ("Health", [("Heart Rate", "Yes"), ("Water Resistance", "50m"), ("Crash Detection", "Included")]), + ), + ["watch", "apple", "gps", "wearable"], + "A recognizable wearable for rewards and support tasks.", + "This watch offers a clean path for store pickup, delivery options, and support article tasks around wearable connectivity.", + featured=True, + deal_badge="Popular Pickup", + stock_count=21, + ), + _product_payload( + 100017, + "Dyson PureBreeze Cool Tower", + "dyson", + "appliances", + 499.99, + 549.99, + 4.4, + 142, + ["Purifies and cools", "Remote with night mode", "Oscillation and auto mode"], + _make_spec_sections( + ("Air Care", [("Modes", "Cool / Purify"), ("Oscillation", "350 degrees"), ("Filter", "HEPA + carbon")]), + ("Convenience", [("Noise", "Quiet mode"), ("Control", "Remote"), ("Footprint", "Slim tower")]), + ), + ["air purifier", "tower fan", "dyson"], + "An appliance seed product designed for delivery and plan-comparison tasks.", + "This demo appliance introduces a higher-ticket home item with thoughtful delivery, support, and protection plan angles.", + featured=True, + deal_badge="Healthy Home", + stock_count=6, + ), + _product_payload( + 100018, + "WD Vault 2TB Portable SSD", + "wd", + "storage-networking", + 169.99, + 199.99, + 4.7, + 412, + ["USB-C portable SSD", "2TB capacity", "Drop-resistant shell"], + _make_spec_sections( + ("Storage", [("Capacity", "2TB"), ("Connection", "USB-C 10Gbps"), ("Read Speed", "1,050 MB/s")]), + ("Travel", [("Weight", "0.19 lb"), ("Resistance", "Drop resistant"), ("Cable Included", "USB-C / USB-A")]), + ), + ["ssd", "2tb", "portable", "wd"], + "A benchmark-friendly storage product with clear capacity and speed filters.", + "This seeded portable SSD supports tasks around cheapest eligible products, deals, and office/creator shopping flows.", + featured=True, + deal_badge="Fast Storage", + stock_count=33, + ), + _product_payload( + 100019, + "Belkin Boost 3-in-1 Mag Stand", + "belkin", + "phones-wearables", + 129.99, + 149.99, + 4.5, + 217, + ["Phone, watch, and earbud charging", "Weighted desk base", "Travel-friendly cord wrap"], + _make_spec_sections( + ("Charging", [("Outputs", "3 devices"), ("Phone Alignment", "Magnetic"), ("Cable", "USB-C included")]), + ("Design", [("Finish", "Soft-touch"), ("Travel", "Cord wrap"), ("Footprint", "Compact stand")]), + ), + ["charger", "belkin", "magnetic", "3-in-1"], + "A small accessory with high-intent shopper signals and plan upsell space.", + "This charging stand gives the mobile category a compact accessory for search, cart, and protection plan tasks.", + featured=True, + deal_badge="Desk Setup Pick", + stock_count=26, + ), + _product_payload( + 100020, + "Insignia Dual Brew 12-Cup Coffee Station", + "insignia", + "appliances", + 89.99, + 109.99, + 4.3, + 188, + ["Brew pot or single cup", "Programmable timer", "Removable water reservoir"], + _make_spec_sections( + ("Brewing", [("Capacity", "12 cups"), ("Single Serve", "Yes"), ("Timer", "24-hour programmable")]), + ("Maintenance", [("Reservoir", "Removable"), ("Carafe", "Glass"), ("Footprint", "Counter friendly")]), + ), + ["coffee maker", "dual brew", "insignia"], + "An accessible appliance for deal, support, and checkout tasks.", + "This synthetic coffee station rounds out the appliance selection with a lower price point and strong support article relevance.", + featured=True, + deal_badge="Kitchen Refresh", + stock_count=28, + ), + ] + + +def _generic_products(start_sku: int) -> list[dict[str, object]]: + payloads = _manual_products() + counts = {slug: 0 for slug in PRODUCT_TARGETS} + brand_display = {slugify(name): name for name, _ in BRANDS} + for payload in payloads: + counts[payload["category_slug"]] += 1 + + sku_value = start_sku + brand_sets = { + "laptops": ["dell", "hp", "lenovo", "asus", "acer", "apple", "microsoft", "samsung", "lg"], + "tvs-home-theater": ["samsung", "lg", "sony", "tcl", "insignia", "epson"], + "tablets": ["apple", "samsung", "lenovo", "microsoft", "acer"], + "cameras": ["canon", "nikon", "sony", "gopro", "samsung"], + "headphones": ["sony", "bose", "jbl", "apple", "logitech", "samsung"], + "gaming": ["nintendo", "microsoft", "sony", "logitech", "acer", "asus"], + "smart-home": ["samsung", "belkin", "insignia", "apple", "sony"], + "monitors": ["acer", "lg", "dell", "asus", "samsung", "hp"], + "storage-networking": ["wd", "belkin", "logitech", "samsung", "epson", "brother"], + "appliances": ["dyson", "insignia", "lg", "samsung", "hp", "belkin"], + "phones-wearables": ["apple", "samsung", "belkin", "garmin", "sony"], + "printers-office": ["hp", "canon", "epson", "brother", "dell"], + } + series_names = { + "laptops": ["FlexBook", "CreatorEdge", "CampusPro", "PixelLine", "TravelLite", "StudioNorth"], + "tvs-home-theater": ["VisionCore", "BrightRoom", "MovieNight", "StudioScreen", "ArenaView"], + "tablets": ["NoteSlate", "CampusTab", "SketchPad", "ViewMate"], + "cameras": ["TravelSnap", "SceneCraft", "VlogReady", "ActionTrail"], + "headphones": ["SoundArc", "QuietLine", "PulseOne", "CityTune"], + "gaming": ["ArenaPack", "PlayCore", "Respawn", "LevelShift"], + "smart-home": ["HomeGrid", "GlowDock", "SafeView", "ComfortHub"], + "monitors": ["PixelDeck", "StudioCanvas", "SwiftFrame", "ArenaPanel"], + "storage-networking": ["VaultLine", "MeshPort", "StreamNode", "SpeedStore"], + "appliances": ["KitchenFlow", "FreshAir", "QuickHeat", "BlendMate"], + "phones-wearables": ["PocketLink", "TrackPulse", "MagCharge", "MotionFit"], + "printers-office": ["OfficeJet", "PaperFlow", "ScanSmart", "LabelShift"], + } + spec_labels = { + "laptops": [ + ("Processor", ["Intel Core i5", "Intel Core i7", "AMD Ryzen 7", "Intel Core Ultra 7"]), + ("Memory", ["16GB", "16GB", "24GB", "32GB"]), + ("Storage", ["512GB SSD", "1TB SSD", "1TB SSD", "2TB SSD"]), + ("Screen Size", ['13.3"', '14"', '15.6"', '16"']), + ], + "tvs-home-theater": [ + ("Screen Size", ['50"', '55"', '65"', '75"']), + ("Panel Type", ["LED", "QLED", "OLED", "Mini-LED"]), + ("Resolution", ["4K UHD", "4K UHD", "4K UHD", "8K Demo"]), + ("Refresh Rate", ["60Hz", "120Hz", "120Hz", "144Hz"]), + ], + "tablets": [ + ("Screen Size", ['10.9"', '11"', '12.4"', '12.9"']), + ("Storage", ["128GB", "256GB", "256GB", "512GB"]), + ("Battery", ["12 hours", "13 hours", "14 hours", "15 hours"]), + ("Connectivity", ["Wi-Fi", "Wi-Fi", "Wi-Fi + 5G", "Wi-Fi + 5G"]), + ], + "cameras": [ + ("Sensor", ["24MP APS-C", "32MP APS-C", "24MP Full Frame", "5.3K Action Sensor"]), + ("Lens Included", ["Body Only", "18-45mm Kit", "16-50mm Kit", "Zoom Module"]), + ("Video", ["4K30", "4K60", "6K Open Gate", "5.3K60"]), + ("Stabilization", ["Digital", "Lens + Digital", "5-axis IBIS", "HyperSmooth"]), + ], + "headphones": [ + ("Style", ["Over-ear", "On-ear", "In-ear", "Open-ear"]), + ("Battery Life", ["8 hours", "20 hours", "30 hours", "40 hours"]), + ("Noise Canceling", ["Passive", "Hybrid ANC", "Adaptive ANC", "Adaptive ANC"]), + ("Connection", ["Bluetooth 5.3", "Bluetooth 5.4", "Bluetooth + USB-C", "Bluetooth"]), + ], + "gaming": [ + ("Platform", ["Xbox / PC", "Switch", "PlayStation / PC", "Universal"]), + ("Connection", ["Wireless", "Wireless", "USB-C", "Bluetooth / USB"]), + ("Battery", ["Rechargeable", "Rechargeable", "AA or pack", "No battery"]), + ("Feature", ["RGB", "Motion control", "Hall effect sticks", "Low latency mode"]), + ], + "smart-home": [ + ("Category", ["Thermostat", "Security Cam", "Smart Speaker", "Lighting Hub"]), + ("Connectivity", ["Wi-Fi", "Wi-Fi 6", "Matter", "Thread"]), + ("Control", ["Voice + app", "App", "Voice + touch", "App + automation"]), + ("Power", ["Battery", "Plug-in", "Battery or wired", "USB-C"]), + ], + "monitors": [ + ("Size", ['24"', '27"', '32"', '34"']), + ("Resolution", ["1080p", "QHD", "4K UHD", "UWQHD"]), + ("Refresh Rate", ["75Hz", "144Hz", "165Hz", "240Hz"]), + ("Panel", ["IPS", "VA", "OLED", "IPS Black"]), + ], + "storage-networking": [ + ("Type", ["Portable SSD", "Mesh Router", "Memory Card", "NAS Drive"]), + ("Capacity", ["1TB", "2TB", "512GB", "4TB"]), + ("Connection", ["USB-C", "Wi-Fi 6", "UHS-II", "2.5GbE"]), + ("Speed", ["1,050 MB/s", "3.6 Gbps", "300 MB/s", "5400 RPM"]), + ], + "appliances": [ + ("Category", ["Air Fryer", "Coffee Maker", "Blender", "Air Purifier"]), + ("Capacity", ["4 qt", "12 cups", "68 oz", "350 sq ft"]), + ("Power", ["1500W", "1200W", "1100W", "Quiet mode"]), + ("Controls", ["Digital", "Programmable", "Preset programs", "Remote"]), + ], + "phones-wearables": [ + ("Type", ["Phone", "Watch", "Charger", "Tracker"]), + ("Storage", ["128GB", "256GB", "N/A", "N/A"]), + ("Battery", ["All-day", "18 hours", "Fast charge", "7 days"]), + ("Connectivity", ["5G", "GPS", "Magnetic", "Bluetooth"]), + ], + "printers-office": [ + ("Type", ["All-in-One", "Photo Printer", "Label Maker", "Document Scanner"]), + ("Speed", ["11 ppm", "22 ppm", "N/A", "40 ppm"]), + ("Connection", ["Wi-Fi", "Wi-Fi + USB", "Bluetooth", "USB-C"]), + ("Media", ["Plain + glossy", "Photo paper", "Thermal label", "ADF 50 sheets"]), + ], + } + + descriptions = { + "laptops": "A local demo laptop with clear specs, realistic pricing, and checkout-safe inventory.", + "tvs-home-theater": "A synthetic TV or theater product with strong deal, review, and delivery metadata.", + "tablets": "A tablet-oriented product seeded for filter, compare, and accessory-friendly tasks.", + "cameras": "A camera listing with pickup-ready stock and structured specs for search and compare tasks.", + "headphones": "An audio item designed for ratings, delivery, and protection plan reasoning.", + "gaming": "A gaming-focused product with memorable keywords, compare value, and pickup eligibility.", + "smart-home": "A connected-home product that pairs naturally with support article workflows.", + "monitors": "A display-focused listing with size, panel, and refresh information front and center.", + "storage-networking": "A storage or networking product with clean capacity and speed comparisons.", + "appliances": "A small appliance listing with support coverage, pickup options, and warranty hooks.", + "phones-wearables": "A mobile or wearable product with accessory, support, and checkout-friendly metadata.", + "printers-office": "An office product with crisp specs and support ties for shipping and setup questions.", + } + + while any(counts[slug] < PRODUCT_TARGETS[slug] for slug in PRODUCT_TARGETS): + for category_slug, target in PRODUCT_TARGETS.items(): + if counts[category_slug] >= target: + continue + index = counts[category_slug] + brand_slug = brand_sets[category_slug][index % len(brand_sets[category_slug])] + spec_source = spec_labels[category_slug] + values = [choices[index % len(choices)] for _, choices in spec_source] + series = series_names[category_slug][index % len(series_names[category_slug])] + name = f"{brand_display[brand_slug]} {series} {index + 1}" + if category_slug == "laptops": + name += " Laptop" + elif category_slug == "tvs-home-theater": + name += " TV" + elif category_slug == "monitors": + name += " Monitor" + elif category_slug == "headphones": + name += " Headphones" + elif category_slug == "cameras": + name += " Camera" + elif category_slug == "tablets": + name += " Tablet" + elif category_slug == "storage-networking": + name += " Kit" + elif category_slug == "printers-office": + name += " System" + + price = 79.99 + (index * 57) + (list(PRODUCT_TARGETS).index(category_slug) * 38) + if category_slug in {"laptops", "tvs-home-theater", "cameras"}: + price += 380 + if category_slug == "phones-wearables": + price += 120 + list_price = price + 40 + (index % 4) * 25 + rating = 4.1 + ((index + len(category_slug)) % 7) * 0.1 + review_count = 60 + index * 19 + len(category_slug) + specs = _make_spec_sections( + ("Highlights", list(zip([label for label, _ in spec_source], values))), + ("Why it stands out", [("Pickup", "Eligible" if index % 3 != 0 else "Ship to home"), ("Delivery", "2-day demo" if index % 4 != 0 else "Same-day demo"), ("Protection", "2 and 3-year plans")]), + ) + payloads.append( + _product_payload( + sku_value, + name, + brand_slug, + category_slug, + price, + list_price, + rating, + review_count, + [ + f"{values[0]} tuned for {category_slug.replace('-', ' ')} shoppers", + "Synthetic stock and pickup labels for benchmark reliability", + "Protection plans and support articles linked from the detail page", + ], + specs, + [category_slug, brand_slug, series.lower(), values[0].lower()], + descriptions[category_slug], + descriptions[category_slug] + " The compare page uses the same specs so multi-step tasks stay grounded.", + featured=index < 2, + deal_badge="Deal of the Day" if index % 5 == 0 else "", + pickup_eligible=index % 3 != 0, + delivery_eligible=index % 4 != 0, + stock_count=8 + ((index * 3) % 35), + ) + ) + counts[category_slug] += 1 + sku_value += 1 + return payloads + + +def _ensure_generated_assets(image_root: Path) -> None: + for category in Category.query.all(): + _write_svg( + image_root / "categories" / f"{category.slug}.svg", + category.name, + category.hero_title, + CATEGORY_COLORS[category.slug], + "SHOP", + ) + for store in Store.query.all(): + palette = CATEGORY_COLORS[list(CATEGORY_COLORS.keys())[store.id % len(CATEGORY_COLORS)]] + _write_svg( + image_root / "stores" / f"{store.slug}.svg", + store.name, + f"{store.city}, {store.state}", + palette, + "STORE", + ) + for product in Product.query.all(): + _write_svg( + image_root / "products" / f"{product.sku}.svg", + product.name, + product.brand.name, + CATEGORY_COLORS[product.category.slug], + product.deal_badge or product.category.name, + ) + _attach_seed_images(image_root) + + +def _persist_products(image_root: Path) -> list[Product]: + brand_lookup = _brand_lookup() + category_lookup = _category_lookup() + products: list[Product] = [] + for payload in _generic_products(100021): + brand = brand_lookup[payload["brand_slug"]] + category = category_lookup[payload["category_slug"]] + palette = CATEGORY_COLORS[category.slug] + relative_path = f"images/products/{payload['sku']}.svg" + _write_svg(image_root / "products" / f"{payload['sku']}.svg", payload["name"], brand.name, palette, payload["deal_badge"] or category.name) + product = Product( + sku=payload["sku"], + slug=payload["slug"], + name=payload["name"], + short_description=payload["description"], + long_description=payload["long_description"], + price=payload["price"], + list_price=payload["list_price"], + rating=payload["rating"], + review_count=payload["review_count"], + availability_status="In stock" if payload["stock_count"] > 6 else "Limited stock", + pickup_eligible=payload["pickup_eligible"], + delivery_eligible=payload["delivery_eligible"], + featured=payload["featured"], + deal_badge=payload["deal_badge"], + image_path=relative_path, + highlights_json=dump_json(payload["highlights"]), + specs_json=dump_json(payload["specs"]), + tags_json=dump_json(payload["tags"]), + search_keywords=" ".join(payload["tags"] + [brand.name.lower(), category.name.lower()]), + stock_count=payload["stock_count"], + category_id=category.id, + brand_id=brand.id, + ) + db.session.add(product) + products.append(product) + db.session.flush() + return products + + +def _add_delivery_options() -> None: + options = [ + ("standard-shipping", "Standard delivery", "Arrives in 3-5 business days", 0.0, "Arrives by Thu, Mar 26"), + ("expedited-shipping", "Expedited delivery", "Faster delivery for urgent orders", 14.99, "Arrives by Tue, Mar 24"), + ("scheduled-delivery", "Scheduled delivery", "Choose a preferred delivery day for large items", 24.99, "Select a 2-hour delivery window"), + ] + for slug, title, description, fee, eta in options: + db.session.add(DeliveryOption(slug=slug, title=title, description=description, fee=fee, eta_label=eta)) + + +def _add_pickup_slots() -> None: + for store in Store.query.all(): + for offset, window in enumerate(["10:00 AM - 12:00 PM", "1:00 PM - 3:00 PM", "5:00 PM - 7:00 PM"]): + db.session.add( + PickupSlot( + store_id=store.id, + slot_code=f"{store.slug}-{offset}", + day_label=(SEED_TIMESTAMP + timedelta(days=offset + 1)).strftime("%a, %b %d"), + time_window=window, + available_capacity=12 - offset * 2, + ) + ) + + +def _add_store_inventory(products: list[Product]) -> None: + stores = Store.query.order_by(Store.id.asc()).all() + for product in products: + for store in stores: + marker = (product.id * 7 + store.id * 3) % 5 + if marker == 0 and product.id % 4 != 0: + continue + quantity = 2 + ((product.id + store.id) % 16) + pickup_window = "Ready in 1 hour" if quantity > 10 else "Ready today" + db.session.add( + StoreInventory( + store_id=store.id, + product_id=product.id, + quantity=quantity, + pickup_window=pickup_window, + aisle=f"{chr(65 + (store.id % 5))}-{(product.id % 12) + 1}", + ) + ) + + +def _add_reviews(products: list[Product]) -> None: + reviewer_names = ["Maya", "Jordan", "Chris", "Taylor", "Sam", "Riley", "Alex", "Morgan"] + review_titles = [ + "Worth the upgrade", + "Easy local pickup", + "Great value in the demo catalog", + "Specs matched what I needed", + "Would recommend after comparing a few options", + "Support article made setup simple", + ] + for product in products: + review_total = 3 if product.featured else 2 + for index in range(review_total): + rating = min(5, max(3, int(round(product.rating + (index % 2) - 0.5)))) + db.session.add( + Review( + product_id=product.id, + author_name=reviewer_names[(product.id + index) % len(reviewer_names)], + title=review_titles[(product.id + index) % len(review_titles)], + body=( + f"This synthetic review mentions {product.name}, store pickup, and the local support flow. " + f"It is designed to ground review and rating tasks for {product.category.name.lower()}." + ), + rating=rating, + verified=index % 2 == 0, + created_at=SEED_TIMESTAMP - timedelta(days=(product.id + index) % 45), + ) + ) + + +def _add_protection_plans(products: list[Product]) -> None: + for product in products: + price = round(max(19.99, product.price * 0.11), 2) + db.session.add( + ProtectionPlan( + product_id=product.id, + name=f"2-Year Protection for {product.name}", + years=2, + price=price, + coverage_summary="Covers power issues, mechanical failure, and support routing.", + accidental=False, + priority_support=True, + ) + ) + db.session.add( + ProtectionPlan( + product_id=product.id, + name=f"3-Year Accidental Plan for {product.name}", + years=3, + price=round(price * 1.45, 2), + coverage_summary="Adds accidental handling coverage and priority replacement review.", + accidental=True, + priority_support=True, + ) + ) + + +def _add_support_articles() -> None: + article_bodies = { + "shipping-delivery-store-pickup": "Standard demo delivery arrives in 3-5 business days. Same-day delivery appears only in selected metro areas. Store pickup orders show a pickup window on the order detail page.", + "same-day-delivery-eligibility": "Same-day delivery is available only for items marked as delivery eligible and only in stores covering that synthetic ZIP area. Large items may fall back to scheduled delivery.", + "curbside-check-in": "Use the synthetic store name, order number, and the pickup window shown in the order page. The mirror does not require a real phone number to simulate check-in.", + "pickup-id-requirements": "For demo pickup, bring the synthetic order number and a photo ID listed on the benchmark account. Another person can collect only if the order note says alternate pickup is enabled.", + "tracking-synthetic-orders": "After checkout, the order page shows whether the order is preparing shipment, shipped, delivered, or ready for pickup. No real carrier tracking exists in this mirror.", + "order-lookup-by-number": "Order lookup requires a synthetic order number plus the seeded email used on that order. Successful lookup stores a local session so the detail page can be refreshed.", + "gift-return-demo-policy": "Demo returns are informational only. Orders are not really canceled or refunded, but the mirror explains the policy language and steps a shopper would usually follow.", + "protection-plan-differences": "Two-year plans focus on mechanical and power issues. Three-year accidental plans add handling damage coverage and priority replacement review.", + "accidental-damage-coverage": "Accidental plans cover drops, spills, and cracked surfaces for eligible devices. Basic plans do not include accidental handling.", + "tv-wall-mount-measuring": "Measure the width of the wall, note viewing distance, and confirm VESA compatibility before choosing a mount. Large-screen delivery may suggest scheduled slots.", + "laptop-memory-storage-guide": "Memory affects multitasking while storage affects local file capacity. Creator and gaming models in this mirror highlight 16GB, 24GB, and 32GB options plus 512GB to 2TB SSD tiers.", + "camera-lens-kit-guide": "Kit lenses are more flexible for first-time buyers, while body-only listings work better if you already own compatible lenses.", + "headphone-fit-and-noise-control": "Over-ear models prioritize long battery life and strong passive isolation. Earbuds focus on portability and case charging.", + "router-mesh-vs-single-unit": "Mesh kits help larger homes while single routers fit smaller apartments. The mirror includes both so tasks can compare capacity and speed.", + "smart-home-setup-basics": "Use the product detail page to check Wi-Fi, Matter, or Thread support before choosing hubs, cameras, and speakers.", + "target-circle-benefits": "Target Circle points and offers in this mirror come from seeded orders and promotions. The dashboard can also reflect certificate-style savings redemptions.", + "target-circle-360-benefits": "Target Circle 360 adds richer delivery messaging, extra support copy, and membership-style perks in this local mirror.", + "same-day-essentials-delivery": "Everyday items marked with delivery badges can use same-day essentials delivery in supported metro stores. No real courier network is contacted.", + "appliance-installation-demo": "Small appliances use simple delivery messaging. This site does not schedule real installation or technician appointments.", + "monitor-color-workflow": "Creators should compare panel type, color-focused terminology, and resolution together, not refresh rate alone.", + "printer-paper-ink-basics": "Pair photo printers with glossy media and office models with high-yield ink workflows. Support tasks use these distinctions heavily.", + "wearable-cellular-vs-gps": "GPS models sync with a phone, while cellular-capable wearables can receive more independent notifications and location features.", + "gaming-accessory-compatibility": "Check the platform field on the product detail page before adding controllers, keyboards, and headsets to the cart.", + "contact-support-options": "This local mirror offers synthetic chat, email, and phone support history through seeded support tickets. No live agents are contacted.", + } + for index, (slug, title, topic) in enumerate(SUPPORT_ARTICLES, start=1): + body = ( + f"{article_bodies.get(slug, title)}\n" + "No real shipment, order placement, or external service is involved.\n" + "Use the surrounding UI to complete grounded support, pickup, delivery, and rewards tasks." + ) + db.session.add( + SupportArticle( + slug=slug, + title=title, + topic=topic, + summary=f"Demo guidance for {topic.lower()} tasks.", + body=body, + upstream_url="https://www.target.com/c/store-pickup/-/N-5xsxt", + keywords_json=dump_json([topic.lower(), "target demo", slug.replace("-", " ")]), + ) + ) + + +def _add_deals(products: list[Product]) -> None: + sorted_products = sorted(products, key=lambda product: product.discount_percent(), reverse=True) + for index, product in enumerate(sorted_products[:18], start=1): + db.session.add( + Deal( + slug=f"deal-{product.sku.lower()}", + title=f"{product.name} limited-time savings", + subtitle=f"Save {product.discount_percent()}% on a local demo favorite.", + badge="Top Deal" if index <= 6 else "Member Deal", + discount_percent=product.discount_percent(), + ends_label=f"Ends {['Mar 28', 'Mar 29', 'Mar 30'][index % 3]}", + category_slug=product.category.slug, + product_id=product.id, + ) + ) + + +def _add_users_and_accounts() -> list[User]: + created_users: list[User] = [] + for index, (email, full_name, city, state, preferred_store_slug, tier) in enumerate(BENCHMARK_USERS, start=1): + user = User( + email=email, + full_name=full_name, + phone=f"555-100-{index:04d}"[-12:], + city=city, + state=state, + preferred_store_slug=preferred_store_slug, + member_tier=tier, + rewards_member_id=f"TGC-{420000 + index}", + created_at=SEED_TIMESTAMP - timedelta(days=120 - index * 5), + ) + user.set_password(BENCHMARK_PASSWORD) + db.session.add(user) + db.session.flush() + db.session.add( + RewardAccount( + user_id=user.id, + member_id=user.rewards_member_id, + points_balance=1600 + index * 240, + tier=tier, + available_certificates=index - 1, + ) + ) + created_users.append(user) + return created_users + + +def _add_reward_activity(users: list[User]) -> None: + for user_index, user in enumerate(users, start=1): + for activity_index in range(5): + delta = [140, 260, -50, 320, 90][activity_index] + db.session.add( + RewardActivity( + user_id=user.id, + points_delta=delta, + title=[ + "Home order points", + "Weekly deal bonus", + "Offer redemption", + "Store pickup promo", + "Target Circle appreciation credit", + ][activity_index], + note="Synthetic rewards activity used for account dashboard tasks.", + created_at=SEED_TIMESTAMP - timedelta(days=user_index * 10 + activity_index * 6), + ) + ) + + +def _add_support_tickets(users: list[User]) -> None: + for index, user in enumerate(users, start=1): + db.session.add( + SupportTicket( + user_id=user.id, + subject=f"Pickup window confirmation for order TGT-24{index:04d}", + status="Resolved" if index % 2 else "Awaiting follow-up", + channel="Chat" if index % 2 else "Email", + summary="Synthetic support history entry for dashboard review tasks.", + created_at=SEED_TIMESTAMP - timedelta(days=14 + index * 4), + ) + ) + db.session.add( + SupportTicket( + user_id=user.id, + subject=f"Protection plan question for demo household bundle #{index}", + status="Resolved", + channel="Phone", + summary="Synthetic support note about protection plan comparison.", + created_at=SEED_TIMESTAMP - timedelta(days=25 + index * 5), + ) + ) + + +def _add_wishlist_compare_cart(users: list[User], products: list[Product]) -> None: + for user_index, user in enumerate(users): + picks = products[user_index * 8 : user_index * 8 + 8] + for product in picks[:4]: + db.session.add(WishlistItem(user_id=user.id, product_id=product.id)) + for product in picks[4:7]: + db.session.add(CompareItem(user_id=user.id, product_id=product.id)) + + first_product = picks[0] + plan = ProtectionPlan.query.filter_by(product_id=first_product.id).order_by(ProtectionPlan.price.asc()).first() + delivery_option = DeliveryOption.query.filter_by(slug="standard-shipping").first() + db.session.add( + CartItem( + user_id=user.id, + product_id=first_product.id, + quantity=1 + (user_index % 2), + fulfillment_method="delivery", + delivery_option_id=delivery_option.id if delivery_option else None, + protection_plan_id=plan.id if plan else None, + created_at=SEED_TIMESTAMP - timedelta(days=1 + user_index), + ) + ) + + second_product = picks[1] + pickup_store = Store.query.filter_by(slug=user.preferred_store_slug).first() + db.session.add( + CartItem( + user_id=user.id, + product_id=second_product.id, + quantity=1, + fulfillment_method="pickup", + store_id=pickup_store.id if pickup_store else None, + created_at=SEED_TIMESTAMP - timedelta(hours=6 + user_index), + ) + ) + + +def _seed_orders(users: list[User], products: list[Product]) -> None: + delivery_options = DeliveryOption.query.order_by(DeliveryOption.id.asc()).all() + stores = Store.query.order_by(Store.id.asc()).all() + order_counter = 0 + + for user_index, user in enumerate(users, start=1): + for local_index in range(15): + order_counter += 1 + status_cycle = ["Delivered", "Delivered", "Shipped", "Ready for pickup", "Processing"] + mode = "pickup" if local_index % 4 == 0 else "delivery" + store = stores[(user_index + local_index) % len(stores)] if mode == "pickup" else None + delivery_option = delivery_options[(user_index + local_index) % len(delivery_options)] if mode == "delivery" else None + base_product = products[(user_index * 17 + local_index * 3) % len(products)] + extra_product = products[(user_index * 19 + local_index * 5 + 11) % len(products)] + subtotal = round(base_product.price + (extra_product.price if local_index % 3 == 0 else 0), 2) + shipping_fee = delivery_option.fee if delivery_option else 0.0 + tax = round((subtotal + shipping_fee) * 0.086, 2) + total = round(subtotal + shipping_fee + tax, 2) + order = Order( + user_id=user.id, + order_number=f"TGT-{240000 + order_counter}", + email=user.email, + status=status_cycle[local_index % len(status_cycle)], + subtotal=subtotal, + tax=tax, + total=total, + fulfillment_method=mode, + store_id=store.id if store else None, + delivery_option_id=delivery_option.id if delivery_option else None, + shipping_name=user.full_name, + shipping_city=user.city, + shipping_state=user.state, + shipping_zip=f"98{user_index}{local_index:03d}"[-5:], + payment_brand="Demo Visa" if local_index % 2 == 0 else "Demo Mastercard", + payment_last4=f"{1111 + local_index + user_index}"[-4:], + confirmation_note="Synthetic order only. No real order was placed.", + pickup_slot_label="5:00 PM - 7:00 PM" if mode == "pickup" else "", + placed_at=SEED_TIMESTAMP - timedelta(days=local_index * 4 + user_index), + ) + db.session.add(order) + db.session.flush() + db.session.add( + OrderItem( + order_id=order.id, + product_id=base_product.id, + item_name=base_product.name, + quantity=1, + unit_price=base_product.price, + protection_plan_name="", + ) + ) + if local_index % 3 == 0: + db.session.add( + OrderItem( + order_id=order.id, + product_id=extra_product.id, + item_name=extra_product.name, + quantity=1, + unit_price=extra_product.price, + protection_plan_name="", + ) + ) + db.session.add( + PaymentMock( + order_id=order.id, + amount=total, + card_label=order.payment_brand, + auth_status="Approved", + approval_code=f"APR{order_counter:05d}", + created_at=order.placed_at, + ) + ) + + +def _attach_seed_images(image_root: Path) -> None: + _write_svg( + image_root / "hero" / "target-hero.svg", + "Target local benchmark mirror", + "Synthetic deals, pickup, delivery, rewards, and checkout tasks", + ("#8f0000", "#cc0000", "#ffffff"), + "DEAL", + ) + + +def _populate_all(image_root: Path) -> None: + _add_brand_records() + db.session.flush() + _add_category_records(image_root) + db.session.flush() + _add_store_records(image_root) + db.session.flush() + _add_delivery_options() + db.session.flush() + products = _persist_products(image_root) + _add_pickup_slots() + _add_store_inventory(products) + _add_reviews(products) + _add_protection_plans(products) + _add_support_articles() + _add_deals(products) + users = _add_users_and_accounts() + db.session.flush() + _add_reward_activity(users) + _add_support_tickets(users) + _add_wishlist_compare_cart(users, products) + _seed_orders(users, products) + _attach_seed_images(image_root) + + +def ensure_seed_data(force: bool, runtime_db_path: Path, seed_db_path: Path, image_root: Path) -> None: + image_root.mkdir(parents=True, exist_ok=True) + + if force or not _counts_ok(): + db.drop_all() + db.create_all() + _populate_all(image_root) + db.session.commit() + db.session.remove() + if runtime_db_path.exists(): + seed_db_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(runtime_db_path, seed_db_path) + return + + _ensure_generated_assets(image_root) + if runtime_db_path.exists() and not seed_db_path.exists(): + seed_db_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(runtime_db_path, seed_db_path) + + +if __name__ == "__main__": + from app import app + + with app.app_context(): + ensure_seed_data( + force=not Path("instance_seed/target.db").exists(), + runtime_db_path=Path("instance/target.db"), + seed_db_path=Path("instance_seed/target.db"), + image_root=Path("static/images"), + ) + diff --git a/sites/target/static/css/main.css b/sites/target/static/css/main.css new file mode 100644 index 00000000..47429adf --- /dev/null +++ b/sites/target/static/css/main.css @@ -0,0 +1,1029 @@ +:root { + --bb-blue: #cc0000; + --bb-blue-deep: #8f0000; + --bb-blue-soft: #fff0f0; + --bb-yellow: #ffffff; + --bb-ink: #1f1717; + --bb-slate: #6f5f5f; + --bb-border: #efd9d9; + --bb-surface: #ffffff; + --bb-surface-alt: #fff8f8; + --bb-shadow: 0 20px 50px rgba(89, 12, 12, 0.08); + --bb-radius: 22px; + --bb-shell: min(1240px, calc(100vw - 32px)); +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + font-family: "Segoe UI", "Trebuchet MS", Arial, sans-serif; + color: var(--bb-ink); + background: + radial-gradient(circle at top right, rgba(204, 0, 0, 0.08), transparent 28%), + linear-gradient(180deg, #fffafa 0%, #fff2f2 100%); +} + +a { + color: var(--bb-blue); + text-decoration: none; +} + +img { + max-width: 100%; + display: block; +} + +button, +input, +select { + font: inherit; +} + +.shell { + width: var(--bb-shell); + margin: 0 auto; +} + +.promo-strip { + background: linear-gradient(90deg, var(--bb-blue-deep), var(--bb-blue)); + color: #fff; + font-size: 0.92rem; +} + +.promo-strip__inner { + display: flex; + gap: 20px; + align-items: center; + justify-content: space-between; + padding: 10px 0; + flex-wrap: wrap; +} + +.site-header { + position: sticky; + top: 0; + z-index: 20; + backdrop-filter: blur(14px); + background: rgba(255, 255, 255, 0.92); + border-bottom: 1px solid rgba(204, 0, 0, 0.08); +} + +.site-header__top, +.site-header__nav { + display: flex; + align-items: center; + gap: 18px; + padding: 14px 0; +} + +.site-header__nav { + padding-top: 0; + padding-bottom: 14px; +} + +.logo-badge { + flex: 0 0 auto; + width: 118px; + height: 86px; + border-radius: 18px; + background: linear-gradient(145deg, #ffffff, #fff4f4); + color: #09111f; + display: grid; + place-items: center; + box-shadow: var(--bb-shadow); + font-weight: 900; + line-height: 1; + border: 2px solid rgba(204, 0, 0, 0.14); + position: relative; +} + +.logo-badge__bullseye { + width: 46px; + height: 46px; + border-radius: 999px; + background: + radial-gradient(circle, #fff 0 5px, #cc0000 5px 12px, #fff 12px 18px, #cc0000 18px 100%); +} + +.logo-badge__text { + font-size: 0.74rem; + letter-spacing: 0.08em; + text-align: center; + text-transform: uppercase; +} + +.site-search { + flex: 1 1 auto; + display: grid; + grid-template-columns: 1fr auto; + gap: 12px; +} + +.site-search input, +.inline-search input, +.auth-form input, +.auth-form select, +.filter-form input, +.filter-form select { + width: 100%; + border: 1px solid var(--bb-border); + border-radius: 14px; + padding: 13px 15px; + background: #fff; +} + +.site-search button, +.button { + border: none; + border-radius: 14px; + background: var(--bb-yellow); + color: #09111f; + font-weight: 800; + padding: 13px 18px; + cursor: pointer; + transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease; +} + +.site-search button:hover, +.button:hover { + transform: translateY(-1px); + box-shadow: 0 12px 24px rgba(9, 17, 31, 0.12); +} + +.button--secondary { + background: var(--bb-blue); + color: #fff; +} + +.button--ghost { + background: transparent; + border: 1px solid var(--bb-border); + color: var(--bb-blue); +} + +.header-actions { + display: flex; + align-items: center; + gap: 14px; + font-weight: 700; + flex-wrap: wrap; +} + +.header-actions span { + display: inline-grid; + place-items: center; + min-width: 26px; + height: 26px; + margin-left: 8px; + border-radius: 999px; + background: var(--bb-blue-soft); + color: var(--bb-blue); +} + +.category-nav { + display: flex; + gap: 16px; + flex-wrap: wrap; +} + +.category-nav a { + color: var(--bb-ink); + font-weight: 700; +} + +.store-banner { + background: linear-gradient(90deg, rgba(0, 70, 190, 0.06), rgba(255, 242, 0, 0.25)); + border-bottom: 1px solid rgba(0, 70, 190, 0.08); +} + +.store-banner__inner { + display: flex; + gap: 12px; + align-items: center; + padding: 12px 0; + flex-wrap: wrap; +} + +.page-main { + padding-bottom: 64px; +} + +.page-section { + padding: 28px 0; +} + +.flash-stack { + display: grid; + gap: 10px; + margin-top: 18px; +} + +.flash { + padding: 14px 16px; + border-radius: 16px; + border: 1px solid var(--bb-border); + background: #fff; +} + +.flash--success { + background: #edf9f0; + border-color: #b9e2c5; +} + +.flash--warning { + background: #fff7e8; + border-color: #f1ddb0; +} + +.flash--danger { + background: #fff0f0; + border-color: #efb6b6; +} + +.flash--info { + background: #eef5ff; + border-color: #c7daf8; +} + +.hero { + padding: 34px 0 14px; +} + +.hero__grid { + display: grid; + grid-template-columns: 1.15fr 0.85fr; + gap: 28px; + align-items: stretch; +} + +.hero__copy, +.hero__visual { + border-radius: 30px; + box-shadow: var(--bb-shadow); +} + +.hero__copy { + padding: 42px; + background: + radial-gradient(circle at top left, rgba(255, 242, 0, 0.26), transparent 34%), + linear-gradient(135deg, var(--bb-blue-deep), var(--bb-blue)); + color: #fff; +} + +.hero__copy h1 { + margin: 10px 0 16px; + font-size: clamp(2rem, 4vw, 3.8rem); + line-height: 1.02; +} + +.hero__copy p { + max-width: 54ch; + color: rgba(255, 255, 255, 0.9); +} + +.hero__actions, +.inline-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.hero__actions { + margin-top: 26px; +} + +.hero__visual { + background: linear-gradient(180deg, #eaf4ff, #ffffff); + padding: 24px; + display: grid; + place-items: center; +} + +.eyebrow { + margin: 0; + text-transform: uppercase; + letter-spacing: 0.14em; + font-size: 0.76rem; + font-weight: 800; + color: var(--bb-blue); +} + +.feature-strip, +.deal-grid, +.category-grid, +.store-grid, +.support-grid, +.plan-grid, +.stats-grid { + display: grid; + gap: 18px; +} + +.feature-strip { + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-top: 10px; +} + +.feature-tile, +.deal-card, +.category-card, +.store-card, +.support-card, +.panel, +.stat-card, +.product-card, +.plan-card, +.ticket-card, +.review-card { + background: var(--bb-surface); + border: 1px solid rgba(0, 70, 190, 0.08); + border-radius: var(--bb-radius); + box-shadow: var(--bb-shadow); +} + +.feature-tile { + display: grid; + grid-template-columns: 120px 1fr; + gap: 14px; + padding: 14px; + align-items: center; +} + +.feature-tile strong, +.section-heading h1, +.section-heading h2, +.product-card h3, +.panel h1, +.panel h2, +.panel h3 { + margin: 0; +} + +.feature-tile span, +.category-card span, +.store-card span, +.support-card span, +.deal-card__badge, +.product-card__meta, +.tag-row span, +.tag-row li { + font-size: 0.82rem; + color: var(--bb-slate); +} + +.section-heading { + display: flex; + gap: 16px; + justify-content: space-between; + align-items: end; + margin-bottom: 18px; + flex-wrap: wrap; +} + +.section-heading h1 { + font-size: clamp(1.8rem, 3vw, 2.8rem); +} + +.section-heading h2 { + font-size: clamp(1.35rem, 2.3vw, 2rem); +} + +.result-count { + padding: 10px 14px; + border-radius: 999px; + background: var(--bb-blue-soft); + color: var(--bb-blue); + font-weight: 800; +} + +.deal-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.deal-card, +.support-card, +.plan-card, +.panel, +.ticket-card, +.review-card, +.stat-card { + padding: 22px; +} + +.deal-card__badge { + display: inline-block; + padding: 7px 10px; + border-radius: 999px; + background: rgba(255, 242, 0, 0.28); + color: #4e4700; + font-weight: 800; +} + +.deal-card__meta, +.summary-row, +.reward-row, +.order-row, +.inventory-row, +.rating-row { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; +} + +.product-grid { + display: grid; + gap: 18px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.product-card { + display: grid; + grid-template-rows: auto 1fr auto; + overflow: hidden; +} + +.product-card__image-wrap { + position: relative; + background: linear-gradient(180deg, #f0f5ff, #fff); + padding: 18px; +} + +.product-card__image { + height: 220px; + width: 100%; + object-fit: cover; + border-radius: 18px; +} + +.product-card__badge { + position: absolute; + top: 16px; + left: 16px; + padding: 8px 12px; + border-radius: 999px; + background: var(--bb-yellow); + color: #1b1600; + font-weight: 800; +} + +.product-card__body, +.product-card__actions { + padding: 20px; +} + +.product-card__desc, +.product-summary, +.plan-card p, +.support-card p, +.deal-card p, +.ticket-card p { + color: var(--bb-slate); + line-height: 1.5; +} + +.price-row { + display: flex; + gap: 10px; + align-items: baseline; + flex-wrap: wrap; +} + +.price-row strong { + font-size: 1.4rem; +} + +.price-row--large strong { + font-size: 2rem; +} + +.price-row__strike { + color: #7f8aa3; + text-decoration: line-through; +} + +.price-row__save { + color: #0b8f4d; + font-weight: 700; +} + +.tag-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 0; + margin: 14px 0 0; + list-style: none; +} + +.tag-row span, +.tag-row li { + padding: 6px 10px; + border-radius: 999px; + background: var(--bb-surface-alt); + border: 1px solid var(--bb-border); +} + +.tag-row--left { + justify-content: flex-start; +} + +.catalog-layout, +.dual-grid, +.checkout-layout, +.article-layout, +.auth-layout, +.account-layout, +.product-layout { + display: grid; + gap: 22px; +} + +.catalog-layout { + grid-template-columns: 280px 1fr; +} + +.filter-card, +.order-summary, +.account-nav { + position: sticky; + top: 145px; + align-self: start; +} + +.filter-card, +.order-summary, +.account-nav { + background: #fff; + border: 1px solid rgba(0, 70, 190, 0.08); + border-radius: var(--bb-radius); + box-shadow: var(--bb-shadow); +} + +.filter-card { + padding: 18px; +} + +.filter-form { + display: grid; + gap: 14px; +} + +.filter-form__row, +.auth-form .filter-form__row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.checkbox-row { + display: flex; + align-items: center; + gap: 10px; +} + +.checkbox-row input { + width: auto; +} + +.product-layout { + grid-template-columns: 1.1fr 1fr 320px; + align-items: start; +} + +.product-layout__media, +.product-layout__content, +.product-layout__buybox { + background: #fff; + border: 1px solid rgba(0, 70, 190, 0.08); + border-radius: var(--bb-radius); + box-shadow: var(--bb-shadow); +} + +.product-layout__media { + padding: 24px; + background: linear-gradient(180deg, #f2f6ff, #fff); +} + +.product-layout__content, +.product-layout__buybox { + padding: 24px; +} + +.bullet-list { + display: grid; + gap: 10px; + padding-left: 20px; +} + +.bullet-list--compact { + gap: 8px; +} + +.buybox-price { + font-size: 2rem; + font-weight: 900; + margin: 8px 0 14px; +} + +.buybox-panel { + display: grid; + gap: 10px; + padding: 14px; + border-radius: 18px; + background: var(--bb-surface-alt); +} + +.spec-section + .spec-section { + margin-top: 18px; +} + +.spec-grid { + display: grid; + grid-template-columns: 180px 1fr; + gap: 12px 18px; +} + +.spec-grid dt { + font-weight: 700; + color: var(--bb-slate); +} + +.compare-strip { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 18px; + margin-bottom: 20px; +} + +.compare-product { + padding: 18px; + border-radius: var(--bb-radius); + background: #fff; + border: 1px solid rgba(0, 70, 190, 0.08); + box-shadow: var(--bb-shadow); +} + +.table-wrap { + overflow-x: auto; + border-radius: 18px; +} + +.compare-table, +.inventory-table { + width: 100%; + border-collapse: collapse; + background: #fff; + overflow: hidden; +} + +.compare-table th, +.compare-table td, +.inventory-table th, +.inventory-table td { + padding: 14px 16px; + border-bottom: 1px solid var(--bb-border); + text-align: left; + vertical-align: top; +} + +.store-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.store-card, +.category-card { + display: grid; + grid-template-columns: 160px 1fr; + gap: 16px; + padding: 16px; + align-items: center; +} + +.store-list, +.support-list { + display: grid; + gap: 14px; +} + +.store-list__item, +.support-list__item, +.mini-product, +.order-row, +.wishlist-row, +.cart-row { + display: flex; + gap: 14px; + align-items: center; +} + +.store-list__item, +.support-list__item, +.mini-product, +.ticket-card, +.review-card, +.order-row { + padding: 14px; + border-radius: 18px; + background: var(--bb-surface-alt); +} + +.support-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.support-card strong { + display: block; + margin: 8px 0; +} + +.topic-row { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 18px; +} + +.topic-row a { + padding: 9px 12px; + border-radius: 999px; + background: #fff; + border: 1px solid var(--bb-border); + font-weight: 700; + color: var(--bb-slate); +} + +.topic-row a.is-active { + background: var(--bb-blue); + color: #fff; + border-color: var(--bb-blue); +} + +.search-sections { + display: grid; + gap: 20px; +} + +.auth-layout { + grid-template-columns: 1fr 0.9fr; +} + +.auth-form { + display: grid; + gap: 14px; +} + +.auth-form label, +.filter-form label { + display: grid; + gap: 8px; + font-weight: 700; +} + +.auth-note { + color: var(--bb-slate); +} + +.account-layout { + grid-template-columns: 240px 1fr; +} + +.account-nav { + display: grid; + gap: 8px; + padding: 16px; +} + +.account-nav a { + padding: 12px 14px; + border-radius: 14px; + font-weight: 700; + color: var(--bb-ink); +} + +.account-nav a.is-active { + background: var(--bb-blue); + color: #fff; +} + +.account-content { + display: grid; + gap: 20px; +} + +.stats-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.stat-card span { + display: block; + color: var(--bb-slate); +} + +.stat-card strong { + display: block; + margin-top: 8px; + font-size: 2rem; +} + +.reward-row, +.cart-row, +.wishlist-row { + padding: 14px 0; + border-bottom: 1px solid var(--bb-border); +} + +.reward-points { + font-size: 1.1rem; + font-weight: 900; + color: #0b8f4d; +} + +.reward-points.is-negative { + color: #b53f46; +} + +.checkout-layout { + grid-template-columns: minmax(0, 1fr) 320px; + align-items: start; +} + +.checkout-steps { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 20px; +} + +.checkout-step { + padding: 14px; + border-radius: 18px; + background: #fff; + border: 1px solid var(--bb-border); + display: flex; + gap: 10px; + align-items: center; + color: var(--bb-slate); +} + +.checkout-step span { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border-radius: 999px; + background: var(--bb-surface-alt); +} + +.checkout-step.is-active { + background: linear-gradient(135deg, rgba(0, 70, 190, 0.09), rgba(255, 242, 0, 0.18)); + color: var(--bb-ink); + border-color: rgba(0, 70, 190, 0.18); +} + +.summary-row { + padding: 12px 0; + border-bottom: 1px solid var(--bb-border); +} + +.summary-row--total { + font-size: 1.08rem; + font-weight: 900; +} + +.empty-state { + padding: 48px 28px; + text-align: center; + border-radius: 28px; + background: #fff; + border: 1px solid rgba(0, 70, 190, 0.08); + box-shadow: var(--bb-shadow); +} + +.empty-state--success { + background: linear-gradient(180deg, #ffffff, #f2f7ff); +} + +.inline-form { + display: flex; + gap: 12px; + align-items: end; + flex-wrap: wrap; +} + +.inline-form label { + display: grid; + gap: 6px; + font-weight: 700; +} + +.inline-form select { + min-width: 88px; +} + +.panel--store-hero { + padding: 20px; + background: linear-gradient(180deg, #f2f7ff, #fff); +} + +.site-footer { + margin-top: 60px; + padding: 34px 0 40px; + background: #0c1730; + color: rgba(255, 255, 255, 0.88); +} + +.site-footer__grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 20px; +} + +.site-footer h4 { + margin-top: 0; + color: #fff; +} + +.site-footer ul { + margin: 0; + padding-left: 18px; +} + +@media (max-width: 1100px) { + .hero__grid, + .catalog-layout, + .product-layout, + .checkout-layout, + .account-layout, + .auth-layout { + grid-template-columns: 1fr; + } + + .filter-card, + .order-summary, + .account-nav { + position: static; + } + + .product-grid, + .deal-grid, + .store-grid, + .support-grid, + .feature-strip, + .stats-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .site-header__top { + flex-wrap: wrap; + } +} + +@media (max-width: 760px) { + :root { + --bb-shell: min(100vw - 18px, 100vw - 18px); + } + + .product-grid, + .deal-grid, + .store-grid, + .support-grid, + .feature-strip, + .stats-grid, + .site-footer__grid, + .filter-form__row, + .auth-form .filter-form__row, + .store-card, + .category-card { + grid-template-columns: 1fr; + } + + .checkout-steps { + grid-template-columns: 1fr; + } + + .site-search { + grid-template-columns: 1fr; + } + + .header-actions { + width: 100%; + justify-content: space-between; + } + + .feature-tile { + grid-template-columns: 1fr; + } + + .spec-grid { + grid-template-columns: 1fr; + } +} + diff --git a/sites/target/static/js/main.js b/sites/target/static/js/main.js new file mode 100644 index 00000000..98231394 --- /dev/null +++ b/sites/target/static/js/main.js @@ -0,0 +1,2 @@ +document.documentElement.classList.add("js"); + diff --git a/sites/target/tasks.jsonl b/sites/target/tasks.jsonl new file mode 100644 index 00000000..454f7b3e --- /dev/null +++ b/sites/target/tasks.jsonl @@ -0,0 +1,20 @@ +{"web_name": "Target", "id": "Target--0", "ques": "Search for the Dell Horizon 14 OLED Laptop and report its current price.", "web": "http://localhost:40015/", "upstream_url": "https://www.target.com/"} +{"web_name": "Target", "id": "Target--1", "ques": "Compare the Dell Horizon 14 OLED Laptop and the Apple MacBook Air 13 Sky M3, then tell me which one has more storage.", "web": "http://localhost:40015/", "upstream_url": "https://www.target.com/c/categories/-/N-5xt0a"} +{"web_name": "Target", "id": "Target--2", "ques": "Open the LG Aurora 65-inch QNED 4K Smart TV and tell me which protection plan includes accidental handling coverage.", "web": "http://localhost:40015/", "upstream_url": "https://www.target.com/c/categories/-/N-5xt0a"} +{"web_name": "Target", "id": "Target--3", "ques": "Look up the Bellevue Square store and tell me one amenity that store offers.", "web": "http://localhost:40015/", "upstream_url": "https://www.target.com/c/store-pickup/-/N-5xsxt"} +{"web_name": "Target", "id": "Target--4", "ques": "Search support for same-day delivery and tell me what large items may fall back to instead.", "web": "http://localhost:40015/", "upstream_url": "https://www.target.com/c/store-pickup/-/N-5xsxt"} +{"web_name": "Target", "id": "Target--5", "ques": "Sign in as alice.j@test.com using the benchmark password, open the rewards page, and report the current points balance.", "web": "http://localhost:40015/", "upstream_url": "https://www.target.com/"} +{"web_name": "Target", "id": "Target--6", "ques": "Sign in as alice.j@test.com, open the wishlist, and add the Apple MacBook Air 13 Sky M3 to the cart.", "web": "http://localhost:40015/", "upstream_url": "https://www.target.com/"} +{"web_name": "Target", "id": "Target--7", "ques": "Sign in as alice.j@test.com, open the compare page, and identify which compared item is the most expensive.", "web": "http://localhost:40015/", "upstream_url": "https://www.target.com/"} +{"web_name": "Target", "id": "Target--8", "ques": "Look up order TGT-240001 with alice.j@test.com and tell me whether it is a pickup order or a delivery order.", "web": "http://localhost:40015/", "upstream_url": "https://www.target.com/"} +{"web_name": "Target", "id": "Target--9", "ques": "Search for the Microsoft Xbox Carbon Wireless Controller, add it to the cart, and set it up for store pickup at Austin Domain.", "web": "http://localhost:40015/", "upstream_url": "https://www.target.com/c/store-pickup/-/N-5xsxt"} +{"web_name": "Target", "id": "Target--10", "ques": "Search for the WD Vault 2TB Portable SSD and tell me its listed read speed.", "web": "http://localhost:40015/", "upstream_url": "https://www.target.com/c/categories/-/N-5xt0a"} +{"web_name": "Target", "id": "Target--11", "ques": "Open the deals page and find which product is shown with a Member Deal badge and is a monitor.", "web": "http://localhost:40015/", "upstream_url": "https://www.target.com/c/deals/-/N-4xw74"} +{"web_name": "Target", "id": "Target--12", "ques": "Filter the monitors category to find the Acer NitroMesh 27 Gaming Monitor and report its refresh rate.", "web": "http://localhost:40015/", "upstream_url": "https://www.target.com/c/categories/-/N-5xt0a"} +{"web_name": "Target", "id": "Target--13", "ques": "Search support for pickup requirements and tell me what two things the article says you should bring for demo pickup.", "web": "http://localhost:40015/", "upstream_url": "https://www.target.com/c/store-pickup/-/N-5xsxt"} +{"web_name": "Target", "id": "Target--14", "ques": "Browse Storage & Accessories, filter to pickup-eligible products under $200, and tell me which item is the cheapest.", "web": "http://localhost:40015/", "upstream_url": "https://www.target.com/c/categories/-/N-5xt0a"} +{"web_name": "Target", "id": "Target--15", "ques": "Sign in as bob.c@test.com, add the Belkin Boost 3-in-1 Mag Stand to the cart, choose standard delivery, and stop on the review step before placing the order.", "web": "http://localhost:40015/", "upstream_url": "https://www.target.com/"} +{"web_name": "Target", "id": "Target--16", "ques": "Sign in as carol.d@test.com and tell me the status of the most recent order in account order history.", "web": "http://localhost:40015/", "upstream_url": "https://www.target.com/"} +{"web_name": "Target", "id": "Target--17", "ques": "Search for the Canon Vista R10 Mirrorless Camera Kit and tell me which lens is included.", "web": "http://localhost:40015/", "upstream_url": "https://www.target.com/c/categories/-/N-5xt0a"} +{"web_name": "Target", "id": "Target--18", "ques": "Find the article about Target Circle rewards and tell me whether the dashboard can also reflect certificate-style savings redemptions.", "web": "http://localhost:40015/", "upstream_url": "https://www.target.com/"} +{"web_name": "Target", "id": "Target--19", "ques": "Sign in as david.k@test.com, buy the Sony QuietWave XM6 Wireless Headphones with demo checkout, and report the confirmation order number shown at the end.", "web": "http://localhost:40015/", "upstream_url": "https://www.target.com/"} diff --git a/sites/target/templates/_account_nav.html b/sites/target/templates/_account_nav.html new file mode 100644 index 00000000..4e91f206 --- /dev/null +++ b/sites/target/templates/_account_nav.html @@ -0,0 +1,8 @@ + + + diff --git a/sites/target/templates/_checkout_steps.html b/sites/target/templates/_checkout_steps.html new file mode 100644 index 00000000..662c45ad --- /dev/null +++ b/sites/target/templates/_checkout_steps.html @@ -0,0 +1,11 @@ +{% set steps = ['Choose fulfillment', 'Shipping / pickup', 'Payment', 'Review', 'Confirmation'] %} +
+ {% for step in steps %} +
+ {{ loop.index }} + {{ step }} +
+ {% endfor %} +
+ + diff --git a/sites/target/templates/_product_cards.html b/sites/target/templates/_product_cards.html new file mode 100644 index 00000000..92c8cd8b --- /dev/null +++ b/sites/target/templates/_product_cards.html @@ -0,0 +1,42 @@ +
+ {% for product in products %} +
+ + {{ product.name }} + {% if product.deal_badge %} + {{ product.deal_badge }} + {% endif %} + +
+

{{ product.brand.name }} | {{ product.category.name }}

+

{{ product.name }}

+

{{ product.short_description }}

+
+ {{ product.rating|stars }} 鈽?/span> + {{ product.review_count }} ratings +
+
+ {{ product.price|currency }} + {% if product.list_price > product.price %} + {{ product.list_price|currency }} + Save {{ product.discount_percent() }}% + {% endif %} +
+
+ {% if product.pickup_eligible %}Pickup{% endif %} + {% if product.delivery_eligible %}Delivery{% endif %} + {{ product.availability_status }} +
+
+
+ View details +
+ + +
+
+
+ {% endfor %} +
+ + diff --git a/sites/target/templates/account.html b/sites/target/templates/account.html new file mode 100644 index 00000000..be00abe4 --- /dev/null +++ b/sites/target/templates/account.html @@ -0,0 +1,87 @@ +{% extends "base.html" %} +{% block title %}Account | Target Demo{% endblock %} +{% block content %} + +{% endblock %} + + diff --git a/sites/target/templates/account_orders.html b/sites/target/templates/account_orders.html new file mode 100644 index 00000000..6c4cd2f8 --- /dev/null +++ b/sites/target/templates/account_orders.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block title %}Orders | Target Demo{% endblock %} +{% block content %} + +{% endblock %} + + diff --git a/sites/target/templates/account_rewards.html b/sites/target/templates/account_rewards.html new file mode 100644 index 00000000..42b10cf8 --- /dev/null +++ b/sites/target/templates/account_rewards.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block title %}Rewards | Target Demo{% endblock %} +{% block content %} + +{% endblock %} + + diff --git a/sites/target/templates/base.html b/sites/target/templates/base.html new file mode 100644 index 00000000..f4ca59b3 --- /dev/null +++ b/sites/target/templates/base.html @@ -0,0 +1,107 @@ + + + + + + {% block title %}{{ site_name }} Demo Mirror{% endblock %} + + + +
+
+ Local benchmark mirror only + No real checkout, payment, or Target APIs + Benchmark password: {{ benchmark_password }} +
+
+ + + + {% if preferred_store %} +
+
+ Preferred store + {{ preferred_store.name }} + {{ preferred_store.city }}, {{ preferred_store.state }} + View hours & pickup +
+
+ {% endif %} + +
+
+ {% 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/target/templates/cart.html b/sites/target/templates/cart.html new file mode 100644 index 00000000..2eb81586 --- /dev/null +++ b/sites/target/templates/cart.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} +{% block title %}Cart | Target Demo{% endblock %} +{% block content %} +
+
+
+

Cart

+

Your demo cart

+
+
+ {% if requires_login %} +
+

Sign in to view saved carts

+

This mirror keeps cart state per benchmark account.

+ Sign in +
+ {% elif cart_items %} +
+
+ {% for item in cart_items %} + + {% endfor %} +
+ +
+ {% else %} +
+

Your cart is empty

+

Add a few demo products from the catalog to continue.

+ Browse products +
+ {% endif %} +
+{% endblock %} + + diff --git a/sites/target/templates/categories.html b/sites/target/templates/categories.html new file mode 100644 index 00000000..0d090eee --- /dev/null +++ b/sites/target/templates/categories.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}Categories | Target Demo{% endblock %} +{% block content %} +
+
+
+

Category map

+

Shop the synthetic catalog by department

+
+
+ +
+{% endblock %} + + diff --git a/sites/target/templates/checkout_confirmation.html b/sites/target/templates/checkout_confirmation.html new file mode 100644 index 00000000..165d601f --- /dev/null +++ b/sites/target/templates/checkout_confirmation.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}Confirmation | Target Demo{% endblock %} +{% block content %} +
+ {% set current_step = 4 %} + {% include "_checkout_steps.html" %} +
+

Order confirmed

+

Your synthetic order {{ order.order_number }} has been created.

+

{{ order.confirmation_note }}

+ +
+
+{% endblock %} + + diff --git a/sites/target/templates/checkout_mode.html b/sites/target/templates/checkout_mode.html new file mode 100644 index 00000000..e9d81daf --- /dev/null +++ b/sites/target/templates/checkout_mode.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block title %}Checkout | Target Demo{% endblock %} +{% block content %} +
+ {% set current_step = 0 %} + {% include "_checkout_steps.html" %} +
+
+

Checkout

+

Choose fulfillment for this order

+ {% if requires_login %} +

This page is visible to all users, but the actual checkout flow requires a benchmark account.

+ + {% else %} + + {% endif %} +
+ +
+
+{% endblock %} + + diff --git a/sites/target/templates/checkout_payment.html b/sites/target/templates/checkout_payment.html new file mode 100644 index 00000000..0b89363a --- /dev/null +++ b/sites/target/templates/checkout_payment.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block title %}Payment | Target Demo{% endblock %} +{% block content %} +
+ {% set current_step = 2 %} + {% include "_checkout_steps.html" %} +
+
+

Payment

+

Enter demo payment details

+
+ + +

This step is synthetic only. No real card or processor is involved.

+ +
+
+
+
+{% endblock %} + + diff --git a/sites/target/templates/checkout_pickup.html b/sites/target/templates/checkout_pickup.html new file mode 100644 index 00000000..4aee7dd3 --- /dev/null +++ b/sites/target/templates/checkout_pickup.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block title %}Store Pickup | Target Demo{% endblock %} +{% block content %} +
+ {% set current_step = 1 %} + {% include "_checkout_steps.html" %} +
+
+

Pickup

+

Select a store and pickup slot

+
+ + + +
+
+ +
+
+{% endblock %} + + diff --git a/sites/target/templates/checkout_review.html b/sites/target/templates/checkout_review.html new file mode 100644 index 00000000..a85999a2 --- /dev/null +++ b/sites/target/templates/checkout_review.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% block title %}Review Order | Target Demo{% endblock %} +{% block content %} +
+ {% set current_step = 3 %} + {% include "_checkout_steps.html" %} +
+
+

Review

+

Confirm your synthetic order

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

{{ item.product.price|currency }} | Qty {{ item.quantity }}

+ {% if item.protection_plan %} + {{ item.protection_plan.name }} | {{ item.protection_plan.price|currency }} + {% endif %} +
+
+
+ {% endfor %} +
+ +
+
+ +
+
+{% endblock %} + + diff --git a/sites/target/templates/checkout_shipping.html b/sites/target/templates/checkout_shipping.html new file mode 100644 index 00000000..b06b095c --- /dev/null +++ b/sites/target/templates/checkout_shipping.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block title %}Shipping | Target Demo{% endblock %} +{% block content %} +
+ {% set current_step = 1 %} + {% include "_checkout_steps.html" %} +
+
+

Delivery

+

Choose shipping for this synthetic order

+
+ +
+ + +
+ + + +
+
+ +
+
+{% endblock %} + + diff --git a/sites/target/templates/compare.html b/sites/target/templates/compare.html new file mode 100644 index 00000000..d295b8bd --- /dev/null +++ b/sites/target/templates/compare.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% block title %}Compare products | Target Demo{% endblock %} +{% block content %} +
+
+
+

Product comparison

+

Side-by-side spec view

+

Compare up to four synthetic products. The table highlights the same specs seeded into detail pages.

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

{{ product.name }}

+

{{ product.price|currency }}

+
+ + +
+
+ {% endfor %} +
+
+ + + + + {% for product in products %} + + {% endfor %} + + + + {% for label, values in spec_rows %} + + + {% for value in values %} + + {% endfor %} + + {% endfor %} + +
Spec{{ product.name }}
{{ label }}{{ value }}
+
+ {% else %} +
+

No products in compare

+

Use product pages or category listings to add items into the compare tray.

+
+ {% endif %} +
+{% endblock %} + + diff --git a/sites/target/templates/deals.html b/sites/target/templates/deals.html new file mode 100644 index 00000000..dd293f8a --- /dev/null +++ b/sites/target/templates/deals.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block title %}Deals | Target Demo{% endblock %} +{% block content %} +
+
+
+

Current offers

+

Deals seeded for retail benchmark tasks

+
+
+
+ {% for deal in deals %} +
+

{{ deal.badge }}

+

{{ deal.title }}

+

{{ deal.subtitle }}

+
+ {{ deal.discount_percent }}% off + {{ deal.ends_label }} +
+ {% if deal.product %} + Open product + {% endif %} +
+ {% endfor %} +
+
+{% endblock %} + + diff --git a/sites/target/templates/home.html b/sites/target/templates/home.html new file mode 100644 index 00000000..7a21e61d --- /dev/null +++ b/sites/target/templates/home.html @@ -0,0 +1,112 @@ +{% extends "base.html" %} +{% block title %}Target Demo Mirror{% endblock %} +{% block content %} +
+
+
+

Target local benchmark

+

Fill a cart, pick a store, and finish a retail flow without ever leaving localhost.

+

This mirror uses deterministic synthetic products, stores, rewards, and orders. It is designed for benchmark tasks, not real shopping.

+ +
+
+ Target demo hero graphic +
+
+
+ +
+ {% for category in categories[:4] %} + + {{ category.name }} +
+ {{ category.section }} + {{ category.name }} +
+
+ {% endfor %} +
+ +
+
+
+

Featured deals

+

Demo offers with strong task coverage

+
+ See all deals +
+
+ {% for deal in deal_cards %} +
+

{{ deal.badge }}

+

{{ deal.title }}

+

{{ deal.subtitle }}

+
+ Save {{ deal.discount_percent }}% + {{ deal.ends_label }} +
+ {% if deal.product %} + Open product + {% endif %} +
+ {% endfor %} +
+
+ +
+
+
+

Trending now

+

Local bestsellers across home, style, kids, and everyday essentials

+
+
+ {% set products = featured_products %} + {% include "_product_cards.html" %} +
+ +
+
+
+
+

Store pickup

+

Demo stores with pickup windows

+
+ See all stores +
+ +
+
+
+
+

Help topics

+

Support pages wired into tasks

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

{{ article.summary }}

+ {{ article.topic }} +
+ {% endfor %} +
+
+
+{% endblock %} + + diff --git a/sites/target/templates/login.html b/sites/target/templates/login.html new file mode 100644 index 00000000..e7af6ae4 --- /dev/null +++ b/sites/target/templates/login.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}Sign in | Target Demo{% endblock %} +{% block content %} +
+
+

Sign in

+

Use a benchmark account

+
+ + + +
+

Demo password for seeded accounts: {{ benchmark_password }}

+
+
+

Seeded accounts

+
    +
  • alice.j@test.com
  • +
  • bob.c@test.com
  • +
  • carol.d@test.com
  • +
  • david.k@test.com
  • +
+

Create a new synthetic account from the register page if you want a fresh profile.

+ Register a demo account +
+
+{% endblock %} + + diff --git a/sites/target/templates/order_detail.html b/sites/target/templates/order_detail.html new file mode 100644 index 00000000..9df864e7 --- /dev/null +++ b/sites/target/templates/order_detail.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block title %}{{ order.order_number }} | Target Demo{% endblock %} +{% block content %} +
+
+
+

Order detail

+

{{ order.order_number }}

+

{{ order.status }} | {{ order.fulfillment_method|title }} | {{ order.placed_at.strftime('%b %d, %Y') }}

+
+
{{ order.total|currency }}
+
+
+ + +
+
+{% endblock %} + + diff --git a/sites/target/templates/order_lookup.html b/sites/target/templates/order_lookup.html new file mode 100644 index 00000000..0c576539 --- /dev/null +++ b/sites/target/templates/order_lookup.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}Order Lookup | Target Demo{% endblock %} +{% block content %} +
+
+

Order lookup

+

Find a synthetic order by number

+
+ + + +
+
+
+

How it works

+
    +
  • Use a synthetic order number from seeded account history or benchmark tasks.
  • +
  • This form never contacts real Target systems.
  • +
  • After a successful lookup, the order detail page stays available for the current session.
  • +
+
+
+{% endblock %} + + diff --git a/sites/target/templates/product_detail.html b/sites/target/templates/product_detail.html new file mode 100644 index 00000000..f27fd6e9 --- /dev/null +++ b/sites/target/templates/product_detail.html @@ -0,0 +1,153 @@ +{% extends "base.html" %} +{% block title %}{{ product.name }} | Target Demo{% endblock %} +{% block content %} +
+
+ {{ product.name }} +
+
+

{{ product.brand.name }} | {{ product.category.name }}

+

{{ product.name }}

+
+ {{ product.rating|stars }} 鈽?/span> + {{ product.review_count }} ratings +
+
+ {{ product.price|currency }} + {% if product.list_price > product.price %} + {{ product.list_price|currency }} + Save {{ product.discount_percent() }}% + {% endif %} +
+

{{ product.short_description }}

+
    + {% for bullet in product.highlights() %} +
  • {{ bullet }}
  • + {% endfor %} +
+
+ {{ product.availability_status }} + {% if product.pickup_eligible %}Store pickup{% endif %} + {% if product.delivery_eligible %}Delivery{% endif %} +
+
+
+ + + + +
+
+ + +
+ {% if current_user.is_authenticated %} +
+ + +
+ {% endif %} +
+
+ +
+ +
+
+
+
+

Tech specs

+

What to compare

+
+
+ {% for section in product.specs() %} +
+

{{ section['title'] }}

+
+ {% for item in section['items'] %} +
{{ item['label'] }}
+
{{ item['value'] }}
+ {% endfor %} +
+
+ {% endfor %} +
+
+
+
+

Protection plans

+

Compare coverage

+
+
+
+ {% for plan in product.protection_plans %} +
+

{{ plan.name }}

+ {{ plan.price|currency }} +

{{ plan.coverage_summary }}

+
    +
  • {{ plan.years }} years of demo coverage
  • +
  • {% if plan.accidental %}Includes accidental handling claims{% else %}Mechanical and power issue coverage{% endif %}
  • +
  • Priority support: {% if plan.priority_support %}Included{% else %}Not included{% endif %}
  • +
+
+ {% endfor %} +
+
+
+ +
+
+
+
+

Reviews

+

Customer perspective

+
+
+ {% for review in product.reviews[:6] %} +
+
+ {{ review.title }} + {{ review.rating }} 鈽?/span> +
+

{{ review.body }}

+ {{ review.author_name }} | {% if review.verified %}Verified purchase{% else %}Demo review{% endif %} +
+ {% endfor %} +
+
+
+
+

You may also like

+

Related category picks

+
+
+ {% set products = related_products %} + {% include "_product_cards.html" %} +
+
+{% endblock %} + + diff --git a/sites/target/templates/products.html b/sites/target/templates/products.html new file mode 100644 index 00000000..544c8ae3 --- /dev/null +++ b/sites/target/templates/products.html @@ -0,0 +1,86 @@ +{% extends "base.html" %} +{% block title %}{{ page_title }} | Target Demo{% endblock %} +{% block content %} +
+
+
+

{{ category.section if category else 'Shop products' }}

+

{{ page_title }}

+

{{ page_description }}

+
+
{{ products|length }} demo products
+
+
+ +
+ {% if products %} + {% include "_product_cards.html" %} + {% else %} +
+

No synthetic products matched those filters

+

Try widening the price range or clearing one of the availability filters.

+
+ {% endif %} +
+
+
+{% endblock %} + + diff --git a/sites/target/templates/register.html b/sites/target/templates/register.html new file mode 100644 index 00000000..b3537045 --- /dev/null +++ b/sites/target/templates/register.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% block title %}Register | Target Demo{% endblock %} +{% block content %} +
+
+

Create demo account

+

Register a synthetic shopper profile

+
+ + + + + +
+ + +
+ + +
+
+
+

Why register?

+
    +
  • Persist wishlist, cart, rewards, and order history locally.
  • +
  • Access checkout, pickup, and synthetic order confirmation flows.
  • +
  • No real customer or payment data is sent anywhere.
  • +
+
+
+{% endblock %} + + diff --git a/sites/target/templates/search.html b/sites/target/templates/search.html new file mode 100644 index 00000000..86a43d9e --- /dev/null +++ b/sites/target/templates/search.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} +{% block title %}Search | Target Demo{% endblock %} +{% block content %} +
+
+
+

Global search

+

Results for "{{ active_query }}"

+
+
+ {% if active_query %} +
+
+

Products

+ {% if product_results %} + {% set products = product_results %} + {% include "_product_cards.html" %} + {% else %} +

No product matches found.

+ {% endif %} +
+
+

Stores

+
+ {% for store in store_results %} + + {{ store.name }} +
+ {{ store.name }} +

{{ store.city }}, {{ store.state }}

+
+
+ {% else %} +

No store matches found.

+ {% endfor %} +
+
+
+

Support

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

{{ article.summary }}

+
+ {% else %} +

No support articles matched.

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

Search the local retail catalog

+

Try "OLED laptop", "pickup", "protection plan", or "same-day delivery".

+
+ {% endif %} +
+{% endblock %} + + diff --git a/sites/target/templates/store_detail.html b/sites/target/templates/store_detail.html new file mode 100644 index 00000000..4084e3fa --- /dev/null +++ b/sites/target/templates/store_detail.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} +{% block title %}{{ store.name }} | Target Demo{% endblock %} +{% block content %} +
+
+ {{ store.name }} +
+
+

Store details

+

{{ store.name }}

+

{{ store.address }} | {{ store.city }}, {{ store.state }}

+

{{ store.phone }}

+

{{ store.hero_copy }}

+

Hours

+
    + {% for hour in store.hours() %} +
  • {{ hour }}
  • + {% endfor %} +
+

Amenities

+
    + {% for amenity in store.amenities() %} +
  • {{ amenity }}
  • + {% endfor %} +
+
+
+ +
+
+
+

Pickup-ready inventory

+

Popular local items at this store

+
+
+
+ + + + + + + + + + + {% for row in inventory_rows %} + + + + + + + {% endfor %} + +
ProductPickup windowQtyAisle
{{ row.product.name }}{{ row.pickup_window }}{{ row.quantity }}{{ row.aisle }}
+
+
+{% endblock %} + + diff --git a/sites/target/templates/stores.html b/sites/target/templates/stores.html new file mode 100644 index 00000000..9de10175 --- /dev/null +++ b/sites/target/templates/stores.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block title %}Stores | Target Demo{% endblock %} +{% block content %} +
+
+
+

Store lookup

+

Find stores, pickup desks, and amenities

+
+ +
+ +
+{% endblock %} + + diff --git a/sites/target/templates/support.html b/sites/target/templates/support.html new file mode 100644 index 00000000..36778ccb --- /dev/null +++ b/sites/target/templates/support.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}Support | Target Demo{% endblock %} +{% block content %} +
+
+
+

Support center

+

Support articles for shipping, pickup, rewards, and protection plans

+
+ +
+
+ All topics + {% for topic in topics %} + {{ topic }} + {% endfor %} +
+
+ {% for article in articles %} + + {{ article.topic }} + {{ article.title }} +

{{ article.summary }}

+
+ {% endfor %} +
+
+{% endblock %} + + diff --git a/sites/target/templates/support_article.html b/sites/target/templates/support_article.html new file mode 100644 index 00000000..816182da --- /dev/null +++ b/sites/target/templates/support_article.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block title %}{{ article.title }} | Target Demo{% endblock %} +{% block content %} +
+
+

{{ article.topic }}

+

{{ article.title }}

+

{{ article.summary }}

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

{{ paragraph }}

+ {% endfor %} + {% if article.upstream_url %} +

Reference upstream topic

+ {% endif %} +
+ +
+{% endblock %} + + diff --git a/sites/target/templates/wishlist.html b/sites/target/templates/wishlist.html new file mode 100644 index 00000000..e61ebccb --- /dev/null +++ b/sites/target/templates/wishlist.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block title %}Wishlist | Target Demo{% endblock %} +{% block content %} + +{% endblock %} + + diff --git a/websyn_start.sh b/websyn_start.sh index 72defad8..b6633619 100644 --- a/websyn_start.sh +++ b/websyn_start.sh @@ -1,11 +1,11 @@ #!/bin/bash -# WebSyn startup: launch all 12 mirror sites, then exec the original CMD. +# WebSyn startup: launch all mirror sites, then exec the original CMD. # This preserves the base image's browser env server (port 8100) as PID 1. set -e SITES=(allrecipes amazon apple arxiv bbc_news booking github google_flights google_map google_search huggingface wolfram_alpha - cambridge_dictionary coursera espn) + cambridge_dictionary coursera espn target) 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