Compare commits
48 Commits
main
...
74d6071ad4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74d6071ad4 | ||
|
|
1aa3659bb8 | ||
|
|
aab9cf3f6b | ||
|
|
6984f3f3db | ||
|
|
a50c5e4d46 | ||
|
|
ca5b952ffc | ||
|
|
d7162f5543 | ||
|
|
0a3997b82a | ||
|
|
50cad49576 | ||
|
|
fe255fc53c | ||
|
|
ad445e2fd2 | ||
|
|
b04dbbba67 | ||
|
|
3fea2e6fdb | ||
|
|
acf352ee3b | ||
|
|
33befd4c3d | ||
|
|
8dfb95ccab | ||
|
|
7ccdc1fa83 | ||
|
|
31f9aa3fac | ||
|
|
ec1880b658 | ||
|
|
72042e793b | ||
|
|
b3125c5db4 | ||
|
|
7edc0a53a1 | ||
|
|
867bfa234f | ||
|
|
bc1a7783df | ||
|
|
0015839845 | ||
|
|
63778d9f20 | ||
|
|
28938e38b5 | ||
|
|
faa72eec5d | ||
|
|
8c2358022a | ||
|
|
44f475857b | ||
|
|
039386b6e7 | ||
|
|
dc94cfa29e | ||
|
|
a29612ffa4 | ||
|
|
99dd473afb | ||
|
|
7820257577 | ||
|
|
f81d6803d4 | ||
|
|
ba10f5547a | ||
|
|
7025333951 | ||
|
|
737c82ae7f | ||
|
|
cccdba65a8 | ||
|
|
7b1f8a0f5e | ||
|
|
000185c2cc | ||
|
|
4f90f65a8f | ||
|
|
3b24cf45e5 | ||
|
|
a6ed49d0c1 | ||
|
|
62000256aa | ||
|
|
079293eb2c | ||
|
|
478636f799 |
@@ -2,7 +2,7 @@ name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, decoupling]
|
||||
|
||||
env:
|
||||
REGISTRY: registry.rose-ash.com:5000
|
||||
@@ -36,9 +36,23 @@ jobs:
|
||||
run: |
|
||||
ssh "root@$DEPLOY_HOST" "
|
||||
cd ${{ env.REPO_DIR }}
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
git fetch origin ${{ github.ref_name }}
|
||||
git reset --hard origin/${{ github.ref_name }}
|
||||
git submodule update --init --recursive
|
||||
# Clean ALL sibling dirs (including stale self-copies from previous runs)
|
||||
for sibling in blog market cart events; do
|
||||
rm -rf \$sibling
|
||||
done
|
||||
# Copy non-self sibling models for cross-domain imports
|
||||
for sibling in blog market cart events; do
|
||||
[ \"\$sibling\" = \"${{ env.IMAGE }}\" ] && continue
|
||||
repo=/root/rose-ash/\$sibling
|
||||
if [ -d \$repo/.git ]; then
|
||||
git -C \$repo fetch origin ${{ github.ref_name }} 2>/dev/null || true
|
||||
mkdir -p \$sibling
|
||||
git -C \$repo archive origin/${{ github.ref_name }} -- __init__.py models/ 2>/dev/null | tar -x -C \$sibling/ || true
|
||||
fi
|
||||
done
|
||||
"
|
||||
|
||||
- name: Build and push image
|
||||
|
||||
5
.gitmodules
vendored
5
.gitmodules
vendored
@@ -1,3 +1,4 @@
|
||||
[submodule "shared_lib"]
|
||||
path = shared_lib
|
||||
[submodule "shared"]
|
||||
path = shared
|
||||
url = https://git.rose-ash.com/coop/shared.git
|
||||
branch = decoupling
|
||||
|
||||
@@ -5,6 +5,7 @@ FROM python:3.11-slim AS base
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/app \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
APP_PORT=8000 \
|
||||
APP_MODULE=app:app
|
||||
@@ -17,14 +18,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
postgresql-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY shared_lib/requirements.txt ./requirements.txt
|
||||
COPY shared/requirements.txt ./requirements.txt
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# Link app blueprints into the shared library's namespace
|
||||
RUN rm -rf /app/shared_lib/suma_browser/app/bp && ln -s /app/bp /app/shared_lib/suma_browser/app/bp
|
||||
|
||||
# ---------- Runtime setup ----------
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
113
README.md
113
README.md
@@ -1,67 +1,84 @@
|
||||
# Market App
|
||||
|
||||
Product browsing and marketplace application for the Rose Ash cooperative.
|
||||
|
||||
## Overview
|
||||
|
||||
The Market app is one of three microservices split from the original coop monolith:
|
||||
|
||||
- **coop** (:8000) - Blog, calendar, auth, settings
|
||||
- **market** (:8001) - Product browsing, categories, product detail
|
||||
- **cart** (:8002) - Shopping cart, orders, payments
|
||||
Product browsing and marketplace service for the Rose Ash cooperative. Displays products scraped from Suma Wholesale.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Framework:** Quart (async Flask)
|
||||
- **Database:** PostgreSQL 16 with SQLAlchemy 2.0 (async)
|
||||
- **Cache:** Redis (tag-based page cache)
|
||||
- **Frontend:** HTMX + Jinja2 + Tailwind CSS
|
||||
- **Data:** Products scraped from Suma Wholesale
|
||||
One of four Quart microservices sharing a single PostgreSQL database:
|
||||
|
||||
## Blueprints
|
||||
| App | Port | Domain |
|
||||
|-----|------|--------|
|
||||
| blog (coop) | 8000 | Auth, blog, admin, menus, snippets |
|
||||
| **market** | 8001 | Product browsing, Suma scraping |
|
||||
| cart | 8002 | Shopping cart, checkout, orders |
|
||||
| events | 8003 | Calendars, bookings, tickets |
|
||||
|
||||
- `bp/market/` - Market root (navigation, category listing)
|
||||
- `bp/browse/` - Product browsing with filters and infinite scroll
|
||||
- `bp/product/` - Product detail pages
|
||||
- `bp/api/` - Product sync API (used by scraper)
|
||||
## Structure
|
||||
|
||||
## Development
|
||||
```
|
||||
app.py # Application factory (create_base_app + blueprints)
|
||||
path_setup.py # Adds project root + app dir to sys.path
|
||||
config/app-config.yaml # App URLs, feature flags
|
||||
models/ # Market-domain models
|
||||
market.py # Product, Category, CartItem
|
||||
market_place.py # MarketPlace (page-scoped marketplace)
|
||||
bp/ # Blueprints
|
||||
market/ # Market root, navigation, category listing
|
||||
browse/ # Product browsing with filters and infinite scroll
|
||||
product/ # Product detail pages
|
||||
cart/ # Page-scoped cart views
|
||||
api/ # Product sync API (used by scraper)
|
||||
scrape/ # Suma Wholesale scraper
|
||||
get_auth.py # Authentication
|
||||
listings.py # Product listing pages
|
||||
nav.py # Category navigation
|
||||
product/ # Individual product scraping
|
||||
build_snapshot/ # Build product snapshots
|
||||
persist_snapshot/ # Save snapshots to DB
|
||||
persist_api/ # Save via API
|
||||
templates/ # Jinja2 templates
|
||||
entrypoint.sh # Docker entrypoint
|
||||
Dockerfile
|
||||
shared/ # Submodule → git.rose-ash.com/coop/shared.git
|
||||
glue/ # Submodule → git.rose-ash.com/coop/glue.git
|
||||
```
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
## Dependencies
|
||||
|
||||
# Set environment variables
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
**Cross-app model imports:**
|
||||
- `blog.models.ghost_content.Post` — `app.py` hydrates page data for marketplace views
|
||||
|
||||
# Run migrations
|
||||
alembic upgrade head
|
||||
**Glue services:**
|
||||
- `glue.services.navigation.get_navigation_tree` — context processor builds site nav
|
||||
|
||||
# Scrape products
|
||||
bash scrape.sh
|
||||
|
||||
# Run the dev server
|
||||
hypercorn app:app --reload --bind 0.0.0.0:8001
|
||||
**Internal APIs:**
|
||||
- Calls `GET /internal/cart/summary` — context processor for cart widget
|
||||
|
||||
## Scraping
|
||||
|
||||
# Full scrape (max 50 pages, 200k products, 8 concurrent)
|
||||
bash scrape.sh
|
||||
```bash
|
||||
# Full scrape (Suma Wholesale catalogue)
|
||||
bash scrape.sh
|
||||
|
||||
# Test scraping
|
||||
bash scrape-test.sh
|
||||
# Test scraping (limited)
|
||||
bash scrape-test.sh
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
export DATABASE_URL_ASYNC=postgresql+asyncpg://user:pass@localhost/coop
|
||||
export REDIS_URL=redis://localhost:6379/0
|
||||
export SECRET_KEY=your-secret-key
|
||||
export SUMA_USER=your-suma-username
|
||||
export SUMA_PASSWORD=your-suma-password
|
||||
|
||||
hypercorn app:app --reload --bind 0.0.0.0:8001
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
docker build -t market .
|
||||
docker run -p 8001:8000 --env-file .env market
|
||||
|
||||
## Environment Variables
|
||||
|
||||
DATABASE_URL_ASYNC=postgresql+asyncpg://user:pass@localhost/coop
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
SECRET_KEY=your-secret-key
|
||||
SUMA_USER=your-suma-username
|
||||
SUMA_PASSWORD=your-suma-password
|
||||
APP_URL_COOP=http://localhost:8000
|
||||
APP_URL_MARKET=http://localhost:8001
|
||||
APP_URL_CART=http://localhost:8002
|
||||
```bash
|
||||
docker build -t market .
|
||||
docker run -p 8001:8000 --env-file .env market
|
||||
```
|
||||
|
||||
0
__init__.py
Normal file
0
__init__.py
Normal file
100
app.py
100
app.py
@@ -7,46 +7,68 @@ from quart import g, abort, render_template, make_response
|
||||
from jinja2 import FileSystemLoader, ChoiceLoader
|
||||
from sqlalchemy import select
|
||||
|
||||
from shared.factory import create_base_app
|
||||
from shared.cart_loader import load_cart
|
||||
from config import config
|
||||
from shared.infrastructure.factory import create_base_app
|
||||
from shared.config import config
|
||||
|
||||
from suma_browser.app.bp import register_market_bp
|
||||
from bp import register_market_bp
|
||||
|
||||
|
||||
async def market_context() -> dict:
|
||||
"""
|
||||
Market app context processor.
|
||||
|
||||
- menu_items: fetched from coop internal API
|
||||
- cart_count/cart_total: fetched from cart internal API
|
||||
- menu_items: direct DB query
|
||||
- cart_count/cart_total: via cart service (includes calendar entries)
|
||||
- cart: direct ORM query (templates need .product relationship)
|
||||
"""
|
||||
from shared.context import base_context
|
||||
from shared.internal_api import get as api_get, dictobj
|
||||
from shared.infrastructure.context import base_context
|
||||
from shared.services.navigation import get_navigation_tree
|
||||
from shared.services.registry import services
|
||||
from shared.infrastructure.cart_identity import current_cart_identity
|
||||
from shared.models.market import CartItem
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
ctx = await base_context()
|
||||
|
||||
# Menu items from coop API (wrapped for attribute access in templates)
|
||||
menu_data = await api_get("coop", "/internal/menu-items")
|
||||
ctx["menu_items"] = dictobj(menu_data) if menu_data else []
|
||||
ctx["menu_items"] = await get_navigation_tree(g.s)
|
||||
|
||||
# Cart data from cart API
|
||||
cart_data = await api_get("cart", "/internal/cart/summary", forward_session=True)
|
||||
if cart_data:
|
||||
ctx["cart_count"] = cart_data.get("count", 0)
|
||||
ctx["cart_total"] = cart_data.get("total", 0)
|
||||
ident = current_cart_identity()
|
||||
|
||||
# cart_count/cart_total via service (consistent with blog/events apps)
|
||||
summary = await services.cart.cart_summary(
|
||||
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
|
||||
)
|
||||
ctx["cart_count"] = summary.count + summary.calendar_count
|
||||
ctx["cart_total"] = float(summary.total + summary.calendar_total)
|
||||
|
||||
# ORM cart items for product templates (need .product relationship)
|
||||
filters = [CartItem.deleted_at.is_(None)]
|
||||
if ident["user_id"] is not None:
|
||||
filters.append(CartItem.user_id == ident["user_id"])
|
||||
elif ident["session_id"] is not None:
|
||||
filters.append(CartItem.session_id == ident["session_id"])
|
||||
else:
|
||||
ctx["cart_count"] = 0
|
||||
ctx["cart_total"] = 0
|
||||
ctx["cart"] = []
|
||||
return ctx
|
||||
|
||||
result = await g.s.execute(
|
||||
select(CartItem).where(*filters).options(selectinload(CartItem.product))
|
||||
)
|
||||
ctx["cart"] = list(result.scalars().all())
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
def create_app() -> "Quart":
|
||||
from models.market_place import MarketPlace
|
||||
from models.ghost_content import Post
|
||||
from shared.services.registry import services
|
||||
from services import register_domain_services
|
||||
|
||||
app = create_base_app("market", context_fn=market_context, before_request_fns=[load_cart])
|
||||
app = create_base_app(
|
||||
"market",
|
||||
context_fn=market_context,
|
||||
domain_services_fn=register_domain_services,
|
||||
)
|
||||
|
||||
# App-specific templates override shared templates
|
||||
app_templates = str(Path(__file__).resolve().parent / "templates")
|
||||
@@ -89,12 +111,8 @@ def create_app() -> "Quart":
|
||||
if not post_slug or not market_slug:
|
||||
return
|
||||
|
||||
# Load post by slug
|
||||
post = (
|
||||
await g.s.execute(
|
||||
select(Post).where(Post.slug == post_slug)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
# Load post by slug via blog service
|
||||
post = await services.blog.get_post_by_slug(g.s, post_slug)
|
||||
if not post:
|
||||
abort(404)
|
||||
|
||||
@@ -111,12 +129,13 @@ def create_app() -> "Quart":
|
||||
},
|
||||
}
|
||||
|
||||
# Load market scoped to post
|
||||
# Load market scoped to post (container pattern)
|
||||
market = (
|
||||
await g.s.execute(
|
||||
select(MarketPlace).where(
|
||||
MarketPlace.slug == market_slug,
|
||||
MarketPlace.post_id == post.id,
|
||||
MarketPlace.container_type == "page",
|
||||
MarketPlace.container_id == post.id,
|
||||
MarketPlace.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
@@ -135,16 +154,23 @@ def create_app() -> "Quart":
|
||||
# --- Root route: market listing ---
|
||||
@app.get("/")
|
||||
async def markets_listing():
|
||||
from sqlalchemy.orm import selectinload
|
||||
result = await g.s.execute(
|
||||
select(MarketPlace)
|
||||
.where(MarketPlace.deleted_at.is_(None), MarketPlace.container_type == "page")
|
||||
.order_by(MarketPlace.name)
|
||||
)
|
||||
all_markets = result.scalars().all()
|
||||
|
||||
markets = (
|
||||
await g.s.execute(
|
||||
select(MarketPlace)
|
||||
.where(MarketPlace.deleted_at.is_(None))
|
||||
.options(selectinload(MarketPlace.post))
|
||||
.order_by(MarketPlace.name)
|
||||
)
|
||||
).scalars().all()
|
||||
# Resolve page posts via blog service
|
||||
post_ids = list({m.container_id for m in all_markets})
|
||||
posts_by_id = {
|
||||
p.id: p
|
||||
for p in await services.blog.get_posts_by_ids(g.s, post_ids)
|
||||
}
|
||||
markets = []
|
||||
for market in all_markets:
|
||||
market.page = posts_by_id.get(market.container_id)
|
||||
markets.append(market)
|
||||
|
||||
html = await render_template(
|
||||
"_types/market/markets_listing.html",
|
||||
|
||||
@@ -27,8 +27,8 @@ from models.market import (
|
||||
ProductAllergen,
|
||||
)
|
||||
|
||||
from suma_browser.app.redis_cacher import clear_cache
|
||||
from suma_browser.app.csrf import csrf_exempt
|
||||
from shared.browser.app.redis_cacher import clear_cache
|
||||
from shared.browser.app.csrf import csrf_exempt
|
||||
|
||||
|
||||
products_api = Blueprint("products_api", __name__, url_prefix="/api/products")
|
||||
|
||||
@@ -10,7 +10,7 @@ from quart import (
|
||||
make_response,
|
||||
current_app,
|
||||
)
|
||||
from config import config
|
||||
from shared.config import config
|
||||
from .services.nav import category_context, get_nav
|
||||
from .services.blacklist.category import is_category_blocked
|
||||
|
||||
@@ -21,8 +21,8 @@ from .services import (
|
||||
_current_url_without_page,
|
||||
)
|
||||
|
||||
from suma_browser.app.redis_cacher import cache_page
|
||||
from suma_browser.app.utils.htmx import is_htmx_request
|
||||
from shared.browser.app.redis_cacher import cache_page
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
def register():
|
||||
browse_bp = Blueprint("browse", __name__)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# suma_browser/category_blacklist.py
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
from config import config
|
||||
from shared.config import config
|
||||
|
||||
def _norm(s: str) -> str:
|
||||
return (s or "").strip().lower().strip("/")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Set, Optional
|
||||
from ..slugs import canonical_html_slug
|
||||
from config import config
|
||||
from shared.config import config
|
||||
|
||||
_blocked: Set[str] = set()
|
||||
_mtime: Optional[float] = None
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import re
|
||||
from config import config
|
||||
from shared.config import config
|
||||
|
||||
def _norm_title_key(t: str) -> str:
|
||||
t = (t or "").strip().lower()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
import os, json
|
||||
from typing import List, Optional
|
||||
from config import config
|
||||
from shared.config import config
|
||||
from .blacklist.product import is_product_blocked
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Dict, List, Optional
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from config import config # if unused elsewhere, you can remove this import
|
||||
from shared.config import config # if unused elsewhere, you can remove this import
|
||||
|
||||
# ORM models
|
||||
from models.market import (
|
||||
|
||||
@@ -5,7 +5,7 @@ import re
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
from urllib.parse import urlparse, urljoin
|
||||
|
||||
from config import config
|
||||
from shared.config import config
|
||||
from . import db_backend as cb
|
||||
from .blacklist.category import is_category_blocked # Reverse map: slug -> label
|
||||
|
||||
@@ -142,7 +142,7 @@ def category_context(top_slug: Optional[str], sub_slug: Optional[str], nav: Dict
|
||||
# list of subcategories, each with its own count
|
||||
"subs_local": _order_subs_selected_first(subs, sub_slug),
|
||||
|
||||
#"current_local_href": current_local_href,
|
||||
"current_local_href": current_local_href,
|
||||
}
|
||||
|
||||
def _apply_category_blacklist(nav: Dict[str, Dict]) -> Dict[str, Dict]:
|
||||
|
||||
@@ -6,11 +6,11 @@ from quart import (
|
||||
g,
|
||||
request,
|
||||
)
|
||||
from config import config
|
||||
from shared.config import config
|
||||
from .products import products, products_nocounts
|
||||
from .blacklist.product_details import is_blacklisted_heading
|
||||
|
||||
from utils import host_url
|
||||
from shared.utils import host_url
|
||||
|
||||
|
||||
from sqlalchemy import select
|
||||
@@ -163,7 +163,7 @@ def _massage_product(d):
|
||||
|
||||
|
||||
# Re-export from canonical shared location
|
||||
from shared.http_utils import vary as _vary, current_url_without_page as _current_url_without_page
|
||||
from shared.infrastructure.http_utils import vary as _vary, current_url_without_page as _current_url_without_page
|
||||
|
||||
async def _is_liked(user_id: int | None, slug: str) -> bool:
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import re
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from config import config
|
||||
from shared.config import config
|
||||
|
||||
def product_slug_from_href(href: str) -> str:
|
||||
p = urlparse(href)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Re-export from canonical shared location
|
||||
from shared.cart_identity import CartIdentity, current_cart_identity
|
||||
from shared.infrastructure.cart_identity import CartIdentity, current_cart_identity
|
||||
|
||||
__all__ = ["CartIdentity", "current_cart_identity"]
|
||||
|
||||
@@ -5,7 +5,7 @@ from quart import (
|
||||
)
|
||||
|
||||
|
||||
from suma_browser.app.authz import require_admin
|
||||
from shared.browser.app.authz import require_admin
|
||||
|
||||
|
||||
def register():
|
||||
@@ -15,7 +15,7 @@ def register():
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
async def admin():
|
||||
from suma_browser.app.utils.htmx import is_htmx_request
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
# Determine which template to use based on request type
|
||||
if not is_htmx_request():
|
||||
|
||||
@@ -2,10 +2,10 @@ from quart import request
|
||||
|
||||
from typing import Iterable, Optional, Union
|
||||
|
||||
from suma_browser.app.filters.qs_base import (
|
||||
from shared.browser.app.filters.qs_base import (
|
||||
KEEP, _norm, make_filter_set, build_qs,
|
||||
)
|
||||
from suma_browser.app.filters.query_types import MarketQuery
|
||||
from shared.browser.app.filters.query_types import MarketQuery
|
||||
|
||||
|
||||
def decode() -> MarketQuery:
|
||||
|
||||
@@ -15,8 +15,8 @@ from ..browse.services.slugs import canonical_html_slug
|
||||
from ..browse.services.blacklist.product import is_product_blocked
|
||||
from ..browse.services import db_backend as cb
|
||||
from ..browse.services import _massage_product
|
||||
from utils import host_url
|
||||
from suma_browser.app.redis_cacher import cache_page, clear_cache
|
||||
from shared.utils import host_url
|
||||
from shared.browser.app.redis_cacher import cache_page, clear_cache
|
||||
from ..cart.services import total
|
||||
from .services.product_operations import toggle_product_like, massage_full_product
|
||||
|
||||
@@ -94,7 +94,7 @@ def register():
|
||||
@bp.get("/")
|
||||
@cache_page(tag="browse")
|
||||
async def product_detail(slug: str):
|
||||
from suma_browser.app.utils.htmx import is_htmx_request
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
# Determine which template to use based on request type
|
||||
if not is_htmx_request():
|
||||
@@ -136,11 +136,11 @@ def register():
|
||||
)
|
||||
return html
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@bp.get("/admin/")
|
||||
async def admin(slug: str):
|
||||
from suma_browser.app.utils.htmx import is_htmx_request
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
if not is_htmx_request():
|
||||
# Normal browser request: full page with layout
|
||||
@@ -152,8 +152,8 @@ def register():
|
||||
return await make_response(html)
|
||||
|
||||
|
||||
from suma_browser.app.bp.cart.services.identity import current_cart_identity
|
||||
#from suma_browser.app.bp.cart.routes import view_cart
|
||||
from bp.cart.services.identity import current_cart_identity
|
||||
#from bp.cart.routes import view_cart
|
||||
from models.market import CartItem
|
||||
from quart import request, url_for
|
||||
|
||||
@@ -192,11 +192,23 @@ def register():
|
||||
|
||||
ident = current_cart_identity()
|
||||
|
||||
filters = [CartItem.deleted_at.is_(None), CartItem.product_id == product_id]
|
||||
# Load cart items for current user/session
|
||||
from sqlalchemy.orm import selectinload
|
||||
cart_filters = [CartItem.deleted_at.is_(None)]
|
||||
if ident["user_id"] is not None:
|
||||
filters.append(CartItem.user_id == ident["user_id"])
|
||||
cart_filters.append(CartItem.user_id == ident["user_id"])
|
||||
else:
|
||||
filters.append(CartItem.session_id == ident["session_id"])
|
||||
cart_filters.append(CartItem.session_id == ident["session_id"])
|
||||
cart_result = await g.s.execute(
|
||||
select(CartItem)
|
||||
.where(*cart_filters)
|
||||
.order_by(CartItem.created_at.desc())
|
||||
.options(
|
||||
selectinload(CartItem.product),
|
||||
selectinload(CartItem.market_place),
|
||||
)
|
||||
)
|
||||
g.cart = list(cart_result.scalars().all())
|
||||
|
||||
ci = next(
|
||||
(item for item in g.cart if item.product_id == product_id),
|
||||
@@ -238,11 +250,15 @@ def register():
|
||||
"_types/product/_added.html",
|
||||
cart=g.cart,
|
||||
item=ci,
|
||||
total = total
|
||||
total=total,
|
||||
cart_count=sum(i.quantity for i in g.cart),
|
||||
cart_total=total(g.cart),
|
||||
calendar_total=lambda entries: 0,
|
||||
calendar_cart_entries=[],
|
||||
)
|
||||
|
||||
# normal POST: go to cart page
|
||||
from shared.urls import cart_url
|
||||
from shared.infrastructure.urls import cart_url
|
||||
return redirect(cart_url("/"))
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ def massage_full_product(product: Product) -> dict:
|
||||
Convert a Product ORM model to a dictionary with all fields.
|
||||
Used for rendering product detail pages.
|
||||
"""
|
||||
from suma_browser.app.bp.browse.services import _massage_product
|
||||
from bp.browse.services import _massage_product
|
||||
|
||||
gallery = []
|
||||
if product.image:
|
||||
|
||||
83
config/app-config.yaml
Normal file
83
config/app-config.yaml
Normal file
@@ -0,0 +1,83 @@
|
||||
# App-wide settings
|
||||
base_host: "wholesale.suma.coop"
|
||||
base_login: https://wholesale.suma.coop/customer/account/login/
|
||||
base_url: https://wholesale.suma.coop/
|
||||
title: Rose Ash
|
||||
coop_root: /market
|
||||
coop_title: Market
|
||||
blog_root: /
|
||||
blog_title: all the news
|
||||
cart_root: /cart
|
||||
app_urls:
|
||||
coop: "http://localhost:8000"
|
||||
market: "http://localhost:8001"
|
||||
cart: "http://localhost:8002"
|
||||
events: "http://localhost:8003"
|
||||
cache:
|
||||
fs_root: _snapshot # <- absolute path to your snapshot dir
|
||||
categories:
|
||||
allow:
|
||||
Basics: basics
|
||||
Branded Goods: branded-goods
|
||||
Chilled: chilled
|
||||
Frozen: frozen
|
||||
Non-foods: non-foods
|
||||
Supplements: supplements
|
||||
Christmas: christmas
|
||||
slugs:
|
||||
skip:
|
||||
- ""
|
||||
- customer
|
||||
- account
|
||||
- checkout
|
||||
- wishlist
|
||||
- sales
|
||||
- contact
|
||||
- privacy-policy
|
||||
- terms-and-conditions
|
||||
- delivery
|
||||
- catalogsearch
|
||||
- quickorder
|
||||
- apply
|
||||
- search
|
||||
- static
|
||||
- media
|
||||
section-titles:
|
||||
- ingredients
|
||||
- allergy information
|
||||
- allergens
|
||||
- nutritional information
|
||||
- nutrition
|
||||
- storage
|
||||
- directions
|
||||
- preparation
|
||||
- serving suggestions
|
||||
- origin
|
||||
- country of origin
|
||||
- recycling
|
||||
- general information
|
||||
- additional information
|
||||
- a note about prices
|
||||
|
||||
blacklist:
|
||||
category:
|
||||
- branded-goods/alcoholic-drinks
|
||||
- branded-goods/beers
|
||||
- branded-goods/wines
|
||||
- branded-goods/ciders
|
||||
product:
|
||||
- list-price-suma-current-suma-price-list-each-bk012-2-html
|
||||
- ---just-lem-just-wholefoods-jelly-crystals-lemon-12-x-85g-vf067-2-html
|
||||
product-details:
|
||||
- General Information
|
||||
- A Note About Prices
|
||||
|
||||
# SumUp payment settings (fill these in for live usage)
|
||||
sumup:
|
||||
merchant_code: "ME4J6100"
|
||||
currency: "GBP"
|
||||
# Name of the environment variable that holds your SumUp API key
|
||||
api_key_env: "SUMUP_API_KEY"
|
||||
webhook_secret: "CHANGE_ME_TO_A_LONG_RANDOM_STRING"
|
||||
checkout_reference_prefix: 'dev-'
|
||||
|
||||
8
models/__init__.py
Normal file
8
models/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from .market import (
|
||||
Product, ProductLike, ProductImage, ProductSection,
|
||||
NavTop, NavSub, Listing, ListingItem,
|
||||
LinkError, LinkExternal, SubcategoryRedirect, ProductLog,
|
||||
ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen,
|
||||
CartItem,
|
||||
)
|
||||
from .market_place import MarketPlace
|
||||
7
models/market.py
Normal file
7
models/market.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from shared.models.market import ( # noqa: F401
|
||||
Product, ProductLike, ProductImage, ProductSection,
|
||||
NavTop, NavSub, Listing, ListingItem,
|
||||
LinkError, LinkExternal, SubcategoryRedirect, ProductLog,
|
||||
ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen,
|
||||
CartItem,
|
||||
)
|
||||
1
models/market_place.py
Normal file
1
models/market_place.py
Normal file
@@ -0,0 +1 @@
|
||||
from shared.models.market_place import MarketPlace # noqa: F401
|
||||
@@ -1,7 +1,9 @@
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the shared library submodule to the Python path
|
||||
_shared = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shared_lib")
|
||||
if _shared not in sys.path:
|
||||
sys.path.insert(0, _shared)
|
||||
_app_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
_project_root = os.path.dirname(_app_dir)
|
||||
|
||||
for _p in (_project_root, _app_dir):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
@@ -7,9 +7,9 @@ from typing import Dict, Set
|
||||
from ..http_client import configure_cookies
|
||||
from ..get_auth import login
|
||||
|
||||
from config import config
|
||||
from shared.config import config
|
||||
|
||||
from utils import log
|
||||
from shared.utils import log
|
||||
|
||||
# DB: persistence helpers
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from bs4 import BeautifulSoup
|
||||
from urllib.parse import urlparse, urljoin
|
||||
|
||||
from ._anchor_text import _anchor_text
|
||||
from suma_browser.app.bp.browse.services.slugs import product_slug_from_href
|
||||
from bp.browse.services.slugs import product_slug_from_href
|
||||
from .APP_ROOT_PLACEHOLDER import APP_ROOT_PLACEHOLDER
|
||||
|
||||
def _rewrite_links_fragment(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from urllib.parse import urljoin
|
||||
from config import config
|
||||
from utils import log
|
||||
from shared.config import config
|
||||
from shared.utils import log
|
||||
from ...listings import scrape_products
|
||||
|
||||
async def capture_category(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from typing import Dict, Set
|
||||
from .capture_category import capture_category
|
||||
from .capture_sub import capture_sub
|
||||
from config import config
|
||||
from shared.config import config
|
||||
|
||||
|
||||
async def capture_product_slugs(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from urllib.parse import urljoin
|
||||
from urllib.parse import urljoin
|
||||
from config import config
|
||||
from utils import log
|
||||
from shared.config import config
|
||||
from shared.utils import log
|
||||
from ...listings import scrape_products
|
||||
|
||||
async def capture_sub(
|
||||
|
||||
@@ -6,12 +6,12 @@ import httpx
|
||||
|
||||
|
||||
from ...html_utils import to_fragment
|
||||
from suma_browser.app.bp.browse.services.slugs import suma_href_from_html_slug
|
||||
|
||||
from bp.browse.services.slugs import suma_href_from_html_slug
|
||||
|
||||
from config import config
|
||||
|
||||
from utils import log
|
||||
from shared.config import config
|
||||
|
||||
from shared.utils import log
|
||||
|
||||
# DB: persistence helpers
|
||||
from ...product.product_detail import scrape_product_detail
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import asyncio
|
||||
from typing import Dict, List, Set
|
||||
from config import config
|
||||
from utils import log
|
||||
from shared.config import config
|
||||
from shared.utils import log
|
||||
from .fetch_and_upsert_product import fetch_and_upsert_product
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
from typing import Dict
|
||||
from urllib.parse import urljoin
|
||||
from config import config
|
||||
from shared.config import config
|
||||
|
||||
def rewrite_nav(nav: Dict[str, Dict], nav_redirects:Dict[str, str]):
|
||||
if nav_redirects:
|
||||
|
||||
@@ -2,7 +2,7 @@ from typing import Optional, Dict, Any, List
|
||||
from urllib.parse import urljoin
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
from config import config
|
||||
from shared.config import config
|
||||
|
||||
class LoginFailed(Exception):
|
||||
def __init__(self, message: str, *, debug: Dict[str, Any]):
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from typing import Optional
|
||||
from bs4 import BeautifulSoup
|
||||
from urllib.parse import urljoin
|
||||
from config import config
|
||||
from shared.config import config
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import secrets
|
||||
from typing import Optional, Dict
|
||||
|
||||
import httpx
|
||||
from config import config
|
||||
from shared.config import config
|
||||
|
||||
_CLIENT: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse
|
||||
|
||||
|
||||
from .http_client import fetch
|
||||
from suma_browser.app.bp.browse.services.slugs import product_slug_from_href
|
||||
from suma_browser.app.bp.browse.services.state import (
|
||||
from bp.browse.services.slugs import product_slug_from_href
|
||||
from bp.browse.services.state import (
|
||||
KNOWN_PRODUCT_SLUGS,
|
||||
_listing_page_cache,
|
||||
_listing_page_ttl,
|
||||
@@ -16,8 +16,8 @@ from suma_browser.app.bp.browse.services.state import (
|
||||
_listing_variant_ttl,
|
||||
now,
|
||||
)
|
||||
from utils import normalize_text, soup_of
|
||||
from config import config
|
||||
from shared.utils import normalize_text, soup_of
|
||||
from shared.config import config
|
||||
|
||||
|
||||
def parse_total_pages_from_text(text: str) -> Optional[int]:
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Dict, List, Tuple, Optional
|
||||
from urllib.parse import urlparse, urljoin
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from config import config
|
||||
from shared.config import config
|
||||
from .http_client import fetch # only fetch; define soup_of locally
|
||||
#from .. import cache_backend as cb
|
||||
#from ..blacklist.category import is_category_blocked # Reverse map: slug -> label
|
||||
|
||||
@@ -17,7 +17,7 @@ from models.market import (
|
||||
Listing,
|
||||
ListingItem,
|
||||
)
|
||||
from db.session import get_session
|
||||
from shared.db.session import get_session
|
||||
|
||||
# --- Models are unchanged, see original code ---
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Dict
|
||||
from models.market import (
|
||||
ProductLog,
|
||||
)
|
||||
from db.session import get_session
|
||||
from shared.db.session import get_session
|
||||
|
||||
|
||||
async def log_product_result(ok: bool, payload: Dict) -> None:
|
||||
|
||||
@@ -7,7 +7,7 @@ from models.market import (
|
||||
LinkError,
|
||||
LinkExternal,
|
||||
)
|
||||
from db.session import get_session
|
||||
from shared.db.session import get_session
|
||||
|
||||
# --- Models are unchanged, see original code ---
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from models.market import (
|
||||
NavTop,
|
||||
NavSub,
|
||||
)
|
||||
from db.session import get_session
|
||||
from shared.db.session import get_session
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy import (
|
||||
from models.market import (
|
||||
SubcategoryRedirect,
|
||||
)
|
||||
from db.session import get_session
|
||||
from shared.db.session import get_session
|
||||
|
||||
# --- Models are unchanged, see original code ---
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from models.market import (
|
||||
ProductNutrition,
|
||||
ProductAllergen
|
||||
)
|
||||
from db.session import get_session
|
||||
from shared.db.session import get_session
|
||||
|
||||
from ._get import _get
|
||||
from .log_product_result import _log_product_result
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from typing import Dict, List, Union
|
||||
from urllib.parse import urlparse
|
||||
from bs4 import BeautifulSoup
|
||||
from utils import normalize_text
|
||||
from shared.utils import normalize_text
|
||||
from ..registry import extractor
|
||||
|
||||
@extractor
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
from __future__ import annotations
|
||||
from typing import Dict, List
|
||||
from bs4 import BeautifulSoup
|
||||
from utils import normalize_text
|
||||
from shared.utils import normalize_text
|
||||
from ...html_utils import absolutize_fragment
|
||||
from ..registry import extractor
|
||||
from ..helpers.desc import (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
from __future__ import annotations
|
||||
from typing import Dict, Union
|
||||
from bs4 import BeautifulSoup
|
||||
from utils import normalize_text
|
||||
from shared.utils import normalize_text
|
||||
from ..registry import extractor
|
||||
from ..helpers.price import parse_price, parse_case_size
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
from __future__ import annotations
|
||||
from typing import Dict, List
|
||||
from bs4 import BeautifulSoup
|
||||
from utils import normalize_text
|
||||
from shared.utils import normalize_text
|
||||
from ..registry import extractor
|
||||
|
||||
@extractor
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
import re
|
||||
from bs4 import BeautifulSoup
|
||||
from utils import normalize_text
|
||||
from shared.utils import normalize_text
|
||||
from ..registry import extractor
|
||||
from ..helpers.desc import (
|
||||
split_description_container, find_description_container,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
from __future__ import annotations
|
||||
from typing import Dict
|
||||
from bs4 import BeautifulSoup
|
||||
from utils import normalize_text
|
||||
from shared.utils import normalize_text
|
||||
from ..registry import extractor
|
||||
|
||||
@extractor
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
from __future__ import annotations
|
||||
from typing import Dict
|
||||
from bs4 import BeautifulSoup
|
||||
from utils import normalize_text
|
||||
from shared.utils import normalize_text
|
||||
from ..registry import extractor
|
||||
|
||||
@extractor
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
from __future__ import annotations
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from bs4 import BeautifulSoup, NavigableString, Tag
|
||||
from utils import normalize_text
|
||||
from shared.utils import normalize_text
|
||||
from ...html_utils import absolutize_fragment
|
||||
from .text import clean_title, is_blacklisted_heading
|
||||
from config import config
|
||||
from shared.config import config
|
||||
|
||||
|
||||
def split_description_container(desc_el: Tag) -> Tuple[str, List[Dict]]:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
from __future__ import annotations
|
||||
from typing import List, Optional
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from config import config
|
||||
from shared.config import config
|
||||
|
||||
def first_from_srcset(val: str) -> Optional[str]:
|
||||
if not val:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
import re
|
||||
from utils import normalize_text
|
||||
from config import config
|
||||
from shared.utils import normalize_text
|
||||
from shared.config import config
|
||||
|
||||
def clean_title(t: str) -> str:
|
||||
t = normalize_text(t)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import Dict, Tuple, Union
|
||||
from utils import soup_of
|
||||
from shared.utils import soup_of
|
||||
from ..http_client import fetch
|
||||
from ..html_utils import absolutize_fragment
|
||||
from suma_browser.app.bp.browse.services.slugs import product_slug_from_href
|
||||
from bp.browse.services.slugs import product_slug_from_href
|
||||
from .registry import REGISTRY, merge_missing
|
||||
from . import extractors as _auto_register # noqa: F401 (import-time side effects)
|
||||
|
||||
|
||||
26
services/__init__.py
Normal file
26
services/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Market app service registration."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def register_domain_services() -> None:
|
||||
"""Register services for the market app.
|
||||
|
||||
Market owns: Product, CartItem, MarketPlace, NavTop, NavSub,
|
||||
Listing, ProductImage.
|
||||
Standard deployment registers all 4 services as real DB impls
|
||||
(shared DB). For composable deployments, swap non-owned services
|
||||
with stubs from shared.services.stubs.
|
||||
"""
|
||||
from shared.services.registry import services
|
||||
from shared.services.blog_impl import SqlBlogService
|
||||
from shared.services.calendar_impl import SqlCalendarService
|
||||
from shared.services.market_impl import SqlMarketService
|
||||
from shared.services.cart_impl import SqlCartService
|
||||
|
||||
services.market = SqlMarketService()
|
||||
if not services.has("blog"):
|
||||
services.blog = SqlBlogService()
|
||||
if not services.has("calendar"):
|
||||
services.calendar = SqlCalendarService()
|
||||
if not services.has("cart"):
|
||||
services.cart = SqlCartService()
|
||||
1
shared
Submodule
1
shared
Submodule
Submodule shared added at d404349806
Submodule shared_lib deleted from 356d916e26
@@ -1,7 +0,0 @@
|
||||
{% import "macros/links.html" as links %}
|
||||
{% if g.rights.admin %}
|
||||
{% from 'macros/admin_nav.html' import admin_nav_item %}
|
||||
{{admin_nav_item(
|
||||
url_for('market.browse.product.admin', slug=slug)
|
||||
)}}
|
||||
{% endif %}
|
||||
@@ -1,5 +0,0 @@
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 md:grid-cols-6 gap-3">
|
||||
{% include "_types/browse/_product_cards.html" %}
|
||||
</div>
|
||||
<div class="pb-8"></div>
|
||||
@@ -1,104 +0,0 @@
|
||||
{% import 'macros/stickers.html' as stick %}
|
||||
{% import '_types/product/prices.html' as prices %}
|
||||
{% set prices_ns = namespace() %}
|
||||
{{ prices.set_prices(p, prices_ns) }}
|
||||
{% set item_href = url_for('market.browse.product.product_detail', slug=p.slug)|host %}
|
||||
<div class="flex flex-col rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden relative">
|
||||
{# ❤️ like button overlay - OUTSIDE the link #}
|
||||
{% if g.user %}
|
||||
<div class="absolute top-2 right-2 z-10 text-6xl md:text-xl">
|
||||
{% set slug = p.slug %}
|
||||
{% set liked = p.is_liked or False %}
|
||||
{% include "_types/browse/like/button.html" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<a
|
||||
href="{{ item_href }}"
|
||||
hx-get="{{ item_href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select ="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
class=""
|
||||
>
|
||||
|
||||
{# Make this relative so we can absolutely position children #}
|
||||
<div class="w-full aspect-square bg-stone-100 relative">
|
||||
{% if p.image %}
|
||||
<figure class="inline-block w-full h-full">
|
||||
<div class="relative w-full h-full">
|
||||
<img
|
||||
src="{{ p.image }}"
|
||||
alt="no image"
|
||||
class="absolute inset-0 w-full h-full object-contain object-top"
|
||||
loading="lazy" decoding="async" fetchpriority="low"
|
||||
/>
|
||||
|
||||
{% for l in p.labels %}
|
||||
<img
|
||||
src="{{ asset_url('labels/' + l + '.svg') }}"
|
||||
alt=""
|
||||
class="pointer-events-none absolute inset-0 w-full h-full object-contain object-top"
|
||||
/>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<figcaption class="
|
||||
mt-2 text-sm text-center
|
||||
{{ 'bg-yellow-200' if p.brand in selected_brands else '' }}
|
||||
text-stone-600
|
||||
">
|
||||
{{ p.brand }}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
{% else %}
|
||||
<div class="p-2 flex flex-col items-center justify-center gap-2 text-red-500 h-full relative">
|
||||
<div class="text-stone-400 text-xs">No image</div>
|
||||
<ul class="flex flex-row gap-1">
|
||||
{% for l in p.labels %}
|
||||
<li>{{ l }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="text-stone-900 text-center line-clamp-3 break-words [overflow-wrap:anywhere]">
|
||||
{{ p.brand }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{# <div>{{ prices.rrp(prices_ns) }}</div> #}
|
||||
{{ prices.card_price(p)}}
|
||||
|
||||
{% import '_types/product/_cart.html' as _cart %}
|
||||
</a>
|
||||
<div class="flex justify-center">
|
||||
{{ _cart.add(p.slug, cart)}}
|
||||
</div>
|
||||
|
||||
|
||||
<a
|
||||
href="{{ item_href }}"
|
||||
hx-get="{{ item_href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select ="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<div class="flex flex-row justify-center gap-2 p-2">
|
||||
{% for s in p.stickers %}
|
||||
{{ stick.sticker(
|
||||
asset_url('stickers/' + s + '.svg'),
|
||||
s,
|
||||
True,
|
||||
size=24,
|
||||
found=s in selected_stickers
|
||||
) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="text-sm font-medium text-stone-800 text-center line-clamp-3 break-words [overflow-wrap:anywhere]">
|
||||
{{ p.title | highlight(search) }}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
@@ -1,107 +0,0 @@
|
||||
{% for p in products %}
|
||||
{% include "_types/browse/_product_card.html" %}
|
||||
{% endfor %}
|
||||
{% if page < total_pages|int %}
|
||||
|
||||
|
||||
<div
|
||||
id="sentinel-{{ page }}-m"
|
||||
class="block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"
|
||||
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host }}"
|
||||
hx-trigger="intersect once delay:250ms, sentinelmobile:retry"
|
||||
hx-swap="outerHTML"
|
||||
_="
|
||||
init
|
||||
if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end
|
||||
if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' end
|
||||
|
||||
on resize from window
|
||||
if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' else remove @hx-disabled end
|
||||
|
||||
on htmx:beforeRequest
|
||||
if window.matchMedia('(min-width: 768px)').matches then halt end
|
||||
add .hidden to .js-neterr in me
|
||||
remove .hidden from .js-loading in me
|
||||
remove .opacity-100 from me
|
||||
add .opacity-0 to me
|
||||
|
||||
def backoff()
|
||||
set ms to me.dataset.retryMs
|
||||
if ms > 30000 then set ms to 30000 end
|
||||
-- show big SVG panel & make sentinel visible
|
||||
add .hidden to .js-loading in me
|
||||
remove .hidden from .js-neterr in me
|
||||
remove .opacity-0 from me
|
||||
add .opacity-100 to me
|
||||
wait ms ms
|
||||
trigger sentinelmobile:retry
|
||||
set ms to ms * 2
|
||||
if ms > 30000 then set ms to 30000 end
|
||||
set me.dataset.retryMs to ms
|
||||
end
|
||||
|
||||
on htmx:sendError call backoff()
|
||||
on htmx:responseError call backoff()
|
||||
on htmx:timeout call backoff()
|
||||
"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{% include "sentinel/mobile_content.html" %}
|
||||
</div>
|
||||
<!-- DESKTOP sentinel (custom scroll container) -->
|
||||
<div
|
||||
id="sentinel-{{ page }}-d"
|
||||
class="hidden md:block h-4 opacity-0 pointer-events-none"
|
||||
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host}}"
|
||||
hx-trigger="intersect once delay:250ms, sentinel:retry"
|
||||
hx-swap="outerHTML"
|
||||
_="
|
||||
init
|
||||
if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end
|
||||
|
||||
on htmx:beforeRequest(event)
|
||||
add .hidden to .js-neterr in me
|
||||
remove .hidden from .js-loading in me
|
||||
remove .opacity-100 from me
|
||||
add .opacity-0 to me
|
||||
|
||||
set trig to null
|
||||
if event.detail and event.detail.triggeringEvent then
|
||||
set trig to event.detail.triggeringEvent
|
||||
end
|
||||
if trig and trig.type is 'intersect'
|
||||
set scroller to the closest .js-grid-viewport
|
||||
if scroller is null then halt end
|
||||
if scroller.scrollTop < 20 then halt end
|
||||
end
|
||||
|
||||
def backoff()
|
||||
set ms to me.dataset.retryMs
|
||||
if ms > 30000 then set ms to 30000 end
|
||||
add .hidden to .js-loading in me
|
||||
remove .hidden from .js-neterr in me
|
||||
remove .opacity-0 from me
|
||||
add .opacity-100 to me
|
||||
wait ms ms
|
||||
trigger sentinel:retry
|
||||
set ms to ms * 2
|
||||
if ms > 30000 then set ms to 30000 end
|
||||
set me.dataset.retryMs to ms
|
||||
end
|
||||
|
||||
on htmx:sendError call backoff()
|
||||
on htmx:responseError call backoff()
|
||||
on htmx:timeout call backoff()
|
||||
"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{% include "sentinel/desktop_content.html" %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-span-full mt-4 text-center text-xs text-stone-400">End of results</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
{# Categories #}
|
||||
<nav aria-label="Categories"
|
||||
class="rounded-xl border bg-white shadow-sm min-h-0">
|
||||
<ul class="divide-y">
|
||||
{% set top_active = (current_local_href == top_local_href) %}
|
||||
{% set href = (url_for('market.browse.browse_top', top_slug=top_slug) ~ qs)|host %}
|
||||
<li>
|
||||
<a
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
aria-selected="{{ 'true' if top_active else 'false' }}"
|
||||
class="block px-4 py-3 text-[15px] transition {{select_colours}}">
|
||||
<div class="prose prose-stone max-w-none">All products</div>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% for sub in subs_local %}
|
||||
{% set active = (current_local_href == sub.local_href) %}
|
||||
{% set href = (url_for('market.browse.browse_sub', top_slug=top_slug, sub_slug=sub.slug) ~ qs)|host %}
|
||||
<li>
|
||||
<a
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
aria-selected="{{ 'true' if active else 'false' }}"
|
||||
class="block px-4 py-3 text-[15px] border-l-4 transition {{select_colours}}"
|
||||
>
|
||||
<div class="prose prose-stone max-w-none">{{ (sub.html_label or sub.name) | safe }}</div>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</nav>
|
||||
@@ -1,40 +0,0 @@
|
||||
{# Brand filter (desktop, single-select) #}
|
||||
|
||||
{# Brands #}
|
||||
<nav aria-label="Brands"
|
||||
class="rounded-xl border bg-white shadow-sm">
|
||||
<h2 class="text-md mt-2 font-semibold">Brands</h2>
|
||||
<ul class="divide-y">
|
||||
{% for b in brands %}
|
||||
{% set is_selected = (b.name in selected_brands) %}
|
||||
{% if is_selected %}
|
||||
{% set brand_href = (current_local_href ~ {"remove_brand": b.name, "page": None}|qs)|host %}
|
||||
{% else %}
|
||||
{% set brand_href = (current_local_href ~ {"add_brand": b.name, "page": None}|qs)|host %}
|
||||
{% endif %}
|
||||
<li>
|
||||
<a
|
||||
href="{{ brand_href }}"
|
||||
hx-get="{{ brand_href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML" hx-push-url="true" hx-on:htmx:afterSwap="this.closest('details')?.removeAttribute('open')"
|
||||
class="flex items-center gap-2 px-2 py-2 rounded transition {% if is_selected %} bg-stone-900 text-white {% else %} hover:bg-stone-50 {% endif %}">
|
||||
<span class="inline-flex items-center justify-center w-5 h-5 rounded border {% if is_selected %} border-stone-900 bg-stone-900 text-white {% else %} border-stone-300 {% endif %}">
|
||||
{% if is_selected %}
|
||||
<svg viewBox="0 0 24 24" class="w-4 h-4" aria-hidden="true">
|
||||
<path d="M5 13l4 4L19 7" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="flex-1 text-sm">{{ b.name }}</span>
|
||||
|
||||
{% if b.count is not none %}
|
||||
<span class="{% if b.count==0 %}text-lg text-red-500{% else %}text-sm{% endif %} {% if is_selected %}opacity-90{% else %}text-stone-500{% endif %}">{{ b.count }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
</ul>
|
||||
</nav>
|
||||
@@ -1,44 +0,0 @@
|
||||
|
||||
|
||||
|
||||
{% import 'macros/stickers.html' as stick %}
|
||||
|
||||
<ul
|
||||
id="labels-details-desktop"
|
||||
class="flex justify-center p-0 m-0 gap-2"
|
||||
>
|
||||
{% for s in labels %}
|
||||
{% set is_on = (selected_labels and (s.name|lower in selected_labels)) %}
|
||||
{% set qs = {"remove_label": s.name, "page":None}|qs if is_on
|
||||
else {"add_label": s.name, "page":None}|qs %}
|
||||
{% set href = (current_local_href ~ qs)|host %}
|
||||
<li>
|
||||
<a
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
role="button"
|
||||
aria-pressed="{{ 'true' if is_on else 'false' }}"
|
||||
title="{{ s.name }}" aria-label="{{ s.name }}"
|
||||
class="flex w-full h-full flex-col items-center justify-center py-2"
|
||||
>
|
||||
<!-- col 1: icon -->
|
||||
{{ stick.sticker(asset_url('nav-labels/' + s.name + '.svg'), s.name, is_on)}}
|
||||
|
||||
|
||||
<!-- col 3: count (right aligned) -->
|
||||
{% if s.count is not none %}
|
||||
<span class="
|
||||
{{'text-xs text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
|
||||
leading-none justify-self-end tabular-nums">
|
||||
{{ s.count }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% endfor %}
|
||||
</ul>
|
||||
@@ -1,38 +0,0 @@
|
||||
{% import 'macros/stickers.html' as stick %}
|
||||
{% set qs = {"liked": None if liked else True, "page": None}|qs %}
|
||||
{% set href = (current_local_href ~ qs)|host %}
|
||||
<a
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
role="button"
|
||||
aria-pressed="{{ 'true' if liked else 'false' }}"
|
||||
title="liked" aria-label="liked"
|
||||
class="flex flex-col items-center justify-start p-1 rounded hover:bg-stone-50"
|
||||
{% if liked %}
|
||||
aria-label="liked and unliked"
|
||||
{% else %}
|
||||
aria-label="just liked"
|
||||
{% endif %}
|
||||
>
|
||||
{% if liked %}
|
||||
<i aria-hidden="true"
|
||||
class="fa-solid fa-heart text-red-500 text-[40px] leading-none"
|
||||
></i>
|
||||
{% else %}
|
||||
<i aria-hidden="true"
|
||||
class="fa-solid fa-heart text-stone-300 text-[40px] leading-none"
|
||||
></i>
|
||||
{% endif %}
|
||||
<span class="
|
||||
{{'text-[10px] text-stone-500' if liked_count != 0 else 'text-md text-red-500 font-bold'}}
|
||||
mt-1 leading-none tabular-nums
|
||||
"
|
||||
aria_label="liked count"
|
||||
>
|
||||
{{ liked_count }}
|
||||
</span>
|
||||
</a>
|
||||
@@ -1,44 +0,0 @@
|
||||
|
||||
{% macro search(current_local_href,search, search_count, hx_select) -%}
|
||||
<!-- Search (1/3 width → 4/12 columns) -->
|
||||
<!-- nb this does NOT oob itself!! -->
|
||||
<div
|
||||
id="search-desktop-wrapper"
|
||||
class="flex flex-row gap-2 items-center"
|
||||
>
|
||||
<input
|
||||
id="search-desktop"
|
||||
type="text"
|
||||
name="search"
|
||||
aria-label="search"
|
||||
class="w-full mx-1 my-3 px-3 py-2 text-md rounded-xl border-2 shadow-sm border-white placeholder-shown:border-white [&:not(:placeholder-shown)]:border-yellow-200"
|
||||
hx-preserve
|
||||
value="{{ search|default('', true) }}"
|
||||
placeholder="search"
|
||||
hx-trigger="input changed delay:300ms"
|
||||
hx-target="#main-panel"
|
||||
|
||||
hx-select="{{hx_select}}, #search-mobile-wrapper, #search-desktop-wrapper"
|
||||
hx-get="{{ (current_local_href ~ {'search': None}|qs)|host}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
hx-headers='{"X-Origin":"search-desktop", "X-Search":"true"}'
|
||||
hx-sync="this:replace"
|
||||
|
||||
autocomplete="off"
|
||||
>
|
||||
|
||||
<div
|
||||
id="search-count-desktop"
|
||||
aria-label="search count"
|
||||
{% if not search_count %}
|
||||
class="text-xl text-red-500"
|
||||
{% endif %}
|
||||
>
|
||||
{% if search %}
|
||||
{{search_count}}
|
||||
{% endif %}
|
||||
{{zap_filter}}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
@@ -1,34 +0,0 @@
|
||||
|
||||
|
||||
|
||||
|
||||
{% import 'macros/stickers.html' as stick %}
|
||||
{% set sort_val = sort|default('az', true) %}
|
||||
|
||||
<ul
|
||||
id="sort-details-desktop"
|
||||
class="flex w-full p-0 m-0 border bg-white shadow-sm rounded-xl gap-0 [&>li]:list-none [&>li]:flex-1"
|
||||
>
|
||||
{% for key,label,icon in sort_options %}
|
||||
{% set is_on = (sort_val == key) %}
|
||||
{% set qs = {"sort": None, "page": None}|qs if is_on
|
||||
else {"sort": key, "page": None}|qs %}
|
||||
{% set href = (current_local_href ~ qs)|host %}
|
||||
|
||||
<li>
|
||||
<a
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
role="button"
|
||||
aria-pressed="{{ 'true' if is_on else 'false' }}"
|
||||
class="flex flex-col items-center justify-center w-full h-full py-2 m-0"
|
||||
>
|
||||
{{ stick.sticker(asset_url(icon), label, is_on) }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
@@ -1,46 +0,0 @@
|
||||
|
||||
|
||||
|
||||
|
||||
{% import 'macros/stickers.html' as stick %}
|
||||
|
||||
<ul
|
||||
id="stickers-details-desktop"
|
||||
class="flex flex-wrap justify-center w-full p-0 m-0 border bg-white shadow-sm rounded-xl gap-1 [&>li]:list-none [&>li]:basis-[20%] [&>li]:max-w-[20%] [&>li]:grow-0"
|
||||
>
|
||||
{% for s in stickers %}
|
||||
{% set is_on = (selected_stickers and (s.name|lower in selected_stickers)) %}
|
||||
{% set qs = {"remove_sticker": s.name, "page": None}|qs if is_on
|
||||
else {"add_sticker": s.name, "page": None}|qs %}
|
||||
{% set href = (current_local_href ~ qs)|host%}
|
||||
<li>
|
||||
<a
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
role="button"
|
||||
aria-pressed="{{ 'true' if is_on else 'false' }}"
|
||||
title="{{ s.name }}" aria-label="{{ s.name }}"
|
||||
class="flex w-full h-full flex-col items-center justify-center py-2"
|
||||
>
|
||||
<span class="text-[11px]">{{s.name|capitalize if s.name|lower != 'sugarfree' else 'Sugar'}}</span>
|
||||
<!-- col 1: icon -->
|
||||
{{ stick.sticker(asset_url('stickers/' + s.name + '.svg'), s.name, is_on)}}
|
||||
|
||||
|
||||
<!-- col 3: count (right aligned) -->
|
||||
{% if s.count is not none %}
|
||||
<span class="
|
||||
{{'text-xs text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
|
||||
leading-none justify-self-end tabular-nums">
|
||||
{{ s.count }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% endfor %}
|
||||
</ul>
|
||||
@@ -1,37 +0,0 @@
|
||||
{% import '_types/browse/desktop/_filter/search.html' as s %}
|
||||
{{ s.search(current_local_href, search, search_count, hx_select) }}
|
||||
|
||||
<div
|
||||
id="category-summary-desktop"
|
||||
hxx-swap-oob="outerHTML"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<div class="text-2xl uppercase tracking-wide text-black-500">{{ category_label }}</div>
|
||||
</div>
|
||||
{% include "_types/browse/desktop/_filter/sort.html" %}
|
||||
<nav aria-label="like" class="flex flex-row justify-center w-full p-0 m-0 border bg-white shadow-sm rounded-xl gap-1">
|
||||
{% include "_types/browse/desktop/_filter/like.html" %}
|
||||
{% if labels %}
|
||||
{% include "_types/browse/desktop/_filter/labels.html" %}
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
{% if stickers %}
|
||||
{% include "_types/browse/desktop/_filter/stickers.html" %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if subs_local and top_local_href %}
|
||||
{% include "_types/browse/desktop/_category_selector.html" %}
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="filter-summary-desktop"
|
||||
hxx-swap-oob="outerHTML"
|
||||
>
|
||||
|
||||
{% include "_types/browse/desktop/_filter/brand.html" %}
|
||||
|
||||
</div>
|
||||
@@ -1,13 +0,0 @@
|
||||
{% extends '_types/market/index.html' %}
|
||||
|
||||
{% block filter %}
|
||||
{% include "_types/browse/mobile/_filter/summary.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block aside %}
|
||||
{% include "_types/browse/desktop/menu.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "_types/browse/_main_panel.html" %}
|
||||
{% endblock %}
|
||||
@@ -1,20 +0,0 @@
|
||||
<button
|
||||
class="flex items-center gap-1 {% if liked %} text-red-600 {% else %} text-stone-300 {% endif %} hover:text-red-600 transition-colors w-[1em] h-[1em]"
|
||||
hx-post="{{ like_url if like_url else url_for('market.browse.product.like_toggle', slug=slug)|host }}"
|
||||
hx-target="this"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="false"
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
|
||||
hx-swap-settle="0ms"
|
||||
{% if liked %}
|
||||
aria-label="Unlike this {{ item_type if item_type else 'product' }}"
|
||||
{% else %}
|
||||
aria-label="Like this {{ item_type if item_type else 'product' }}"
|
||||
{% endif %}
|
||||
>
|
||||
{% if liked %}
|
||||
<i aria-hidden="true" class="fa-solid fa-heart"></i>
|
||||
{% else %}
|
||||
<i aria-hidden="true" class="fa-regular fa-heart"></i>
|
||||
{% endif %}
|
||||
</button>
|
||||
@@ -1,40 +0,0 @@
|
||||
<nav aria-label="Brands" class="px-4 pb-3" >
|
||||
{% if brands|length %}
|
||||
<h2 class="text-md mt-2 font-semibold">Brands</h2>
|
||||
<ul class="space-y-1 pr-1" >
|
||||
{% for b in brands %}
|
||||
{% set is_selected = (b.name in selected_brands) %}
|
||||
<li>
|
||||
{{current_local_href}}
|
||||
<a
|
||||
{% if is_selected %}
|
||||
{% set href = (current_local_href ~ {"remove_brand": b.name, "page": None}|qs)|host %}
|
||||
{% else %}
|
||||
{% set href = (current_local_href ~ {"add_brand": b.name, "page": None}|qs)|host %}
|
||||
{%endif%}
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
|
||||
class="flex items-center gap-2 my-3 px-2 py-2 rounded transition {% if is_selected %} bg-stone-900 text-white {% else %} hover:bg-stone-50 {% endif %}">
|
||||
<span class="inline-flex items-center justify-center w-5 h-5 rounded border {% if is_selected %} border-stone-900 bg-stone-900 text-white {% else %} border-stone-300 {% endif %}">
|
||||
{% if is_selected %}
|
||||
<svg viewBox="0 0 24 24" class="w-4 h-4" aria-hidden="true">
|
||||
<path d="M5 13l4 4L19 7" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="flex-1 text-sm">{{ b.name }}</span>
|
||||
{% if b.count is not none %}
|
||||
<span class="text-xs {% if is_selected %}opacity-90{% else %}text-stone-500{% endif %}">{{ b.count }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</nav>
|
||||
@@ -1,30 +0,0 @@
|
||||
|
||||
{% include "_types/browse/mobile/_filter/sort_ul.html" %}
|
||||
{% if search or selected_labels|length or selected_stickers|length or selected_brands|length %}
|
||||
{% set href = (current_local_href ~ {"clear_filters": True}|qs)|host %}
|
||||
<div class = "flex flex-row justify-center">
|
||||
<a
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
role="button"
|
||||
title="clear filters"
|
||||
aria-label="clear filters"
|
||||
class="flex flex-col items-center justify-start p-1 rounded bg-stone-200 text-black cursor-pointer">
|
||||
<span class="mt-1 leading-none tabular-nums"
|
||||
>
|
||||
clear filters
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex flex-row gap-2 justify-center items center">
|
||||
{% include "_types/browse/mobile/_filter/like.html" %}
|
||||
{% include "_types/browse/mobile/_filter/labels.html" %}
|
||||
</div>
|
||||
{% include "_types/browse/mobile/_filter/stickers.html" %}
|
||||
{% include "_types/browse/mobile/_filter/brand_ul.html" %}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
{% import 'macros/stickers.html' as stick %}
|
||||
<nav aria-label="labels" class="px-4 pb-3">
|
||||
{# One row only; center when not overflowing; horizontal scroll when needed #}
|
||||
<ul
|
||||
class="flex w-full items-start justify-center gap-3 overflow-x-auto overflow-y-visible pr-1 no-scrollbar"
|
||||
>
|
||||
|
||||
{% for s in labels %}
|
||||
{% set is_on = (selected_labels and (s.name|lower in selected_labels)) %}
|
||||
|
||||
{% set qs = {"remove_label": s.name, "page": None}|qs if is_on
|
||||
else {"add_label": s.name, "page": None}|qs %}
|
||||
{% set href = (current_local_href ~ qs)|host %}
|
||||
|
||||
<li class="list-none shrink-0">
|
||||
<a
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
role="button"
|
||||
aria-pressed="{{ 'true' if is_on else 'false' }}"
|
||||
title="{{ s.name }}" aria-label="{{ s.name }}"
|
||||
class="flex flex-col items-center justify-start p-1 rounded hover:bg-stone-50">
|
||||
{{ stick.sticker(asset_url('nav-labels/' + s.name + '.svg'), s.name, is_on)}}
|
||||
{% if s.count is not none %}
|
||||
<span class="
|
||||
{{'text-[10px] text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
|
||||
mt-1 leading-none tabular-nums
|
||||
"
|
||||
>
|
||||
{{ s.count }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{# Optional: hide horizontal scrollbar on mobile while keeping scrollable #}
|
||||
<style>
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
</style>
|
||||
@@ -1,40 +0,0 @@
|
||||
{% import 'macros/stickers.html' as stick %}
|
||||
<nav aria-label="like" class="px-4 pb-3">
|
||||
{% set qs = {"liked": None if liked else True, "page": None}|qs%}
|
||||
{% set href = (current_local_href ~ qs)|host %}
|
||||
<a
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
role="button"
|
||||
aria-pressed="{{ 'true' if liked else 'false' }}"
|
||||
title="liked" aria-label="liked"
|
||||
class="flex flex-col items-center justify-start p-1 rounded hover:bg-stone-50"
|
||||
{% if liked %}
|
||||
aria-label="liked and unliked"
|
||||
{% else %}
|
||||
aria-label="just liked"
|
||||
{% endif %}
|
||||
>
|
||||
{% if liked %}
|
||||
<i aria-hidden="true"
|
||||
class="fa-solid fa-heart text-red-500 text-[40px] leading-none"
|
||||
></i>
|
||||
{% else %}
|
||||
<i aria-hidden="true"
|
||||
class="fa-solid fa-heart text-stone-500 text-[40px] leading-none"
|
||||
></i>
|
||||
{% endif %}
|
||||
<span class="
|
||||
{{'text-[10px] text-stone-500' if liked_count != 0 else 'text-md text-red-500 font-bold'}}
|
||||
mt-1 leading-none tabular-nums
|
||||
"
|
||||
aria_label="liked count"
|
||||
>
|
||||
{{ liked_count }}
|
||||
</span>
|
||||
</a>
|
||||
</nav>
|
||||
@@ -1,40 +0,0 @@
|
||||
{% macro search(current_local_href, search, search_count, hx_select) -%}
|
||||
|
||||
<div
|
||||
id="search-mobile-wrapper"
|
||||
class="flex flex-row gap-2 items-center flex-1 min-w-0 pr-2"
|
||||
>
|
||||
<input
|
||||
id="search-mobile"
|
||||
type="text"
|
||||
name="search"
|
||||
aria-label="search"
|
||||
class="text-base md:text-sm col-span-5 rounded-md px-3 py-2 mb-2 w-full min-w-0 max-w-full border-2 border-stone-200 placeholder-shown:border-stone-200 [&:not(:placeholder-shown)]:border-yellow-200"
|
||||
hx-preserve
|
||||
value="{{ search|default('', true) }}"
|
||||
placeholder="search"
|
||||
hx-trigger="input changed delay:300ms"
|
||||
hx-target="#main-panel"
|
||||
|
||||
hx-select="{{hx_select}}, #search-mobile-wrapper, #search-desktop-wrapper"
|
||||
hx-get="{{ (current_local_href ~ {'search': None}|qs)|host }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
hx-headers='{"X-Origin":"search-mobile", "X-Search":"true"}'
|
||||
hx-sync="this:replace"
|
||||
autocomplete="off"
|
||||
>
|
||||
|
||||
<div
|
||||
id="search-count-mobile"
|
||||
aria-label="search count"
|
||||
{% if not search_count %}
|
||||
class="text-xl text-red-500"
|
||||
{% endif %}
|
||||
>
|
||||
{% if search %}
|
||||
{{search_count}}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
@@ -1,33 +0,0 @@
|
||||
|
||||
|
||||
|
||||
{% import 'macros/stickers.html' as stick %}
|
||||
|
||||
|
||||
<nav aria-label="sort" class="px-4 pb-3" >
|
||||
<ul class="flex w-full items-start justify-center gap-3 overflow-x-auto overflow-y-visible pr-1 no-scrollbar">
|
||||
|
||||
{% for key,label,icon in sort_options %}
|
||||
<li class="list-none">
|
||||
<div class="flex flex-col items-center justify-center w-full">
|
||||
<a
|
||||
{% if sort == key %}
|
||||
{% set href= (current_local_href, {"sort": None, "page": None}|qs )|host %}
|
||||
{% else %}
|
||||
{% set href= (current_local_href ~ {"sort": key, "page": None}|qs )|host %}
|
||||
{% endif %}
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
>
|
||||
{{ stick.sticker(asset_url(icon), label, sort==key) }}
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</nav>
|
||||
@@ -1,50 +0,0 @@
|
||||
{% import 'macros/stickers.html' as stick %}
|
||||
|
||||
<nav aria-label="stickers" class="px-4 pb-3">
|
||||
{# One row only; center when not overflowing; horizontal scroll when needed #}
|
||||
<ul
|
||||
class="flex w-full items-start justify-center gap-3 overflow-x-auto overflow-y-visible pr-1 no-scrollbar"
|
||||
>
|
||||
|
||||
{% for s in stickers %}
|
||||
{% set is_on = (selected_stickers and (s.name|lower in selected_stickers)) %}
|
||||
{% set qs = {"remove_sticker": s.name, "page": None}|qs if is_on
|
||||
else {"add_sticker": s.name, "page": None}|qs %}
|
||||
|
||||
{% set href = (current_local_href ~ qs)|host %}
|
||||
|
||||
<li class="list-none shrink-0">
|
||||
<a
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
role="button"
|
||||
aria-pressed="{{ 'true' if is_on else 'false' }}"
|
||||
title="{{ s.name }}" aria-label="{{ s.name }}"
|
||||
class="flex flex-col items-center justify-start p-1 rounded hover:bg-stone-50">
|
||||
<span class="text-sm">{{s.name|capitalize if s.name|lower != 'sugarfree' else 'Sugar'}}</span>
|
||||
{{ stick.sticker(asset_url('stickers/' + s.name + '.svg'), s.name, is_on) }}
|
||||
|
||||
{% if s.count is not none %}
|
||||
<span class="
|
||||
{{'text-[10px] text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
|
||||
mt-1 leading-none tabular-nums
|
||||
"
|
||||
>
|
||||
{{ s.count }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{# Optional: hide horizontal scrollbar on mobile while keeping scrollable #}
|
||||
<style>
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
</style>
|
||||
@@ -1,120 +0,0 @@
|
||||
{% import 'macros/stickers.html' as stick %}
|
||||
{% import 'macros/layout.html' as layout %}
|
||||
|
||||
|
||||
|
||||
|
||||
{% call layout.details('/filter', 'md:hidden') %}
|
||||
{% call layout.filter_summary("filter-summary-mobile", current_local_href, search, search_count, hx_select) %}
|
||||
<div
|
||||
class="col-span-12 min-w-0 grid grid-cols-1 gap-1 bg-gray-100 px-2"
|
||||
role="list">
|
||||
|
||||
|
||||
<div class="flex flex-row items-start gap-2">
|
||||
{% if sort %}
|
||||
<ul class="relative inline-flex items-center justify-center gap-2">
|
||||
<!-- sticker icon -->
|
||||
{% for k,l,i in sort_options %}
|
||||
{% if k == sort %}
|
||||
{% set key = k %}
|
||||
{% set label = l %}
|
||||
{% set icon = i %}
|
||||
<li role="listitem">
|
||||
{{ stick.sticker(asset_url(icon), label, True)}}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if liked %}
|
||||
<div class="flex flex-col items-center gap-1 pb-1">
|
||||
<i aria-hidden="true"
|
||||
class="fa-solid fa-heart text-red-500 text-[40px] leading-none"
|
||||
></i>
|
||||
{% if liked_count is not none %}
|
||||
<div class="
|
||||
{{'text-[10px] text-stone-500' if liked_count != 0 else 'text-md text-red-500 font-bold'}}
|
||||
mt-1 leading-none tabular-nums"
|
||||
>
|
||||
{{ liked_count }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if selected_labels and selected_labels|length %}
|
||||
<ul class="relative inline-flex items-center justify-center gap-2">
|
||||
{% for st in selected_labels %}
|
||||
{% for s in labels %}
|
||||
{% if st == s.name %}
|
||||
<li role="listitem" class="flex flex-col items-center gap-1 pb-1">
|
||||
{{ stick.sticker(asset_url('nav-labels/' + s.name + '.svg'), s.name, True)}}
|
||||
{% if s.count is not none %}
|
||||
<div class="
|
||||
{{'text-[10px] text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
|
||||
mt-1 leading-none tabular-nums
|
||||
"
|
||||
>
|
||||
{{ s.count }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if selected_stickers and selected_stickers|length %}
|
||||
<ul class="relative inline-flex items-center justify-center gap-2">
|
||||
{% for st in selected_stickers %}
|
||||
{% for s in stickers %}
|
||||
{% if st == s.name %}
|
||||
<li role="listitem" class="flex flex-col items-center gap-1 pb-1">
|
||||
<!-- sticker icon -->
|
||||
{{ stick.sticker(asset_url('stickers/' + s.name + '.svg'), s.name, True)}}
|
||||
{% if s.count is not none %}
|
||||
<span class="
|
||||
{{'text-[10px] text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
|
||||
mt-1 leading-none tabular-nums
|
||||
"
|
||||
>
|
||||
{{ s.count }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if selected_brands and selected_brands|length %}
|
||||
<ul class_="w-full grid grid-cols-12 items-center gap-3 px-4 py-3">
|
||||
{% for b in selected_brands %}
|
||||
<li role="listitem" class="flex flex-row items-center gap-2">
|
||||
{% set ns = namespace(count=0) %}
|
||||
{% for brand in brands %}
|
||||
{% if brand.name == b %}
|
||||
{% set ns.count = brand.count %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if ns.count %}
|
||||
<div class="text-md">{{ b }}</div>
|
||||
<div class="text-md">{{ ns.count }}</div>
|
||||
{% else %}
|
||||
<div class="text-md text-red-500">{{ b }}</div>
|
||||
<div class="text-xl text-red-500">0</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endcall %}
|
||||
<div id="filter-details-mobile" style="display:contents">
|
||||
{% include "_types/browse/mobile/_filter/index.html" %}
|
||||
</div>
|
||||
{% endcall %}
|
||||
@@ -1,7 +0,0 @@
|
||||
{% import "macros/links.html" as links %}
|
||||
{% if g.rights.admin %}
|
||||
{% from 'macros/admin_nav.html' import admin_nav_item %}
|
||||
{{admin_nav_item(
|
||||
url_for('market.admin.admin')
|
||||
)}}
|
||||
{% endif %}
|
||||
@@ -1,23 +0,0 @@
|
||||
{# Main panel fragment for HTMX navigation - market landing page #}
|
||||
<article class="relative w-full">
|
||||
{% if post.custom_excerpt %}
|
||||
<div class="w-full text-center italic text-3xl p-2">
|
||||
{{post.custom_excerpt|safe}}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if post.feature_image %}
|
||||
<div class="mb-3 flex justify-center">
|
||||
<img
|
||||
src="{{ post.feature_image }}"
|
||||
alt=""
|
||||
class="rounded-lg w-full md:w-3/4 object-cover"
|
||||
>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="blog-content p-2">
|
||||
{% if post.html %}
|
||||
{{post.html|safe}}
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
<div class="pb-8"></div>
|
||||
@@ -1,17 +0,0 @@
|
||||
<div
|
||||
class="font-bold text-xl flex-shrink-0 flex gap-2 items-center">
|
||||
<div>
|
||||
<i class="fa fa-shop"></i>
|
||||
{{ coop_title }}
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row md:gap-2 text-xs">
|
||||
<div>
|
||||
{{top_slug or ''}}
|
||||
</div>
|
||||
{% if sub_slug %}
|
||||
<div>
|
||||
{{sub_slug}}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1 +0,0 @@
|
||||
market admin
|
||||
@@ -1,2 +0,0 @@
|
||||
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||
{{ placeholder_nav() }}
|
||||
@@ -1,29 +0,0 @@
|
||||
{% extends 'oob_elements.html' %}
|
||||
|
||||
{# OOB elements for HTMX navigation - all elements that need updating #}
|
||||
|
||||
{# Import shared OOB macros #}
|
||||
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||
|
||||
{# Header with app title - includes cart-mini, navigation, and market-specific header #}
|
||||
|
||||
{% block oobs %}
|
||||
|
||||
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||
{{oob_header('market-header-child', 'market-admin-header-child', '_types/market/admin/header/_header.html')}}
|
||||
|
||||
{% from '_types/market/header/_header.html' import header_row with context %}
|
||||
{{ header_row(oob=True) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block mobile_menu %}
|
||||
{% include '_types/market/admin/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% include "_types/market/admin/_main_panel.html" %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='market-admin-row', oob=oob) %}
|
||||
{% call links.link(url_for('market.admin.admin'), hx_select_search) %}
|
||||
{{ links.admin() }}
|
||||
{% endcall %}
|
||||
{% call links.desktop_nav() %}
|
||||
{% include '_types/market/admin/_nav.html' %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
@@ -1,19 +0,0 @@
|
||||
{% extends '_types/market/index.html' %}
|
||||
|
||||
|
||||
{% block market_header_child %}
|
||||
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||
{% call index_row('market-admin-header-child', '_types/market/admin/header/_header.html') %}
|
||||
{% block market_admin_header_child %}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block _main_mobile_menu %}
|
||||
{% include '_types/market/admin/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/market/admin/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
@@ -1,38 +0,0 @@
|
||||
<!-- Desktop nav -->
|
||||
<nav class="hidden md:flex gap-4 text-sm ml-2 w-full justify-end items-center">
|
||||
{% set all_href = (url_for('market.browse.browse_all') ~ qs)|host %}
|
||||
{% set all_active = (category_label == 'All Products') %}
|
||||
<div class="relative nav-group">
|
||||
<a
|
||||
href="{{ all_href }}"
|
||||
hx-get="{{ all_href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
aria-selected="{{ 'true' if all_active else 'false' }}"
|
||||
class="block px-2 py-1 rounded text-center whitespace-normal break-words leading-snug bg-stone-200 text-black {{select_colours}}">
|
||||
All
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% for cat, data in categories.items() %}
|
||||
{% set cat_href = (url_for('market.browse.browse_top', top_slug=data.slug) ~ qs)|host%}
|
||||
{% set cat_active = (cat == category_label) %}
|
||||
<div class="relative nav-group">
|
||||
<a
|
||||
href="{{ cat_href }}"
|
||||
hx-get="{{ cat_href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
aria-selected="{{ 'true' if cat_active else 'false' }}"
|
||||
class="block px-2 py-1 rounded text-center whitespace-normal break-words leading-snug bg-stone-200 text-black {{select_colours}}"
|
||||
>
|
||||
{{ cat }}
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% include '_types/market/_admin.html' %}
|
||||
</nav>
|
||||
@@ -1,11 +0,0 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='market-row', oob=oob) %}
|
||||
{% call links.link(url_for('market.browse.home'), hx_select_search ) %}
|
||||
{% include '_types/market/_title.html' %}
|
||||
{% endcall %}
|
||||
{% call links.desktop_nav() %}
|
||||
{% include '_types/market/desktop/_nav.html' %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
@@ -7,7 +7,7 @@
|
||||
{% if markets %}
|
||||
<div class="grid gap-4">
|
||||
{% for m in markets %}
|
||||
<a href="/{{ m.post.slug }}/{{ m.slug }}/"
|
||||
<a href="/{{ m.page.slug }}/{{ m.slug }}/"
|
||||
class="block p-6 bg-white border border-stone-200 rounded-lg hover:border-stone-400 transition-colors">
|
||||
<h2 class="text-lg font-semibold">{{ m.name }}</h2>
|
||||
{% if m.description %}
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
{% from 'macros/glyphs.html' import opener %}
|
||||
<div class="px-4 py-2">
|
||||
<div class="divide-y">
|
||||
{% set all_href = (url_for('market.browse.browse_all') ~ qs)|host %}
|
||||
{% set all_active = (category_label == 'All Products') %}
|
||||
<a role="option"
|
||||
href="{{ all_href }}"
|
||||
hx-get="{{ all_href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
aria-selected="{{ 'true' if all_active else 'false' }}"
|
||||
class="block rounded-lg px-3 py-3 text-base hover:bg-stone-50 {{select_colours}}">
|
||||
<div class="prose prose-stone max-w-none">
|
||||
All
|
||||
</div>
|
||||
</a>
|
||||
{% for cat, data in categories.items() %}
|
||||
<details
|
||||
class="group/cat py-1"
|
||||
{% if top_slug == (data.slug | lower) %}open{% endif %}
|
||||
>
|
||||
<summary class="flex items-center justify-between cursor-pointer select-none block rounded-lg px-3 py-3 text-base hover:bg-stone-50 {% if top_slug==(data.slug | lower) %} bg-stone-900 text-white hover:bg-stone-900 {% endif %}">
|
||||
|
||||
{% set href = (url_for('market.browse.browse_top', top_slug=data.slug) ~ qs)|host %}
|
||||
|
||||
<a
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{ hx_select_search }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
aria-selected="{{ 'true' if top_slug==(data.slug | lower) else 'false' }}"
|
||||
class="font-medium {{ select_colours }} flex flex-row gap-2"
|
||||
>
|
||||
<div>{{ cat }}</div>
|
||||
<div aria-label="{{ data.count }} products">{{ data.count }}</div>
|
||||
</a>
|
||||
{{ opener('cat')}}
|
||||
|
||||
</summary>
|
||||
|
||||
<div class="pb-3 pl-2">
|
||||
{% if data.subs %}
|
||||
<!-- Viewport -->
|
||||
<div
|
||||
data-peek-viewport
|
||||
data-peek-size-px="18"
|
||||
data-peek-edge="bottom"
|
||||
data-peek-mask="true"
|
||||
class="m-2 bg-stone-100">
|
||||
<!-- Inner list (no negative margin by default) -->
|
||||
<div data-peek-inner class="grid grid-cols-1 gap-1 snap-y snap-mandatory pr-1" aria-label="Subcategories">
|
||||
{% for sub in data.subs %}
|
||||
{% set href = (url_for('market.browse.browse_sub', top_slug=data.slug, sub_slug=sub.slug) ~qs)|host%}
|
||||
{% if top_slug==(data.slug | lower) and sub_slug == sub.slug %}
|
||||
<a
|
||||
class="snap-start px-2 py-3 rounded {{select_colours}} flex flex-row gap-2"
|
||||
aria-selected="{{ 'true' if top_slug==(data.slug | lower) and sub_slug == sub.slug else 'false' }}"
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<div>{{ sub.html_label or sub.name }}</div>
|
||||
<div aria-label="{{ sub.count }} products">{{ sub.count }}</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% for sub in data.subs %}
|
||||
{% if not (top_slug==(data.slug | lower) and sub_slug == sub.slug) %}
|
||||
{% set href = (url_for('market.browse.browse_sub', top_slug=data.slug, sub_slug=sub.slug) ~ qs)|host%}
|
||||
<a
|
||||
class="snap-start px-2 py-3 rounded {{select_colours}} flex flex-row gap-2"
|
||||
aria-selected="{{ 'true' if top_slug==(data.slug | lower) and sub_slug == sub.slug else 'false' }}"
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<div>{{ sub.name }}</div>
|
||||
<div aria-label="{{ sub.count }} products">{{ sub.count }}</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{% set href = (url_for('market.browse.browse_top', top_slug=data.slug) ~ qs)|host%}
|
||||
<a class="px-2 py-1 rounded hover:bg-stone-100 block"
|
||||
href="{{ href }}"
|
||||
hx-get="{{ href }}"
|
||||
hx-target="#main-panel"
|
||||
hx-select="{{hx_select_search}}"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
>View all</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
{% include '_types/market/_admin.html' %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,6 +0,0 @@
|
||||
{% extends 'mobile/menu.html' %}
|
||||
{% block menu %}
|
||||
{% block mobile_menu %}
|
||||
{% endblock %}
|
||||
{% include '_types/market/mobile/_nav_panel.html' %}
|
||||
{% endblock %}
|
||||
@@ -1,25 +0,0 @@
|
||||
{% set oob='true' %}
|
||||
{% import '_types/product/_cart.html' as _cart %}
|
||||
{% from '_types/cart/_mini.html' import mini with context %}
|
||||
{{mini()}}
|
||||
|
||||
{{ _cart.add(d.slug, cart, oob='true')}}
|
||||
|
||||
{% from '_types/product/_cart.html' import cart_item with context %}
|
||||
|
||||
{% if cart | sum(attribute="quantity") > 0 %}
|
||||
{% if item.quantity > 0 %}
|
||||
{{ cart_item(oob='true')}}
|
||||
{% else %}
|
||||
{{ cart_item(oob='delete')}}
|
||||
{% endif %}
|
||||
{% from '_types/cart/_cart.html' import summary %}
|
||||
|
||||
{{ summary(cart, total,calendar_total, calendar_cart_entries, oob='true')}}
|
||||
|
||||
{% else %}
|
||||
{% set cart=[] %}
|
||||
{% from '_types/cart/_cart.html' import show_cart with context %}
|
||||
{{ show_cart( oob='true') }}
|
||||
|
||||
{% endif %}
|
||||
@@ -1,131 +0,0 @@
|
||||
{# Main panel fragment for HTMX navigation - product detail content #}
|
||||
{% import 'macros/stickers.html' as stick %}
|
||||
{% import '_types/product/prices.html' as prices %}
|
||||
{% set prices_ns = namespace() %}
|
||||
{{ prices.set_prices(d, prices_ns)}}
|
||||
|
||||
{# Product detail grid from content block #}
|
||||
<div class="mt-3 grid grid-cols-1 md:grid-cols-5 gap-6" data-gallery-root>
|
||||
<div class="md:col-span-2">
|
||||
{% if d.images and d.images|length > 0 %}
|
||||
<div class="relative rounded-xl overflow-hidden bg-stone-100">
|
||||
{# --- like button overlay in top-right --- #}
|
||||
{% if g.user %}
|
||||
<div class="absolute top-3 right-5 z-10 text-6xl md:text-xl">
|
||||
{% set slug = d.slug %}
|
||||
{% set liked = liked_by_current_user %}
|
||||
{% include "_types/browse/like/button.html" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<figure class="inline-block">
|
||||
<div class="relative w-full aspect-square">
|
||||
<img
|
||||
data-main-img
|
||||
src="{{ d.images[0] }}"
|
||||
alt="{{ d.title }}"
|
||||
class="w-full h-full object-contain object-top"
|
||||
loading="eager" decoding="async"
|
||||
/>
|
||||
|
||||
{% for l in d.labels %}
|
||||
<img
|
||||
src="{{ asset_url('labels/' + l + '.svg') }}"
|
||||
alt=""
|
||||
class="pointer-events-none absolute inset-0 w-full h-full object-contain object-top"
|
||||
/>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<figcaption class="mt-2 text-sm text-stone-600 text-center">
|
||||
{{ d.brand }}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
{% if d.images|length > 1 %}
|
||||
<button type="button" data-prev
|
||||
class="absolute left-2 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-12 h-12 md:w-14 md:h-14 rounded-full bg-white/90 hover:bg-white shadow-lg focus:outline-none focus:ring-2 focus:ring-stone-300 text-3xl md:text-4xl"
|
||||
title="Previous">‹</button>
|
||||
<button type="button" data-next
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-12 h-12 md:w-14 md:h-14 rounded-full bg-white/90 hover:bg-white shadow-lg focus:outline-none focus:ring-2 focus:ring-stone-300 text-3xl md:text-4xl"
|
||||
title="Next">›</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row justify-center">
|
||||
<div class="mt-3 flex gap-2 overflow-x-auto no-scrollbar">
|
||||
{% for u in d.images %}
|
||||
<button type="button" data-thumb
|
||||
class="shrink-0 rounded-lg overflow-hidden bg-stone-100 hover:opacity-90 ring-offset-2"
|
||||
title="Image {{ loop.index }}">
|
||||
<img src="{{ u }}" class="h-16 w-16 object-contain" alt="thumb {{ loop.index }}" loading="lazy" decoding="async">
|
||||
</button>
|
||||
<span data-image-src="{{ u }}" class="hidden"></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="relative aspect-square bg-stone-100 rounded-xl flex items-center justify-center text-stone-400">
|
||||
{# Even if no image, still render the like button in the corner for consistency #}
|
||||
{% if g.user %}
|
||||
<div class="absolute top-2 right-2 z-10">
|
||||
{% set slug = d.slug %}
|
||||
{% set liked = liked_by_current_user %}
|
||||
{% include "_types/browse/like/button.html" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
No image
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="p-2 flex flex-row justify-center gap-2">
|
||||
{% for s in d.stickers %}
|
||||
{{ stick.sticker(asset_url('stickers/' + s + '.svg'), s, True, size=40) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-3">
|
||||
{# Optional extras shown quietly #}
|
||||
<div class="mt-2 space-y-1 text-sm text-stone-600">
|
||||
{% if d.price_per_unit or d.price_per_unit_raw %}
|
||||
<div>Unit price: {{ prices.price_str(d.price_per_unit, d.price_per_unit_raw, d.price_per_unit_currency) }}</div>
|
||||
{% endif %}
|
||||
{% if d.case_size_raw %}
|
||||
<div>Case size: {{ d.case_size_raw }}</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
{% if d.description_short or d.description_html %}
|
||||
<div class="mt-4 text-stone-800 space-y-3">
|
||||
{% if d.description_short %}
|
||||
<p class="leading-relaxed text-lg">{{ d.description_short }}</p>
|
||||
{% endif %}
|
||||
{% if d.description_html %}
|
||||
<div class="max-w-none text-sm leading-relaxed">
|
||||
{{ d.description_html | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if d.sections and d.sections|length %}
|
||||
<div class="mt-8 space-y-3">
|
||||
{% for sec in d.sections %}
|
||||
<details class="group rounded-xl border bg-white shadow-sm open:shadow p-0">
|
||||
<summary class="cursor-pointer select-none px-4 py-3 flex items-center justify-between">
|
||||
<span class="font-medium">{{ sec.title }}</span>
|
||||
<span class="ml-2 text-xl transition-transform group-open:rotate-180">⌄</span>
|
||||
</summary>
|
||||
<div class="px-4 pb-4 max-w-none text-sm leading-relaxed">
|
||||
{{ sec.html | safe }}
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="pb-8"></div>
|
||||
@@ -1,106 +0,0 @@
|
||||
{# --- social/meta_product.html --- #}
|
||||
{# Context expected:
|
||||
site, d (Product), request
|
||||
#}
|
||||
|
||||
{# Visibility → robots: index unless soft-deleted #}
|
||||
{% set robots_here = 'noindex,nofollow' if d.deleted_at else 'index,follow' %}
|
||||
|
||||
{# Compute canonical #}
|
||||
{% set _site_url = site().url.rstrip('/') if site and site().url else '' %}
|
||||
{% set _product_path = request.path if request else ('/products/' ~ (d.slug or '')) %}
|
||||
{% set canonical = _site_url ~ _product_path if _site_url else (request.url if request else None) %}
|
||||
|
||||
{# Include common base (charset, viewport, robots default, RSS, Org/WebSite JSON-LD) #}
|
||||
{% set robots_override = robots_here %}
|
||||
{% include 'social/meta_base.html' %}
|
||||
|
||||
{# ---- Titles / descriptions ---- #}
|
||||
{% set base_product_title = d.title or base_title %}
|
||||
{% set og_title = base_product_title %}
|
||||
{% set tw_title = base_product_title %}
|
||||
|
||||
{# Description: prefer short, then HTML stripped #}
|
||||
{% set desc_source = d.description_short
|
||||
or (d.description_html|striptags if d.description_html else '') %}
|
||||
{% set description = (desc_source|trim|replace('\n',' ')|replace('\r',' ')|striptags)|truncate(160, True, '…') %}
|
||||
|
||||
{# ---- Image priority: product image, then first gallery image, then site default ---- #}
|
||||
{% set image_url = d.image
|
||||
or ((d.images|first).url if d.images and (d.images|first).url else None)
|
||||
or (site().default_image if site and site().default_image else None) %}
|
||||
|
||||
{# ---- Price / offer helpers ---- #}
|
||||
{% set price = d.special_price or d.regular_price or d.rrp %}
|
||||
{% set price_currency = d.special_price_currency or d.regular_price_currency or d.rrp_currency %}
|
||||
|
||||
{# ---- Basic meta ---- #}
|
||||
<title>{{ base_product_title }}</title>
|
||||
<meta name="description" content="{{ description }}">
|
||||
{% if canonical %}<link rel="canonical" href="{{ canonical }}">{% endif %}
|
||||
|
||||
{# ---- Open Graph ---- #}
|
||||
<meta property="og:site_name" content="{{ site().title if site and site().title else '' }}">
|
||||
<meta property="og:type" content="product">
|
||||
<meta property="og:title" content="{{ og_title }}">
|
||||
<meta property="og:description" content="{{ description }}">
|
||||
{% if canonical %}<meta property="og:url" content="{{ canonical }}">{% endif %}
|
||||
{% if image_url %}<meta property="og:image" content="{{ image_url }}">{% endif %}
|
||||
|
||||
{# Optional product OG price tags #}
|
||||
{% if price and price_currency %}
|
||||
<meta property="product:price:amount" content="{{ '%.2f'|format(price) }}">
|
||||
<meta property="product:price:currency" content="{{ price_currency }}">
|
||||
{% endif %}
|
||||
{% if d.brand %}
|
||||
<meta property="product:brand" content="{{ d.brand }}">
|
||||
{% endif %}
|
||||
{% if d.sku %}
|
||||
<meta property="product:retailer_item_id" content="{{ d.sku }}">
|
||||
{% endif %}
|
||||
|
||||
{# ---- Twitter ---- #}
|
||||
<meta name="twitter:card" content="{{ 'summary_large_image' if image_url else 'summary' }}">
|
||||
{% if site and site().twitter_site %}<meta name="twitter:site" content="{{ site().twitter_site }}">{% endif %}
|
||||
<meta name="twitter:title" content="{{ tw_title }}">
|
||||
<meta name="twitter:description" content="{{ description }}">
|
||||
{% if image_url %}<meta name="twitter:image" content="{{ image_url }}">{% endif %}
|
||||
|
||||
{# ---- JSON-LD Product ---- #}
|
||||
{% set jsonld = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Product",
|
||||
"name": d.title,
|
||||
"image": image_url,
|
||||
"description": description,
|
||||
"sku": d.sku,
|
||||
"brand": d.brand,
|
||||
"url": canonical
|
||||
} %}
|
||||
|
||||
{# Brand as proper object if present #}
|
||||
{% if d.brand %}
|
||||
{% set jsonld = jsonld | combine({
|
||||
"brand": {
|
||||
"@type": "Brand",
|
||||
"name": d.brand
|
||||
}
|
||||
}) %}
|
||||
{% endif %}
|
||||
|
||||
{# Offers if price available #}
|
||||
{% if price and price_currency %}
|
||||
{% set jsonld = jsonld | combine({
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": price,
|
||||
"priceCurrency": price_currency,
|
||||
"url": canonical,
|
||||
"availability": "https://schema.org/InStock"
|
||||
}
|
||||
}) %}
|
||||
{% endif %}
|
||||
|
||||
<script type="application/ld+json">
|
||||
{{ jsonld | tojson }}
|
||||
</script>
|
||||
@@ -1,49 +0,0 @@
|
||||
{% extends 'oob_elements.html' %}
|
||||
{# OOB elements for HTMX navigation - product extends browse so use similar structure #}
|
||||
{% import 'macros/layout.html' as layout %}
|
||||
{% import 'macros/stickers.html' as stick %}
|
||||
{% import '_types/product/prices.html' as prices %}
|
||||
{% set prices_ns = namespace() %}
|
||||
{{ prices.set_prices(d, prices_ns)}}
|
||||
|
||||
{# Import shared OOB macros #}
|
||||
{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %}
|
||||
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
|
||||
|
||||
|
||||
|
||||
{% block oobs %}
|
||||
{% from '_types/market/header/_header.html' import header_row with context %}
|
||||
{{ header_row(oob=True) }}
|
||||
|
||||
{% from '_types/root/_n/macros.html' import oob_header with context %}
|
||||
{{oob_header('market-header-child', 'product-header-child', '_types/product/header/_header.html')}}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
{% block mobile_menu %}
|
||||
{% include '_types/market/mobile/_nav_panel.html' %}
|
||||
{% include '_types/browse/_admin.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block filter %}
|
||||
{% call layout.details() %}
|
||||
{% call layout.summary('coop-child-header') %}
|
||||
{% endcall %}
|
||||
{% call layout.menu('blog-child-menu') %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
|
||||
{% call layout.details() %}
|
||||
{% call layout.summary('product-child-header') %}
|
||||
{% endcall %}
|
||||
{% call layout.menu('item-child-menu') %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/product/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
@@ -1,33 +0,0 @@
|
||||
{% import '_types/product/_cart.html' as _cart %}
|
||||
{# ---- Price block ---- #}
|
||||
{% import '_types/product/prices.html' as prices %}
|
||||
{% set prices_ns = namespace() %}
|
||||
{{ prices.set_prices(d, prices_ns)}}
|
||||
|
||||
<div class="flex flex-row items-center justify-between md:gap-2 md:px-2">
|
||||
{{ _cart.add(d.slug, cart)}}
|
||||
|
||||
{% if prices_ns.sp_val %}
|
||||
<div class="text-md font-bold text-emerald-700">
|
||||
Special price
|
||||
</div>
|
||||
<div class="text-xl font-semibold text-emerald-700">
|
||||
{{ prices.price_str(prices_ns.sp_val, prices_ns.sp_raw, prices_ns.sp_cur) }}
|
||||
</div>
|
||||
{% if prices_ns.sp_val and prices_ns.rp_val %}
|
||||
<div class="text-base text-md line-through text-stone-500">
|
||||
{{ prices.price_str(prices_ns.rp_val, prices_ns.rp_raw, prices_ns.rp_cur) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% elif prices_ns.rp_val %}
|
||||
<div class="hidden md:block text-xl font-bold">
|
||||
Our price
|
||||
</div>
|
||||
<div class="text-xl font-semibold">
|
||||
{{ prices.price_str(prices_ns.rp_val, prices_ns.rp_raw, prices_ns.rp_cur) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ prices.rrp(prices_ns) }}
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
<i class="fa fa-shopping-bag" aria-hidden="true"></i>
|
||||
<div>{{ d.title }}</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user