Compare commits
145 Commits
main
...
decoupling
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90bb061c08 | ||
|
|
923eea339e | ||
|
|
2a90a21c64 | ||
|
|
85ffe34fc9 | ||
|
|
3db0ca23c7 | ||
|
|
7949718383 | ||
|
|
1ff4f64d5d | ||
|
|
4d2a14cdcb | ||
|
|
395d40c7f7 | ||
|
|
07ed2980fa | ||
|
|
f34d35b9c4 | ||
|
|
49a4780efe | ||
|
|
d1a6690cc3 | ||
|
|
93f830ff13 | ||
|
|
06e700820e | ||
|
|
d92d4840ed | ||
|
|
291c829c7f | ||
|
|
e0b640f15b | ||
|
|
25228881aa | ||
|
|
97bc694ff0 | ||
|
|
d014203776 | ||
|
|
f53d2841e9 | ||
|
|
3c9ff1210a | ||
|
|
4a37f281d4 | ||
|
|
e49668b301 | ||
|
|
005c04e5f9 | ||
|
|
e479730f3f | ||
|
|
0954dc0505 | ||
|
|
84f13153a6 | ||
|
|
09ca461df6 | ||
|
|
0e2f0b818e | ||
|
|
c147900072 | ||
|
|
fc8bbc927b | ||
|
|
8f44f99232 | ||
|
|
05d7ccd422 | ||
|
|
74fc2f4fb9 | ||
|
|
f0743a5949 | ||
|
|
b91abd1a34 | ||
|
|
6e1a7cfc5b | ||
|
|
973d639f0b | ||
|
|
2fd05faccb | ||
|
|
1919258dcd | ||
|
|
b027bf5bdf | ||
|
|
9eab90a3ae | ||
|
|
691cb9c2ab | ||
|
|
6fa2b73072 | ||
|
|
0fd1e5be99 | ||
|
|
2205e23e56 | ||
|
|
d4aa3ea4d2 | ||
|
|
2ea879db44 | ||
|
|
9d8e21001b | ||
|
|
26bc7c885a | ||
|
|
bddc3cb122 | ||
|
|
72a30b90f6 | ||
|
|
7261645d1e | ||
|
|
edaa028b67 | ||
|
|
4be16b92cd | ||
|
|
fd1575ed04 | ||
|
|
5e26b5ec63 | ||
|
|
715db8e493 | ||
|
|
9b4a63ff1e | ||
|
|
a8e587ebb3 | ||
|
|
139eb3ac1f | ||
|
|
dcb93269fc | ||
|
|
3c87832fdf | ||
|
|
555ac6a152 | ||
|
|
800d4c1822 | ||
|
|
0fcfed4546 | ||
|
|
2cc646b5c6 | ||
|
|
9ef6f47bf1 | ||
|
|
504ada5d9b | ||
|
|
a5ad2af550 | ||
|
|
43b98dd45a | ||
|
|
20daef8808 | ||
|
|
930ffae854 | ||
|
|
460b909392 | ||
|
|
dd3bf455ef | ||
|
|
8db76c7099 | ||
|
|
ab93ca2b84 | ||
|
|
599ba37d61 | ||
|
|
1d891a5cbf | ||
|
|
4671bc616e | ||
|
|
c05e6e5baa | ||
|
|
8bbf70eafd | ||
|
|
bf0996e013 | ||
|
|
0811b52869 | ||
|
|
81526d5a9f | ||
|
|
57a7ee3358 | ||
|
|
a80547c7fa | ||
|
|
3bddee0d94 | ||
|
|
ade59dcbb4 | ||
|
|
e6fa255941 | ||
|
|
a57ea63b92 | ||
|
|
e42a91982f | ||
|
|
806efadb93 | ||
|
|
93aa61494a | ||
|
|
05cba16cef | ||
|
|
74d6071ad4 | ||
|
|
1aa3659bb8 | ||
|
|
aab9cf3f6b | ||
|
|
6984f3f3db | ||
|
|
a50c5e4d46 | ||
|
|
ca5b952ffc | ||
|
|
d7162f5543 | ||
|
|
0a3997b82a | ||
|
|
50cad49576 | ||
|
|
fe255fc53c | ||
|
|
ad445e2fd2 | ||
|
|
b04dbbba67 | ||
|
|
3fea2e6fdb | ||
|
|
acf352ee3b | ||
|
|
33befd4c3d | ||
|
|
8dfb95ccab | ||
|
|
7ccdc1fa83 | ||
|
|
31f9aa3fac | ||
|
|
ec1880b658 | ||
|
|
72042e793b | ||
|
|
b3125c5db4 | ||
|
|
7edc0a53a1 | ||
|
|
867bfa234f | ||
|
|
bc1a7783df | ||
|
|
0015839845 | ||
|
|
63778d9f20 | ||
|
|
28938e38b5 | ||
|
|
faa72eec5d | ||
|
|
8c2358022a | ||
|
|
44f475857b | ||
|
|
039386b6e7 | ||
|
|
dc94cfa29e | ||
|
|
a29612ffa4 | ||
|
|
99dd473afb | ||
|
|
7820257577 | ||
|
|
f81d6803d4 | ||
|
|
ba10f5547a | ||
|
|
7025333951 | ||
|
|
737c82ae7f | ||
|
|
cccdba65a8 | ||
|
|
7b1f8a0f5e | ||
|
|
000185c2cc | ||
|
|
4f90f65a8f | ||
|
|
3b24cf45e5 | ||
|
|
a6ed49d0c1 | ||
|
|
62000256aa | ||
|
|
079293eb2c | ||
|
|
478636f799 |
@@ -2,13 +2,13 @@ name: Build and Deploy
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main, decoupling]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: registry.rose-ash.com:5000
|
REGISTRY: registry.rose-ash.com:5000
|
||||||
IMAGE: market
|
IMAGE: market
|
||||||
REPO_DIR: /root/rose-ash/market
|
REPO_DIR: /root/rose-ash/market
|
||||||
COOP_DIR: /root/coop
|
COOP_DIR: /root/rose-ash
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
@@ -36,9 +36,23 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
ssh "root@$DEPLOY_HOST" "
|
ssh "root@$DEPLOY_HOST" "
|
||||||
cd ${{ env.REPO_DIR }}
|
cd ${{ env.REPO_DIR }}
|
||||||
git fetch origin main
|
git fetch origin ${{ github.ref_name }}
|
||||||
git reset --hard origin/main
|
git reset --hard origin/${{ github.ref_name }}
|
||||||
git submodule update --init --recursive
|
git submodule update --init --recursive
|
||||||
|
# Clean ALL sibling dirs (including stale self-copies from previous runs)
|
||||||
|
for sibling in blog market cart events federation; do
|
||||||
|
rm -rf \$sibling
|
||||||
|
done
|
||||||
|
# Copy non-self sibling models for cross-domain imports
|
||||||
|
for sibling in blog market cart events federation; 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
|
- name: Build and push image
|
||||||
|
|||||||
5
.gitmodules
vendored
5
.gitmodules
vendored
@@ -1,3 +1,4 @@
|
|||||||
[submodule "shared_lib"]
|
[submodule "shared"]
|
||||||
path = shared_lib
|
path = shared
|
||||||
url = https://git.rose-ash.com/coop/shared.git
|
url = https://git.rose-ash.com/coop/shared.git
|
||||||
|
branch = decoupling
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ FROM python:3.11-slim AS base
|
|||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONPATH=/app \
|
||||||
PIP_NO_CACHE_DIR=1 \
|
PIP_NO_CACHE_DIR=1 \
|
||||||
APP_PORT=8000 \
|
APP_PORT=8000 \
|
||||||
APP_MODULE=app:app
|
APP_MODULE=app:app
|
||||||
@@ -17,14 +18,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
postgresql-client \
|
postgresql-client \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& 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
|
RUN pip install -r requirements.txt
|
||||||
|
|
||||||
COPY . .
|
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 ----------
|
# ---------- Runtime setup ----------
|
||||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||||
|
|||||||
91
README.md
91
README.md
@@ -1,67 +1,56 @@
|
|||||||
# Market App
|
# Market App
|
||||||
|
|
||||||
Product browsing and marketplace application for the Rose Ash cooperative.
|
Product browsing and marketplace service for the Rose Ash cooperative. Displays products scraped from Suma Wholesale.
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- **Framework:** Quart (async Flask)
|
One of five Quart microservices sharing a single PostgreSQL database:
|
||||||
- **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
|
|
||||||
|
|
||||||
## 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 |
|
||||||
|
| federation | 8004 | ActivityPub, fediverse social |
|
||||||
|
|
||||||
- `bp/market/` - Market root (navigation, category listing)
|
## Structure
|
||||||
- `bp/browse/` - Product browsing with filters and infinite scroll
|
|
||||||
- `bp/product/` - Product detail pages
|
|
||||||
- `bp/api/` - Product sync API (used by scraper)
|
|
||||||
|
|
||||||
## 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 (+ re-export stubs)
|
||||||
|
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
|
||||||
|
services/ # register_domain_services() — wires market + cart
|
||||||
|
shared/ # Submodule -> git.rose-ash.com/coop/shared.git
|
||||||
|
```
|
||||||
|
|
||||||
# Install dependencies
|
## Cross-Domain Communication
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# Set environment variables
|
- `services.cart.*` — cart summary via CartService protocol
|
||||||
export $(grep -v '^#' .env | xargs)
|
- `services.federation.*` — AP publishing via FederationService protocol
|
||||||
|
- `shared.services.navigation` — site navigation tree
|
||||||
# Run migrations
|
|
||||||
alembic upgrade head
|
|
||||||
|
|
||||||
# Scrape products
|
|
||||||
bash scrape.sh
|
|
||||||
|
|
||||||
# Run the dev server
|
|
||||||
hypercorn app:app --reload --bind 0.0.0.0:8001
|
|
||||||
|
|
||||||
## Scraping
|
## Scraping
|
||||||
|
|
||||||
# Full scrape (max 50 pages, 200k products, 8 concurrent)
|
```bash
|
||||||
bash scrape.sh
|
bash scrape.sh # Full Suma Wholesale catalogue
|
||||||
|
bash scrape-test.sh # Limited test scrape
|
||||||
|
```
|
||||||
|
|
||||||
# Test scraping
|
## Running
|
||||||
bash scrape-test.sh
|
|
||||||
|
|
||||||
## Docker
|
```bash
|
||||||
|
export DATABASE_URL_ASYNC=postgresql+asyncpg://user:pass@localhost/coop
|
||||||
|
export REDIS_URL=redis://localhost:6379/0
|
||||||
|
export SECRET_KEY=your-secret-key
|
||||||
|
|
||||||
docker build -t market .
|
hypercorn app:app --bind 0.0.0.0:8001
|
||||||
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
|
|
||||||
|
|||||||
0
__init__.py
Normal file
0
__init__.py
Normal file
146
app.py
146
app.py
@@ -1,52 +1,80 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import path_setup # noqa: F401 # adds shared_lib to sys.path
|
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import g, abort, render_template, make_response
|
from quart import g, abort, request
|
||||||
from jinja2 import FileSystemLoader, ChoiceLoader
|
from jinja2 import FileSystemLoader, ChoiceLoader
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from shared.factory import create_base_app
|
from shared.infrastructure.factory import create_base_app
|
||||||
from shared.cart_loader import load_cart
|
from shared.config import config
|
||||||
from config import config
|
|
||||||
|
|
||||||
from suma_browser.app.bp import register_market_bp
|
from bp import register_market_bp, register_all_markets, register_page_markets, register_fragments
|
||||||
|
|
||||||
|
|
||||||
async def market_context() -> dict:
|
async def market_context() -> dict:
|
||||||
"""
|
"""
|
||||||
Market app context processor.
|
Market app context processor.
|
||||||
|
|
||||||
- menu_items: fetched from coop internal API
|
- nav_tree_html: fetched from blog as fragment
|
||||||
- cart_count/cart_total: fetched from cart internal API
|
- 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.infrastructure.context import base_context
|
||||||
from shared.internal_api import get as api_get, dictobj
|
from shared.services.navigation import get_navigation_tree
|
||||||
|
from shared.services.registry import services
|
||||||
|
from shared.infrastructure.cart_identity import current_cart_identity
|
||||||
|
from shared.infrastructure.fragments import fetch_fragment
|
||||||
|
from shared.models.market import CartItem
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
ctx = await base_context()
|
ctx = await base_context()
|
||||||
|
|
||||||
# Menu items from coop API (wrapped for attribute access in templates)
|
ctx["nav_tree_html"] = await fetch_fragment(
|
||||||
menu_data = await api_get("coop", "/internal/menu-items")
|
"blog", "nav-tree",
|
||||||
ctx["menu_items"] = dictobj(menu_data) if menu_data else []
|
params={"app_name": "market", "path": request.path},
|
||||||
|
)
|
||||||
|
# Fallback for _nav.html when nav-tree fragment fetch fails
|
||||||
|
ctx["menu_items"] = await get_navigation_tree(g.s)
|
||||||
|
|
||||||
# Cart data from cart API
|
ident = current_cart_identity()
|
||||||
cart_data = await api_get("cart", "/internal/cart/summary", forward_session=True)
|
|
||||||
if cart_data:
|
# cart_count/cart_total via service (consistent with blog/events apps)
|
||||||
ctx["cart_count"] = cart_data.get("count", 0)
|
summary = await services.cart.cart_summary(
|
||||||
ctx["cart_total"] = cart_data.get("total", 0)
|
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:
|
else:
|
||||||
ctx["cart_count"] = 0
|
ctx["cart"] = []
|
||||||
ctx["cart_total"] = 0
|
return ctx
|
||||||
|
|
||||||
|
result = await g.s.execute(
|
||||||
|
select(CartItem).where(*filters).options(selectinload(CartItem.product))
|
||||||
|
)
|
||||||
|
ctx["cart"] = list(result.scalars().all())
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> "Quart":
|
def create_app() -> "Quart":
|
||||||
from models.market_place import MarketPlace
|
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-specific templates override shared templates
|
||||||
app_templates = str(Path(__file__).resolve().parent / "templates")
|
app_templates = str(Path(__file__).resolve().parent / "templates")
|
||||||
@@ -55,19 +83,37 @@ def create_app() -> "Quart":
|
|||||||
app.jinja_loader,
|
app.jinja_loader,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
# All markets: / — global view across all pages
|
||||||
|
app.register_blueprint(
|
||||||
|
register_all_markets(),
|
||||||
|
url_prefix="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Page markets: /<slug>/ — markets for a single page
|
||||||
|
app.register_blueprint(
|
||||||
|
register_page_markets(),
|
||||||
|
url_prefix="/<slug>",
|
||||||
|
)
|
||||||
|
|
||||||
# Market blueprint nested under post slug: /<page_slug>/<market_slug>/
|
# Market blueprint nested under post slug: /<page_slug>/<market_slug>/
|
||||||
app.register_blueprint(
|
app.register_blueprint(
|
||||||
register_market_bp(
|
register_market_bp(
|
||||||
url_prefix="/",
|
url_prefix="/",
|
||||||
title=config()["coop_title"],
|
title=config()["market_title"],
|
||||||
),
|
),
|
||||||
url_prefix="/<page_slug>/<market_slug>",
|
url_prefix="/<page_slug>/<market_slug>",
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Auto-inject page_slug and market_slug into url_for() calls ---
|
app.register_blueprint(register_fragments())
|
||||||
|
|
||||||
|
# --- Auto-inject slugs into url_for() calls ---
|
||||||
@app.url_value_preprocessor
|
@app.url_value_preprocessor
|
||||||
def pull_slugs(endpoint, values):
|
def pull_slugs(endpoint, values):
|
||||||
if values:
|
if values:
|
||||||
|
# page_markets blueprint uses "slug"
|
||||||
|
if "slug" in values:
|
||||||
|
g.post_slug = values.pop("slug")
|
||||||
|
# market blueprint uses "page_slug" / "market_slug"
|
||||||
if "page_slug" in values:
|
if "page_slug" in values:
|
||||||
g.post_slug = values.pop("page_slug")
|
g.post_slug = values.pop("page_slug")
|
||||||
if "market_slug" in values:
|
if "market_slug" in values:
|
||||||
@@ -75,26 +121,26 @@ def create_app() -> "Quart":
|
|||||||
|
|
||||||
@app.url_defaults
|
@app.url_defaults
|
||||||
def inject_slugs(endpoint, values):
|
def inject_slugs(endpoint, values):
|
||||||
for attr, param in [("post_slug", "page_slug"), ("market_slug", "market_slug")]:
|
slug = g.get("post_slug")
|
||||||
val = g.get(attr)
|
if slug:
|
||||||
if val and param not in values:
|
for param in ("slug", "page_slug"):
|
||||||
if app.url_map.is_endpoint_expecting(endpoint, param):
|
if param not in values and app.url_map.is_endpoint_expecting(endpoint, param):
|
||||||
values[param] = val
|
values[param] = slug
|
||||||
|
market_slug = g.get("market_slug")
|
||||||
|
if market_slug and "market_slug" not in values:
|
||||||
|
if app.url_map.is_endpoint_expecting(endpoint, "market_slug"):
|
||||||
|
values["market_slug"] = market_slug
|
||||||
|
|
||||||
# --- Load post and market data ---
|
# --- Load post and market data ---
|
||||||
@app.before_request
|
@app.before_request
|
||||||
async def hydrate_market():
|
async def hydrate_market():
|
||||||
post_slug = getattr(g, "post_slug", None)
|
post_slug = getattr(g, "post_slug", None)
|
||||||
market_slug = getattr(g, "market_slug", None)
|
market_slug = getattr(g, "market_slug", None)
|
||||||
if not post_slug or not market_slug:
|
if not post_slug:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Load post by slug
|
# Load post by slug via blog service
|
||||||
post = (
|
post = await services.blog.get_post_by_slug(g.s, post_slug)
|
||||||
await g.s.execute(
|
|
||||||
select(Post).where(Post.slug == post_slug)
|
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if not post:
|
if not post:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
@@ -111,12 +157,16 @@ def create_app() -> "Quart":
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Load market scoped to post
|
# Only load market when market_slug is present (/<page_slug>/<market_slug>/)
|
||||||
|
if not market_slug:
|
||||||
|
return
|
||||||
|
|
||||||
market = (
|
market = (
|
||||||
await g.s.execute(
|
await g.s.execute(
|
||||||
select(MarketPlace).where(
|
select(MarketPlace).where(
|
||||||
MarketPlace.slug == market_slug,
|
MarketPlace.slug == market_slug,
|
||||||
MarketPlace.post_id == post.id,
|
MarketPlace.container_type == "page",
|
||||||
|
MarketPlace.container_id == post.id,
|
||||||
MarketPlace.deleted_at.is_(None),
|
MarketPlace.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -132,26 +182,6 @@ def create_app() -> "Quart":
|
|||||||
return {}
|
return {}
|
||||||
return {**post_data}
|
return {**post_data}
|
||||||
|
|
||||||
# --- Root route: market listing ---
|
|
||||||
@app.get("/")
|
|
||||||
async def markets_listing():
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
markets = (
|
|
||||||
await g.s.execute(
|
|
||||||
select(MarketPlace)
|
|
||||||
.where(MarketPlace.deleted_at.is_(None))
|
|
||||||
.options(selectinload(MarketPlace.post))
|
|
||||||
.order_by(MarketPlace.name)
|
|
||||||
)
|
|
||||||
).scalars().all()
|
|
||||||
|
|
||||||
html = await render_template(
|
|
||||||
"_types/market/markets_listing.html",
|
|
||||||
markets=markets,
|
|
||||||
)
|
|
||||||
return await make_response(html)
|
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,5 @@
|
|||||||
from .market.routes import register as register_market_bp
|
from .market.routes import register as register_market_bp
|
||||||
from .product.routes import register as register_product
|
from .product.routes import register as register_product
|
||||||
|
from .all_markets.routes import register as register_all_markets
|
||||||
|
from .page_markets.routes import register as register_page_markets
|
||||||
|
from .fragments import register_fragments
|
||||||
|
|||||||
0
bp/all_markets/__init__.py
Normal file
0
bp/all_markets/__init__.py
Normal file
74
bp/all_markets/routes.py
Normal file
74
bp/all_markets/routes.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""
|
||||||
|
All-markets blueprint — shows markets across ALL pages.
|
||||||
|
|
||||||
|
Mounted at / (root of market app). No slug context.
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
GET / — full page with first page of markets
|
||||||
|
GET /all-markets — HTMX fragment for infinite scroll
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from quart import Blueprint, g, request, render_template, make_response
|
||||||
|
|
||||||
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
from shared.services.registry import services
|
||||||
|
|
||||||
|
|
||||||
|
def register() -> Blueprint:
|
||||||
|
bp = Blueprint("all_markets", __name__)
|
||||||
|
|
||||||
|
async def _load_markets(page, per_page=20):
|
||||||
|
"""Load all markets + page info for container badges."""
|
||||||
|
markets, has_more = await services.market.list_marketplaces(
|
||||||
|
g.s, page=page, per_page=per_page,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Batch-load page info for container_ids
|
||||||
|
page_info = {}
|
||||||
|
if markets:
|
||||||
|
post_ids = list({
|
||||||
|
m.container_id for m in markets
|
||||||
|
if m.container_type == "page"
|
||||||
|
})
|
||||||
|
if post_ids:
|
||||||
|
posts = await services.blog.get_posts_by_ids(g.s, post_ids)
|
||||||
|
for p in posts:
|
||||||
|
page_info[p.id] = {"title": p.title, "slug": p.slug}
|
||||||
|
|
||||||
|
return markets, has_more, page_info
|
||||||
|
|
||||||
|
@bp.get("/")
|
||||||
|
async def index():
|
||||||
|
page = int(request.args.get("page", 1))
|
||||||
|
markets, has_more, page_info = await _load_markets(page)
|
||||||
|
|
||||||
|
ctx = dict(
|
||||||
|
markets=markets,
|
||||||
|
has_more=has_more,
|
||||||
|
page_info=page_info,
|
||||||
|
page=page,
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_htmx_request():
|
||||||
|
html = await render_template("_types/all_markets/_main_panel.html", **ctx)
|
||||||
|
else:
|
||||||
|
html = await render_template("_types/all_markets/index.html", **ctx)
|
||||||
|
|
||||||
|
return await make_response(html, 200)
|
||||||
|
|
||||||
|
@bp.get("/all-markets")
|
||||||
|
async def markets_fragment():
|
||||||
|
page = int(request.args.get("page", 1))
|
||||||
|
markets, has_more, page_info = await _load_markets(page)
|
||||||
|
|
||||||
|
html = await render_template(
|
||||||
|
"_types/all_markets/_cards.html",
|
||||||
|
markets=markets,
|
||||||
|
has_more=has_more,
|
||||||
|
page_info=page_info,
|
||||||
|
page=page,
|
||||||
|
)
|
||||||
|
return await make_response(html, 200)
|
||||||
|
|
||||||
|
return bp
|
||||||
@@ -27,8 +27,8 @@ from models.market import (
|
|||||||
ProductAllergen,
|
ProductAllergen,
|
||||||
)
|
)
|
||||||
|
|
||||||
from suma_browser.app.redis_cacher import clear_cache
|
from shared.browser.app.redis_cacher import clear_cache
|
||||||
from suma_browser.app.csrf import csrf_exempt
|
from shared.browser.app.csrf import csrf_exempt
|
||||||
|
|
||||||
|
|
||||||
products_api = Blueprint("products_api", __name__, url_prefix="/api/products")
|
products_api = Blueprint("products_api", __name__, url_prefix="/api/products")
|
||||||
@@ -291,6 +291,22 @@ async def _create_product_from_payload(session: AsyncSession, payload: Dict[str,
|
|||||||
#await session.flush() # get p.id
|
#await session.flush() # get p.id
|
||||||
_replace_children(p, payload)
|
_replace_children(p, payload)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
|
# Publish to federation inline
|
||||||
|
from shared.services.federation_publish import try_publish
|
||||||
|
await try_publish(
|
||||||
|
session,
|
||||||
|
user_id=getattr(p, "user_id", None),
|
||||||
|
activity_type="Create",
|
||||||
|
object_type="Object",
|
||||||
|
object_data={
|
||||||
|
"name": p.title or "",
|
||||||
|
"summary": getattr(p, "description", "") or "",
|
||||||
|
},
|
||||||
|
source_type="Product",
|
||||||
|
source_id=p.id,
|
||||||
|
)
|
||||||
|
|
||||||
return p
|
return p
|
||||||
|
|
||||||
# ---- API --------------------------------------------------------------------
|
# ---- API --------------------------------------------------------------------
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from quart import (
|
|||||||
make_response,
|
make_response,
|
||||||
current_app,
|
current_app,
|
||||||
)
|
)
|
||||||
from config import config
|
from shared.config import config
|
||||||
from .services.nav import category_context, get_nav
|
from .services.nav import category_context, get_nav
|
||||||
from .services.blacklist.category import is_category_blocked
|
from .services.blacklist.category import is_category_blocked
|
||||||
|
|
||||||
@@ -21,8 +21,8 @@ from .services import (
|
|||||||
_current_url_without_page,
|
_current_url_without_page,
|
||||||
)
|
)
|
||||||
|
|
||||||
from suma_browser.app.redis_cacher import cache_page
|
from shared.browser.app.redis_cacher import cache_page
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
browse_bp = Blueprint("browse", __name__)
|
browse_bp = Blueprint("browse", __name__)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# suma_browser/category_blacklist.py
|
# suma_browser/category_blacklist.py
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from config import config
|
from shared.config import config
|
||||||
|
|
||||||
def _norm(s: str) -> str:
|
def _norm(s: str) -> str:
|
||||||
return (s or "").strip().lower().strip("/")
|
return (s or "").strip().lower().strip("/")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from typing import Set, Optional
|
from typing import Set, Optional
|
||||||
from ..slugs import canonical_html_slug
|
from ..slugs import canonical_html_slug
|
||||||
from config import config
|
from shared.config import config
|
||||||
|
|
||||||
_blocked: Set[str] = set()
|
_blocked: Set[str] = set()
|
||||||
_mtime: Optional[float] = None
|
_mtime: Optional[float] = None
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import re
|
import re
|
||||||
from config import config
|
from shared.config import config
|
||||||
|
|
||||||
def _norm_title_key(t: str) -> str:
|
def _norm_title_key(t: str) -> str:
|
||||||
t = (t or "").strip().lower()
|
t = (t or "").strip().lower()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import os, json
|
import os, json
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from config import config
|
from shared.config import config
|
||||||
from .blacklist.product import is_product_blocked
|
from .blacklist.product import is_product_blocked
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from typing import Dict, List, Optional
|
|||||||
from sqlalchemy import select, and_
|
from sqlalchemy import select, and_
|
||||||
from sqlalchemy.orm import selectinload
|
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
|
# ORM models
|
||||||
from models.market import (
|
from models.market import (
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import re
|
|||||||
from typing import Dict, List, Tuple, Optional
|
from typing import Dict, List, Tuple, Optional
|
||||||
from urllib.parse import urlparse, urljoin
|
from urllib.parse import urlparse, urljoin
|
||||||
|
|
||||||
from config import config
|
from shared.config import config
|
||||||
from . import db_backend as cb
|
from . import db_backend as cb
|
||||||
from .blacklist.category import is_category_blocked # Reverse map: slug -> label
|
from .blacklist.category import is_category_blocked # Reverse map: slug -> label
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ from quart import (
|
|||||||
g,
|
g,
|
||||||
request,
|
request,
|
||||||
)
|
)
|
||||||
from config import config
|
from shared.config import config
|
||||||
from .products import products, products_nocounts
|
from .products import products, products_nocounts
|
||||||
from .blacklist.product_details import is_blacklisted_heading
|
from .blacklist.product_details import is_blacklisted_heading
|
||||||
|
|
||||||
from utils import host_url
|
from shared.utils import host_url
|
||||||
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -163,7 +163,7 @@ def _massage_product(d):
|
|||||||
|
|
||||||
|
|
||||||
# Re-export from canonical shared location
|
# 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:
|
async def _is_liked(user_id: int | None, slug: str) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import re
|
import re
|
||||||
from urllib.parse import urljoin, urlparse
|
from urllib.parse import urljoin, urlparse
|
||||||
from config import config
|
from shared.config import config
|
||||||
|
|
||||||
def product_slug_from_href(href: str) -> str:
|
def product_slug_from_href(href: str) -> str:
|
||||||
p = urlparse(href)
|
p = urlparse(href)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Re-export from canonical shared location
|
# 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"]
|
__all__ = ["CartIdentity", "current_cart_identity"]
|
||||||
|
|||||||
1
bp/fragments/__init__.py
Normal file
1
bp/fragments/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from .routes import register as register_fragments
|
||||||
54
bp/fragments/routes.py
Normal file
54
bp/fragments/routes.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""Market app fragment endpoints.
|
||||||
|
|
||||||
|
Exposes HTML fragments at ``/internal/fragments/<type>`` for consumption
|
||||||
|
by other coop apps via the fragment client.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from quart import Blueprint, Response, g, render_template, request
|
||||||
|
|
||||||
|
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||||
|
from shared.services.registry import services
|
||||||
|
|
||||||
|
|
||||||
|
def register():
|
||||||
|
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||||
|
|
||||||
|
_handlers: dict[str, object] = {}
|
||||||
|
|
||||||
|
@bp.before_request
|
||||||
|
async def _require_fragment_header():
|
||||||
|
if not request.headers.get(FRAGMENT_HEADER):
|
||||||
|
return Response("", status=403)
|
||||||
|
|
||||||
|
@bp.get("/<fragment_type>")
|
||||||
|
async def get_fragment(fragment_type: str):
|
||||||
|
handler = _handlers.get(fragment_type)
|
||||||
|
if handler is None:
|
||||||
|
return Response("", status=200, content_type="text/html")
|
||||||
|
html = await handler()
|
||||||
|
return Response(html, status=200, content_type="text/html")
|
||||||
|
|
||||||
|
# --- container-nav fragment: market links --------------------------------
|
||||||
|
|
||||||
|
async def _container_nav_handler():
|
||||||
|
container_type = request.args.get("container_type", "page")
|
||||||
|
container_id = int(request.args.get("container_id", 0))
|
||||||
|
post_slug = request.args.get("post_slug", "")
|
||||||
|
|
||||||
|
markets = await services.market.marketplaces_for_container(
|
||||||
|
g.s, container_type, container_id,
|
||||||
|
)
|
||||||
|
if not markets:
|
||||||
|
return ""
|
||||||
|
return await render_template(
|
||||||
|
"fragments/container_nav_markets.html",
|
||||||
|
markets=markets, post_slug=post_slug,
|
||||||
|
)
|
||||||
|
|
||||||
|
_handlers["container-nav"] = _container_nav_handler
|
||||||
|
|
||||||
|
bp._fragment_handlers = _handlers
|
||||||
|
|
||||||
|
return bp
|
||||||
@@ -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():
|
def register():
|
||||||
@@ -15,7 +15,7 @@ def register():
|
|||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@require_admin
|
@require_admin
|
||||||
async def 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
|
# Determine which template to use based on request type
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ from quart import request
|
|||||||
|
|
||||||
from typing import Iterable, Optional, Union
|
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,
|
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:
|
def decode() -> MarketQuery:
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ def register(url_prefix, title):
|
|||||||
post_data = getattr(g, "post_data", None) or {}
|
post_data = getattr(g, "post_data", None) or {}
|
||||||
return {
|
return {
|
||||||
**post_data,
|
**post_data,
|
||||||
"coop_title": market.name if market else title,
|
"market_title": market.name if market else title,
|
||||||
"categories": (await get_nav(g.s, market_id=market_id))["cats"],
|
"categories": (await get_nav(g.s, market_id=market_id))["cats"],
|
||||||
"qs": makeqs_factory()(),
|
"qs": makeqs_factory()(),
|
||||||
"market": market,
|
"market": market,
|
||||||
|
|||||||
0
bp/page_markets/__init__.py
Normal file
0
bp/page_markets/__init__.py
Normal file
65
bp/page_markets/routes.py
Normal file
65
bp/page_markets/routes.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""
|
||||||
|
Page-markets blueprint — shows markets for a single page.
|
||||||
|
|
||||||
|
Mounted at /<slug> (page-scoped). Requires g.post_data from hydrate_post.
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
GET /<slug>/ — full page scoped to this page
|
||||||
|
GET /<slug>/page-markets — HTMX fragment for infinite scroll
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from quart import Blueprint, g, request, render_template, make_response
|
||||||
|
|
||||||
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
from shared.services.registry import services
|
||||||
|
|
||||||
|
|
||||||
|
def register() -> Blueprint:
|
||||||
|
bp = Blueprint("page_markets", __name__)
|
||||||
|
|
||||||
|
async def _load_markets(post_id, page, per_page=20):
|
||||||
|
"""Load markets for this page's container."""
|
||||||
|
markets, has_more = await services.market.list_marketplaces(
|
||||||
|
g.s, "page", post_id, page=page, per_page=per_page,
|
||||||
|
)
|
||||||
|
return markets, has_more
|
||||||
|
|
||||||
|
@bp.get("/")
|
||||||
|
async def index():
|
||||||
|
post = g.post_data["post"]
|
||||||
|
page = int(request.args.get("page", 1))
|
||||||
|
|
||||||
|
markets, has_more = await _load_markets(post["id"], page)
|
||||||
|
|
||||||
|
ctx = dict(
|
||||||
|
markets=markets,
|
||||||
|
has_more=has_more,
|
||||||
|
page_info={},
|
||||||
|
page=page,
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_htmx_request():
|
||||||
|
html = await render_template("_types/page_markets/_main_panel.html", **ctx)
|
||||||
|
else:
|
||||||
|
html = await render_template("_types/page_markets/index.html", **ctx)
|
||||||
|
|
||||||
|
return await make_response(html, 200)
|
||||||
|
|
||||||
|
@bp.get("/page-markets")
|
||||||
|
async def markets_fragment():
|
||||||
|
post = g.post_data["post"]
|
||||||
|
page = int(request.args.get("page", 1))
|
||||||
|
|
||||||
|
markets, has_more = await _load_markets(post["id"], page)
|
||||||
|
|
||||||
|
html = await render_template(
|
||||||
|
"_types/page_markets/_cards.html",
|
||||||
|
markets=markets,
|
||||||
|
has_more=has_more,
|
||||||
|
page_info={},
|
||||||
|
page=page,
|
||||||
|
)
|
||||||
|
return await make_response(html, 200)
|
||||||
|
|
||||||
|
return bp
|
||||||
@@ -15,27 +15,33 @@ from ..browse.services.slugs import canonical_html_slug
|
|||||||
from ..browse.services.blacklist.product import is_product_blocked
|
from ..browse.services.blacklist.product import is_product_blocked
|
||||||
from ..browse.services import db_backend as cb
|
from ..browse.services import db_backend as cb
|
||||||
from ..browse.services import _massage_product
|
from ..browse.services import _massage_product
|
||||||
from utils import host_url
|
from shared.utils import host_url
|
||||||
from suma_browser.app.redis_cacher import cache_page, clear_cache
|
from shared.browser.app.redis_cacher import cache_page, clear_cache
|
||||||
from ..cart.services import total
|
from ..cart.services import total
|
||||||
from .services.product_operations import toggle_product_like, massage_full_product
|
from .services.product_operations import toggle_product_like, massage_full_product
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
bp = Blueprint("product", __name__, url_prefix="/product/<slug>")
|
bp = Blueprint("product", __name__, url_prefix="/product/<product_slug>")
|
||||||
@bp.url_value_preprocessor
|
@bp.url_value_preprocessor
|
||||||
def pull_blog(endpoint, values):
|
def pull_product_slug(endpoint, values):
|
||||||
g.product_slug = values.get("slug")
|
# product_slug is distinct from the app-level "slug"/"page_slug" params,
|
||||||
|
# so it won't be popped by the app-level preprocessor in app.py.
|
||||||
|
g.product_slug = values.pop("product_slug", None)
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# BEFORE REQUEST: Slug or numeric ID resolver
|
# BEFORE REQUEST: Slug or numeric ID resolver
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
@bp.before_request
|
@bp.before_request
|
||||||
async def resolve_product():
|
async def resolve_product():
|
||||||
|
from quart import request as req
|
||||||
|
|
||||||
raw_slug = g.product_slug = getattr(g, "product_slug", None)
|
raw_slug = g.product_slug = getattr(g, "product_slug", None)
|
||||||
if raw_slug is None:
|
if raw_slug is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
is_post = req.method == "POST"
|
||||||
|
|
||||||
# 1. If slug is INT → load product by ID
|
# 1. If slug is INT → load product by ID
|
||||||
if raw_slug.isdigit():
|
if raw_slug.isdigit():
|
||||||
product_id = int(raw_slug)
|
product_id = int(raw_slug)
|
||||||
@@ -53,20 +59,24 @@ def register():
|
|||||||
g.item_data = {"d": d, "slug": product["slug"], "liked": False}
|
g.item_data = {"d": d, "slug": product["slug"], "liked": False}
|
||||||
return
|
return
|
||||||
|
|
||||||
# Not deleted → redirect to canonical slug
|
# Not deleted → redirect to canonical slug (GET only)
|
||||||
|
if not is_post:
|
||||||
canon = canonical_html_slug(product["slug"])
|
canon = canonical_html_slug(product["slug"])
|
||||||
return redirect(
|
return redirect(
|
||||||
host_url(url_for("market.browse.product.product_detail", slug=canon))
|
host_url(url_for("market.browse.product.product_detail", product_slug=canon))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
g.item_data = {"d": product, "slug": product["slug"], "liked": False}
|
||||||
|
return
|
||||||
|
|
||||||
# 2. Normal slug-based behaviour
|
# 2. Normal slug-based behaviour
|
||||||
if is_product_blocked(raw_slug):
|
if is_product_blocked(raw_slug):
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
canon = canonical_html_slug(raw_slug)
|
canon = canonical_html_slug(raw_slug)
|
||||||
if canon != raw_slug:
|
if canon != raw_slug and not is_post:
|
||||||
return redirect(
|
return redirect(
|
||||||
host_url(url_for("product.product_detail", slug=canon))
|
host_url(url_for("market.browse.product.product_detail", product_slug=canon))
|
||||||
)
|
)
|
||||||
|
|
||||||
# hydrate full product
|
# hydrate full product
|
||||||
@@ -75,7 +85,7 @@ def register():
|
|||||||
)
|
)
|
||||||
if not d:
|
if not d:
|
||||||
abort(404)
|
abort(404)
|
||||||
g.item_data = {"d": d, "slug": canon, "liked": d["is_liked"]}
|
g.item_data = {"d": d, "slug": canon, "liked": d.get("is_liked", False)}
|
||||||
|
|
||||||
@bp.context_processor
|
@bp.context_processor
|
||||||
def context():
|
def context():
|
||||||
@@ -93,8 +103,8 @@ def register():
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@cache_page(tag="browse")
|
@cache_page(tag="browse")
|
||||||
async def product_detail(slug: str):
|
async def product_detail():
|
||||||
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
|
# Determine which template to use based on request type
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
@@ -108,9 +118,8 @@ def register():
|
|||||||
|
|
||||||
@bp.post("/like/toggle/")
|
@bp.post("/like/toggle/")
|
||||||
@clear_cache(tag="browse", tag_scope="user")
|
@clear_cache(tag="browse", tag_scope="user")
|
||||||
async def like_toggle(slug):
|
async def like_toggle():
|
||||||
# Use slug from URL parameter (set by url_prefix="/product/<slug>")
|
product_slug = g.product_slug
|
||||||
product_slug = slug
|
|
||||||
|
|
||||||
if not g.user:
|
if not g.user:
|
||||||
html = await render_template(
|
html = await render_template(
|
||||||
@@ -139,8 +148,8 @@ def register():
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/admin/")
|
@bp.get("/admin/")
|
||||||
async def admin(slug: str):
|
async def admin():
|
||||||
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():
|
if not is_htmx_request():
|
||||||
# Normal browser request: full page with layout
|
# Normal browser request: full page with layout
|
||||||
@@ -152,14 +161,15 @@ def register():
|
|||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
|
|
||||||
from suma_browser.app.bp.cart.services.identity import current_cart_identity
|
from bp.cart.services.identity import current_cart_identity
|
||||||
#from suma_browser.app.bp.cart.routes import view_cart
|
#from bp.cart.routes import view_cart
|
||||||
from models.market import CartItem
|
from models.market import CartItem
|
||||||
from quart import request, url_for
|
from quart import request, url_for
|
||||||
|
|
||||||
@bp.post("/cart/")
|
@bp.post("/cart/")
|
||||||
@clear_cache(tag="browse", tag_scope="user")
|
@clear_cache(tag="browse", tag_scope="user")
|
||||||
async def cart(slug: str):
|
async def cart():
|
||||||
|
slug = g.product_slug
|
||||||
# make sure product exists (we *allow* deleted_at != None later if you want)
|
# make sure product exists (we *allow* deleted_at != None later if you want)
|
||||||
product_id = await g.s.scalar(
|
product_id = await g.s.scalar(
|
||||||
select(Product.id).where(
|
select(Product.id).where(
|
||||||
@@ -192,11 +202,23 @@ def register():
|
|||||||
|
|
||||||
ident = current_cart_identity()
|
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:
|
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:
|
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(
|
ci = next(
|
||||||
(item for item in g.cart if item.product_id == product_id),
|
(item for item in g.cart if item.product_id == product_id),
|
||||||
@@ -230,19 +252,16 @@ def register():
|
|||||||
|
|
||||||
# no explicit commit; your session middleware should handle it
|
# no explicit commit; your session middleware should handle it
|
||||||
|
|
||||||
# htmx support (optional)
|
# htmx response: OOB-swap mini cart + product buttons
|
||||||
if request.headers.get("HX-Request") == "true":
|
if request.headers.get("HX-Request") == "true":
|
||||||
# You can return a small fragment or mini-cart here
|
|
||||||
|
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"_types/product/_added.html",
|
"_types/product/_added.html",
|
||||||
cart=g.cart,
|
cart=g.cart,
|
||||||
item=ci,
|
item=ci,
|
||||||
total = total
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# normal POST: go to cart page
|
# normal POST: go to cart page
|
||||||
from shared.urls import cart_url
|
from shared.infrastructure.urls import cart_url
|
||||||
return redirect(cart_url("/"))
|
return redirect(cart_url("/"))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ def massage_full_product(product: Product) -> dict:
|
|||||||
Convert a Product ORM model to a dictionary with all fields.
|
Convert a Product ORM model to a dictionary with all fields.
|
||||||
Used for rendering product detail pages.
|
Used for rendering product detail pages.
|
||||||
"""
|
"""
|
||||||
from suma_browser.app.bp.browse.services import _massage_product
|
from bp.browse.services import _massage_product
|
||||||
|
|
||||||
gallery = []
|
gallery = []
|
||||||
if product.image:
|
if product.image:
|
||||||
|
|||||||
84
config/app-config.yaml
Normal file
84
config/app-config.yaml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# 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
|
||||||
|
market_root: /market
|
||||||
|
market_title: Market
|
||||||
|
blog_root: /
|
||||||
|
blog_title: all the news
|
||||||
|
cart_root: /cart
|
||||||
|
app_urls:
|
||||||
|
blog: "http://localhost:8000"
|
||||||
|
market: "http://localhost:8001"
|
||||||
|
cart: "http://localhost:8002"
|
||||||
|
events: "http://localhost:8003"
|
||||||
|
federation: "http://localhost:8004"
|
||||||
|
cache:
|
||||||
|
fs_root: _snapshot # <- absolute path to your snapshot dir
|
||||||
|
categories:
|
||||||
|
allow:
|
||||||
|
Basics: basics
|
||||||
|
Branded Goods: branded-goods
|
||||||
|
Chilled: chilled
|
||||||
|
Frozen: frozen
|
||||||
|
Non-foods: non-foods
|
||||||
|
Supplements: supplements
|
||||||
|
Christmas: christmas
|
||||||
|
slugs:
|
||||||
|
skip:
|
||||||
|
- ""
|
||||||
|
- customer
|
||||||
|
- account
|
||||||
|
- checkout
|
||||||
|
- wishlist
|
||||||
|
- sales
|
||||||
|
- contact
|
||||||
|
- privacy-policy
|
||||||
|
- terms-and-conditions
|
||||||
|
- delivery
|
||||||
|
- catalogsearch
|
||||||
|
- quickorder
|
||||||
|
- apply
|
||||||
|
- search
|
||||||
|
- static
|
||||||
|
- media
|
||||||
|
section-titles:
|
||||||
|
- ingredients
|
||||||
|
- allergy information
|
||||||
|
- allergens
|
||||||
|
- nutritional information
|
||||||
|
- nutrition
|
||||||
|
- storage
|
||||||
|
- directions
|
||||||
|
- preparation
|
||||||
|
- serving suggestions
|
||||||
|
- origin
|
||||||
|
- country of origin
|
||||||
|
- recycling
|
||||||
|
- general information
|
||||||
|
- additional information
|
||||||
|
- a note about prices
|
||||||
|
|
||||||
|
blacklist:
|
||||||
|
category:
|
||||||
|
- branded-goods/alcoholic-drinks
|
||||||
|
- branded-goods/beers
|
||||||
|
- branded-goods/wines
|
||||||
|
- branded-goods/ciders
|
||||||
|
product:
|
||||||
|
- list-price-suma-current-suma-price-list-each-bk012-2-html
|
||||||
|
- ---just-lem-just-wholefoods-jelly-crystals-lemon-12-x-85g-vf067-2-html
|
||||||
|
product-details:
|
||||||
|
- General Information
|
||||||
|
- A Note About Prices
|
||||||
|
|
||||||
|
# SumUp payment settings (fill these in for live usage)
|
||||||
|
sumup:
|
||||||
|
merchant_code: "ME4J6100"
|
||||||
|
currency: "GBP"
|
||||||
|
# Name of the environment variable that holds your SumUp API key
|
||||||
|
api_key_env: "SUMUP_API_KEY"
|
||||||
|
webhook_secret: "CHANGE_ME_TO_A_LONG_RANDOM_STRING"
|
||||||
|
checkout_reference_prefix: 'dev-'
|
||||||
|
|
||||||
8
models/__init__.py
Normal file
8
models/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from .market import (
|
||||||
|
Product, ProductLike, ProductImage, ProductSection,
|
||||||
|
NavTop, NavSub, Listing, ListingItem,
|
||||||
|
LinkError, LinkExternal, SubcategoryRedirect, ProductLog,
|
||||||
|
ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen,
|
||||||
|
CartItem,
|
||||||
|
)
|
||||||
|
from .market_place import MarketPlace
|
||||||
7
models/market.py
Normal file
7
models/market.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from shared.models.market import ( # noqa: F401
|
||||||
|
Product, ProductLike, ProductImage, ProductSection,
|
||||||
|
NavTop, NavSub, Listing, ListingItem,
|
||||||
|
LinkError, LinkExternal, SubcategoryRedirect, ProductLog,
|
||||||
|
ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen,
|
||||||
|
CartItem,
|
||||||
|
)
|
||||||
1
models/market_place.py
Normal file
1
models/market_place.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from shared.models.market_place import MarketPlace # noqa: F401
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# Add the shared library submodule to the Python path
|
_app_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
_shared = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shared_lib")
|
_project_root = os.path.dirname(_app_dir)
|
||||||
if _shared not in sys.path:
|
|
||||||
sys.path.insert(0, _shared)
|
for _p in (_project_root, _app_dir):
|
||||||
|
if _p not in sys.path:
|
||||||
|
sys.path.insert(0, _p)
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ from typing import Dict, Set
|
|||||||
from ..http_client import configure_cookies
|
from ..http_client import configure_cookies
|
||||||
from ..get_auth import login
|
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
|
# DB: persistence helpers
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from bs4 import BeautifulSoup
|
|||||||
from urllib.parse import urlparse, urljoin
|
from urllib.parse import urlparse, urljoin
|
||||||
|
|
||||||
from ._anchor_text import _anchor_text
|
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
|
from .APP_ROOT_PLACEHOLDER import APP_ROOT_PLACEHOLDER
|
||||||
|
|
||||||
def _rewrite_links_fragment(
|
def _rewrite_links_fragment(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
from config import config
|
from shared.config import config
|
||||||
from utils import log
|
from shared.utils import log
|
||||||
from ...listings import scrape_products
|
from ...listings import scrape_products
|
||||||
|
|
||||||
async def capture_category(
|
async def capture_category(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from typing import Dict, Set
|
from typing import Dict, Set
|
||||||
from .capture_category import capture_category
|
from .capture_category import capture_category
|
||||||
from .capture_sub import capture_sub
|
from .capture_sub import capture_sub
|
||||||
from config import config
|
from shared.config import config
|
||||||
|
|
||||||
|
|
||||||
async def capture_product_slugs(
|
async def capture_product_slugs(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
from config import config
|
from shared.config import config
|
||||||
from utils import log
|
from shared.utils import log
|
||||||
from ...listings import scrape_products
|
from ...listings import scrape_products
|
||||||
|
|
||||||
async def capture_sub(
|
async def capture_sub(
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import httpx
|
|||||||
|
|
||||||
|
|
||||||
from ...html_utils import to_fragment
|
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 shared.config import config
|
||||||
|
|
||||||
from utils import log
|
from shared.utils import log
|
||||||
|
|
||||||
# DB: persistence helpers
|
# DB: persistence helpers
|
||||||
from ...product.product_detail import scrape_product_detail
|
from ...product.product_detail import scrape_product_detail
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import Dict, List, Set
|
from typing import Dict, List, Set
|
||||||
from config import config
|
from shared.config import config
|
||||||
from utils import log
|
from shared.utils import log
|
||||||
from .fetch_and_upsert_product import fetch_and_upsert_product
|
from .fetch_and_upsert_product import fetch_and_upsert_product
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from urllib.parse import urljoin
|
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]):
|
def rewrite_nav(nav: Dict[str, Dict], nav_redirects:Dict[str, str]):
|
||||||
if nav_redirects:
|
if nav_redirects:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from typing import Optional, Dict, Any, List
|
|||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
import httpx
|
import httpx
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from config import config
|
from shared.config import config
|
||||||
|
|
||||||
class LoginFailed(Exception):
|
class LoginFailed(Exception):
|
||||||
def __init__(self, message: str, *, debug: Dict[str, Any]):
|
def __init__(self, message: str, *, debug: Dict[str, Any]):
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
from config import config
|
from shared.config import config
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import secrets
|
|||||||
from typing import Optional, Dict
|
from typing import Optional, Dict
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from config import config
|
from shared.config import config
|
||||||
|
|
||||||
_CLIENT: httpx.AsyncClient | None = None
|
_CLIENT: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse
|
|||||||
|
|
||||||
|
|
||||||
from .http_client import fetch
|
from .http_client import fetch
|
||||||
from suma_browser.app.bp.browse.services.slugs import product_slug_from_href
|
from bp.browse.services.slugs import product_slug_from_href
|
||||||
from suma_browser.app.bp.browse.services.state import (
|
from bp.browse.services.state import (
|
||||||
KNOWN_PRODUCT_SLUGS,
|
KNOWN_PRODUCT_SLUGS,
|
||||||
_listing_page_cache,
|
_listing_page_cache,
|
||||||
_listing_page_ttl,
|
_listing_page_ttl,
|
||||||
@@ -16,8 +16,8 @@ from suma_browser.app.bp.browse.services.state import (
|
|||||||
_listing_variant_ttl,
|
_listing_variant_ttl,
|
||||||
now,
|
now,
|
||||||
)
|
)
|
||||||
from utils import normalize_text, soup_of
|
from shared.utils import normalize_text, soup_of
|
||||||
from config import config
|
from shared.config import config
|
||||||
|
|
||||||
|
|
||||||
def parse_total_pages_from_text(text: str) -> Optional[int]:
|
def parse_total_pages_from_text(text: str) -> Optional[int]:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import Dict, List, Tuple, Optional
|
|||||||
from urllib.parse import urlparse, urljoin
|
from urllib.parse import urlparse, urljoin
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
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 .http_client import fetch # only fetch; define soup_of locally
|
||||||
#from .. import cache_backend as cb
|
#from .. import cache_backend as cb
|
||||||
#from ..blacklist.category import is_category_blocked # Reverse map: slug -> label
|
#from ..blacklist.category import is_category_blocked # Reverse map: slug -> label
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from models.market import (
|
|||||||
Listing,
|
Listing,
|
||||||
ListingItem,
|
ListingItem,
|
||||||
)
|
)
|
||||||
from db.session import get_session
|
from shared.db.session import get_session
|
||||||
|
|
||||||
# --- Models are unchanged, see original code ---
|
# --- Models are unchanged, see original code ---
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import Dict
|
|||||||
from models.market import (
|
from models.market import (
|
||||||
ProductLog,
|
ProductLog,
|
||||||
)
|
)
|
||||||
from db.session import get_session
|
from shared.db.session import get_session
|
||||||
|
|
||||||
|
|
||||||
async def log_product_result(ok: bool, payload: Dict) -> None:
|
async def log_product_result(ok: bool, payload: Dict) -> None:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from models.market import (
|
|||||||
LinkError,
|
LinkError,
|
||||||
LinkExternal,
|
LinkExternal,
|
||||||
)
|
)
|
||||||
from db.session import get_session
|
from shared.db.session import get_session
|
||||||
|
|
||||||
# --- Models are unchanged, see original code ---
|
# --- Models are unchanged, see original code ---
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from models.market import (
|
|||||||
NavTop,
|
NavTop,
|
||||||
NavSub,
|
NavSub,
|
||||||
)
|
)
|
||||||
from db.session import get_session
|
from shared.db.session import get_session
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from sqlalchemy import (
|
|||||||
from models.market import (
|
from models.market import (
|
||||||
SubcategoryRedirect,
|
SubcategoryRedirect,
|
||||||
)
|
)
|
||||||
from db.session import get_session
|
from shared.db.session import get_session
|
||||||
|
|
||||||
# --- Models are unchanged, see original code ---
|
# --- Models are unchanged, see original code ---
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from models.market import (
|
|||||||
ProductNutrition,
|
ProductNutrition,
|
||||||
ProductAllergen
|
ProductAllergen
|
||||||
)
|
)
|
||||||
from db.session import get_session
|
from shared.db.session import get_session
|
||||||
|
|
||||||
from ._get import _get
|
from ._get import _get
|
||||||
from .log_product_result import _log_product_result
|
from .log_product_result import _log_product_result
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from typing import Dict, List, Union
|
from typing import Dict, List, Union
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from utils import normalize_text
|
from shared.utils import normalize_text
|
||||||
from ..registry import extractor
|
from ..registry import extractor
|
||||||
|
|
||||||
@extractor
|
@extractor
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from utils import normalize_text
|
from shared.utils import normalize_text
|
||||||
from ...html_utils import absolutize_fragment
|
from ...html_utils import absolutize_fragment
|
||||||
from ..registry import extractor
|
from ..registry import extractor
|
||||||
from ..helpers.desc import (
|
from ..helpers.desc import (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from typing import Dict, Union
|
from typing import Dict, Union
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from utils import normalize_text
|
from shared.utils import normalize_text
|
||||||
from ..registry import extractor
|
from ..registry import extractor
|
||||||
from ..helpers.price import parse_price, parse_case_size
|
from ..helpers.price import parse_price, parse_case_size
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from utils import normalize_text
|
from shared.utils import normalize_text
|
||||||
from ..registry import extractor
|
from ..registry import extractor
|
||||||
|
|
||||||
@extractor
|
@extractor
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
import re
|
import re
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from utils import normalize_text
|
from shared.utils import normalize_text
|
||||||
from ..registry import extractor
|
from ..registry import extractor
|
||||||
from ..helpers.desc import (
|
from ..helpers.desc import (
|
||||||
split_description_container, find_description_container,
|
split_description_container, find_description_container,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from utils import normalize_text
|
from shared.utils import normalize_text
|
||||||
from ..registry import extractor
|
from ..registry import extractor
|
||||||
|
|
||||||
@extractor
|
@extractor
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from utils import normalize_text
|
from shared.utils import normalize_text
|
||||||
from ..registry import extractor
|
from ..registry import extractor
|
||||||
|
|
||||||
@extractor
|
@extractor
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
from bs4 import BeautifulSoup, NavigableString, Tag
|
from bs4 import BeautifulSoup, NavigableString, Tag
|
||||||
from utils import normalize_text
|
from shared.utils import normalize_text
|
||||||
from ...html_utils import absolutize_fragment
|
from ...html_utils import absolutize_fragment
|
||||||
from .text import clean_title, is_blacklisted_heading
|
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]]:
|
def split_description_container(desc_el: Tag) -> Tuple[str, List[Dict]]:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from urllib.parse import urljoin, urlparse
|
from urllib.parse import urljoin, urlparse
|
||||||
from config import config
|
from shared.config import config
|
||||||
|
|
||||||
def first_from_srcset(val: str) -> Optional[str]:
|
def first_from_srcset(val: str) -> Optional[str]:
|
||||||
if not val:
|
if not val:
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import re
|
import re
|
||||||
from utils import normalize_text
|
from shared.utils import normalize_text
|
||||||
from config import config
|
from shared.config import config
|
||||||
|
|
||||||
def clean_title(t: str) -> str:
|
def clean_title(t: str) -> str:
|
||||||
t = normalize_text(t)
|
t = normalize_text(t)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from typing import Dict, Tuple, Union
|
from typing import Dict, Tuple, Union
|
||||||
from utils import soup_of
|
from shared.utils import soup_of
|
||||||
from ..http_client import fetch
|
from ..http_client import fetch
|
||||||
from ..html_utils import absolutize_fragment
|
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 .registry import REGISTRY, merge_missing
|
||||||
from . import extractors as _auto_register # noqa: F401 (import-time side effects)
|
from . import extractors as _auto_register # noqa: F401 (import-time side effects)
|
||||||
|
|
||||||
|
|||||||
29
services/__init__.py
Normal file
29
services/__init__.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"""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()
|
||||||
|
if not services.has("federation"):
|
||||||
|
from shared.services.federation_impl import SqlFederationService
|
||||||
|
services.federation = SqlFederationService()
|
||||||
1
shared
Submodule
1
shared
Submodule
Submodule shared added at 9ab4b7b3fe
Submodule shared_lib deleted from 356d916e26
33
templates/_types/all_markets/_card.html
Normal file
33
templates/_types/all_markets/_card.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{# Card for a single market in the global listing #}
|
||||||
|
{% set pi = page_info.get(market.container_id, {}) %}
|
||||||
|
{% set page_slug = pi.get('slug', '') %}
|
||||||
|
{% set page_title = pi.get('title') %}
|
||||||
|
{% if page_slug %}
|
||||||
|
{% set market_href = market_url('/' ~ page_slug ~ '/' ~ market.slug ~ '/') %}
|
||||||
|
{% else %}
|
||||||
|
{% set market_href = '' %}
|
||||||
|
{% endif %}
|
||||||
|
<article class="rounded-xl bg-white shadow-sm border border-stone-200 p-5 flex flex-col justify-between hover:border-stone-400 transition-colors">
|
||||||
|
<div>
|
||||||
|
{% if market_href %}
|
||||||
|
<a href="{{ market_href }}" class="hover:text-emerald-700">
|
||||||
|
<h2 class="text-lg font-semibold text-stone-900">{{ market.name }}</h2>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<h2 class="text-lg font-semibold text-stone-900">{{ market.name }}</h2>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if market.description %}
|
||||||
|
<p class="text-sm text-stone-600 mt-1 line-clamp-2">{{ market.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-1.5 mt-3">
|
||||||
|
{% if page_title %}
|
||||||
|
<a href="{{ market_url('/' ~ page_slug ~ '/') }}"
|
||||||
|
class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200">
|
||||||
|
{{ page_title }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
18
templates/_types/all_markets/_cards.html
Normal file
18
templates/_types/all_markets/_cards.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{% for market in markets %}
|
||||||
|
{% include "_types/all_markets/_card.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if has_more %}
|
||||||
|
{# Infinite scroll sentinel #}
|
||||||
|
{% set next_url = url_for('all_markets.markets_fragment', page=page + 1)|host %}
|
||||||
|
<div
|
||||||
|
id="sentinel-{{ page }}"
|
||||||
|
class="h-4 opacity-0 pointer-events-none"
|
||||||
|
hx-get="{{ next_url }}"
|
||||||
|
hx-trigger="intersect once delay:250ms"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
role="status"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div class="text-center text-xs text-stone-400">loading...</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
12
templates/_types/all_markets/_main_panel.html
Normal file
12
templates/_types/all_markets/_main_panel.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{# Markets grid #}
|
||||||
|
{% if markets %}
|
||||||
|
<div class="max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
{% include "_types/all_markets/_cards.html" %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="px-3 py-12 text-center text-stone-400">
|
||||||
|
<i class="fa fa-store text-4xl mb-3" aria-hidden="true"></i>
|
||||||
|
<p class="text-lg">No markets available</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="pb-8"></div>
|
||||||
7
templates/_types/all_markets/index.html
Normal file
7
templates/_types/all_markets/index.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{% extends '_types/root/_index.html' %}
|
||||||
|
|
||||||
|
{% block meta %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_types/all_markets/_main_panel.html' %}
|
||||||
|
{% endblock %}
|
||||||
@@ -2,6 +2,6 @@
|
|||||||
{% if g.rights.admin %}
|
{% if g.rights.admin %}
|
||||||
{% from 'macros/admin_nav.html' import admin_nav_item %}
|
{% from 'macros/admin_nav.html' import admin_nav_item %}
|
||||||
{{admin_nav_item(
|
{{admin_nav_item(
|
||||||
url_for('market.browse.product.admin', slug=slug)
|
url_for('market.browse.product.admin', product_slug=slug)
|
||||||
)}}
|
)}}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
{% import '_types/product/prices.html' as prices %}
|
{% import '_types/product/prices.html' as prices %}
|
||||||
{% set prices_ns = namespace() %}
|
{% set prices_ns = namespace() %}
|
||||||
{{ prices.set_prices(p, prices_ns) }}
|
{{ prices.set_prices(p, prices_ns) }}
|
||||||
{% set item_href = url_for('market.browse.product.product_detail', slug=p.slug)|host %}
|
{% set item_href = url_for('market.browse.product.product_detail', product_slug=p.slug)|host %}
|
||||||
<div class="flex flex-col rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden relative">
|
<div class="flex flex-col rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden relative">
|
||||||
{# ❤️ like button overlay - OUTSIDE the link #}
|
{# ❤️ like button overlay - OUTSIDE the link #}
|
||||||
{% if g.user %}
|
{% if g.user %}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<nav aria-label="Categories"
|
<nav aria-label="Categories"
|
||||||
class="rounded-xl border bg-white shadow-sm min-h-0">
|
class="rounded-xl border bg-white shadow-sm min-h-0">
|
||||||
<ul class="divide-y">
|
<ul class="divide-y">
|
||||||
{% set top_active = (current_local_href == top_local_href) %}
|
{% set top_active = (sub_slug is not defined or sub_slug is none or sub_slug == '') %}
|
||||||
{% set href = (url_for('market.browse.browse_top', top_slug=top_slug) ~ qs)|host %}
|
{% set href = (url_for('market.browse.browse_top', top_slug=top_slug) ~ qs)|host %}
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
</li>
|
</li>
|
||||||
|
|
||||||
{% for sub in subs_local %}
|
{% for sub in subs_local %}
|
||||||
{% set active = (current_local_href == sub.local_href) %}
|
{% set active = (sub.slug == sub_slug) %}
|
||||||
{% set href = (url_for('market.browse.browse_sub', top_slug=top_slug, sub_slug=sub.slug) ~ qs)|host %}
|
{% set href = (url_for('market.browse.browse_sub', top_slug=top_slug, sub_slug=sub.slug) ~ qs)|host %}
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<button
|
<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]"
|
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-post="{{ like_url if like_url else url_for('market.browse.product.like_toggle', product_slug=slug)|host }}"
|
||||||
hx-target="this"
|
hx-target="this"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
hx-push-url="false"
|
hx-push-url="false"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
class="font-bold text-xl flex-shrink-0 flex gap-2 items-center">
|
class="font-bold text-xl flex-shrink-0 flex gap-2 items-center">
|
||||||
<div>
|
<div>
|
||||||
<i class="fa fa-shop"></i>
|
<i class="fa fa-shop"></i>
|
||||||
{{ coop_title }}
|
{{ market_title }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col md:flex-row md:gap-2 text-xs">
|
<div class="flex flex-col md:flex-row md:gap-2 text-xs">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
{% if markets %}
|
{% if markets %}
|
||||||
<div class="grid gap-4">
|
<div class="grid gap-4">
|
||||||
{% for m in markets %}
|
{% 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">
|
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>
|
<h2 class="text-lg font-semibold">{{ m.name }}</h2>
|
||||||
{% if m.description %}
|
{% if m.description %}
|
||||||
|
|||||||
13
templates/_types/page_markets/_card.html
Normal file
13
templates/_types/page_markets/_card.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{# Card for a single market in a page-scoped listing #}
|
||||||
|
{% set market_href = market_url('/' ~ post.slug ~ '/' ~ market.slug ~ '/') %}
|
||||||
|
<article class="rounded-xl bg-white shadow-sm border border-stone-200 p-5 flex flex-col justify-between hover:border-stone-400 transition-colors">
|
||||||
|
<div>
|
||||||
|
<a href="{{ market_href }}" class="hover:text-emerald-700">
|
||||||
|
<h2 class="text-lg font-semibold text-stone-900">{{ market.name }}</h2>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if market.description %}
|
||||||
|
<p class="text-sm text-stone-600 mt-1 line-clamp-2">{{ market.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
18
templates/_types/page_markets/_cards.html
Normal file
18
templates/_types/page_markets/_cards.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{% for market in markets %}
|
||||||
|
{% include "_types/page_markets/_card.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if has_more %}
|
||||||
|
{# Infinite scroll sentinel #}
|
||||||
|
{% set next_url = url_for('page_markets.markets_fragment', page=page + 1)|host %}
|
||||||
|
<div
|
||||||
|
id="sentinel-{{ page }}"
|
||||||
|
class="h-4 opacity-0 pointer-events-none"
|
||||||
|
hx-get="{{ next_url }}"
|
||||||
|
hx-trigger="intersect once delay:250ms"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
role="status"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div class="text-center text-xs text-stone-400">loading...</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
12
templates/_types/page_markets/_main_panel.html
Normal file
12
templates/_types/page_markets/_main_panel.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{# Markets grid for a single page #}
|
||||||
|
{% if markets %}
|
||||||
|
<div class="max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
{% include "_types/page_markets/_cards.html" %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="px-3 py-12 text-center text-stone-400">
|
||||||
|
<i class="fa fa-store text-4xl mb-3" aria-hidden="true"></i>
|
||||||
|
<p class="text-lg">No markets for this page</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="pb-8"></div>
|
||||||
15
templates/_types/page_markets/index.html
Normal file
15
templates/_types/page_markets/index.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{% extends '_types/root/_index.html' %}
|
||||||
|
|
||||||
|
{% block meta %}{% endblock %}
|
||||||
|
|
||||||
|
{% block root_header_child %}
|
||||||
|
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||||
|
{% call index_row('post-header-child', '_types/post/header/_header.html') %}
|
||||||
|
{% block post_header_child %}
|
||||||
|
{% endblock %}
|
||||||
|
{% endcall %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_types/page_markets/_main_panel.html' %}
|
||||||
|
{% endblock %}
|
||||||
15
templates/_types/post/_nav.html
Normal file
15
templates/_types/post/_nav.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{% import 'macros/links.html' as links %}
|
||||||
|
{# Widget-driven container nav — entries, calendars, markets #}
|
||||||
|
{% if container_nav_widgets %}
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
|
||||||
|
id="entries-calendars-nav-wrapper">
|
||||||
|
{% include '_types/post/admin/_nav_entries.html' %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Admin link #}
|
||||||
|
{% if post and has_access('blog.post.admin.admin') %}
|
||||||
|
{% call links.link(url_for('blog.post.admin.admin', slug=post.slug), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
|
||||||
|
<i class="fa fa-cog" aria-hidden="true"></i>
|
||||||
|
{% endcall %}
|
||||||
|
{% endif %}
|
||||||
50
templates/_types/post/admin/_nav_entries.html
Normal file
50
templates/_types/post/admin/_nav_entries.html
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
|
||||||
|
{# Left scroll arrow - desktop only #}
|
||||||
|
<button
|
||||||
|
class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
||||||
|
aria-label="Scroll left"
|
||||||
|
_="on click
|
||||||
|
set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200">
|
||||||
|
<i class="fa fa-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{# Widget-driven nav items container #}
|
||||||
|
<div id="associated-items-container"
|
||||||
|
class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"
|
||||||
|
style="scroll-behavior: smooth;"
|
||||||
|
_="on load or scroll
|
||||||
|
-- Show arrows if content overflows (desktop only)
|
||||||
|
if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth
|
||||||
|
remove .hidden from .entries-nav-arrow
|
||||||
|
add .flex to .entries-nav-arrow
|
||||||
|
else
|
||||||
|
add .hidden to .entries-nav-arrow
|
||||||
|
remove .flex from .entries-nav-arrow
|
||||||
|
end">
|
||||||
|
<div class="flex flex-col sm:flex-row gap-1">
|
||||||
|
{% for wdata in container_nav_widgets %}
|
||||||
|
{% with ctx=wdata.ctx %}
|
||||||
|
{% include wdata.widget.template with context %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{# Right scroll arrow - desktop only #}
|
||||||
|
<button
|
||||||
|
class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"
|
||||||
|
aria-label="Scroll right"
|
||||||
|
_="on click
|
||||||
|
set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200">
|
||||||
|
<i class="fa fa-chevron-right"></i>
|
||||||
|
</button>
|
||||||
28
templates/_types/post/header/_header.html
Normal file
28
templates/_types/post/header/_header.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{% import 'macros/links.html' as links %}
|
||||||
|
{% macro header_row(oob=False) %}
|
||||||
|
{% call links.menu_row(id='post-row', oob=oob) %}
|
||||||
|
{% call links.link(blog_url('/' + post.slug + '/'), hx_select_search ) %}
|
||||||
|
{% if post.feature_image %}
|
||||||
|
<img
|
||||||
|
src="{{ post.feature_image }}"
|
||||||
|
class="h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"
|
||||||
|
>
|
||||||
|
{% endif %}
|
||||||
|
<span>
|
||||||
|
{{ post.title | truncate(160, True, '…') }}
|
||||||
|
</span>
|
||||||
|
{% endcall %}
|
||||||
|
{% call links.desktop_nav() %}
|
||||||
|
{% if page_cart_count is defined and page_cart_count > 0 %}
|
||||||
|
<a
|
||||||
|
href="{{ cart_url('/' + post.slug + '/') }}"
|
||||||
|
class="relative inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100 transition"
|
||||||
|
>
|
||||||
|
<i class="fa fa-shopping-cart" aria-hidden="true"></i>
|
||||||
|
<span>{{ page_cart_count }}</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% include '_types/post/_nav.html' %}
|
||||||
|
{% endcall %}
|
||||||
|
{% endcall %}
|
||||||
|
{% endmacro %}
|
||||||
@@ -1,25 +1,17 @@
|
|||||||
{% set oob='true' %}
|
{# HTMX response after add-to-cart: OOB-swap the mini cart + product buttons #}
|
||||||
{% import '_types/product/_cart.html' as _cart %}
|
{% import '_types/product/_cart.html' as _cart %}
|
||||||
{% from '_types/cart/_mini.html' import mini with context %}
|
|
||||||
{{mini()}}
|
|
||||||
|
|
||||||
|
{# 1. Update mini cart directly — handler already has the cart data #}
|
||||||
|
{% from 'macros/cart_icon.html' import cart_icon %}
|
||||||
|
{{ cart_icon(count=cart | sum(attribute="quantity")) }}
|
||||||
|
|
||||||
|
{# 2. Update add/remove buttons on the product #}
|
||||||
{{ _cart.add(d.slug, cart, oob='true') }}
|
{{ _cart.add(d.slug, cart, oob='true') }}
|
||||||
|
|
||||||
|
{# 3. Update cart item row if visible #}
|
||||||
{% from '_types/product/_cart.html' import cart_item with context %}
|
{% from '_types/product/_cart.html' import cart_item with context %}
|
||||||
|
{% if item and item.quantity > 0 %}
|
||||||
{% if cart | sum(attribute="quantity") > 0 %}
|
|
||||||
{% if item.quantity > 0 %}
|
|
||||||
{{ cart_item(oob='true') }}
|
{{ cart_item(oob='true') }}
|
||||||
{% else %}
|
{% elif item %}
|
||||||
{{ cart_item(oob='delete') }}
|
{{ cart_item(oob='delete') }}
|
||||||
{% endif %}
|
{% 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 %}
|
|
||||||
@@ -7,9 +7,9 @@
|
|||||||
|
|
||||||
{% if not quantity %}
|
{% if not quantity %}
|
||||||
<form
|
<form
|
||||||
action="{{ url_for('market.browse.product.cart', slug=slug) }}"
|
action="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
|
||||||
method="post"
|
method="post"
|
||||||
hx-post="{{ url_for('market.browse.product.cart', slug=slug) }}"
|
hx-post="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
|
||||||
hx-target="#cart-mini"
|
hx-target="#cart-mini"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
class="rounded flex items-center"
|
class="rounded flex items-center"
|
||||||
@@ -38,9 +38,9 @@
|
|||||||
<div class="rounded flex items-center gap-2">
|
<div class="rounded flex items-center gap-2">
|
||||||
<!-- minus -->
|
<!-- minus -->
|
||||||
<form
|
<form
|
||||||
action="{{ url_for('market.browse.product.cart', slug=slug) }}"
|
action="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
|
||||||
method="post"
|
method="post"
|
||||||
hx-post="{{ url_for('market.browse.product.cart', slug=slug) }}"
|
hx-post="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
|
||||||
hx-target="#cart-mini"
|
hx-target="#cart-mini"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
>
|
>
|
||||||
@@ -80,9 +80,9 @@
|
|||||||
|
|
||||||
<!-- plus -->
|
<!-- plus -->
|
||||||
<form
|
<form
|
||||||
action="{{ url_for('market.browse.product.cart', slug=slug) }}"
|
action="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
|
||||||
method="post"
|
method="post"
|
||||||
hx-post="{{ url_for('market.browse.product.cart', slug=slug) }}"
|
hx-post="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
|
||||||
hx-target="#cart-mini"
|
hx-target="#cart-mini"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
>
|
>
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3">
|
<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<h2 class="text-sm sm:text-base md:text-lg font-semibold text-stone-900">
|
<h2 class="text-sm sm:text-base md:text-lg font-semibold text-stone-900">
|
||||||
{% set href=url_for('market.browse.product.product_detail', slug=p.slug) %}
|
{% set href=url_for('market.browse.product.product_detail', product_slug=p.slug) %}
|
||||||
<a
|
<a
|
||||||
href="{{ href }}"
|
href="{{ href }}"
|
||||||
hx_get="{{href}}"
|
hx_get="{{href}}"
|
||||||
@@ -189,9 +189,9 @@
|
|||||||
<div class="flex items-center gap-2 text-xs sm:text-sm text-stone-700">
|
<div class="flex items-center gap-2 text-xs sm:text-sm text-stone-700">
|
||||||
<span class="text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500">Quantity</span>
|
<span class="text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500">Quantity</span>
|
||||||
<form
|
<form
|
||||||
action="{{ url_for('market.browse.product.cart', slug=p.slug) }}"
|
action="{{ url_for('market.browse.product.cart', product_slug=p.slug) }}"
|
||||||
method="post"
|
method="post"
|
||||||
hx-post="{{ url_for('market.browse.product.cart', slug=p.slug) }}"
|
hx-post="{{ url_for('market.browse.product.cart', product_slug=p.slug) }}"
|
||||||
hx-target="#cart-mini"
|
hx-target="#cart-mini"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
>
|
>
|
||||||
@@ -212,9 +212,9 @@
|
|||||||
{{ item.quantity }}
|
{{ item.quantity }}
|
||||||
</span>
|
</span>
|
||||||
<form
|
<form
|
||||||
action="{{ url_for('market.browse.product.cart', slug=p.slug) }}"
|
action="{{ url_for('market.browse.product.cart', product_slug=p.slug) }}"
|
||||||
method="post"
|
method="post"
|
||||||
hx-post="{{ url_for('market.browse.product.cart', slug=p.slug) }}"
|
hx-post="{{ url_for('market.browse.product.cart', product_slug=p.slug) }}"
|
||||||
hx-target="#cart-mini"
|
hx-target="#cart-mini"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
{% block filter %}
|
{% block filter %}
|
||||||
{% call layout.details() %}
|
{% call layout.details() %}
|
||||||
{% call layout.summary('coop-child-header') %}
|
{% call layout.summary('blog-child-header') %}
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% call layout.menu('blog-child-menu') %}
|
{% call layout.menu('blog-child-menu') %}
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% import 'macros/links.html' as links %}
|
{% import 'macros/links.html' as links %}
|
||||||
{% macro header_row(oob=False) %}
|
{% macro header_row(oob=False) %}
|
||||||
{% call links.menu_row(id='product-admin-row', oob=oob) %}
|
{% call links.menu_row(id='product-admin-row', oob=oob) %}
|
||||||
{% call links.link(url_for('market.browse.product.admin', slug=d.slug), hx_select_search ) %}
|
{% call links.link(url_for('market.browse.product.admin', product_slug=d.slug), hx_select_search ) %}
|
||||||
admin!!
|
admin!!
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% call links.desktop_nav() %}
|
{% call links.desktop_nav() %}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
{% block ___app_title %}
|
{% block ___app_title %}
|
||||||
{% import 'macros/links.html' as links %}
|
{% import 'macros/links.html' as links %}
|
||||||
{% call links.menu_row() %}
|
{% call links.menu_row() %}
|
||||||
{% call links.link(url_for('market.browse.product.admin', slug=slug), hx_select_search) %}
|
{% call links.link(url_for('market.browse.product.admin', product_slug=slug), hx_select_search) %}
|
||||||
{{ links.admin() }}
|
{{ links.admin() }}
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% call links.desktop_nav() %}
|
{% call links.desktop_nav() %}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% import 'macros/links.html' as links %}
|
{% import 'macros/links.html' as links %}
|
||||||
{% macro header_row(oob=False) %}
|
{% macro header_row(oob=False) %}
|
||||||
{% call links.menu_row(id='product-row', oob=oob) %}
|
{% call links.menu_row(id='product-row', oob=oob) %}
|
||||||
{% call links.link(url_for('market.browse.product.product_detail', slug=d.slug), hx_select_search ) %}
|
{% call links.link(url_for('market.browse.product.product_detail', product_slug=d.slug), hx_select_search ) %}
|
||||||
{% include '_types/product/_title.html' %}
|
{% include '_types/product/_title.html' %}
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% include '_types/product/_prices.html' %}
|
{% include '_types/product/_prices.html' %}
|
||||||
|
|||||||
@@ -30,8 +30,8 @@
|
|||||||
{% block filter %}
|
{% block filter %}
|
||||||
|
|
||||||
{% call layout.details() %}
|
{% call layout.details() %}
|
||||||
{% call layout.summary('coop-child-header') %}
|
{% call layout.summary('blog-child-header') %}
|
||||||
{% block coop_child_summary %}
|
{% block blog_child_summary %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% call layout.menu('blog-child-menu') %}
|
{% call layout.menu('blog-child-menu') %}
|
||||||
|
|||||||
7
templates/aside_clear.html
Normal file
7
templates/aside_clear.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<aside
|
||||||
|
id="aside"
|
||||||
|
hx-swap-oob="outerHTML"
|
||||||
|
class="hidden"
|
||||||
|
>
|
||||||
|
</aside>
|
||||||
|
|
||||||
5
templates/filter_clear.html
Normal file
5
templates/filter_clear.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<div
|
||||||
|
id="filter"
|
||||||
|
hx-swap-oob="outerHTML"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
9
templates/fragments/container_nav_markets.html
Normal file
9
templates/fragments/container_nav_markets.html
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{# Market links nav — served as fragment from market app #}
|
||||||
|
{% for m in markets %}
|
||||||
|
<a
|
||||||
|
href="{{ market_url('/' + post_slug + '/' + m.slug + '/') }}"
|
||||||
|
class="{{styles.nav_button_less_pad}}">
|
||||||
|
<i class="fa fa-shopping-bag" aria-hidden="true"></i>
|
||||||
|
<div>{{m.name}}</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
117
templates/macros/filters.html
Normal file
117
templates/macros/filters.html
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
{#
|
||||||
|
Unified filter macros for browse/shop pages
|
||||||
|
Consolidates duplicate mobile/desktop filter components
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% macro filter_item(href, is_on, title, icon_html, count=none, variant='desktop') %}
|
||||||
|
{#
|
||||||
|
Generic filter item (works for labels, stickers, etc.)
|
||||||
|
variant: 'desktop' or 'mobile'
|
||||||
|
#}
|
||||||
|
{% set base_class = "flex flex-col items-center justify-center" %}
|
||||||
|
{% if variant == 'mobile' %}
|
||||||
|
{% set item_class = base_class ~ " p-1 rounded hover:bg-stone-50" %}
|
||||||
|
{% set count_class = "text-[10px] text-stone-500 mt-1 leading-none tabular-nums" if count != 0 else "text-md text-red-500 font-bold mt-1 leading-none tabular-nums" %}
|
||||||
|
{% else %}
|
||||||
|
{% set item_class = base_class ~ " py-2 w-full h-full" %}
|
||||||
|
{% set count_class = "text-xs text-stone-500 leading-none justify-self-end tabular-nums" if count != 0 else "text-md text-red-500 font-bold leading-none justify-self-end tabular-nums" %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="{{ href }}"
|
||||||
|
hx-get="{{ href }}"
|
||||||
|
hx-target="#main-panel"
|
||||||
|
hx-select="{{ hx_select_search }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="true"
|
||||||
|
role="button"
|
||||||
|
aria-pressed="{{ 'true' if is_on else 'false' }}"
|
||||||
|
title="{{ title }}"
|
||||||
|
aria-label="{{ title }}"
|
||||||
|
class="{{ item_class }}"
|
||||||
|
>
|
||||||
|
{{ icon_html | safe }}
|
||||||
|
{% if count is not none %}
|
||||||
|
<span class="{{ count_class }}">{{ count }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
{% macro labels_list(labels, selected_labels, current_local_href, variant='desktop') %}
|
||||||
|
{#
|
||||||
|
Unified labels filter list
|
||||||
|
variant: 'desktop' or 'mobile'
|
||||||
|
#}
|
||||||
|
{% import 'macros/stickers.html' as stick %}
|
||||||
|
|
||||||
|
{% if variant == 'mobile' %}
|
||||||
|
<nav aria-label="labels" class="px-4 pb-3">
|
||||||
|
<ul class="flex w-full items-start justify-center gap-3 overflow-x-auto overflow-y-visible pr-1 no-scrollbar">
|
||||||
|
{% else %}
|
||||||
|
<ul id="labels-details-desktop" class="flex justify-center p-0 m-0 gap-2" >
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for s in labels %}
|
||||||
|
{% set is_on = (selected_labels and (s.name|lower in selected_labels)) %}
|
||||||
|
{% set qs = {"remove_label": s.name, "page": None}|qs if is_on else {"add_label": s.name, "page": None}|qs %}
|
||||||
|
{% set href = (current_local_href ~ qs)|host %}
|
||||||
|
|
||||||
|
<li class="{{ 'list-none shrink-0' if variant == 'mobile' else '' }}">
|
||||||
|
{{ filter_item(
|
||||||
|
href, is_on, s.name,
|
||||||
|
stick.sticker(asset_url('nav-labels/' ~ s.name ~ '.svg'), s.name, is_on),
|
||||||
|
s.count, variant
|
||||||
|
) }}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
{% if variant == 'mobile' %}
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
{% macro stickers_list(stickers, selected_stickers, current_local_href, variant='desktop') %}
|
||||||
|
{#
|
||||||
|
Unified stickers filter list
|
||||||
|
variant: 'desktop' or 'mobile'
|
||||||
|
#}
|
||||||
|
{% import 'macros/stickers.html' as stick %}
|
||||||
|
|
||||||
|
{% if variant == 'mobile' %}
|
||||||
|
<nav aria-label="stickers" class="px-4 pb-3">
|
||||||
|
<ul class="flex w-full items-start justify-center gap-3 overflow-x-auto overflow-y-visible pr-1 no-scrollbar">
|
||||||
|
{% else %}
|
||||||
|
<ul id="stickers-details-desktop"
|
||||||
|
class="flex flex-wrap justify-center w-full p-0 m-0 border bg-white shadow-sm rounded-xl gap-1 [&>li]:list-none [&>li]:basis-[20%] [&>li]:max-w-[20%] [&>li]:grow-0"
|
||||||
|
>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for s in stickers %}
|
||||||
|
{% set is_on = (selected_stickers and (s.name|lower in selected_stickers)) %}
|
||||||
|
{% set qs = {"remove_sticker": s.name, "page": None}|qs if is_on else {"add_sticker": s.name, "page": None}|qs %}
|
||||||
|
{% set href = (current_local_href ~ qs)|host %}
|
||||||
|
{% set display_name = s.name|capitalize if s.name|lower != 'sugarfree' else 'Sugar' %}
|
||||||
|
|
||||||
|
<li class="{{ 'list-none shrink-0' if variant == 'mobile' else '' }}">
|
||||||
|
{% set icon_html %}
|
||||||
|
<span class="{{ 'text-sm' if variant == 'mobile' else 'text-[11px]' }}">{{ display_name }}</span>
|
||||||
|
{{ stick.sticker(asset_url('stickers/' ~ s.name ~ '.svg'), s.name, is_on) }}
|
||||||
|
{% endset %}
|
||||||
|
{{ filter_item(href, is_on, s.name, icon_html, s.count, variant) }}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
{% if variant == 'mobile' %}
|
||||||
|
</nav>
|
||||||
|
<style>
|
||||||
|
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||||
|
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||||
|
</style>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user