diff --git a/.assets-revision b/.assets-revision index 5c22104e..21c69272 100644 --- a/.assets-revision +++ b/.assets-revision @@ -5,4 +5,4 @@ # is a git revision (branch name like `main`, a tag, or a specific commit # sha). Override at runtime with the ASSETS_REVISION env var. repo: ChilleD/WebHarbor -revision: 54882a6a66a17a3e43455057e7c9e0d103cd8b81 +revision: refs/pr/21 diff --git a/.gitignore b/.gitignore index 24ce1529..e7899232 100644 --- a/.gitignore +++ b/.gitignore @@ -94,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 afa6f8b4..ad618907 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ A coding agent (Claude Code, Cursor, Aider, Codex, ...) is reading this. Read on ## What it is -15 Flask mirror websites (Amazon, GitHub, BBC News, ...) packaged into one Docker image, plus a control plane on `:8101` for resetting per-site state. Used as a deterministic offline environment for web-agent benchmarks. ~3 GB image. +17 Flask mirror websites (Amazon, GitHub, BBC News, ...) packaged into one Docker image, plus a control plane on `:8101` for resetting per-site state. Used as a deterministic offline environment for web-agent benchmarks. ~3 GB image. Two repos: - **code** (this one) — Flask apps, control plane, scripts. @@ -48,17 +48,17 @@ Inside the image, sites live at `/opt/WebSyn//`. The path predates the ren # fresh clone ./scripts/fetch_assets.sh # pulls assets from HF ./scripts/build.sh # docker build -t webharbor:dev . -docker run -d -p 8101:8101 -p 40000-40014:40000-40014 webharbor:dev +docker run -d -p 8101:8101 -p 40000-40016:40000-40016 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-40016:40000-40016 \ battalion7244/webharbor:latest ``` -Sites are on `40000`-`40014` in the order declared by `SITES=( ... )` in `websyn_start.sh`. Control plane: +Sites are on `40000`-`40016` in the order declared by `SITES=( ... )` in `websyn_start.sh`. Control plane: | Method | Path | Purpose | |--------|---------------------|-------------------------------------------| @@ -136,7 +136,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-41016:40000-40016 webharbor:dev # 4. control plane healthy, all sites alive curl -s http://localhost:8201/health | python3 -m json.tool | head diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 927ed53f..7a251c56 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ git clone https://github.com//webharbor && cd webharbor ./scripts/fetch_assets.sh # pull current assets ./scripts/new_site.py mywebsite # OR edit an existing site ./scripts/build.sh && docker run -d --rm \ - -p 8101:8101 -p 40000-40014:40000-40014 webharbor:dev + -p 8101:8101 -p 40000-40016:40000-40016 webharbor:dev # iterate locally... ./scripts/extract_assets.sh ../webharbor-static-pr/ # split assets out @@ -277,4 +277,4 @@ Sites must not import from one another. The image launches each as an independen ### Don't hard-code secrets -Each site sets `SECRET_KEY` to a deterministic dev value. Acceptable for a benchmark image (resets blow away sessions anyway). If a contrib ever needs real secrets, raise it in an issue first. \ No newline at end of file +Each site sets `SECRET_KEY` to a deterministic dev value. Acceptable for a benchmark image (resets blow away sessions anyway). If a contrib ever needs real secrets, raise it in an issue first. diff --git a/Dockerfile b/Dockerfile index 1e86b1d0..ad6d5d9f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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-40015 +EXPOSE 8101 40000-40016 CMD ["/opt/websyn_start.sh"] diff --git a/README.md b/README.md index dce3f934..6364e6ee 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** — 17 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-40016:40000-40016 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:40016` to explore 17 local mirrors: `Allrecipes, Amazon, Apple, ArXiv, BBC News, Booking, GitHub, Google Flights, Google Maps, Google Search, Hugging Face, Wolfram Alpha, Cambridge Dictionary, Coursera, ESPN, Merriam-Webster, and Wine Access`. 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 4b6b995e..6c62a32d 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', 'merriam_webster', + 'coursera', 'espn', 'merriam_webster', 'wineaccess', ] BASE_PORT = 40000 WEBSYN_DIR = '/opt/WebSyn' diff --git a/sites/wineaccess/_health.py b/sites/wineaccess/_health.py new file mode 100644 index 00000000..4c8b96bf --- /dev/null +++ b/sites/wineaccess/_health.py @@ -0,0 +1,28 @@ +"""Wine Access mirror health check.""" +from healthcheck import random_user + + +def run(p): + p.assert_get("home", "/", must_contain="Wine Access") + p.assert_get("store", "/store/?q=cabernet", must_contain="Cabernet") + p.assert_get("club", "/club/", must_contain="A Club for Every Palate") + + user = random_user() + p.assert_post( + "register submit", + "/register", + { + "display_name": user["name"], + "email": user["email"], + "password": user["password"], + }, + accept_status=(200, 302, 303), + ) + p.get("/logout") + p.assert_post( + "login submit", + "/login", + {"email": user["email"], "password": user["password"]}, + accept_status=(200, 302, 303), + ) + p.assert_get("account", "/account", must_contain=user["first_name"]) diff --git a/sites/wineaccess/app.py b/sites/wineaccess/app.py new file mode 100644 index 00000000..c7ebe524 --- /dev/null +++ b/sites/wineaccess/app.py @@ -0,0 +1,748 @@ +#!/usr/bin/env python3 +"""Wine Access mirror - Flask application.""" +import json +import os +import re +from datetime import datetime + +from flask import ( + Flask, + abort, + flash, + redirect, + render_template, + request, + url_for, +) +from flask_bcrypt import Bcrypt +from flask_login import ( + LoginManager, + UserMixin, + current_user, + login_required, + login_user, + logout_user, +) +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import or_ + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +REFERENCE_DATE = datetime(2026, 5, 20, 9, 0, 0) + +app = Flask(__name__, instance_path=os.path.join(BASE_DIR, "instance")) +app.config["SECRET_KEY"] = "webharbor-wineaccess-dev-key" +app.config["SQLALCHEMY_DATABASE_URI"] = ( + f"sqlite:///{os.path.join(BASE_DIR, 'instance', 'wineaccess.db')}" +) +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False +os.makedirs(os.path.join(BASE_DIR, "instance"), exist_ok=True) + +db = SQLAlchemy(app) +bcrypt = Bcrypt(app) +login_manager = LoginManager(app) +login_manager.login_view = "login" +login_manager.login_message = "Sign in to manage your cellar, cart, and orders." + +STOP_WORDS = { + "the", "a", "an", "and", "or", "for", "of", "in", "on", "to", + "with", "wine", "wines", +} + + +class User(db.Model, UserMixin): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + email = db.Column(db.String(120), unique=True, nullable=False, index=True) + password_hash = db.Column(db.String(255), nullable=False) + display_name = db.Column(db.String(120), nullable=False) + phone = db.Column(db.String(30), default="") + address_line1 = db.Column(db.String(180), default="") + address_line2 = db.Column(db.String(180), default="") + city = db.Column(db.String(80), default="") + state = db.Column(db.String(40), default="") + zip_code = db.Column(db.String(20), default="") + favorite_variety = db.Column(db.String(80), default="") + created_at = db.Column(db.DateTime, default=lambda: REFERENCE_DATE) + + cart_items = db.relationship("CartItem", backref="user", cascade="all, delete-orphan") + wishlist_items = db.relationship("WishlistItem", backref="user", cascade="all, delete-orphan") + orders = db.relationship("Order", backref="user", cascade="all, delete-orphan") + payment_methods = db.relationship("PaymentMethod", backref="user", cascade="all, delete-orphan") + memberships = db.relationship("ClubMembership", backref="user", cascade="all, delete-orphan") + + def set_password(self, password): + self.password_hash = bcrypt.generate_password_hash(password).decode("utf-8") + + def check_password(self, password): + return bcrypt.check_password_hash(self.password_hash, password) + + @property + def first_name(self): + return self.display_name.split()[0] + + +class Wine(db.Model): + __tablename__ = "wines" + + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(220), unique=True, nullable=False, index=True) + name = db.Column(db.String(260), nullable=False) + vintage = db.Column(db.Integer, nullable=False) + winery = db.Column(db.String(120), nullable=False) + wine_type = db.Column(db.String(40), index=True) + variety = db.Column(db.String(80), index=True) + region = db.Column(db.String(120), index=True) + country = db.Column(db.String(80), default="United States") + appellation = db.Column(db.String(140), default="") + price = db.Column(db.Float, nullable=False) + list_price = db.Column(db.Float, default=0.0) + case_price = db.Column(db.Float, default=0.0) + rating = db.Column(db.Float, default=4.6) + score = db.Column(db.Integer, default=92) + reviewer = db.Column(db.String(80), default="Wine Access") + inventory = db.Column(db.Integer, default=48) + availability = db.Column(db.String(40), default="Ships Immediately") + limited_offer = db.Column(db.Boolean, default=False) + expert_pick = db.Column(db.Boolean, default=False) + club_eligible = db.Column(db.Boolean, default=True) + image = db.Column(db.String(260), default="") + teaser = db.Column(db.String(240), default="") + tasting_notes = db.Column(db.Text, default="") + story = db.Column(db.Text, default="") + pairings_json = db.Column(db.Text, default="[]") + specs_json = db.Column(db.Text, default="{}") + + reviews = db.relationship("Review", backref="wine", cascade="all, delete-orphan") + cart_items = db.relationship("CartItem", backref="wine", cascade="all, delete-orphan") + wishlist_items = db.relationship("WishlistItem", backref="wine", cascade="all, delete-orphan") + + @property + def discount_percent(self): + if not self.list_price or self.list_price <= self.price: + return 0 + return round((self.list_price - self.price) / self.list_price * 100) + + @property + def pairings(self): + return json.loads(self.pairings_json or "[]") + + @property + def specs(self): + return json.loads(self.specs_json or "{}") + + @property + def display_price(self): + return f"${self.price:,.0f}" if self.price >= 100 else f"${self.price:,.2f}" + + +class Review(db.Model): + __tablename__ = "reviews" + + id = db.Column(db.Integer, primary_key=True) + wine_id = db.Column(db.Integer, db.ForeignKey("wines.id"), nullable=False) + author = db.Column(db.String(120), nullable=False) + rating = db.Column(db.Integer, default=5) + title = db.Column(db.String(160), default="") + body = db.Column(db.Text, default="") + created_at = db.Column(db.DateTime, default=lambda: REFERENCE_DATE) + + +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) + wine_id = db.Column(db.Integer, db.ForeignKey("wines.id"), nullable=False) + quantity = db.Column(db.Integer, 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) + wine_id = db.Column(db.Integer, db.ForeignKey("wines.id"), nullable=False) + note = db.Column(db.String(180), default="") + + +class PaymentMethod(db.Model): + __tablename__ = "payment_methods" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + cardholder = db.Column(db.String(120), nullable=False) + brand = db.Column(db.String(40), nullable=False) + last4 = db.Column(db.String(4), nullable=False) + exp_month = db.Column(db.Integer, nullable=False) + exp_year = db.Column(db.Integer, nullable=False) + is_default = db.Column(db.Boolean, default=False) + + +class Order(db.Model): + __tablename__ = "orders" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + order_number = db.Column(db.String(30), unique=True, nullable=False) + status = db.Column(db.String(40), default="Processing") + placed_at = db.Column(db.DateTime, default=lambda: REFERENCE_DATE) + subtotal = db.Column(db.Float, default=0.0) + shipping = db.Column(db.Float, default=0.0) + tax = db.Column(db.Float, default=0.0) + total = db.Column(db.Float, default=0.0) + tracking_number = db.Column(db.String(80), default="") + ship_to = db.Column(db.String(260), default="") + + items = db.relationship("OrderItem", backref="order", cascade="all, delete-orphan") + + +class OrderItem(db.Model): + __tablename__ = "order_items" + + id = db.Column(db.Integer, primary_key=True) + order_id = db.Column(db.Integer, db.ForeignKey("orders.id"), nullable=False) + wine_id = db.Column(db.Integer, db.ForeignKey("wines.id"), nullable=False) + wine_name = db.Column(db.String(260), nullable=False) + quantity = db.Column(db.Integer, default=1) + unit_price = db.Column(db.Float, nullable=False) + + +class Club(db.Model): + __tablename__ = "clubs" + + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(120), unique=True, nullable=False) + name = db.Column(db.String(140), nullable=False) + tagline = db.Column(db.String(180), default="") + bottles = db.Column(db.Integer, default=4) + frequency = db.Column(db.String(80), default="Quarterly") + price_per_shipment = db.Column(db.Float, default=120.0) + description = db.Column(db.Text, default="") + image = db.Column(db.String(260), default="") + + +class ClubMembership(db.Model): + __tablename__ = "club_memberships" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + club_id = db.Column(db.Integer, db.ForeignKey("clubs.id"), nullable=False) + status = db.Column(db.String(40), default="Active") + next_ship_date = db.Column(db.String(40), default="June 18, 2026") + + club = db.relationship("Club") + + +@login_manager.user_loader +def load_user(user_id): + return db.session.get(User, int(user_id)) + + +@app.context_processor +def inject_globals(): + cart_count = 0 + saved_count = 0 + if current_user.is_authenticated: + cart_count = sum(item.quantity for item in current_user.cart_items) + saved_count = len(current_user.wishlist_items) + return { + "cart_count": cart_count, + "saved_count": saved_count, + "types": ["Red", "White", "Rose", "Sparkling", "Sweet", "Fortified"], + "varieties": [ + "Cabernet Sauvignon", "Pinot Noir", "Chardonnay", "Sauvignon Blanc", + "Red Blend", "Syrah", "Zinfandel", "Malbec", + ], + } + + +def slugify(value): + return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + + +def tokens_for(query): + aliases = {"french": "france", "italian": "italy", "american": "united", "californian": "california"} + return [ + aliases.get(token, token) + for token in re.split(r"\W+", (query or "").lower()) + if len(token) > 2 and token not in STOP_WORDS + ] + + +def scored_search(query, base_query=None): + wines = list(base_query or Wine.query.all()) + tokens = tokens_for(query) + if not tokens: + return wines + scored = [] + for wine in wines: + haystack = " ".join( + [ + wine.name, wine.winery, wine.wine_type or "", wine.variety or "", + wine.region or "", wine.country or "", wine.appellation or "", + wine.teaser or "", wine.tasting_notes or "", + ] + ).lower() + score = sum(2 if token in wine.name.lower() else 1 for token in tokens if token in haystack) + if score: + scored.append((score, wine)) + scored.sort(key=lambda item: (-item[0], item[1].price, item[1].name)) + return [wine for _, wine in scored] + + +def filtered_wines(): + wines = Wine.query + wine_type = request.args.get("type", "").strip() + variety = request.args.get("variety", "").strip() + region = request.args.get("region", "").strip() + max_price = request.args.get("max_price", "").strip() + availability = request.args.get("availability", "").strip() + if wine_type: + wines = wines.filter(Wine.wine_type.ilike(wine_type)) + if variety: + wines = wines.filter(Wine.variety.ilike(variety)) + if region: + wines = wines.filter(or_(Wine.region.ilike(f"%{region}%"), Wine.country.ilike(f"%{region}%"))) + if max_price: + try: + wines = wines.filter(Wine.price <= float(max_price)) + except ValueError: + pass + if availability: + wines = wines.filter(Wine.availability.ilike(f"%{availability}%")) + return wines + + +@app.route("/") +def index(): + wines = Wine.query.order_by(Wine.score.desc(), Wine.name.asc()).limit(4).all() + collections = Wine.query.order_by(Wine.id).limit(3).all() + return render_template( + "index.html", + wines=wines, + collections=collections, + ) + + +@app.route("/store/") +@app.route("/store/is_offer/true/", defaults={"category": "is_offer/true"}) +@app.route("/store//") +def store(category=None): + query_text = request.args.get("q", "").strip() + wines_query = filtered_wines() + if category: + if category.rstrip("/") == "is_offer/true": + wines_query = wines_query.filter_by(limited_offer=True) + category_map = { + "red-wine": ("wine_type", "Red"), + "white-wine": ("wine_type", "White"), + "rose": ("wine_type", "Rose"), + "sparkling": ("wine_type", "Sparkling"), + "dessert-fortified": ("wine_type", "Fortified"), + "sake": ("variety", "Sake"), + "varietals/cabernet-sauvignon": ("variety", "Cabernet Sauvignon"), + "varietals/pinot-noir": ("variety", "Pinot Noir"), + "varietals/chardonnay": ("variety", "Chardonnay"), + "varietals/sauvignon-blanc": ("variety", "Sauvignon Blanc"), + "regions/united-states": ("country", "United States"), + "regions/france": ("country", "France"), + "regions/italy": ("country", "Italy"), + } + field, value = category_map.get(category.rstrip("/"), ("", "")) + if field: + wines_query = wines_query.filter(getattr(Wine, field).ilike(value)) + wines = scored_search(query_text, wines_query.all()) if query_text else wines_query.all() + sort = request.args.get("sort", "newest") + if sort == "price_low": + wines.sort(key=lambda wine: wine.price) + elif sort == "price_high": + wines.sort(key=lambda wine: -wine.price) + elif sort == "score": + wines.sort(key=lambda wine: (-wine.score, wine.price)) + else: + wines.sort(key=lambda wine: (-wine.limited_offer, -wine.vintage, wine.name)) + return render_template( + "store.html", + wines=wines, + query_text=query_text, + category=category, + sort=sort, + selected=request.args, + ) + + +@app.route("/search") +def search(): + args = request.args.to_dict() + args.setdefault("q", request.args.get("q", "")) + return redirect(url_for("store", **args)) + + +@app.route("/catalog//") +@app.route("/wine/") +def wine_detail(slug): + wine = Wine.query.filter_by(slug=slug).first_or_404() + related = ( + Wine.query.filter(Wine.id != wine.id) + .filter(or_(Wine.variety == wine.variety, Wine.region == wine.region)) + .order_by(Wine.score.desc()) + .limit(4) + .all() + ) + in_saved = False + if current_user.is_authenticated: + in_saved = WishlistItem.query.filter_by(user_id=current_user.id, wine_id=wine.id).first() is not None + return render_template("detail.html", wine=wine, related=related, in_saved=in_saved) + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + 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("Welcome back to Wine Access.", "success") + return redirect(request.args.get("next") or url_for("account")) + flash("Email or password did not match.", "error") + return render_template("login.html") + + +@app.route("/register", methods=["GET", "POST"]) +def register(): + if request.method == "POST": + email = request.form.get("email", "").strip().lower() + password = request.form.get("password", "") + display_name = request.form.get("display_name", "").strip() + if not display_name or "@" not in email or len(password) < 8: + flash("Please provide a name, valid email, and password with at least 8 characters.", "error") + elif User.query.filter_by(email=email).first(): + flash("An account already exists for that email.", "error") + else: + username = slugify(email.split("@")[0]) + user = User(username=username, email=email, display_name=display_name) + user.set_password(password) + db.session.add(user) + db.session.commit() + login_user(user) + flash("Your Wine Access account is ready.", "success") + return redirect(url_for("account")) + return render_template("register.html") + + +@app.route("/logout") +def logout(): + logout_user() + flash("You have been signed out.", "info") + return redirect(url_for("index")) + + +@app.route("/account", methods=["GET", "POST"]) +@login_required +def account(): + if request.method == "POST": + current_user.display_name = request.form.get("display_name", current_user.display_name).strip() + current_user.phone = request.form.get("phone", "").strip() + current_user.address_line1 = request.form.get("address_line1", "").strip() + current_user.address_line2 = request.form.get("address_line2", "").strip() + current_user.city = request.form.get("city", "").strip() + current_user.state = request.form.get("state", "").strip() + current_user.zip_code = request.form.get("zip_code", "").strip() + current_user.favorite_variety = request.form.get("favorite_variety", "").strip() + db.session.commit() + flash("Account details updated.", "success") + return redirect(url_for("account")) + return render_template("account.html") + + +@app.route("/saved") +@login_required +def saved(): + return render_template("saved.html", items=current_user.wishlist_items) + + +@app.post("/saved/add/") +@login_required +def add_saved(wine_id): + wine = db.session.get(Wine, wine_id) or abort(404) + if not WishlistItem.query.filter_by(user_id=current_user.id, wine_id=wine.id).first(): + db.session.add(WishlistItem(user_id=current_user.id, wine_id=wine.id)) + db.session.commit() + flash(f"Saved {wine.name} to your cellar list.", "success") + return redirect(request.referrer or url_for("wine_detail", slug=wine.slug)) + + +@app.post("/saved/remove/") +@login_required +def remove_saved(wine_id): + item = WishlistItem.query.filter_by(user_id=current_user.id, wine_id=wine_id).first() + if item: + db.session.delete(item) + db.session.commit() + flash("Removed wine from your saved list.", "info") + return redirect(request.referrer or url_for("saved")) + + +@app.post("/cart/add/") +@login_required +def add_cart(wine_id): + wine = db.session.get(Wine, wine_id) or abort(404) + quantity = max(1, min(24, int(request.form.get("quantity", 1) or 1))) + item = CartItem.query.filter_by(user_id=current_user.id, wine_id=wine.id).first() + if item: + item.quantity = min(24, item.quantity + quantity) + else: + db.session.add(CartItem(user_id=current_user.id, wine_id=wine.id, quantity=quantity)) + db.session.commit() + flash(f"Added {quantity} bottle(s) to your cart.", "success") + return redirect(request.form.get("next") or url_for("cart")) + + +@app.route("/cart", methods=["GET", "POST"]) +@login_required +def cart(): + if request.method == "POST": + for item in list(current_user.cart_items): + raw = request.form.get(f"qty_{item.id}", str(item.quantity)) + try: + qty = int(raw) + except ValueError: + qty = item.quantity + if qty <= 0: + db.session.delete(item) + else: + item.quantity = min(qty, 24) + db.session.commit() + flash("Cart updated.", "success") + return redirect(url_for("cart")) + return render_template("cart.html", totals=cart_totals()) + + +@app.post("/cart/remove/") +@login_required +def remove_cart_item(item_id): + 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")) + + +def cart_totals(): + subtotal = sum(item.quantity * item.wine.price for item in current_user.cart_items) + shipping = 0 if subtotal >= 150 or subtotal == 0 else 19.95 + tax = round(subtotal * 0.0825, 2) + total = round(subtotal + shipping + tax, 2) + return {"subtotal": subtotal, "shipping": shipping, "tax": tax, "total": total} + + +@app.route("/checkout", methods=["GET", "POST"]) +@login_required +def checkout(): + if not current_user.cart_items: + flash("Your cart is empty.", "info") + return redirect(url_for("store")) + totals = cart_totals() + if request.method == "POST": + required = ["address_line1", "city", "state", "zip_code", "card_last4"] + missing = [field for field in required if not request.form.get(field, "").strip()] + if missing: + flash("Shipping address and payment details are required.", "error") + return render_template("checkout.html", totals=totals) + order = Order( + user_id=current_user.id, + order_number=f"WA-{REFERENCE_DATE.strftime('%y%m%d')}-{current_user.id}{Order.query.count()+1:03d}", + status="Processing", + placed_at=REFERENCE_DATE, + subtotal=totals["subtotal"], + shipping=totals["shipping"], + tax=totals["tax"], + total=totals["total"], + tracking_number="Pending cellar release", + ship_to=", ".join( + [ + request.form.get("address_line1", "").strip(), + request.form.get("city", "").strip(), + request.form.get("state", "").strip(), + request.form.get("zip_code", "").strip(), + ] + ), + ) + db.session.add(order) + db.session.flush() + for item in list(current_user.cart_items): + db.session.add( + OrderItem( + order_id=order.id, + wine_id=item.wine.id, + wine_name=item.wine.name, + quantity=item.quantity, + unit_price=item.wine.price, + ) + ) + item.wine.inventory = max(0, item.wine.inventory - item.quantity) + db.session.delete(item) + db.session.commit() + flash("Order placed. Your Wine Access shipment is being prepared.", "success") + return redirect(url_for("order_detail", order_number=order.order_number)) + return render_template("checkout.html", totals=totals) + + +@app.route("/orders") +@login_required +def orders(): + return render_template("orders.html", orders=current_user.orders) + + +@app.route("/orders/") +@login_required +def order_detail(order_number): + order = Order.query.filter_by(user_id=current_user.id, order_number=order_number).first_or_404() + return render_template("order_detail.html", order=order) + + +@app.route("/club/") +def club(): + clubs = Club.query.all() + return render_template("club.html", clubs=clubs) + + +@app.route("/club//") +def club_detail(slug): + club_obj = Club.query.filter_by(slug=slug).first_or_404() + related_clubs = Club.query.filter(Club.id != club_obj.id).order_by(Club.id).all() + is_member = False + if current_user.is_authenticated: + is_member = ( + ClubMembership.query.filter_by(user_id=current_user.id, club_id=club_obj.id) + .first() + is not None + ) + return render_template( + "club_detail.html", + club=club_obj, + related_clubs=related_clubs, + is_member=is_member, + ) + + +@app.route("/gifting/") +@app.route("/wine-club/gifting/") +def gifting(): + clubs = Club.query.all() + return render_template("gifting.html", clubs=clubs) + + +@app.route("/podcast/") +def podcast(): + episodes = [ + { + "number": "Episode 44", + "date": "January 1, 2026", + "title": "Season Three: Better Questions, Better Bottles", + "wine": "2023 Domaine des Cinq Chemins Grolleau Gris Loire Valley", + }, + { + "number": "Episode 35", + "date": "February 8, 2024", + "title": "Building a Dream Cellar at Home", + "wine": "2021 Massican Winery Annia White Wine Napa Valley", + }, + { + "number": "Episode 16", + "date": "March 18, 2021", + "title": "Wine, Family, and a Championship Mindset", + "wine": "2018 Radio Silence Napa Valley Cabernet Sauvignon", + }, + ] + return render_template("podcast.html", episodes=episodes) + + +@app.route("/wine-team/") +def experts(): + experts = [ + { + "name": "Laura Koffer", + "role": "Advanced Sommelier", + "quote": "I seek out iconic wines from classic regions, but I also look for a story and a connection beyond the sensory experience.", + "image": "expert_laura.webp", + }, + { + "name": "Eduardo Dingler", + "role": "Wine Judge, Sake Ambassador and Sommelier", + "quote": "My favorite wines unfold in the glass over time, creating the kind of lasting memories great bottles are made for.", + "image": "expert_eduardo.webp", + }, + { + "name": "Amanda McCrossin", + "role": "Host of Wine Access Unfiltered", + "quote": "Setting is my most important criterion for wine selection, but I am never opposed to opening something special on a Tuesday.", + "image": "expert_amanda.webp", + }, + { + "name": "Vincent Morrow MS", + "role": "Master Sommelier", + "quote": "My favorite wines demonstrate authenticity: where they are grown, who produces them, and the moment in which they are enjoyed.", + "image": "expert_vincent.webp", + }, + ] + wines = Wine.query.filter_by(expert_pick=True).order_by(Wine.score.desc()).limit(4).all() + return render_template("experts.html", experts=experts, wines=wines) + + +@app.post("/club/join/") +@login_required +def join_club(slug): + club_obj = Club.query.filter_by(slug=slug).first_or_404() + existing = ClubMembership.query.filter_by(user_id=current_user.id, club_id=club_obj.id).first() + if not existing: + db.session.add(ClubMembership(user_id=current_user.id, club_id=club_obj.id)) + db.session.commit() + flash(f"You joined {club_obj.name}.", "success") + else: + flash("You are already a member of that club.", "info") + return redirect(url_for("account")) + + +@app.route("/contact-us/") +def contact(): + return render_template("contact.html") + + +@app.route("/where-we-ship/") +def where_we_ship(): + return render_template("where_we_ship.html") + + +@app.route("/_health") +def health(): + return {"ok": True, "site": "wineaccess", "wines": Wine.query.count()} + + +from seed_data import seed_benchmark_users, seed_database # noqa: E402 + +with app.app_context(): + db.create_all() + seed_database(db, Wine, Review, Club, slugify) + seed_benchmark_users( + db, + User, + Wine, + CartItem, + WishlistItem, + PaymentMethod, + Order, + OrderItem, + Club, + ClubMembership, + bcrypt, + REFERENCE_DATE, + ) + + +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/wineaccess/requirements.txt b/sites/wineaccess/requirements.txt new file mode 100644 index 00000000..d98a3746 --- /dev/null +++ b/sites/wineaccess/requirements.txt @@ -0,0 +1,4 @@ +Flask +Flask-SQLAlchemy +Flask-Login +Flask-Bcrypt diff --git a/sites/wineaccess/seed_data.py b/sites/wineaccess/seed_data.py new file mode 100644 index 00000000..e8c53ac9 --- /dev/null +++ b/sites/wineaccess/seed_data.py @@ -0,0 +1,278 @@ +"""Deterministic Wine Access seed data.""" +import json +from datetime import timedelta + +PASSWORD = "TestPass123!" + +PRODUCTS = [ + ("2023 Jolie-Laide Syrah California", 2023, "Jolie-Laide", "Red", "Syrah", "California", "United States", "California", 20, 36, 94, "Antonio Galloni", "wine_01_2023-jolie-laide-syrah-california.webp", "Galloni: I cannot recommend them highly enough."), + ("2024 Saint Cosme Cotes du Rhone", 2024, "Saint Cosme", "Red", "Red Blend", "Rhone Valley", "France", "Cotes du Rhone", 19, 20, 93, "Wine Access", "wine_02_2024-saint-cosme-cotes-du-rhone.webp", "Our top Cotes-du-Rhone from a 100-point southern Rhone estate."), + ("2024 Paltrinieri Radice Lambrusco di Sorbara", 2024, "Paltrinieri", "Sparkling", "Lambrusco", "Emilia-Romagna", "Italy", "Sorbara", 24, 31, 92, "Wine Access", "wine_03_2024-paltrinieri-radice-lambrusco-di-sorbara.webp", "Dry, bright, and wildly refreshing sparkling red."), + ("2020 Chateau Pavie Saint-Emilion", 2020, "Chateau Pavie", "Red", "Bordeaux Blend", "Bordeaux", "France", "Saint-Emilion", 399, 475, 99, "Wine Access", "wine_04_2020-chateau-pavie-saint-emilion.webp", "A cellar landmark from Saint-Emilion."), + ("2022 Albert Bichot Saint-Veran Burgundy", 2022, "Albert Bichot", "White", "Chardonnay", "Burgundy", "France", "Saint-Veran", 32, 40, 91, "Wine Access", "wine_05_2022-albert-bichot-saint-veran-burgundy.webp", "Limestone snap and orchard fruit from Burgundy."), + ("2015 Chateau Haut-Brion Pessac-Leognan", 2015, "Chateau Haut-Brion", "Red", "Bordeaux Blend", "Bordeaux", "France", "Pessac-Leognan", 950, 1100, 100, "Wine Access", "wine_06_2015-chateau-haut-brion-pessac-leognan.webp", "First-growth depth with graphite, cassis, and cigar box."), + ("2023 Vinedo Chadwick Maipo Valley Chile", 2023, "Vinedo Chadwick", "Red", "Cabernet Sauvignon", "Maipo Valley", "Chile", "Maipo Valley", 380, 420, 98, "Wine Access", "wine_07_2023-vinedo-chadwick-maipo-valley-chile.webp", "Chile's benchmark Cabernet in a polished vintage."), + ("2021 Bank Shot Cabernet Sauvignon Napa Valley", 2021, "Bank Shot", "Red", "Cabernet Sauvignon", "Napa Valley", "United States", "Napa Valley", 45, 95, 94, "Wine Access", "wine_08_2021-bank-shot-cabernet-sauvignon-napa-valley.webp", "Napa Cabernet power at a weeknight-friendly price."), + ("2021 Le Pich Cabernet Sauvignon Napa Valley", 2021, "Le Pich", "Red", "Cabernet Sauvignon", "Napa Valley", "United States", "Napa Valley", 65, 85, 95, "Wine Access", "wine_09_2021-le-pich-cabernet-sauvignon-napa-valley.webp", "A top value from Napa's acclaimed 2021 vintage."), + ("2020 Hawk and Horse Cabernet Sauvignon Red Hills Lake County", 2020, "Hawk and Horse", "Red", "Cabernet Sauvignon", "Lake County", "United States", "Red Hills", 58, 75, 93, "Wine Access", "wine_10_2020-hawk-and-horse-vineyards-cabernet-sauvignon-red-hills-lake-county.webp", "Mountain-grown Cabernet with organic farming roots."), + ("2017 Maison Leroy Nuits-Saint-Georges", 2017, "Maison Leroy", "Red", "Pinot Noir", "Burgundy", "France", "Nuits-Saint-Georges", 995, 1200, 97, "Wine Access", "wine_11_2017-maison-leroy-nuits-saint-georges.webp", "Rare Burgundy from one of the region's legendary names."), + ("2024 Vinos Finos de California Sabroso Central Coast", 2024, "Vinos Finos de California", "White", "White Blend", "Central Coast", "United States", "Central Coast", 22, 30, 91, "Wine Access", "wine_12_2024-vinos-finos-de-california-sabroso-central-coast.webp", "Coastal freshness with citrus, melon, and sea-spray lift."), + ("2023 Glassmen Pinot Noir Sonoma Coast", 2023, "Glassmen", "Red", "Pinot Noir", "Sonoma Coast", "United States", "Sonoma Coast", 39, 55, 94, "Wine Access", "wine_13_2023-glassmen-pinot-noir-sonoma-coast.webp", "Silky single-vineyard style Sonoma Pinot."), + ("2017 Maison Leroy Gevrey-Chambertin", 2017, "Maison Leroy", "Red", "Pinot Noir", "Burgundy", "France", "Gevrey-Chambertin", 1150, 1380, 98, "Wine Access", "wine_14_2017-maison-leroy-gevrey-chambertin.webp", "A rare village bottling with grand-cru energy."), + ("2022 Williams Selyem Pinot Noir Russian River Valley", 2022, "Williams Selyem", "Red", "Pinot Noir", "Russian River Valley", "United States", "Russian River Valley", 119, 140, 96, "Wine Access", "wine_15_2022-williams-selyem-pinot-noir-russian-river-valley.webp", "Russian River perfume, cherry, and polished spice."), + ("2018 F. Thienpont Bordeaux", 2018, "F. Thienpont", "Red", "Bordeaux Blend", "Bordeaux", "France", "Bordeaux", 29, 45, 92, "Wine Access", "wine_16_2018-f-thienpont-bordeaux.webp", "Right Bank pedigree in a generous vintage."), + ("2023 Ponzi Pinot Noir Tavola Willamette Valley", 2023, "Ponzi Vineyards", "Red", "Pinot Noir", "Willamette Valley", "United States", "Willamette Valley", 31, 45, 92, "Wine Access", "wine_17_2023-ponzi-vineyards-pinot-noir-tavola-willamette-valley.webp", "Oregon Pinot with red cherry and forest floor detail."), + ("2024 RAEN Pinot Noir Royal St. Robert Cuvee Sonoma Coast", 2024, "RAEN", "Red", "Pinot Noir", "Sonoma Coast", "United States", "Sonoma Coast", 88, 110, 96, "Wine Access", "wine_18_2024-raen-pinot-noir-royal-st-robert-cuvee-sonoma-coast.webp", "Coastal Pinot from a celebrated Sonoma Coast project."), + ("2022 Hoopes Cabernet Sauvignon Napa Valley", 2022, "Hoopes Family Vineyard", "Red", "Cabernet Sauvignon", "Napa Valley", "United States", "Napa Valley", 72, 95, 94, "Wine Access", "wine_19_2022-hoopes-family-vineyard-cabernet-sauvignon-napa-valley.webp", "Classic Napa Cabernet with cassis and cedar."), + ("2023 Williams Selyem Zinfandel Papera Vineyard", 2023, "Williams Selyem", "Red", "Zinfandel", "Russian River Valley", "United States", "Papera Vineyard", 68, 80, 95, "Wine Access", "wine_20_2023-williams-selyem-zinfandel-papera-vineyard-russian-river-valley.webp", "Old-vine Zinfandel with berry compote and spice."), + ("2021 Domaine du Clos de Tart Grand Cru Monopole", 2021, "Domaine du Clos de Tart", "Red", "Pinot Noir", "Burgundy", "France", "Clos de Tart", 850, 980, 98, "Wine Access", "wine_21_2021-domaine-du-clos-de-tart-clos-de-tart-grand-cru-monopole.webp", "Grand cru Burgundy from a walled monopole."), + ("2023 Zuccardi Concreto Malbec Paraje Altamira Mendoza", 2023, "Zuccardi", "Red", "Malbec", "Mendoza", "Argentina", "Paraje Altamira", 42, 55, 95, "Wine Access", "wine_22_2023-zuccardi-concreto-malbec-paraje-altamira-mendoza.webp", "Stony, vivid Malbec from high-elevation Mendoza."), + ("2022 La Pelle Cabernet Sauvignon Napa Valley", 2022, "La Pelle Wines", "Red", "Cabernet Sauvignon", "Napa Valley", "United States", "Napa Valley", 98, 125, 96, "Wine Access", "wine_23_2022-la-pelle-wines-cabernet-sauvignon-napa-valley.webp", "A serious cellar Cabernet with polished tannins."), + ("2023 Aperture Cabernet Sauvignon Soil Specific Sonoma County", 2023, "Aperture Cellars", "Red", "Cabernet Sauvignon", "Sonoma County", "United States", "Sonoma County", 74, 95, 94, "Wine Access", "wine_24_2023-aperture-cellars-cabernet-sauvignon-soil-specific-sonoma-county.webp", "Cabernet precision from Sonoma volcanic soils."), + ("2019 Pas de Cheval Cabernet Sauvignon Finale Howell Mountain", 2019, "Pas de Cheval", "Red", "Cabernet Sauvignon", "Howell Mountain", "United States", "Napa Valley", 155, 210, 97, "Wine Access", "wine_25_2019-pas-de-cheval-cabernet-sauvignon-finale-howell-mountain-napa-vall.webp", "Howell Mountain structure with blue fruit and cocoa."), + ("2019 Gilbert Hall Cabernet Sauvignon Napa Valley", 2019, "Gilbert Hall", "Red", "Cabernet Sauvignon", "Napa Valley", "United States", "Napa Valley", 89, 135, 95, "Wine Access", "wine_26_2019-napa-cabernet-cellared-and-sublime-2019-gilbert-hall-cabernet-sau.webp", "Cellared Napa Cabernet entering a graceful window."), + ("2023 Lorenza Old Vine Carignan Rauser Vineyard", 2023, "Lorenza", "Red", "Carignan", "Mokelumne River", "United States", "Lodi", 28, 38, 91, "Wine Access", "wine_27_2023-lorenza-old-vine-carignan-rauser-vineyard-mokelumne-river.webp", "Bright old-vine Carignan with a savory snap."), + ("2021 Pas de Cheval Cabernet Sauvignon Prelude Oakville", 2021, "Pas de Cheval", "Red", "Cabernet Sauvignon", "Oakville", "United States", "Napa Valley", 125, 165, 96, "Wine Access", "wine_28_2021-pas-de-cheval-cabernet-sauvignon-prelude-oakville-napa-valley.webp", "Oakville Cabernet with plush fruit and firm ageworthy lines."), + ("2021 Waugh Cellars Cabernet Sauvignon Napa Valley", 2021, "Waugh Cellars", "Red", "Cabernet Sauvignon", "Napa Valley", "United States", "Napa Valley", 54, 80, 93, "Wine Access", "wine_29_2021-waugh-cellars-cabernet-sauvignon-napa-valley.webp", "A polished Napa Cabernet for grilled ribeye."), + ("2024 Campo Della Fortuna Pinot Grigio", 2024, "Campo Della Fortuna", "White", "Pinot Grigio", "Veneto", "Italy", "Veneto", 18, 25, 90, "Wine Access", "wine_30_2024-campo-della-fortuna-pinot-grigio.webp", "Crisp pear, lemon peel, and mineral refreshment."), + ("2022 DuMOL Chardonnay Chloe Russian River Valley", 2022, "DuMOL", "White", "Chardonnay", "Russian River Valley", "United States", "Russian River Valley", 86, 105, 95, "Wine Access", "wine_31_2022-dumol-chardonnay-chloe-russian-river-valley.webp", "Layered Russian River Chardonnay with fine acidity."), + ("2025 Mount Fishtail Sauvignon Blanc Sur Lie Marlborough", 2025, "Mount Fishtail", "White", "Sauvignon Blanc", "Marlborough", "New Zealand", "Marlborough", 19, 27, 91, "Wine Access", "wine_32_2025-mount-fishtail-sauvignon-blanc-sur-lie-marlborough.webp", "Sur lie texture meets grapefruit and passionfruit."), + ("2023 Domaine Roland Lavantureux Vieilles Vignes Chablis", 2023, "Domaine Roland Lavantureux", "White", "Chardonnay", "Burgundy", "France", "Chablis", 48, 62, 94, "Wine Access", "wine_33_2023-domaine-roland-lavantureux-vieilles-vignes-chablis.webp", "Old-vine Chablis with oyster-shell precision."), + ("2022 Fantesca Estate Chardonnay Russian River Valley", 2022, "Fantesca Estate", "White", "Chardonnay", "Russian River Valley", "United States", "Russian River Valley", 64, 85, 94, "Wine Access", "wine_34_2022-fantesca-estate-chardonnay-russian-river-valley.webp", "A plush yet focused Russian River Chardonnay."), +] + +EXTRA_PRODUCTS = [ + ("2025 LoveR Coteaux d'Aix en Provence Rose", 2025, "LoveR", "Rose", "Rose Blend", "Provence", "France", "Coteaux d'Aix", 26, 35, 92), + ("M. Brugnon Selection Brut Champagne", 2020, "M. Brugnon", "Sparkling", "Champagne Blend", "Champagne", "France", "Champagne", 49, 65, 93), + ("Maurice Grumier Coeur de Rose Champagne", 2019, "Maurice Grumier", "Sparkling", "Champagne Blend", "Champagne", "France", "Champagne", 58, 75, 94), + ("2023 1881 Napa Cabernet Sauvignon Napa Valley", 2023, "1881 Napa", "Red", "Cabernet Sauvignon", "Napa Valley", "United States", "Napa Valley", 40, 70, 93), + ("2023 Karo-Kann Cabernet Sauvignon Napa Valley", 2023, "Karo-Kann", "Red", "Cabernet Sauvignon", "Napa Valley", "United States", "Napa Valley", 35, 60, 92), + ("2023 Off the Cuff Cabernet Sauvignon Napa Valley", 2023, "Off the Cuff", "Red", "Cabernet Sauvignon", "Napa Valley", "United States", "Napa Valley", 32, 55, 91), + ("2021 Three Wine Company Old Vines Field Blend", 2021, "Three Wine Company", "Red", "Red Blend", "Contra Costa County", "United States", "Contra Costa County", 24, 38, 92), + ("2024 Lionel Osmin Cami Salie Jurancon", 2024, "Lionel Osmin", "White", "Petit Manseng", "Southwest France", "France", "Jurancon", 21, 30, 91), + ("2022 Atlas Peak Merlot Napa Valley", 2022, "Atlas Peak", "Red", "Merlot", "Napa Valley", "United States", "Napa Valley", 44, 65, 92), + ("2021 Sage Ridge Cabernet Franc Napa Valley", 2021, "Sage Ridge", "Red", "Cabernet Franc", "Napa Valley", "United States", "Napa Valley", 59, 78, 94), + ("2023 Riverstone Riesling Mosel Kabinett", 2023, "Riverstone", "White", "Riesling", "Mosel", "Germany", "Mosel", 29, 42, 93), + ("2024 Tidal Bay Albarino Rias Baixas", 2024, "Tidal Bay", "White", "Albarino", "Rias Baixas", "Spain", "Rias Baixas", 23, 32, 91), + ("2021 Barolo Cascina del Conte", 2021, "Cascina del Conte", "Red", "Nebbiolo", "Piedmont", "Italy", "Barolo", 62, 85, 95), + ("2020 Brunello di Montalcino La Quercia", 2020, "La Quercia", "Red", "Sangiovese", "Tuscany", "Italy", "Brunello di Montalcino", 72, 96, 94), + ("2024 Junmai Ginjo Sake Akari", 2024, "Akari", "Sake", "Sake", "Niigata", "Japan", "Niigata", 36, 48, 92), + ("2018 Vintage Port Quinta do Sol", 2018, "Quinta do Sol", "Fortified", "Port Blend", "Douro", "Portugal", "Porto", 42, 58, 92), + ("2023 Dry Creek Petite Sirah Reserve", 2023, "Dry Creek Reserve", "Red", "Petite Sirah", "Sonoma County", "United States", "Dry Creek Valley", 34, 50, 91), + ("2025 Willamette Valley Rose of Pinot Noir", 2025, "North Crest", "Rose", "Pinot Noir", "Willamette Valley", "United States", "Willamette Valley", 24, 34, 90), + ("2024 Etna Bianco Carricante", 2024, "Terra Nera", "White", "Carricante", "Sicily", "Italy", "Etna", 38, 52, 93), + ("2022 Paso Robles GSM Reserve", 2022, "Limestone Bench", "Red", "GSM Blend", "Paso Robles", "United States", "Paso Robles", 28, 44, 91), + ("2021 Rutherford Bench Cabernet Sauvignon", 2021, "Rutherford Bench", "Red", "Cabernet Sauvignon", "Napa Valley", "United States", "Rutherford", 79, 110, 95), + ("2022 Santa Barbara Chardonnay Ocean Block", 2022, "Ocean Block", "White", "Chardonnay", "Santa Barbara County", "United States", "Santa Barbara", 37, 55, 92), + ("2023 Anderson Valley Pinot Noir Fogline", 2023, "Fogline", "Red", "Pinot Noir", "Anderson Valley", "United States", "Anderson Valley", 46, 64, 93), + ("2024 Loire Valley Sauvignon Blanc Les Cailloux", 2024, "Les Cailloux", "White", "Sauvignon Blanc", "Loire Valley", "France", "Touraine", 20, 29, 90), + ("2020 Rioja Reserva Vina Cerrada", 2020, "Vina Cerrada", "Red", "Tempranillo", "Rioja", "Spain", "Rioja", 31, 46, 92), + ("2023 Santa Lucia Highlands Pinot Noir Benchland", 2023, "Benchland", "Red", "Pinot Noir", "Santa Lucia Highlands", "United States", "Santa Lucia Highlands", 43, 60, 92), +] + + +def _pairings(wine_type, variety): + if wine_type == "White": + return ["roast chicken", "shellfish", "spring vegetables"] + if wine_type == "Sparkling": + return ["fried chicken", "triple cream cheese", "celebrations"] + if wine_type == "Rose": + return ["salmon", "summer salads", "goat cheese"] + if variety == "Cabernet Sauvignon": + return ["ribeye", "braised short ribs", "aged cheddar"] + if variety == "Pinot Noir": + return ["duck", "mushroom risotto", "roast pork"] + return ["grilled lamb", "charcuterie", "hard cheeses"] + + +def _notes(wine_type, variety, region, score): + return ( + f"{score}-point {variety} from {region}. The profile is layered with " + "fresh fruit, savory detail, and a clean finish. The Wine Access panel " + "selected it for balance, typicity, and value relative to comparable bottles." + ) + + +def seed_database(db, Wine, Review, Club, slugify): + if Wine.query.count() > 0: + return + + rows = list(PRODUCTS) + images = [row[12] for row in PRODUCTS] + for i, row in enumerate(EXTRA_PRODUCTS): + name, vintage, winery, wine_type, variety, region, country, appellation, price, list_price, score = row + image = images[i % len(images)] + rows.append((name, vintage, winery, wine_type, variety, region, country, appellation, price, list_price, score, "Wine Access", image, "Member-favorite discovery selected by the Wine Access tasting panel.")) + + for idx, row in enumerate(rows, start=1): + name, vintage, winery, wine_type, variety, region, country, appellation, price, list_price, score, reviewer, image, teaser = row + wine = Wine( + slug=slugify(name), + name=name, + vintage=vintage, + winery=winery, + wine_type=wine_type, + variety=variety, + region=region, + country=country, + appellation=appellation, + price=float(price), + list_price=float(list_price), + case_price=round(float(price) * 12 * 0.88, 2), + rating=min(round(4.1 + (score - 88) / 20, 1), 4.9), + score=score, + reviewer=reviewer, + inventory=18 + (idx * 7) % 84, + availability="Preorder" if idx % 11 == 0 else "Ships Immediately", + limited_offer=idx % 3 == 0, + expert_pick=score >= 94 or idx % 9 == 0, + club_eligible=idx % 7 != 0, + image=image, + teaser=teaser, + tasting_notes=_notes(wine_type, variety, region, score), + story=( + f"Wine Access sourced {name} after tasting through a focused set of " + f"{variety} bottlings from {region}. It stood out for provenance, " + "cellar condition, and the kind of price-to-quality ratio members expect." + ), + pairings_json=json.dumps(_pairings(wine_type, variety)), + specs_json=json.dumps( + { + "Vintage": vintage, + "Variety": variety, + "Region": region, + "Country": country, + "Appellation": appellation, + "Bottle Size": "750 ml", + "Alcohol": f"{13 + (idx % 4) * 0.4:.1f}%", + "Drinking Window": f"2026-{2030 + idx % 9}", + } + ), + ) + db.session.add(wine) + db.session.flush() + for r in range(3): + db.session.add( + Review( + wine_id=wine.id, + author=["Maya R.", "Thomas L.", "Erin C."][r], + rating=5 if r != 1 else 4, + title=["Cellar-worthy", "Strong value", "Dinner standout"][r], + body=[ + "Arrived in perfect condition and opened with beautiful aromatics.", + "The profile matched the notes and the price was fair for the quality.", + "Served it with dinner and every glass disappeared quickly.", + ][r], + ) + ) + + clubs = [ + ("unfiltered-podcast", "Unfiltered Podcast Wine Club", "Educational and fun", 4, "6 times per year", 120, "Drink along with Amanda McCrossin as she shares stories, guests, and expert selections.", "club_unfiltered.webp"), + ("connoisseurs", "Connoisseurs Club", "Unparalleled reds", 2, "Quarterly", 150, "Two breathtaking red wines selected for collectors who want rare, ageworthy bottles.", "club_wine_display.webp"), + ("discovery", "Discovery Club", "Classic styles, new favorites", 4, "Quarterly", 110, "A broad tour through regions, grapes, and styles that overdeliver for the price.", "club_wine_display.webp"), + ("champagne", "Sparkling Club", "Bubbles beyond the obvious", 3, "Quarterly", 165, "Champagne and world-class sparkling wines with food-pairing notes.", "club_unfiltered.webp"), + ] + for slug, name, tagline, bottles, frequency, price, description, image in clubs: + db.session.add( + Club( + slug=slug, + name=name, + tagline=tagline, + bottles=bottles, + frequency=frequency, + price_per_shipment=price, + description=description, + image=image, + ) + ) + db.session.commit() + + +def seed_benchmark_users( + db, + User, + Wine, + CartItem, + WishlistItem, + PaymentMethod, + Order, + OrderItem, + Club, + ClubMembership, + bcrypt, + reference_date, +): + if User.query.filter_by(email="alice.j@test.com").first(): + return + + users = [ + ("alice_j", "alice.j@test.com", "Alice Johnson", "Cabernet Sauvignon", "122 Camino Oruga", "Napa", "CA", "94558"), + ("bob_c", "bob.c@test.com", "Bob Chen", "Pinot Noir", "418 Market Street", "San Francisco", "CA", "94105"), + ("carol_d", "carol.d@test.com", "Carol Davis", "Chardonnay", "75 Pearl Street", "Denver", "CO", "80203"), + ("david_k", "david.k@test.com", "David Kim", "Sparkling", "9 Walnut Avenue", "Portland", "OR", "97205"), + ] + all_wines = Wine.query.order_by(Wine.id).all() + clubs = Club.query.all() + for idx, (username, email, display_name, favorite, line1, city, state, zip_code) in enumerate(users): + user = User( + username=username, + email=email, + display_name=display_name, + phone=f"(707) 555-01{idx}0", + address_line1=line1, + city=city, + state=state, + zip_code=zip_code, + favorite_variety=favorite, + ) + user.password_hash = bcrypt.generate_password_hash(PASSWORD).decode("utf-8") + db.session.add(user) + db.session.flush() + + for wine in all_wines[idx * 2 : idx * 2 + 3]: + db.session.add(CartItem(user_id=user.id, wine_id=wine.id, quantity=1 + (wine.id % 2))) + for wine in all_wines[10 + idx * 4 : 15 + idx * 4]: + db.session.add(WishlistItem(user_id=user.id, wine_id=wine.id, note="Consider for next cellar shipment")) + db.session.add( + PaymentMethod( + user_id=user.id, + cardholder=display_name, + brand="Visa" if idx % 2 == 0 else "Mastercard", + last4=["4242", "1881", "9455", "2026"][idx], + exp_month=8 + idx, + exp_year=2029, + is_default=True, + ) + ) + if clubs: + db.session.add(ClubMembership(user_id=user.id, club_id=clubs[idx % len(clubs)].id)) + + subtotal = 0 + order = Order( + user_id=user.id, + order_number=f"WA-26041{idx + 1}-{idx + 204}", + status=["Delivered", "Shipped", "Processing", "Delivered"][idx], + placed_at=reference_date - timedelta(days=12 + idx * 8), + tracking_number=["1ZWA204944", "9400111899", "Pending cellar release", "1ZWA778204"][idx], + ship_to=f"{line1}, {city}, {state} {zip_code}", + ) + db.session.add(order) + db.session.flush() + for wine in all_wines[24 + idx * 3 : 27 + idx * 3]: + qty = 1 + (wine.id % 3 == 0) + subtotal += wine.price * qty + db.session.add( + OrderItem( + order_id=order.id, + wine_id=wine.id, + wine_name=wine.name, + quantity=qty, + unit_price=wine.price, + ) + ) + order.subtotal = round(subtotal, 2) + order.shipping = 0 if subtotal >= 150 else 19.95 + order.tax = round(subtotal * 0.0825, 2) + order.total = round(order.subtotal + order.shipping + order.tax, 2) + + db.session.commit() diff --git a/sites/wineaccess/static/css/.gitkeep b/sites/wineaccess/static/css/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/wineaccess/static/css/style.css b/sites/wineaccess/static/css/style.css new file mode 100644 index 00000000..d041ad0e --- /dev/null +++ b/sites/wineaccess/static/css/style.css @@ -0,0 +1,937 @@ +:root { + --ink: #170d0b; + --muted: #6b625d; + --line: #e2d8cf; + --paper: #fffaf4; + --cream: #f6efe7; + --wine: #9e2f2e; + --wine-dark: #6f1c1b; + --green: #5f6f52; + --gold: #b68b4c; +} + +* { box-sizing: border-box; } +body { + margin: 0; + color: var(--ink); + background: var(--paper); + font-family: Arial, Helvetica, sans-serif; + line-height: 1.45; +} +a { color: inherit; text-decoration: none; } +img { max-width: 100%; display: block; } +.skip { position: absolute; left: -999px; } +.skip:focus { left: 12px; top: 12px; background: white; padding: 8px; z-index: 10; } +.promo { + background: var(--wine-dark); + color: white; + text-align: center; + padding: 9px; + font-size: 13px; + letter-spacing: .04em; +} +.site-header { + display: grid; + grid-template-columns: 260px minmax(260px, 1fr) 360px; + gap: 24px; + align-items: center; + padding: 24px 5vw; + border-bottom: 1px solid var(--line); + background: #fff; +} +.brand { + font-family: Georgia, serif; + font-size: clamp(30px, 3vw, 42px); + font-weight: 700; + color: var(--ink); + text-align: center; + text-transform: uppercase; + letter-spacing: .08em; + grid-column: 2; + grid-row: 1; +} +.header-actions { + display: flex; + gap: 8px; + grid-column: 3; + grid-row: 1; + align-items: center; + justify-content: flex-end; +} +.search { + display: flex; + gap: 8px; + flex: 1 1 280px; + max-width: 430px; +} +.search input { + border-radius: 999px; + padding: 10px 18px; +} +.search button { + min-height: 40px; + border-radius: 999px; +} +input, select, textarea { + width: 100%; + border: 1px solid #c9bbb0; + background: white; + padding: 11px 12px; + font: inherit; +} +button, .button { + border: 1px solid var(--wine); + background: var(--wine); + color: white; + padding: 11px 16px; + font-weight: 700; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 42px; +} +.button:not(.primary), .link-button { + background: transparent; + color: var(--wine); +} +.link-button { border: 0; padding-left: 0; } +.full { width: 100%; } +.account-nav, .main-nav, .footer div:last-child { + display: flex; + gap: 18px; + align-items: center; + flex-wrap: wrap; +} +.account-nav { + grid-column: 1; + grid-row: 1; +} +.cart-link { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 48px; + justify-content: center; +} +.cart-icon { + position: relative; + width: 23px; + height: 27px; + border: 2px solid var(--ink); + border-top: 0; +} +.cart-icon::before { + content: ""; + position: absolute; + left: 5px; + top: -10px; + width: 10px; + height: 12px; + border: 2px solid var(--ink); + border-bottom: 0; + border-radius: 8px 8px 0 0; +} +.cart-text { + font-size: 14px; +} +.main-nav { + justify-content: center; + gap: clamp(20px, 3vw, 48px); + padding: 18px 36px; + border-bottom: 1px solid var(--line); + background: white; + box-shadow: 0 2px 12px rgba(23,13,11,.05); + font-size: 19px; +} +.main-nav a.active { + border-bottom: 4px solid var(--wine); + padding-bottom: 4px; +} +.flashes { max-width: 1180px; margin: 14px auto 0; padding: 0 24px; } +.flash { padding: 12px 14px; border: 1px solid var(--line); background: white; } +.flash.success { border-color: #8baa70; } +.flash.error { border-color: var(--wine); } +.hero { + display: grid; + grid-template-columns: minmax(320px, 0.95fr) minmax(320px, 1.05fr); + gap: 32px; + align-items: center; + min-height: 520px; + padding: 54px 6vw; + background: linear-gradient(90deg, #f8f0e7, #fffaf4); +} +.hero h1 { + font-family: Georgia, serif; + font-size: clamp(42px, 5vw, 72px); + line-height: 1.02; + margin: 10px 0 18px; +} +.hero p { max-width: 580px; font-size: 18px; } +.hero img { width: 100%; max-height: 500px; object-fit: cover; } +.hero-actions { display: flex; gap: 12px; margin-top: 28px; flex-wrap: wrap; } +.eyebrow { + color: var(--wine); + text-transform: uppercase; + font-size: 12px; + font-weight: 800; + letter-spacing: .08em; +} +.band, .grid-section, .store-layout, .cart-layout, .account-grid, .two-col { + max-width: 1240px; + margin: 0 auto; + padding: 44px 24px; +} +.section-title { + display: flex; + justify-content: space-between; + gap: 20px; + align-items: end; + margin-bottom: 22px; +} +h1, h2, h3 { font-family: Georgia, serif; } +.ranked-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 28px; +} +.ranked-list a { + display: grid; + grid-template-columns: 34px 1fr; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--line); +} +.ranked-list span { color: var(--gold); font-size: 24px; font-family: Georgia, serif; } +.ranked-list em { grid-column: 2; color: var(--muted); font-style: normal; } +.wine-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: 22px; +} +.wine-grid.list { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); } +.wine-card { + border: 1px solid var(--line); + background: white; + min-height: 100%; + display: grid; + grid-template-rows: 260px 1fr; +} +.offer-page { + display: grid; + grid-template-columns: 336px minmax(0, 1fr); + gap: 56px; + max-width: 1920px; + margin: 0 auto; + padding: 96px 5vw 72px; + background: #fff; +} +.offer-filters { + display: grid; + align-self: start; +} +.filter-row { + width: 100%; + border: 0; + border-bottom: 1px solid #d8d2cd; + background: transparent; + color: #0e0a08; + min-height: 84px; + padding: 0; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 20px; + font-weight: 700; +} +.offer-toolbar { + display: grid; + grid-template-columns: auto auto minmax(220px, 1fr); + align-items: center; + gap: 26px; + margin-bottom: 30px; +} +.filter-chip { + border: 1px solid #2d2724; + border-radius: 999px; + display: inline-flex; + align-items: center; + gap: 14px; + padding: 8px 18px; + font-size: 18px; +} +.filter-chip a, .clear-filters { + text-decoration: underline; +} +.sort-label { + justify-self: end; + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 18px; +} +.sort-label select { + width: auto; + border: 0; + padding: 4px 22px 4px 0; + font-weight: 700; + background: white; +} +.offer-grid { + display: grid; + grid-template-columns: repeat(3, minmax(260px, 1fr)); + gap: 28px; +} +.offer-card { + position: relative; + overflow: hidden; + border: 1px solid #eee5df; + background: #fbf7f4; + min-height: 640px; + display: grid; + grid-template-rows: auto 1fr; +} +.offer-corner { + position: absolute; + right: 0; + top: 0; + width: 86px; + height: 86px; + background: var(--wine); + clip-path: polygon(100% 0, 0 0, 100% 100%); +} +.offer-label { + color: #c62225; + text-transform: uppercase; + text-align: center; + font-weight: 800; + letter-spacing: .06em; + margin: 30px 20px 18px; + padding-right: 58px; + font-size: 18px; + white-space: nowrap; +} +.offer-card-top { + display: grid; + grid-template-columns: 34% minmax(0, 1fr); + align-items: center; + gap: 28px; + padding: 0 34px 26px; + min-height: 305px; +} +.offer-image { + display: grid; + place-items: center; + min-width: 0; +} +.offer-image img { + max-height: 255px; + width: 100%; + object-fit: contain; +} +.offer-teaser { + min-width: 0; + color: #0f0b09; + font-size: clamp(22px, 1.45vw, 27px); + line-height: 1.18; + font-weight: 500; +} +.offer-card-body { + display: grid; + grid-template-rows: auto auto 1fr; + gap: 18px; + padding: 0 24px 24px; +} +.offer-card h2 { + font-family: Arial, Helvetica, sans-serif; + font-size: 20px; + line-height: 1.25; + margin: 0; + font-weight: 600; +} +.offer-price { + display: grid; + grid-template-columns: auto auto 1fr auto; + align-items: baseline; + gap: 8px; + font-size: 18px; +} +.offer-price a { + justify-self: end; + text-decoration: underline; + white-space: nowrap; +} +.offer-actions { + align-self: end; + display: grid; + grid-template-columns: minmax(0, 1fr) 54px; + gap: 10px; +} +.offer-actions form:first-child { + display: grid; + grid-template-columns: 42px 44px 42px 58px; + gap: 6px; +} +.offer-actions input { + text-align: center; + border-color: #bdb5af; + padding: 0; + font-size: 17px; +} +.offer-actions button { + min-height: 52px; + border-color: #34302e; + background: white; + color: #34302e; + font-size: 24px; + font-weight: 400; +} +.offer-actions .bag-button { + background: #34302e; + color: white; + font-size: 13px; + font-weight: 800; +} +.offer-actions .heart-button { + width: 54px; + border: 0; + color: var(--wine); + font-size: 0; + background: transparent; +} +.offer-actions .heart-button::before { + content: "\2661"; + font-size: 46px; + line-height: 1; +} +.bottle-link { + background: var(--cream); + display: grid; + place-items: center; + padding: 18px; +} +.bottle-link img { max-height: 230px; object-fit: contain; } +.card-body { padding: 16px; display: flex; flex-direction: column; gap: 8px; } +.card-body h3 { margin: 0; font-size: 19px; } +.teaser, .meta { color: var(--muted); margin: 0; } +.teaser.large { font-size: 19px; color: var(--ink); } +.score { color: var(--green); font-weight: 700; } +.price-line, .detail-price { + display: flex; + gap: 10px; + align-items: baseline; + flex-wrap: wrap; +} +.was { color: var(--muted); text-decoration: line-through; } +.card-actions, .buy-box { display: flex; gap: 8px; margin-top: auto; } +.card-actions input, .buy-box input { width: 72px; } +.store-layout { + display: grid; + grid-template-columns: 260px 1fr; + gap: 32px; +} +.filters { + border-right: 1px solid var(--line); + padding-right: 24px; +} +.filters form, .form-card { display: grid; gap: 14px; } +.results-head, .order-card, .summary p { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; +} +.detail { + max-width: 1180px; + margin: 0 auto; + padding: 44px 24px; + display: grid; + grid-template-columns: minmax(280px, 0.85fr) minmax(320px, 1.15fr); + gap: 44px; +} +.detail-image { + background: var(--cream); + display: grid; + place-items: center; + padding: 32px; +} +.detail-image img { max-height: 560px; object-fit: contain; } +.detail-copy h1 { font-size: clamp(34px, 4vw, 58px); line-height: 1.05; } +.two-col, .cart-layout, .account-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 360px; + gap: 32px; +} +.specs { + display: grid; + grid-template-columns: 150px 1fr; + gap: 10px 16px; +} +.specs dt { color: var(--muted); } +.chips { display: flex; gap: 8px; padding: 0; flex-wrap: wrap; } +.chips li { list-style: none; background: var(--cream); padding: 8px 10px; } +.review-grid, .support-grid, .club-mini-grid, .club-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 18px; +} +.review-grid article, .mini-card, .order-card, .summary, .form-card, .support-grid article { + border: 1px solid var(--line); + background: white; + padding: 18px; +} +.club-strip { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 28px; + background: var(--wine-dark); + color: white; + padding: 46px 6vw; +} +.club-strip .eyebrow { color: #f0c177; } +.club-strip .mini-card { background: rgba(255,255,255,.08); border-color: rgba(255,255,255,.2); } +.auth-panel, .narrow { max-width: 760px; margin: 0 auto; padding: 48px 24px; } +.quick-links { display: flex; gap: 12px; flex-wrap: wrap; margin: 20px 0; } +.quick-links a { border: 1px solid var(--line); padding: 10px 12px; background: white; } +.line-item { + display: grid; + grid-template-columns: 90px 1fr 96px auto; + align-items: center; + gap: 16px; + border-bottom: 1px solid var(--line); + padding: 16px 0; +} +.line-item img { height: 110px; object-fit: contain; background: var(--cream); padding: 8px; } +.summary { align-self: start; position: sticky; top: 18px; } +.summary .total { border-top: 1px solid var(--line); padding-top: 12px; font-size: 20px; } +.summary.inline { position: static; margin-top: 24px; } +.form-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } +.club-hero { + grid-template-columns: minmax(360px, 0.9fr) minmax(420px, 1.1fr); + gap: 0; + background: linear-gradient(90deg, #fbf3ea 0 50%, #fff 50% 100%); +} +.club-hero img { + max-height: 520px; + object-fit: contain; + background: #fff; + padding: 20px; +} +.club-hero h1 { + max-width: 650px; +} +#clubs.grid-section { + max-width: 1380px; +} +#clubs .section-title { + display: grid; + grid-template-columns: 1fr; + justify-items: center; + text-align: center; +} +.club-grid { + grid-template-columns: repeat(2, minmax(360px, 1fr)); + gap: 24px; +} +.club-card { + border: 1px solid var(--line); + background: white; + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 22px; + padding: 20px; + min-width: 0; +} +.club-card > div { min-width: 0; } +.club-card img { height: 220px; width: 100%; object-fit: cover; background: var(--cream); } +.club-card h3 { + font-size: 27px; + line-height: 1.12; + margin: 10px 0; +} +.club-card p { + overflow-wrap: anywhere; +} +.club-detail-hero { + display: grid; + grid-template-columns: minmax(360px, 0.95fr) minmax(420px, 1.05fr); + gap: 48px; + align-items: center; + padding: 72px 6vw; + background: linear-gradient(90deg, #fbf3ea 0 50%, #fff 50% 100%); +} +.club-detail-hero h1 { + font-size: clamp(46px, 5vw, 78px); + line-height: 1.04; + margin: 10px 0 18px; +} +.club-detail-hero p { + max-width: 640px; + font-size: 20px; +} +.club-detail-hero img { + width: 100%; + max-height: 520px; + object-fit: contain; + background: white; + padding: 22px; +} +.club-terms { + font-weight: 700; +} +.member-badge { + display: inline-flex; + border: 1px solid #8baa70; + background: #f2f8ee; + color: #38542f; + padding: 10px 12px; + font-weight: 700; +} +.club-benefit-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; +} +.club-benefit-grid article { + border: 1px solid var(--line); + background: white; + padding: 22px; +} +.stack { display: grid; gap: 14px; } +.podcast-hero { + min-height: 640px; + display: grid; + place-items: center; + align-content: center; + gap: 12px; + background: #f7f7f7; + text-align: center; + padding: 74px 24px; +} +.podcast-hero p { + color: #49474b; + font-size: 30px; + margin: 14px 0 0; +} +.podcast-hero h1 { + margin: 0; + color: #8e2446; + font-family: Arial, Helvetica, sans-serif; + font-size: clamp(48px, 5vw, 72px); + letter-spacing: .18em; + text-transform: uppercase; +} +.podcast-hero h2 { + margin: 0; + color: #49474b; + font-family: Arial, Helvetica, sans-serif; + font-size: clamp(30px, 3vw, 46px); +} +.mic-mark { + width: 160px; + height: 220px; + position: relative; +} +.mic-mark span { + position: absolute; + left: 46px; + top: 0; + width: 78px; + height: 120px; + border-radius: 44px; + background: #8e2446; +} +.mic-mark span::after { + content: ""; + position: absolute; + right: -4px; + top: 24px; + width: 32px; + height: 78px; + background: repeating-linear-gradient(to bottom, #fff 0 10px, transparent 10px 20px); +} +.mic-mark::before { + content: ""; + position: absolute; + left: 20px; + top: 86px; + width: 120px; + height: 92px; + border: 12px solid #3b3a40; + border-top: 0; + border-radius: 0 0 72px 72px; +} +.mic-mark::after { + content: ""; + position: absolute; + left: 76px; + top: 176px; + width: 18px; + height: 42px; + background: #3b3a40; + box-shadow: -38px 38px 0 -1px #3b3a40, 38px 38px 0 -1px #3b3a40, 0 38px 0 0 #3b3a40; +} +.breadcrumbs { + max-width: 1320px; + margin: 0 auto; + padding: 22px 5vw; + display: flex; + gap: 10px; + flex-wrap: wrap; + color: #4e4a46; + background: white; +} +.breadcrumbs a { + text-decoration: underline; +} +.editorial-split, .gift-hero { + display: grid; + grid-template-columns: minmax(360px, 0.95fr) minmax(420px, 1.05fr); + gap: 56px; + align-items: center; + max-width: 1380px; + margin: 0 auto; + padding: 78px 5vw; +} +.editorial-split h2, .gift-hero h1, .experts-hero h1, .expert-band h2 { + font-size: clamp(42px, 5vw, 76px); + line-height: 1.05; + margin: 8px 0 18px; +} +.editorial-split p, .gift-hero p, .experts-hero p, .expert-band p { + font-size: 20px; + max-width: 700px; +} +.editorial-split img, .gift-hero img { + width: 100%; + max-height: 520px; + object-fit: contain; + background: #fff; +} +.episode-grid, .gift-steps, .gift-grid, .expert-grid { + display: grid; + gap: 22px; +} +.episode-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} +.episode-grid article, .gift-steps article, .gift-card, .expert-card { + border: 1px solid var(--line); + background: white; + padding: 24px; +} +.episode-grid h3, .gift-card h3, .expert-card h3 { + font-size: 28px; + line-height: 1.12; + margin: 10px 0; +} +.gift-hero { + background: linear-gradient(90deg, #fbf3ea 0 50%, #fff 50% 100%); + max-width: none; +} +.gift-hero > * { + max-width: 680px; +} +.gift-steps { + grid-template-columns: repeat(3, minmax(0, 1fr)); + max-width: 1180px; + margin: -34px auto 0; + padding: 0 24px 46px; +} +.gift-steps span { + display: inline-grid; + place-items: center; + width: 42px; + height: 42px; + border-radius: 50%; + background: var(--wine); + color: white; + font-weight: 800; +} +.gift-grid { + grid-template-columns: repeat(2, minmax(360px, 1fr)); +} +.gift-card { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 24px; + align-items: center; +} +.gift-card img { + width: 100%; + height: 220px; + object-fit: contain; + background: var(--cream); +} +.home-hero { + min-height: 590px; + display: grid; + align-items: center; + padding: 72px 7vw; + background: + linear-gradient(90deg, rgba(24, 15, 13, .88) 0 38%, rgba(24, 15, 13, .12) 68%), + url("../images/home_referral_hero.webp") center / cover no-repeat, + #241713; + color: white; +} +.home-hero-copy { max-width: 560px; } +.home-hero h1 { + margin: 10px 0 22px; + font-size: clamp(58px, 7vw, 104px); + line-height: .88; + letter-spacing: -.04em; +} +.home-hero p { max-width: 520px; font-size: 20px; } +.button.light { background: white; color: var(--ink); } +.centered { text-align: center; } +.home-press { + padding: 76px 24px; + text-align: center; + background: #efe8de; +} +.home-press blockquote { + max-width: 920px; + margin: 18px auto 34px; + font-family: Georgia, serif; + font-size: clamp(32px, 4vw, 58px); + line-height: 1.12; +} +.press-names { display: flex; justify-content: center; gap: 56px; flex-wrap: wrap; font-weight: 800; } +.home-collections { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; } +.home-collections a { display: grid; place-items: center; min-height: 360px; padding: 28px; background: var(--cream); font-size: 22px; } +.home-collections img { width: 100%; height: 270px; object-fit: contain; } +.experts-hero { + display: grid; + grid-template-columns: minmax(520px, 1.15fr) minmax(480px, .85fr); + align-items: center; + gap: 70px; + min-height: 680px; + padding: 54px 6vw; + background: #f3eee7; + color: var(--ink); +} +.experts-intro { max-width: 900px; } +.experts-hero .eyebrow { + color: var(--wine); +} +.expert-links { + display: flex; + gap: 28px; + flex-wrap: wrap; + margin-top: 40px; + padding-top: 22px; + border-top: 1px solid #c8bdb2; +} +.expert-links a { text-decoration: underline; text-underline-offset: 5px; } +.featured-expert { + display: grid; + grid-template-columns: minmax(240px, .9fr) minmax(220px, 1.1fr); + align-items: center; + gap: 28px; +} +.featured-expert img { + width: 100%; + max-height: 520px; + object-fit: cover; +} +.featured-expert h2 { + margin: 8px 0 16px; + font-size: clamp(38px, 4vw, 62px); + line-height: 1; +} +.expert-profile { + display: grid; + grid-template-columns: minmax(320px, 1fr) minmax(360px, 1fr); + align-items: center; + max-width: 1180px; + margin: 0 auto; + padding: 54px 5vw; + gap: 72px; +} +.expert-profile:nth-of-type(odd) { background: #faf7f2; max-width: none; padding-left: max(5vw, calc((100vw - 1180px) / 2)); padding-right: max(5vw, calc((100vw - 1180px) / 2)); } +.expert-profile img { + width: 100%; + max-height: 620px; + object-fit: cover; +} +.expert-profile h2 { + margin: 8px 0 20px; + font-size: clamp(44px, 5vw, 72px); + line-height: 1; +} +.footer { + display: grid; + grid-template-columns: 1.2fr 1fr 1fr; + gap: 24px; + padding: 36px; + border-top: 1px solid var(--line); + background: #1b1412; + color: white; +} +.footer a { display: block; margin: 6px 0; } +@media (max-width: 860px) { + .site-header, .hero, .store-layout, .detail, .two-col, .cart-layout, .account-grid, .club-strip, .footer { + grid-template-columns: 1fr; + } + .brand, .header-actions, .account-nav { + grid-column: 1; + grid-row: auto; + justify-self: stretch; + } + .brand { + text-align: left; + } + .filters { border-right: 0; border-bottom: 1px solid var(--line); padding: 0 0 22px; } + .ranked-list { grid-template-columns: 1fr; } + .line-item { grid-template-columns: 70px 1fr; } + .form-row { grid-template-columns: 1fr; } + .club-card { grid-template-columns: 1fr; } + .offer-page, .offer-toolbar, .offer-grid, .club-grid { + grid-template-columns: 1fr; + } + .offer-page { + gap: 26px; + padding: 28px 18px 46px; + } + .filter-row { + min-height: 54px; + } + .sort-label { + justify-self: start; + } + .offer-card-top { + grid-template-columns: 120px minmax(0, 1fr); + padding: 0 18px 20px; + } + .offer-actions form:first-child { + grid-template-columns: 44px minmax(48px, 1fr) 44px 60px; + } + .club-hero { + background: #fbf3ea; + } + .header-actions, .editorial-split, .gift-hero, .gift-steps, .gift-grid, .expert-grid, .episode-grid, .club-detail-hero, .club-benefit-grid, .home-collections, .expert-profile, .experts-hero, .featured-expert { + grid-template-columns: 1fr; + } + .header-actions { + justify-content: stretch; + } + .gift-card, .expert-card { + grid-template-columns: 1fr; + } + .podcast-hero { + min-height: 520px; + } + .gift-hero { + background: #fbf3ea; + } + .club-detail-hero { + background: #fbf3ea; + padding: 46px 24px; + } + .experts-hero { + min-height: 0; + padding: 48px 24px 28px; + } + .home-hero { min-height: 520px; padding: 52px 24px; background-position: 62% center; } + .expert-profile { gap: 28px; padding: 36px 24px; } +} diff --git a/sites/wineaccess/static/icons/.gitkeep b/sites/wineaccess/static/icons/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/wineaccess/static/js/.gitkeep b/sites/wineaccess/static/js/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/wineaccess/tasks.jsonl b/sites/wineaccess/tasks.jsonl new file mode 100644 index 00000000..0c7c1576 --- /dev/null +++ b/sites/wineaccess/tasks.jsonl @@ -0,0 +1,18 @@ +{"web_name":"Wine Access","id":"Wine Access--0","ques":"Log in as alice.j@test.com with password TestPass123!, find a Napa Valley Cabernet Sauvignon under $50, open its detail page, and add one bottle to the cart.","web":"http://localhost:40016/","upstream_url":"https://www.wineaccess.com/","verifier_path":"sites/wineaccess/verify/verify_0.py","judge_rubric":"STATE CHECKPOINTS: Alice has a net cart-quantity increase for a Napa Valley Cabernet Sauvignon priced below $50. NAVIGATION: login, store results, the qualifying wine detail, and cart must be visited."} +{"web_name":"Wine Access","id":"Wine Access--1","ques":"Log in as alice.j@test.com with password TestPass123!, search for Sonoma Coast Pinot Noir, and save a 94-point or higher bottle to the saved cellar list.","web":"http://localhost:40016/","upstream_url":"https://www.wineaccess.com/","verifier_path":"sites/wineaccess/verify/verify_1.py","judge_rubric":"STATE CHECKPOINTS: Alice gains a saved Sonoma Coast Pinot Noir scoring at least 94. NAVIGATION: login, search or store results, the qualifying wine detail, and Saved Cellar must be visited."} +{"web_name":"Wine Access","id":"Wine Access--2","ques":"Open the DuMOL Chloe Chardonnay and Fantesca Estate Chardonnay detail pages, then report which one has the later drinking window.","web":"http://localhost:40016/","upstream_url":"https://www.wineaccess.com/","verifier_path":"sites/wineaccess/verify/verify_2.py","judge_rubric":"FACT CHECKPOINTS: Fantesca Estate Chardonnay has the later drinking window, 2026-2037 versus DuMOL Chloe at 2026-2034. NAVIGATION: both Chardonnay detail pages must be visited."} +{"web_name":"Wine Access","id":"Wine Access--3","ques":"Find the highest-scoring French red wine in the store and report its appellation and current price.","web":"http://localhost:40016/","upstream_url":"https://www.wineaccess.com/","verifier_path":"sites/wineaccess/verify/verify_3.py","judge_rubric":"FACT CHECKPOINTS: 2015 Chateau Haut-Brion Pessac-Leognan; appellation Pessac-Leognan; current price $950. NAVIGATION: store results and that wine detail page must be visited."} +{"web_name":"Wine Access","id":"Wine Access--4","ques":"Log in as alice.j@test.com with password TestPass123!, filter the store to sparkling wines, compare the Champagne options, add the less expensive Champagne to the cart, and identify one suggested pairing from its detail page.","web":"http://localhost:40016/","upstream_url":"https://www.wineaccess.com/","verifier_path":"sites/wineaccess/verify/verify_4.py","judge_rubric":"STATE CHECKPOINTS: Alice gains M. Brugnon Selection Brut Champagne, the less expensive Champagne, in her cart. FACT CHECKPOINTS: fried chicken, triple cream cheese, or celebrations. NAVIGATION: login, sparkling results, the M. Brugnon detail, and cart must be visited."} +{"web_name":"Wine Access","id":"Wine Access--5","ques":"Log in as alice.j@test.com with password TestPass123!, update the favorite variety on the account page to Pinot Noir, and save the change.","web":"http://localhost:40016/","upstream_url":"https://www.wineaccess.com/","verifier_path":"sites/wineaccess/verify/verify_5.py","judge_rubric":"STATE CHECKPOINTS: Alice favorite_variety changes from Cabernet Sauvignon to Pinot Noir. NAVIGATION: login and account page must be visited."} +{"web_name":"Wine Access","id":"Wine Access--6","ques":"Log in as bob.c@test.com with password TestPass123! and find the tracking number for the most recent order in order history.","web":"http://localhost:40016/","upstream_url":"https://www.wineaccess.com/","verifier_path":"sites/wineaccess/verify/verify_6.py","judge_rubric":"FACT CHECKPOINTS: Bob most recent order tracking number is 9400111899. NAVIGATION: login and order history must be visited."} +{"web_name":"Wine Access","id":"Wine Access--7","ques":"Log in as alice.j@test.com with password TestPass123!, find a white wine from France with a score of at least 94 and add two bottles to the cart.","web":"http://localhost:40016/","upstream_url":"https://www.wineaccess.com/","verifier_path":"sites/wineaccess/verify/verify_7.py","judge_rubric":"STATE CHECKPOINTS: Alice gains at least two bottles of 2023 Domaine Roland Lavantureux Chablis, the qualifying French white. NAVIGATION: login, store results, its detail, and cart must be visited."} +{"web_name":"Wine Access","id":"Wine Access--8","ques":"Find a Cabernet Sauvignon from Oakville, open the details, and identify its drinking window.","web":"http://localhost:40016/","upstream_url":"https://www.wineaccess.com/","verifier_path":"sites/wineaccess/verify/verify_8.py","judge_rubric":"FACT CHECKPOINTS: 2021 Pas de Cheval Cabernet Sauvignon Prelude Oakville has drinking window 2026-2031. NAVIGATION: store results and that detail page must be visited."} +{"web_name":"Wine Access","id":"Wine Access--9","ques":"Compare the case price per bottle for Le Pich Cabernet Sauvignon and Bank Shot Cabernet Sauvignon, then state which is lower.","web":"http://localhost:40016/","upstream_url":"https://www.wineaccess.com/","verifier_path":"sites/wineaccess/verify/verify_9.py","judge_rubric":"FACT CHECKPOINTS: Bank Shot case price is $39.60 per bottle, lower than Le Pich at $57.20. NAVIGATION: both detail pages must be visited."} +{"web_name":"Wine Access","id":"Wine Access--10","ques":"Log in as carol.d@test.com using password TestPass123!, open Exclusive Clubs, join the Connoisseurs Club, and confirm it appears on the account page.","web":"http://localhost:40016/","upstream_url":"https://www.wineaccess.com/","verifier_path":"sites/wineaccess/verify/verify_10.py","judge_rubric":"STATE CHECKPOINTS: Carol newly gains exactly one Connoisseurs Club membership. NAVIGATION: login, Exclusive Clubs, Connoisseurs Club detail, and account must be visited."} +{"web_name":"Wine Access","id":"Wine Access--11","ques":"Use the contact page to find the Wine Access mailing address and support phone number.","web":"http://localhost:40016/","upstream_url":"https://www.wineaccess.com/","verifier_path":"sites/wineaccess/verify/verify_11.py","judge_rubric":"FACT CHECKPOINTS: WineAccess, Inc., 122 Camino Oruga, Building A, Napa, CA 94558; support phone (866) 946-3923. NAVIGATION: Contact Us must be visited."} +{"web_name":"Wine Access","id":"Wine Access--12","ques":"Search for Burgundy Pinot Noir, compare all three qualifying Burgundy bottles, and identify the bottle whose drinking window has the latest end year.","web":"http://localhost:40016/","upstream_url":"https://www.wineaccess.com/","verifier_path":"sites/wineaccess/verify/verify_12.py","judge_rubric":"FACT CHECKPOINTS: Maison Leroy Gevrey-Chambertin has the latest end year, 2035, versus Nuits-Saint-Georges at 2032 and Clos de Tart at 2033. NAVIGATION: Burgundy search/results and all three qualifying Burgundy Pinot Noir detail pages must be visited."} +{"web_name":"Wine Access","id":"Wine Access--13","ques":"Create a new account, add any red blend under $30 to the cart, and proceed to the checkout page without placing the order.","web":"http://localhost:40016/","upstream_url":"https://www.wineaccess.com/","verifier_path":"sites/wineaccess/verify/verify_13.py","judge_rubric":"STATE CHECKPOINTS: exactly one new account is created, its cart contains a red blend under $30, and it has no order. NAVIGATION: register, store, a qualifying detail, cart, and checkout must be visited."} +{"web_name":"Wine Access","id":"Wine Access--14","ques":"Log in as david.k@test.com with password TestPass123!, remove one item from the cart, and confirm the cart total changes.","web":"http://localhost:40016/","upstream_url":"https://www.wineaccess.com/","verifier_path":"sites/wineaccess/verify/verify_14.py","judge_rubric":"STATE CHECKPOINTS: David cart merchandise total decreases after an item is removed. NAVIGATION: login and cart must be visited."} +{"web_name":"Wine Access","id":"Wine Access--15","ques":"Find all wines from Italy under $40, open the best-scoring option, and report its suggested food pairings.","web":"http://localhost:40016/","upstream_url":"https://www.wineaccess.com/","verifier_path":"sites/wineaccess/verify/verify_15.py","judge_rubric":"FACT CHECKPOINTS: 2024 Etna Bianco Carricante is the highest-scoring Italy option under $40; pairing includes roast chicken, shellfish, or spring vegetables. NAVIGATION: Italy results and its detail page must be visited."} +{"web_name":"Wine Access","id":"Wine Access--16","ques":"Log in as alice.j@test.com with password TestPass123!, complete checkout using the prefilled shipping details and card ending 4242, then report the new order number.","web":"http://localhost:40016/","upstream_url":"https://www.wineaccess.com/","verifier_path":"sites/wineaccess/verify/verify_16.py","judge_rubric":"STATE CHECKPOINTS: Alice gains exactly one order and her cart becomes empty; the reported WA order number must match the new order when present. NAVIGATION: login, cart, checkout, and new order detail must be visited."} +{"web_name":"Wine Access","id":"Wine Access--17","ques":"Use the shipping information page to determine how Wine Access handles weather holds before delivery.","web":"http://localhost:40016/","upstream_url":"https://www.wineaccess.com/","verifier_path":"sites/wineaccess/verify/verify_17.py","judge_rubric":"FACT CHECKPOINTS: during heat or cold events, members can delay shipping to protect bottle condition. NAVIGATION: Where We Ship must be visited."} diff --git a/sites/wineaccess/templates/.gitkeep b/sites/wineaccess/templates/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/wineaccess/templates/_offer_card.html b/sites/wineaccess/templates/_offer_card.html new file mode 100644 index 00000000..481fe319 --- /dev/null +++ b/sites/wineaccess/templates/_offer_card.html @@ -0,0 +1,32 @@ +
+ +

