Compare commits

...

23 Commits

Author SHA1 Message Date
giles
95d954fdb6 fix: remove existing bp dir before symlinking in Dockerfile
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 01:08:12 +00:00
giles
ac9d97d78d fix: preserve post admin nav bar across events app pages
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s
- Add post-admin-header-child row to calendars, calendar, markets,
  and payments index templates so the admin nav bar persists
- Create events-app-specific post/admin nav and header templates
  using coop_url() for blog endpoints instead of url_for()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:51:40 +00:00
giles
e0679f8100 feat: add markets and payments management pages
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s
- New markets blueprint at /<slug>/markets/ with create/delete
- New payments blueprint at /<slug>/payments/ with SumUp config
- Register both in events app with context processor for markets
- Remove PageConfig feature flag check from calendar creation
  (feature toggles replaced by direct management pages)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 23:45:07 +00:00
giles
0255c937dd chore: move repo to ~/rose-ash/ and add configurable CI paths
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 37s
REPO_DIR points to /root/rose-ash/events, COOP_DIR to /root/coop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 22:05:44 +00:00
giles
a4ea5e3bd1 chore: update shared_lib submodule to Phase 4
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m1s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 21:47:48 +00:00
giles
c09c433f82 chore: update shared_lib submodule to Phase 3
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 35s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:54:23 +00:00
giles
249b7d3ff2 chore: update shared_lib submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 37s
Market URLs now include post slug prefix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:16:01 +00:00
giles
ab2fa6a393 chore: update shared_lib submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 35s
Market top menu link now goes to coop blog page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:08:08 +00:00
giles
27e6c85e2c chore: update shared_lib submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 36s
Picks up MarketPlace model, nav entries template, and related changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:01:02 +00:00
giles
8c1a0240a3 feat: enforce calendar creation only on pages with calendar feature
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 48s
Calendar creation now requires the parent post to be a page (is_page=True)
with the calendar feature enabled in its PageConfig. Update shared_lib
submodule with PageConfig model.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 14:27:39 +00:00
giles
4a707577d3 chore: update shared_lib (fix settings cog visibility)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 39s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 13:22:15 +00:00
giles
aa63af28fe chore: update shared_lib (fix stale blog.post.calendars refs)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 40s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 13:02:31 +00:00
giles
438b2edb0d fix: replace stale blog.post.* endpoint refs with coop_url()
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Replace url_for('blog.post.post_detail', ...) and
url_for('blog.post.admin.entries', ...) with coop_url() for
cross-service links back to the blog app.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 13:02:16 +00:00
giles
65f14ef0f5 chore: update shared_lib (cross-service calendar templates)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 40s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 11:55:21 +00:00
giles
dcf716c685 fix: make calendar name a link back to calendar view
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 11:52:25 +00:00
giles
99822607a4 chore: update shared_lib (remove console.log)
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 11:51:38 +00:00
giles
2f4f8b3e36 fix: use **kwargs in calendar admin handlers (slug popped by preprocessor)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 37s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 11:14:08 +00:00
giles
838f1ae1bc fix: calendar nav settings cog, header padding, post nav admin link
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 37s
- Calendar nav: fix Slots link URL, add admin cog for admins
- Calendar header: add left padding to calendar name
- Post nav: add settings cog linking to blog admin

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 11:09:37 +00:00
giles
75cdb489c4 chore: update shared_lib submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 10:55:12 +00:00
giles
9df5b45696 refactor: remove events post header override (shared covers it)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 10:54:55 +00:00
giles
fb0fc5999e feat: add post header (village hall menu bar) to calendar pages
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 37s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 10:47:16 +00:00
giles
43dd4d6db4 chore: update shared_lib submodule (post slug in events_url paths)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 10:36:57 +00:00
giles
0fb9b1f880 feat: nest calendars under /<slug>/calendars with auto slug injection
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 10:36:41 +00:00
34 changed files with 667 additions and 23 deletions

View File

