From d2c1297ce1ea165ef196cd87255faa45fc239a3d Mon Sep 17 00:00:00 2001 From: XuanRui LI Date: Thu, 4 Jun 2026 18:48:06 +0800 Subject: [PATCH] Add IKEA WebHarbor mirror --- .gitignore | 8 +- AGENTS.md | 6 +- Dockerfile | 4 +- README.md | 8 +- control_server.py | 2 +- sites/ikea/_health.py | 5 + sites/ikea/app.py | 1255 +++++++++++++++++ sites/ikea/requirements.txt | 3 + sites/ikea/seed_data.py | 682 +++++++++ sites/ikea/static/css/.gitkeep | 0 sites/ikea/static/css/main.css | 357 +++++ sites/ikea/static/icons/.gitkeep | 0 sites/ikea/static/js/.gitkeep | 0 sites/ikea/static/js/main.js | 5 + sites/ikea/tasks.jsonl | 18 + sites/ikea/templates/.gitkeep | 0 sites/ikea/templates/account.html | 40 + sites/ikea/templates/account_edit.html | 24 + sites/ikea/templates/account_orders.html | 19 + sites/ikea/templates/account_rewards.html | 19 + sites/ikea/templates/account_wishlist.html | 11 + sites/ikea/templates/base.html | 77 + sites/ikea/templates/cart.html | 42 + sites/ikea/templates/categories.html | 40 + .../ikea/templates/checkout_confirmation.html | 28 + sites/ikea/templates/checkout_payment.html | 25 + sites/ikea/templates/checkout_pickup.html | 25 + sites/ikea/templates/checkout_review.html | 37 + sites/ikea/templates/checkout_shipping.html | 29 + sites/ikea/templates/checkout_start.html | 33 + sites/ikea/templates/compare.html | 53 + sites/ikea/templates/deals.html | 22 + sites/ikea/templates/index.html | 108 ++ sites/ikea/templates/login.html | 28 + sites/ikea/templates/order_detail.html | 26 + sites/ikea/templates/order_lookup.html | 25 + .../ikea/templates/partials_product_card.html | 33 + sites/ikea/templates/product_detail.html | 119 ++ sites/ikea/templates/products.html | 85 ++ sites/ikea/templates/register.html | 26 + sites/ikea/templates/room_planner.html | 32 + sites/ikea/templates/store_detail.html | 41 + sites/ikea/templates/stores.html | 20 + sites/ikea/templates/support.html | 21 + sites/ikea/templates/support_article.html | 29 + websyn_start.sh | 26 +- 46 files changed, 3466 insertions(+), 30 deletions(-) create mode 100644 sites/ikea/_health.py create mode 100644 sites/ikea/app.py create mode 100644 sites/ikea/requirements.txt create mode 100644 sites/ikea/seed_data.py create mode 100644 sites/ikea/static/css/.gitkeep create mode 100644 sites/ikea/static/css/main.css create mode 100644 sites/ikea/static/icons/.gitkeep create mode 100644 sites/ikea/static/js/.gitkeep create mode 100644 sites/ikea/static/js/main.js create mode 100644 sites/ikea/tasks.jsonl create mode 100644 sites/ikea/templates/.gitkeep create mode 100644 sites/ikea/templates/account.html create mode 100644 sites/ikea/templates/account_edit.html create mode 100644 sites/ikea/templates/account_orders.html create mode 100644 sites/ikea/templates/account_rewards.html create mode 100644 sites/ikea/templates/account_wishlist.html create mode 100644 sites/ikea/templates/base.html create mode 100644 sites/ikea/templates/cart.html create mode 100644 sites/ikea/templates/categories.html create mode 100644 sites/ikea/templates/checkout_confirmation.html create mode 100644 sites/ikea/templates/checkout_payment.html create mode 100644 sites/ikea/templates/checkout_pickup.html create mode 100644 sites/ikea/templates/checkout_review.html create mode 100644 sites/ikea/templates/checkout_shipping.html create mode 100644 sites/ikea/templates/checkout_start.html create mode 100644 sites/ikea/templates/compare.html create mode 100644 sites/ikea/templates/deals.html create mode 100644 sites/ikea/templates/index.html create mode 100644 sites/ikea/templates/login.html create mode 100644 sites/ikea/templates/order_detail.html create mode 100644 sites/ikea/templates/order_lookup.html create mode 100644 sites/ikea/templates/partials_product_card.html create mode 100644 sites/ikea/templates/product_detail.html create mode 100644 sites/ikea/templates/products.html create mode 100644 sites/ikea/templates/register.html create mode 100644 sites/ikea/templates/room_planner.html create mode 100644 sites/ikea/templates/store_detail.html create mode 100644 sites/ikea/templates/stores.html create mode 100644 sites/ikea/templates/support.html create mode 100644 sites/ikea/templates/support_article.html diff --git a/.gitignore b/.gitignore index c2efc04c..e7899232 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,10 @@ sites/*/static/external_cache/ # ============================================================= # Intermediate / volatile — never committed anywhere. # ============================================================= -sites/*/scraped_data/ # scrape pipeline intermediate; runtime data lives in instance_seed/*.db -sites/*/instance/ # rebuilt at every container boot from instance_seed/ +# scrape pipeline intermediate; runtime data lives in instance_seed/*.db +sites/*/scraped_data/ +# rebuilt at every container boot from instance_seed/ +sites/*/instance/ sites/*/venv/ # HF download metadata produced by `hf download`. @@ -92,4 +94,4 @@ secrets.json # ============================================================ # Agent demo results # ============================================================= -agent_demo/runs/ \ No newline at end of file +agent_demo/runs/ diff --git a/AGENTS.md b/AGENTS.md index 8b24944f..b6db8b3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,13 +48,13 @@ Inside the image, sites live at `/opt/WebSyn//`. The path predates the ren # fresh clone ./scripts/fetch_assets.sh # pulls assets from HF ./scripts/build.sh # docker build -t webharbor:dev . -docker run -d -p 8101:8101 -p 40000-40014:40000-40014 webharbor:dev +docker run -d -p 8101:8101 -p 40000-40015:40000-40015 webharbor:dev ``` Or use the published image directly: ```bash -docker run -d -p 8101:8101 -p 40000-40014:40000-40014 \ +docker run -d -p 8101:8101 -p 40000-40015:40000-40015 \ battalion7244/webharbor:latest ``` @@ -129,7 +129,7 @@ python3 -m py_compile sites//app.py # 3. run on alt ports (don't collide with anything you already have running) docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41014:40000-40014 webharbor:dev + -p 8201:8101 -p 41000-41015:40000-40015 webharbor:dev # 4. control plane healthy, all sites alive curl -s http://localhost:8201/health | python3 -m json.tool | head diff --git a/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..20a80a50 100644 --- a/README.md +++ b/README.md @@ -36,17 +36,17 @@ WebHarbor takes a different approach. We leverage coding agent (e.g., Claude Cod - **Deep features unlocked** — carts, checkouts, accounts, all fully testable - **Evolving** — harder tasks drive richer mirrors; the environment grows with agents - **RL-ready** — sub-second database resets between rollouts -- **Community-driven** — 15 sites today, scaling to 100+ together +- **Community-driven** — 16 sites today, scaling to 100+ together ## 🚀 Quickstart One command to run all web environments: ```bash -docker run -p 8101:8101 -p 40000-40014:40000-40014 battalion7244/webharbor:latest +docker run -p 8101:8101 -p 40000-40015:40000-40015 battalion7244/webharbor:latest ``` -Then point your agent at `http://localhost:40000` through `http://localhost:40014` to explore 15 local mirrors of webvoyager sites: `Allrecipes, Amazon, Apple, ArXiv, BBC News, Booking, GitHub, Google Flights, Google Maps, Google Search, Hugging Face, Wolfram Alpha, Cambridge Dictionary, Coursera, and ESPN`. +Then point your agent at `http://localhost:40000` through `http://localhost:40015` to explore 16 local mirrors of webvoyager sites: `Allrecipes, Amazon, Apple, ArXiv, BBC News, Booking, GitHub, Google Flights, Google Maps, Google Search, Hugging Face, Wolfram Alpha, Cambridge Dictionary, Coursera, ESPN, and IKEA`. 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..c82c96c2 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', 'ikea', ] BASE_PORT = 40000 WEBSYN_DIR = '/opt/WebSyn' diff --git a/sites/ikea/_health.py b/sites/ikea/_health.py new file mode 100644 index 00000000..2e65de41 --- /dev/null +++ b/sites/ikea/_health.py @@ -0,0 +1,5 @@ +"""Per-site health probe (optional, called by control_server).""" + + +def health(): + return {"ok": True, "site": "ikea", "note": "Local retail demo ready"} diff --git a/sites/ikea/app.py b/sites/ikea/app.py new file mode 100644 index 00000000..9ad33756 --- /dev/null +++ b/sites/ikea/app.py @@ -0,0 +1,1255 @@ +"""IKEA local demo mirror for WebHarbor.""" +from __future__ import annotations + +import json +import os +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_ +from werkzeug.security import check_password_hash, generate_password_hash + +BASE_DIR = Path(__file__).resolve().parent +INSTANCE_DIR = BASE_DIR / "instance" +DB_PATH = INSTANCE_DIR / "ikea.db" + +INSTANCE_DIR.mkdir(parents=True, exist_ok=True) + +app = Flask(__name__, instance_path=str(INSTANCE_DIR)) +app.config["SECRET_KEY"] = "webharbor-ikea-demo-key" +app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{DB_PATH}" +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + +db = SQLAlchemy(app) +login_manager = LoginManager(app) +login_manager.login_view = "login" +login_manager.login_message = "Sign in to continue in this local IKEA demo." +login_manager.login_message_category = "info" + +DEMO_PASSWORD = "TestPass123!" +ROOM_LABELS = { + "living-room": "Living room", + "bedroom": "Bedroom", + "kitchen": "Kitchen & dining", + "office": "Home office", + "lighting": "Lighting", + "bathroom": "Bathroom", + "kids": "Children's room", + "outdoor": "Outdoor", + "entryway": "Entryway", + "textiles": "Textiles", + "storage": "Storage", + "decor": "Decor", +} + + +def dumps_json(value: Any) -> str: + return json.dumps(value, ensure_ascii=True) + + +def loads_json(value: str | None, default: Any) -> Any: + if not value: + return default + try: + return json.loads(value) + except json.JSONDecodeError: + return default + + +class User(db.Model, UserMixin): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(120), unique=True, nullable=False, index=True) + password_hash = db.Column(db.String(255), nullable=False) + first_name = db.Column(db.String(80), nullable=False) + last_name = db.Column(db.String(80), nullable=False) + phone = db.Column(db.String(40), default="") + city = db.Column(db.String(100), default="") + state = db.Column(db.String(40), default="") + zip_code = db.Column(db.String(20), default="") + preferred_store_slug = db.Column(db.String(120), default="") + family_tier = db.Column(db.String(40), default="IKEA Family") + rewards_points = db.Column(db.Integer, default=0) + newsletter_opt_in = db.Column(db.Boolean, default=True) + + cart_items = db.relationship("CartItem", backref="user", cascade="all, delete-orphan") + wishlist_items = db.relationship("WishlistItem", backref="user", cascade="all, delete-orphan") + compare_items = db.relationship("CompareItem", backref="user", cascade="all, delete-orphan") + orders = db.relationship("Order", backref="user", cascade="all, delete-orphan") + reward_activities = db.relationship( + "RewardActivity", backref="user", cascade="all, delete-orphan" + ) + support_tickets = db.relationship( + "SupportTicket", backref="user", cascade="all, delete-orphan" + ) + + @property + def full_name(self) -> str: + return f"{self.first_name} {self.last_name}" + + def set_password(self, password: str) -> None: + self.password_hash = generate_password_hash(password) + + def check_password(self, password: str) -> bool: + return check_password_hash(self.password_hash, password) + + +class Category(db.Model): + __tablename__ = "categories" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), nullable=False) + slug = db.Column(db.String(120), unique=True, nullable=False, index=True) + room_slug = db.Column(db.String(80), nullable=False, index=True) + description = db.Column(db.Text, default="") + hero_caption = db.Column(db.String(140), default="") + icon_name = db.Column(db.String(80), default="") + + +class Store(db.Model): + __tablename__ = "stores" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(140), nullable=False) + slug = db.Column(db.String(140), unique=True, nullable=False, index=True) + city = db.Column(db.String(120), nullable=False) + state = db.Column(db.String(50), nullable=False) + address = db.Column(db.String(200), nullable=False) + phone = db.Column(db.String(30), default="") + hours = db.Column(db.String(120), default="") + amenities_json = db.Column(db.Text, default="[]") + services_json = db.Column(db.Text, default="[]") + image_path = db.Column(db.String(255), default="") + pickup_note = db.Column(db.String(200), default="") + + inventories = db.relationship("StoreInventory", backref="store", cascade="all, delete-orphan") + pickup_slots = db.relationship("PickupSlot", backref="store", cascade="all, delete-orphan") + + @property + def amenities(self) -> list[str]: + return loads_json(self.amenities_json, []) + + @property + def services(self) -> list[str]: + return loads_json(self.services_json, []) + + +class Product(db.Model): + __tablename__ = "products" + + id = db.Column(db.Integer, primary_key=True) + sku = db.Column(db.String(40), unique=True, nullable=False, index=True) + name = db.Column(db.String(180), nullable=False) + series = db.Column(db.String(80), nullable=False) + slug = db.Column(db.String(180), unique=True, nullable=False, index=True) + category_slug = db.Column(db.String(120), nullable=False, index=True) + room_slug = db.Column(db.String(80), nullable=False, index=True) + description = db.Column(db.Text, default="") + material = db.Column(db.String(120), default="") + color = db.Column(db.String(80), default="") + dimensions = db.Column(db.String(120), default="") + assembly_level = db.Column(db.String(80), default="Weekend setup") + price = db.Column(db.Float, nullable=False) + list_price = db.Column(db.Float, nullable=False) + rating = db.Column(db.Float, default=4.4) + review_count = db.Column(db.Integer, default=0) + availability_bucket = db.Column(db.String(80), default="Ready for pickup") + delivery_note = db.Column(db.String(160), default="") + pickup_badge = db.Column(db.String(160), default="") + image_path = db.Column(db.String(255), default="") + gallery_json = db.Column(db.Text, default="[]") + features_json = db.Column(db.Text, default="[]") + specs_json = db.Column(db.Text, default="{}") + tags_json = db.Column(db.Text, default="[]") + is_featured = db.Column(db.Boolean, default=False) + is_new = db.Column(db.Boolean, default=False) + is_deal = db.Column(db.Boolean, default=False) + is_bestseller = db.Column(db.Boolean, default=False) + compare_group = db.Column(db.String(120), default="") + + reviews = db.relationship("Review", backref="product", cascade="all, delete-orphan") + cart_items = db.relationship("CartItem", backref="product", cascade="all, delete-orphan") + wishlist_items = db.relationship("WishlistItem", backref="product", cascade="all, delete-orphan") + compare_items = db.relationship("CompareItem", backref="product", cascade="all, delete-orphan") + protection_plans = db.relationship( + "ProtectionPlan", backref="product", cascade="all, delete-orphan" + ) + inventories = db.relationship("StoreInventory", backref="product", cascade="all, delete-orphan") + + @property + def gallery(self) -> list[str]: + return loads_json(self.gallery_json, []) + + @property + def features(self) -> list[str]: + return loads_json(self.features_json, []) + + @property + def tags(self) -> list[str]: + return loads_json(self.tags_json, []) + + @property + def specs(self) -> dict[str, str]: + return loads_json(self.specs_json, {}) + + @property + def savings(self) -> float: + return max(self.list_price - self.price, 0.0) + + +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) + aisle = db.Column(db.String(40), default="") + pickup_available = db.Column(db.Boolean, default=True) + delivery_available = db.Column(db.Boolean, default=True) + + +class Review(db.Model): + __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(120), nullable=False) + headline = db.Column(db.String(160), nullable=False) + body = db.Column(db.Text, nullable=False) + rating = db.Column(db.Integer, nullable=False) + helpful_count = db.Column(db.Integer, default=0) + created_on = db.Column(db.String(20), default="") + + +class CartItem(db.Model): + __tablename__ = "cart_items" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False) + quantity = db.Column(db.Integer, nullable=False, default=1) + + +class WishlistItem(db.Model): + __tablename__ = "wishlist_items" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False) + created_on = db.Column(db.String(20), default="") + + +class CompareItem(db.Model): + __tablename__ = "compare_items" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + product_id = db.Column(db.Integer, db.ForeignKey("products.id"), nullable=False) + created_on = db.Column(db.String(20), default="") + + +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) + name = db.Column(db.String(120), nullable=False) + fee = db.Column(db.Float, nullable=False) + window_label = db.Column(db.String(120), nullable=False) + description = db.Column(db.String(200), default="") + carbon_note = 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_date = db.Column(db.String(20), nullable=False) + time_window = db.Column(db.String(80), nullable=False) + remaining_capacity = db.Column(db.Integer, default=0) + + +class Order(db.Model): + __tablename__ = "orders" + + id = db.Column(db.Integer, primary_key=True) + order_number = db.Column(db.String(30), unique=True, nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey("stores.id")) + fulfillment_method = db.Column(db.String(40), nullable=False) + status = db.Column(db.String(80), nullable=False) + subtotal = db.Column(db.Float, nullable=False) + shipping_fee = db.Column(db.Float, nullable=False) + tax = db.Column(db.Float, nullable=False) + total = db.Column(db.Float, nullable=False) + placed_on = db.Column(db.String(20), nullable=False) + delivery_window = db.Column(db.String(120), default="") + pickup_window = db.Column(db.String(120), default="") + contact_name = db.Column(db.String(120), default="") + payment_summary = db.Column(db.String(120), default="") + + items = db.relationship("OrderItem", backref="order", cascade="all, delete-orphan") + payment = db.relationship( + "PaymentMock", backref="order", uselist=False, cascade="all, delete-orphan" + ) + store = db.relationship("Store") + + +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) + quantity = db.Column(db.Integer, nullable=False) + unit_price = db.Column(db.Float, nullable=False) + + product = db.relationship("Product") + + +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"), nullable=False) + method_label = db.Column(db.String(80), nullable=False) + last_four = db.Column(db.String(4), nullable=False) + billing_name = db.Column(db.String(120), nullable=False) + status = db.Column(db.String(40), default="Authorized") + + +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) + label = db.Column(db.String(160), nullable=False) + points_delta = db.Column(db.Integer, nullable=False) + activity_type = db.Column(db.String(80), nullable=False) + occurred_on = db.Column(db.String(20), nullable=False) + + +class SupportArticle(db.Model): + __tablename__ = "support_articles" + + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(180), nullable=False) + slug = db.Column(db.String(180), unique=True, nullable=False, index=True) + category = db.Column(db.String(120), nullable=False) + summary = db.Column(db.String(240), nullable=False) + body = db.Column(db.Text, nullable=False) + related_topics_json = db.Column(db.Text, default="[]") + + @property + def related_topics(self) -> list[str]: + return loads_json(self.related_topics_json, []) + + +class SupportTicket(db.Model): + __tablename__ = "support_tickets" + + id = db.Column(db.Integer, primary_key=True) + ticket_number = db.Column(db.String(30), unique=True, nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + subject = db.Column(db.String(160), nullable=False) + status = db.Column(db.String(80), nullable=False) + article_slug = db.Column(db.String(180), default="") + opened_on = db.Column(db.String(20), nullable=False) + note = db.Column(db.Text, default="") + + +class Deal(db.Model): + __tablename__ = "deals" + + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(160), nullable=False) + slug = db.Column(db.String(160), unique=True, nullable=False, index=True) + category_slug = db.Column(db.String(120), nullable=False) + badge = db.Column(db.String(80), default="") + summary = db.Column(db.String(240), nullable=False) + discount_text = db.Column(db.String(80), nullable=False) + product_sku = db.Column(db.String(40), default="") + + +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(140), nullable=False) + years = db.Column(db.Integer, nullable=False) + price = db.Column(db.Float, nullable=False) + description = db.Column(db.String(200), default="") + benefits_json = db.Column(db.Text, default="[]") + + @property + def benefits(self) -> list[str]: + return loads_json(self.benefits_json, []) + + +class RoomBundle(db.Model): + __tablename__ = "room_bundles" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(160), nullable=False) + slug = db.Column(db.String(160), unique=True, nullable=False, index=True) + room_slug = db.Column(db.String(80), nullable=False) + summary = db.Column(db.String(240), nullable=False) + total_price = db.Column(db.Float, nullable=False) + item_skus_json = db.Column(db.Text, default="[]") + hero_note = db.Column(db.String(160), default="") + + @property + def item_skus(self) -> list[str]: + return loads_json(self.item_skus_json, []) + + +@login_manager.user_loader +def load_user(user_id: str) -> User | None: + return db.session.get(User, int(user_id)) + + +@app.template_filter("money") +def money(value: float) -> str: + return f"${value:,.2f}" + + +@app.template_filter("stars") +def stars(value: float) -> str: + return f"{value:.1f}" + + +def cart_items_for_user(user: User | None = None) -> list[CartItem]: + if not user or not user.is_authenticated: + return [] + return ( + CartItem.query.filter_by(user_id=user.id) + .join(Product) + .order_by(Product.name.asc()) + .all() + ) + + +def cart_summary(user: User | None = None) -> dict[str, Any]: + items = cart_items_for_user(user or current_user) + subtotal = sum(item.product.price * item.quantity for item in items) + return { + "items": items, + "count": sum(item.quantity for item in items), + "subtotal": subtotal, + "estimated_tax": round(subtotal * 0.085, 2), + } + + +def compare_products() -> list[Product]: + if not current_user.is_authenticated: + return [] + items = CompareItem.query.filter_by(user_id=current_user.id).all() + return [item.product for item in items] + + +def selected_store() -> Store | None: + preferred_slug = session.get("checkout_store") + if not preferred_slug and current_user.is_authenticated: + preferred_slug = current_user.preferred_store_slug + if not preferred_slug: + return Store.query.order_by(Store.name.asc()).first() + return Store.query.filter_by(slug=preferred_slug).first() + + +def inventory_for_store(product: Product, store_slug: str | None = None) -> StoreInventory | None: + slug = store_slug or (selected_store().slug if selected_store() else "") + if not slug: + return None + return ( + StoreInventory.query.join(Store) + .filter(Store.slug == slug, StoreInventory.product_id == product.id) + .first() + ) + + +def query_products_from_request(category_slug: str | None = None): + query = Product.query + if category_slug: + query = query.filter_by(category_slug=category_slug) + + search = request.args.get("q", "").strip() + if search: + for token in search.split(): + token_like = f"%{token}%" + query = query.filter( + or_( + Product.name.ilike(token_like), + Product.series.ilike(token_like), + Product.description.ilike(token_like), + Product.tags_json.ilike(token_like), + Product.room_slug.ilike(token_like), + ) + ) + + room = request.args.get("room", "").strip() + if room: + query = query.filter_by(room_slug=room) + + series = request.args.get("series", "").strip() + if series: + query = query.filter_by(series=series) + + if request.args.get("deal") == "1": + query = query.filter_by(is_deal=True) + + min_price = request.args.get("min_price", "").strip() + max_price = request.args.get("max_price", "").strip() + if min_price: + query = query.filter(Product.price >= float(min_price)) + if max_price: + query = query.filter(Product.price <= float(max_price)) + + min_rating = request.args.get("min_rating", "").strip() + if min_rating: + query = query.filter(Product.rating >= float(min_rating)) + + availability = request.args.get("availability", "").strip() + if availability: + query = query.filter(Product.availability_bucket.ilike(f"%{availability}%")) + + pickup_only = request.args.get("pickup", "").strip() == "1" + if pickup_only: + store_slug = request.args.get("store", "").strip() or (selected_store().slug if selected_store() else "") + if store_slug: + query = query.join(StoreInventory).join(Store).filter( + Store.slug == store_slug, + StoreInventory.pickup_available.is_(True), + StoreInventory.quantity > 0, + ) + + 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.is_new.desc(), Product.series.asc()) + elif sort == "popular": + query = query.order_by(Product.review_count.desc(), Product.rating.desc()) + else: + query = query.order_by( + Product.is_featured.desc(), + Product.is_deal.desc(), + Product.rating.desc(), + Product.review_count.desc(), + ) + return query.distinct() + + +def checkout_state() -> dict[str, Any]: + state = session.setdefault("checkout_state", {}) + return state + + +def clear_checkout_state() -> None: + session.pop("checkout_state", None) + session.pop("confirmation_order_number", None) + + +def ensure_checkout_ready() -> tuple[dict[str, Any], dict[str, Any]]: + summary = cart_summary() + if not summary["items"]: + flash("Your cart is empty in this local demo.", "warning") + return {}, summary + return checkout_state(), summary + + +@app.context_processor +def inject_globals() -> dict[str, Any]: + categories = Category.query.order_by(Category.name.asc()).all() + stores = Store.query.order_by(Store.name.asc()).all() + compare_count = len(compare_products()) if current_user.is_authenticated else 0 + summary = cart_summary() if current_user.is_authenticated else {"count": 0, "subtotal": 0.0} + return { + "nav_categories": categories[:8], + "room_labels": ROOM_LABELS, + "store_options": stores, + "cart_count": summary["count"], + "compare_count": compare_count, + "demo_password": DEMO_PASSWORD, + } + + +@app.route("/") +@app.route("/home") +def index(): + featured_products = Product.query.filter_by(is_featured=True).order_by(Product.rating.desc()).limit(8).all() + deals = Deal.query.order_by(Deal.title.asc()).limit(6).all() + categories = Category.query.order_by(Category.name.asc()).all() + bundles = RoomBundle.query.order_by(RoomBundle.room_slug.asc()).limit(6).all() + support_articles = SupportArticle.query.order_by(SupportArticle.category.asc(), SupportArticle.title.asc()).limit(6).all() + stores = Store.query.order_by(Store.name.asc()).limit(4).all() + return render_template( + "index.html", + featured_products=featured_products, + deals=deals, + categories=categories, + bundles=bundles, + support_articles=support_articles, + stores=stores, + ) + + +@app.route("/categories") +def categories(): + categories_list = Category.query.order_by(Category.room_slug.asc(), Category.name.asc()).all() + bundles = RoomBundle.query.order_by(RoomBundle.total_price.asc()).all() + return render_template("categories.html", categories=categories_list, bundles=bundles) + + +@app.route("/category/") +def category_view(category_slug: str): + category = Category.query.filter_by(slug=category_slug).first_or_404() + products = query_products_from_request(category_slug=category_slug).all() + return render_template( + "products.html", + title=category.name, + description=category.description, + products=products, + category=category, + series_options=sorted({product.series for product in products}), + ) + + +@app.route("/products") +def products(): + results = query_products_from_request().all() + return render_template( + "products.html", + title="Products", + description="Search, filter, and compare local IKEA demo products.", + products=results, + category=None, + series_options=sorted({product.series for product in Product.query.all()}), + ) + + +@app.route("/search") +def search(): + results = query_products_from_request().all() + return render_template( + "products.html", + title=f"Search results for “{request.args.get('q', '').strip()}”", + description="Local search results from deterministic demo data.", + products=results, + category=None, + series_options=sorted({product.series for product in Product.query.all()}), + ) + + +@app.route("/deals") +def deals(): + deal_rows = Deal.query.order_by(Deal.badge.asc(), Deal.title.asc()).all() + highlighted = { + deal.product_sku: Product.query.filter_by(sku=deal.product_sku).first() + for deal in deal_rows + if deal.product_sku + } + return render_template("deals.html", deals=deal_rows, highlighted=highlighted) + + +@app.route("/product/") +def product_detail(sku: str): + product = Product.query.filter_by(sku=sku).first_or_404() + related = ( + Product.query.filter( + Product.category_slug == product.category_slug, + Product.sku != product.sku, + ) + .order_by(Product.rating.desc()) + .limit(4) + .all() + ) + category = Category.query.filter_by(slug=product.category_slug).first() + store_records = ( + StoreInventory.query.filter_by(product_id=product.id) + .join(Store) + .order_by(StoreInventory.quantity.desc(), Store.city.asc()) + .limit(4) + .all() + ) + return render_template( + "product_detail.html", + product=product, + category=category, + related=related, + store_records=store_records, + ) + + +@app.route("/compare") +@login_required +def compare(): + products_to_compare = compare_products() + shared_keys = [] + if products_to_compare: + shared_keys = sorted({key for product in products_to_compare for key in product.specs}) + return render_template( + "compare.html", + products=products_to_compare, + shared_keys=shared_keys, + ) + + +@app.post("/compare/toggle/") +@login_required +def compare_toggle(sku: str): + product = Product.query.filter_by(sku=sku).first_or_404() + 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: + flash("You can compare up to four products at once.", "warning") + else: + db.session.add( + CompareItem( + user_id=current_user.id, + product_id=product.id, + created_on="2026-06-04", + ) + ) + flash(f"Added {product.name} to compare.", "success") + db.session.commit() + return redirect(request.referrer or url_for("compare")) + + +@app.route("/room-planner") +def room_planner(): + room = request.args.get("room", "").strip() + bundles_query = RoomBundle.query + if room: + bundles_query = bundles_query.filter_by(room_slug=room) + bundles = bundles_query.order_by(RoomBundle.total_price.asc()).all() + return render_template("room_planner.html", bundles=bundles, selected_room=room) + + +@app.post("/room-planner/add/") +@login_required +def add_bundle(bundle_slug: str): + bundle = RoomBundle.query.filter_by(slug=bundle_slug).first_or_404() + skus = bundle.item_skus + for sku in skus: + product = Product.query.filter_by(sku=sku).first() + if not product: + continue + item = CartItem.query.filter_by(user_id=current_user.id, product_id=product.id).first() + if item: + item.quantity += 1 + else: + db.session.add(CartItem(user_id=current_user.id, product_id=product.id, quantity=1)) + db.session.commit() + flash(f"Added the {bundle.name} bundle to your cart.", "success") + return redirect(url_for("cart")) + + +@app.route("/stores") +def stores(): + search_text = request.args.get("q", "").strip() + query = Store.query + if search_text: + token_like = f"%{search_text}%" + query = query.filter( + or_(Store.name.ilike(token_like), Store.city.ilike(token_like), Store.state.ilike(token_like)) + ) + stores_list = query.order_by(Store.state.asc(), Store.city.asc()).all() + return render_template("stores.html", stores=stores_list) + + +@app.route("/stores/") +def store_detail(store_slug: str): + store = Store.query.filter_by(slug=store_slug).first_or_404() + slots = PickupSlot.query.filter_by(store_id=store.id).order_by(PickupSlot.slot_date.asc()).all() + featured_products = ( + Product.query.join(StoreInventory) + .filter(StoreInventory.store_id == store.id, StoreInventory.quantity > 0) + .order_by(Product.rating.desc()) + .limit(8) + .all() + ) + return render_template( + "store_detail.html", + store=store, + slots=slots, + featured_products=featured_products, + ) + + +@app.route("/support") +def support(): + query_text = request.args.get("q", "").strip() + articles_query = SupportArticle.query + if query_text: + token_like = f"%{query_text}%" + articles_query = articles_query.filter( + or_( + SupportArticle.title.ilike(token_like), + SupportArticle.summary.ilike(token_like), + SupportArticle.body.ilike(token_like), + SupportArticle.category.ilike(token_like), + ) + ) + articles = articles_query.order_by(SupportArticle.category.asc(), SupportArticle.title.asc()).all() + return render_template("support.html", articles=articles, query_text=query_text) + + +@app.route("/support/") +def support_article(article_slug: str): + article = SupportArticle.query.filter_by(slug=article_slug).first_or_404() + related = ( + SupportArticle.query.filter( + SupportArticle.category == article.category, + SupportArticle.slug != article.slug, + ) + .order_by(SupportArticle.title.asc()) + .limit(4) + .all() + ) + return render_template("support_article.html", article=article, related=related) + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if current_user.is_authenticated: + return redirect(url_for("account")) + if request.method == "POST": + email = request.form.get("email", "").strip().lower() + password = request.form.get("password", "") + user = User.query.filter_by(email=email).first() + if user and user.check_password(password): + login_user(user) + flash("Signed in to the IKEA demo account.", "success") + return redirect(url_for("account")) + flash("That demo sign-in did not match our seeded users.", "danger") + return render_template("login.html") + + +@app.route("/register", methods=["GET", "POST"]) +def register(): + if current_user.is_authenticated: + return redirect(url_for("account")) + if request.method == "POST": + email = request.form.get("email", "").strip().lower() + if User.query.filter_by(email=email).first(): + flash("That email is already present in this local demo.", "warning") + return redirect(url_for("login")) + user = User( + email=email, + first_name=request.form.get("first_name", "Demo").strip() or "Demo", + last_name=request.form.get("last_name", "Guest").strip() or "Guest", + city=request.form.get("city", "").strip(), + state=request.form.get("state", "").strip(), + zip_code=request.form.get("zip_code", "").strip(), + phone=request.form.get("phone", "").strip(), + preferred_store_slug=request.form.get("preferred_store_slug", "").strip(), + rewards_points=250, + ) + user.set_password(request.form.get("password", DEMO_PASSWORD)) + db.session.add(user) + db.session.commit() + login_user(user) + flash("Created a new local demo account.", "success") + return redirect(url_for("account")) + return render_template("register.html") + + +@app.route("/logout") +@login_required +def logout(): + logout_user() + flash("Signed out of the IKEA demo.", "info") + return redirect(url_for("index")) + + +@app.route("/account") +@login_required +def account(): + open_orders = ( + Order.query.filter_by(user_id=current_user.id) + .order_by(Order.placed_on.desc(), Order.order_number.desc()) + .limit(4) + .all() + ) + tickets = SupportTicket.query.filter_by(user_id=current_user.id).order_by(SupportTicket.opened_on.desc()).all() + return render_template("account.html", open_orders=open_orders, tickets=tickets) + + +@app.route("/account/edit", methods=["GET", "POST"]) +@login_required +def account_edit(): + if request.method == "POST": + current_user.first_name = request.form.get("first_name", current_user.first_name).strip() + current_user.last_name = request.form.get("last_name", current_user.last_name).strip() + current_user.phone = request.form.get("phone", current_user.phone).strip() + current_user.city = request.form.get("city", current_user.city).strip() + current_user.state = request.form.get("state", current_user.state).strip() + current_user.zip_code = request.form.get("zip_code", current_user.zip_code).strip() + current_user.preferred_store_slug = request.form.get( + "preferred_store_slug", current_user.preferred_store_slug + ).strip() + current_user.newsletter_opt_in = request.form.get("newsletter_opt_in") == "on" + db.session.commit() + flash("Saved your local profile updates.", "success") + return redirect(url_for("account")) + return render_template("account_edit.html") + + +@app.route("/account/orders") +@login_required +def account_orders(): + orders = ( + Order.query.filter_by(user_id=current_user.id) + .order_by(Order.placed_on.desc(), Order.order_number.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.occurred_on.desc()) + .all() + ) + return render_template("account_rewards.html", activities=activities) + + +@app.route("/account/wishlist") +@login_required +def account_wishlist(): + items = ( + WishlistItem.query.filter_by(user_id=current_user.id) + .join(Product) + .order_by(Product.name.asc()) + .all() + ) + return render_template("account_wishlist.html", items=items) + + +@app.post("/wishlist/toggle/") +@login_required +def wishlist_toggle(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 wishlist.", "info") + else: + db.session.add( + WishlistItem( + user_id=current_user.id, + product_id=product.id, + created_on="2026-06-04", + ) + ) + flash(f"Saved {product.name} to wishlist.", "success") + db.session.commit() + return redirect(request.referrer or url_for("account_wishlist")) + + +@app.route("/cart") +@login_required +def cart(): + summary = cart_summary() + return render_template("cart.html", summary=summary) + + +@app.post("/cart/add") +@login_required +def cart_add(): + product = Product.query.filter_by(sku=request.form.get("sku", "").strip()).first_or_404() + quantity = max(int(request.form.get("quantity", 1)), 1) + item = CartItem.query.filter_by(user_id=current_user.id, product_id=product.id).first() + if item: + item.quantity += quantity + else: + db.session.add(CartItem(user_id=current_user.id, product_id=product.id, quantity=quantity)) + db.session.commit() + flash(f"Added {product.name} to your cart.", "success") + return redirect(request.referrer or url_for("cart")) + + +@app.post("/cart/update/") +@login_required +def cart_update(item_id: int): + item = CartItem.query.filter_by(id=item_id, user_id=current_user.id).first_or_404() + quantity = max(int(request.form.get("quantity", item.quantity)), 1) + item.quantity = quantity + db.session.commit() + flash("Updated cart quantity.", "success") + return redirect(url_for("cart")) + + +@app.post("/cart/remove/") +@login_required +def cart_remove(item_id: int): + item = CartItem.query.filter_by(id=item_id, user_id=current_user.id).first_or_404() + db.session.delete(item) + db.session.commit() + flash("Removed item from cart.", "info") + return redirect(url_for("cart")) + + +@app.route("/checkout", methods=["GET", "POST"]) +@login_required +def checkout_start(): + state, summary = ensure_checkout_ready() + if not summary["items"]: + return redirect(url_for("cart")) + if request.method == "POST": + method = request.form.get("method", "delivery") + state["method"] = method + session.modified = True + if method == "pickup": + return redirect(url_for("checkout_pickup")) + return redirect(url_for("checkout_shipping")) + return render_template("checkout_start.html", summary=summary, state=state) + + +@app.route("/checkout/shipping", methods=["GET", "POST"]) +@login_required +def checkout_shipping(): + state, summary = ensure_checkout_ready() + if not summary["items"]: + return redirect(url_for("cart")) + delivery_options = DeliveryOption.query.order_by(DeliveryOption.fee.asc()).all() + if request.method == "POST": + state["method"] = "delivery" + state["delivery_option"] = request.form.get("delivery_option", "parcel") + state["delivery_name"] = request.form.get("delivery_name", current_user.full_name).strip() + state["delivery_zip"] = request.form.get("delivery_zip", current_user.zip_code).strip() + session.modified = True + return redirect(url_for("checkout_payment")) + return render_template( + "checkout_shipping.html", + summary=summary, + state=state, + delivery_options=delivery_options, + ) + + +@app.route("/checkout/pickup", methods=["GET", "POST"]) +@login_required +def checkout_pickup(): + state, summary = ensure_checkout_ready() + if not summary["items"]: + return redirect(url_for("cart")) + stores = Store.query.order_by(Store.state.asc(), Store.city.asc()).all() + if request.method == "POST": + state["method"] = "pickup" + state["store_slug"] = request.form.get("store_slug", "") + state["pickup_slot_id"] = request.form.get("pickup_slot_id", "") + session["checkout_store"] = state["store_slug"] + session.modified = True + return redirect(url_for("checkout_payment")) + selected_slug = state.get("store_slug") or (selected_store().slug if selected_store() else "") + slots = [] + if selected_slug: + store = Store.query.filter_by(slug=selected_slug).first() + if store: + slots = PickupSlot.query.filter_by(store_id=store.id).order_by(PickupSlot.slot_date.asc()).all() + return render_template( + "checkout_pickup.html", + summary=summary, + state=state, + stores=stores, + slots=slots, + ) + + +@app.route("/checkout/payment", methods=["GET", "POST"]) +@login_required +def checkout_payment(): + state, summary = ensure_checkout_ready() + if not summary["items"]: + return redirect(url_for("cart")) + if request.method == "POST": + state["payment_method"] = request.form.get("payment_method", "IKEA Family Visa") + state["payment_last4"] = request.form.get("payment_last4", "4242").strip()[-4:] + state["billing_name"] = request.form.get("billing_name", current_user.full_name).strip() + session.modified = True + return redirect(url_for("checkout_review")) + return render_template("checkout_payment.html", summary=summary, state=state) + + +@app.route("/checkout/review", methods=["GET", "POST"]) +@login_required +def checkout_review(): + state, summary = ensure_checkout_ready() + if not summary["items"]: + return redirect(url_for("cart")) + delivery_option = None + pickup_store = None + pickup_slot = None + if state.get("delivery_option"): + delivery_option = DeliveryOption.query.filter_by(slug=state["delivery_option"]).first() + if state.get("store_slug"): + pickup_store = Store.query.filter_by(slug=state["store_slug"]).first() + if state.get("pickup_slot_id"): + pickup_slot = PickupSlot.query.filter_by(id=int(state["pickup_slot_id"])).first() + + if request.method == "POST": + next_index = (db.session.query(db.func.count(Order.id)).scalar() or 0) + 1 + order_number = f"IK-26{next_index:04d}" + shipping_fee = delivery_option.fee if delivery_option else 0.0 + total = round(summary["subtotal"] + summary["estimated_tax"] + shipping_fee, 2) + order = Order( + order_number=order_number, + user_id=current_user.id, + store_id=pickup_store.id if pickup_store else None, + fulfillment_method=state.get("method", "delivery"), + status="Ready for processing", + subtotal=summary["subtotal"], + shipping_fee=shipping_fee, + tax=summary["estimated_tax"], + total=total, + placed_on="2026-06-04", + delivery_window=delivery_option.window_label if delivery_option else "", + pickup_window=f"{pickup_slot.slot_date} · {pickup_slot.time_window}" if pickup_slot else "", + contact_name=state.get("delivery_name") or current_user.full_name, + payment_summary=f"{state.get('payment_method', 'Demo Card')} •••• {state.get('payment_last4', '4242')}", + ) + db.session.add(order) + db.session.flush() + for item in summary["items"]: + db.session.add( + OrderItem( + order_id=order.id, + product_id=item.product_id, + quantity=item.quantity, + unit_price=item.product.price, + ) + ) + db.session.delete(item) + db.session.add( + PaymentMock( + order_id=order.id, + method_label=state.get("payment_method", "Demo Card"), + last_four=state.get("payment_last4", "4242"), + billing_name=state.get("billing_name", current_user.full_name), + status="Authorized in local demo", + ) + ) + db.session.add( + RewardActivity( + user_id=current_user.id, + label=f"Order {order_number}", + points_delta=int(summary["subtotal"] // 10), + activity_type="purchase", + occurred_on="2026-06-04", + ) + ) + current_user.rewards_points += int(summary["subtotal"] // 10) + db.session.commit() + session["confirmation_order_number"] = order_number + clear_checkout_state() + session["confirmation_order_number"] = order_number + return redirect(url_for("checkout_confirmation")) + + return render_template( + "checkout_review.html", + summary=summary, + state=state, + delivery_option=delivery_option, + pickup_store=pickup_store, + pickup_slot=pickup_slot, + ) + + +@app.route("/checkout/confirmation") +@login_required +def checkout_confirmation(): + order_number = session.get("confirmation_order_number") + if not order_number: + flash("There is no recent checkout confirmation to show.", "warning") + return redirect(url_for("account_orders")) + order = Order.query.filter_by(order_number=order_number, user_id=current_user.id).first_or_404() + return render_template("checkout_confirmation.html", order=order) + + +@app.route("/order-lookup", methods=["GET", "POST"]) +def order_lookup(): + order = None + looked_up = False + if request.method == "POST": + looked_up = True + order_number = request.form.get("order_number", "").strip().upper() + email = request.form.get("email", "").strip().lower() + query = Order.query.filter_by(order_number=order_number) + if email: + query = query.join(User).filter(User.email == email) + order = query.first() + if not order: + flash("No synthetic order matched that lookup.", "warning") + return render_template("order_lookup.html", order=order, looked_up=looked_up) + + +@app.route("/order/") +def order_detail(order_number: str): + order = Order.query.filter_by(order_number=order_number.upper()).first_or_404() + return render_template("order_detail.html", order=order) + + +@app.route("/_health") +def health(): + return jsonify( + { + "ok": True, + "site": "ikea", + "products": Product.query.count(), + "stores": Store.query.count(), + } + ) + + +def bootstrap_site() -> None: + from seed_data import seed_benchmark_users, seed_database + + with app.app_context(): + db.create_all() + seed_database() + seed_benchmark_users() + + +if os.environ.get("WEBSYN_SKIP_BOOTSTRAP") != "1": + bootstrap_site() + + +if __name__ == "__main__": + port = int(os.environ.get("PORT", 5000)) + app.run(host="0.0.0.0", port=port, debug=False) diff --git a/sites/ikea/requirements.txt b/sites/ikea/requirements.txt new file mode 100644 index 00000000..a4d756f4 --- /dev/null +++ b/sites/ikea/requirements.txt @@ -0,0 +1,3 @@ +Flask +Flask-Login +Flask-SQLAlchemy diff --git a/sites/ikea/seed_data.py b/sites/ikea/seed_data.py new file mode 100644 index 00000000..b73551e5 --- /dev/null +++ b/sites/ikea/seed_data.py @@ -0,0 +1,682 @@ +"""Deterministic seed data and local SVG asset generation for the IKEA demo.""" +from __future__ import annotations + +import os +import random +import shutil +from pathlib import Path + +os.environ.setdefault("WEBSYN_SKIP_BOOTSTRAP", "1") + +from app import ( # noqa: E402 + BASE_DIR, + DB_PATH, + CartItem, + Category, + CompareItem, + Deal, + DeliveryOption, + Order, + OrderItem, + PaymentMock, + PickupSlot, + Product, + ProtectionPlan, + RewardActivity, + RoomBundle, + Store, + StoreInventory, + SupportArticle, + SupportTicket, + User, + WishlistItem, + app, + db, + dumps_json, +) +from app import Review # noqa: E402 +from app import ROOM_LABELS # noqa: E402 + +RNG = random.Random(20260604) +STATIC_IMAGES = BASE_DIR / "static" / "images" +INSTANCE_SEED_DIR = BASE_DIR / "instance_seed" + +CATEGORY_DATA = [ + ("living-room-seating", "Living room seating", "living-room", "Modular sofas, nesting tables, and relaxed seating built for everyday lounging."), + ("bedroom-storage", "Bedroom storage", "bedroom", "Beds, dressers, and wardrobes that keep calm routines on schedule."), + ("kitchen-dining", "Kitchen & dining", "kitchen", "Tables, chairs, and smart carts sized for shared meals and small spaces."), + ("home-office", "Home office", "office", "Desks, seating, and organizers for focused work-from-home setups."), + ("lighting", "Lighting", "lighting", "Layered task, floor, and pendant lighting for brighter rooms."), + ("bathroom", "Bathroom", "bathroom", "Vanities, caddies, mirrors, and textile helpers for small baths."), + ("kids-room", "Children's room", "kids", "Toy storage, activity furniture, and sleep essentials sized for families."), + ("outdoor-living", "Outdoor living", "outdoor", "Patio seating, balcony dining, and weather-tough storage."), + ("entryway", "Entryway", "entryway", "Hooks, benches, shoe storage, and compact drop zones."), + ("textiles-rugs", "Textiles & rugs", "textiles", "Rugs, curtains, throws, and cushion layers for warmth and softness."), + ("storage-organization", "Storage & organization", "storage", "Shelving, bins, rails, and carts that keep clutter sorted."), + ("decor-mirrors", "Decor & mirrors", "decor", "Mirrors, planters, wall accents, and finishing touches for every room."), +] + +STORE_DATA = [ + ("IKEA Brooklyn", "brooklyn-ny", "Brooklyn", "NY", "1 Beard Street, Brooklyn, NY 11231"), + ("IKEA Burbank", "burbank-ca", "Burbank", "CA", "600 S Ikea Way, Burbank, CA 91502"), + ("IKEA Schaumburg", "schaumburg-il", "Schaumburg", "IL", "1800 E McConnor Pkwy, Schaumburg, IL 60173"), + ("IKEA Renton", "renton-wa", "Renton", "WA", "601 SW 41st Street, Renton, WA 98057"), + ("IKEA Stoughton", "stoughton-ma", "Stoughton", "MA", "1 Ikea Way, Stoughton, MA 02072"), + ("IKEA Atlanta", "atlanta-ga", "Atlanta", "GA", "441 16th Street NW, Atlanta, GA 30363"), + ("IKEA Round Rock", "round-rock-tx", "Round Rock", "TX", "1 Ikea Way, Round Rock, TX 78664"), + ("IKEA Sunrise", "sunrise-fl", "Sunrise", "FL", "151 NW 136th Avenue, Sunrise, FL 33325"), + ("IKEA Paramus", "paramus-nj", "Paramus", "NJ", "100 Ikea Drive, Paramus, NJ 07652"), + ("IKEA Conshohocken", "conshohocken-pa", "Conshohocken", "PA", "2206 Chemical Road, Conshohocken, PA 19428"), + ("IKEA Portland", "portland-or", "Portland", "OR", "10280 NE Cascades Pkwy, Portland, OR 97220"), + ("IKEA Tempe", "tempe-az", "Tempe", "AZ", "2110 W Ikea Way, Tempe, AZ 85284"), + ("IKEA Minneapolis", "minneapolis-mn", "Minneapolis", "MN", "8000 Ikea Way, Bloomington, MN 55425"), + ("IKEA San Diego", "san-diego-ca", "San Diego", "CA", "2149 Fenton Pkwy, San Diego, CA 92108"), + ("IKEA Charlotte", "charlotte-nc", "Charlotte", "NC", "8300 Ikea Blvd, Charlotte, NC 28262"), +] + +SERIES = [ + "BJORNA", "LINDMO", "VALLTORP", "HAVSBERG", "NORDHAV", "LAGKAPTEN", + "KLARNA", "SOLVIK", "RYTM", "FJARNA", "HEMLUND", "SKOGEN", + "GLANSA", "SANDVIK", "TROFASTA", "MALARO", "VIKSTEN", "ELDMARK", +] + +COLORS = [ + "Birch", "Oak", "White", "Warm beige", "Forest green", "Slate blue", + "Charcoal", "Soft gray", "Yellow stripe", "Rust red", "Natural pine", +] + +MATERIALS = [ + "Solid pine", "Ash veneer", "Steel", "Powder-coated steel", "Cotton blend", + "Wool mix", "Bamboo", "Tempered glass", "Particleboard", "Birch veneer", +] + +SPECIAL_PRODUCTS = [ + {"sku": "IK-10001", "name": "HEMLUND modular sofa", "series": "HEMLUND", "category_slug": "living-room-seating", "room_slug": "living-room", "price": 799.0, "list_price": 899.0, "material": "Cotton blend", "color": "Forest green", "dimensions": "118\" W x 37\" D x 32\" H", "assembly": "Two-person setup", "tags": ["modular", "chaise", "washable cover"], "specs": {"Seats": "4", "Cover": "Removable", "Frame": "Kiln-dried pine", "Depth": "37 in", "Width": "118 in"}, "featured": True, "deal": True}, + {"sku": "IK-10002", "name": "BJORNA lift-top coffee table", "series": "BJORNA", "category_slug": "living-room-seating", "room_slug": "living-room", "price": 229.0, "list_price": 279.0, "material": "Ash veneer", "color": "Oak", "dimensions": "47\" W x 24\" D x 18\" H", "assembly": "Quick setup", "tags": ["storage", "oak", "living room"], "specs": {"Lift top": "Yes", "Storage shelf": "Dual compartment", "Width": "47 in", "Depth": "24 in", "Finish": "Matte oak"}, "featured": True, "deal": False}, + {"sku": "IK-10003", "name": "NORDHAV storage bed frame", "series": "NORDHAV", "category_slug": "bedroom-storage", "room_slug": "bedroom", "price": 649.0, "list_price": 749.0, "material": "Birch veneer", "color": "Warm beige", "dimensions": "84\" W x 87\" D x 45\" H", "assembly": "Weekend setup", "tags": ["queen", "under-bed drawers", "bedroom"], "specs": {"Size": "Queen", "Drawers": "4", "Headboard": "Integrated shelf", "Width": "84 in", "Depth": "87 in"}, "featured": True, "deal": True}, + {"sku": "IK-10004", "name": "NORDHAV 6-drawer dresser", "series": "NORDHAV", "category_slug": "bedroom-storage", "room_slug": "bedroom", "price": 379.0, "list_price": 429.0, "material": "Particleboard", "color": "White", "dimensions": "63\" W x 19\" D x 31\" H", "assembly": "Quick setup", "tags": ["dresser", "6 drawers", "white"], "specs": {"Drawers": "6", "Soft close": "Yes", "Width": "63 in", "Depth": "19 in", "Height": "31 in"}, "featured": False, "deal": False}, + {"sku": "IK-10005", "name": "FJARNA extendable dining table", "series": "FJARNA", "category_slug": "kitchen-dining", "room_slug": "kitchen", "price": 499.0, "list_price": 569.0, "material": "Solid pine", "color": "Natural pine", "dimensions": "71-94\" W x 35\" D x 30\" H", "assembly": "Two-person setup", "tags": ["extendable", "dining table", "seats 6-8"], "specs": {"Seats": "6-8", "Extension leaves": "2", "Width": "71-94 in", "Top": "Solid pine", "Care": "Easy-wipe lacquer"}, "featured": True, "deal": True}, + {"sku": "IK-10006", "name": "FJARNA spindle dining chair", "series": "FJARNA", "category_slug": "kitchen-dining", "room_slug": "kitchen", "price": 89.0, "list_price": 109.0, "material": "Solid pine", "color": "Birch", "dimensions": "18\" W x 20\" D x 35\" H", "assembly": "Quick setup", "tags": ["chair", "dining", "birch"], "specs": {"Seat height": "18 in", "Stackable": "No", "Frame": "Solid pine", "Width": "18 in", "Depth": "20 in"}, "featured": False, "deal": False}, + {"sku": "IK-10007", "name": "LAGKAPTEN sit-stand desk", "series": "LAGKAPTEN", "category_slug": "home-office", "room_slug": "office", "price": 459.0, "list_price": 499.0, "material": "Powder-coated steel", "color": "White", "dimensions": "55\" W x 27\" D x 25-50\" H", "assembly": "Weekend setup", "tags": ["desk", "adjustable", "cable management"], "specs": {"Height range": "25-50 in", "Cable tray": "Included", "Width": "55 in", "Depth": "27 in", "Memory presets": "4"}, "featured": True, "deal": False}, + {"sku": "IK-10008", "name": "LAGKAPTEN ergonomic swivel chair", "series": "LAGKAPTEN", "category_slug": "home-office", "room_slug": "office", "price": 219.0, "list_price": 259.0, "material": "Steel", "color": "Charcoal", "dimensions": "27\" W x 27\" D x 47\" H", "assembly": "Quick setup", "tags": ["ergonomic", "mesh", "office"], "specs": {"Lumbar support": "Adjustable", "Tilt lock": "Yes", "Seat height": "17-22 in", "Material": "Mesh back", "Weight limit": "275 lb"}, "featured": False, "deal": False}, + {"sku": "IK-10009", "name": "SMYCKA arc floor lamp", "series": "SMYCKA", "category_slug": "lighting", "room_slug": "lighting", "price": 149.0, "list_price": 179.0, "material": "Steel", "color": "Slate blue", "dimensions": "18\" W x 68\" D x 83\" H", "assembly": "Quick setup", "tags": ["floor lamp", "reading", "living room"], "specs": {"Bulb base": "E26", "Dimmable": "Yes", "Cord length": "96 in", "Reach": "68 in", "Shade": "Metal"}, "featured": True, "deal": False}, + {"sku": "IK-10010", "name": "SMYCKA pendant cluster lamp", "series": "SMYCKA", "category_slug": "lighting", "room_slug": "lighting", "price": 189.0, "list_price": 229.0, "material": "Tempered glass", "color": "Warm beige", "dimensions": "21\" W x 21\" D x 54\" H", "assembly": "Two-person setup", "tags": ["pendant", "kitchen island", "glass"], "specs": {"Bulbs": "3", "Dimmable": "Yes", "Cord drop": "Adjustable", "Diameter": "21 in", "Shade": "Hand-blown glass"}, "featured": False, "deal": True}, + {"sku": "IK-10011", "name": "KLARNA rolling vanity cart", "series": "KLARNA", "category_slug": "bathroom", "room_slug": "bathroom", "price": 79.0, "list_price": 99.0, "material": "Steel", "color": "Soft gray", "dimensions": "19\" W x 14\" D x 31\" H", "assembly": "Quick setup", "tags": ["bath cart", "wheels", "storage"], "specs": {"Shelves": "3", "Wheels": "4 locking", "Width": "19 in", "Depth": "14 in", "Finish": "Moisture resistant"}, "featured": False, "deal": False}, + {"sku": "IK-10012", "name": "KLARNA mirror cabinet", "series": "KLARNA", "category_slug": "bathroom", "room_slug": "bathroom", "price": 179.0, "list_price": 209.0, "material": "Bamboo", "color": "Natural pine", "dimensions": "24\" W x 6\" D x 30\" H", "assembly": "Quick setup", "tags": ["mirror cabinet", "bathroom", "storage"], "specs": {"Shelves": "4", "Door": "Soft close", "Width": "24 in", "Depth": "6 in", "Finish": "Sealed bamboo"}, "featured": False, "deal": False}, + {"sku": "IK-10013", "name": "TROFASTA toy storage bench", "series": "TROFASTA", "category_slug": "kids-room", "room_slug": "kids", "price": 139.0, "list_price": 169.0, "material": "Particleboard", "color": "Yellow stripe", "dimensions": "35\" W x 17\" D x 20\" H", "assembly": "Quick setup", "tags": ["toy storage", "kids", "bench"], "specs": {"Bins": "4 removable", "Seat height": "20 in", "Width": "35 in", "Depth": "17 in", "Safety": "Rounded edges"}, "featured": True, "deal": False}, + {"sku": "IK-10014", "name": "TROFASTA loft activity table", "series": "TROFASTA", "category_slug": "kids-room", "room_slug": "kids", "price": 169.0, "list_price": 199.0, "material": "Solid pine", "color": "White", "dimensions": "47\" W x 23\" D x 20\" H", "assembly": "Weekend setup", "tags": ["activity table", "storage", "kids"], "specs": {"Seat count": "2", "Bins": "6 integrated", "Width": "47 in", "Depth": "23 in", "Surface": "Easy-clean laminate"}, "featured": False, "deal": True}, + {"sku": "IK-10015", "name": "SKOGEN balcony lounge set", "series": "SKOGEN", "category_slug": "outdoor-living", "room_slug": "outdoor", "price": 429.0, "list_price": 499.0, "material": "Powder-coated steel", "color": "Forest green", "dimensions": "Loveseat + table set", "assembly": "Weekend setup", "tags": ["patio", "outdoor", "2-seat"], "specs": {"Pieces": "3", "Cushions": "Water-repellent", "Stackable chairs": "Yes", "Frame": "Powder-coated steel", "Cover": "Machine washable"}, "featured": True, "deal": True}, + {"sku": "IK-10016", "name": "MALARO weatherproof storage bench", "series": "MALARO", "category_slug": "outdoor-living", "room_slug": "outdoor", "price": 219.0, "list_price": 259.0, "material": "Bamboo", "color": "Charcoal", "dimensions": "48\" W x 21\" D x 22\" H", "assembly": "Quick setup", "tags": ["storage bench", "outdoor", "weatherproof"], "specs": {"Capacity": "47 gal", "Seat count": "2", "Width": "48 in", "Depth": "21 in", "Lid support": "Hydraulic"}, "featured": False, "deal": False}, + {"sku": "IK-10017", "name": "VALLTORP shoe cabinet", "series": "VALLTORP", "category_slug": "entryway", "room_slug": "entryway", "price": 169.0, "list_price": 199.0, "material": "Particleboard", "color": "Warm beige", "dimensions": "31\" W x 10\" D x 50\" H", "assembly": "Quick setup", "tags": ["entryway", "shoe storage", "narrow"], "specs": {"Pairs": "12", "Depth": "10 in", "Width": "31 in", "Wall anchor": "Included", "Ventilation": "Rear panel"}, "featured": True, "deal": False}, + {"sku": "IK-10018", "name": "VALLTORP hallway bench", "series": "VALLTORP", "category_slug": "entryway", "room_slug": "entryway", "price": 129.0, "list_price": 149.0, "material": "Ash veneer", "color": "Oak", "dimensions": "39\" W x 16\" D x 19\" H", "assembly": "Quick setup", "tags": ["bench", "entryway", "oak"], "specs": {"Storage shelf": "Slatted", "Width": "39 in", "Depth": "16 in", "Seat height": "19 in", "Finish": "Matte oak"}, "featured": False, "deal": False}, + {"sku": "IK-10019", "name": "GLANSA handwoven area rug", "series": "GLANSA", "category_slug": "textiles-rugs", "room_slug": "textiles", "price": 199.0, "list_price": 239.0, "material": "Wool mix", "color": "Rust red", "dimensions": "6'7\" x 9'10\"", "assembly": "No assembly", "tags": ["rug", "handwoven", "living room"], "specs": {"Pile": "Low", "Reversible": "Yes", "Material": "Wool mix", "Size": "6'7\" x 9'10\"", "Care": "Vacuum only"}, "featured": True, "deal": True}, + {"sku": "IK-10020", "name": "GLANSA blackout curtain pair", "series": "GLANSA", "category_slug": "textiles-rugs", "room_slug": "textiles", "price": 69.0, "list_price": 85.0, "material": "Cotton blend", "color": "Slate blue", "dimensions": "57\" x 98\"", "assembly": "No assembly", "tags": ["curtains", "blackout", "bedroom"], "specs": {"Panels": "2", "Header": "Hidden tabs", "Length": "98 in", "Width": "57 in", "Light block": "95%"}, "featured": False, "deal": False}, + {"sku": "IK-10021", "name": "RYTM steel shelving unit", "series": "RYTM", "category_slug": "storage-organization", "room_slug": "storage", "price": 149.0, "list_price": 179.0, "material": "Powder-coated steel", "color": "Charcoal", "dimensions": "33\" W x 16\" D x 71\" H", "assembly": "Quick setup", "tags": ["shelving", "steel", "storage"], "specs": {"Shelves": "5", "Adjustable feet": "Yes", "Width": "33 in", "Depth": "16 in", "Weight limit": "110 lb per shelf"}, "featured": True, "deal": False}, + {"sku": "IK-10022", "name": "RYTM utility cart", "series": "RYTM", "category_slug": "storage-organization", "room_slug": "storage", "price": 59.0, "list_price": 75.0, "material": "Steel", "color": "Soft gray", "dimensions": "18\" W x 14\" D x 31\" H", "assembly": "Quick setup", "tags": ["cart", "utility", "rolling"], "specs": {"Shelves": "3", "Wheels": "4", "Width": "18 in", "Depth": "14 in", "Finish": "Powder coated"}, "featured": False, "deal": True}, + {"sku": "IK-10023", "name": "KLARGLA arched floor mirror", "series": "KLARGLA", "category_slug": "decor-mirrors", "room_slug": "decor", "price": 189.0, "list_price": 229.0, "material": "Steel", "color": "Black", "dimensions": "28\" W x 68\" H", "assembly": "No assembly", "tags": ["mirror", "arched", "decor"], "specs": {"Mounting": "Lean or wall-mount", "Width": "28 in", "Height": "68 in", "Frame": "Powder-coated steel", "Finish": "Matte black"}, "featured": True, "deal": False}, + {"sku": "IK-10024", "name": "KLARGLA planter stand trio", "series": "KLARGLA", "category_slug": "decor-mirrors", "room_slug": "decor", "price": 89.0, "list_price": 109.0, "material": "Steel", "color": "Birch", "dimensions": "Set of 3", "assembly": "Quick setup", "tags": ["planter", "decor", "set"], "specs": {"Pieces": "3", "Outdoor safe": "Covered use", "Material": "Steel and birch", "Tallest height": "28 in", "Tray diameter": "12 in"}, "featured": False, "deal": False}, +] + + +def slugify(text: str) -> str: + return ( + text.lower() + .replace("&", "and") + .replace("'", "") + .replace('"', "") + .replace(" ", "-") + ) + + +def svg_card(path: Path, title: str, subtitle: str, bg: str, accent: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + svg = f""" + + + + + + + + +{title} +{subtitle} +Local benchmark mirror asset +""" + path.write_text(svg, encoding="utf-8") + + +def category_palette(index: int) -> tuple[str, str]: + backgrounds = ["#f6f6ef", "#f5efe5", "#eaf2ff", "#eef7ea", "#fff4df", "#f0efff"] + accents = ["#0058a3", "#f2b632", "#2b5d82", "#2f6f4f", "#b45309", "#5046e5"] + return backgrounds[index % len(backgrounds)], accents[index % len(accents)] + + +def build_categories() -> list[Category]: + categories: list[Category] = [] + for idx, (slug, name, room_slug, description) in enumerate(CATEGORY_DATA): + categories.append( + Category( + slug=slug, + name=name, + room_slug=room_slug, + description=description, + hero_caption=f"Built for the {room_slug.replace('-', ' ')} routines in this local demo.", + icon_name=slug.split("-")[0], + ) + ) + bg, accent = category_palette(idx) + svg_card(STATIC_IMAGES / "categories" / f"{slug}.svg", name, ROOM_LABELS[room_slug], bg, accent) + return categories + + +def build_stores() -> list[Store]: + stores: list[Store] = [] + for idx, (name, slug, city, state, address) in enumerate(STORE_DATA): + stores.append( + Store( + name=name, + slug=slug, + city=city, + state=state, + address=address, + phone=f"(555) 01{idx:02d}-{2000 + idx}", + hours="10:00 AM - 9:00 PM", + amenities_json=dumps_json([ + "Swedish Restaurant", + "Click & collect", + "Planning studio", + "Family member lane", + ][0 : 2 + (idx % 3)]), + services_json=dumps_json([ + "Assembly planning", + "Kitchen consultation", + "Returns desk", + "Large item loading", + ][0 : 2 + (idx % 2)]), + image_path=f"images/stores/{slug}.svg", + pickup_note="Most furniture orders are ready within 4 hours in this demo.", + ) + ) + bg, accent = category_palette(idx) + svg_card(STATIC_IMAGES / "stores" / f"{slug}.svg", name, f"{city}, {state}", bg, accent) + return stores + + +def product_record(index: int, category_slug: str, room_slug: str, base_name: str) -> dict: + series = SERIES[index % len(SERIES)] + color = COLORS[index % len(COLORS)] + material = MATERIALS[index % len(MATERIALS)] + width = 24 + (index % 8) * 6 + depth = 14 + (index % 5) * 4 + height = 18 + (index % 9) * 5 + price = round(59 + (index % 13) * 28 + (index % 4) * 9, 2) + list_price = round(price + 15 + (index % 5) * 7, 2) + return { + "sku": f"IK-{11001 + index:05d}", + "name": f"{series} {base_name}", + "series": series, + "category_slug": category_slug, + "room_slug": room_slug, + "price": price, + "list_price": list_price, + "material": material, + "color": color, + "dimensions": f"{width}\" W x {depth}\" D x {height}\" H", + "assembly": ["Quick setup", "Weekend setup", "Two-person setup"][index % 3], + "tags": [base_name.split()[0].lower(), room_slug, color.lower(), material.split()[0].lower()], + "specs": { + "Width": f"{width} in", + "Depth": f"{depth} in", + "Height": f"{height} in", + "Material": material, + "Color": color, + }, + "featured": index % 9 == 0, + "deal": index % 7 == 0, + } + + +def build_products() -> list[Product]: + categories = {slug: room_slug for slug, _name, room_slug, _desc in CATEGORY_DATA} + base_names = { + "living-room-seating": ["3-seat sofa", "storage ottoman", "accent chair", "coffee table", "side table", "media bench", "console table", "daybed", "loveseat", "sleeper sofa", "chaise lounge", "nesting table", "sofa table"], + "bedroom-storage": ["nightstand", "wardrobe", "dresser", "bedside bench", "platform bed", "storage chest", "underbed box", "headboard shelf", "mirror door wardrobe", "linen chest", "bed frame", "dresser topper", "bed tray"], + "kitchen-dining": ["bar stool", "serving cart", "dining chair", "drop-leaf table", "sideboard", "baker rack", "kitchen island", "counter stool", "bench seat", "tray table", "dinnerware shelf", "extendable table", "dish cart"], + "home-office": ["writing desk", "task chair", "bookcase", "drawer unit", "monitor riser", "desk shelf", "printer cabinet", "filing cart", "laptop stand", "desk lamp", "wall organizer", "storage bench", "corner desk"], + "lighting": ["table lamp", "pendant lamp", "wall light", "floor uplight", "reading lamp", "lantern", "spotlight bar", "ceiling fixture", "picture light", "desk lamp", "led strip", "bedside lamp", "task lamp"], + "bathroom": ["ladder shelf", "mirror shelf", "towel stand", "laundry hamper", "vanity stool", "shower caddy", "bath mat set", "wall cabinet", "sink trolley", "toilet shelf", "soap tray set", "bath bench", "mirror"], + "kids-room": ["book ledge", "play table", "canopy bed", "storage cart", "step stool", "art cart", "reading nook chair", "wardrobe", "toy bin", "night light", "desk", "peg rail", "play rug"], + "outdoor-living": ["dining set", "stackable chair", "balcony table", "planter bench", "sun lounger", "outdoor rug", "storage table", "serving trolley", "shade umbrella", "planter shelf", "patio lamp", "adirondack chair", "bistro table"], + "entryway": ["coat rack", "umbrella stand", "key shelf", "mail organizer", "narrow bench", "drawer console", "mirror shelf", "boot tray", "shoe rack", "hook rail", "woven basket", "console table", "tray shelf"], + "textiles-rugs": ["runner rug", "throw blanket", "cushion cover set", "window panel", "bed throw", "sheer curtain", "door mat", "seat pad", "blanket ladder", "wool rug", "pillow insert", "sofa throw", "table textile set"], + "storage-organization": ["pegboard set", "drawer insert", "storage box", "closet organizer", "wall rail", "basket set", "wire shelf", "cube shelf", "underbed bag", "label bin", "shoe box", "rolling cart", "corner shelf"], + "decor-mirrors": ["wall mirror", "leaning mirror", "vase set", "frame trio", "scented candle", "side planter", "floating shelf", "wall clock", "table mirror", "accent tray", "ceramic bowl", "wall hook set", "picture ledge"], + } + + seeded = list(SPECIAL_PRODUCTS) + filler_index = 0 + for category_slug, names in base_names.items(): + existing = sum(1 for product in seeded if product["category_slug"] == category_slug) + needed = 13 - existing + room_slug = categories[category_slug] + for i in range(needed): + seeded.append(product_record(filler_index + i, category_slug, room_slug, names[i])) + filler_index += needed + + products: list[Product] = [] + for index, raw in enumerate(seeded): + bg, accent = category_palette(index) + slug = slugify(raw["name"]) + image_rel = f"images/products/{raw['sku']}.svg" + svg_card( + STATIC_IMAGES / "products" / f"{raw['sku']}.svg", + raw["name"], + f"{raw['series']} · {raw['color']}", + bg, + accent, + ) + products.append( + Product( + sku=raw["sku"], + name=raw["name"], + series=raw["series"], + slug=slug, + category_slug=raw["category_slug"], + room_slug=raw["room_slug"], + description=f"{raw['name']} brings {raw['room_slug'].replace('-', ' ')} storage and calm function into this local demo mirror.", + material=raw["material"], + color=raw["color"], + dimensions=raw["dimensions"], + assembly_level=raw["assembly"], + price=raw["price"], + list_price=raw["list_price"], + rating=round(4.1 + ((index % 9) * 0.1), 1), + review_count=2 + (index % 4), + availability_bucket=["Ready for pickup", "Low stock", "Delivery in 2-5 days"][index % 3], + delivery_note=["Parcel delivery", "Truck delivery", "Room-of-choice delivery"][index % 3], + pickup_badge=["Pickup today", "Pickup tomorrow", "Schedule pickup"][index % 3], + image_path=image_rel, + gallery_json=dumps_json([image_rel]), + features_json=dumps_json([ + f"{raw['color']} finish", + raw["assembly"], + f"Built for the {raw['room_slug'].replace('-', ' ')}", + ]), + specs_json=dumps_json(raw["specs"]), + tags_json=dumps_json(raw["tags"]), + is_featured=raw["featured"], + is_new=index % 8 == 0, + is_deal=raw["deal"], + is_bestseller=index % 10 == 0, + compare_group=raw["category_slug"], + ) + ) + return products + + +def build_support_articles() -> list[SupportArticle]: + article_specs = [ + ("Click and collect pickup windows", "click-and-collect-pickup-windows", "Pickup", "How to choose a pickup slot and what to bring to the store."), + ("Large item delivery for sofas and beds", "large-item-delivery", "Delivery", "What room-of-choice delivery includes for oversized furniture."), + ("Order lookup and status notes", "order-lookup-status", "Orders", "Where to find your order number and how local demo statuses move."), + ("Returns and exchanges in the local demo", "returns-and-exchanges", "Returns", "How returns are explained in this mirror without real transactions."), + ("Assembly planning and service add-ons", "assembly-planning-service", "Services", "What assembly planning covers and what it does not in the demo."), + ("Kitchen planning appointments", "kitchen-planning-appointments", "Planning", "How to book a design conversation in the mirror experience."), + ("IKEA Family points and order rewards", "family-points-and-rewards", "Rewards", "How points accrue from synthetic orders in the local benchmark."), + ("Mattress delivery and room-of-choice setup", "mattress-delivery-room-choice", "Delivery", "A walkthrough for bedroom deliveries and stairs notes."), + ("Store amenities and Swedish Restaurant hours", "store-amenities-and-restaurant-hours", "Stores", "How to check store amenities, dining, and planning desks."), + ("Pickup order readiness notifications", "pickup-readiness-notifications", "Pickup", "What the demo means when an order says pickup ready."), + ("Protection plans for desks and chairs", "protection-plans-desks-chairs", "Protection plans", "Compare accident coverage and finish protection on work-from-home pieces."), + ("Protection plans for sofas and beds", "protection-plans-sofas-beds", "Protection plans", "Understand coverage windows for upholstery and sleep furniture."), + ("How to use the room planner bundles", "room-planner-bundles", "Planning", "Use curated room bundles before adding multiple products to cart."), + ("Store pickup vs parcel delivery", "pickup-vs-parcel-delivery", "Delivery", "Choose the best fulfillment path for compact home accessories."), + ("Truck delivery for heavy storage", "truck-delivery-heavy-storage", "Delivery", "What to expect when shelving and wardrobes need truck delivery."), + ("Updating account preferences", "updating-account-preferences", "Account", "Change preferred store, ZIP code, and newsletter settings."), + ("Wishlist and compare lists", "wishlist-and-compare-lists", "Account", "Keep products handy before building a room or checking out."), + ("Bedroom storage measurement tips", "bedroom-storage-measurement-tips", "Planning", "Double-check widths and depths before choosing dressers and wardrobes."), + ("Outdoor furniture seasonal care", "outdoor-furniture-seasonal-care", "Care", "Cleaning notes for patio seating and balcony tables."), + ("Textile care and blackout curtains", "textile-care-and-blackout-curtains", "Care", "Laundry and hanging guidance for demo textile products."), + ("Bathroom storage in small spaces", "bathroom-storage-small-spaces", "Planning", "Ways to fit mirror cabinets and rolling carts into tighter footprints."), + ("Kids room safety anchors", "kids-room-safety-anchors", "Safety", "Anchor guidance for toy storage, desks, and wardrobes."), + ("Entryway organization for narrow hallways", "entryway-organization-narrow-hallways", "Planning", "Choose shoe cabinets and hooks for tight drop zones."), + ("Lighting bundles for open-plan rooms", "lighting-bundles-open-plan-rooms", "Planning", "Mix floor, pendant, and task lighting without overwhelming a room."), + ] + articles = [] + for title, slug, category, summary in article_specs: + articles.append( + SupportArticle( + title=title, + slug=slug, + category=category, + summary=summary, + body=( + f"{summary}\n\n" + "This is a local benchmark mirror using deterministic demo data. " + "No real orders, payments, delivery bookings, or external APIs are involved." + ), + related_topics_json=dumps_json(["delivery", "pickup", "planning", "support"]), + ) + ) + return articles + + +def build_deals(products: list[Product]) -> list[Deal]: + chosen = [product for product in products if product.is_deal][:18] + deals = [] + for idx, product in enumerate(chosen): + deals.append( + Deal( + title=f"{product.series} spotlight", + slug=f"deal-{product.sku.lower()}", + category_slug=product.category_slug, + badge=["Weekend offer", "Family pick", "Room refresh"][idx % 3], + summary=f"Save on {product.name} while keeping the room plan under budget.", + discount_text=f"Save {int(round(product.list_price - product.price))} dollars", + product_sku=product.sku, + ) + ) + return deals + + +def build_room_bundles(products: list[Product]) -> list[RoomBundle]: + bundles: list[RoomBundle] = [] + room_groups = {} + for product in products: + room_groups.setdefault(product.room_slug, []).append(product) + for room_slug, items in room_groups.items(): + selected = items[:3] + bundles.append( + RoomBundle( + name=f"{ROOM_LABELS[room_slug]} starter plan", + slug=f"{room_slug}-starter-plan", + room_slug=room_slug, + summary=f"Three coordinated picks for a quick {ROOM_LABELS[room_slug].lower()} refresh.", + total_price=round(sum(item.price for item in selected), 2), + item_skus_json=dumps_json([item.sku for item in selected]), + hero_note="Curated bundle built from deterministic demo inventory.", + ) + ) + return bundles + + +def seed_database(force: bool = False) -> None: + if Category.query.count() > 0 and not force: + return + + if force: + db.drop_all() + db.create_all() + + categories = build_categories() + stores = build_stores() + products = build_products() + articles = build_support_articles() + deals = build_deals(products) + bundles = build_room_bundles(products) + + db.session.add_all(categories) + db.session.add_all(stores) + db.session.add_all(products) + db.session.add_all(articles) + db.session.add_all(deals) + db.session.add_all(bundles) + + db.session.add_all( + [ + DeliveryOption(slug="parcel", name="Parcel delivery", fee=19.0, window_label="Arrives in 2-5 days", description="Compact items shipped to your door.", carbon_note="Lower-impact route"), + DeliveryOption(slug="truck", name="Truck delivery", fee=79.0, window_label="Choose a 4-hour window", description="Large furniture delivery with threshold drop-off.", carbon_note="Best for wardrobes and sofas"), + DeliveryOption(slug="room-choice", name="Room-of-choice delivery", fee=109.0, window_label="Choose a 2-hour window", description="Large items delivered into the room you select.", carbon_note="Includes upstairs carry in this demo"), + ] + ) + + db.session.flush() + + for store_index, store in enumerate(stores): + for slot_index in range(3): + db.session.add( + PickupSlot( + store_id=store.id, + slot_date=f"2026-06-{8 + slot_index:02d}", + time_window=["10:00-12:00", "1:00-3:00", "5:00-7:00"][slot_index], + remaining_capacity=12 - ((store_index + slot_index) % 5), + ) + ) + + for product_index, product in enumerate(products): + review_total = 2 + (product_index % 4) + for review_index in range(review_total): + db.session.add( + Review( + product_id=product.id, + author_name=f"Demo shopper {product_index + review_index + 1}", + headline=[ + "Looks polished in person", + "Easy to style in a small room", + "Great value for the size", + "Helpful storage details", + ][review_index % 4], + body=( + f"{product.name} works well in this synthetic benchmark home. " + f"I picked the {product.color.lower()} option and liked the {product.material.lower()} finish." + ), + rating=4 + (review_index % 2), + helpful_count=4 + review_index * 3, + created_on=f"2026-05-{10 + review_index:02d}", + ) + ) + for plan_years, plan_price in ((3, round(product.price * 0.08, 2)), (5, round(product.price * 0.13, 2))): + db.session.add( + ProtectionPlan( + product_id=product.id, + name=f"{plan_years}-year home protection", + years=plan_years, + price=plan_price, + description="Covers finish issues, hardware replacements, and accidental stains in this demo.", + benefits_json=dumps_json([ + "Finish coverage", + "Replacement hardware", + "Priority support script", + ]), + ) + ) + for store_index, store in enumerate(stores): + quantity = 3 + ((product_index + store_index) % 14) + db.session.add( + StoreInventory( + store_id=store.id, + product_id=product.id, + quantity=quantity, + aisle=f"{chr(65 + (store_index % 5))}-{10 + (product_index % 9)}", + pickup_available=quantity > 2, + delivery_available=(product_index + store_index) % 5 != 0, + ) + ) + + db.session.commit() + + +def seed_benchmark_users(force: bool = False) -> None: + if User.query.filter_by(email="alice.j@test.com").first() and not force: + return + + if force: + CompareItem.query.delete() + WishlistItem.query.delete() + CartItem.query.delete() + RewardActivity.query.delete() + PaymentMock.query.delete() + OrderItem.query.delete() + Order.query.delete() + SupportTicket.query.delete() + User.query.delete() + db.session.commit() + + profiles = [ + ("alice.j@test.com", "Alice", "Jones", "Brooklyn", "NY", "11215", "brooklyn-ny", 940), + ("bob.c@test.com", "Bob", "Chen", "Austin", "TX", "78758", "round-rock-tx", 720), + ("carol.d@test.com", "Carol", "Diaz", "Atlanta", "GA", "30318", "atlanta-ga", 860), + ("david.k@test.com", "David", "Kim", "Tempe", "AZ", "85281", "tempe-az", 680), + ] + users = [] + for email, first_name, last_name, city, state, zip_code, preferred_store, points in profiles: + user = User( + email=email, + first_name=first_name, + last_name=last_name, + phone="555-0100", + city=city, + state=state, + zip_code=zip_code, + preferred_store_slug=preferred_store, + rewards_points=points, + ) + user.set_password("TestPass123!") + users.append(user) + db.session.add_all(users) + db.session.flush() + + products = {product.sku: product for product in Product.query.order_by(Product.sku.asc()).all()} + stores = {store.slug: store for store in Store.query.order_by(Store.slug.asc()).all()} + + wishlist_sets = { + "alice.j@test.com": ["IK-10001", "IK-10003", "IK-10005", "IK-10017"], + "bob.c@test.com": ["IK-10007", "IK-10008", "IK-10021", "IK-10022"], + "carol.d@test.com": ["IK-10009", "IK-10010", "IK-10019", "IK-10024"], + "david.k@test.com": ["IK-10013", "IK-10015", "IK-10018", "IK-10023"], + } + compare_sets = { + "alice.j@test.com": ["IK-10001", "IK-10002", "IK-10015"], + "bob.c@test.com": ["IK-10007", "IK-10008", "IK-10021"], + "carol.d@test.com": ["IK-10005", "IK-10006", "IK-10019"], + "david.k@test.com": ["IK-10013", "IK-10014", "IK-10024"], + } + cart_sets = { + "alice.j@test.com": [("IK-10002", 1), ("IK-10017", 1)], + "bob.c@test.com": [("IK-10007", 1), ("IK-10022", 2)], + "carol.d@test.com": [("IK-10005", 1), ("IK-10020", 2)], + "david.k@test.com": [("IK-10013", 1), ("IK-10015", 1)], + } + + for user in users: + for sku in wishlist_sets[user.email]: + db.session.add(WishlistItem(user_id=user.id, product_id=products[sku].id, created_on="2026-05-30")) + for sku in compare_sets[user.email]: + db.session.add(CompareItem(user_id=user.id, product_id=products[sku].id, created_on="2026-05-28")) + for sku, quantity in cart_sets[user.email]: + db.session.add(CartItem(user_id=user.id, product_id=products[sku].id, quantity=quantity)) + + for idx in range(5): + db.session.add( + RewardActivity( + user_id=user.id, + label=[ + "Spring room refresh order", + "Wishlist inspiration bonus", + "Pickup ready bonus", + "Local planning appointment", + "Bedroom storage order", + ][idx], + points_delta=[120, 35, 40, 55, 160][idx], + activity_type=["purchase", "bonus", "bonus", "service", "purchase"][idx], + occurred_on=f"2026-05-{20 + idx:02d}", + ) + ) + + statuses = [ + "Preparing order", + "Ready for pickup", + "Out for delivery", + "Delivered", + "Delivered", + "Awaiting customer pickup", + ] + seeded_skus = list(products.keys()) + for order_index in range(60): + user = users[order_index % 4] + store = stores[user.preferred_store_slug] + order_number = f"IK-24{order_index + 1:04d}" + fulfillment = "pickup" if order_index % 3 == 0 else "delivery" + order = Order( + order_number=order_number, + user_id=user.id, + store_id=store.id, + fulfillment_method=fulfillment, + status=statuses[order_index % len(statuses)], + subtotal=0.0, + shipping_fee=0.0 if fulfillment == "pickup" else [19.0, 79.0, 109.0][order_index % 3], + tax=0.0, + total=0.0, + placed_on=f"2026-04-{1 + (order_index % 28):02d}", + delivery_window="2026-04-18 · 1:00 PM - 5:00 PM" if fulfillment == "delivery" else "", + pickup_window="2026-04-18 · 10:00 AM - 12:00 PM" if fulfillment == "pickup" else "", + contact_name=user.full_name, + payment_summary=f"Local demo card •••• {4242 + (order_index % 4)}", + ) + db.session.add(order) + db.session.flush() + selected = [ + products[seeded_skus[(order_index * 3) % len(seeded_skus)]], + products[seeded_skus[(order_index * 3 + 7) % len(seeded_skus)]], + ] + subtotal = 0.0 + for item_index, product in enumerate(selected): + quantity = 1 if item_index == 0 else 1 + (order_index % 2) + subtotal += product.price * quantity + db.session.add( + OrderItem( + order_id=order.id, + product_id=product.id, + quantity=quantity, + unit_price=product.price, + ) + ) + order.subtotal = round(subtotal, 2) + order.tax = round(subtotal * 0.085, 2) + order.total = round(order.subtotal + order.shipping_fee + order.tax, 2) + db.session.add( + PaymentMock( + order_id=order.id, + method_label=["IKEA Family Visa", "Demo Mastercard", "Demo Klarna"][order_index % 3], + last_four=str(4242 + (order_index % 4)), + billing_name=user.full_name, + status="Settled in local demo", + ) + ) + + for idx, user in enumerate(users): + for ticket_index in range(2): + db.session.add( + SupportTicket( + ticket_number=f"SUP-{idx + 1}{ticket_index + 1:03d}", + user_id=user.id, + subject=[ + "Pickup readiness clarification", + "Assembly planning follow-up", + ][ticket_index], + status=["Waiting on customer", "Resolved"][ticket_index], + article_slug=["pickup-readiness-notifications", "assembly-planning-service"][ticket_index], + opened_on=f"2026-05-{12 + ticket_index:02d}", + note="Demo support notes only; no live customer service is connected.", + ) + ) + + db.session.commit() + + +def build_seed_database() -> None: + STATIC_IMAGES.mkdir(parents=True, exist_ok=True) + INSTANCE_SEED_DIR.mkdir(parents=True, exist_ok=True) + if DB_PATH.exists(): + DB_PATH.unlink() + with app.app_context(): + db.drop_all() + db.create_all() + seed_database(force=True) + seed_benchmark_users(force=True) + shutil.copyfile(DB_PATH, INSTANCE_SEED_DIR / "ikea.db") + + +if __name__ == "__main__": + build_seed_database() + print("Seed database and local SVG assets generated for IKEA.") diff --git a/sites/ikea/static/css/.gitkeep b/sites/ikea/static/css/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/ikea/static/css/main.css b/sites/ikea/static/css/main.css new file mode 100644 index 00000000..62117cd9 --- /dev/null +++ b/sites/ikea/static/css/main.css @@ -0,0 +1,357 @@ +:root { + --ikea-blue: #0058a3; + --ikea-yellow: #fbd914; + --ink: #121826; + --muted: #566074; + --paper: #f5f6f8; + --card: #ffffff; + --line: #d7dde7; + --success: #0d7a3a; + --warning: #a15b00; +} + +* { box-sizing: border-box; } +body { + margin: 0; + font-family: "Trebuchet MS", "Segoe UI", sans-serif; + color: var(--ink); + background: + radial-gradient(circle at top right, rgba(251, 217, 20, 0.14), transparent 25%), + linear-gradient(180deg, #f7f8fa 0%, #eef2f7 100%); +} +a { color: inherit; text-decoration: none; } +img { max-width: 100%; display: block; } +button, .button { + border: 0; + border-radius: 999px; + padding: 0.9rem 1.25rem; + background: var(--ikea-blue); + color: white; + font-weight: 700; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; +} +button.secondary, .button.secondary { + background: white; + color: var(--ikea-blue); + border: 1px solid rgba(0, 88, 163, 0.24); +} +input, select { + width: 100%; + border: 1px solid var(--line); + border-radius: 16px; + padding: 0.85rem 0.95rem; + font: inherit; + background: white; +} +label { display: grid; gap: 0.45rem; font-weight: 600; } + +.promo-strip { + background: var(--ikea-blue); + color: white; + display: flex; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 1.5rem; + font-size: 0.95rem; +} +.site-header { + position: sticky; + top: 0; + z-index: 10; + background: rgba(255, 255, 255, 0.96); + backdrop-filter: blur(10px); + border-bottom: 1px solid rgba(18, 24, 38, 0.08); +} +.header-main, .category-nav, .page-shell, .site-footer, .flash-stack { + width: min(1280px, calc(100vw - 2rem)); + margin: 0 auto; +} +.header-main { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 1rem; + align-items: center; + padding: 1rem 0; +} +.logo-lockup { + display: inline-flex; + align-items: center; + gap: 0.85rem; +} +.logo-badge { + min-width: 84px; + padding: 0.75rem 0.9rem; + border-radius: 16px; + background: var(--ikea-yellow); + color: var(--ikea-blue); + text-align: center; + font-weight: 800; + letter-spacing: 0.08em; +} +.logo-copy small { display: block; color: var(--muted); } +.search-shell { + display: grid; + grid-template-columns: 1fr auto; + gap: 0.75rem; +} +.account-actions, .category-nav, .link-list, .hero-actions, .badge-row, .topic-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} +.account-actions a, .category-nav a { + font-weight: 700; + color: var(--muted); +} +.category-nav { + padding: 0 0 1rem; + overflow-x: auto; +} +.flash-stack { padding: 1rem 0 0; } +.flash-card { + padding: 0.9rem 1.1rem; + border-radius: 18px; + margin-bottom: 0.75rem; + background: white; + border: 1px solid var(--line); +} +.flash-success { border-color: rgba(13, 122, 58, 0.25); color: var(--success); } +.flash-warning { border-color: rgba(161, 91, 0, 0.25); color: var(--warning); } +.flash-danger { border-color: rgba(155, 28, 28, 0.25); color: #9b1c1c; } + +.page-shell { padding: 1.5rem 0 3rem; } +.eyebrow { + margin: 0 0 0.35rem; + color: var(--ikea-blue); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.82rem; +} +.page-lead h1, .hero-copy h1 { + margin: 0; + font-size: clamp(2rem, 4vw, 3.6rem); + line-height: 1.02; +} +.page-lead p:last-child, .hero-copy p { color: var(--muted); max-width: 65ch; } + +.hero-grid { + display: grid; + grid-template-columns: 1.35fr 1fr; + gap: 1.5rem; + align-items: stretch; +} +.hero-copy, .hero-panel, .summary-card, .info-card, .form-panel, .confirmation-card { + background: var(--card); + border: 1px solid rgba(18, 24, 38, 0.08); + border-radius: 28px; + box-shadow: 0 20px 50px rgba(18, 24, 38, 0.06); +} +.hero-copy { + padding: 2.4rem; + background: + linear-gradient(135deg, rgba(0, 88, 163, 0.96), rgba(0, 88, 163, 0.78)), + linear-gradient(135deg, #0058a3, #16324f); + color: white; +} +.hero-copy .eyebrow, .hero-copy p { color: rgba(255, 255, 255, 0.9); } +.hero-copy .button.secondary { + background: rgba(255, 255, 255, 0.14); + border-color: rgba(255, 255, 255, 0.24); + color: white; +} +.hero-panel, .summary-card, .info-card, .bundle-card, .deal-card, .article-card, .store-card, .product-card, .category-card, .empty-card, .cart-card, .order-card { + padding: 1.25rem; +} +.mini-grid, .product-grid, .deal-grid, .article-list, .store-grid, .category-grid, .dashboard-grid, .bundle-list { + display: grid; + gap: 1rem; +} +.mini-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.mini-card { + display: grid; + gap: 0.75rem; + background: #f3f7fb; + border-radius: 20px; + padding: 0.8rem; +} +.section-block { margin-top: 2rem; } +.section-heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} +.section-heading h2, .hero-panel h2, .summary-card h2, .info-card h2 { margin: 0; } + +.deal-strip, .bundle-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; } +.deal-card { + background: linear-gradient(135deg, #fff6d3, #fff); + border-radius: 24px; + border: 1px solid rgba(251, 217, 20, 0.5); +} +.deal-card.large { min-height: 210px; } +.pill { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.35rem 0.7rem; + border-radius: 999px; + background: rgba(0, 88, 163, 0.12); + color: var(--ikea-blue); + font-size: 0.82rem; + font-weight: 700; +} +.pill-deal { background: rgba(251, 217, 20, 0.33); color: #7c4a00; } +.pill-muted { background: #eef2f7; color: var(--muted); } + +.product-grid { grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); } +.product-card { + display: grid; + gap: 1rem; + border-radius: 24px; + background: white; + border: 1px solid rgba(18, 24, 38, 0.08); +} +.product-card__image { + position: relative; + border-radius: 18px; + overflow: hidden; + background: #f5f6f8; +} +.product-card__body h3 { margin: 0; font-size: 1.08rem; } +.meta, .availability, .result-count, .review-card p, .article-card p, .store-card p, .category-card p, .ticket-row p { + color: var(--muted); +} +.price-row, .rating-row, .detail-price, .grand-total { + display: flex; + align-items: center; + gap: 0.65rem; +} +.strike { + color: var(--muted); + text-decoration: line-through; +} +.card-actions, .stacked-actions { display: flex; gap: 0.7rem; flex-wrap: wrap; } + +.listing-layout, .detail-layout, .checkout-layout, .split-layout { + display: grid; + gap: 1.25rem; +} +.listing-layout { grid-template-columns: 290px 1fr; } +.filter-panel, .form-panel { + padding: 1.3rem; +} +.filter-form, .form-panel, .auth-layout { + display: grid; + gap: 1rem; +} +.price-fields, .form-grid, .confirmation-grid { display: grid; gap: 1rem; grid-template-columns: repeat(2, minmax(0, 1fr)); } +.checkbox-row { display: flex; align-items: center; gap: 0.65rem; } +.checkbox-row input { width: auto; } + +.detail-layout { grid-template-columns: 1.1fr 1fr; align-items: start; } +.detail-media img, .store-card img, .product-card__image img { border-radius: 20px; } +.purchase-panel { + margin-top: 1rem; + padding: 1rem; + border-radius: 20px; + background: #f6f8fb; +} +.detail-sections { display: grid; gap: 1rem; margin-top: 1.25rem; } +.spec-grid { + display: grid; + gap: 0.9rem; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); +} +.spec-grid dt { color: var(--muted); font-weight: 700; } +.spec-grid dd { margin: 0.3rem 0 0; } +.plan-grid, .store-pickup-list, .review-list, .order-list { display: grid; gap: 0.9rem; } +.plan-card, .review-card, .order-card, .ticket-row, .choice-card { + border: 1px solid var(--line); + border-radius: 20px; + padding: 1rem; + background: white; +} +.choice-card { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.8rem; + align-items: start; +} +.choice-card input { width: auto; margin-top: 0.2rem; } + +.auth-layout, .dashboard-grid, .store-grid, .category-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.store-card, .category-card, .order-card { + display: grid; + grid-template-columns: 160px 1fr; + gap: 1rem; + align-items: start; + border-radius: 24px; + background: white; + border: 1px solid rgba(18, 24, 38, 0.08); +} +.bundle-card, .empty-card, .confirmation-card { border-radius: 24px; } +.article-card { + display: block; + border-radius: 24px; + background: white; + border: 1px solid rgba(18, 24, 38, 0.08); +} +.support .article-list { grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); } +.search-inline { + display: grid; + grid-template-columns: 1fr auto; + gap: 0.75rem; + margin-bottom: 1rem; +} +.compare-table-shell { + overflow: auto; + background: white; + border-radius: 28px; + border: 1px solid rgba(18, 24, 38, 0.08); +} +.compare-table { + width: 100%; + min-width: 800px; + border-collapse: collapse; +} +.compare-table th, .compare-table td { + padding: 1rem; + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: top; +} +.compare-table thead th { + position: sticky; + top: 0; + background: white; +} +.site-footer { + display: flex; + justify-content: space-between; + gap: 1rem; + padding: 2rem 0 3rem; + color: var(--muted); +} +.site-footer a { color: var(--ikea-blue); display: block; margin-bottom: 0.35rem; } + +@media (max-width: 980px) { + .header-main, .hero-grid, .listing-layout, .detail-layout, .checkout-layout, .split-layout, .auth-layout, .dashboard-grid, .store-grid, .category-grid { + grid-template-columns: 1fr; + } + .account-actions { justify-content: flex-start; } + .promo-strip, .site-footer { flex-direction: column; } +} + +@media (max-width: 680px) { + .page-shell, .header-main, .category-nav, .site-footer, .flash-stack { width: min(100vw - 1rem, 1280px); } + .price-fields, .form-grid, .confirmation-grid, .search-shell, .search-inline { grid-template-columns: 1fr; } + .store-card, .category-card, .order-card { grid-template-columns: 1fr; } + .hero-copy { padding: 1.6rem; } +} diff --git a/sites/ikea/static/icons/.gitkeep b/sites/ikea/static/icons/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/ikea/static/js/.gitkeep b/sites/ikea/static/js/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/ikea/static/js/main.js b/sites/ikea/static/js/main.js new file mode 100644 index 00000000..3620ef0a --- /dev/null +++ b/sites/ikea/static/js/main.js @@ -0,0 +1,5 @@ +document.addEventListener("DOMContentLoaded", () => { + for (const flash of document.querySelectorAll(".flash-card")) { + setTimeout(() => flash.remove(), 5200); + } +}); diff --git a/sites/ikea/tasks.jsonl b/sites/ikea/tasks.jsonl new file mode 100644 index 00000000..422e89c8 --- /dev/null +++ b/sites/ikea/tasks.jsonl @@ -0,0 +1,18 @@ +{"web_name":"IKEA","id":"IKEA--0","ques":"Search for the HEMLUND modular sofa and save it to the wishlist from its product page.","web":"http://localhost:40015/","upstream_url":"https://www.ikea.com/"} +{"web_name":"IKEA","id":"IKEA--1","ques":"Browse the living room seating category and find the BJORNA lift-top coffee table. Report its current price and whether it is marked as a local deal.","web":"http://localhost:40015/","upstream_url":"https://www.ikea.com/"} +{"web_name":"IKEA","id":"IKEA--2","ques":"Use the search page to find the FJARNA extendable dining table, then compare it with the FJARNA spindle dining chair using the compare flow.","web":"http://localhost:40015/","upstream_url":"https://www.ikea.com/"} +{"web_name":"IKEA","id":"IKEA--3","ques":"Filter products to the home office room and find the LAGKAPTEN sit-stand desk. Add one desk to the cart.","web":"http://localhost:40015/","upstream_url":"https://www.ikea.com/"} +{"web_name":"IKEA","id":"IKEA--4","ques":"Open the product page for the KLARGLA arched floor mirror and identify which protection plan lasts longer.","web":"http://localhost:40015/","upstream_url":"https://www.ikea.com/"} +{"web_name":"IKEA","id":"IKEA--5","ques":"Go to the room planner and add the living room starter plan bundle to the cart.","web":"http://localhost:40015/","upstream_url":"https://www.ikea.com/"} +{"web_name":"IKEA","id":"IKEA--6","ques":"Find the IKEA Brooklyn store page and list two amenities shown there.","web":"http://localhost:40015/","upstream_url":"https://www.ikea.com/"} +{"web_name":"IKEA","id":"IKEA--7","ques":"Use the support center to find the article about large item delivery for sofas and beds, then report what type of delivery it explains.","web":"http://localhost:40015/","upstream_url":"https://www.ikea.com/us/en/customer-service/"} +{"web_name":"IKEA","id":"IKEA--8","ques":"Look up order IK-240001 using the seeded demo account for Alice and report the order status.","web":"http://localhost:40015/","upstream_url":"https://www.ikea.com/us/en/customer-service/order-status/"} +{"web_name":"IKEA","id":"IKEA--9","ques":"Sign in as alice.j@test.com and check the rewards page. What is the newest reward activity label?","web":"http://localhost:40015/","upstream_url":"https://www.ikea.com/us/en/ikea-family/"} +{"web_name":"IKEA","id":"IKEA--10","ques":"Sign in as bob.c@test.com, open the wishlist, and move one saved item into the cart by opening its product page and adding it.","web":"http://localhost:40015/","upstream_url":"https://www.ikea.com/"} +{"web_name":"IKEA","id":"IKEA--11","ques":"Search for blackout curtains and find the GLANSA blackout curtain pair. Report its dimensions from the product detail page.","web":"http://localhost:40015/","upstream_url":"https://www.ikea.com/"} +{"web_name":"IKEA","id":"IKEA--12","ques":"Open the support center and find the article about pickup order readiness notifications. Report the help category shown on that article.","web":"http://localhost:40015/","upstream_url":"https://www.ikea.com/us/en/customer-service/"} +{"web_name":"IKEA","id":"IKEA--13","ques":"Use the stores page to find a location in Atlanta and report one service listed on its store detail page.","web":"http://localhost:40015/","upstream_url":"https://www.ikea.com/"} +{"web_name":"IKEA","id":"IKEA--14","ques":"Sign in as carol.d@test.com and open account orders. Find the most recent order in the list and report its order number.","web":"http://localhost:40015/","upstream_url":"https://www.ikea.com/"} +{"web_name":"IKEA","id":"IKEA--15","ques":"Add the SMYCKA pendant cluster lamp to the compare list, then open compare and report one spec row that is displayed there.","web":"http://localhost:40015/","upstream_url":"https://www.ikea.com/"} +{"web_name":"IKEA","id":"IKEA--16","ques":"Starting from the cart, go through checkout and choose store pickup, then continue until you reach the review step without placing a real order.","web":"http://localhost:40015/","upstream_url":"https://www.ikea.com/"} +{"web_name":"IKEA","id":"IKEA--17","ques":"Complete a full mock checkout flow in the local IKEA mirror and report the confirmation order number shown at the end.","web":"http://localhost:40015/","upstream_url":"https://www.ikea.com/"} diff --git a/sites/ikea/templates/.gitkeep b/sites/ikea/templates/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/ikea/templates/account.html b/sites/ikea/templates/account.html new file mode 100644 index 00000000..6bcbccc2 --- /dev/null +++ b/sites/ikea/templates/account.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}Account - IKEA demo{% endblock %} +{% block content %} +
+