Limited Time Offer

+ +
+

{{ wine.name }}

+
+ {% if wine.list_price > wine.price %}${{ '%.0f'|format(wine.list_price) }}{% endif %} + {{ wine.display_price }} + 1-11 bottles + See all prices +
+
+
+ + + + + +
+
+ + +
+
+
+
diff --git a/sites/wineaccess/templates/_wine_card.html b/sites/wineaccess/templates/_wine_card.html new file mode 100644 index 00000000..9b918c33 --- /dev/null +++ b/sites/wineaccess/templates/_wine_card.html @@ -0,0 +1,21 @@ +
+ + {{ wine.name }} + +
+ {% if wine.limited_offer %}Limited Time Offer{% endif %} +

{{ wine.teaser }}

+

{{ wine.name }}

+

{{ wine.variety }} · {{ wine.region }}

+

{{ wine.score }} pts · {{ wine.reviewer }}

+
+ {% if wine.list_price > wine.price %}Price was ${{ '%.0f'|format(wine.list_price) }}{% endif %} + {{ wine.display_price }} +
+
+ + + +
+
+
diff --git a/sites/wineaccess/templates/account.html b/sites/wineaccess/templates/account.html new file mode 100644 index 00000000..ba3f7180 --- /dev/null +++ b/sites/wineaccess/templates/account.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% block title %}Account - Wine Access{% endblock %} +{% block content %} + +{% endblock %} diff --git a/sites/wineaccess/templates/base.html b/sites/wineaccess/templates/base.html new file mode 100644 index 00000000..3a5aa1a5 --- /dev/null +++ b/sites/wineaccess/templates/base.html @@ -0,0 +1,71 @@ + + + + + + {% block title %}Wine Access{% endblock %} + + + + +
Rack Up $50 Wine Credits
+ + + {% 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/wineaccess/templates/cart.html b/sites/wineaccess/templates/cart.html new file mode 100644 index 00000000..f644b237 --- /dev/null +++ b/sites/wineaccess/templates/cart.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% block title %}Cart - Wine Access{% endblock %} +{% block content %} +
+
+

