Compare commits
23 Commits
139cf5fa2f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95d954fdb6 | ||
|
|
ac9d97d78d | ||
|
|
e0679f8100 | ||
|
|
0255c937dd | ||
|
|
a4ea5e3bd1 | ||
|
|
c09c433f82 | ||
|
|
249b7d3ff2 | ||
|
|
ab2fa6a393 | ||
|
|
27e6c85e2c | ||
|
|
8c1a0240a3 | ||
|
|
4a707577d3 | ||
|
|
aa63af28fe | ||
|
|
438b2edb0d | ||
|
|
65f14ef0f5 | ||
|
|
dcf716c685 | ||
|
|
99822607a4 | ||
|
|
2f4f8b3e36 | ||
|
|
838f1ae1bc | ||
|
|
75cdb489c4 | ||
|
|
9df5b45696 | ||
|
|
fb0fc5999e | ||
|
|
43dd4d6db4 | ||
|
|
0fb9b1f880 |
@@ -7,7 +7,8 @@ on:
|
||||
env:
|
||||
REGISTRY: registry.rose-ash.com:5000
|
||||
IMAGE: events
|
||||
REPO_DIR: /root/events
|
||||
REPO_DIR: /root/rose-ash/events
|
||||
COOP_DIR: /root/coop
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
@@ -58,7 +59,7 @@ jobs:
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
run: |
|
||||
ssh "root@$DEPLOY_HOST" "
|
||||
cd /root/coop
|
||||
cd ${{ env.COOP_DIR }}
|
||||
source .env
|
||||
docker stack deploy -c docker-compose.yml coop
|
||||
echo 'Waiting for services to update...'
|
||||
|
||||
@@ -22,7 +22,7 @@ RUN pip install -r requirements.txt
|
||||
COPY . .
|
||||
|
||||
# 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 ----------
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
|
||||
88
app.py
88
app.py
@@ -3,12 +3,13 @@ from __future__ import annotations
|
||||
import path_setup # noqa: F401 # adds shared_lib to sys.path
|
||||
from pathlib import Path
|
||||
|
||||
from quart import g
|
||||
from quart import g, abort
|
||||
from jinja2 import FileSystemLoader, ChoiceLoader
|
||||
from sqlalchemy import select
|
||||
|
||||
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:
|
||||
@@ -40,6 +41,10 @@ async def events_context() -> dict:
|
||||
|
||||
|
||||
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-specific templates override shared templates
|
||||
@@ -49,12 +54,87 @@ def create_app() -> "Quart":
|
||||
app.jinja_loader,
|
||||
])
|
||||
|
||||
# Calendars blueprint at root — standalone mode (no post nesting)
|
||||
# Calendars nested under post slug: /<slug>/calendars/...
|
||||
app.register_blueprint(
|
||||
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
|
||||
from bp.tickets.routes import register as register_tickets
|
||||
app.register_blueprint(register_tickets())
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
from .calendars.routes import register as register_calendars
|
||||
from .markets.routes import register as register_markets
|
||||
from .payments.routes import register as register_payments
|
||||
|
||||
@@ -16,7 +16,7 @@ def register():
|
||||
# ---------- Pages ----------
|
||||
@bp.get("/")
|
||||
@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
|
||||
|
||||
# Determine which template to use based on request type
|
||||
@@ -32,7 +32,7 @@ def register():
|
||||
|
||||
@bp.get("/description/")
|
||||
@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
|
||||
html = await render_template(
|
||||
"_types/calendar/admin/_description_edit.html",
|
||||
@@ -45,7 +45,7 @@ def register():
|
||||
@bp.post("/description/")
|
||||
@require_admin
|
||||
@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
|
||||
description = (form.get("description") or "").strip() or None
|
||||
|
||||
@@ -64,7 +64,7 @@ def register():
|
||||
|
||||
@bp.get("/description/view/")
|
||||
@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)
|
||||
html = await render_template(
|
||||
"_types/calendar/admin/_description.html",
|
||||
|
||||
@@ -79,10 +79,14 @@ async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calend
|
||||
slug=slugify(name)
|
||||
|
||||
# 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:
|
||||
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)
|
||||
q = await sess.execute(
|
||||
select(Calendar).where(Calendar.post_id == post_id, Calendar.name == name)
|
||||
|
||||
0
bp/markets/__init__.py
Normal file
0
bp/markets/__init__.py
Normal file
68
bp/markets/routes.py
Normal file
68
bp/markets/routes.py
Normal 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
|
||||
0
bp/markets/services/__init__.py
Normal file
0
bp/markets/services/__init__.py
Normal file
85
bp/markets/services/markets.py
Normal file
85
bp/markets/services/markets.py
Normal 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
0
bp/payments/__init__.py
Normal file
81
bp/payments/routes.py
Normal file
81
bp/payments/routes.py
Normal 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
|
||||
Submodule shared_lib updated: 66edfca7dc...0c9b8d6aa2
@@ -1,8 +1,10 @@
|
||||
<!-- Desktop nav -->
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% call links.link(
|
||||
url_for('calendars.calendar.slots.get', calendar_slug=calendar.slug),
|
||||
hx_select_search,
|
||||
select_colours,
|
||||
True,
|
||||
aclass=styles.nav_button
|
||||
) %}
|
||||
<i class="fa fa-clock" aria-hidden="true"></i>
|
||||
@@ -12,4 +14,5 @@
|
||||
{% endcall %}
|
||||
{% if g.rights.admin %}
|
||||
{% from 'macros/admin_nav.html' import admin_nav_item %}
|
||||
{{ admin_nav_item(url_for('calendars.calendar.admin.admin', calendar_slug=calendar.slug)) }}
|
||||
{% endif %}
|
||||
@@ -1,6 +1,7 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% 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-row items-center gap-2">
|
||||
<i class="fa fa-calendar"></i>
|
||||
@@ -11,6 +12,7 @@
|
||||
{% from '_types/calendar/_description.html' import description %}
|
||||
{{description(calendar)}}
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% call links.desktop_nav() %}
|
||||
{% include '_types/calendar/_nav.html' %}
|
||||
{% endcall %}
|
||||
|
||||
@@ -4,12 +4,16 @@
|
||||
|
||||
{% block root_header_child %}
|
||||
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||
{% call index_row('calendar-header-child', '_types/calendar/header/_header.html') %}
|
||||
{% block calendar_header_child %}
|
||||
{% endblock %}
|
||||
{% 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('calendar-header-child', '_types/calendar/header/_header.html') %}
|
||||
{% block calendar_header_child %}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
{% block _main_mobile_menu %}
|
||||
{% include '_types/calendar/_nav.html' %}
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
|
||||
{% block root_header_child %}
|
||||
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||
{% call index_row('calendars-header-child', '_types/calendars/header/_header.html') %}
|
||||
{% block calendars_header_child %}
|
||||
{% endblock %}
|
||||
{% 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('calendars-header-child', '_types/calendars/header/_header.html') %}
|
||||
{% block calendars_header_child %}
|
||||
{% endblock %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
|
||||
{% call(entry_post) scrolling_menu('entry-posts-container', entry_posts) %}
|
||||
<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">
|
||||
{% if entry_post.feature_image %}
|
||||
<img src="{{ entry_post.feature_image }}"
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
|
||||
{% call(entry_post) scrolling_menu('entry-posts-container', entry_posts) %}
|
||||
<a
|
||||
href="{{ url_for('blog.post.post_detail', slug=entry_post.slug) }}"
|
||||
href="{{ coop_url('/' + entry_post.slug + '/') }}"
|
||||
class="{{styles.nav_button}}"
|
||||
>
|
||||
{% if entry_post.feature_image %}
|
||||
|
||||
25
templates/_types/markets/_main_panel.html
Normal file
25
templates/_types/markets/_main_panel.html
Normal 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>
|
||||
37
templates/_types/markets/_markets_list.html
Normal file
37
templates/_types/markets/_markets_list.html
Normal 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 %}
|
||||
2
templates/_types/markets/_nav.html
Normal file
2
templates/_types/markets/_nav.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||
{{ placeholder_nav() }}
|
||||
19
templates/_types/markets/_oob_elements.html
Normal file
19
templates/_types/markets/_oob_elements.html
Normal 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 %}
|
||||
14
templates/_types/markets/header/_header.html
Normal file
14
templates/_types/markets/header/_header.html
Normal 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 %}
|
||||
23
templates/_types/markets/index.html
Normal file
23
templates/_types/markets/index.html
Normal 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 %}
|
||||
70
templates/_types/payments/_main_panel.html
Normal file
70
templates/_types/payments/_main_panel.html
Normal 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>
|
||||
2
templates/_types/payments/_nav.html
Normal file
2
templates/_types/payments/_nav.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||
{{ placeholder_nav() }}
|
||||
19
templates/_types/payments/_oob_elements.html
Normal file
19
templates/_types/payments/_oob_elements.html
Normal 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 %}
|
||||
14
templates/_types/payments/header/_header.html
Normal file
14
templates/_types/payments/header/_header.html
Normal 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 %}
|
||||
23
templates/_types/payments/index.html
Normal file
23
templates/_types/payments/index.html
Normal 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 %}
|
||||
14
templates/_types/post/_nav.html
Normal file
14
templates/_types/post/_nav.html
Normal 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 %}
|
||||
36
templates/_types/post/admin/_nav.html
Normal file
36
templates/_types/post/admin/_nav.html
Normal 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>
|
||||
12
templates/_types/post/admin/header/_header.html
Normal file
12
templates/_types/post/admin/header/_header.html
Normal 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 %}
|
||||
@@ -1,7 +1,7 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% 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>
|
||||
<div>
|
||||
entries
|
||||
|
||||
Reference in New Issue
Block a user