From 9dbaacc861034d45a1a2a38bf4080b81e38c63cb Mon Sep 17 00:00:00 2001 From: xinyuzhang Date: Sun, 16 Aug 2026 00:20:45 +0800 Subject: [PATCH 1/2] Add WineAccess mirror --- .assets-revision | 2 +- .gitignore | 8 +- AGENTS.md | 8 +- CONTRIBUTING.md | 4 +- Dockerfile | 4 +- README.md | 8 +- control_server.py | 2 +- sites/wineaccess/_health.py | 28 + sites/wineaccess/app.py | 751 +++++++++++++++ sites/wineaccess/requirements.txt | 4 + sites/wineaccess/seed_data.py | 278 ++++++ sites/wineaccess/static/css/.gitkeep | 0 sites/wineaccess/static/css/style.css | 876 ++++++++++++++++++ sites/wineaccess/static/icons/.gitkeep | 0 sites/wineaccess/static/js/.gitkeep | 0 sites/wineaccess/tasks.jsonl | 18 + sites/wineaccess/templates/.gitkeep | 0 sites/wineaccess/templates/_offer_card.html | 32 + sites/wineaccess/templates/_wine_card.html | 21 + sites/wineaccess/templates/account.html | 39 + sites/wineaccess/templates/base.html | 71 ++ sites/wineaccess/templates/cart.html | 39 + sites/wineaccess/templates/checkout.html | 26 + sites/wineaccess/templates/club.html | 35 + sites/wineaccess/templates/club_detail.html | 61 ++ sites/wineaccess/templates/contact.html | 18 + sites/wineaccess/templates/detail.html | 74 ++ sites/wineaccess/templates/experts.html | 48 + sites/wineaccess/templates/gifting.html | 52 ++ sites/wineaccess/templates/index.html | 38 + sites/wineaccess/templates/login.html | 13 + sites/wineaccess/templates/order_detail.html | 28 + sites/wineaccess/templates/orders.html | 21 + sites/wineaccess/templates/podcast.html | 47 + sites/wineaccess/templates/register.html | 13 + sites/wineaccess/templates/saved.html | 15 + sites/wineaccess/templates/store.html | 61 ++ sites/wineaccess/templates/where_we_ship.html | 14 + websyn_start.sh | 10 +- 39 files changed, 2745 insertions(+), 22 deletions(-) create mode 100644 sites/wineaccess/_health.py create mode 100644 sites/wineaccess/app.py create mode 100644 sites/wineaccess/requirements.txt create mode 100644 sites/wineaccess/seed_data.py create mode 100644 sites/wineaccess/static/css/.gitkeep create mode 100644 sites/wineaccess/static/css/style.css create mode 100644 sites/wineaccess/static/icons/.gitkeep create mode 100644 sites/wineaccess/static/js/.gitkeep create mode 100644 sites/wineaccess/tasks.jsonl create mode 100644 sites/wineaccess/templates/.gitkeep create mode 100644 sites/wineaccess/templates/_offer_card.html create mode 100644 sites/wineaccess/templates/_wine_card.html create mode 100644 sites/wineaccess/templates/account.html create mode 100644 sites/wineaccess/templates/base.html create mode 100644 sites/wineaccess/templates/cart.html create mode 100644 sites/wineaccess/templates/checkout.html create mode 100644 sites/wineaccess/templates/club.html create mode 100644 sites/wineaccess/templates/club_detail.html create mode 100644 sites/wineaccess/templates/contact.html create mode 100644 sites/wineaccess/templates/detail.html create mode 100644 sites/wineaccess/templates/experts.html create mode 100644 sites/wineaccess/templates/gifting.html create mode 100644 sites/wineaccess/templates/index.html create mode 100644 sites/wineaccess/templates/login.html create mode 100644 sites/wineaccess/templates/order_detail.html create mode 100644 sites/wineaccess/templates/orders.html create mode 100644 sites/wineaccess/templates/podcast.html create mode 100644 sites/wineaccess/templates/register.html create mode 100644 sites/wineaccess/templates/saved.html create mode 100644 sites/wineaccess/templates/store.html create mode 100644 sites/wineaccess/templates/where_we_ship.html diff --git a/.assets-revision b/.assets-revision index 77578c00..3bd8e9f7 100644 --- a/.assets-revision +++ b/.assets-revision @@ -6,4 +6,4 @@ # sha). Override at runtime with the ASSETS_REVISION env var. repo: ChilleD/WebHarbor -revision: main +revision: refs/pr/21 diff --git a/.gitignore b/.gitignore index c2efc04c..e7899232 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,10 @@ sites/*/static/external_cache/ # ============================================================= # Intermediate / volatile — never committed anywhere. # ============================================================= -sites/*/scraped_data/ # scrape pipeline intermediate; runtime data lives in instance_seed/*.db -sites/*/instance/ # rebuilt at every container boot from instance_seed/ +# scrape pipeline intermediate; runtime data lives in instance_seed/*.db +sites/*/scraped_data/ +# rebuilt at every container boot from instance_seed/ +sites/*/instance/ sites/*/venv/ # HF download metadata produced by `hf download`. @@ -92,4 +94,4 @@ secrets.json # ============================================================ # Agent demo results # ============================================================= -agent_demo/runs/ \ No newline at end of file +agent_demo/runs/ diff --git a/AGENTS.md b/AGENTS.md index 8b24944f..7227b5c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,17 +48,17 @@ Inside the image, sites live at `/opt/WebSyn//`. The path predates the ren # fresh clone ./scripts/fetch_assets.sh # pulls assets from HF ./scripts/build.sh # docker build -t webharbor:dev . -docker run -d -p 8101:8101 -p 40000-40014:40000-40014 webharbor:dev +docker run -d -p 8101:8101 -p 40000-40015:40000-40015 webharbor:dev ``` Or use the published image directly: ```bash -docker run -d -p 8101:8101 -p 40000-40014:40000-40014 \ +docker run -d -p 8101:8101 -p 40000-40015:40000-40015 \ battalion7244/webharbor:latest ``` -Sites are on `40000`-`40014` in the order declared by `SITES=( ... )` in `websyn_start.sh`. Control plane: +Sites are on `40000`-`40015` in the order declared by `SITES=( ... )` in `websyn_start.sh`. Control plane: | Method | Path | Purpose | |--------|---------------------|-------------------------------------------| @@ -129,7 +129,7 @@ python3 -m py_compile sites//app.py # 3. run on alt ports (don't collide with anything you already have running) docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41014:40000-40014 webharbor:dev + -p 8201:8101 -p 41000-41015:40000-40015 webharbor:dev # 4. control plane healthy, all sites alive curl -s http://localhost:8201/health | python3 -m json.tool | head diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 17c6a060..cdc45125 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ git clone https://github.com//webharbor && cd webharbor ./scripts/fetch_assets.sh # pull current assets ./scripts/new_site.py mywebsite # OR edit an existing site ./scripts/build.sh && docker run -d --rm \ - -p 8101:8101 -p 40000-40014:40000-40014 webharbor:dev + -p 8101:8101 -p 40000-40015:40000-40015 webharbor:dev # iterate locally... ./scripts/extract_assets.sh ../webharbor-static-pr/ # split assets out @@ -186,4 +186,4 @@ Sites must not import from one another. The image launches each as an independen ### Don't hard-code secrets -Each site sets `SECRET_KEY` to a deterministic dev value. Acceptable for a benchmark image (resets blow away sessions anyway). If a contrib ever needs real secrets, raise it in an issue first. \ No newline at end of file +Each site sets `SECRET_KEY` to a deterministic dev value. Acceptable for a benchmark image (resets blow away sessions anyway). If a contrib ever needs real secrets, raise it in an issue first. diff --git a/Dockerfile b/Dockerfile index 991e5ab6..1e86b1d0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # WebHarbor — slim, self-contained image. -# 15 Flask mirror sites + control plane on :8101. +# 16 Flask mirror sites + control plane on :8101. FROM python:3.12-slim-bookworm @@ -33,6 +33,6 @@ COPY control_server.py /opt/control_server.py COPY site_runner.py /opt/site_runner.py RUN chmod +x /opt/websyn_start.sh -EXPOSE 8101 40000-40014 +EXPOSE 8101 40000-40015 CMD ["/opt/websyn_start.sh"] diff --git a/README.md b/README.md index dce3f934..5c3516ad 100644 --- a/README.md +++ b/README.md @@ -36,17 +36,17 @@ WebHarbor takes a different approach. We leverage coding agent (e.g., Claude Cod - **Deep features unlocked** — carts, checkouts, accounts, all fully testable - **Evolving** — harder tasks drive richer mirrors; the environment grows with agents - **RL-ready** — sub-second database resets between rollouts -- **Community-driven** — 15 sites today, scaling to 100+ together +- **Community-driven** — 16 sites today, scaling to 100+ together ## 🚀 Quickstart One command to run all web environments: ```bash -docker run -p 8101:8101 -p 40000-40014:40000-40014 battalion7244/webharbor:latest +docker run -p 8101:8101 -p 40000-40015:40000-40015 battalion7244/webharbor:latest ``` -Then point your agent at `http://localhost:40000` through `http://localhost:40014` to explore 15 local mirrors of webvoyager sites: `Allrecipes, Amazon, Apple, ArXiv, BBC News, Booking, GitHub, Google Flights, Google Maps, Google Search, Hugging Face, Wolfram Alpha, Cambridge Dictionary, Coursera, and ESPN`. +Then point your agent at `http://localhost:40000` through `http://localhost:40015` to explore 16 local mirrors of webvoyager sites: `Allrecipes, Amazon, Apple, ArXiv, BBC News, Booking, GitHub, Google Flights, Google Maps, Google Search, Hugging Face, Wolfram Alpha, Cambridge Dictionary, Coursera, ESPN, and 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 c255253c..f8be2b97 100644 --- a/control_server.py +++ b/control_server.py @@ -26,7 +26,7 @@ 'allrecipes', 'amazon', 'apple', 'arxiv', 'bbc_news', 'booking', 'github', 'google_flights', 'google_map', 'google_search', 'huggingface', 'wolfram_alpha', 'cambridge_dictionary', - 'coursera', 'espn', + 'coursera', 'espn', '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..61f38c6f --- /dev/null +++ b/sites/wineaccess/app.py @@ -0,0 +1,751 @@ +#!/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.filter_by(limited_offer=True) + .order_by(Wine.vintage.desc(), Wine.name.asc()) + .limit(12) + .all() + ) + return render_template( + "index.html", + wines=wines, + ) + + +@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": "club_wine_display.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": "wine_03_2024-paltrinieri-radice-lambrusco-di-sorbara.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": "club_unfiltered.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": "wine_09_2021-le-pich-cabernet-sauvignon-napa-valley.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..58f5700b --- /dev/null +++ b/sites/wineaccess/static/css/style.css @@ -0,0 +1,876 @@ +: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); +} +.experts-hero { + min-height: 520px; + display: grid; + align-items: center; + padding: 88px 6vw; + background: + linear-gradient(90deg, rgba(23,13,11,.82), rgba(23,13,11,.35)), + url("../images/club_wine_display.webp") center right / contain no-repeat, + #1b1412; + color: white; +} +.experts-hero .eyebrow { + color: #f0c177; +} +.expert-band { + max-width: 1080px; + margin: 0 auto; + padding: 68px 24px 30px; + text-align: center; +} +.expert-grid { + grid-template-columns: repeat(2, minmax(360px, 1fr)); +} +.expert-card { + display: grid; + grid-template-columns: 180px minmax(0, 1fr); + gap: 22px; + align-items: center; +} +.expert-card img { + width: 100%; + height: 180px; + object-fit: cover; + background: var(--cream); +} +.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 { + 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 { + background: + linear-gradient(90deg, rgba(23,13,11,.88), rgba(23,13,11,.72)), + url("../images/club_wine_display.webp") center bottom / cover no-repeat, + #1b1412; + } +} 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..349b1ac8 --- /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, find a Napa Valley Cabernet Sauvignon under $50, open its detail page, and add one bottle to the cart.", "web": "http://localhost:40015/", "upstream_url": "https://www.wineaccess.com/"} +{"web_name": "Wine Access", "id": "Wine Access--1", "ques": "Log in as alice.j@test.com, search for Sonoma Coast Pinot Noir, and save a 94-point or higher bottle to the saved cellar list.", "web": "http://localhost:40015/", "upstream_url": "https://www.wineaccess.com/"} +{"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:40015/", "upstream_url": "https://www.wineaccess.com/"} +{"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:40015/", "upstream_url": "https://www.wineaccess.com/"} +{"web_name": "Wine Access", "id": "Wine Access--4", "ques": "Log in as alice.j@test.com, 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:40015/", "upstream_url": "https://www.wineaccess.com/"} +{"web_name": "Wine Access", "id": "Wine Access--5", "ques": "Log in as alice.j@test.com, update the favorite variety on the account page to Pinot Noir, and save the change.", "web": "http://localhost:40015/", "upstream_url": "https://www.wineaccess.com/"} +{"web_name": "Wine Access", "id": "Wine Access--6", "ques": "Log in as bob.c@test.com and find the tracking number for the most recent order in order history.", "web": "http://localhost:40015/", "upstream_url": "https://www.wineaccess.com/"} +{"web_name": "Wine Access", "id": "Wine Access--7", "ques": "Log in as alice.j@test.com, find a white wine from France with a score of at least 94 and add two bottles to the cart.", "web": "http://localhost:40015/", "upstream_url": "https://www.wineaccess.com/"} +{"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:40015/", "upstream_url": "https://www.wineaccess.com/"} +{"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:40015/", "upstream_url": "https://www.wineaccess.com/"} +{"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:40015/", "upstream_url": "https://www.wineaccess.com/"} +{"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:40015/", "upstream_url": "https://www.wineaccess.com/"} +{"web_name": "Wine Access", "id": "Wine Access--12", "ques": "Search for Burgundy Pinot Noir, open two Burgundy detail pages, and identify which bottle has the later drinking window.", "web": "http://localhost:40015/", "upstream_url": "https://www.wineaccess.com/"} +{"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:40015/", "upstream_url": "https://www.wineaccess.com/"} +{"web_name": "Wine Access", "id": "Wine Access--14", "ques": "Log in as david.k@test.com, remove one item from the cart, and confirm the cart total changes.", "web": "http://localhost:40015/", "upstream_url": "https://www.wineaccess.com/"} +{"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:40015/", "upstream_url": "https://www.wineaccess.com/"} +{"web_name": "Wine Access", "id": "Wine Access--16", "ques": "Log in as alice.j@test.com, complete checkout using the prefilled shipping details and card ending 4242, then report the new order number.", "web": "http://localhost:40015/", "upstream_url": "https://www.wineaccess.com/"} +{"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:40015/", "upstream_url": "https://www.wineaccess.com/"} 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..7384c9e0 --- /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..2398e8cc --- /dev/null +++ b/sites/wineaccess/templates/experts.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block title %}Unrivaled Experts - Wine Access{% endblock %} +{% block content %} +
+
+