Your Cart

+
+ {% for item in current_user.cart_items %} +
+ {{ item.wine.name }} +
+

{{ item.wine.name }}

+

{{ item.wine.variety }} · {{ item.wine.region }}

+ {{ item.wine.display_price }} +
+ + +
+ {% else %} +

Your cart is empty. Shop wine.

+ {% endfor %} + {% if current_user.cart_items %} + + {% endif %} +
+ {% for item in current_user.cart_items %} +
+ {% endfor %} +
+ +
+{% endblock %} diff --git a/sites/wineaccess/templates/checkout.html b/sites/wineaccess/templates/checkout.html new file mode 100644 index 00000000..c06f35e5 --- /dev/null +++ b/sites/wineaccess/templates/checkout.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}Checkout - Wine Access{% endblock %} +{% block content %} +
+
+

Checkout

+

Shipping

+ +
+ + + +
+

Payment

+ + +
+ +
+{% endblock %} diff --git a/sites/wineaccess/templates/club.html b/sites/wineaccess/templates/club.html new file mode 100644 index 00000000..edf1c13a --- /dev/null +++ b/sites/wineaccess/templates/club.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block title %}Wine Clubs - Wine Access{% endblock %} +{% block content %} +
+
+

Wine Access Clubs

+

A Club for Every Palate