Account dashboard

+

Welcome back, {{ current_user.first_name }}

+

{{ current_user.family_tier }} · {{ current_user.rewards_points }} points

+
+
+ + +
+

Support tickets

+ {% for ticket in tickets %} +
+ {{ ticket.ticket_number }} +

{{ ticket.subject }}

+

{{ ticket.status }}

+
+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/ikea/templates/account_edit.html b/sites/ikea/templates/account_edit.html new file mode 100644 index 00000000..fe3ca394 --- /dev/null +++ b/sites/ikea/templates/account_edit.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}Edit profile - IKEA demo{% endblock %} +{% block content %} +
+

Edit account

+
+ + + + + + + + +
+ +
+{% endblock %} diff --git a/sites/ikea/templates/account_orders.html b/sites/ikea/templates/account_orders.html new file mode 100644 index 00000000..4e3c2c32 --- /dev/null +++ b/sites/ikea/templates/account_orders.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}Orders - IKEA demo{% endblock %} +{% block content %} +

Your orders

+ +{% endblock %} diff --git a/sites/ikea/templates/account_rewards.html b/sites/ikea/templates/account_rewards.html new file mode 100644 index 00000000..76e6656d --- /dev/null +++ b/sites/ikea/templates/account_rewards.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}Rewards - IKEA demo{% endblock %} +{% block content %} +
+