@@ -7,7 +7,8 @@ on:
env: env:
REGISTRY: registry.rose-ash.com:5000 REGISTRY: registry.rose-ash.com:5000
IMAGE: events IMAGE: events
REPO_DIR: /root/events REPO_DIR: /root/rose-ash/events
COOP_DIR: /root/coop
jobs: jobs:
build-and-deploy: build-and-deploy:
@@ -58,7 +59,7 @@ jobs:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: | run: |
ssh "root@$DEPLOY_HOST" " ssh "root@$DEPLOY_HOST" "
cd /root/coop cd ${{ env.COOP_DIR }}
source .env source .env
docker stack deploy -c docker-compose.yml coop docker stack deploy -c docker-compose.yml coop
echo 'Waiting for services to update...' echo 'Waiting for services to update...'

View File

@@ -22,7 +22,7 @@ RUN pip install -r requirements.txt
COPY . . COPY . .
# Link app blueprints into the shared library's namespace # Link app blueprints into the shared library's namespace
RUN ln -s /app/bp /app/shared_lib/suma_browser/app/bp 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

88
app.py
View File

@@ -3,12 +3,13 @@ from __future__ import annotations
import path_setup # noqa: F401 # adds shared_lib to sys.path import path_setup # noqa: F401 # adds shared_lib to sys.path
from pathlib import Path from pathlib import Path
from quart import g from quart import g, abort
from jinja2 import FileSystemLoader, ChoiceLoader from jinja2 import FileSystemLoader, ChoiceLoader
from sqlalchemy import select
from shared.factory import create_base_app from shared.factory import create_base_app
from suma_browser.app.bp import register_calendars from suma_browser.app.bp import register_calendars, register_markets, register_payments
async def events_context() -> dict: async def events_context() -> dict:
@@ -40,6 +41,10 @@ async def events_context() -> dict:
def create_app() -> "Quart": def create_app() -> "Quart":
from models.ghost_content import Post
from models.calendars import Calendar
from models.market_place import MarketPlace
app = create_base_app("events", context_fn=events_context) app = create_base_app("events", context_fn=events_context)
# App-specific templates override shared templates # App-specific templates override shared templates
@@ -49,12 +54,87 @@ def create_app() -> "Quart":
app.jinja_loader, app.jinja_loader,
]) ])
# Calendars blueprint at root — standalone mode (no post nesting) # Calendars nested under post slug: /<slug>/calendars/...
app.register_blueprint( app.register_blueprint(
register_calendars(), register_calendars(),
url_prefix="/calendars", url_prefix="/<slug>/calendars",
) )
# Markets nested under post slug: /<slug>/markets/...
app.register_blueprint(
register_markets(),
url_prefix="/<slug>/markets",
)
# Payments nested under post slug: /<slug>/payments/...
app.register_blueprint(
register_payments(),
url_prefix="/<slug>/payments",
)
# --- Auto-inject slug into url_for() calls ---
@app.url_value_preprocessor
def pull_slug(endpoint, values):
if values and "slug" in values:
g.post_slug = values.pop("slug")
@app.url_defaults
def inject_slug(endpoint, values):
slug = g.get("post_slug")
if slug and "slug" not in values:
if app.url_map.is_endpoint_expecting(endpoint, "slug"):
values["slug"] = slug
# --- Load post data for slug ---
@app.before_request
async def hydrate_post():
slug = getattr(g, "post_slug", None)
if not slug:
return
post = (
await g.s.execute(
select(Post).where(Post.slug == slug)
)
).scalar_one_or_none()
if not post:
abort(404)
g.post_data = {
"post": {
"id": post.id,
"title": post.title,
"slug": post.slug,
"feature_image": post.feature_image,
"status": post.status,
"visibility": post.visibility,
},
}
@app.context_processor
async def inject_post():
post_data = getattr(g, "post_data", None)
if not post_data:
return {}
post_id = post_data["post"]["id"]
calendars = (
await g.s.execute(
select(Calendar)
.where(Calendar.post_id == post_id, Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc())
)
).scalars().all()
markets = (
await g.s.execute(
select(MarketPlace)
.where(MarketPlace.post_id == post_id, MarketPlace.deleted_at.is_(None))
.order_by(MarketPlace.name.asc())
)
).scalars().all()
return {
**post_data,
"calendars": calendars,
"markets": markets,
}
# Tickets blueprint — user-facing ticket views and QR codes # Tickets blueprint — user-facing ticket views and QR codes
from bp.tickets.routes import register as register_tickets from bp.tickets.routes import register as register_tickets
app.register_blueprint(register_tickets()) app.register_blueprint(register_tickets())