+

Expertly curated wines and the stories behind them, delivered to your door.

+ Find Your Club +
+ Wine bottles and wine glass display +
+
+
+

Member Benefits

+

New bottles, tasting cards, early access

+
+
+ {% for club in clubs %} +
+ + {{ club.name }} + +
+

{{ club.tagline }}

+

{{ club.name }}

+

{{ club.description }}

+

{{ club.bottles }} bottles / {{ club.frequency }} / ${{ '%.0f'|format(club.price_per_shipment) }} per shipment

+ View Club +
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/wineaccess/templates/club_detail.html b/sites/wineaccess/templates/club_detail.html new file mode 100644 index 00000000..f2b25026 --- /dev/null +++ b/sites/wineaccess/templates/club_detail.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} +{% block title %}{{ club.name }} - Wine Access{% endblock %} +{% block content %} +
+
+

Wine Access Clubs

+

{{ club.name }}

+

{{ club.description }}

+

{{ club.bottles }} bottles / {{ club.frequency }} / ${{ '%.0f'|format(club.price_per_shipment) }} per shipment

+ {% if current_user.is_authenticated %} + {% if is_member %} +

This club appears on your account page.

+ View Account + {% else %} +
+ +
+ {% endif %} + {% else %} + Sign In to Join + {% endif %} +
+ {{ club.name }} +
+ +
+
+