IKEA Family rewards

+

{{ current_user.rewards_points }} demo points available.

+
+
+ {% for activity in activities %} +
+
+ {{ activity.label }} +

{{ activity.activity_type|capitalize }} · {{ activity.occurred_on }}

+
+ {{ activity.points_delta }} pts +
+ {% endfor %} +
+{% endblock %} diff --git a/sites/ikea/templates/account_wishlist.html b/sites/ikea/templates/account_wishlist.html new file mode 100644 index 00000000..93f1fc17 --- /dev/null +++ b/sites/ikea/templates/account_wishlist.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block title %}Wishlist - IKEA demo{% endblock %} +{% block content %} +

Your wishlist

+
+ {% for item in items %} + {% set product = item.product %} + {% include "partials_product_card.html" %} + {% endfor %} +
+{% endblock %} diff --git a/sites/ikea/templates/base.html b/sites/ikea/templates/base.html new file mode 100644 index 00000000..079dc0a5 --- /dev/null +++ b/sites/ikea/templates/base.html @@ -0,0 +1,77 @@ + + + + + + {% block title %}IKEA Demo Mirror{% endblock %} + + + + + + + {% 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/ikea/templates/cart.html b/sites/ikea/templates/cart.html new file mode 100644 index 00000000..06bb0a86 --- /dev/null +++ b/sites/ikea/templates/cart.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} +{% block title %}Cart - IKEA demo{% endblock %} +{% block content %} +
+