View File

@@ -1 +1,3 @@
from .calendars.routes import register as register_calendars from .calendars.routes import register as register_calendars
from .markets.routes import register as register_markets
from .payments.routes import register as register_payments

View File

@@ -16,7 +16,7 @@ def register():
# ---------- Pages ---------- # ---------- Pages ----------
@bp.get("/") @bp.get("/")
@require_admin @require_admin
async def admin(slug: str, calendar_slug: str): async def admin(calendar_slug: str, **kwargs):
from suma_browser.app.utils.htmx import is_htmx_request from suma_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
@@ -32,7 +32,7 @@ def register():
@bp.get("/description/") @bp.get("/description/")
@require_admin @require_admin
async def calendar_description_edit(slug: str, calendar_slug: str): async def calendar_description_edit(calendar_slug: str, **kwargs):
# g.post and g.calendar should already be set by the parent calendar bp # g.post and g.calendar should already be set by the parent calendar bp
html = await render_template( html = await render_template(
"_types/calendar/admin/_description_edit.html", "_types/calendar/admin/_description_edit.html",
@@ -45,7 +45,7 @@ def register():
@bp.post("/description/") @bp.post("/description/")
@require_admin @require_admin
@clear_cache(tag="calendars", tag_scope="all") @clear_cache(tag="calendars", tag_scope="all")
async def calendar_description_save(slug: str, calendar_slug: str): async def calendar_description_save(calendar_slug: str, **kwargs):
form = await request.form form = await request.form
description = (form.get("description") or "").strip() or None description = (form.get("description") or "").strip() or None
@@ -64,7 +64,7 @@ def register():
@bp.get("/description/view/") @bp.get("/description/view/")
@require_admin @require_admin
async def calendar_description_view(slug: str, calendar_slug: str): async def calendar_description_view(calendar_slug: str, **kwargs):
# just render the display version without touching the DB (used by Cancel) # just render the display version without touching the DB (used by Cancel)
html = await render_template( html = await render_template(
"_types/calendar/admin/_description.html", "_types/calendar/admin/_description.html",

View File

@@ -79,10 +79,14 @@ async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calend
slug=slugify(name) slug=slugify(name)
# Ensure post exists (avoid silent FK errors in some DBs) # Ensure post exists (avoid silent FK errors in some DBs)
post = (await sess.execute(select(Post.id).where(Post.id == post_id))).scalar_one_or_none() post = (await sess.execute(select(Post).where(Post.id == post_id))).scalar_one_or_none()
if not post: if not post:
raise CalendarError(f"Post {post_id} does not exist.") raise CalendarError(f"Post {post_id} does not exist.")
# Enforce: calendars can only be created on pages with the calendar feature
if not post.is_page:
raise CalendarError("Calendars can only be created on pages, not posts.")
# Look for existing (including soft-deleted) # Look for existing (including soft-deleted)
q = await sess.execute( q = await sess.execute(
select(Calendar).where(Calendar.post_id == post_id, Calendar.name == name) select(Calendar).where(Calendar.post_id == post_id, Calendar.name == name)

0
bp/markets/__init__.py Normal file
View File

68
bp/markets/routes.py Normal file
View File

@@ -0,0 +1,68 @@
from __future__ import annotations
from quart import (
request, render_template, make_response, Blueprint, g
)
from sqlalchemy import select
from models.market_place import MarketPlace
from .services.markets import (
create_market as svc_create_market,
soft_delete as svc_soft_delete,
)
from suma_browser.app.redis_cacher import cache_page, clear_cache
from suma_browser.app.authz import require_admin
from suma_browser.app.utils.htmx import is_htmx_request
def register():
bp = Blueprint("markets", __name__, url_prefix='/markets')
@bp.context_processor
async def inject_root():
return {}
@bp.get("/")
async def home(**kwargs):
if not is_htmx_request():
html = await render_template("_types/markets/index.html")
else:
html = await render_template("_types/markets/_oob_elements.html")
return await make_response(html)
@bp.post("/new/")
@require_admin
async def create_market(**kwargs):
form = await request.form
name = (form.get("name") or "").strip()
post_data = getattr(g, "post_data", None)
post_id = (post_data.get("post") or {}).get("id") if post_data else None
if not post_id:
post_id = form.get("post_id")
if post_id:
post_id = int(post_id)
try:
await svc_create_market(g.s, post_id, name)
except Exception as e:
return await make_response(f'<div class="text-red-600 text-sm">{e}</div>', 422)
html = await render_template("_types/markets/index.html")
return await make_response(html)
@bp.delete("/<market_slug>/")
@require_admin
async def delete_market(market_slug: str, **kwargs):
post_slug = getattr(g, "post_slug", None)
deleted = await svc_soft_delete(g.s, post_slug, market_slug)
if not deleted:
return await make_response("Market not found", 404)
html = await render_template("_types/markets/index.html")
return await make_response(html)
return bp

View File

View File

@@ -0,0 +1,85 @@
from __future__ import annotations
import re
import unicodedata
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.market_place import MarketPlace
from models.ghost_content import Post
from suma_browser.app.utils import utcnow
class MarketError(ValueError):
"""Base error for market service operations."""
def slugify(value: str, max_len: int = 255) -> str:
if value is None:
value = ""
value = unicodedata.normalize("NFKD", value)
value = value.encode("ascii", "ignore").decode("ascii")
value = value.lower()
value = value.replace("/", "-")
value = re.sub(r"[^a-z0-9]+", "-", value)
value = re.sub(r"-{2,}", "-", value)
value = value.strip("-")[:max_len].strip("-")
return value or "market"
async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPlace:
"""
Create a market for a page. Name must be unique per page.
If a market with the same (post_id, slug) exists but is soft-deleted,
it will be revived.
"""
name = (name or "").strip()
if not name:
raise MarketError("Market name must not be empty.")
slug = slugify(name)
post = (await sess.execute(select(Post).where(Post.id == post_id))).scalar_one_or_none()
if not post:
raise MarketError(f"Post {post_id} does not exist.")
if not post.is_page:
raise MarketError("Markets can only be created on pages, not posts.")
# Look for existing (including soft-deleted)
existing = (await sess.execute(
select(MarketPlace).where(MarketPlace.post_id == post_id, MarketPlace.slug == slug)
)).scalar_one_or_none()
if existing:
if existing.deleted_at is not None:
existing.deleted_at = None
existing.name = name
await sess.flush()
return existing
raise MarketError(f'Market with slug "{slug}" already exists for this page.')
market = MarketPlace(post_id=post_id, name=name, slug=slug)
sess.add(market)
await sess.flush()
return market
async def soft_delete(sess: AsyncSession, post_slug: str, market_slug: str) -> bool:
market = (
await sess.execute(
select(MarketPlace)
.join(Post, MarketPlace.post_id == Post.id)
.where(
Post.slug == post_slug,
MarketPlace.slug == market_slug,
MarketPlace.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if not market:
return False
market.deleted_at = utcnow()
await sess.flush()
return True

0
bp/payments/__init__.py Normal file
View File

81
bp/payments/routes.py Normal file
View File

@@ -0,0 +1,81 @@
from __future__ import annotations
from quart import (
render_template, make_response, Blueprint, g, request
)
from sqlalchemy import select
from models.page_config import PageConfig
from suma_browser.app.authz import require_admin
from suma_browser.app.utils.htmx import is_htmx_request
def register():
bp = Blueprint("payments", __name__, url_prefix='/payments')
@bp.context_processor
async def inject_root():
return {}
async def _load_payment_ctx():
"""Load PageConfig SumUp data for the current page."""
post = (getattr(g, "post_data", None) or {}).get("post", {})
post_id = post.get("id")
if not post_id:
return {}
pc = (await g.s.execute(
select(PageConfig).where(PageConfig.post_id == post_id)
)).scalar_one_or_none()
return {
"sumup_configured": bool(pc and pc.sumup_api_key),
"sumup_merchant_code": (pc.sumup_merchant_code or "") if pc else "",
"sumup_checkout_prefix": (pc.sumup_checkout_prefix or "") if pc else "",
}
@bp.get("/")
@require_admin
async def home(**kwargs):
ctx = await _load_payment_ctx()
if not is_htmx_request():
html = await render_template("_types/payments/index.html", **ctx)
else:
html = await render_template("_types/payments/_oob_elements.html", **ctx)
return await make_response(html)
@bp.put("/")
@require_admin
async def update_sumup(**kwargs):
"""Update SumUp credentials for this page."""
post = (getattr(g, "post_data", None) or {}).get("post", {})
post_id = post.get("id")
if not post_id:
return await make_response("Post not found", 404)
pc = (await g.s.execute(
select(PageConfig).where(PageConfig.post_id == post_id)
)).scalar_one_or_none()
if pc is None:
pc = PageConfig(post_id=post_id, features={})
g.s.add(pc)
await g.s.flush()
form = await request.form
merchant_code = (form.get("merchant_code") or "").strip()
api_key = (form.get("api_key") or "").strip()
checkout_prefix = (form.get("checkout_prefix") or "").strip()
pc.sumup_merchant_code = merchant_code or None
pc.sumup_checkout_prefix = checkout_prefix or None
if api_key:
pc.sumup_api_key = api_key
await g.s.flush()
ctx = await _load_payment_ctx()
html = await render_template("_types/payments/_main_panel.html", **ctx)
return await make_response(html)
return bp

View File

@@ -1,8 +1,10 @@
<!-- Desktop nav --> <!-- Desktop nav -->
{% import 'macros/links.html' as links %} {% import 'macros/links.html' as links %}
{% call links.link( {% call links.link(
url_for('calendars.calendar.slots.get', calendar_slug=calendar.slug),
hx_select_search, hx_select_search,
select_colours, select_colours,
True,
aclass=styles.nav_button aclass=styles.nav_button
) %} ) %}
<i class="fa fa-clock" aria-hidden="true"></i> <i class="fa fa-clock" aria-hidden="true"></i>
@@ -12,4 +14,5 @@
{% endcall %} {% endcall %}
{% 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(url_for('calendars.calendar.admin.admin', calendar_slug=calendar.slug)) }}
{% endif %} {% endif %}

View File

@@ -1,6 +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='calendar-row', oob=oob) %} {% call links.menu_row(id='calendar-row', oob=oob) %}
{% call links.link(url_for('calendars.calendar.get', calendar_slug=calendar.slug), hx_select_search) %}
<div class="flex flex-col md:flex-row md:gap-2 items-center min-w-0"> <div class="flex flex-col md:flex-row md:gap-2 items-center min-w-0">
<div class="flex flex-row items-center gap-2"> <div class="flex flex-row items-center gap-2">
<i class="fa fa-calendar"></i> <i class="fa fa-calendar"></i>
@@ -11,6 +12,7 @@
{% from '_types/calendar/_description.html' import description %} {% from '_types/calendar/_description.html' import description %}
{{description(calendar)}} {{description(calendar)}}
</div> </div>
{% endcall %}
{% call links.desktop_nav() %} {% call links.desktop_nav() %}
{% include '_types/calendar/_nav.html' %} {% include '_types/calendar/_nav.html' %}
{% endcall %} {% endcall %}

View File

@@ -4,9 +4,13 @@
{% block root_header_child %} {% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %} {% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('calendar-header-child', '_types/calendar/header/_header.html') %} {% call index_row('post-header-child', '_types/post/header/_header.html') %}
{% block calendar_header_child %} {% call index_row('post-admin-header-child', '_types/post/admin/header/_header.html') %}
{% endblock %} {% call index_row('calendar-header-child', '_types/calendar/header/_header.html') %}
{% block calendar_header_child %}
{% endblock %}
{% endcall %}
{% endcall %}
{% endcall %} {% endcall %}
{% endblock %} {% endblock %}

View File

@@ -4,9 +4,13 @@
{% block root_header_child %} {% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %} {% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('calendars-header-child', '_types/calendars/header/_header.html') %} {% call index_row('post-header-child', '_types/post/header/_header.html') %}
{% block calendars_header_child %} {% call index_row('post-admin-header-child', '_types/post/admin/header/_header.html') %}
{% endblock %} {% call index_row('calendars-header-child', '_types/calendars/header/_header.html') %}
{% block calendars_header_child %}
{% endblock %}
{% endcall %}
{% endcall %}
{% endcall %} {% endcall %}
{% endblock %} {% endblock %}

View File

@@ -6,7 +6,7 @@
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %} {% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
{% call(entry_post) scrolling_menu('entry-posts-container', entry_posts) %} {% call(entry_post) scrolling_menu('entry-posts-container', entry_posts) %}
<a <a
href="{{ url_for('blog.post.post_detail', slug=entry_post.slug) }}" href="{{ coop_url('/' + entry_post.slug + '/') }}"
class="flex items-center gap-2 px-3 py-2 hover:bg-stone-100 rounded transition text-sm border sm:whitespace-nowrap sm:flex-shrink-0"> class="flex items-center gap-2 px-3 py-2 hover:bg-stone-100 rounded transition text-sm border sm:whitespace-nowrap sm:flex-shrink-0">
{% if entry_post.feature_image %} {% if entry_post.feature_image %}
<img src="{{ entry_post.feature_image }}" <img src="{{ entry_post.feature_image }}"

View File

@@ -9,7 +9,7 @@
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %} {% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
{% call(entry_post) scrolling_menu('entry-posts-container', entry_posts) %} {% call(entry_post) scrolling_menu('entry-posts-container', entry_posts) %}
<a <a
href="{{ url_for('blog.post.post_detail', slug=entry_post.slug) }}" href="{{ coop_url('/' + entry_post.slug + '/') }}"
class="{{styles.nav_button}}" class="{{styles.nav_button}}"
> >
{% if entry_post.feature_image %} {% if entry_post.feature_image %}

View File

@@ -0,0 +1,25 @@
<section class="p-4">
{% if has_access('markets.create_market') %}
<div id="market-create-errors" class="mt-2 text-sm text-red-600"></div>
<form
class="mt-4 flex gap-2 items-end"
hx-post="{{ url_for('markets.create_market') }}"
hx-target="#markets-list"
hx-select="#markets-list"
hx-swap="outerHTML"
hx-on::before-request="document.querySelector('#market-create-errors').textContent='';"
hx-on::response-error="document.querySelector('#market-create-errors').innerHTML = event.detail.xhr.responseText;"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="flex-1">
<label class="block text-sm text-gray-600">Name</label>
<input name="name" type="text" required class="w-full border rounded px-3 py-2" placeholder="e.g. Farm Shop, Bakery" />
</div>
<button type="submit" class="border rounded px-3 py-2">Add market</button>
</form>
{% endif %}
<div id="markets-list" class="mt-6">
{% include "_types/markets/_markets_list.html" %}
</div>
</section>

View File

@@ -0,0 +1,37 @@
{% for m in markets %}
<div class="mt-6 border rounded-lg p-4">
<div class="flex items-center justify-between gap-3">
{% set market_href = market_url('/' + post.slug + '/' + m.slug + '/') %}
<a
class="flex items-baseline gap-3"
href="{{ market_href }}"
>
<h3 class="font-semibold">{{ m.name }}</h3>
<h4 class="text-gray-500">/{{ m.slug }}/</h4>
</a>
<button
class="text-sm border rounded px-3 py-1 hover:bg-red-50 hover:border-red-400"
data-confirm
data-confirm-title="Delete market?"
data-confirm-text="Products will be hidden (soft delete)"
data-confirm-icon="warning"
data-confirm-confirm-text="Yes, delete it"
data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed"
hx-delete="{{ url_for('markets.delete_market', market_slug=m.slug) }}"
hx-trigger="confirmed"
hx-target="#markets-list"
hx-select="#markets-list"
hx-swap="outerHTML"
hx-headers='{"X-CSRFToken":"{{ csrf_token() }}"}'
>
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div>
{% else %}
<p class="text-gray-500 mt-4">No markets yet. Create one above.</p>
{% endfor %}

View File

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

View File

@@ -0,0 +1,19 @@
{% extends 'oob_elements.html' %}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('post-admin-header-child', 'markets-header-child', '_types/markets/header/_header.html')}}
{% from '_types/post/admin/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block mobile_menu %}
{% include '_types/markets/_nav.html' %}
{% endblock %}
{% block content %}
{% include "_types/markets/_main_panel.html" %}
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='markets-row', oob=oob) %}
{% call links.link(url_for('markets.home'), hx_select_search) %}
<i class="fa fa-shopping-bag" aria-hidden="true"></i>
<div>
Markets
</div>
{% endcall %}
{% call links.desktop_nav() %}
{% include '_types/markets/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,23 @@
{% 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') %}
{% call index_row('post-admin-header-child', '_types/post/admin/header/_header.html') %}
{% call index_row('markets-header-child', '_types/markets/header/_header.html') %}
{% block markets_header_child %}
{% endblock %}
{% endcall %}
{% endcall %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include '_types/markets/_nav.html' %}
{% endblock %}
{% block content %}
{% include '_types/markets/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,70 @@
<section class="p-4 max-w-lg mx-auto">
<div id="payments-panel" class="space-y-4 p-4 bg-white rounded-lg border border-stone-200">
<h3 class="text-lg font-semibold text-stone-800">
<i class="fa fa-credit-card text-purple-600 mr-1"></i>
SumUp Payment
</h3>
<p class="text-xs text-stone-400">
Configure per-page SumUp credentials. Leave blank to use the global merchant account.
</p>
<form
hx-put="{{ url_for('payments.update_sumup') }}"
hx-target="#payments-panel"
hx-swap="outerHTML"
hx-select="#payments-panel"
class="space-y-3"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label class="block text-xs font-medium text-stone-600 mb-1">Merchant Code</label>
<input
type="text"
name="merchant_code"
value="{{ sumup_merchant_code }}"
placeholder="e.g. ME4J6100"
class="w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"
>
</div>
<div>
<label class="block text-xs font-medium text-stone-600 mb-1">API Key</label>
<input
type="password"
name="api_key"
value=""
placeholder="{{ '--------' if sumup_configured else 'sup_sk_...' }}"
class="w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"
>
{% if sumup_configured %}
<p class="text-xs text-stone-400 mt-0.5">Key is set. Leave blank to keep current key.</p>
{% endif %}
</div>
<div>
<label class="block text-xs font-medium text-stone-600 mb-1">Checkout Reference Prefix</label>
<input
type="text"
name="checkout_prefix"
value="{{ sumup_checkout_prefix }}"
placeholder="e.g. ROSE-"
class="w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"
>
</div>
<button
type="submit"
class="px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500"
>
Save SumUp Settings
</button>
{% if sumup_configured %}
<span class="ml-2 text-xs text-green-600">
<i class="fa fa-check-circle"></i> Connected
</span>
{% endif %}
</form>
</div>
</section>

View File

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

View File

@@ -0,0 +1,19 @@
{% extends 'oob_elements.html' %}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('post-admin-header-child', 'payments-header-child', '_types/payments/header/_header.html')}}
{% from '_types/post/admin/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block mobile_menu %}
{% include '_types/payments/_nav.html' %}
{% endblock %}
{% block content %}
{% include "_types/payments/_main_panel.html" %}
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='payments-row', oob=oob) %}
{% call links.link(url_for('payments.home'), hx_select_search) %}
<i class="fa fa-credit-card" aria-hidden="true"></i>
<div>
Payments
</div>
{% endcall %}
{% call links.desktop_nav() %}
{% include '_types/payments/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,23 @@
{% 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') %}
{% call index_row('post-admin-header-child', '_types/post/admin/header/_header.html') %}
{% call index_row('payments-header-child', '_types/payments/header/_header.html') %}
{% block payments_header_child %}
{% endblock %}
{% endcall %}
{% endcall %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include '_types/payments/_nav.html' %}
{% endblock %}
{% block content %}
{% include '_types/payments/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% import 'macros/links.html' as links %}
{% if calendars %}
{% for calendar in calendars %}
{% call links.link(url_for('calendars.calendar.get', calendar_slug=calendar.slug), hx_select_search, select_colours, True, aclass=styles.nav_button_less_pad) %}
<i class="fa fa-calendar" aria-hidden="true"></i>
<div>{{ calendar.name }}</div>
{% endcall %}
{% endfor %}
{% endif %}
{% if g.rights.admin %}
<a href="{{ coop_url('/' + post.slug + '/admin/') }}" class="{{styles.nav_button}}">
<i class="fa fa-cog" aria-hidden="true"></i>
</a>
{% endif %}

View File

@@ -0,0 +1,36 @@
{% import 'macros/links.html' as links %}
<div class="relative nav-group">
<a href="{{ events_url('/' + post.slug + '/calendars/') }}" class="{{styles.nav_button}}">
calendars
</a>
</div>
<div class="relative nav-group">
<a href="{{ events_url('/' + post.slug + '/markets/') }}" class="{{styles.nav_button}}">
markets
</a>
</div>
<div class="relative nav-group">
<a href="{{ events_url('/' + post.slug + '/payments/') }}" class="{{styles.nav_button}}">
payments
</a>
</div>
<div class="relative nav-group">
<a href="{{ coop_url('/' + post.slug + '/admin/entries/') }}" class="{{styles.nav_button}}">
entries
</a>
</div>
<div class="relative nav-group">
<a href="{{ coop_url('/' + post.slug + '/admin/data/') }}" class="{{styles.nav_button}}">
data
</a>
</div>
<div class="relative nav-group">
<a href="{{ coop_url('/' + post.slug + '/admin/edit/') }}" class="{{styles.nav_button}}">
edit
</a>
</div>
<div class="relative nav-group">
<a href="{{ coop_url('/' + post.slug + '/admin/settings/') }}" class="{{styles.nav_button}}">
settings
</a>
</div>

View File

@@ -0,0 +1,12 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='post-admin-row', oob=oob) %}
<a href="{{ coop_url('/' + post.slug + '/admin/') }}"
class="flex items-center gap-2 px-3 py-2 rounded">
{{ links.admin() }}
</a>
{% call links.desktop_nav() %}
{% include '_types/post/admin/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -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='post_entries-row', oob=oob) %} {% call links.menu_row(id='post_entries-row', oob=oob) %}
{% call links.link(url_for('blog.post.admin.entries', slug=post.slug), hx_select_search) %} {% call links.link(coop_url('/' + post.slug + '/admin/entries/'), hx_select_search) %}
<i class="fa fa-clock" aria-hidden="true"></i> <i class="fa fa-clock" aria-hidden="true"></i>
<div> <div>
entries entries