Unrivaled Experts

+

Meet the Palates Behind Wine Access

+

Our team tastes broadly, buys selectively, and brings sommelier-level context to bottles that overdeliver on origin, story, and value.

+
+
+ +
+

A rigorous tasting bench

+

Only a small fraction of wines make it through

+

Wine Access experts compare bottles blind, revisit promising finds, and choose wines that can support real recommendations across food, gifting, collecting, and everyday drinking.

+
+ +
+
+

Wine Team

+

People, perspective, and provenance

+
+
+ {% for expert in experts %} +
+ {{ 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..3d67835c --- /dev/null +++ b/sites/wineaccess/templates/index.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}Limited Time Offers - Wine Access{% endblock %} +{% block content %} +
+ + +
+
+
+ Limited Time Offer (Preorders) + x +
+ Clear Filters (1) + +
+ +
+ {% for wine in wines %} + {% include "_offer_card.html" %} + {% 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/websyn_start.sh b/websyn_start.sh index 72defad8..2919d4b4 100644 --- a/websyn_start.sh +++ b/websyn_start.sh @@ -1,11 +1,11 @@ #!/bin/bash -# WebSyn startup: launch all 12 mirror sites, then exec the original CMD. +# WebSyn startup: launch all mirror sites, then exec the original CMD. # This preserves the base image's browser env server (port 8100) as PID 1. set -e SITES=(allrecipes amazon apple arxiv bbc_news booking github google_flights google_map google_search huggingface wolfram_alpha - cambridge_dictionary coursera espn) + cambridge_dictionary coursera espn 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 15 sites on ports ${BASE_PORT}-$((BASE_PORT + 14))..." +echo "[WebSyn] Starting 16 sites on ports ${BASE_PORT}-$((BASE_PORT + 15))..." for i in "${!SITES[@]}"; do site="${SITES[$i]}" port=$((BASE_PORT + i)) @@ -51,8 +51,8 @@ except Exception: exit(1) ready=$((ready + 1)) fi done - echo " [${elapsed}/${max_wait}s] ${ready}/15 sites ready" - if [ $ready -eq 15 ]; then + echo " [${elapsed}/${max_wait}s] ${ready}/${#SITES[@]} sites ready" + if [ $ready -eq ${#SITES[@]} ]; then break fi done From 30dda57ce7a8a1d78bb4558eae3fcf8057a61843 Mon Sep 17 00:00:00 2001 From: xinyuzhang Date: Mon, 17 Aug 2026 22:01:17 +0800 Subject: [PATCH 2/2] fix(wineaccess): feature Laura in experts hero --- sites/wineaccess/static/css/style.css | 28 +++++++++++++++++++++---- sites/wineaccess/templates/experts.html | 22 ++++++++++++------- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/sites/wineaccess/static/css/style.css b/sites/wineaccess/static/css/style.css index dd5faacd..d041ad0e 100644 --- a/sites/wineaccess/static/css/style.css +++ b/sites/wineaccess/static/css/style.css @@ -801,12 +801,16 @@ h1, h2, h3 { font-family: Georgia, serif; } .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 { - min-height: 360px; - padding: 76px 6vw 34px; + 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: 940px; } +.experts-intro { max-width: 900px; } .experts-hero .eyebrow { color: var(--wine); } @@ -819,6 +823,22 @@ h1, h2, h3 { font-family: Georgia, serif; } 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); @@ -889,7 +909,7 @@ h1, h2, h3 { font-family: Georgia, serif; } .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 { + .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 { diff --git a/sites/wineaccess/templates/experts.html b/sites/wineaccess/templates/experts.html index 90ebba44..f1fbc36e 100644 --- a/sites/wineaccess/templates/experts.html +++ b/sites/wineaccess/templates/experts.html @@ -6,16 +6,24 @@

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 %} -
+{% for expert in experts[1:] %} +
Portrait of {{ expert.name }}

{{ expert.role }}