Cart summary

+

Your demo cart

+
+ +
+
+ {% for item in summary["items"] %} +
+ {{ item.product.name }} +
+

{{ item.product.name }}

+

{{ item.product.series }} · {{ item.product.color }}

+

{{ item.product.price|money }}

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

Your cart is empty.

+

Sign in and add a few products to continue the checkout flow.

+
+ {% endfor %} +
+ +
+{% endblock %} diff --git a/sites/ikea/templates/categories.html b/sites/ikea/templates/categories.html new file mode 100644 index 00000000..5ac2fa6f --- /dev/null +++ b/sites/ikea/templates/categories.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}IKEA categories{% endblock %} +{% block content %} +
+
+

Browse by room and category

+

Explore every local IKEA demo department

+
+
+ + + +
+
+

Bundle-first planning

+ Open room planner +
+
+ {% for bundle in bundles %} +
+

{{ room_labels[bundle.room_slug] }}

+

{{ bundle.name }}

+

{{ bundle.summary }}

+ {{ bundle.total_price|money }} +
+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/ikea/templates/checkout_confirmation.html b/sites/ikea/templates/checkout_confirmation.html new file mode 100644 index 00000000..22e4f986 --- /dev/null +++ b/sites/ikea/templates/checkout_confirmation.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block title %}Order confirmation - IKEA demo{% endblock %} +{% block content %} +
+