What Members Receive

+

Curated bottles with context

+
+
+
+

Expert selection

+

Wine Access tasters choose bottles that match the club's palate and purpose.

+
+
+

Tasting cards

+

Each shipment includes serving notes, food-pairing ideas, and bottle stories.

+
+
+

Member access

+

Members get early access to limited releases and club-only discoveries.

+
+
+
+ +
+
+

Other Clubs

+

Compare your options

+
+ +
+{% endblock %} diff --git a/sites/wineaccess/templates/contact.html b/sites/wineaccess/templates/contact.html new file mode 100644 index 00000000..31a61a30 --- /dev/null +++ b/sites/wineaccess/templates/contact.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}Contact Us - Wine Access{% endblock %} +{% block content %} +
+

Customer Support

+

We're Here to Help

+

Our member services team is ready to help via phone or email from 8am to 5pm PT, daily.

+
+

Manage Your Account

Update delivery dates, shipping addresses, profiles, email preferences, and credit cards.

+

Privacy and Security

Review account settings, order details, and saved payment methods.

+

Mailing Address

WineAccess, Inc.
122 Camino Oruga
Building A
Napa, CA 94558

+
+

FAQs

+
What is Wine Access?

Wine Access researches, tastes, and negotiates for wines that overdeliver on quality and price.

