Compare commits

..

48 Commits

Author SHA1 Message Date
giles
74d6071ad4 Update shared submodule: select_colours Jinja global
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:17:26 +00:00
giles
1aa3659bb8 Uncomment current_local_href in category_context for subcategory highlighting
The category selector compares current_local_href against sub.local_href
to determine the active subcategory. This was commented out, so no
subcategory was ever highlighted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:17:12 +00:00
giles
aab9cf3f6b Update shared submodule: fix menu item highlighting
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m59s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:57:12 +00:00
giles
6984f3f3db Update shared submodule: delete button + quantity clamp in cart_item
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 48s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:43:42 +00:00
giles
a50c5e4d46 Update shared submodule: cart_quantity_url template support
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 48s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:32:38 +00:00
giles
ca5b952ffc Update shared submodule: MarketService write methods
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 05:57:33 +00:00
giles
d7162f5543 Fix inconsistent cart count: include calendar entries in market app
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s
Market context processor was only counting product CartItems for cart_count,
while blog/cart/events apps include calendar entries too. Use cart service
for consistent counts across all apps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 05:17:04 +00:00
giles
0a3997b82a Update shared submodule: DTO template compatibility fixes
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 40s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 05:05:46 +00:00
giles
50cad49576 Update shared submodule: revert extend_existing workaround
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 04:51:08 +00:00
giles
fe255fc53c Fix cart template: use direct CartItem queries in market context
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
Market owns CartItem/Product — query directly with selectinload
so templates can access item.product.slug and other ORM attributes.
The cart service DTOs are for cross-domain consumers (blog, events).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 04:50:06 +00:00
giles
ad445e2fd2 Fix NameError: import services registry in create_app scope
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
The services singleton was used in before_request closures but the
import was removed when refactoring to domain_services_fn.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 04:45:37 +00:00
giles
b04dbbba67 Remove glue submodule: models moved to shared/
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
The glue layer's models (MenuNode, ContainerRelation), services
(navigation, relationships), and event handlers have been absorbed
into shared/. The glue submodule caused duplicate SQLAlchemy table
registration for 'menu_nodes'.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 04:41:20 +00:00
giles
3fea2e6fdb Update shared submodule: fix duplicate table error for MenuNode
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 04:34:59 +00:00
giles
acf352ee3b Domain isolation: replace cross-domain imports with service calls
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m3s
Replace direct Post query and CartItem imports with typed service calls.
Market registers all 4 services via domain_services_fn with has() guards.

Key changes:
- app.py: use domain_services_fn, Post query → services.blog,
  CartItem → services.cart, MarketPlace+Post join → separate queries,
  glue navigation → shared navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 04:30:22 +00:00
giles
33befd4c3d Update shared submodule: fix ticket_types lazy-load
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 39s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 22:02:22 +00:00
giles
8dfb95ccab Update shared submodule: fix cart-mini home link
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 40s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:49:54 +00:00
giles
7ccdc1fa83 Decouple market: use shared.models for all cross-app imports
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s
- Replace blog.models import with shared.models equivalent
- Convert market/models/market.py and market_place.py to re-export stubs
- Update shared + glue submodule pointers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 20:58:11 +00:00
giles
31f9aa3fac Remove 47 identical market template overrides of shared templates
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 20:03:31 +00:00
giles
ec1880b658 Update shared submodule: fix orders link htmx interception
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 40s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 19:30:58 +00:00
giles
72042e793b Update shared submodule: use coop_url for auth links
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 51s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 19:18:43 +00:00
giles
b3125c5db4 Update shared submodule: fix market nav link
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:57:11 +00:00
giles
7edc0a53a1 Update shared submodule: add page_config to get_checkout
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:38:20 +00:00
giles
867bfa234f Update shared submodule: market_product_url for correct product URLs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 49s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:19:56 +00:00
giles
bc1a7783df Update shared submodule: add page_config to SumUp checkout
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 51s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 17:52:19 +00:00
giles
0015839845 Update shared submodule: fix doubled URLs in |host filter
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 51s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:46:07 +00:00
giles
63778d9f20 Fix cart quantities not showing on product list after page refresh
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s
The context processor loaded cart items but only passed aggregates
(cart_count, cart_total) — not the cart list itself. Templates need
the full list to display per-product quantities.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 21:40:48 +00:00
giles
28938e38b5 Fix cart count: query DB directly instead of cross-app API
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
Two bugs fixed:

1. First htmx add didn't update mini cart count because the context
   processor's API call couldn't see the uncommitted transaction.
   Fix: pass cart_count/cart_total explicitly from the route handler.

2. Page refresh always showed cart count 0 because the internal API
   call to the cart service failed to resolve cart identity correctly.
   Fix: replace the API call with a direct DB query using the same
   shared database and session, matching how the cart app itself works.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 10:39:43 +00:00
giles
faa72eec5d Update shared submodule: cross-app checkout URL fix
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 39s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 10:22:30 +00:00
giles
8c2358022a Fix undefined calendar_total/calendar_cart_entries in product cart template
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 40s
The _added.html template's summary macro expects calendar_total and
calendar_cart_entries from the cart/events domain. The market app has
no calendar entries, so pass a no-op function and empty list.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 10:15:07 +00:00
giles
44f475857b Fix AttributeError on g.cart in product cart route
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
g.cart was never populated in the market app — the cart loader
before_request hook was only registered in the cart microservice.
Replace the dead filter-building code with an actual query that
loads cart items inline, scoped to the current user/session.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 10:11:49 +00:00
giles
039386b6e7 README: replace vague cross-app section with actual code dependencies
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
List specific model imports, glue services, and internal APIs
that market code actually references.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 19:47:50 +00:00
giles
dc94cfa29e Update shared + glue submodule pointers (README additions)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 19:36:26 +00:00
giles
a29612ffa4 Rewrite README for post-decoupling architecture
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 55s
Fix microservice count (4 not 3), document submodules, models,
scraper, all blueprints including bp/cart/, and cross-app integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 19:28:56 +00:00
giles
99dd473afb Phase 5: Update shared + glue submodule pointers
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 39s
shared: migration to drop cross-domain FK constraints
glue: order lifecycle services, cart adoption, login/order handlers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 19:11:58 +00:00
giles
7820257577 Update shared submodule to include glue layer + MenuItem fix
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 08:03:32 +00:00
giles
f81d6803d4 Add glue layer: replace /internal/menu-items API with direct DB query
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
- Context processor: get_navigation_tree() replaces api_get("coop", "/internal/menu-items")
- Add glue submodule

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:37:47 +00:00
giles
ba10f5547a ci: clean all sibling dirs before copying to fix stale table defs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m26s
Previous runs left self-copies (e.g. market/market/) that caused
'Table already defined' errors. Split into two loops: first rm -rf
all sibling dirs, then copy only non-self siblings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:31:49 +00:00
giles
7025333951 CI: skip copying own models to avoid duplicate SQLAlchemy table defs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m34s
Each app has its own models/ at the root (imported as bare `models.X`).
The CI copy was also creating {app}/models/ (imported as `{app}.models.X`),
causing SQLAlchemy to see the same table defined twice.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:14:50 +00:00
giles
737c82ae7f Update shared submodule: import all model packages at startup
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:01:41 +00:00
giles
cccdba65a8 CI: use git archive for sibling models (atomic, race-safe)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 49s
The cp approach failed when sibling repos were mid-update from
their own CI runs. git archive reads directly from git objects,
and git fetch ensures origin/decoupling is available even if the
sibling working tree is on a different branch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:10:59 +00:00
giles
7b1f8a0f5e CI: copy sibling app models into build context for cross-domain imports
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m42s
Phases 1-3 split models by domain ownership, but cross-app imports
still exist (e.g. cart imports market.models.CartItem). In Docker
each app only has its own code. The CI step now copies sibling app
model packages into the build context before docker build.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:01:51 +00:00
giles
000185c2cc Update shared submodule: merge diverged alembic heads
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m26s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 14:27:30 +00:00
giles
4f90f65a8f Update shared submodule (adds missing alembic.ini)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m28s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 14:20:16 +00:00
giles
3b24cf45e5 Add PYTHONPATH=/app so Hypercorn spawn workers find app module
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m40s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 14:01:33 +00:00
giles
a6ed49d0c1 Update shared submodule: rename logging → log_config
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m19s
Fixes stdlib logging shadow that caused circular import in Docker.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 13:55:51 +00:00
giles
62000256aa Replace shared_lib submodule with shared for decoupling deploy
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m11s
- Swap shared_lib submodule → shared (tracking decoupling branch)
- Dockerfile: shared_lib/ → shared/, remove bp symlink hack
- CI: trigger on decoupling branch, use dynamic ref_name

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 13:29:25 +00:00
giles
079293eb2c fix: update market template to use container refs, add config
- Template uses m.page.slug instead of m.post.slug
- Add app-config.yaml

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 12:50:48 +00:00
giles
478636f799 feat: decouple market from shared_lib, add app-owned models
Phase 1-3 of decoupling:
- path_setup.py adds project root to sys.path
- Market-owned models in market/models/ (market, market_place)
- All imports updated: shared.infrastructure, shared.db, shared.browser, etc.
- MarketPlace uses container_type/container_id instead of post_id FK

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 12:46:32 +00:00
107 changed files with 377 additions and 2011 deletions

View File