Mock checkout complete

+

Order {{ order.order_number }} confirmed

+

This synthetic order was created inside the local benchmark only.

+
+
+ Status +

{{ order.status }}

+
+
+ Fulfillment +

{{ order.fulfillment_method|capitalize }}

+
+
+ Total +

{{ order.total|money }}

+
+
+ Payment +

{{ order.payment_summary }}

+
+
+ View order details +
+{% endblock %} diff --git a/sites/ikea/templates/checkout_payment.html b/sites/ikea/templates/checkout_payment.html new file mode 100644 index 00000000..2966d1c7 --- /dev/null +++ b/sites/ikea/templates/checkout_payment.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}Payment - IKEA demo{% endblock %} +{% block content %} +
+

Checkout step 3 of 4

+

Enter demo payment details

+

No real payment is processed in this benchmark environment.

+
+
+ + + + +
+{% endblock %} diff --git a/sites/ikea/templates/checkout_pickup.html b/sites/ikea/templates/checkout_pickup.html new file mode 100644 index 00000000..ede8721f --- /dev/null +++ b/sites/ikea/templates/checkout_pickup.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}Pickup - IKEA demo{% endblock %} +{% block content %} +
+

Checkout step 2 of 4

+

Select store pickup

+
+
+ + + +
+{% endblock %} diff --git a/sites/ikea/templates/checkout_review.html b/sites/ikea/templates/checkout_review.html new file mode 100644 index 00000000..9bae9fb4 --- /dev/null +++ b/sites/ikea/templates/checkout_review.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block title %}Review order - IKEA demo{% endblock %} +{% block content %} +
+