+
How are wines chosen?

The panel tastes broadly, checks provenance, and selects bottles with a clear story and value.

+
How do I find current offers?

Shop the store, follow limited time offers, or join a club for curated shipments.

+
+{% endblock %} diff --git a/sites/wineaccess/templates/detail.html b/sites/wineaccess/templates/detail.html new file mode 100644 index 00000000..f5a40d52 --- /dev/null +++ b/sites/wineaccess/templates/detail.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} +{% block title %}{{ wine.name }} - Wine Access{% endblock %} +{% block content %} +
+
+ {{ wine.name }} +
+
+ {% if wine.limited_offer %}

Limited Time Offer

{% endif %} +

{{ wine.name }}

+

{{ wine.teaser }}

+

{{ wine.score }} points · {{ wine.reviewer }}

+

{{ wine.tasting_notes }}

+
+ {% if wine.list_price > wine.price %}Price was ${{ '%.0f'|format(wine.list_price) }}{% endif %} + {{ wine.display_price }} + 1-11 bottles + ${{ '%.2f'|format(wine.case_price / 12) }} per bottle by the case +
+
+ + +
+ {% if current_user.is_authenticated %} +
+ +
+ {% endif %} +
+
+ +
+
+

Wine Access Tasting Notes

+

{{ wine.story }}

+

Pairings

+
    + {% for pairing in wine.pairings %}
  • {{ pairing }}
  • {% endfor %} +
+
+
+

Details

+
+ {% for key, value in wine.specs.items() %} +
{{ key }}
{{ value }}
+ {% endfor %} +
Availability
{{ wine.availability }}
+
Inventory
{{ wine.inventory }} bottles
+
+
+
+ +
+

Member Reviews

+
+ {% for review in wine.reviews %} +
+ {{ review.title }} + {{ review.rating }} stars by {{ review.author }} +

{{ review.body }}

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

Related Selections

+
+ {% for wine in related %} + {% include "_wine_card.html" %} + {% endfor %} +
+
+{% endblock %} diff --git a/sites/wineaccess/templates/experts.html b/sites/wineaccess/templates/experts.html new file mode 100644 index 00000000..f1fbc36e --- /dev/null +++ b/sites/wineaccess/templates/experts.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% block title %}Unrivaled Experts - Wine Access{% endblock %} +{% block content %} +
+
+

Unrivaled Experts

+

We're here to give you unprecedented access.

+