@@ -2,7 +2,7 @@ name: Build and Deploy
on:
push:
branches: [main]
branches: [main, decoupling]
env:
REGISTRY: registry.rose-ash.com:5000
@@ -36,9 +36,23 @@ jobs:
run: |
ssh "root@$DEPLOY_HOST" "
cd ${{ env.REPO_DIR }}
git fetch origin main
git reset --hard origin/main
git fetch origin ${{ github.ref_name }}
git reset --hard origin/${{ github.ref_name }}
git submodule update --init --recursive
# Clean ALL sibling dirs (including stale self-copies from previous runs)
for sibling in blog market cart events; do
rm -rf \$sibling
done
# Copy non-self sibling models for cross-domain imports
for sibling in blog market cart events; do
[ \"\$sibling\" = \"${{ env.IMAGE }}\" ] && continue
repo=/root/rose-ash/\$sibling
if [ -d \$repo/.git ]; then
git -C \$repo fetch origin ${{ github.ref_name }} 2>/dev/null || true
mkdir -p \$sibling
git -C \$repo archive origin/${{ github.ref_name }} -- __init__.py models/ 2>/dev/null | tar -x -C \$sibling/ || true
fi
done
"
- name: Build and push image

5
.gitmodules vendored
View File

@@ -1,3 +1,4 @@
[submodule "shared_lib"]
path = shared_lib
[submodule "shared"]
path = shared
url = https://git.rose-ash.com/coop/shared.git
branch = decoupling

View File

@@ -5,6 +5,7 @@ FROM python:3.11-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app \
PIP_NO_CACHE_DIR=1 \
APP_PORT=8000 \
APP_MODULE=app:app
@@ -17,14 +18,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
COPY shared_lib/requirements.txt ./requirements.txt
COPY shared/requirements.txt ./requirements.txt
RUN pip install -r requirements.txt
COPY . .
# Link app blueprints into the shared library's namespace
RUN rm -rf /app/shared_lib/suma_browser/app/bp && ln -s /app/bp /app/shared_lib/suma_browser/app/bp
# ---------- Runtime setup ----------
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

113
README.md
View File

@@ -1,67 +1,84 @@
# Market App
Product browsing and marketplace application for the Rose Ash cooperative.
## Overview
The Market app is one of three microservices split from the original coop monolith:
- **coop** (:8000) - Blog, calendar, auth, settings
- **market** (:8001) - Product browsing, categories, product detail
- **cart** (:8002) - Shopping cart, orders, payments
Product browsing and marketplace service for the Rose Ash cooperative. Displays products scraped from Suma Wholesale.
## Architecture
- **Framework:** Quart (async Flask)
- **Database:** PostgreSQL 16 with SQLAlchemy 2.0 (async)
- **Cache:** Redis (tag-based page cache)
- **Frontend:** HTMX + Jinja2 + Tailwind CSS
- **Data:** Products scraped from Suma Wholesale
One of four Quart microservices sharing a single PostgreSQL database:
## Blueprints
| App | Port | Domain |
|-----|------|--------|
| blog (coop) | 8000 | Auth, blog, admin, menus, snippets |
| **market** | 8001 | Product browsing, Suma scraping |
| cart | 8002 | Shopping cart, checkout, orders |
| events | 8003 | Calendars, bookings, tickets |
- `bp/market/` - Market root (navigation, category listing)
- `bp/browse/` - Product browsing with filters and infinite scroll
- `bp/product/` - Product detail pages
- `bp/api/` - Product sync API (used by scraper)
## Structure
## Development
```
app.py # Application factory (create_base_app + blueprints)
path_setup.py # Adds project root + app dir to sys.path
config/app-config.yaml # App URLs, feature flags
models/ # Market-domain models
market.py # Product, Category, CartItem
market_place.py # MarketPlace (page-scoped marketplace)
bp/ # Blueprints
market/ # Market root, navigation, category listing
browse/ # Product browsing with filters and infinite scroll
product/ # Product detail pages
cart/ # Page-scoped cart views
api/ # Product sync API (used by scraper)
scrape/ # Suma Wholesale scraper
get_auth.py # Authentication
listings.py # Product listing pages
nav.py # Category navigation
product/ # Individual product scraping
build_snapshot/ # Build product snapshots
persist_snapshot/ # Save snapshots to DB
persist_api/ # Save via API
templates/ # Jinja2 templates
entrypoint.sh # Docker entrypoint
Dockerfile
shared/ # Submodule → git.rose-ash.com/coop/shared.git
glue/ # Submodule → git.rose-ash.com/coop/glue.git
```
# Install dependencies
pip install -r requirements.txt
## Dependencies
# Set environment variables
export $(grep -v '^#' .env | xargs)
**Cross-app model imports:**
- `blog.models.ghost_content.Post``app.py` hydrates page data for marketplace views
# Run migrations
alembic upgrade head
**Glue services:**
- `glue.services.navigation.get_navigation_tree` — context processor builds site nav
# Scrape products
bash scrape.sh
# Run the dev server
hypercorn app:app --reload --bind 0.0.0.0:8001
**Internal APIs:**
- Calls `GET /internal/cart/summary` — context processor for cart widget
## Scraping
# Full scrape (max 50 pages, 200k products, 8 concurrent)
bash scrape.sh
```bash
# Full scrape (Suma Wholesale catalogue)
bash scrape.sh
# Test scraping
bash scrape-test.sh
# Test scraping (limited)
bash scrape-test.sh
```
## Running
```bash
export DATABASE_URL_ASYNC=postgresql+asyncpg://user:pass@localhost/coop
export REDIS_URL=redis://localhost:6379/0
export SECRET_KEY=your-secret-key
export SUMA_USER=your-suma-username
export SUMA_PASSWORD=your-suma-password
hypercorn app:app --reload --bind 0.0.0.0:8001
```
## Docker
docker build -t market .
docker run -p 8001:8000 --env-file .env market
## Environment Variables
DATABASE_URL_ASYNC=postgresql+asyncpg://user:pass@localhost/coop
REDIS_URL=redis://localhost:6379/0
SECRET_KEY=your-secret-key
SUMA_USER=your-suma-username
SUMA_PASSWORD=your-suma-password
APP_URL_COOP=http://localhost:8000
APP_URL_MARKET=http://localhost:8001
APP_URL_CART=http://localhost:8002
```bash
docker build -t market .
docker run -p 8001:8000 --env-file .env market
```

0
__init__.py Normal file
View File

100
app.py
View File

@@ -7,46 +7,68 @@ from quart import g, abort, render_template, make_response
from jinja2 import FileSystemLoader, ChoiceLoader
from sqlalchemy import select
from shared.factory import create_base_app
from shared.cart_loader import load_cart
from config import config
from shared.infrastructure.factory import create_base_app
from shared.config import config
from suma_browser.app.bp import register_market_bp
from bp import register_market_bp
async def market_context() -> dict:
"""
Market app context processor.
- menu_items: fetched from coop internal API
- cart_count/cart_total: fetched from cart internal API
- menu_items: direct DB query
- cart_count/cart_total: via cart service (includes calendar entries)
- cart: direct ORM query (templates need .product relationship)
"""
from shared.context import base_context
from shared.internal_api import get as api_get, dictobj
from shared.infrastructure.context import base_context
from shared.services.navigation import get_navigation_tree
from shared.services.registry import services
from shared.infrastructure.cart_identity import current_cart_identity
from shared.models.market import CartItem
from sqlalchemy.orm import selectinload
ctx = await base_context()
# Menu items from coop API (wrapped for attribute access in templates)
menu_data = await api_get("coop", "/internal/menu-items")
ctx["menu_items"] = dictobj(menu_data) if menu_data else []
ctx["menu_items"] = await get_navigation_tree(g.s)
# Cart data from cart API
cart_data = await api_get("cart", "/internal/cart/summary", forward_session=True)
if cart_data:
ctx["cart_count"] = cart_data.get("count", 0)
ctx["cart_total"] = cart_data.get("total", 0)
ident = current_cart_identity()
# cart_count/cart_total via service (consistent with blog/events apps)
summary = await services.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
)
ctx["cart_count"] = summary.count + summary.calendar_count
ctx["cart_total"] = float(summary.total + summary.calendar_total)
# ORM cart items for product templates (need .product relationship)
filters = [CartItem.deleted_at.is_(None)]
if ident["user_id"] is not None:
filters.append(CartItem.user_id == ident["user_id"])
elif ident["session_id"] is not None:
filters.append(CartItem.session_id == ident["session_id"])
else:
ctx["cart_count"] = 0
ctx["cart_total"] = 0
ctx["cart"] = []
return ctx
result = await g.s.execute(
select(CartItem).where(*filters).options(selectinload(CartItem.product))
)
ctx["cart"] = list(result.scalars().all())
return ctx
def create_app() -> "Quart":
from models.market_place import MarketPlace
from models.ghost_content import Post
from shared.services.registry import services
from services import register_domain_services
app = create_base_app("market", context_fn=market_context, before_request_fns=[load_cart])
app = create_base_app(
"market",
context_fn=market_context,
domain_services_fn=register_domain_services,
)
# App-specific templates override shared templates
app_templates = str(Path(__file__).resolve().parent / "templates")
@@ -89,12 +111,8 @@ def create_app() -> "Quart":
if not post_slug or not market_slug:
return
# Load post by slug
post = (
await g.s.execute(
select(Post).where(Post.slug == post_slug)
)
).scalar_one_or_none()
# Load post by slug via blog service
post = await services.blog.get_post_by_slug(g.s, post_slug)
if not post:
abort(404)
@@ -111,12 +129,13 @@ def create_app() -> "Quart":
},
}
# Load market scoped to post
# Load market scoped to post (container pattern)
market = (
await g.s.execute(
select(MarketPlace).where(
MarketPlace.slug == market_slug,
MarketPlace.post_id == post.id,
MarketPlace.container_type == "page",
MarketPlace.container_id == post.id,
MarketPlace.deleted_at.is_(None),
)
)
@@ -135,16 +154,23 @@ def create_app() -> "Quart":
# --- Root route: market listing ---
@app.get("/")
async def markets_listing():
from sqlalchemy.orm import selectinload
result = await g.s.execute(
select(MarketPlace)
.where(MarketPlace.deleted_at.is_(None), MarketPlace.container_type == "page")
.order_by(MarketPlace.name)
)
all_markets = result.scalars().all()
markets = (
await g.s.execute(
select(MarketPlace)
.where(MarketPlace.deleted_at.is_(None))
.options(selectinload(MarketPlace.post))
.order_by(MarketPlace.name)
)
).scalars().all()
# Resolve page posts via blog service
post_ids = list({m.container_id for m in all_markets})
posts_by_id = {
p.id: p
for p in await services.blog.get_posts_by_ids(g.s, post_ids)
}
markets = []
for market in all_markets:
market.page = posts_by_id.get(market.container_id)
markets.append(market)
html = await render_template(
"_types/market/markets_listing.html",

View File

@@ -27,8 +27,8 @@ from models.market import (
ProductAllergen,
)
from suma_browser.app.redis_cacher import clear_cache
from suma_browser.app.csrf import csrf_exempt
from shared.browser.app.redis_cacher import clear_cache
from shared.browser.app.csrf import csrf_exempt
products_api = Blueprint("products_api", __name__, url_prefix="/api/products")

View File

@@ -10,7 +10,7 @@ from quart import (
make_response,
current_app,
)
from config import config
from shared.config import config
from .services.nav import category_context, get_nav
from .services.blacklist.category import is_category_blocked
@@ -21,8 +21,8 @@ from .services import (
_current_url_without_page,
)
from suma_browser.app.redis_cacher import cache_page
from suma_browser.app.utils.htmx import is_htmx_request
from shared.browser.app.redis_cacher import cache_page
from shared.browser.app.utils.htmx import is_htmx_request
def register():
browse_bp = Blueprint("browse", __name__)

View File

@@ -1,7 +1,7 @@
# suma_browser/category_blacklist.py
from __future__ import annotations
from typing import Optional
from config import config
from shared.config import config
def _norm(s: str) -> str:
return (s or "").strip().lower().strip("/")

View File

@@ -1,6 +1,6 @@
from typing import Set, Optional
from ..slugs import canonical_html_slug
from config import config
from shared.config import config
_blocked: Set[str] = set()
_mtime: Optional[float] = None

View File

@@ -1,5 +1,5 @@
import re
from config import config
from shared.config import config
def _norm_title_key(t: str) -> str:
t = (t or "").strip().lower()

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
import os, json
from typing import List, Optional
from config import config
from shared.config import config
from .blacklist.product import is_product_blocked

View File

@@ -4,7 +4,7 @@ from typing import Dict, List, Optional
from sqlalchemy import select, and_
from sqlalchemy.orm import selectinload
from config import config # if unused elsewhere, you can remove this import
from shared.config import config # if unused elsewhere, you can remove this import
# ORM models
from models.market import (

View File

@@ -5,7 +5,7 @@ import re
from typing import Dict, List, Tuple, Optional
from urllib.parse import urlparse, urljoin
from config import config
from shared.config import config
from . import db_backend as cb
from .blacklist.category import is_category_blocked # Reverse map: slug -> label
@@ -142,7 +142,7 @@ def category_context(top_slug: Optional[str], sub_slug: Optional[str], nav: Dict
# list of subcategories, each with its own count
"subs_local": _order_subs_selected_first(subs, sub_slug),
#"current_local_href": current_local_href,
"current_local_href": current_local_href,
}
def _apply_category_blacklist(nav: Dict[str, Dict]) -> Dict[str, Dict]:

View File

@@ -6,11 +6,11 @@ from quart import (
g,
request,
)
from config import config
from shared.config import config
from .products import products, products_nocounts
from .blacklist.product_details import is_blacklisted_heading
from utils import host_url
from shared.utils import host_url
from sqlalchemy import select
@@ -163,7 +163,7 @@ def _massage_product(d):
# Re-export from canonical shared location
from shared.http_utils import vary as _vary, current_url_without_page as _current_url_without_page
from shared.infrastructure.http_utils import vary as _vary, current_url_without_page as _current_url_without_page
async def _is_liked(user_id: int | None, slug: str) -> bool:
"""

View File

@@ -1,6 +1,6 @@
import re
from urllib.parse import urljoin, urlparse
from config import config
from shared.config import config
def product_slug_from_href(href: str) -> str:
p = urlparse(href)

View File

@@ -1,4 +1,4 @@
# Re-export from canonical shared location
from shared.cart_identity import CartIdentity, current_cart_identity
from shared.infrastructure.cart_identity import CartIdentity, current_cart_identity
__all__ = ["CartIdentity", "current_cart_identity"]

View File

@@ -5,7 +5,7 @@ from quart import (
)
from suma_browser.app.authz import require_admin
from shared.browser.app.authz import require_admin
def register():
@@ -15,7 +15,7 @@ def register():
@bp.get("/")
@require_admin
async def admin():
from suma_browser.app.utils.htmx import is_htmx_request
from shared.browser.app.utils.htmx import is_htmx_request
# Determine which template to use based on request type
if not is_htmx_request():

View File

@@ -2,10 +2,10 @@ from quart import request
from typing import Iterable, Optional, Union
from suma_browser.app.filters.qs_base import (
from shared.browser.app.filters.qs_base import (
KEEP, _norm, make_filter_set, build_qs,
)
from suma_browser.app.filters.query_types import MarketQuery
from shared.browser.app.filters.query_types import MarketQuery
def decode() -> MarketQuery:

View File

@@ -15,8 +15,8 @@ from ..browse.services.slugs import canonical_html_slug
from ..browse.services.blacklist.product import is_product_blocked
from ..browse.services import db_backend as cb
from ..browse.services import _massage_product
from utils import host_url
from suma_browser.app.redis_cacher import cache_page, clear_cache
from shared.utils import host_url
from shared.browser.app.redis_cacher import cache_page, clear_cache
from ..cart.services import total
from .services.product_operations import toggle_product_like, massage_full_product
@@ -94,7 +94,7 @@ def register():
@bp.get("/")
@cache_page(tag="browse")
async def product_detail(slug: str):
from suma_browser.app.utils.htmx import is_htmx_request
from shared.browser.app.utils.htmx import is_htmx_request
# Determine which template to use based on request type
if not is_htmx_request():
@@ -136,11 +136,11 @@ def register():
)
return html
@bp.get("/admin/")
async def admin(slug: str):
from suma_browser.app.utils.htmx import is_htmx_request
from shared.browser.app.utils.htmx import is_htmx_request
if not is_htmx_request():
# Normal browser request: full page with layout
@@ -152,8 +152,8 @@ def register():
return await make_response(html)
from suma_browser.app.bp.cart.services.identity import current_cart_identity
#from suma_browser.app.bp.cart.routes import view_cart
from bp.cart.services.identity import current_cart_identity
#from bp.cart.routes import view_cart
from models.market import CartItem
from quart import request, url_for
@@ -192,11 +192,23 @@ def register():
ident = current_cart_identity()
filters = [CartItem.deleted_at.is_(None), CartItem.product_id == product_id]
# Load cart items for current user/session
from sqlalchemy.orm import selectinload
cart_filters = [CartItem.deleted_at.is_(None)]
if ident["user_id"] is not None:
filters.append(CartItem.user_id == ident["user_id"])
cart_filters.append(CartItem.user_id == ident["user_id"])
else:
filters.append(CartItem.session_id == ident["session_id"])
cart_filters.append(CartItem.session_id == ident["session_id"])
cart_result = await g.s.execute(
select(CartItem)
.where(*cart_filters)
.order_by(CartItem.created_at.desc())
.options(
selectinload(CartItem.product),
selectinload(CartItem.market_place),
)
)
g.cart = list(cart_result.scalars().all())
ci = next(
(item for item in g.cart if item.product_id == product_id),
@@ -238,11 +250,15 @@ def register():
"_types/product/_added.html",
cart=g.cart,
item=ci,
total = total
total=total,
cart_count=sum(i.quantity for i in g.cart),
cart_total=total(g.cart),
calendar_total=lambda entries: 0,
calendar_cart_entries=[],
)
# normal POST: go to cart page
from shared.urls import cart_url
from shared.infrastructure.urls import cart_url
return redirect(cart_url("/"))

View File

@@ -13,7 +13,7 @@ def massage_full_product(product: Product) -> dict:
Convert a Product ORM model to a dictionary with all fields.
Used for rendering product detail pages.
"""
from suma_browser.app.bp.browse.services import _massage_product
from bp.browse.services import _massage_product
gallery = []
if product.image:

83
config/app-config.yaml Normal file
View File

@@ -0,0 +1,83 @@
# App-wide settings
base_host: "wholesale.suma.coop"
base_login: https://wholesale.suma.coop/customer/account/login/
base_url: https://wholesale.suma.coop/
title: Rose Ash
coop_root: /market
coop_title: Market
blog_root: /
blog_title: all the news
cart_root: /cart
app_urls:
coop: "http://localhost:8000"
market: "http://localhost:8001"
cart: "http://localhost:8002"
events: "http://localhost:8003"
cache:
fs_root: _snapshot # <- absolute path to your snapshot dir
categories:
allow:
Basics: basics
Branded Goods: branded-goods
Chilled: chilled
Frozen: frozen
Non-foods: non-foods
Supplements: supplements
Christmas: christmas
slugs:
skip:
- ""
- customer
- account
- checkout
- wishlist
- sales
- contact
- privacy-policy
- terms-and-conditions
- delivery
- catalogsearch
- quickorder
- apply
- search
- static
- media
section-titles:
- ingredients
- allergy information
- allergens
- nutritional information
- nutrition
- storage
- directions
- preparation
- serving suggestions
- origin
- country of origin
- recycling
- general information
- additional information
- a note about prices
blacklist:
category:
- branded-goods/alcoholic-drinks
- branded-goods/beers
- branded-goods/wines
- branded-goods/ciders
product:
- list-price-suma-current-suma-price-list-each-bk012-2-html
- ---just-lem-just-wholefoods-jelly-crystals-lemon-12-x-85g-vf067-2-html
product-details:
- General Information
- A Note About Prices
# SumUp payment settings (fill these in for live usage)
sumup:
merchant_code: "ME4J6100"
currency: "GBP"
# Name of the environment variable that holds your SumUp API key
api_key_env: "SUMUP_API_KEY"
webhook_secret: "CHANGE_ME_TO_A_LONG_RANDOM_STRING"
checkout_reference_prefix: 'dev-'

8
models/__init__.py Normal file
View File

@@ -0,0 +1,8 @@
from .market import (
Product, ProductLike, ProductImage, ProductSection,
NavTop, NavSub, Listing, ListingItem,
LinkError, LinkExternal, SubcategoryRedirect, ProductLog,
ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen,
CartItem,
)
from .market_place import MarketPlace

7
models/market.py Normal file
View File

@@ -0,0 +1,7 @@
from shared.models.market import ( # noqa: F401
Product, ProductLike, ProductImage, ProductSection,
NavTop, NavSub, Listing, ListingItem,
LinkError, LinkExternal, SubcategoryRedirect, ProductLog,
ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen,
CartItem,
)

1
models/market_place.py Normal file
View File

@@ -0,0 +1 @@
from shared.models.market_place import MarketPlace # noqa: F401

View File

@@ -1,7 +1,9 @@
import sys
import os
# Add the shared library submodule to the Python path
_shared = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shared_lib")
if _shared not in sys.path:
sys.path.insert(0, _shared)
_app_dir = os.path.dirname(os.path.abspath(__file__))
_project_root = os.path.dirname(_app_dir)
for _p in (_project_root, _app_dir):
if _p not in sys.path:
sys.path.insert(0, _p)

View File

@@ -7,9 +7,9 @@ from typing import Dict, Set
from ..http_client import configure_cookies
from ..get_auth import login
from config import config
from shared.config import config
from utils import log
from shared.utils import log
# DB: persistence helpers

View File

@@ -3,7 +3,7 @@ from bs4 import BeautifulSoup
from urllib.parse import urlparse, urljoin
from ._anchor_text import _anchor_text
from suma_browser.app.bp.browse.services.slugs import product_slug_from_href
from bp.browse.services.slugs import product_slug_from_href
from .APP_ROOT_PLACEHOLDER import APP_ROOT_PLACEHOLDER
def _rewrite_links_fragment(

View File

@@ -1,6 +1,6 @@
from urllib.parse import urljoin
from config import config
from utils import log
from shared.config import config
from shared.utils import log
from ...listings import scrape_products
async def capture_category(

View File

@@ -1,7 +1,7 @@
from typing import Dict, Set
from .capture_category import capture_category
from .capture_sub import capture_sub
from config import config
from shared.config import config
async def capture_product_slugs(

View File

@@ -1,7 +1,7 @@
from urllib.parse import urljoin
from urllib.parse import urljoin
from config import config
from utils import log
from shared.config import config
from shared.utils import log
from ...listings import scrape_products
async def capture_sub(

View File

@@ -6,12 +6,12 @@ import httpx
from ...html_utils import to_fragment
from suma_browser.app.bp.browse.services.slugs import suma_href_from_html_slug
from bp.browse.services.slugs import suma_href_from_html_slug
from config import config
from utils import log
from shared.config import config
from shared.utils import log
# DB: persistence helpers
from ...product.product_detail import scrape_product_detail

View File

@@ -1,7 +1,7 @@
import asyncio
from typing import Dict, List, Set
from config import config
from utils import log
from shared.config import config
from shared.utils import log
from .fetch_and_upsert_product import fetch_and_upsert_product

View File

@@ -1,7 +1,7 @@
from typing import Dict
from urllib.parse import urljoin
from config import config
from shared.config import config
def rewrite_nav(nav: Dict[str, Dict], nav_redirects:Dict[str, str]):
if nav_redirects:

View File

@@ -2,7 +2,7 @@ from typing import Optional, Dict, Any, List
from urllib.parse import urljoin
import httpx
from bs4 import BeautifulSoup
from config import config
from shared.config import config
class LoginFailed(Exception):
def __init__(self, message: str, *, debug: Dict[str, Any]):

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import Optional
from bs4 import BeautifulSoup
from urllib.parse import urljoin
from config import config
from shared.config import config

View File

@@ -7,7 +7,7 @@ import secrets
from typing import Optional, Dict
import httpx
from config import config
from shared.config import config
_CLIENT: httpx.AsyncClient | None = None

View File

@@ -7,8 +7,8 @@ from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse
from .http_client import fetch
from suma_browser.app.bp.browse.services.slugs import product_slug_from_href
from suma_browser.app.bp.browse.services.state import (
from bp.browse.services.slugs import product_slug_from_href
from bp.browse.services.state import (
KNOWN_PRODUCT_SLUGS,
_listing_page_cache,
_listing_page_ttl,
@@ -16,8 +16,8 @@ from suma_browser.app.bp.browse.services.state import (
_listing_variant_ttl,
now,
)
from utils import normalize_text, soup_of
from config import config
from shared.utils import normalize_text, soup_of
from shared.config import config
def parse_total_pages_from_text(text: str) -> Optional[int]:

View File

@@ -5,7 +5,7 @@ from typing import Dict, List, Tuple, Optional
from urllib.parse import urlparse, urljoin
from bs4 import BeautifulSoup
from config import config
from shared.config import config
from .http_client import fetch # only fetch; define soup_of locally
#from .. import cache_backend as cb
#from ..blacklist.category import is_category_blocked # Reverse map: slug -> label

View File

@@ -17,7 +17,7 @@ from models.market import (
Listing,
ListingItem,
)
from db.session import get_session
from shared.db.session import get_session
# --- Models are unchanged, see original code ---

View File

@@ -5,7 +5,7 @@ from typing import Dict
from models.market import (
ProductLog,
)
from db.session import get_session
from shared.db.session import get_session
async def log_product_result(ok: bool, payload: Dict) -> None:

View File

@@ -7,7 +7,7 @@ from models.market import (
LinkError,
LinkExternal,
)
from db.session import get_session
from shared.db.session import get_session
# --- Models are unchanged, see original code ---

View File

@@ -9,7 +9,7 @@ from models.market import (
NavTop,
NavSub,
)
from db.session import get_session
from shared.db.session import get_session

View File

@@ -8,7 +8,7 @@ from sqlalchemy import (
from models.market import (
SubcategoryRedirect,
)
from db.session import get_session
from shared.db.session import get_session
# --- Models are unchanged, see original code ---

View File

@@ -17,7 +17,7 @@ from models.market import (
ProductNutrition,
ProductAllergen
)
from db.session import get_session
from shared.db.session import get_session
from ._get import _get
from .log_product_result import _log_product_result

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import Dict, List, Union
from urllib.parse import urlparse
from bs4 import BeautifulSoup
from utils import normalize_text
from shared.utils import normalize_text
from ..registry import extractor
@extractor

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import Dict, List
from bs4 import BeautifulSoup
from utils import normalize_text
from shared.utils import normalize_text
from ...html_utils import absolutize_fragment
from ..registry import extractor
from ..helpers.desc import (

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import Dict, Union
from bs4 import BeautifulSoup
from utils import normalize_text
from shared.utils import normalize_text
from ..registry import extractor
from ..helpers.price import parse_price, parse_case_size

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import Dict, List
from bs4 import BeautifulSoup
from utils import normalize_text
from shared.utils import normalize_text
from ..registry import extractor
@extractor

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Dict, List, Optional, Tuple
import re
from bs4 import BeautifulSoup
from utils import normalize_text
from shared.utils import normalize_text
from ..registry import extractor
from ..helpers.desc import (
split_description_container, find_description_container,

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import Dict
from bs4 import BeautifulSoup
from utils import normalize_text
from shared.utils import normalize_text
from ..registry import extractor
@extractor

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import Dict
from bs4 import BeautifulSoup
from utils import normalize_text
from shared.utils import normalize_text
from ..registry import extractor
@extractor

View File

@@ -2,10 +2,10 @@
from __future__ import annotations
from typing import Dict, List, Optional, Tuple
from bs4 import BeautifulSoup, NavigableString, Tag
from utils import normalize_text
from shared.utils import normalize_text
from ...html_utils import absolutize_fragment
from .text import clean_title, is_blacklisted_heading
from config import config
from shared.config import config
def split_description_container(desc_el: Tag) -> Tuple[str, List[Dict]]:

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import List, Optional
from urllib.parse import urljoin, urlparse
from config import config
from shared.config import config
def first_from_srcset(val: str) -> Optional[str]:
if not val:

View File

@@ -1,8 +1,8 @@
from __future__ import annotations
import re
from utils import normalize_text
from config import config
from shared.utils import normalize_text
from shared.config import config
def clean_title(t: str) -> str:
t = normalize_text(t)

View File

@@ -1,10 +1,10 @@
from __future__ import annotations
from typing import Dict, Tuple, Union
from utils import soup_of
from shared.utils import soup_of
from ..http_client import fetch
from ..html_utils import absolutize_fragment
from suma_browser.app.bp.browse.services.slugs import product_slug_from_href
from bp.browse.services.slugs import product_slug_from_href
from .registry import REGISTRY, merge_missing
from . import extractors as _auto_register # noqa: F401 (import-time side effects)

26
services/__init__.py Normal file
View File

@@ -0,0 +1,26 @@
"""Market app service registration."""
from __future__ import annotations
def register_domain_services() -> None:
"""Register services for the market app.
Market owns: Product, CartItem, MarketPlace, NavTop, NavSub,
Listing, ProductImage.
Standard deployment registers all 4 services as real DB impls
(shared DB). For composable deployments, swap non-owned services
with stubs from shared.services.stubs.
"""
from shared.services.registry import services
from shared.services.blog_impl import SqlBlogService
from shared.services.calendar_impl import SqlCalendarService
from shared.services.market_impl import SqlMarketService
from shared.services.cart_impl import SqlCartService
services.market = SqlMarketService()
if not services.has("blog"):
services.blog = SqlBlogService()
if not services.has("calendar"):
services.calendar = SqlCalendarService()
if not services.has("cart"):
services.cart = SqlCartService()

1
shared Submodule

Submodule shared added at d404349806

Submodule shared_lib deleted from 356d916e26

View File

@@ -1,7 +0,0 @@
{% import "macros/links.html" as links %}
{% if g.rights.admin %}
{% from 'macros/admin_nav.html' import admin_nav_item %}
{{admin_nav_item(
url_for('market.browse.product.admin', slug=slug)
)}}
{% endif %}

View File

@@ -1,5 +0,0 @@
<div class="grid grid-cols-1 sm:grid-cols-3 md:grid-cols-6 gap-3">
{% include "_types/browse/_product_cards.html" %}
</div>
<div class="pb-8"></div>

View File

@@ -1,104 +0,0 @@
{% import 'macros/stickers.html' as stick %}
{% import '_types/product/prices.html' as prices %}
{% set prices_ns = namespace() %}
{{ prices.set_prices(p, prices_ns) }}
{% set item_href = url_for('market.browse.product.product_detail', slug=p.slug)|host %}
<div class="flex flex-col rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden relative">
{# ❤️ like button overlay - OUTSIDE the link #}
{% if g.user %}
<div class="absolute top-2 right-2 z-10 text-6xl md:text-xl">
{% set slug = p.slug %}
{% set liked = p.is_liked or False %}
{% include "_types/browse/like/button.html" %}
</div>
{% endif %}
<a
href="{{ item_href }}"
hx-get="{{ item_href }}"
hx-target="#main-panel"
hx-select ="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class=""
>
{# Make this relative so we can absolutely position children #}
<div class="w-full aspect-square bg-stone-100 relative">
{% if p.image %}
<figure class="inline-block w-full h-full">
<div class="relative w-full h-full">
<img
src="{{ p.image }}"
alt="no image"
class="absolute inset-0 w-full h-full object-contain object-top"
loading="lazy" decoding="async" fetchpriority="low"
/>
{% for l in p.labels %}
<img
src="{{ asset_url('labels/' + l + '.svg') }}"
alt=""
class="pointer-events-none absolute inset-0 w-full h-full object-contain object-top"
/>
{% endfor %}
</div>
<figcaption class="
mt-2 text-sm text-center
{{ 'bg-yellow-200' if p.brand in selected_brands else '' }}
text-stone-600
">
{{ p.brand }}
</figcaption>
</figure>
{% else %}
<div class="p-2 flex flex-col items-center justify-center gap-2 text-red-500 h-full relative">
<div class="text-stone-400 text-xs">No image</div>
<ul class="flex flex-row gap-1">
{% for l in p.labels %}
<li>{{ l }}</li>
{% endfor %}
</ul>
<div class="text-stone-900 text-center line-clamp-3 break-words [overflow-wrap:anywhere]">
{{ p.brand }}
</div>
</div>
{% endif %}
</div>
{# <div>{{ prices.rrp(prices_ns) }}</div> #}
{{ prices.card_price(p)}}
{% import '_types/product/_cart.html' as _cart %}
</a>
<div class="flex justify-center">
{{ _cart.add(p.slug, cart)}}
</div>
<a
href="{{ item_href }}"
hx-get="{{ item_href }}"
hx-target="#main-panel"
hx-select ="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
<div class="flex flex-row justify-center gap-2 p-2">
{% for s in p.stickers %}
{{ stick.sticker(
asset_url('stickers/' + s + '.svg'),
s,
True,
size=24,
found=s in selected_stickers
) }}
{% endfor %}
</div>
<div class="text-sm font-medium text-stone-800 text-center line-clamp-3 break-words [overflow-wrap:anywhere]">
{{ p.title | highlight(search) }}
</div>
</a>
</div>

View File

@@ -1,107 +0,0 @@
{% for p in products %}
{% include "_types/browse/_product_card.html" %}
{% endfor %}
{% if page < total_pages|int %}
<div
id="sentinel-{{ page }}-m"
class="block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host }}"
hx-trigger="intersect once delay:250ms, sentinelmobile:retry"
hx-swap="outerHTML"
_="
init
if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end
if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' end
on resize from window
if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' else remove @hx-disabled end
on htmx:beforeRequest
if window.matchMedia('(min-width: 768px)').matches then halt end
add .hidden to .js-neterr in me
remove .hidden from .js-loading in me
remove .opacity-100 from me
add .opacity-0 to me
def backoff()
set ms to me.dataset.retryMs
if ms > 30000 then set ms to 30000 end
-- show big SVG panel & make sentinel visible
add .hidden to .js-loading in me
remove .hidden from .js-neterr in me
remove .opacity-0 from me
add .opacity-100 to me
wait ms ms
trigger sentinelmobile:retry
set ms to ms * 2
if ms > 30000 then set ms to 30000 end
set me.dataset.retryMs to ms
end
on htmx:sendError call backoff()
on htmx:responseError call backoff()
on htmx:timeout call backoff()
"
role="status"
aria-live="polite"
aria-hidden="true"
>
{% include "sentinel/mobile_content.html" %}
</div>
<!-- DESKTOP sentinel (custom scroll container) -->
<div
id="sentinel-{{ page }}-d"
class="hidden md:block h-4 opacity-0 pointer-events-none"
hx-get="{{ (current_local_href ~ {'page': page + 1}|qs)|host}}"
hx-trigger="intersect once delay:250ms, sentinel:retry"
hx-swap="outerHTML"
_="
init
if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end
on htmx:beforeRequest(event)
add .hidden to .js-neterr in me
remove .hidden from .js-loading in me
remove .opacity-100 from me
add .opacity-0 to me
set trig to null
if event.detail and event.detail.triggeringEvent then
set trig to event.detail.triggeringEvent
end
if trig and trig.type is 'intersect'
set scroller to the closest .js-grid-viewport
if scroller is null then halt end
if scroller.scrollTop < 20 then halt end
end
def backoff()
set ms to me.dataset.retryMs
if ms > 30000 then set ms to 30000 end
add .hidden to .js-loading in me
remove .hidden from .js-neterr in me
remove .opacity-0 from me
add .opacity-100 to me
wait ms ms
trigger sentinel:retry
set ms to ms * 2
if ms > 30000 then set ms to 30000 end
set me.dataset.retryMs to ms
end
on htmx:sendError call backoff()
on htmx:responseError call backoff()
on htmx:timeout call backoff()
"
role="status"
aria-live="polite"
aria-hidden="true"
>
{% include "sentinel/desktop_content.html" %}
</div>
{% else %}
<div class="col-span-full mt-4 text-center text-xs text-stone-400">End of results</div>
{% endif %}

View File

@@ -1,40 +0,0 @@
{# Categories #}
<nav aria-label="Categories"
class="rounded-xl border bg-white shadow-sm min-h-0">
<ul class="divide-y">
{% set top_active = (current_local_href == top_local_href) %}
{% set href = (url_for('market.browse.browse_top', top_slug=top_slug) ~ qs)|host %}
<li>
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
aria-selected="{{ 'true' if top_active else 'false' }}"
class="block px-4 py-3 text-[15px] transition {{select_colours}}">
<div class="prose prose-stone max-w-none">All products</div>
</a>
</li>
{% for sub in subs_local %}
{% set active = (current_local_href == sub.local_href) %}
{% set href = (url_for('market.browse.browse_sub', top_slug=top_slug, sub_slug=sub.slug) ~ qs)|host %}
<li>
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
aria-selected="{{ 'true' if active else 'false' }}"
class="block px-4 py-3 text-[15px] border-l-4 transition {{select_colours}}"
>
<div class="prose prose-stone max-w-none">{{ (sub.html_label or sub.name) | safe }}</div>
</a>
</li>
{% endfor %}
</ul>
</nav>

View File

@@ -1,40 +0,0 @@
{# Brand filter (desktop, single-select) #}
{# Brands #}
<nav aria-label="Brands"
class="rounded-xl border bg-white shadow-sm">
<h2 class="text-md mt-2 font-semibold">Brands</h2>
<ul class="divide-y">
{% for b in brands %}
{% set is_selected = (b.name in selected_brands) %}
{% if is_selected %}
{% set brand_href = (current_local_href ~ {"remove_brand": b.name, "page": None}|qs)|host %}
{% else %}
{% set brand_href = (current_local_href ~ {"add_brand": b.name, "page": None}|qs)|host %}
{% endif %}
<li>
<a
href="{{ brand_href }}"
hx-get="{{ brand_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML" hx-push-url="true" hx-on:htmx:afterSwap="this.closest('details')?.removeAttribute('open')"
class="flex items-center gap-2 px-2 py-2 rounded transition {% if is_selected %} bg-stone-900 text-white {% else %} hover:bg-stone-50 {% endif %}">
<span class="inline-flex items-center justify-center w-5 h-5 rounded border {% if is_selected %} border-stone-900 bg-stone-900 text-white {% else %} border-stone-300 {% endif %}">
{% if is_selected %}
<svg viewBox="0 0 24 24" class="w-4 h-4" aria-hidden="true">
<path d="M5 13l4 4L19 7" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{% endif %}
</span>
<span class="flex-1 text-sm">{{ b.name }}</span>
{% if b.count is not none %}
<span class="{% if b.count==0 %}text-lg text-red-500{% else %}text-sm{% endif %} {% if is_selected %}opacity-90{% else %}text-stone-500{% endif %}">{{ b.count }}</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
</nav>

View File

@@ -1,44 +0,0 @@
{% import 'macros/stickers.html' as stick %}
<ul
id="labels-details-desktop"
class="flex justify-center p-0 m-0 gap-2"
>
{% for s in labels %}
{% set is_on = (selected_labels and (s.name|lower in selected_labels)) %}
{% set qs = {"remove_label": s.name, "page":None}|qs if is_on
else {"add_label": s.name, "page":None}|qs %}
{% set href = (current_local_href ~ qs)|host %}
<li>
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
role="button"
aria-pressed="{{ 'true' if is_on else 'false' }}"
title="{{ s.name }}" aria-label="{{ s.name }}"
class="flex w-full h-full flex-col items-center justify-center py-2"
>
<!-- col 1: icon -->
{{ stick.sticker(asset_url('nav-labels/' + s.name + '.svg'), s.name, is_on)}}
<!-- col 3: count (right aligned) -->
{% if s.count is not none %}
<span class="
{{'text-xs text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
leading-none justify-self-end tabular-nums">
{{ s.count }}
</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>

View File

@@ -1,38 +0,0 @@
{% import 'macros/stickers.html' as stick %}
{% set qs = {"liked": None if liked else True, "page": None}|qs %}
{% set href = (current_local_href ~ qs)|host %}
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
role="button"
aria-pressed="{{ 'true' if liked else 'false' }}"
title="liked" aria-label="liked"
class="flex flex-col items-center justify-start p-1 rounded hover:bg-stone-50"
{% if liked %}
aria-label="liked and unliked"
{% else %}
aria-label="just liked"
{% endif %}
>
{% if liked %}
<i aria-hidden="true"
class="fa-solid fa-heart text-red-500 text-[40px] leading-none"
></i>
{% else %}
<i aria-hidden="true"
class="fa-solid fa-heart text-stone-300 text-[40px] leading-none"
></i>
{% endif %}
<span class="
{{'text-[10px] text-stone-500' if liked_count != 0 else 'text-md text-red-500 font-bold'}}
mt-1 leading-none tabular-nums
"
aria_label="liked count"
>
{{ liked_count }}
</span>
</a>

View File

@@ -1,44 +0,0 @@
{% macro search(current_local_href,search, search_count, hx_select) -%}
<!-- Search (1/3 width → 4/12 columns) -->
<!-- nb this does NOT oob itself!! -->
<div
id="search-desktop-wrapper"
class="flex flex-row gap-2 items-center"
>
<input
id="search-desktop"
type="text"
name="search"
aria-label="search"
class="w-full mx-1 my-3 px-3 py-2 text-md rounded-xl border-2 shadow-sm border-white placeholder-shown:border-white [&:not(:placeholder-shown)]:border-yellow-200"
hx-preserve
value="{{ search|default('', true) }}"
placeholder="search"
hx-trigger="input changed delay:300ms"
hx-target="#main-panel"
hx-select="{{hx_select}}, #search-mobile-wrapper, #search-desktop-wrapper"
hx-get="{{ (current_local_href ~ {'search': None}|qs)|host}}"
hx-swap="outerHTML"
hx-push-url="true"
hx-headers='{"X-Origin":"search-desktop", "X-Search":"true"}'
hx-sync="this:replace"
autocomplete="off"
>
<div
id="search-count-desktop"
aria-label="search count"
{% if not search_count %}
class="text-xl text-red-500"
{% endif %}
>
{% if search %}
{{search_count}}
{% endif %}
{{zap_filter}}
</div>
</div>
{% endmacro %}

View File

@@ -1,34 +0,0 @@
{% import 'macros/stickers.html' as stick %}
{% set sort_val = sort|default('az', true) %}
<ul
id="sort-details-desktop"
class="flex w-full p-0 m-0 border bg-white shadow-sm rounded-xl gap-0 [&>li]:list-none [&>li]:flex-1"
>
{% for key,label,icon in sort_options %}
{% set is_on = (sort_val == key) %}
{% set qs = {"sort": None, "page": None}|qs if is_on
else {"sort": key, "page": None}|qs %}
{% set href = (current_local_href ~ qs)|host %}
<li>
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
role="button"
aria-pressed="{{ 'true' if is_on else 'false' }}"
class="flex flex-col items-center justify-center w-full h-full py-2 m-0"
>
{{ stick.sticker(asset_url(icon), label, is_on) }}
</a>
</li>
{% endfor %}
</ul>

View File

@@ -1,46 +0,0 @@
{% import 'macros/stickers.html' as stick %}
<ul
id="stickers-details-desktop"
class="flex flex-wrap justify-center w-full p-0 m-0 border bg-white shadow-sm rounded-xl gap-1 [&>li]:list-none [&>li]:basis-[20%] [&>li]:max-w-[20%] [&>li]:grow-0"
>
{% for s in stickers %}
{% set is_on = (selected_stickers and (s.name|lower in selected_stickers)) %}
{% set qs = {"remove_sticker": s.name, "page": None}|qs if is_on
else {"add_sticker": s.name, "page": None}|qs %}
{% set href = (current_local_href ~ qs)|host%}
<li>
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
role="button"
aria-pressed="{{ 'true' if is_on else 'false' }}"
title="{{ s.name }}" aria-label="{{ s.name }}"
class="flex w-full h-full flex-col items-center justify-center py-2"
>
<span class="text-[11px]">{{s.name|capitalize if s.name|lower != 'sugarfree' else 'Sugar'}}</span>
<!-- col 1: icon -->
{{ stick.sticker(asset_url('stickers/' + s.name + '.svg'), s.name, is_on)}}
<!-- col 3: count (right aligned) -->
{% if s.count is not none %}
<span class="
{{'text-xs text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
leading-none justify-self-end tabular-nums">
{{ s.count }}
</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>

View File

@@ -1,37 +0,0 @@
{% import '_types/browse/desktop/_filter/search.html' as s %}
{{ s.search(current_local_href, search, search_count, hx_select) }}
<div
id="category-summary-desktop"
hxx-swap-oob="outerHTML"
>
<div class="mb-4">
<div class="text-2xl uppercase tracking-wide text-black-500">{{ category_label }}</div>
</div>
{% include "_types/browse/desktop/_filter/sort.html" %}
<nav aria-label="like" class="flex flex-row justify-center w-full p-0 m-0 border bg-white shadow-sm rounded-xl gap-1">
{% include "_types/browse/desktop/_filter/like.html" %}
{% if labels %}
{% include "_types/browse/desktop/_filter/labels.html" %}
{% endif %}
</nav>
{% if stickers %}
{% include "_types/browse/desktop/_filter/stickers.html" %}
{% endif %}
{% if subs_local and top_local_href %}
{% include "_types/browse/desktop/_category_selector.html" %}
{% endif %}
</div>
<div
id="filter-summary-desktop"
hxx-swap-oob="outerHTML"
>
{% include "_types/browse/desktop/_filter/brand.html" %}
</div>

View File

@@ -1,13 +0,0 @@
{% extends '_types/market/index.html' %}
{% block filter %}
{% include "_types/browse/mobile/_filter/summary.html" %}
{% endblock %}
{% block aside %}
{% include "_types/browse/desktop/menu.html" %}
{% endblock %}
{% block content %}
{% include "_types/browse/_main_panel.html" %}
{% endblock %}

View File

@@ -1,20 +0,0 @@
<button
class="flex items-center gap-1 {% if liked %} text-red-600 {% else %} text-stone-300 {% endif %} hover:text-red-600 transition-colors w-[1em] h-[1em]"
hx-post="{{ like_url if like_url else url_for('market.browse.product.like_toggle', slug=slug)|host }}"
hx-target="this"
hx-swap="outerHTML"
hx-push-url="false"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-swap-settle="0ms"
{% if liked %}
aria-label="Unlike this {{ item_type if item_type else 'product' }}"
{% else %}
aria-label="Like this {{ item_type if item_type else 'product' }}"
{% endif %}
>
{% if liked %}
<i aria-hidden="true" class="fa-solid fa-heart"></i>
{% else %}
<i aria-hidden="true" class="fa-regular fa-heart"></i>
{% endif %}
</button>

View File

@@ -1,40 +0,0 @@
<nav aria-label="Brands" class="px-4 pb-3" >
{% if brands|length %}
<h2 class="text-md mt-2 font-semibold">Brands</h2>
<ul class="space-y-1 pr-1" >
{% for b in brands %}
{% set is_selected = (b.name in selected_brands) %}
<li>
{{current_local_href}}
<a
{% if is_selected %}
{% set href = (current_local_href ~ {"remove_brand": b.name, "page": None}|qs)|host %}
{% else %}
{% set href = (current_local_href ~ {"add_brand": b.name, "page": None}|qs)|host %}
{%endif%}
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="flex items-center gap-2 my-3 px-2 py-2 rounded transition {% if is_selected %} bg-stone-900 text-white {% else %} hover:bg-stone-50 {% endif %}">
<span class="inline-flex items-center justify-center w-5 h-5 rounded border {% if is_selected %} border-stone-900 bg-stone-900 text-white {% else %} border-stone-300 {% endif %}">
{% if is_selected %}
<svg viewBox="0 0 24 24" class="w-4 h-4" aria-hidden="true">
<path d="M5 13l4 4L19 7" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{% endif %}
</span>
<span class="flex-1 text-sm">{{ b.name }}</span>
{% if b.count is not none %}
<span class="text-xs {% if is_selected %}opacity-90{% else %}text-stone-500{% endif %}">{{ b.count }}</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
{% endif %}
</nav>

View File

@@ -1,30 +0,0 @@
{% include "_types/browse/mobile/_filter/sort_ul.html" %}
{% if search or selected_labels|length or selected_stickers|length or selected_brands|length %}
{% set href = (current_local_href ~ {"clear_filters": True}|qs)|host %}
<div class = "flex flex-row justify-center">
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
role="button"
title="clear filters"
aria-label="clear filters"
class="flex flex-col items-center justify-start p-1 rounded bg-stone-200 text-black cursor-pointer">
<span class="mt-1 leading-none tabular-nums"
>
clear filters
</span>
</a>
</div>
{% endif %}
<div class="flex flex-row gap-2 justify-center items center">
{% include "_types/browse/mobile/_filter/like.html" %}
{% include "_types/browse/mobile/_filter/labels.html" %}
</div>
{% include "_types/browse/mobile/_filter/stickers.html" %}
{% include "_types/browse/mobile/_filter/brand_ul.html" %}

View File

@@ -1,47 +0,0 @@
{% import 'macros/stickers.html' as stick %}
<nav aria-label="labels" class="px-4 pb-3">
{# One row only; center when not overflowing; horizontal scroll when needed #}
<ul
class="flex w-full items-start justify-center gap-3 overflow-x-auto overflow-y-visible pr-1 no-scrollbar"
>
{% for s in labels %}
{% set is_on = (selected_labels and (s.name|lower in selected_labels)) %}
{% set qs = {"remove_label": s.name, "page": None}|qs if is_on
else {"add_label": s.name, "page": None}|qs %}
{% set href = (current_local_href ~ qs)|host %}
<li class="list-none shrink-0">
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
role="button"
aria-pressed="{{ 'true' if is_on else 'false' }}"
title="{{ s.name }}" aria-label="{{ s.name }}"
class="flex flex-col items-center justify-start p-1 rounded hover:bg-stone-50">
{{ stick.sticker(asset_url('nav-labels/' + s.name + '.svg'), s.name, is_on)}}
{% if s.count is not none %}
<span class="
{{'text-[10px] text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
mt-1 leading-none tabular-nums
"
>
{{ s.count }}
</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
</nav>
{# Optional: hide horizontal scrollbar on mobile while keeping scrollable #}
<style>
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
</style>

View File

@@ -1,40 +0,0 @@
{% import 'macros/stickers.html' as stick %}
<nav aria-label="like" class="px-4 pb-3">
{% set qs = {"liked": None if liked else True, "page": None}|qs%}
{% set href = (current_local_href ~ qs)|host %}
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
role="button"
aria-pressed="{{ 'true' if liked else 'false' }}"
title="liked" aria-label="liked"
class="flex flex-col items-center justify-start p-1 rounded hover:bg-stone-50"
{% if liked %}
aria-label="liked and unliked"
{% else %}
aria-label="just liked"
{% endif %}
>
{% if liked %}
<i aria-hidden="true"
class="fa-solid fa-heart text-red-500 text-[40px] leading-none"
></i>
{% else %}
<i aria-hidden="true"
class="fa-solid fa-heart text-stone-500 text-[40px] leading-none"
></i>
{% endif %}
<span class="
{{'text-[10px] text-stone-500' if liked_count != 0 else 'text-md text-red-500 font-bold'}}
mt-1 leading-none tabular-nums
"
aria_label="liked count"
>
{{ liked_count }}
</span>
</a>
</nav>

View File

@@ -1,40 +0,0 @@
{% macro search(current_local_href, search, search_count, hx_select) -%}
<div
id="search-mobile-wrapper"
class="flex flex-row gap-2 items-center flex-1 min-w-0 pr-2"
>
<input
id="search-mobile"
type="text"
name="search"
aria-label="search"
class="text-base md:text-sm col-span-5 rounded-md px-3 py-2 mb-2 w-full min-w-0 max-w-full border-2 border-stone-200 placeholder-shown:border-stone-200 [&:not(:placeholder-shown)]:border-yellow-200"
hx-preserve
value="{{ search|default('', true) }}"
placeholder="search"
hx-trigger="input changed delay:300ms"
hx-target="#main-panel"
hx-select="{{hx_select}}, #search-mobile-wrapper, #search-desktop-wrapper"
hx-get="{{ (current_local_href ~ {'search': None}|qs)|host }}"
hx-swap="outerHTML"
hx-push-url="true"
hx-headers='{"X-Origin":"search-mobile", "X-Search":"true"}'
hx-sync="this:replace"
autocomplete="off"
>
<div
id="search-count-mobile"
aria-label="search count"
{% if not search_count %}
class="text-xl text-red-500"
{% endif %}
>
{% if search %}
{{search_count}}
{% endif %}
</div>
</div>
{% endmacro %}

View File

@@ -1,33 +0,0 @@
{% import 'macros/stickers.html' as stick %}
<nav aria-label="sort" class="px-4 pb-3" >
<ul class="flex w-full items-start justify-center gap-3 overflow-x-auto overflow-y-visible pr-1 no-scrollbar">
{% for key,label,icon in sort_options %}
<li class="list-none">
<div class="flex flex-col items-center justify-center w-full">
<a
{% if sort == key %}
{% set href= (current_local_href, {"sort": None, "page": None}|qs )|host %}
{% else %}
{% set href= (current_local_href ~ {"sort": key, "page": None}|qs )|host %}
{% endif %}
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
{{ stick.sticker(asset_url(icon), label, sort==key) }}
</a>
</div>
</li>
{% endfor %}
</ul>
</nav>

View File

@@ -1,50 +0,0 @@
{% import 'macros/stickers.html' as stick %}
<nav aria-label="stickers" class="px-4 pb-3">
{# One row only; center when not overflowing; horizontal scroll when needed #}
<ul
class="flex w-full items-start justify-center gap-3 overflow-x-auto overflow-y-visible pr-1 no-scrollbar"
>
{% for s in stickers %}
{% set is_on = (selected_stickers and (s.name|lower in selected_stickers)) %}
{% set qs = {"remove_sticker": s.name, "page": None}|qs if is_on
else {"add_sticker": s.name, "page": None}|qs %}
{% set href = (current_local_href ~ qs)|host %}
<li class="list-none shrink-0">
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
role="button"
aria-pressed="{{ 'true' if is_on else 'false' }}"
title="{{ s.name }}" aria-label="{{ s.name }}"
class="flex flex-col items-center justify-start p-1 rounded hover:bg-stone-50">
<span class="text-sm">{{s.name|capitalize if s.name|lower != 'sugarfree' else 'Sugar'}}</span>
{{ stick.sticker(asset_url('stickers/' + s.name + '.svg'), s.name, is_on) }}
{% if s.count is not none %}
<span class="
{{'text-[10px] text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
mt-1 leading-none tabular-nums
"
>
{{ s.count }}
</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
</nav>
{# Optional: hide horizontal scrollbar on mobile while keeping scrollable #}
<style>
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
</style>

View File

@@ -1,120 +0,0 @@
{% import 'macros/stickers.html' as stick %}
{% import 'macros/layout.html' as layout %}
{% call layout.details('/filter', 'md:hidden') %}
{% call layout.filter_summary("filter-summary-mobile", current_local_href, search, search_count, hx_select) %}
<div
class="col-span-12 min-w-0 grid grid-cols-1 gap-1 bg-gray-100 px-2"
role="list">
<div class="flex flex-row items-start gap-2">
{% if sort %}
<ul class="relative inline-flex items-center justify-center gap-2">
<!-- sticker icon -->
{% for k,l,i in sort_options %}
{% if k == sort %}
{% set key = k %}
{% set label = l %}
{% set icon = i %}
<li role="listitem">
{{ stick.sticker(asset_url(icon), label, True)}}
</li>
{% endif %}
{% endfor %}
</ul>
{% endif %}
{% if liked %}
<div class="flex flex-col items-center gap-1 pb-1">
<i aria-hidden="true"
class="fa-solid fa-heart text-red-500 text-[40px] leading-none"
></i>
{% if liked_count is not none %}
<div class="
{{'text-[10px] text-stone-500' if liked_count != 0 else 'text-md text-red-500 font-bold'}}
mt-1 leading-none tabular-nums"
>
{{ liked_count }}
</div>
{% endif %}
</div>
{% endif %}
{% if selected_labels and selected_labels|length %}
<ul class="relative inline-flex items-center justify-center gap-2">
{% for st in selected_labels %}
{% for s in labels %}
{% if st == s.name %}
<li role="listitem" class="flex flex-col items-center gap-1 pb-1">
{{ stick.sticker(asset_url('nav-labels/' + s.name + '.svg'), s.name, True)}}
{% if s.count is not none %}
<div class="
{{'text-[10px] text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
mt-1 leading-none tabular-nums
"
>
{{ s.count }}
</div>
{% endif %}
</li>
{% endif %}
{% endfor %}
{% endfor %}
</ul>
{% endif %}
{% if selected_stickers and selected_stickers|length %}
<ul class="relative inline-flex items-center justify-center gap-2">
{% for st in selected_stickers %}
{% for s in stickers %}
{% if st == s.name %}
<li role="listitem" class="flex flex-col items-center gap-1 pb-1">
<!-- sticker icon -->
{{ stick.sticker(asset_url('stickers/' + s.name + '.svg'), s.name, True)}}
{% if s.count is not none %}
<span class="
{{'text-[10px] text-stone-500' if s.count != 0 else 'text-md text-red-500 font-bold'}}
mt-1 leading-none tabular-nums
"
>
{{ s.count }}
</span>
{% endif %}
</li>
{% endif %}
{% endfor %}
{% endfor %}
</ul>
{% endif %}
</div>
{% if selected_brands and selected_brands|length %}
<ul class_="w-full grid grid-cols-12 items-center gap-3 px-4 py-3">
{% for b in selected_brands %}
<li role="listitem" class="flex flex-row items-center gap-2">
{% set ns = namespace(count=0) %}
{% for brand in brands %}
{% if brand.name == b %}
{% set ns.count = brand.count %}
{% endif %}
{% endfor %}
{% if ns.count %}
<div class="text-md">{{ b }}</div>
<div class="text-md">{{ ns.count }}</div>
{% else %}
<div class="text-md text-red-500">{{ b }}</div>
<div class="text-xl text-red-500">0</div>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endcall %}
<div id="filter-details-mobile" style="display:contents">
{% include "_types/browse/mobile/_filter/index.html" %}
</div>
{% endcall %}

View File

@@ -1,7 +0,0 @@
{% import "macros/links.html" as links %}
{% if g.rights.admin %}
{% from 'macros/admin_nav.html' import admin_nav_item %}
{{admin_nav_item(
url_for('market.admin.admin')
)}}
{% endif %}

View File

@@ -1,23 +0,0 @@
{# Main panel fragment for HTMX navigation - market landing page #}
<article class="relative w-full">
{% if post.custom_excerpt %}
<div class="w-full text-center italic text-3xl p-2">
{{post.custom_excerpt|safe}}
</div>
{% endif %}
{% if post.feature_image %}
<div class="mb-3 flex justify-center">
<img
src="{{ post.feature_image }}"
alt=""
class="rounded-lg w-full md:w-3/4 object-cover"
>
</div>
{% endif %}
<div class="blog-content p-2">
{% if post.html %}
{{post.html|safe}}
{% endif %}
</div>
</article>
<div class="pb-8"></div>

View File

@@ -1,17 +0,0 @@
<div
class="font-bold text-xl flex-shrink-0 flex gap-2 items-center">
<div>
<i class="fa fa-shop"></i>
{{ coop_title }}
</div>
<div class="flex flex-col md:flex-row md:gap-2 text-xs">
<div>
{{top_slug or ''}}
</div>
{% if sub_slug %}
<div>
{{sub_slug}}
</div>
{% endif %}
</div>
</div>

View File

@@ -1 +0,0 @@
market admin

View File

@@ -1,2 +0,0 @@
{% from 'macros/admin_nav.html' import placeholder_nav %}
{{ placeholder_nav() }}

View File

@@ -1,29 +0,0 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - all elements that need updating #}
{# Import shared OOB macros #}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{# Header with app title - includes cart-mini, navigation, and market-specific header #}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('market-header-child', 'market-admin-header-child', '_types/market/admin/header/_header.html')}}
{% from '_types/market/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block mobile_menu %}
{% include '_types/market/admin/_nav.html' %}
{% endblock %}
{% block content %}
{% include "_types/market/admin/_main_panel.html" %}
{% endblock %}

View File

@@ -1,11 +0,0 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='market-admin-row', oob=oob) %}
{% call links.link(url_for('market.admin.admin'), hx_select_search) %}
{{ links.admin() }}
{% endcall %}
{% call links.desktop_nav() %}
{% include '_types/market/admin/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -1,19 +0,0 @@
{% extends '_types/market/index.html' %}
{% block market_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('market-admin-header-child', '_types/market/admin/header/_header.html') %}
{% block market_admin_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include '_types/market/admin/_nav.html' %}
{% endblock %}
{% block content %}
{% include '_types/market/admin/_main_panel.html' %}
{% endblock %}

View File

@@ -1,38 +0,0 @@
<!-- Desktop nav -->
<nav class="hidden md:flex gap-4 text-sm ml-2 w-full justify-end items-center">
{% set all_href = (url_for('market.browse.browse_all') ~ qs)|host %}
{% set all_active = (category_label == 'All Products') %}
<div class="relative nav-group">
<a
href="{{ all_href }}"
hx-get="{{ all_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
aria-selected="{{ 'true' if all_active else 'false' }}"
class="block px-2 py-1 rounded text-center whitespace-normal break-words leading-snug bg-stone-200 text-black {{select_colours}}">
All
</a>
</div>
{% for cat, data in categories.items() %}
{% set cat_href = (url_for('market.browse.browse_top', top_slug=data.slug) ~ qs)|host%}
{% set cat_active = (cat == category_label) %}
<div class="relative nav-group">
<a
href="{{ cat_href }}"
hx-get="{{ cat_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
aria-selected="{{ 'true' if cat_active else 'false' }}"
class="block px-2 py-1 rounded text-center whitespace-normal break-words leading-snug bg-stone-200 text-black {{select_colours}}"
>
{{ cat }}
</a>
</div>
{% endfor %}
{% include '_types/market/_admin.html' %}
</nav>

View File

@@ -1,11 +0,0 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='market-row', oob=oob) %}
{% call links.link(url_for('market.browse.home'), hx_select_search ) %}
{% include '_types/market/_title.html' %}
{% endcall %}
{% call links.desktop_nav() %}
{% include '_types/market/desktop/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -7,7 +7,7 @@
{% if markets %}
<div class="grid gap-4">
{% for m in markets %}
<a href="/{{ m.post.slug }}/{{ m.slug }}/"
<a href="/{{ m.page.slug }}/{{ m.slug }}/"
class="block p-6 bg-white border border-stone-200 rounded-lg hover:border-stone-400 transition-colors">
<h2 class="text-lg font-semibold">{{ m.name }}</h2>
{% if m.description %}

View File

@@ -1,110 +0,0 @@
{% from 'macros/glyphs.html' import opener %}
<div class="px-4 py-2">
<div class="divide-y">
{% set all_href = (url_for('market.browse.browse_all') ~ qs)|host %}
{% set all_active = (category_label == 'All Products') %}
<a role="option"
href="{{ all_href }}"
hx-get="{{ all_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
aria-selected="{{ 'true' if all_active else 'false' }}"
class="block rounded-lg px-3 py-3 text-base hover:bg-stone-50 {{select_colours}}">
<div class="prose prose-stone max-w-none">
All
</div>
</a>
{% for cat, data in categories.items() %}
<details
class="group/cat py-1"
{% if top_slug == (data.slug | lower) %}open{% endif %}
>
<summary class="flex items-center justify-between cursor-pointer select-none block rounded-lg px-3 py-3 text-base hover:bg-stone-50 {% if top_slug==(data.slug | lower) %} bg-stone-900 text-white hover:bg-stone-900 {% endif %}">
{% set href = (url_for('market.browse.browse_top', top_slug=data.slug) ~ qs)|host %}
<a
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
hx-push-url="true"
aria-selected="{{ 'true' if top_slug==(data.slug | lower) else 'false' }}"
class="font-medium {{ select_colours }} flex flex-row gap-2"
>
<div>{{ cat }}</div>
<div aria-label="{{ data.count }} products">{{ data.count }}</div>
</a>
{{ opener('cat')}}
</summary>
<div class="pb-3 pl-2">
{% if data.subs %}
<!-- Viewport -->
<div
data-peek-viewport
data-peek-size-px="18"
data-peek-edge="bottom"
data-peek-mask="true"
class="m-2 bg-stone-100">
<!-- Inner list (no negative margin by default) -->
<div data-peek-inner class="grid grid-cols-1 gap-1 snap-y snap-mandatory pr-1" aria-label="Subcategories">
{% for sub in data.subs %}
{% set href = (url_for('market.browse.browse_sub', top_slug=data.slug, sub_slug=sub.slug) ~qs)|host%}
{% if top_slug==(data.slug | lower) and sub_slug == sub.slug %}
<a
class="snap-start px-2 py-3 rounded {{select_colours}} flex flex-row gap-2"
aria-selected="{{ 'true' if top_slug==(data.slug | lower) and sub_slug == sub.slug else 'false' }}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
<div>{{ sub.html_label or sub.name }}</div>
<div aria-label="{{ sub.count }} products">{{ sub.count }}</div>
</a>
{% endif %}
{% endfor %}
{% for sub in data.subs %}
{% if not (top_slug==(data.slug | lower) and sub_slug == sub.slug) %}
{% set href = (url_for('market.browse.browse_sub', top_slug=data.slug, sub_slug=sub.slug) ~ qs)|host%}
<a
class="snap-start px-2 py-3 rounded {{select_colours}} flex flex-row gap-2"
aria-selected="{{ 'true' if top_slug==(data.slug | lower) and sub_slug == sub.slug else 'false' }}"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>
<div>{{ sub.name }}</div>
<div aria-label="{{ sub.count }} products">{{ sub.count }}</div>
</a>
{% endif %}
{% endfor %}
</div>
</div>
{% else %}
{% set href = (url_for('market.browse.browse_top', top_slug=data.slug) ~ qs)|host%}
<a class="px-2 py-1 rounded hover:bg-stone-100 block"
href="{{ href }}"
hx-get="{{ href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
>View all</a>
{% endif %}
</div>
</details>
{% endfor %}
{% include '_types/market/_admin.html' %}
</div>
</div>

View File

@@ -1,6 +0,0 @@
{% extends 'mobile/menu.html' %}
{% block menu %}
{% block mobile_menu %}
{% endblock %}
{% include '_types/market/mobile/_nav_panel.html' %}
{% endblock %}

View File

@@ -1,25 +0,0 @@
{% set oob='true' %}
{% import '_types/product/_cart.html' as _cart %}
{% from '_types/cart/_mini.html' import mini with context %}
{{mini()}}
{{ _cart.add(d.slug, cart, oob='true')}}
{% from '_types/product/_cart.html' import cart_item with context %}
{% if cart | sum(attribute="quantity") > 0 %}
{% if item.quantity > 0 %}
{{ cart_item(oob='true')}}
{% else %}
{{ cart_item(oob='delete')}}
{% endif %}
{% from '_types/cart/_cart.html' import summary %}
{{ summary(cart, total,calendar_total, calendar_cart_entries, oob='true')}}
{% else %}
{% set cart=[] %}
{% from '_types/cart/_cart.html' import show_cart with context %}
{{ show_cart( oob='true') }}
{% endif %}

View File

@@ -1,131 +0,0 @@
{# Main panel fragment for HTMX navigation - product detail content #}
{% import 'macros/stickers.html' as stick %}
{% import '_types/product/prices.html' as prices %}
{% set prices_ns = namespace() %}
{{ prices.set_prices(d, prices_ns)}}
{# Product detail grid from content block #}
<div class="mt-3 grid grid-cols-1 md:grid-cols-5 gap-6" data-gallery-root>
<div class="md:col-span-2">
{% if d.images and d.images|length > 0 %}
<div class="relative rounded-xl overflow-hidden bg-stone-100">
{# --- like button overlay in top-right --- #}
{% if g.user %}
<div class="absolute top-3 right-5 z-10 text-6xl md:text-xl">
{% set slug = d.slug %}
{% set liked = liked_by_current_user %}
{% include "_types/browse/like/button.html" %}
</div>
{% endif %}
<figure class="inline-block">
<div class="relative w-full aspect-square">
<img
data-main-img
src="{{ d.images[0] }}"
alt="{{ d.title }}"
class="w-full h-full object-contain object-top"
loading="eager" decoding="async"
/>
{% for l in d.labels %}
<img
src="{{ asset_url('labels/' + l + '.svg') }}"
alt=""
class="pointer-events-none absolute inset-0 w-full h-full object-contain object-top"
/>
{% endfor %}
</div>
<figcaption class="mt-2 text-sm text-stone-600 text-center">
{{ d.brand }}
</figcaption>
</figure>
{% if d.images|length > 1 %}
<button type="button" data-prev
class="absolute left-2 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-12 h-12 md:w-14 md:h-14 rounded-full bg-white/90 hover:bg-white shadow-lg focus:outline-none focus:ring-2 focus:ring-stone-300 text-3xl md:text-4xl"
title="Previous"></button>
<button type="button" data-next
class="absolute right-2 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-12 h-12 md:w-14 md:h-14 rounded-full bg-white/90 hover:bg-white shadow-lg focus:outline-none focus:ring-2 focus:ring-stone-300 text-3xl md:text-4xl"
title="Next"></button>
{% endif %}
</div>
<div class="flex flex-row justify-center">
<div class="mt-3 flex gap-2 overflow-x-auto no-scrollbar">
{% for u in d.images %}
<button type="button" data-thumb
class="shrink-0 rounded-lg overflow-hidden bg-stone-100 hover:opacity-90 ring-offset-2"
title="Image {{ loop.index }}">
<img src="{{ u }}" class="h-16 w-16 object-contain" alt="thumb {{ loop.index }}" loading="lazy" decoding="async">
</button>
<span data-image-src="{{ u }}" class="hidden"></span>
{% endfor %}
</div>
</div>
{% else %}
<div class="relative aspect-square bg-stone-100 rounded-xl flex items-center justify-center text-stone-400">
{# Even if no image, still render the like button in the corner for consistency #}
{% if g.user %}
<div class="absolute top-2 right-2 z-10">
{% set slug = d.slug %}
{% set liked = liked_by_current_user %}
{% include "_types/browse/like/button.html" %}
</div>
{% endif %}
No image
</div>
{% endif %}
<div class="p-2 flex flex-row justify-center gap-2">
{% for s in d.stickers %}
{{ stick.sticker(asset_url('stickers/' + s + '.svg'), s, True, size=40) }}
{% endfor %}
</div>
</div>
<div class="md:col-span-3">
{# Optional extras shown quietly #}
<div class="mt-2 space-y-1 text-sm text-stone-600">
{% if d.price_per_unit or d.price_per_unit_raw %}
<div>Unit price: {{ prices.price_str(d.price_per_unit, d.price_per_unit_raw, d.price_per_unit_currency) }}</div>
{% endif %}
{% if d.case_size_raw %}
<div>Case size: {{ d.case_size_raw }}</div>
{% endif %}
</div>
{% if d.description_short or d.description_html %}
<div class="mt-4 text-stone-800 space-y-3">
{% if d.description_short %}
<p class="leading-relaxed text-lg">{{ d.description_short }}</p>
{% endif %}
{% if d.description_html %}
<div class="max-w-none text-sm leading-relaxed">
{{ d.description_html | safe }}
</div>
{% endif %}
</div>
{% endif %}
{% if d.sections and d.sections|length %}
<div class="mt-8 space-y-3">
{% for sec in d.sections %}
<details class="group rounded-xl border bg-white shadow-sm open:shadow p-0">
<summary class="cursor-pointer select-none px-4 py-3 flex items-center justify-between">
<span class="font-medium">{{ sec.title }}</span>
<span class="ml-2 text-xl transition-transform group-open:rotate-180"></span>
</summary>
<div class="px-4 pb-4 max-w-none text-sm leading-relaxed">
{{ sec.html | safe }}
</div>
</details>
{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="pb-8"></div>

View File

@@ -1,106 +0,0 @@
{# --- social/meta_product.html --- #}
{# Context expected:
site, d (Product), request
#}
{# Visibility → robots: index unless soft-deleted #}
{% set robots_here = 'noindex,nofollow' if d.deleted_at else 'index,follow' %}
{# Compute canonical #}
{% set _site_url = site().url.rstrip('/') if site and site().url else '' %}
{% set _product_path = request.path if request else ('/products/' ~ (d.slug or '')) %}
{% set canonical = _site_url ~ _product_path if _site_url else (request.url if request else None) %}
{# Include common base (charset, viewport, robots default, RSS, Org/WebSite JSON-LD) #}
{% set robots_override = robots_here %}
{% include 'social/meta_base.html' %}
{# ---- Titles / descriptions ---- #}
{% set base_product_title = d.title or base_title %}
{% set og_title = base_product_title %}
{% set tw_title = base_product_title %}
{# Description: prefer short, then HTML stripped #}
{% set desc_source = d.description_short
or (d.description_html|striptags if d.description_html else '') %}
{% set description = (desc_source|trim|replace('\n',' ')|replace('\r',' ')|striptags)|truncate(160, True, '…') %}
{# ---- Image priority: product image, then first gallery image, then site default ---- #}
{% set image_url = d.image
or ((d.images|first).url if d.images and (d.images|first).url else None)
or (site().default_image if site and site().default_image else None) %}
{# ---- Price / offer helpers ---- #}
{% set price = d.special_price or d.regular_price or d.rrp %}
{% set price_currency = d.special_price_currency or d.regular_price_currency or d.rrp_currency %}
{# ---- Basic meta ---- #}
<title>{{ base_product_title }}</title>
<meta name="description" content="{{ description }}">
{% if canonical %}<link rel="canonical" href="{{ canonical }}">{% endif %}
{# ---- Open Graph ---- #}
<meta property="og:site_name" content="{{ site().title if site and site().title else '' }}">
<meta property="og:type" content="product">
<meta property="og:title" content="{{ og_title }}">
<meta property="og:description" content="{{ description }}">
{% if canonical %}<meta property="og:url" content="{{ canonical }}">{% endif %}
{% if image_url %}<meta property="og:image" content="{{ image_url }}">{% endif %}
{# Optional product OG price tags #}
{% if price and price_currency %}
<meta property="product:price:amount" content="{{ '%.2f'|format(price) }}">
<meta property="product:price:currency" content="{{ price_currency }}">
{% endif %}
{% if d.brand %}
<meta property="product:brand" content="{{ d.brand }}">
{% endif %}
{% if d.sku %}
<meta property="product:retailer_item_id" content="{{ d.sku }}">
{% endif %}
{# ---- Twitter ---- #}
<meta name="twitter:card" content="{{ 'summary_large_image' if image_url else 'summary' }}">
{% if site and site().twitter_site %}<meta name="twitter:site" content="{{ site().twitter_site }}">{% endif %}
<meta name="twitter:title" content="{{ tw_title }}">
<meta name="twitter:description" content="{{ description }}">
{% if image_url %}<meta name="twitter:image" content="{{ image_url }}">{% endif %}
{# ---- JSON-LD Product ---- #}
{% set jsonld = {
"@context": "https://schema.org",
"@type": "Product",
"name": d.title,
"image": image_url,
"description": description,
"sku": d.sku,
"brand": d.brand,
"url": canonical
} %}
{# Brand as proper object if present #}
{% if d.brand %}
{% set jsonld = jsonld | combine({
"brand": {
"@type": "Brand",
"name": d.brand
}
}) %}
{% endif %}
{# Offers if price available #}
{% if price and price_currency %}
{% set jsonld = jsonld | combine({
"offers": {
"@type": "Offer",
"price": price,
"priceCurrency": price_currency,
"url": canonical,
"availability": "https://schema.org/InStock"
}
}) %}
{% endif %}
<script type="application/ld+json">
{{ jsonld | tojson }}
</script>

View File

@@ -1,49 +0,0 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - product extends browse so use similar structure #}
{% import 'macros/layout.html' as layout %}
{% import 'macros/stickers.html' as stick %}
{% import '_types/product/prices.html' as prices %}
{% set prices_ns = namespace() %}
{{ prices.set_prices(d, prices_ns)}}
{# Import shared OOB macros #}
{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/market/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('market-header-child', 'product-header-child', '_types/product/header/_header.html')}}
{% endblock %}
{% block mobile_menu %}
{% include '_types/market/mobile/_nav_panel.html' %}
{% include '_types/browse/_admin.html' %}
{% endblock %}
{% block filter %}
{% call layout.details() %}
{% call layout.summary('coop-child-header') %}
{% endcall %}
{% call layout.menu('blog-child-menu') %}
{% endcall %}
{% endcall %}
{% call layout.details() %}
{% call layout.summary('product-child-header') %}
{% endcall %}
{% call layout.menu('item-child-menu') %}
{% endcall %}
{% endcall %}
{% endblock %}
{% block content %}
{% include '_types/product/_main_panel.html' %}
{% endblock %}

View File

@@ -1,33 +0,0 @@
{% import '_types/product/_cart.html' as _cart %}
{# ---- Price block ---- #}
{% import '_types/product/prices.html' as prices %}
{% set prices_ns = namespace() %}
{{ prices.set_prices(d, prices_ns)}}
<div class="flex flex-row items-center justify-between md:gap-2 md:px-2">
{{ _cart.add(d.slug, cart)}}
{% if prices_ns.sp_val %}
<div class="text-md font-bold text-emerald-700">
Special price
</div>
<div class="text-xl font-semibold text-emerald-700">
{{ prices.price_str(prices_ns.sp_val, prices_ns.sp_raw, prices_ns.sp_cur) }}
</div>
{% if prices_ns.sp_val and prices_ns.rp_val %}
<div class="text-base text-md line-through text-stone-500">
{{ prices.price_str(prices_ns.rp_val, prices_ns.rp_raw, prices_ns.rp_cur) }}
</div>
{% endif %}
{% elif prices_ns.rp_val %}
<div class="hidden md:block text-xl font-bold">
Our price
</div>
<div class="text-xl font-semibold">
{{ prices.price_str(prices_ns.rp_val, prices_ns.rp_raw, prices_ns.rp_cur) }}
</div>
{% endif %}
{{ prices.rrp(prices_ns) }}
</div>

View File

@@ -1,2 +0,0 @@
<i class="fa fa-shopping-bag" aria-hidden="true"></i>
<div>{{ d.title }}</div>

Some files were not shown because too many files have changed in this diff Show More