Checkout step 4 of 4

+

Review your local demo order

+
+
+
+

Items

+ {% for item in summary["items"] %} +

{{ item.quantity }} × {{ item.product.name }} {{ (item.product.price * item.quantity)|money }}

+ {% endfor %} +
+
+

Fulfillment

+ {% if state.get('method') == 'pickup' %} +

Store pickup

+

{{ pickup_store.name if pickup_store else 'No store selected' }}

+

{{ pickup_slot.slot_date }} · {{ pickup_slot.time_window }}

+ {% else %} +

Delivery

+

{{ delivery_option.name if delivery_option else 'No delivery option selected' }}

+

{{ delivery_option.window_label if delivery_option else '' }}

+ {% endif %} +

Payment · {{ state.get('payment_method', 'Demo Card') }} •••• {{ state.get('payment_last4', '4242') }}

+
+
+

Total

+

Subtotal{{ summary.subtotal|money }}

+

Tax{{ summary.estimated_tax|money }}

+

Shipping / pickup{{ delivery_option.fee|money if delivery_option else '$0.00' }}

+

Total{{ (summary.subtotal + summary.estimated_tax + (delivery_option.fee if delivery_option else 0))|money }}

+ +
+
+{% endblock %} diff --git a/sites/ikea/templates/checkout_shipping.html b/sites/ikea/templates/checkout_shipping.html new file mode 100644 index 00000000..66f2705a --- /dev/null +++ b/sites/ikea/templates/checkout_shipping.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}Delivery - IKEA demo{% endblock %} +{% block content %} +
+

