Compare commits
97 Commits
74d6071ad4
...
decoupling
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90bb061c08 | ||
|
|
923eea339e | ||
|
|
2a90a21c64 | ||
|
|
85ffe34fc9 | ||
|
|
3db0ca23c7 | ||
|
|
7949718383 | ||
|
|
1ff4f64d5d | ||
|
|
4d2a14cdcb | ||
|
|
395d40c7f7 | ||
|
|
07ed2980fa | ||
|
|
f34d35b9c4 | ||
|
|
49a4780efe | ||
|
|
d1a6690cc3 | ||
|
|
93f830ff13 | ||
|
|
06e700820e | ||
|
|
d92d4840ed | ||
|
|
291c829c7f | ||
|
|
e0b640f15b | ||
|
|
25228881aa | ||
|
|
97bc694ff0 | ||
|
|
d014203776 | ||
|
|
f53d2841e9 | ||
|
|
3c9ff1210a | ||
|
|
4a37f281d4 | ||
|
|
e49668b301 | ||
|
|
005c04e5f9 | ||
|
|
e479730f3f | ||
|
|
0954dc0505 | ||
|
|
84f13153a6 | ||
|
|
09ca461df6 | ||
|
|
0e2f0b818e | ||
|
|
c147900072 | ||
|
|
fc8bbc927b | ||
|
|
8f44f99232 | ||
|
|
05d7ccd422 | ||
|
|
74fc2f4fb9 | ||
|
|
f0743a5949 | ||
|
|
b91abd1a34 | ||
|
|
6e1a7cfc5b | ||
|
|
973d639f0b | ||
|
|
2fd05faccb | ||
|
|
1919258dcd | ||
|
|
b027bf5bdf | ||
|
|
9eab90a3ae | ||
|
|
691cb9c2ab | ||
|
|
6fa2b73072 | ||
|
|
0fd1e5be99 | ||
|
|
2205e23e56 | ||
|
|
d4aa3ea4d2 | ||
|
|
2ea879db44 | ||
|
|
9d8e21001b | ||
|
|
26bc7c885a | ||
|
|
bddc3cb122 | ||
|
|
72a30b90f6 | ||
|
|
7261645d1e | ||
|
|
edaa028b67 | ||
|
|
4be16b92cd | ||
|
|
fd1575ed04 | ||
|
|
5e26b5ec63 | ||
|
|
715db8e493 | ||
|
|
9b4a63ff1e | ||
|
|
a8e587ebb3 | ||
|
|
139eb3ac1f | ||
|
|
dcb93269fc | ||
|
|
3c87832fdf | ||
|
|
555ac6a152 | ||
|
|
800d4c1822 | ||
|
|
0fcfed4546 | ||
|
|
2cc646b5c6 | ||
|
|
9ef6f47bf1 | ||
|
|
504ada5d9b | ||
|
|
a5ad2af550 | ||
|
|
43b98dd45a | ||
|
|
20daef8808 | ||
|
|
930ffae854 | ||
|
|
460b909392 | ||
|
|
dd3bf455ef | ||
|
|
8db76c7099 | ||
|
|
ab93ca2b84 | ||
|
|
599ba37d61 | ||
|
|
1d891a5cbf | ||
|
|
4671bc616e | ||
|
|
c05e6e5baa | ||
|
|
8bbf70eafd | ||
|
|
bf0996e013 | ||
|
|
0811b52869 | ||
|
|
81526d5a9f | ||
|
|
57a7ee3358 | ||
|
|
a80547c7fa | ||
|
|
3bddee0d94 | ||
|
|
ade59dcbb4 | ||
|
|
e6fa255941 | ||
|
|
a57ea63b92 | ||
|
|
e42a91982f | ||
|
|
806efadb93 | ||
|
|
93aa61494a | ||
|
|
05cba16cef |
@@ -8,7 +8,7 @@ env:
|
||||
REGISTRY: registry.rose-ash.com:5000
|
||||
IMAGE: market
|
||||
REPO_DIR: /root/rose-ash/market
|
||||
COOP_DIR: /root/coop
|
||||
COOP_DIR: /root/rose-ash
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
@@ -40,11 +40,11 @@ jobs:
|
||||
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
|
||||
for sibling in blog market cart events federation; do
|
||||
rm -rf \$sibling
|
||||
done
|
||||
# Copy non-self sibling models for cross-domain imports
|
||||
for sibling in blog market cart events; do
|
||||
for sibling in blog market cart events federation; do
|
||||
[ \"\$sibling\" = \"${{ env.IMAGE }}\" ] && continue
|
||||
repo=/root/rose-ash/\$sibling
|
||||
if [ -d \$repo/.git ]; then
|
||||
|
||||
52
README.md
52
README.md
@@ -4,7 +4,7 @@ Product browsing and marketplace service for the Rose Ash cooperative. Displays
|
||||
|
||||
## Architecture
|
||||
|
||||
One of four Quart microservices sharing a single PostgreSQL database:
|
||||
One of five Quart microservices sharing a single PostgreSQL database:
|
||||
|
||||
| App | Port | Domain |
|
||||
|-----|------|--------|
|
||||
@@ -12,6 +12,7 @@ One of four Quart microservices sharing a single PostgreSQL database:
|
||||
| **market** | 8001 | Product browsing, Suma scraping |
|
||||
| cart | 8002 | Shopping cart, checkout, orders |
|
||||
| events | 8003 | Calendars, bookings, tickets |
|
||||
| federation | 8004 | ActivityPub, fediverse social |
|
||||
|
||||
## Structure
|
||||
|
||||
@@ -19,9 +20,7 @@ One of four Quart microservices sharing a single PostgreSQL database:
|
||||
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)
|
||||
models/ # Market-domain models (+ re-export stubs)
|
||||
bp/ # Blueprints
|
||||
market/ # Market root, navigation, category listing
|
||||
browse/ # Product browsing with filters and infinite scroll
|
||||
@@ -29,39 +28,21 @@ bp/ # Blueprints
|
||||
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
|
||||
services/ # register_domain_services() — wires market + cart
|
||||
shared/ # Submodule -> git.rose-ash.com/coop/shared.git
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
## Cross-Domain Communication
|
||||
|
||||
**Cross-app model imports:**
|
||||
- `blog.models.ghost_content.Post` — `app.py` hydrates page data for marketplace views
|
||||
|
||||
**Glue services:**
|
||||
- `glue.services.navigation.get_navigation_tree` — context processor builds site nav
|
||||
|
||||
**Internal APIs:**
|
||||
- Calls `GET /internal/cart/summary` — context processor for cart widget
|
||||
- `services.cart.*` — cart summary via CartService protocol
|
||||
- `services.federation.*` — AP publishing via FederationService protocol
|
||||
- `shared.services.navigation` — site navigation tree
|
||||
|
||||
## Scraping
|
||||
|
||||
```bash
|
||||
# Full scrape (Suma Wholesale catalogue)
|
||||
bash scrape.sh
|
||||
|
||||
# Test scraping (limited)
|
||||
bash scrape-test.sh
|
||||
bash scrape.sh # Full Suma Wholesale catalogue
|
||||
bash scrape-test.sh # Limited test scrape
|
||||
```
|
||||
|
||||
## Running
|
||||
@@ -70,15 +51,6 @@ bash scrape-test.sh
|
||||
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
|
||||
|
||||
```bash
|
||||
docker build -t market .
|
||||
docker run -p 8001:8000 --env-file .env market
|
||||
hypercorn app:app --bind 0.0.0.0:8001
|
||||
```
|
||||
|
||||
84
app.py
84
app.py
@@ -1,23 +1,23 @@
|
||||
from __future__ import annotations
|
||||
import path_setup # noqa: F401 # adds shared_lib to sys.path
|
||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from quart import g, abort, render_template, make_response
|
||||
from quart import g, abort, request
|
||||
from jinja2 import FileSystemLoader, ChoiceLoader
|
||||
from sqlalchemy import select
|
||||
|
||||
from shared.infrastructure.factory import create_base_app
|
||||
from shared.config import config
|
||||
|
||||
from bp import register_market_bp
|
||||
from bp import register_market_bp, register_all_markets, register_page_markets, register_fragments
|
||||
|
||||
|
||||
async def market_context() -> dict:
|
||||
"""
|
||||
Market app context processor.
|
||||
|
||||
- menu_items: direct DB query
|
||||
- nav_tree_html: fetched from blog as fragment
|
||||
- cart_count/cart_total: via cart service (includes calendar entries)
|
||||
- cart: direct ORM query (templates need .product relationship)
|
||||
"""
|
||||
@@ -25,11 +25,17 @@ async def market_context() -> dict:
|
||||
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.infrastructure.fragments import fetch_fragment
|
||||
from shared.models.market import CartItem
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
ctx = await base_context()
|
||||
|
||||
ctx["nav_tree_html"] = await fetch_fragment(
|
||||
"blog", "nav-tree",
|
||||
params={"app_name": "market", "path": request.path},
|
||||
)
|
||||
# Fallback for _nav.html when nav-tree fragment fetch fails
|
||||
ctx["menu_items"] = await get_navigation_tree(g.s)
|
||||
|
||||
ident = current_cart_identity()
|
||||
@@ -77,19 +83,37 @@ def create_app() -> "Quart":
|
||||
app.jinja_loader,
|
||||
])
|
||||
|
||||
# All markets: / — global view across all pages
|
||||
app.register_blueprint(
|
||||
register_all_markets(),
|
||||
url_prefix="/",
|
||||
)
|
||||
|
||||
# Page markets: /<slug>/ — markets for a single page
|
||||
app.register_blueprint(
|
||||
register_page_markets(),
|
||||
url_prefix="/<slug>",
|
||||
)
|
||||
|
||||
# Market blueprint nested under post slug: /<page_slug>/<market_slug>/
|
||||
app.register_blueprint(
|
||||
register_market_bp(
|
||||
url_prefix="/",
|
||||
title=config()["coop_title"],
|
||||
title=config()["market_title"],
|
||||
),
|
||||
url_prefix="/<page_slug>/<market_slug>",
|
||||
)
|
||||
|
||||
# --- Auto-inject page_slug and market_slug into url_for() calls ---
|
||||
app.register_blueprint(register_fragments())
|
||||
|
||||
# --- Auto-inject slugs into url_for() calls ---
|
||||
@app.url_value_preprocessor
|
||||
def pull_slugs(endpoint, values):
|
||||
if values:
|
||||
# page_markets blueprint uses "slug"
|
||||
if "slug" in values:
|
||||
g.post_slug = values.pop("slug")
|
||||
# market blueprint uses "page_slug" / "market_slug"
|
||||
if "page_slug" in values:
|
||||
g.post_slug = values.pop("page_slug")
|
||||
if "market_slug" in values:
|
||||
@@ -97,18 +121,22 @@ def create_app() -> "Quart":
|
||||
|
||||
@app.url_defaults
|
||||
def inject_slugs(endpoint, values):
|
||||
for attr, param in [("post_slug", "page_slug"), ("market_slug", "market_slug")]:
|
||||
val = g.get(attr)
|
||||
if val and param not in values:
|
||||
if app.url_map.is_endpoint_expecting(endpoint, param):
|
||||
values[param] = val
|
||||
slug = g.get("post_slug")
|
||||
if slug:
|
||||
for param in ("slug", "page_slug"):
|
||||
if param not in values and app.url_map.is_endpoint_expecting(endpoint, param):
|
||||
values[param] = slug
|
||||
market_slug = g.get("market_slug")
|
||||
if market_slug and "market_slug" not in values:
|
||||
if app.url_map.is_endpoint_expecting(endpoint, "market_slug"):
|
||||
values["market_slug"] = market_slug
|
||||
|
||||
# --- Load post and market data ---
|
||||
@app.before_request
|
||||
async def hydrate_market():
|
||||
post_slug = getattr(g, "post_slug", None)
|
||||
market_slug = getattr(g, "market_slug", None)
|
||||
if not post_slug or not market_slug:
|
||||
if not post_slug:
|
||||
return
|
||||
|
||||
# Load post by slug via blog service
|
||||
@@ -129,7 +157,10 @@ def create_app() -> "Quart":
|
||||
},
|
||||
}
|
||||
|
||||
# Load market scoped to post (container pattern)
|
||||
# Only load market when market_slug is present (/<page_slug>/<market_slug>/)
|
||||
if not market_slug:
|
||||
return
|
||||
|
||||
market = (
|
||||
await g.s.execute(
|
||||
select(MarketPlace).where(
|
||||
@@ -151,33 +182,6 @@ def create_app() -> "Quart":
|
||||
return {}
|
||||
return {**post_data}
|
||||
|
||||
# --- Root route: market listing ---
|
||||
@app.get("/")
|
||||
async def markets_listing():
|
||||
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()
|
||||
|
||||
# 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",
|
||||
markets=markets,
|
||||
)
|
||||
return await make_response(html)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
from .market.routes import register as register_market_bp
|
||||
from .product.routes import register as register_product
|
||||
from .all_markets.routes import register as register_all_markets
|
||||
from .page_markets.routes import register as register_page_markets
|
||||
from .fragments import register_fragments
|
||||
|
||||
0
bp/all_markets/__init__.py
Normal file
0
bp/all_markets/__init__.py
Normal file
74
bp/all_markets/routes.py
Normal file
74
bp/all_markets/routes.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
All-markets blueprint — shows markets across ALL pages.
|
||||
|
||||
Mounted at / (root of market app). No slug context.
|
||||
|
||||
Routes:
|
||||
GET / — full page with first page of markets
|
||||
GET /all-markets — HTMX fragment for infinite scroll
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, request, render_template, make_response
|
||||
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.services.registry import services
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("all_markets", __name__)
|
||||
|
||||
async def _load_markets(page, per_page=20):
|
||||
"""Load all markets + page info for container badges."""
|
||||
markets, has_more = await services.market.list_marketplaces(
|
||||
g.s, page=page, per_page=per_page,
|
||||
)
|
||||
|
||||
# Batch-load page info for container_ids
|
||||
page_info = {}
|
||||
if markets:
|
||||
post_ids = list({
|
||||
m.container_id for m in markets
|
||||
if m.container_type == "page"
|
||||
})
|
||||
if post_ids:
|
||||
posts = await services.blog.get_posts_by_ids(g.s, post_ids)
|
||||
for p in posts:
|
||||
page_info[p.id] = {"title": p.title, "slug": p.slug}
|
||||
|
||||
return markets, has_more, page_info
|
||||
|
||||
@bp.get("/")
|
||||
async def index():
|
||||
page = int(request.args.get("page", 1))
|
||||
markets, has_more, page_info = await _load_markets(page)
|
||||
|
||||
ctx = dict(
|
||||
markets=markets,
|
||||
has_more=has_more,
|
||||
page_info=page_info,
|
||||
page=page,
|
||||
)
|
||||
|
||||
if is_htmx_request():
|
||||
html = await render_template("_types/all_markets/_main_panel.html", **ctx)
|
||||
else:
|
||||
html = await render_template("_types/all_markets/index.html", **ctx)
|
||||
|
||||
return await make_response(html, 200)
|
||||
|
||||
@bp.get("/all-markets")
|
||||
async def markets_fragment():
|
||||
page = int(request.args.get("page", 1))
|
||||
markets, has_more, page_info = await _load_markets(page)
|
||||
|
||||
html = await render_template(
|
||||
"_types/all_markets/_cards.html",
|
||||
markets=markets,
|
||||
has_more=has_more,
|
||||
page_info=page_info,
|
||||
page=page,
|
||||
)
|
||||
return await make_response(html, 200)
|
||||
|
||||
return bp
|
||||
@@ -291,6 +291,22 @@ async def _create_product_from_payload(session: AsyncSession, payload: Dict[str,
|
||||
#await session.flush() # get p.id
|
||||
_replace_children(p, payload)
|
||||
await session.flush()
|
||||
|
||||
# Publish to federation inline
|
||||
from shared.services.federation_publish import try_publish
|
||||
await try_publish(
|
||||
session,
|
||||
user_id=getattr(p, "user_id", None),
|
||||
activity_type="Create",
|
||||
object_type="Object",
|
||||
object_data={
|
||||
"name": p.title or "",
|
||||
"summary": getattr(p, "description", "") or "",
|
||||
},
|
||||
source_type="Product",
|
||||
source_id=p.id,
|
||||
)
|
||||
|
||||
return p
|
||||
|
||||
# ---- API --------------------------------------------------------------------
|
||||
|
||||
@@ -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]:
|
||||
|
||||
1
bp/fragments/__init__.py
Normal file
1
bp/fragments/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .routes import register as register_fragments
|
||||
54
bp/fragments/routes.py
Normal file
54
bp/fragments/routes.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Market app fragment endpoints.
|
||||
|
||||
Exposes HTML fragments at ``/internal/fragments/<type>`` for consumption
|
||||
by other coop apps via the fragment client.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, Response, g, render_template, request
|
||||
|
||||
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||
from shared.services.registry import services
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||
|
||||
_handlers: dict[str, object] = {}
|
||||
|
||||
@bp.before_request
|
||||
async def _require_fragment_header():
|
||||
if not request.headers.get(FRAGMENT_HEADER):
|
||||
return Response("", status=403)
|
||||
|
||||
@bp.get("/<fragment_type>")
|
||||
async def get_fragment(fragment_type: str):
|
||||
handler = _handlers.get(fragment_type)
|
||||
if handler is None:
|
||||
return Response("", status=200, content_type="text/html")
|
||||
html = await handler()
|
||||
return Response(html, status=200, content_type="text/html")
|
||||
|
||||
# --- container-nav fragment: market links --------------------------------
|
||||
|
||||
async def _container_nav_handler():
|
||||
container_type = request.args.get("container_type", "page")
|
||||
container_id = int(request.args.get("container_id", 0))
|
||||
post_slug = request.args.get("post_slug", "")
|
||||
|
||||
markets = await services.market.marketplaces_for_container(
|
||||
g.s, container_type, container_id,
|
||||
)
|
||||
if not markets:
|
||||
return ""
|
||||
return await render_template(
|
||||
"fragments/container_nav_markets.html",
|
||||
markets=markets, post_slug=post_slug,
|
||||
)
|
||||
|
||||
_handlers["container-nav"] = _container_nav_handler
|
||||
|
||||
bp._fragment_handlers = _handlers
|
||||
|
||||
return bp
|
||||
@@ -27,7 +27,7 @@ def register(url_prefix, title):
|
||||
post_data = getattr(g, "post_data", None) or {}
|
||||
return {
|
||||
**post_data,
|
||||
"coop_title": market.name if market else title,
|
||||
"market_title": market.name if market else title,
|
||||
"categories": (await get_nav(g.s, market_id=market_id))["cats"],
|
||||
"qs": makeqs_factory()(),
|
||||
"market": market,
|
||||
|
||||
0
bp/page_markets/__init__.py
Normal file
0
bp/page_markets/__init__.py
Normal file
65
bp/page_markets/routes.py
Normal file
65
bp/page_markets/routes.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Page-markets blueprint — shows markets for a single page.
|
||||
|
||||
Mounted at /<slug> (page-scoped). Requires g.post_data from hydrate_post.
|
||||
|
||||
Routes:
|
||||
GET /<slug>/ — full page scoped to this page
|
||||
GET /<slug>/page-markets — HTMX fragment for infinite scroll
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, request, render_template, make_response
|
||||
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.services.registry import services
|
||||
|
||||
|
||||
def register() -> Blueprint:
|
||||
bp = Blueprint("page_markets", __name__)
|
||||
|
||||
async def _load_markets(post_id, page, per_page=20):
|
||||
"""Load markets for this page's container."""
|
||||
markets, has_more = await services.market.list_marketplaces(
|
||||
g.s, "page", post_id, page=page, per_page=per_page,
|
||||
)
|
||||
return markets, has_more
|
||||
|
||||
@bp.get("/")
|
||||
async def index():
|
||||
post = g.post_data["post"]
|
||||
page = int(request.args.get("page", 1))
|
||||
|
||||
markets, has_more = await _load_markets(post["id"], page)
|
||||
|
||||
ctx = dict(
|
||||
markets=markets,
|
||||
has_more=has_more,
|
||||
page_info={},
|
||||
page=page,
|
||||
)
|
||||
|
||||
if is_htmx_request():
|
||||
html = await render_template("_types/page_markets/_main_panel.html", **ctx)
|
||||
else:
|
||||
html = await render_template("_types/page_markets/index.html", **ctx)
|
||||
|
||||
return await make_response(html, 200)
|
||||
|
||||
@bp.get("/page-markets")
|
||||
async def markets_fragment():
|
||||
post = g.post_data["post"]
|
||||
page = int(request.args.get("page", 1))
|
||||
|
||||
markets, has_more = await _load_markets(post["id"], page)
|
||||
|
||||
html = await render_template(
|
||||
"_types/page_markets/_cards.html",
|
||||
markets=markets,
|
||||
has_more=has_more,
|
||||
page_info={},
|
||||
page=page,
|
||||
)
|
||||
return await make_response(html, 200)
|
||||
|
||||
return bp
|
||||
@@ -22,20 +22,26 @@ from .services.product_operations import toggle_product_like, massage_full_produ
|
||||
|
||||
|
||||
def register():
|
||||
bp = Blueprint("product", __name__, url_prefix="/product/<slug>")
|
||||
bp = Blueprint("product", __name__, url_prefix="/product/<product_slug>")
|
||||
@bp.url_value_preprocessor
|
||||
def pull_blog(endpoint, values):
|
||||
g.product_slug = values.get("slug")
|
||||
def pull_product_slug(endpoint, values):
|
||||
# product_slug is distinct from the app-level "slug"/"page_slug" params,
|
||||
# so it won't be popped by the app-level preprocessor in app.py.
|
||||
g.product_slug = values.pop("product_slug", None)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# BEFORE REQUEST: Slug or numeric ID resolver
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@bp.before_request
|
||||
async def resolve_product():
|
||||
from quart import request as req
|
||||
|
||||
raw_slug = g.product_slug = getattr(g, "product_slug", None)
|
||||
if raw_slug is None:
|
||||
return
|
||||
|
||||
is_post = req.method == "POST"
|
||||
|
||||
# 1. If slug is INT → load product by ID
|
||||
if raw_slug.isdigit():
|
||||
product_id = int(raw_slug)
|
||||
@@ -53,20 +59,24 @@ def register():
|
||||
g.item_data = {"d": d, "slug": product["slug"], "liked": False}
|
||||
return
|
||||
|
||||
# Not deleted → redirect to canonical slug
|
||||
canon = canonical_html_slug(product["slug"])
|
||||
return redirect(
|
||||
host_url(url_for("market.browse.product.product_detail", slug=canon))
|
||||
)
|
||||
# Not deleted → redirect to canonical slug (GET only)
|
||||
if not is_post:
|
||||
canon = canonical_html_slug(product["slug"])
|
||||
return redirect(
|
||||
host_url(url_for("market.browse.product.product_detail", product_slug=canon))
|
||||
)
|
||||
|
||||
g.item_data = {"d": product, "slug": product["slug"], "liked": False}
|
||||
return
|
||||
|
||||
# 2. Normal slug-based behaviour
|
||||
if is_product_blocked(raw_slug):
|
||||
abort(404)
|
||||
|
||||
canon = canonical_html_slug(raw_slug)
|
||||
if canon != raw_slug:
|
||||
if canon != raw_slug and not is_post:
|
||||
return redirect(
|
||||
host_url(url_for("product.product_detail", slug=canon))
|
||||
host_url(url_for("market.browse.product.product_detail", product_slug=canon))
|
||||
)
|
||||
|
||||
# hydrate full product
|
||||
@@ -75,7 +85,7 @@ def register():
|
||||
)
|
||||
if not d:
|
||||
abort(404)
|
||||
g.item_data = {"d": d, "slug": canon, "liked": d["is_liked"]}
|
||||
g.item_data = {"d": d, "slug": canon, "liked": d.get("is_liked", False)}
|
||||
|
||||
@bp.context_processor
|
||||
def context():
|
||||
@@ -93,7 +103,7 @@ def register():
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@bp.get("/")
|
||||
@cache_page(tag="browse")
|
||||
async def product_detail(slug: str):
|
||||
async def product_detail():
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
# Determine which template to use based on request type
|
||||
@@ -108,9 +118,8 @@ def register():
|
||||
|
||||
@bp.post("/like/toggle/")
|
||||
@clear_cache(tag="browse", tag_scope="user")
|
||||
async def like_toggle(slug):
|
||||
# Use slug from URL parameter (set by url_prefix="/product/<slug>")
|
||||
product_slug = slug
|
||||
async def like_toggle():
|
||||
product_slug = g.product_slug
|
||||
|
||||
if not g.user:
|
||||
html = await render_template(
|
||||
@@ -139,7 +148,7 @@ def register():
|
||||
|
||||
|
||||
@bp.get("/admin/")
|
||||
async def admin(slug: str):
|
||||
async def admin():
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
if not is_htmx_request():
|
||||
@@ -159,7 +168,8 @@ def register():
|
||||
|
||||
@bp.post("/cart/")
|
||||
@clear_cache(tag="browse", tag_scope="user")
|
||||
async def cart(slug: str):
|
||||
async def cart():
|
||||
slug = g.product_slug
|
||||
# make sure product exists (we *allow* deleted_at != None later if you want)
|
||||
product_id = await g.s.scalar(
|
||||
select(Product.id).where(
|
||||
@@ -242,19 +252,12 @@ def register():
|
||||
|
||||
# no explicit commit; your session middleware should handle it
|
||||
|
||||
# htmx support (optional)
|
||||
# htmx response: OOB-swap mini cart + product buttons
|
||||
if request.headers.get("HX-Request") == "true":
|
||||
# You can return a small fragment or mini-cart here
|
||||
|
||||
return await render_template(
|
||||
"_types/product/_added.html",
|
||||
cart=g.cart,
|
||||
item=ci,
|
||||
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
|
||||
|
||||
@@ -3,16 +3,17 @@ 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
|
||||
market_root: /market
|
||||
market_title: Market
|
||||
blog_root: /
|
||||
blog_title: all the news
|
||||
cart_root: /cart
|
||||
app_urls:
|
||||
coop: "http://localhost:8000"
|
||||
blog: "http://localhost:8000"
|
||||
market: "http://localhost:8001"
|
||||
cart: "http://localhost:8002"
|
||||
events: "http://localhost:8003"
|
||||
federation: "http://localhost:8004"
|
||||
cache:
|
||||
fs_root: _snapshot # <- absolute path to your snapshot dir
|
||||
categories:
|
||||
|
||||
@@ -24,3 +24,6 @@ def register_domain_services() -> None:
|
||||
services.calendar = SqlCalendarService()
|
||||
if not services.has("cart"):
|
||||
services.cart = SqlCartService()
|
||||
if not services.has("federation"):
|
||||
from shared.services.federation_impl import SqlFederationService
|
||||
services.federation = SqlFederationService()
|
||||
|
||||
2
shared
2
shared
Submodule shared updated: d404349806...9ab4b7b3fe
33
templates/_types/all_markets/_card.html
Normal file
33
templates/_types/all_markets/_card.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{# Card for a single market in the global listing #}
|
||||
{% set pi = page_info.get(market.container_id, {}) %}
|
||||
{% set page_slug = pi.get('slug', '') %}
|
||||
{% set page_title = pi.get('title') %}
|
||||
{% if page_slug %}
|
||||
{% set market_href = market_url('/' ~ page_slug ~ '/' ~ market.slug ~ '/') %}
|
||||
{% else %}
|
||||
{% set market_href = '' %}
|
||||
{% endif %}
|
||||
<article class="rounded-xl bg-white shadow-sm border border-stone-200 p-5 flex flex-col justify-between hover:border-stone-400 transition-colors">
|
||||
<div>
|
||||
{% if market_href %}
|
||||
<a href="{{ market_href }}" class="hover:text-emerald-700">
|
||||
<h2 class="text-lg font-semibold text-stone-900">{{ market.name }}</h2>
|
||||
</a>
|
||||
{% else %}
|
||||
<h2 class="text-lg font-semibold text-stone-900">{{ market.name }}</h2>
|
||||
{% endif %}
|
||||
|
||||
{% if market.description %}
|
||||
<p class="text-sm text-stone-600 mt-1 line-clamp-2">{{ market.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-1.5 mt-3">
|
||||
{% if page_title %}
|
||||
<a href="{{ market_url('/' ~ page_slug ~ '/') }}"
|
||||
class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200">
|
||||
{{ page_title }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
18
templates/_types/all_markets/_cards.html
Normal file
18
templates/_types/all_markets/_cards.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% for market in markets %}
|
||||
{% include "_types/all_markets/_card.html" %}
|
||||
{% endfor %}
|
||||
{% if has_more %}
|
||||
{# Infinite scroll sentinel #}
|
||||
{% set next_url = url_for('all_markets.markets_fragment', page=page + 1)|host %}
|
||||
<div
|
||||
id="sentinel-{{ page }}"
|
||||
class="h-4 opacity-0 pointer-events-none"
|
||||
hx-get="{{ next_url }}"
|
||||
hx-trigger="intersect once delay:250ms"
|
||||
hx-swap="outerHTML"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="text-center text-xs text-stone-400">loading...</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
12
templates/_types/all_markets/_main_panel.html
Normal file
12
templates/_types/all_markets/_main_panel.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{# Markets grid #}
|
||||
{% if markets %}
|
||||
<div class="max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{% include "_types/all_markets/_cards.html" %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-3 py-12 text-center text-stone-400">
|
||||
<i class="fa fa-store text-4xl mb-3" aria-hidden="true"></i>
|
||||
<p class="text-lg">No markets available</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="pb-8"></div>
|
||||
7
templates/_types/all_markets/index.html
Normal file
7
templates/_types/all_markets/index.html
Normal file
@@ -0,0 +1,7 @@
|
||||
{% extends '_types/root/_index.html' %}
|
||||
|
||||
{% block meta %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/all_markets/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
7
templates/_types/browse/_admin.html
Normal file
7
templates/_types/browse/_admin.html
Normal file
@@ -0,0 +1,7 @@
|
||||
{% 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', product_slug=slug)
|
||||
)}}
|
||||
{% endif %}
|
||||
5
templates/_types/browse/_main_panel.html
Normal file
5
templates/_types/browse/_main_panel.html
Normal file
@@ -0,0 +1,5 @@
|
||||
|
||||
<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>
|
||||
104
templates/_types/browse/_product_card.html
Normal file
104
templates/_types/browse/_product_card.html
Normal file
@@ -0,0 +1,104 @@
|
||||
{% 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', product_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>
|
||||
107
templates/_types/browse/_product_cards.html
Normal file
107
templates/_types/browse/_product_cards.html
Normal file
@@ -0,0 +1,107 @@
|
||||
{% 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 %}
|
||||
|
||||
40
templates/_types/browse/desktop/_category_selector.html
Normal file
40
templates/_types/browse/desktop/_category_selector.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{# Categories #}
|
||||
<nav aria-label="Categories"
|
||||
class="rounded-xl border bg-white shadow-sm min-h-0">
|
||||
<ul class="divide-y">
|
||||
{% set top_active = (sub_slug is not defined or sub_slug is none or sub_slug == '') %}
|
||||
{% 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 = (sub.slug == sub_slug) %}
|
||||
{% 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>
|
||||
40
templates/_types/browse/desktop/_filter/brand.html
Normal file
40
templates/_types/browse/desktop/_filter/brand.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{# 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>
|
||||
44
templates/_types/browse/desktop/_filter/labels.html
Normal file
44
templates/_types/browse/desktop/_filter/labels.html
Normal file
@@ -0,0 +1,44 @@
|
||||
|
||||
|
||||
|
||||
{% 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>
|
||||
38
templates/_types/browse/desktop/_filter/like.html
Normal file
38
templates/_types/browse/desktop/_filter/like.html
Normal file
@@ -0,0 +1,38 @@
|
||||
{% 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>
|
||||
44
templates/_types/browse/desktop/_filter/search.html
Normal file
44
templates/_types/browse/desktop/_filter/search.html
Normal file
@@ -0,0 +1,44 @@
|
||||
|
||||
{% 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 %}
|
||||
34
templates/_types/browse/desktop/_filter/sort.html
Normal file
34
templates/_types/browse/desktop/_filter/sort.html
Normal file
@@ -0,0 +1,34 @@
|
||||
|
||||
|
||||
|
||||
|
||||
{% 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>
|
||||
46
templates/_types/browse/desktop/_filter/stickers.html
Normal file
46
templates/_types/browse/desktop/_filter/stickers.html
Normal file
@@ -0,0 +1,46 @@
|
||||
|
||||
|
||||
|
||||
|
||||
{% 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>
|
||||
37
templates/_types/browse/desktop/menu.html
Normal file
37
templates/_types/browse/desktop/menu.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{% 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>
|
||||
13
templates/_types/browse/index.html
Normal file
13
templates/_types/browse/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{% 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 %}
|
||||
20
templates/_types/browse/like/button.html
Normal file
20
templates/_types/browse/like/button.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<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', product_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>
|
||||
40
templates/_types/browse/mobile/_filter/brand_ul.html
Normal file
40
templates/_types/browse/mobile/_filter/brand_ul.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<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>
|
||||
30
templates/_types/browse/mobile/_filter/index.html
Normal file
30
templates/_types/browse/mobile/_filter/index.html
Normal file
@@ -0,0 +1,30 @@
|
||||
|
||||
{% 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" %}
|
||||
|
||||
47
templates/_types/browse/mobile/_filter/labels.html
Normal file
47
templates/_types/browse/mobile/_filter/labels.html
Normal file
@@ -0,0 +1,47 @@
|
||||
{% 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>
|
||||
40
templates/_types/browse/mobile/_filter/like.html
Normal file
40
templates/_types/browse/mobile/_filter/like.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{% 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>
|
||||
40
templates/_types/browse/mobile/_filter/search.html
Normal file
40
templates/_types/browse/mobile/_filter/search.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{% 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 %}
|
||||
33
templates/_types/browse/mobile/_filter/sort_ul.html
Normal file
33
templates/_types/browse/mobile/_filter/sort_ul.html
Normal file
@@ -0,0 +1,33 @@
|
||||
|
||||
|
||||
|
||||
{% 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>
|
||||
50
templates/_types/browse/mobile/_filter/stickers.html
Normal file
50
templates/_types/browse/mobile/_filter/stickers.html
Normal file
@@ -0,0 +1,50 @@
|
||||
{% 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>
|
||||
120
templates/_types/browse/mobile/_filter/summary.html
Normal file
120
templates/_types/browse/mobile/_filter/summary.html
Normal file
@@ -0,0 +1,120 @@
|
||||
{% 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 %}
|
||||
7
templates/_types/market/_admin.html
Normal file
7
templates/_types/market/_admin.html
Normal file
@@ -0,0 +1,7 @@
|
||||
{% 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 %}
|
||||
23
templates/_types/market/_main_panel.html
Normal file
23
templates/_types/market/_main_panel.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{# 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>
|
||||
17
templates/_types/market/_title.html
Normal file
17
templates/_types/market/_title.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<div
|
||||
class="font-bold text-xl flex-shrink-0 flex gap-2 items-center">
|
||||
<div>
|
||||
<i class="fa fa-shop"></i>
|
||||
{{ market_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
templates/_types/market/admin/_main_panel.html
Normal file
1
templates/_types/market/admin/_main_panel.html
Normal file
@@ -0,0 +1 @@
|
||||
market admin
|
||||
2
templates/_types/market/admin/_nav.html
Normal file
2
templates/_types/market/admin/_nav.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||
{{ placeholder_nav() }}
|
||||
29
templates/_types/market/admin/_oob_elements.html
Normal file
29
templates/_types/market/admin/_oob_elements.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{% 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 %}
|
||||
|
||||
|
||||
11
templates/_types/market/admin/header/_header.html
Normal file
11
templates/_types/market/admin/header/_header.html
Normal file
@@ -0,0 +1,11 @@
|
||||
{% 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 %}
|
||||
19
templates/_types/market/admin/index.html
Normal file
19
templates/_types/market/admin/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{% 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 %}
|
||||
38
templates/_types/market/desktop/_nav.html
Normal file
38
templates/_types/market/desktop/_nav.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<!-- 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>
|
||||
11
templates/_types/market/header/_header.html
Normal file
11
templates/_types/market/header/_header.html
Normal file
@@ -0,0 +1,11 @@
|
||||
{% 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 %}
|
||||
110
templates/_types/market/mobile/_nav_panel.html
Normal file
110
templates/_types/market/mobile/_nav_panel.html
Normal file
@@ -0,0 +1,110 @@
|
||||
{% 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>
|
||||
6
templates/_types/market/mobile/menu.html
Normal file
6
templates/_types/market/mobile/menu.html
Normal file
@@ -0,0 +1,6 @@
|
||||
{% extends 'mobile/menu.html' %}
|
||||
{% block menu %}
|
||||
{% block mobile_menu %}
|
||||
{% endblock %}
|
||||
{% include '_types/market/mobile/_nav_panel.html' %}
|
||||
{% endblock %}
|
||||
13
templates/_types/page_markets/_card.html
Normal file
13
templates/_types/page_markets/_card.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{# Card for a single market in a page-scoped listing #}
|
||||
{% set market_href = market_url('/' ~ post.slug ~ '/' ~ market.slug ~ '/') %}
|
||||
<article class="rounded-xl bg-white shadow-sm border border-stone-200 p-5 flex flex-col justify-between hover:border-stone-400 transition-colors">
|
||||
<div>
|
||||
<a href="{{ market_href }}" class="hover:text-emerald-700">
|
||||
<h2 class="text-lg font-semibold text-stone-900">{{ market.name }}</h2>
|
||||
</a>
|
||||
|
||||
{% if market.description %}
|
||||
<p class="text-sm text-stone-600 mt-1 line-clamp-2">{{ market.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
18
templates/_types/page_markets/_cards.html
Normal file
18
templates/_types/page_markets/_cards.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% for market in markets %}
|
||||
{% include "_types/page_markets/_card.html" %}
|
||||
{% endfor %}
|
||||
{% if has_more %}
|
||||
{# Infinite scroll sentinel #}
|
||||
{% set next_url = url_for('page_markets.markets_fragment', page=page + 1)|host %}
|
||||
<div
|
||||
id="sentinel-{{ page }}"
|
||||
class="h-4 opacity-0 pointer-events-none"
|
||||
hx-get="{{ next_url }}"
|
||||
hx-trigger="intersect once delay:250ms"
|
||||
hx-swap="outerHTML"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="text-center text-xs text-stone-400">loading...</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
12
templates/_types/page_markets/_main_panel.html
Normal file
12
templates/_types/page_markets/_main_panel.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{# Markets grid for a single page #}
|
||||
{% if markets %}
|
||||
<div class="max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{% include "_types/page_markets/_cards.html" %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-3 py-12 text-center text-stone-400">
|
||||
<i class="fa fa-store text-4xl mb-3" aria-hidden="true"></i>
|
||||
<p class="text-lg">No markets for this page</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="pb-8"></div>
|
||||
15
templates/_types/page_markets/index.html
Normal file
15
templates/_types/page_markets/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{% extends '_types/root/_index.html' %}
|
||||
|
||||
{% block meta %}{% endblock %}
|
||||
|
||||
{% block root_header_child %}
|
||||
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||
{% call index_row('post-header-child', '_types/post/header/_header.html') %}
|
||||
{% block post_header_child %}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/page_markets/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
15
templates/_types/post/_nav.html
Normal file
15
templates/_types/post/_nav.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{# Widget-driven container nav — entries, calendars, markets #}
|
||||
{% if container_nav_widgets %}
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
|
||||
id="entries-calendars-nav-wrapper">
|
||||
{% include '_types/post/admin/_nav_entries.html' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Admin link #}
|
||||
{% if post and has_access('blog.post.admin.admin') %}
|
||||
{% call links.link(url_for('blog.post.admin.admin', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||
<i class="fa fa-cog" aria-hidden="true"></i>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
50
templates/_types/post/admin/_nav_entries.html
Normal file
50
templates/_types/post/admin/_nav_entries.html
Normal file
@@ -0,0 +1,50 @@
|
||||
|
||||
{# Left scroll arrow - desktop only #}
|
||||
<button
|
||||
class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
||||
aria-label="Scroll left"
|
||||
_="on click
|
||||
set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200">
|
||||
<i class="fa fa-chevron-left"></i>
|
||||
</button>
|
||||
|
||||
{# Widget-driven nav items container #}
|
||||
<div id="associated-items-container"
|
||||
class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
|
||||
style="scroll-behavior: smooth;"
|
||||
_="on load or scroll
|
||||
-- Show arrows if content overflows (desktop only)
|
||||
if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth
|
||||
remove .hidden from .entries-nav-arrow
|
||||
add .flex to .entries-nav-arrow
|
||||
else
|
||||
add .hidden to .entries-nav-arrow
|
||||
remove .flex from .entries-nav-arrow
|
||||
end">
|
||||
<div class="flex flex-col sm:flex-row gap-1">
|
||||
{% for wdata in container_nav_widgets %}
|
||||
{% with ctx=wdata.ctx %}
|
||||
{% include wdata.widget.template with context %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
{# Right scroll arrow - desktop only #}
|
||||
<button
|
||||
class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
||||
aria-label="Scroll right"
|
||||
_="on click
|
||||
set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200">
|
||||
<i class="fa fa-chevron-right"></i>
|
||||
</button>
|
||||
28
templates/_types/post/header/_header.html
Normal file
28
templates/_types/post/header/_header.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='post-row', oob=oob) %}
|
||||
{% call links.link(blog_url('/' + post.slug + '/'), hx_select_search ) %}
|
||||
{% if post.feature_image %}
|
||||
<img
|
||||
src="{{ post.feature_image }}"
|
||||
class="h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"
|
||||
>
|
||||
{% endif %}
|
||||
<span>
|
||||
{{ post.title | truncate(160, True, '…') }}
|
||||
</span>
|
||||
{% endcall %}
|
||||
{% call links.desktop_nav() %}
|
||||
{% if page_cart_count is defined and page_cart_count > 0 %}
|
||||
<a
|
||||
href="{{ cart_url('/' + post.slug + '/') }}"
|
||||
class="relative inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100 transition"
|
||||
>
|
||||
<i class="fa fa-shopping-cart" aria-hidden="true"></i>
|
||||
<span>{{ page_cart_count }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% include '_types/post/_nav.html' %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
17
templates/_types/product/_added.html
Normal file
17
templates/_types/product/_added.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{# HTMX response after add-to-cart: OOB-swap the mini cart + product buttons #}
|
||||
{% import '_types/product/_cart.html' as _cart %}
|
||||
|
||||
{# 1. Update mini cart directly — handler already has the cart data #}
|
||||
{% from 'macros/cart_icon.html' import cart_icon %}
|
||||
{{ cart_icon(count=cart | sum(attribute="quantity")) }}
|
||||
|
||||
{# 2. Update add/remove buttons on the product #}
|
||||
{{ _cart.add(d.slug, cart, oob='true') }}
|
||||
|
||||
{# 3. Update cart item row if visible #}
|
||||
{% from '_types/product/_cart.html' import cart_item with context %}
|
||||
{% if item and item.quantity > 0 %}
|
||||
{{ cart_item(oob='true') }}
|
||||
{% elif item %}
|
||||
{{ cart_item(oob='delete') }}
|
||||
{% endif %}
|
||||
@@ -7,9 +7,9 @@
|
||||
|
||||
{% if not quantity %}
|
||||
<form
|
||||
action="{{ url_for('market.browse.product.cart', slug=slug) }}"
|
||||
action="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
|
||||
method="post"
|
||||
hx-post="{{ url_for('market.browse.product.cart', slug=slug) }}"
|
||||
hx-post="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
|
||||
hx-target="#cart-mini"
|
||||
hx-swap="outerHTML"
|
||||
class="rounded flex items-center"
|
||||
@@ -38,9 +38,9 @@
|
||||
<div class="rounded flex items-center gap-2">
|
||||
<!-- minus -->
|
||||
<form
|
||||
action="{{ url_for('market.browse.product.cart', slug=slug) }}"
|
||||
action="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
|
||||
method="post"
|
||||
hx-post="{{ url_for('market.browse.product.cart', slug=slug) }}"
|
||||
hx-post="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
|
||||
hx-target="#cart-mini"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
@@ -80,9 +80,9 @@
|
||||
|
||||
<!-- plus -->
|
||||
<form
|
||||
action="{{ url_for('market.browse.product.cart', slug=slug) }}"
|
||||
action="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
|
||||
method="post"
|
||||
hx-post="{{ url_for('market.browse.product.cart', slug=slug) }}"
|
||||
hx-post="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
|
||||
hx-target="#cart-mini"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
@@ -139,7 +139,7 @@
|
||||
<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-sm sm:text-base md:text-lg font-semibold text-stone-900">
|
||||
{% set href=url_for('market.browse.product.product_detail', slug=p.slug) %}
|
||||
{% set href=url_for('market.browse.product.product_detail', product_slug=p.slug) %}
|
||||
<a
|
||||
href="{{ href }}"
|
||||
hx_get="{{href}}"
|
||||
@@ -189,9 +189,9 @@
|
||||
<div class="flex items-center gap-2 text-xs sm:text-sm text-stone-700">
|
||||
<span class="text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500">Quantity</span>
|
||||
<form
|
||||
action="{{ url_for('market.browse.product.cart', slug=p.slug) }}"
|
||||
action="{{ url_for('market.browse.product.cart', product_slug=p.slug) }}"
|
||||
method="post"
|
||||
hx-post="{{ url_for('market.browse.product.cart', slug=p.slug) }}"
|
||||
hx-post="{{ url_for('market.browse.product.cart', product_slug=p.slug) }}"
|
||||
hx-target="#cart-mini"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
@@ -212,9 +212,9 @@
|
||||
{{ item.quantity }}
|
||||
</span>
|
||||
<form
|
||||
action="{{ url_for('market.browse.product.cart', slug=p.slug) }}"
|
||||
action="{{ url_for('market.browse.product.cart', product_slug=p.slug) }}"
|
||||
method="post"
|
||||
hx-post="{{ url_for('market.browse.product.cart', slug=p.slug) }}"
|
||||
hx-post="{{ url_for('market.browse.product.cart', product_slug=p.slug) }}"
|
||||
hx-target="#cart-mini"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
|
||||
131
templates/_types/product/_main_panel.html
Normal file
131
templates/_types/product/_main_panel.html
Normal file
@@ -0,0 +1,131 @@
|
||||
{# 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>
|
||||
106
templates/_types/product/_meta.html
Normal file
106
templates/_types/product/_meta.html
Normal file
@@ -0,0 +1,106 @@
|
||||
{# --- 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>
|
||||
49
templates/_types/product/_oob_elements.html
Normal file
49
templates/_types/product/_oob_elements.html
Normal file
@@ -0,0 +1,49 @@
|
||||
{% 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('blog-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 %}
|
||||
33
templates/_types/product/_prices.html
Normal file
33
templates/_types/product/_prices.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{% 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>
|
||||
|
||||
2
templates/_types/product/_title.html
Normal file
2
templates/_types/product/_title.html
Normal file
@@ -0,0 +1,2 @@
|
||||
<i class="fa fa-shopping-bag" aria-hidden="true"></i>
|
||||
<div>{{ d.title }}</div>
|
||||
2
templates/_types/product/admin/_nav.html
Normal file
2
templates/_types/product/admin/_nav.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||
{{ placeholder_nav() }}
|
||||
40
templates/_types/product/admin/_oob_elements.html
Normal file
40
templates/_types/product/admin/_oob_elements.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{% extends 'oob_elements.html' %}
|
||||
|
||||
|
||||
{# OOB elements for HTMX navigation - all elements that need updating #}
|
||||
{# 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/root/_n/macros.html' import oob_header with context %}
|
||||
{{oob_header('product-header-child', 'product-admin-header-child', '_types/product/admin/header/_header.html')}}
|
||||
|
||||
{% from '_types/product/header/_header.html' import header_row with context %}
|
||||
{{ header_row(oob=True) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% from '_types/root/_n/macros.html' import header with context %}
|
||||
{% call header(id='product-header-child', oob=True) %}
|
||||
{% call header() %}
|
||||
{% from '_types/product/admin/header/_header.html' import header_row with context %}
|
||||
{{header_row()}}
|
||||
<div id="product-admin-header-header-child">
|
||||
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
|
||||
|
||||
{% block mobile_menu %}
|
||||
{% include '_types/product/admin/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/product/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
11
templates/_types/product/admin/header/_header.html
Normal file
11
templates/_types/product/admin/header/_header.html
Normal file
@@ -0,0 +1,11 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='product-admin-row', oob=oob) %}
|
||||
{% call links.link(url_for('market.browse.product.admin', product_slug=d.slug), hx_select_search ) %}
|
||||
admin!!
|
||||
{% endcall %}
|
||||
{% call links.desktop_nav() %}
|
||||
{% include '_types/product/admin/_nav.html' %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
39
templates/_types/product/admin/index.html
Normal file
39
templates/_types/product/admin/index.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{% extends '_types/product/index.html' %}
|
||||
|
||||
{% import 'macros/layout.html' as layout %}
|
||||
|
||||
{% block product_header_child %}
|
||||
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||
{% call index_row('market-header-child', '_types/product/admin/header/_header.html') %}
|
||||
{% block product_admin_header_child %}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
{% block ___app_title %}
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% call links.menu_row() %}
|
||||
{% call links.link(url_for('market.browse.product.admin', product_slug=slug), hx_select_search) %}
|
||||
{{ links.admin() }}
|
||||
{% endcall %}
|
||||
{% call links.desktop_nav() %}
|
||||
{% include '_types/product/admin/_nav.html' %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
{% block _main_mobile_menu %}
|
||||
{% include '_types/product/admin/_nav.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block aside %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/product/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
15
templates/_types/product/header/_header.html
Normal file
15
templates/_types/product/header/_header.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='product-row', oob=oob) %}
|
||||
{% call links.link(url_for('market.browse.product.product_detail', product_slug=d.slug), hx_select_search ) %}
|
||||
{% include '_types/product/_title.html' %}
|
||||
{% endcall %}
|
||||
{% include '_types/product/_prices.html' %}
|
||||
{% call links.desktop_nav() %}
|
||||
{% include '_types/browse/_admin.html' %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
|
||||
61
templates/_types/product/index.html
Normal file
61
templates/_types/product/index.html
Normal file
@@ -0,0 +1,61 @@
|
||||
{% extends '_types/browse/index.html' %}
|
||||
|
||||
{% block meta %}
|
||||
{% include '_types/product/_meta.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% import 'macros/stickers.html' as stick %}
|
||||
{% import '_types/product/prices.html' as prices %}
|
||||
{% set prices_ns = namespace() %}
|
||||
{{ prices.set_prices(d, prices_ns)}}
|
||||
|
||||
|
||||
|
||||
{% block market_header_child %}
|
||||
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||
{% call index_row('market-header-child', '_types/product/header/_header.html') %}
|
||||
{% block product_header_child %}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block _main_mobile_menu %}
|
||||
{% include '_types/browse/_admin.html' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
{% block filter %}
|
||||
|
||||
{% call layout.details() %}
|
||||
{% call layout.summary('blog-child-header') %}
|
||||
{% block blog_child_summary %}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% call layout.menu('blog-child-menu') %}
|
||||
{% block post_child_menu %}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
|
||||
{% call layout.details() %}
|
||||
{% call layout.summary('product-child-header') %}
|
||||
{% block item_child_summary %}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% call layout.menu('item-child-menu') %}
|
||||
{% block item_child_menu %}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block aside %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/product/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
66
templates/_types/product/prices.html
Normal file
66
templates/_types/product/prices.html
Normal file
@@ -0,0 +1,66 @@
|
||||
{# ---- Price formatting helpers ---- #}
|
||||
{% set _sym = {'GBP':'£','EUR':'€','USD':'$'} %}
|
||||
{% macro price_str(val, raw, cur) -%}
|
||||
{%- if raw -%}
|
||||
{{ raw }}
|
||||
{%- elif val is number -%}
|
||||
{{ (_sym.get(cur) or '') ~ ('%.2f'|format(val)) }}
|
||||
{%- else -%}
|
||||
{{ val or '' }}
|
||||
{%- endif -%}
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
{% macro set_prices(item, ns) -%}
|
||||
|
||||
{% set ns.sp_val = item.special_price or (item.oe_list_price and item.oe_list_price.special) %}
|
||||
{% set ns.sp_raw = item.special_price_raw or (item.oe_list_price and item.oe_list_price.special_raw) %}
|
||||
{% set ns.sp_cur = item.special_price_currency or (item.oe_list_price and item.oe_list_price.special_currency) %}
|
||||
|
||||
{% set ns.rp_val = item.regular_price or item.rrp or (item.oe_list_price and item.oe_list_price.rrp) %}
|
||||
{% set ns.rp_raw = item.regular_price_raw or item.rrp_raw or (item.oe_list_price and item.oe_list_price.rrp_raw) %}
|
||||
{% set ns.rp_cur = item.regular_price_currency or item.rrp_currency or (item.oe_list_price and item.oe_list_price.rrp_currency) %}
|
||||
|
||||
{% set ns.case_size_count = (item.case_size_count or 1) %}
|
||||
{% set ns.rrp = item.rrp_raw[0] ~ "%.2f"|format(item.rrp * (ns.case_size_count)) %}
|
||||
{% set ns.rrp_raw = item.rrp_raw %}
|
||||
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
{% macro rrp(ns) -%}
|
||||
{% if ns.rrp %}
|
||||
<div class="text-base md:text-lgtext-stone-400">
|
||||
<span>rrp:</span>
|
||||
<span>
|
||||
{{ ns.rrp }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
{% macro card_price(item) %}
|
||||
|
||||
|
||||
{# price block unchanged #}
|
||||
{% set _sym = {'GBP':'£','EUR':'€','USD':'$'} %}
|
||||
{% set sp_val = item.special_price or (item.oe_list_price and item.oe_list_price.special) %}
|
||||
{% set sp_raw = item.special_price_raw or (item.oe_list_price and item.oe_list_price.special_raw) %}
|
||||
{% set sp_cur = item.special_price_currency or (item.oe_list_price and item.oe_list_price.special_currency) %}
|
||||
{% set rp_val = item.regular_price or item.rrp or (item.oe_list_price and item.oe_list_price.rrp) %}
|
||||
{% set rp_raw = item.regular_price_raw or item.rrp_raw or (item.oe_list_price and item.oe_list_price.rrp_raw) %}
|
||||
{% set rp_cur = item.regular_price_currency or item.rrp_currency or (item.oe_list_price and item.oe_list_price.rrp_currency) %}
|
||||
{% set sp_str = sp_raw if sp_raw else ( (_sym.get(sp_cur, '') ~ ('%.2f'|format(sp_val))) if sp_val is number else (sp_val or '')) %}
|
||||
{% set rp_str = rp_raw if rp_raw else ( (_sym.get(rp_cur, '') ~ ('%.2f'|format(rp_val))) if rp_val is number else (rp_val or '')) %}
|
||||
<div class="mt-1 flex items-baseline gap-2 justify-center">
|
||||
{% if sp_val %}
|
||||
<div class="text-lg font-semibold text-emerald-700">{{ sp_str }}</div>
|
||||
{% if rp_val %}
|
||||
<div class="text-sm line-through text-stone-500">{{ rp_str }}</div>
|
||||
{% endif %}
|
||||
{% elif rp_val %}
|
||||
<div class="mt-1 text-lg font-semibold">{{ rp_str }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
7
templates/aside_clear.html
Normal file
7
templates/aside_clear.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<aside
|
||||
id="aside"
|
||||
hx-swap-oob="outerHTML"
|
||||
class="hidden"
|
||||
>
|
||||
</aside>
|
||||
|
||||
5
templates/filter_clear.html
Normal file
5
templates/filter_clear.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<div
|
||||
id="filter"
|
||||
hx-swap-oob="outerHTML"
|
||||
>
|
||||
</div>
|
||||
9
templates/fragments/container_nav_markets.html
Normal file
9
templates/fragments/container_nav_markets.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{# Market links nav — served as fragment from market app #}
|
||||
{% for m in markets %}
|
||||
<a
|
||||
href="{{ market_url('/' + post_slug + '/' + m.slug + '/') }}"
|
||||
class="{{styles.nav_button_less_pad}}">
|
||||
<i class="fa fa-shopping-bag" aria-hidden="true"></i>
|
||||
<div>{{m.name}}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
117
templates/macros/filters.html
Normal file
117
templates/macros/filters.html
Normal file
@@ -0,0 +1,117 @@
|
||||
{#
|
||||
Unified filter macros for browse/shop pages
|
||||
Consolidates duplicate mobile/desktop filter components
|
||||
#}
|
||||
|
||||
{% macro filter_item(href, is_on, title, icon_html, count=none, variant='desktop') %}
|
||||
{#
|
||||
Generic filter item (works for labels, stickers, etc.)
|
||||
variant: 'desktop' or 'mobile'
|
||||
#}
|
||||
{% set base_class = "flex flex-col items-center justify-center" %}
|
||||
{% if variant == 'mobile' %}
|
||||
{% set item_class = base_class ~ " p-1 rounded hover:bg-stone-50" %}
|
||||
{% set count_class = "text-[10px] text-stone-500 mt-1 leading-none tabular-nums" if count != 0 else "text-md text-red-500 font-bold mt-1 leading-none tabular-nums" %}
|
||||
{% else %}
|
||||
{% set item_class = base_class ~ " py-2 w-full h-full" %}
|
||||
{% set count_class = "text-xs text-stone-500 leading-none justify-self-end tabular-nums" if count != 0 else "text-md text-red-500 font-bold leading-none justify-self-end tabular-nums" %}
|
||||
{% endif %}
|
||||
|
||||
<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="{{ title }}"
|
||||
aria-label="{{ title }}"
|
||||
class="{{ item_class }}"
|
||||
>
|
||||
{{ icon_html | safe }}
|
||||
{% if count is not none %}
|
||||
<span class="{{ count_class }}">{{ count }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{% macro labels_list(labels, selected_labels, current_local_href, variant='desktop') %}
|
||||
{#
|
||||
Unified labels filter list
|
||||
variant: 'desktop' or 'mobile'
|
||||
#}
|
||||
{% import 'macros/stickers.html' as stick %}
|
||||
|
||||
{% if variant == 'mobile' %}
|
||||
<nav aria-label="labels" 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">
|
||||
{% else %}
|
||||
<ul id="labels-details-desktop" class="flex justify-center p-0 m-0 gap-2" >
|
||||
{% endif %}
|
||||
|
||||
{% 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' if variant == 'mobile' else '' }}">
|
||||
{{ filter_item(
|
||||
href, is_on, s.name,
|
||||
stick.sticker(asset_url('nav-labels/' ~ s.name ~ '.svg'), s.name, is_on),
|
||||
s.count, variant
|
||||
) }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
</ul>
|
||||
{% if variant == 'mobile' %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{% macro stickers_list(stickers, selected_stickers, current_local_href, variant='desktop') %}
|
||||
{#
|
||||
Unified stickers filter list
|
||||
variant: 'desktop' or 'mobile'
|
||||
#}
|
||||
{% import 'macros/stickers.html' as stick %}
|
||||
|
||||
{% if variant == 'mobile' %}
|
||||
<nav aria-label="stickers" 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">
|
||||
{% else %}
|
||||
<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"
|
||||
>
|
||||
{% endif %}
|
||||
|
||||
{% 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 %}
|
||||
{% set display_name = s.name|capitalize if s.name|lower != 'sugarfree' else 'Sugar' %}
|
||||
|
||||
<li class="{{ 'list-none shrink-0' if variant == 'mobile' else '' }}">
|
||||
{% set icon_html %}
|
||||
<span class="{{ 'text-sm' if variant == 'mobile' else 'text-[11px]' }}">{{ display_name }}</span>
|
||||
{{ stick.sticker(asset_url('stickers/' ~ s.name ~ '.svg'), s.name, is_on) }}
|
||||
{% endset %}
|
||||
{{ filter_item(href, is_on, s.name, icon_html, s.count, variant) }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
</ul>
|
||||
{% if variant == 'mobile' %}
|
||||
</nav>
|
||||
<style>
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
</style>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user