Our experts draw on decades in the world's great restaurants, cellars, and wine regions to connect members with bottles worth knowing.

+ +
+ +
+ +{% for expert in experts[1:] %} +
+ Portrait of {{ expert.name }} +
+

{{ expert.role }}

+

{{ expert.name }}

+

{{ expert.quote }}

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

Expert Picks

+

Recently selected by the tasting panel

+
+
+ {% for wine in wines %} + {% include "_wine_card.html" %} + {% endfor %} +
+
+{% endblock %} diff --git a/sites/wineaccess/templates/gifting.html b/sites/wineaccess/templates/gifting.html new file mode 100644 index 00000000..c7b4071c --- /dev/null +++ b/sites/wineaccess/templates/gifting.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} +{% block title %}Wine Club Gifting - Wine Access{% endblock %} +{% block content %} +
+
+

Wine Access Gifting

+

A Wine Club Gift for Every Palate

+

Give expertly curated bottles, tasting notes, and early access to hard-to-find wines. Choose a club, set the delivery details, and let the recipient enjoy the discovery.

+ Start Gifting +
+ Gift-ready Wine Access club bottles +
+ +
+
+ 1 +

Select a club

+

Pick reds, discovery bottles, sparkling wines, or the podcast club.

+
+
+ 2 +

Add the recipient

+

Include a gift note and ship to a supported state.

+
+
+ 3 +

They unwrap access

+

Each shipment includes curated wines and tasting cards.

+
+
+ +
+
+

Gift Memberships

+

Choose their next favorite shipment

+
+
+ {% for club in clubs %} +
+ {{ club.name }} +
+

{{ club.tagline }}

+

{{ club.name }}

+

{{ club.description }}

+

{{ club.bottles }} bottles / {{ club.frequency }} / ${{ '%.0f'|format(club.price_per_shipment) }}

+ Gift This Club +
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/wineaccess/templates/index.html b/sites/wineaccess/templates/index.html new file mode 100644 index 00000000..c935d56c --- /dev/null +++ b/sites/wineaccess/templates/index.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block title %}Wine Access - Exceptional Wines, Curated by Experts{% endblock %} +{% block content %} +
+
+

Share exceptional wine

+

Give $50.
Get $50.

+

Invite a friend to Wine Access. They receive $50 toward their first discovery, and you receive $50 in wine credit.

+ Refer a Friend +
+
+ +
+
+

Curated for our members

+

This Week's Member Favorites

+

Bottles our community is opening, sharing, and returning to right now.

+
+
+ {% for wine in wines %} + {% include "_wine_card.html" %} + {% endfor %} +
+

Shop All Wines

+
+ +
+

A better way to buy wine

+
“Wine Access brings the world's most compelling bottles directly to your door.”
+
+ ForbesBusiness InsiderFood & Wine +
+
+ +
+
+

Explore Wine Access

+

Shop Collections

+
+
+ {% for wine in collections %} + + {{ wine.name }} + {{ wine.region }} + + {% endfor %} +
+
+{% endblock %} diff --git a/sites/wineaccess/templates/login.html b/sites/wineaccess/templates/login.html new file mode 100644 index 00000000..25d908c2 --- /dev/null +++ b/sites/wineaccess/templates/login.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% block title %}Log In - Wine Access{% endblock %} +{% block content %} +
+

Sign Up / Log In

+
+ + + +
+

New to Wine Access? Create an account.

+
+{% endblock %} diff --git a/sites/wineaccess/templates/order_detail.html b/sites/wineaccess/templates/order_detail.html new file mode 100644 index 00000000..4d4dd4b2 --- /dev/null +++ b/sites/wineaccess/templates/order_detail.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block title %}{{ order.order_number }} - Wine Access{% endblock %} +{% block content %} +
+

{{ order.status }}

+

{{ order.order_number }}

+

Placed {{ order.placed_at.strftime('%B %d, %Y') }}

+

Ship to {{ order.ship_to }}

+

Tracking: {{ order.tracking_number }}

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

{{ item.wine_name }}

+

{{ item.quantity }} bottle(s) at ${{ '%.2f'|format(item.unit_price) }}

+
+ ${{ '%.2f'|format(item.quantity * item.unit_price) }} +
+ {% endfor %} +
+ +
+{% endblock %} diff --git a/sites/wineaccess/templates/orders.html b/sites/wineaccess/templates/orders.html new file mode 100644 index 00000000..37a9dc93 --- /dev/null +++ b/sites/wineaccess/templates/orders.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block title %}Orders - Wine Access{% endblock %} +{% block content %} +
+

Order History

+
+ {% for order in orders|sort(attribute='placed_at', reverse=true) %} +
+
+

{{ order.status }}

+

{{ order.order_number }}

+

{{ order.placed_at.strftime('%B %d, %Y') }} · {{ order.tracking_number }}

+
+ ${{ '%.2f'|format(order.total) }} +
+ {% else %} +

No orders yet.

+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/wineaccess/templates/podcast.html b/sites/wineaccess/templates/podcast.html new file mode 100644 index 00000000..4417787f --- /dev/null +++ b/sites/wineaccess/templates/podcast.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% block title %}Wine Access Unfiltered - Wine Access{% endblock %} +{% block content %} +
+ +

Wine Access

+

Unfiltered

+

Official Podcast and Wine Club

+
+ + + +
+
+

Wine Access Unfiltered

+

Where Fine-tuned Palates Meet Fresh Perspectives

+

Hosted by Amanda McCrossin, Unfiltered pairs candid conversations with bottles selected for curious drinkers. Each shipment brings wines connected to the stories, guests, and expert perspective behind the episode.

+ Join the Podcast Club +
+ Wine Access Unfiltered podcast hosts +
+ +
+
+

Recent Episodes

+

Drink along with the conversation

+
+
+ {% for episode in episodes %} +
+

{{ episode.number }} / {{ episode.date }}

+

{{ episode.title }}

+

{{ episode.wine }}

+ Explore the club +
+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/wineaccess/templates/register.html b/sites/wineaccess/templates/register.html new file mode 100644 index 00000000..97bee1e8 --- /dev/null +++ b/sites/wineaccess/templates/register.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% block title %}Register - Wine Access{% endblock %} +{% block content %} +
+

Create a Wine Access account

+
+ + + + +
+
+{% endblock %} diff --git a/sites/wineaccess/templates/saved.html b/sites/wineaccess/templates/saved.html new file mode 100644 index 00000000..6a19ddb3 --- /dev/null +++ b/sites/wineaccess/templates/saved.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}Saved Cellar - Wine Access{% endblock %} +{% block content %} +
+

Saved Cellar

+
+ {% for item in items %} + {% set wine = item.wine %} + {% include "_wine_card.html" %} + {% else %} +

No saved wines yet.

+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/wineaccess/templates/store.html b/sites/wineaccess/templates/store.html new file mode 100644 index 00000000..9fdaa1fb --- /dev/null +++ b/sites/wineaccess/templates/store.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} +{% block title %}Shop Wine Access{% endblock %} +{% block content %} +
+ +
+
+
+

Wine Access Store

+

{% if query_text %}Search: {{ query_text }}{% else %}All Wines{% endif %}

+
+

{{ wines|length }} selections

+
+
+ {% for wine in wines %} + {% include "_wine_card.html" %} + {% else %} +

No wines matched those filters. Try a broader search.

+ {% endfor %} +
+
+
+{% endblock %} diff --git a/sites/wineaccess/templates/where_we_ship.html b/sites/wineaccess/templates/where_we_ship.html new file mode 100644 index 00000000..29ef11b7 --- /dev/null +++ b/sites/wineaccess/templates/where_we_ship.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}Where We Ship - Wine Access{% endblock %} +{% block content %} +
+

Perfect Provenance

+

Where We Ship

+

Wine Access ships with climate-aware options and adult-signature delivery. Availability varies by state and order contents.

+
+

Ships Immediately

California, Colorado, Florida, New York, Oregon, Texas, Washington, and many additional states.

+

Weather Holds

During heat or cold events, members can delay shipping to protect bottle condition.

+

Order Updates

Members can update an existing order from the account dashboard before fulfillment.