Checkout step 2 of 4

+

Select delivery

+
+
+ + +
+ {% for option in delivery_options %} + + {% endfor %} +
+ +
+{% endblock %} diff --git a/sites/ikea/templates/checkout_start.html b/sites/ikea/templates/checkout_start.html new file mode 100644 index 00000000..e738854d --- /dev/null +++ b/sites/ikea/templates/checkout_start.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}Checkout - IKEA demo{% endblock %} +{% block content %} +
+

Checkout step 1 of 4

+

Choose fulfillment

+
+
+
+ + + +
+ +
+{% endblock %} diff --git a/sites/ikea/templates/compare.html b/sites/ikea/templates/compare.html new file mode 100644 index 00000000..cfbc1a24 --- /dev/null +++ b/sites/ikea/templates/compare.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} +{% block title %}Compare - IKEA demo{% endblock %} +{% block content %} +
+

Compare products

+

Up to four products can be compared side by side in this demo.

+
+{% if products %} +
+ + + + + {% for product in products %} + + {% endfor %} + + + + + + {% for product in products %}{% endfor %} + + + + {% for product in products %}{% endfor %} + + + + {% for product in products %}{% endfor %} + + {% for key in shared_keys %} + + + {% for product in products %} + + {% endfor %} + + {% endfor %} + +
Spec + {{ product.name }} + {{ product.name }} +