+
+
+{% endblock %} diff --git a/sites/wineaccess/verify/README.md b/sites/wineaccess/verify/README.md new file mode 100644 index 00000000..b5aa1ef7 --- /dev/null +++ b/sites/wineaccess/verify/README.md @@ -0,0 +1,9 @@ +# WineAccess deterministic task verifiers + +Each verifier requires task-specific navigation plus a frozen answer or a SQLite before/after state transition. Stateful tasks cannot pass from a self-reported answer alone. The grader reads `trajectory.json` from `--run_dir`; container mode copies both `instance_seed/wineaccess.db` and `instance/wineaccess.db` for state checks. + +Run the focused suite with: + +```bash +python3 -m unittest discover -s sites/wineaccess/verify -p 'test_*.py' -v +``` diff --git a/sites/wineaccess/verify/test_verifiers.py b/sites/wineaccess/verify/test_verifiers.py new file mode 100644 index 00000000..3c6f3d79 --- /dev/null +++ b/sites/wineaccess/verify/test_verifiers.py @@ -0,0 +1,130 @@ +import json +import shutil +import sqlite3 +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from verify_lib import NAV_ALTERNATIVES, PASS_ANSWERS, STATEFUL_TASKS, TASK_COUNT, evaluate + + +def trajectory(answer, urls): + return {"final_answer": answer, "steps": [{"step": i, "url": url} for i, url in enumerate(urls)]} + + +def passing_urls(index): + return [group[0] for group in NAV_ALTERNATIVES[index][0]] + + +def apply_state(index, path): + with sqlite3.connect(path) as db: + uid = db.execute("SELECT id FROM users WHERE email='alice.j@test.com'").fetchone()[0] + if index == 0: + db.execute("INSERT INTO cart_items(user_id,wine_id,quantity) VALUES(?,?,?)", (uid, 38, 1)) + elif index == 1: + db.execute("INSERT INTO wishlist_items(user_id,wine_id,note) VALUES(?,?,?)", (uid, 18, "")) + elif index == 4: + db.execute("INSERT INTO cart_items(user_id,wine_id,quantity) VALUES(?,?,?)", (uid, 36, 1)) + elif index == 5: + db.execute("UPDATE users SET favorite_variety='Pinot Noir' WHERE id=?", (uid,)) + elif index == 7: + db.execute("INSERT INTO cart_items(user_id,wine_id,quantity) VALUES(?,?,?)", (uid, 33, 2)) + elif index == 10: + carol = db.execute("SELECT id FROM users WHERE email='carol.d@test.com'").fetchone()[0] + club = db.execute("SELECT id FROM clubs WHERE slug='connoisseurs'").fetchone()[0] + db.execute("INSERT INTO club_memberships(user_id,club_id,status) VALUES(?,?,?)", (carol, club, "Active")) + elif index == 13: + db.execute("INSERT INTO users(id,username,email,password_hash,display_name) VALUES(99,'new-reviewer','new-reviewer@example.com','hash','New Reviewer')") + db.execute("INSERT INTO cart_items(user_id,wine_id,quantity) VALUES(99,2,1)") + elif index == 14: + david = db.execute("SELECT id FROM users WHERE email='david.k@test.com'").fetchone()[0] + item = db.execute("SELECT id FROM cart_items WHERE user_id=? ORDER BY id LIMIT 1", (david,)).fetchone()[0] + db.execute("DELETE FROM cart_items WHERE id=?", (item,)) + elif index == 16: + db.execute("INSERT INTO orders(id,user_id,order_number,status) VALUES(99,?,'WA-260817-1005','Processing')", (uid,)) + db.execute("DELETE FROM cart_items WHERE user_id=?", (uid,)) + db.commit() + + +class WineAccessVerifierTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.tmp = tempfile.TemporaryDirectory() + cls.seed = Path(__file__).parents[1] / "instance_seed" / "wineaccess.db" + cls.after = {} + for index in STATEFUL_TASKS: + path = Path(cls.tmp.name) / f"after-{index}.db" + shutil.copy2(cls.seed, path) + apply_state(index, path) + cls.after[index] = path + + @classmethod + def tearDownClass(cls): + cls.tmp.cleanup() + + def verdict(self, index, answer, urls, changed=True): + return evaluate(index, trajectory(answer, urls), str(self.seed), str(self.after[index] if changed and index in STATEFUL_TASKS else self.seed)) + + def test_all_eighteen_positive_cases(self): + for index in range(TASK_COUNT): + with self.subTest(index=index): + self.assertTrue(self.verdict(index, PASS_ANSWERS[index], passing_urls(index))["pass"]) + + def test_all_eighteen_no_op_runs_fail(self): + for index in range(TASK_COUNT): + with self.subTest(index=index): + self.assertFalse(self.verdict(index, "", ["http://localhost:40015/"])["pass"]) + + def test_all_eighteen_prior_knowledge_shortcuts_fail(self): + for index in range(TASK_COUNT): + with self.subTest(index=index): + result = self.verdict(index, PASS_ANSWERS[index], ["http://localhost:40015/"]) + self.assertFalse(result["pass"]) + self.assertEqual(result["reason"], "required_navigation") + + def test_all_eighteen_wrong_answers_fail(self): + for index in range(TASK_COUNT): + with self.subTest(index=index): + result = self.verdict(index, "Definitely wrong.", passing_urls(index)) + self.assertFalse(result["pass"]) + self.assertEqual(result["reason"], "frozen_answer") + + def test_all_stateful_claims_require_database_change(self): + for index in sorted(STATEFUL_TASKS): + with self.subTest(index=index): + result = self.verdict(index, PASS_ANSWERS[index], passing_urls(index), changed=False) + self.assertFalse(result["pass"]) + + def test_task_file_has_exact_grading_contracts_and_credentials(self): + path = Path(__file__).parents[1] / "tasks.jsonl" + rows = [json.loads(line) for line in path.read_text().splitlines()] + self.assertEqual(len(rows), TASK_COUNT) + auth = {0, 1, 4, 5, 6, 7, 10, 14, 16} + for index, row in enumerate(rows): + self.assertEqual(row.get("verifier_path"), f"sites/wineaccess/verify/verify_{index}.py") + self.assertIn("NAVIGATION:", row.get("judge_rubric", "")) + self.assertNotIn("answer", row) + self.assertEqual(row["web"], "http://localhost:40016/") + self.assertTrue((Path(__file__).parent / f"verify_{index}.py").exists()) + if index in auth: + self.assertIn("TestPass123!", row["ques"]) + + def test_all_wrapper_exit_codes(self): + verify = Path(__file__).parent + for index in range(TASK_COUNT): + run_dir = Path(self.tmp.name) / f"run-{index}" + run_dir.mkdir() + run = run_dir / "trajectory.json" + command = [sys.executable, str(verify / f"verify_{index}.py"), "--run_dir", str(run_dir), "--initial_db", str(self.seed), "--after_db", str(self.after.get(index, self.seed))] + run.write_text(json.dumps(trajectory(PASS_ANSWERS[index], passing_urls(index)))) + passed = subprocess.run(command, capture_output=True, text=True) + self.assertEqual(passed.returncode, 0, passed.stdout + passed.stderr) + run.write_text(json.dumps(trajectory("", ["http://localhost:40015/"]))) + failed = subprocess.run(command, capture_output=True, text=True) + self.assertEqual(failed.returncode, 1, failed.stdout + failed.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/sites/wineaccess/verify/verify_0.py b/sites/wineaccess/verify/verify_0.py new file mode 100644 index 00000000..3e667fc2 --- /dev/null +++ b/sites/wineaccess/verify/verify_0.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import main +raise SystemExit(main(0)) diff --git a/sites/wineaccess/verify/verify_1.py b/sites/wineaccess/verify/verify_1.py new file mode 100644 index 00000000..d8bd4711 --- /dev/null +++ b/sites/wineaccess/verify/verify_1.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import main +raise SystemExit(main(1)) diff --git a/sites/wineaccess/verify/verify_10.py b/sites/wineaccess/verify/verify_10.py new file mode 100644 index 00000000..81cc0d98 --- /dev/null +++ b/sites/wineaccess/verify/verify_10.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import main +raise SystemExit(main(10)) diff --git a/sites/wineaccess/verify/verify_11.py b/sites/wineaccess/verify/verify_11.py new file mode 100644 index 00000000..c65c965a --- /dev/null +++ b/sites/wineaccess/verify/verify_11.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import main +raise SystemExit(main(11)) diff --git a/sites/wineaccess/verify/verify_12.py b/sites/wineaccess/verify/verify_12.py new file mode 100644 index 00000000..ebf0668f --- /dev/null +++ b/sites/wineaccess/verify/verify_12.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import main +raise SystemExit(main(12)) diff --git a/sites/wineaccess/verify/verify_13.py b/sites/wineaccess/verify/verify_13.py new file mode 100644 index 00000000..83dd5e50 --- /dev/null +++ b/sites/wineaccess/verify/verify_13.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import main +raise SystemExit(main(13)) diff --git a/sites/wineaccess/verify/verify_14.py b/sites/wineaccess/verify/verify_14.py new file mode 100644 index 00000000..4d4e1c85 --- /dev/null +++ b/sites/wineaccess/verify/verify_14.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import main +raise SystemExit(main(14)) diff --git a/sites/wineaccess/verify/verify_15.py b/sites/wineaccess/verify/verify_15.py new file mode 100644 index 00000000..4e5589a7 --- /dev/null +++ b/sites/wineaccess/verify/verify_15.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import main +raise SystemExit(main(15)) diff --git a/sites/wineaccess/verify/verify_16.py b/sites/wineaccess/verify/verify_16.py new file mode 100644 index 00000000..895ce00f --- /dev/null +++ b/sites/wineaccess/verify/verify_16.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import main +raise SystemExit(main(16)) diff --git a/sites/wineaccess/verify/verify_17.py b/sites/wineaccess/verify/verify_17.py new file mode 100644 index 00000000..2ce026c9 --- /dev/null +++ b/sites/wineaccess/verify/verify_17.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import main +raise SystemExit(main(17)) diff --git a/sites/wineaccess/verify/verify_2.py b/sites/wineaccess/verify/verify_2.py new file mode 100644 index 00000000..e5e0aca1 --- /dev/null +++ b/sites/wineaccess/verify/verify_2.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import main +raise SystemExit(main(2)) diff --git a/sites/wineaccess/verify/verify_3.py b/sites/wineaccess/verify/verify_3.py new file mode 100644 index 00000000..b7230e53 --- /dev/null +++ b/sites/wineaccess/verify/verify_3.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import main +raise SystemExit(main(3)) diff --git a/sites/wineaccess/verify/verify_4.py b/sites/wineaccess/verify/verify_4.py new file mode 100644 index 00000000..f770b758 --- /dev/null +++ b/sites/wineaccess/verify/verify_4.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import main +raise SystemExit(main(4)) diff --git a/sites/wineaccess/verify/verify_5.py b/sites/wineaccess/verify/verify_5.py new file mode 100644 index 00000000..08b9c385 --- /dev/null +++ b/sites/wineaccess/verify/verify_5.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import main +raise SystemExit(main(5)) diff --git a/sites/wineaccess/verify/verify_6.py b/sites/wineaccess/verify/verify_6.py new file mode 100644 index 00000000..d34948c4 --- /dev/null +++ b/sites/wineaccess/verify/verify_6.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import main +raise SystemExit(main(6)) diff --git a/sites/wineaccess/verify/verify_7.py b/sites/wineaccess/verify/verify_7.py new file mode 100644 index 00000000..33375add --- /dev/null +++ b/sites/wineaccess/verify/verify_7.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import main +raise SystemExit(main(7)) diff --git a/sites/wineaccess/verify/verify_8.py b/sites/wineaccess/verify/verify_8.py new file mode 100644 index 00000000..ef1b73b7 --- /dev/null +++ b/sites/wineaccess/verify/verify_8.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import main +raise SystemExit(main(8)) diff --git a/sites/wineaccess/verify/verify_9.py b/sites/wineaccess/verify/verify_9.py new file mode 100644 index 00000000..89ad36a2 --- /dev/null +++ b/sites/wineaccess/verify/verify_9.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 +from verify_lib import main +raise SystemExit(main(9)) diff --git a/sites/wineaccess/verify/verify_lib.py b/sites/wineaccess/verify/verify_lib.py new file mode 100644 index 00000000..f81da405 --- /dev/null +++ b/sites/wineaccess/verify/verify_lib.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +"""Deterministic grading for the 18 WineAccess benchmark tasks.""" + +import argparse +import json +import math +import os +import re +import sqlite3 +import subprocess +import tempfile +from pathlib import Path +from urllib.parse import unquote + + +SITE = "wineaccess" +TASK_COUNT = 18 +STATEFUL_TASKS = {0, 1, 4, 5, 7, 10, 13, 14, 16} + + +def _alt(*groups): + return tuple(tuple(group) if isinstance(group, (tuple, list)) else (group,) for group in groups) + + +NAV_ALTERNATIVES = { + 0: (_alt("/login", "/store/", ("2021-bank-shot", "2023-1881-napa", "2023-karo-kann", "2023-off-the-cuff"), "/cart"),), + 1: (_alt("/login", ("/search", "/store/"), ("2023-glassmen", "2024-raen"), "/saved"),), + 2: (_alt("2022-dumol", "2022-fantesca"),), + 3: (_alt("/store/", "2015-chateau-haut-brion"),), + 4: (_alt("/login", "/store/sparkling", "m-brugnon", "/cart"),), + 5: (_alt("/login", "/account"),), + 6: (_alt("/login", "/orders"),), + 7: (_alt("/login", "/store/", "2023-domaine-roland", "/cart"),), + 8: (_alt("/store/", "2021-pas-de-cheval-cabernet-sauvignon-prelude-oakville"),), + 9: (_alt("2021-le-pich", "2021-bank-shot"),), + 10: (_alt("/login", "/club/", "/club/connoisseurs", "/account"),), + 11: (_alt("/contact-us"),), + 12: (_alt(("/search", "/store/"), "2017-maison-leroy-nuits", "2017-maison-leroy-gevrey", "2021-domaine-du-clos-de-tart"),), + 13: (_alt("/register", "/store/", ("/wine/", "/catalog/"), "/cart", "/checkout"),), + 14: (_alt("/login", "/cart"),), + 15: (_alt("/store/regions/italy", "2024-etna-bianco"),), + 16: (_alt("/login", "/cart", "/checkout", "/orders/"),), + 17: (_alt("/where-we-ship"),), +} + + +PASS_ANSWERS = { + 0: "Added a qualifying Napa Valley Cabernet Sauvignon under $50 to Alice's cart.", + 1: "Saved a qualifying Sonoma Coast Pinot Noir scoring at least 94 points.", + 2: "Fantesca Estate Chardonnay has the later drinking window, 2026-2037.", + 3: "2015 Chateau Haut-Brion Pessac-Leognan; Pessac-Leognan; $950.", + 4: "Added M. Brugnon Selection Brut Champagne; a suggested pairing is fried chicken.", + 5: "Updated Alice's favorite variety to Pinot Noir.", + 6: "The tracking number is 9400111899.", + 7: "Added 2 bottles of the 2023 Domaine Roland Lavantureux Chablis.", + 8: "The drinking window is 2026-2031.", + 9: "Bank Shot is lower at $39.60 per bottle; Le Pich is $57.20.", + 10: "Joined the Connoisseurs Club and confirmed it on Carol's account.", + 11: "WineAccess, Inc., 122 Camino Oruga, Building A, Napa, CA 94558; (866) 946-3923.", + 12: "Maison Leroy Gevrey-Chambertin has the later window, 2026-2035.", + 13: "Created an account, added a qualifying red blend, and reached checkout without ordering.", + 14: "Removed one cart item and confirmed the total changed.", + 15: "2024 Etna Bianco Carricante; pair with roast chicken, shellfish, or spring vegetables.", + 16: "Checkout completed; the new order number is reported from the order page.", + 17: "During heat or cold events, members can delay shipping to protect bottle condition.", +} + + +def _norm(value): + return re.sub(r"\s+", " ", str(value or "")).strip().casefold() + + +def _all(value, *tokens): + folded = _norm(value) + return all(_norm(token) in folded for token in tokens) + + +def _any(value, *tokens): + folded = _norm(value) + return any(_norm(token) in folded for token in tokens) + + +def _numbers(value): + return [float(item) for item in re.findall(r"(? old, "qualifying_cart_add" + if index == 1: + old = set(_query(initial_db, "SELECT wine_id FROM wishlist_items WHERE user_id=?", (before_alice,))) + new = set(_query(after_db, "SELECT wi.wine_id FROM wishlist_items wi JOIN wines w ON w.id=wi.wine_id WHERE wi.user_id=? AND w.variety='Pinot Noir' AND w.region='Sonoma Coast' AND w.score>=94", (after_alice,))) + return bool(new-old), "qualifying_saved_add" + if index == 4: + wine = _scalar(after_db, "SELECT id FROM wines WHERE slug='m-brugnon-selection-brut-champagne'") + old = _scalar(initial_db, "SELECT COALESCE(SUM(quantity),0) FROM cart_items WHERE user_id=? AND wine_id=?", (before_alice, wine)) or 0 + new = _scalar(after_db, "SELECT COALESCE(SUM(quantity),0) FROM cart_items WHERE user_id=? AND wine_id=?", (after_alice, wine)) or 0 + return new > old, "less_expensive_champagne_added" + if index == 5: + old = _scalar(initial_db, "SELECT favorite_variety FROM users WHERE id=?", (before_alice,)) + new = _scalar(after_db, "SELECT favorite_variety FROM users WHERE id=?", (after_alice,)) + return new == "Pinot Noir" and old != new, "favorite_variety_updated" + if index == 7: + wine = _scalar(after_db, "SELECT id FROM wines WHERE slug='2023-domaine-roland-lavantureux-vieilles-vignes-chablis'") + old = _scalar(initial_db, "SELECT COALESCE(SUM(quantity),0) FROM cart_items WHERE user_id=? AND wine_id=?", (before_alice, wine)) or 0 + new = _scalar(after_db, "SELECT COALESCE(SUM(quantity),0) FROM cart_items WHERE user_id=? AND wine_id=?", (after_alice, wine)) or 0 + return new-old >= 2, "two_french_white_bottles_added" + if index == 10: + before_carol = _user_id(initial_db, "carol.d@test.com") + after_carol = _user_id(after_db, "carol.d@test.com") + club = _scalar(after_db, "SELECT id FROM clubs WHERE slug='connoisseurs'") + old = _scalar(initial_db, "SELECT COUNT(*) FROM club_memberships WHERE user_id=? AND club_id=?", (before_carol, club)) or 0 + new = _scalar(after_db, "SELECT COUNT(*) FROM club_memberships WHERE user_id=? AND club_id=?", (after_carol, club)) or 0 + return old == 0 and new == 1, "connoisseurs_membership_added" + if index == 13: + old_ids = {row[0] for row in _query(initial_db, "SELECT id FROM users")} + new_ids = {row[0] for row in _query(after_db, "SELECT id FROM users")}-old_ids + if len(new_ids) != 1: + return False, "exactly_one_new_account" + uid = next(iter(new_ids)) + qualifying = _scalar(after_db, "SELECT COUNT(*) FROM cart_items c JOIN wines w ON w.id=c.wine_id WHERE c.user_id=? AND w.variety LIKE '%Blend%' AND w.wine_type='Red' AND w.price<30", (uid,)) or 0 + orders = _scalar(after_db, "SELECT COUNT(*) FROM orders WHERE user_id=?", (uid,)) or 0 + return qualifying >= 1 and orders == 0, "new_account_red_blend_at_checkout" + if index == 14: + before_david = _user_id(initial_db, "david.k@test.com") + after_david = _user_id(after_db, "david.k@test.com") + old = _scalar(initial_db, "SELECT COALESCE(SUM(c.quantity*w.price),0) FROM cart_items c JOIN wines w ON w.id=c.wine_id WHERE c.user_id=?", (before_david,)) or 0 + new = _scalar(after_db, "SELECT COALESCE(SUM(c.quantity*w.price),0) FROM cart_items c JOIN wines w ON w.id=c.wine_id WHERE c.user_id=?", (after_david,)) or 0 + return new < old, "cart_total_decreased" + if index == 16: + old_orders = set(_query(initial_db, "SELECT order_number FROM orders WHERE user_id=?", (before_alice,))) + new_orders = set(_query(after_db, "SELECT order_number FROM orders WHERE user_id=?", (after_alice,)))-old_orders + cart_count = _scalar(after_db, "SELECT COUNT(*) FROM cart_items WHERE user_id=?", (after_alice,)) or 0 + answer_match = re.search(r"\bWA-\d{6}-\d+\b", answer, re.I) + return len(new_orders) == 1 and cart_count == 0 and (not answer_match or (answer_match.group(0),) in new_orders), "new_order_and_empty_cart" + return False, "unknown_stateful_task" + + +def evaluate(index, traj, initial_db=None, after_db=None): + if not navigation_ok(index, traj): + return {"pass": False, "reason": "required_navigation"} + answer = str(traj.get("final_answer") or "").strip() + if not answer_ok(index, answer): + return {"pass": False, "reason": "frozen_answer"} + state_pass, state_reason = state_ok(index, initial_db, after_db, answer) + if not state_pass: + return {"pass": False, "reason": state_reason} + return {"pass": True, "reason": "verified"} + + +def _fetch_db(container, kind): + handle, path = tempfile.mkstemp(suffix=".db") + os.close(handle) + source = f"{container}:/opt/WebSyn/{SITE}/{kind}/{SITE}.db" + result = subprocess.run(["docker", "cp", source, path], capture_output=True, text=True) + if result.returncode: + os.unlink(path) + return None + return path + + +def main(index): + parser = argparse.ArgumentParser() + parser.add_argument("--run_dir", required=True) + parser.add_argument("--initial_db") + parser.add_argument("--after_db") + parser.add_argument("--container", default=os.environ.get("WEBHARBOR_CONTAINER", "webharbor")) + args = parser.parse_args() + cleanup = [] + initial = args.initial_db + after = args.after_db + if index in STATEFUL_TASKS: + if not initial: + initial = _fetch_db(args.container, "instance_seed") + if initial: cleanup.append(initial) + if not after: + after = _fetch_db(args.container, "instance") + if after: cleanup.append(after) + try: + result = evaluate(index, load_run(args.run_dir), initial, after) + print(json.dumps(result, sort_keys=True)) + return 0 if result["pass"] else 1 + finally: + for path in cleanup: + try: os.unlink(path) + except OSError: pass diff --git a/websyn_start.sh b/websyn_start.sh index 4d690d78..6c1f3874 100644 --- a/websyn_start.sh +++ b/websyn_start.sh @@ -1,11 +1,11 @@ #!/bin/bash -# WebSyn startup: launch all 16 mirror sites, then exec the original CMD. +# WebSyn startup: launch all 17 mirror sites, then exec the original CMD. # This preserves the base image's browser env server (port 8100) as PID 1. set -e SITES=(allrecipes amazon apple arxiv bbc_news booking github google_flights google_map google_search huggingface wolfram_alpha - cambridge_dictionary coursera espn merriam_webster) + cambridge_dictionary coursera espn merriam_webster wineaccess) BASE_PORT=40000 PID_DIR=/tmp/websyn_pids mkdir -p "$PID_DIR" @@ -17,7 +17,7 @@ for d in "${SITES[@]}"; do cp -a "/opt/WebSyn/$d/instance_seed" "/opt/WebSyn/$d/instance" done -echo "[WebSyn] Starting 16 sites on ports ${BASE_PORT}-$((BASE_PORT + 15))..." +echo "[WebSyn] Starting ${#SITES[@]} sites on ports ${BASE_PORT}-$((BASE_PORT + ${#SITES[@]} - 1))..." for i in "${!SITES[@]}"; do site="${SITES[$i]}" port=$((BASE_PORT + i)) @@ -51,8 +51,8 @@ except Exception: exit(1) ready=$((ready + 1)) fi done - echo " [${elapsed}/${max_wait}s] ${ready}/16 sites ready" - if [ $ready -eq 16 ]; then + echo " [${elapsed}/${max_wait}s] ${ready}/${#SITES[@]} sites ready" + if [ $ready -eq ${#SITES[@]} ]; then break fi done