{{ product.price|money }}

+
Series{{ product.series }}
Rating{{ product.rating|stars }} ★
Availability{{ product.availability_bucket }}
{{ key }}{{ product.specs.get(key, '—') }}
+
+{% else %} +
+

No products selected for compare yet.

+

Add products from a detail page or listing card.

+
+{% endif %} +{% endblock %} diff --git a/sites/ikea/templates/deals.html b/sites/ikea/templates/deals.html new file mode 100644 index 00000000..8748d1ca --- /dev/null +++ b/sites/ikea/templates/deals.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}Deals - IKEA demo{% endblock %} +{% block content %} +
+

Deals and local promos

+

These promotions are synthetic and deterministic for benchmark use.

+
+
+ {% for deal in deals %} + {% set product = highlighted.get(deal.product_sku) %} +
+ {{ deal.badge }} +

{{ deal.title }}

+

{{ deal.summary }}

+ {{ deal.discount_text }} + {% if product %} + Open {{ product.name }} + {% endif %} +
+ {% endfor %} +
+{% endblock %} diff --git a/sites/ikea/templates/index.html b/sites/ikea/templates/index.html new file mode 100644 index 00000000..06451252 --- /dev/null +++ b/sites/ikea/templates/index.html @@ -0,0 +1,108 @@ +{% extends "base.html" %} +{% block title %}IKEA demo mirror{% endblock %} +{% block content %} +
+
+

Furniture + home retail benchmark

+

Design a room, check local pickup, and complete a full mock checkout.

+

This mirror uses deterministic demo inventory, synthetic orders, and local SVG assets only.

+ +
+ +
+ +
+
+

Weekend room refresh deals

+ View all deals +
+
+ {% for deal in deals %} +
+ {{ deal.badge }} +

{{ deal.title }}

+

{{ deal.summary }}

+ {{ deal.discount_text }} +
+ {% endfor %} +
+
+ +
+
+

Featured products

+ Browse all products +
+
+ {% for product in featured_products %} + {% include "partials_product_card.html" %} + {% endfor %} +
+
+ +
+
+
+

Room planner bundles

+ Plan by room +
+
+ {% for bundle in bundles %} +
+

{{ room_labels[bundle.room_slug] }}

+

{{ bundle.name }}

+

{{ bundle.summary }}

+ {{ bundle.total_price|money }} +
+ {% endfor %} +
+
+
+
+

Support highlights

+ Open help center +
+
+ {% for article in support_articles %} + +

{{ article.category }}

+

{{ article.title }}

+

{{ article.summary }}

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

Store pickup and planning

+ Find a store +
+ +
+{% endblock %} diff --git a/sites/ikea/templates/login.html b/sites/ikea/templates/login.html new file mode 100644 index 00000000..bf93ac71 --- /dev/null +++ b/sites/ikea/templates/login.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block title %}Sign in - IKEA demo{% endblock %} +{% block content %} +
+
+

Seeded benchmark users

+

Sign in

+ + + +
+ +
+{% endblock %} diff --git a/sites/ikea/templates/order_detail.html b/sites/ikea/templates/order_detail.html new file mode 100644 index 00000000..de04afe1 --- /dev/null +++ b/sites/ikea/templates/order_detail.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}{{ order.order_number }} - IKEA demo{% endblock %} +{% block content %} +
+

Synthetic order detail

+

{{ order.order_number }}

+

{{ order.status }} · {{ order.fulfillment_method|capitalize }}

+
+
+
+

Items

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

{{ item.quantity }} × {{ item.product.name }} {{ (item.unit_price * item.quantity)|money }}

+ {% endfor %} +
+
+

Summary

+

Placed on{{ order.placed_on }}

+

Subtotal{{ order.subtotal|money }}

+

Shipping{{ order.shipping_fee|money }}

+

Tax{{ order.tax|money }}

+

Total{{ order.total|money }}

+

Payment · {{ order.payment_summary }}

+
+
+{% endblock %} diff --git a/sites/ikea/templates/order_lookup.html b/sites/ikea/templates/order_lookup.html new file mode 100644 index 00000000..22cc1416 --- /dev/null +++ b/sites/ikea/templates/order_lookup.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}Order lookup - IKEA demo{% endblock %} +{% block content %} +
+

Lookup synthetic order details

+

Order lookup

+
+
+ + + +
+{% if looked_up and order %} +
+

{{ order.order_number }}

+

{{ order.status }}

+

{{ order.fulfillment_method|capitalize }} · {{ order.total|money }}

+ Open order detail +
+{% endif %} +{% endblock %} diff --git a/sites/ikea/templates/partials_product_card.html b/sites/ikea/templates/partials_product_card.html new file mode 100644 index 00000000..2f270f54 --- /dev/null +++ b/sites/ikea/templates/partials_product_card.html @@ -0,0 +1,33 @@ +
+ + {{ product.name }} + {% if product.is_deal %}Deal{% endif %} + +
+

{{ product.series }} · {{ room_labels[product.room_slug] }}

+

{{ product.name }}

+

{{ product.color }} · {{ product.material }}

+

+ {{ product.price|money }} + {% if product.list_price > product.price %} + {{ product.list_price|money }} + {% endif %} +

+

{{ product.rating|stars }} ★ · {{ product.review_count }} reviews

+

{{ product.availability_bucket }}

+
+ {% if current_user.is_authenticated %} +
+ + + +
+
+ +
+ {% else %} + Sign in to save + {% endif %} +
+
+
diff --git a/sites/ikea/templates/product_detail.html b/sites/ikea/templates/product_detail.html new file mode 100644 index 00000000..b4bf6c0e --- /dev/null +++ b/sites/ikea/templates/product_detail.html @@ -0,0 +1,119 @@ +{% extends "base.html" %} +{% block title %}{{ product.name }} - IKEA demo{% endblock %} +{% block content %} +
+
+ {{ product.name }} +
+ {{ product.series }} + {{ room_labels[product.room_slug] }} + {% if product.is_deal %}Local deal{% endif %} +
+
+
+

{{ category.name if category else product.category_slug }}

+

{{ product.name }}

+

{{ product.rating|stars }} ★ · {{ product.review_count }} reviews

+

+ {{ product.price|money }} + {% if product.list_price > product.price %} + {{ product.list_price|money }} + {% endif %} +

+

{{ product.description }}

+
    + {% for feature in product.features %} +
  • {{ feature }}
  • + {% endfor %} +
+
+

{{ product.availability_bucket }}

+

{{ product.delivery_note }} · {{ product.pickup_badge }}

+ {% if current_user.is_authenticated %} +
+ + + +
+
+
+ +
+
+ +
+
+ {% else %} + Sign in to shop + {% endif %} +
+
+
+ +
+
+

Key specs

+
+ {% for key, value in product.specs.items() %} +
+
{{ key }}
+
{{ value }}
+
+ {% endfor %} +
+
+
+

Protection plan comparison

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

{{ plan.name }}

+

{{ plan.price|money }}

+

{{ plan.description }}

+
    + {% for benefit in plan.benefits %} +
  • {{ benefit }}
  • + {% endfor %} +
+
+ {% endfor %} +
+
+
+

Store pickup snapshot

+
+ {% for record in store_records %} +
+ {{ record.store.name }} +

{{ record.quantity }} in stock · Aisle {{ record.aisle }}

+
+ {% endfor %} +
+
+
+

Customer reviews

+
+ {% for review in product.reviews[:4] %} +
+ {{ review.headline }} +

{{ review.rating }} ★ · {{ review.author_name }}

+

{{ review.body }}

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

Related picks

+
+
+ {% for product in related %} + {% include "partials_product_card.html" %} + {% endfor %} +
+
+{% endblock %} diff --git a/sites/ikea/templates/products.html b/sites/ikea/templates/products.html new file mode 100644 index 00000000..8976c378 --- /dev/null +++ b/sites/ikea/templates/products.html @@ -0,0 +1,85 @@ +{% extends "base.html" %} +{% block title %}{{ title }} - IKEA demo{% endblock %} +{% block content %} +
+

Product search + filters

+

{{ title }}

+

{{ description }}

+
+ +
+ + +
+

{{ products|length }} products found

+
+ {% for product in products %} + {% include "partials_product_card.html" %} + {% else %} +
+

No products matched these filters.

+

Try removing one filter or browse another room.

+
+ {% endfor %} +
+
+
+{% endblock %} diff --git a/sites/ikea/templates/register.html b/sites/ikea/templates/register.html new file mode 100644 index 00000000..12a58162 --- /dev/null +++ b/sites/ikea/templates/register.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}Register - IKEA demo{% endblock %} +{% block content %} +
+

Local account creation

+

Create a demo account

+
+ + + + + + + + + +
+ +
+{% endblock %} diff --git a/sites/ikea/templates/room_planner.html b/sites/ikea/templates/room_planner.html new file mode 100644 index 00000000..85c3b429 --- /dev/null +++ b/sites/ikea/templates/room_planner.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block title %}Room planner - IKEA demo{% endblock %} +{% block content %} +
+

Bundle-first planning

+

Room planner

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

{{ room_labels[bundle.room_slug] }}

+

{{ bundle.name }}

+

{{ bundle.summary }}

+ {{ bundle.total_price|money }} + {% if current_user.is_authenticated %} +
+ +
+ {% endif %} +
+ {% endfor %} +
+{% endblock %} diff --git a/sites/ikea/templates/store_detail.html b/sites/ikea/templates/store_detail.html new file mode 100644 index 00000000..2287b849 --- /dev/null +++ b/sites/ikea/templates/store_detail.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% block title %}{{ store.name }} - IKEA demo{% endblock %} +{% block content %} +
+

Store detail

+

{{ store.name }}

+

{{ store.address }} · {{ store.phone }}

+
+
+
+

Amenities

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

Services

+
    + {% for service in store.services %} +
  • {{ service }}
  • + {% endfor %} +
+
+
+

Pickup slots

+ {% for slot in slots %} +

{{ slot.slot_date }} · {{ slot.time_window }} {{ slot.remaining_capacity }} left

+ {% endfor %} +
+
+
+

Popular in this store

+
+ {% for product in featured_products %} + {% include "partials_product_card.html" %} + {% endfor %} +
+
+{% endblock %} diff --git a/sites/ikea/templates/stores.html b/sites/ikea/templates/stores.html new file mode 100644 index 00000000..bd5e25c4 --- /dev/null +++ b/sites/ikea/templates/stores.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% block title %}Stores - IKEA demo{% endblock %} +{% block content %} +
+

Store finder

+

Browse pickup locations, planning services, and store amenities.

+
+ +{% endblock %} diff --git a/sites/ikea/templates/support.html b/sites/ikea/templates/support.html new file mode 100644 index 00000000..a567cf37 --- /dev/null +++ b/sites/ikea/templates/support.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block title %}Support - IKEA demo{% endblock %} +{% block content %} +
+

Customer service + help search

+

Help center

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

{{ article.category }}

+

{{ article.title }}

+

{{ article.summary }}

+
+ {% endfor %} +
+{% endblock %} diff --git a/sites/ikea/templates/support_article.html b/sites/ikea/templates/support_article.html new file mode 100644 index 00000000..96400634 --- /dev/null +++ b/sites/ikea/templates/support_article.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}{{ article.title }} - IKEA demo{% endblock %} +{% block content %} +
+

{{ article.category }}

+

{{ article.title }}

+

{{ article.summary }}

+ {% for paragraph in article.body.split('\n\n') %} +

{{ paragraph }}

+ {% endfor %} +
+ {% for topic in article.related_topics %} + {{ topic }} + {% endfor %} +
+
+
+

Related help

+
+ {% for item in related %} + +

{{ item.category }}

+

{{ item.title }}

+

{{ item.summary }}

+
+ {% endfor %} +
+
+{% endblock %} diff --git a/websyn_start.sh b/websyn_start.sh index 72defad8..ddef5d59 100644 --- a/websyn_start.sh +++ b/websyn_start.sh @@ -1,11 +1,10 @@ #!/bin/bash -# WebSyn startup: launch all 12 mirror sites, then exec the original CMD. -# This preserves the base image's browser env server (port 8100) as PID 1. +# WebSyn startup: launch all mirror sites, then exec the control plane. 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 ikea) BASE_PORT=40000 PID_DIR=/tmp/websyn_pids mkdir -p "$PID_DIR" @@ -17,20 +16,16 @@ 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)) - # Spawn via /opt/site_runner.py supervisor so SIGTERM works. - # See site_runner.py for the rationale (Werkzeug ignores SIGTERM). exec python3 /opt/site_runner.py "$site" "$port" \ > "/tmp/websyn_${site}.log" 2>&1 & echo "$!" > "$PID_DIR/${site}.pid" echo " $site -> port $port (PID $!)" done - -# Wait for all sites to bind — retry up to 30 seconds echo "[WebSyn] Waiting for sites to become ready..." max_wait=30 interval=2 @@ -46,18 +41,18 @@ import urllib.request try: r = urllib.request.urlopen('http://127.0.0.1:$port/', timeout=2) exit(0 if r.status < 500 else 1) -except Exception: exit(1) +except Exception: + exit(1) " 2>/dev/null; then 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 -# Final status report echo "[WebSyn] Site status:" for i in "${!SITES[@]}"; do site="${SITES[$i]}" @@ -67,7 +62,8 @@ import urllib.request try: r = urllib.request.urlopen('http://127.0.0.1:$port/', timeout=2) exit(0 if r.status < 500 else 1) -except Exception: exit(1) +except Exception: + exit(1) " 2>/dev/null; then echo " [OK] $site :$port" else @@ -76,8 +72,4 @@ except Exception: exit(1) 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 -# subprocesses are managed via /tmp/websyn_pids/.pid. exec python3 /opt/control_server.py --port 8101