diff --git a/events/sxc/pages/events.sx b/events/sxc/pages/events.sx index 6ad48b0..130e693 100644 --- a/events/sxc/pages/events.sx +++ b/events/sxc/pages/events.sx @@ -1,89 +1,235 @@ ;; Events pages — auto-mounted with absolute paths +;; All helpers return data dicts — markup composition in SX. ;; Calendar admin (defpage calendar-admin :path "///admin/" :auth :admin :layout :events-calendar-admin - :content (calendar-admin-content calendar-slug)) + :data (calendar-admin-data calendar-slug) + :content (~events-calendar-admin-panel + :description-content (~events-calendar-description-display + :description cal-description :edit-url desc-edit-url) + :csrf csrf :description cal-description)) ;; Day admin (defpage day-admin :path "///day////admin/" :auth :admin :layout :events-day-admin - :content (day-admin-content calendar-slug year month day)) + :data (day-admin-data calendar-slug year month day) + :content (~events-day-admin-panel)) ;; Slots listing (defpage slots-listing :path "///slots/" :auth :public :layout :events-slots - :content (slots-content calendar-slug)) + :data (slots-data calendar-slug) + :content (~events-slots-table + :list-container list-container + :rows (if has-slots + (<> (map (fn (s) + (~events-slots-row + :tr-cls tr-cls :slot-href (get s "slot-href") + :pill-cls pill-cls :hx-select hx-select + :slot-name (get s "name") :description (get s "description") + :flexible (get s "flexible") + :days (if (get s "has-days") + (~events-slot-days-pills :days-inner + (<> (map (fn (d) (~events-slot-day-pill :day d)) (get s "day-list")))) + (~events-slot-no-days)) + :time-str (get s "time-str") + :cost-str (get s "cost-str") :action-btn action-btn + :del-url (get s "del-url") + :csrf-hdr csrf-hdr)) + slots-list)) + (~events-slots-empty-row)) + :pre-action pre-action :add-url add-url)) ;; Slot detail (defpage slot-detail :path "///slots//" :auth :admin :layout :events-slot - :content (slot-content calendar-slug slot-id)) + :data (slot-data calendar-slug slot-id) + :content (~events-slot-panel + :slot-id slot-id-str + :list-container list-container + :days (if has-days + (~events-slot-days-pills :days-inner + (<> (map (fn (d) (~events-slot-day-pill :day d)) day-list))) + (~events-slot-no-days)) + :flexible flexible + :time-str time-str :cost-str cost-str + :pre-action pre-action :edit-url edit-url)) ;; Entry detail (defpage entry-detail :path "///day////entries//" :auth :admin :layout :events-entry - :content (entry-content calendar-slug entry-id) - :menu (entry-menu calendar-slug entry-id)) + :data (entry-data calendar-slug entry-id) + :content (~events-entry-panel + :entry-id entry-id-str :list-container list-container + :name (~events-entry-field :label "Name" + :content (~events-entry-name-field :name entry-name)) + :slot (~events-entry-field :label "Slot" + :content (if has-slot + (~events-entry-slot-assigned :slot-name slot-name :flex-label flex-label) + (~events-entry-slot-none))) + :time (~events-entry-field :label "Time Period" + :content (~events-entry-time-field :time-str time-str)) + :state (~events-entry-field :label "State" + :content (~events-entry-state-field :entry-id entry-id-str + :badge (~badge :cls state-badge-cls :label state-badge-label))) + :cost (~events-entry-field :label "Cost" + :content (~events-entry-cost-field :cost cost-str)) + :tickets (~events-entry-field :label "Tickets" + :content (~events-entry-tickets-field :entry-id entry-id-str + :tickets-config tickets-config)) + :buy buy-form + :date (~events-entry-field :label "Date" + :content (~events-entry-date-field :date-str date-str)) + :posts (~events-entry-field :label "Associated Posts" + :content (~events-entry-posts-field :entry-id entry-id-str + :posts-panel posts-panel)) + :options options-html + :pre-action pre-action :edit-url edit-url) + :menu entry-menu) ;; Entry admin (defpage entry-admin :path "///day////entries//admin/" :auth :admin :layout :events-entry-admin - :content (entry-admin-content calendar-slug entry-id) - :menu (admin-menu)) + :data (entry-admin-data calendar-slug entry-id year month day) + :content (~nav-link :href ticket-types-href :label "ticket_types" + :select-colours select-colours :aclass nav-btn :is-selected false) + :menu (~events-admin-placeholder-nav)) ;; Ticket types listing (defpage ticket-types-listing :path "///day////entries//ticket-types/" :auth :public :layout :events-ticket-types - :content (ticket-types-content calendar-slug entry-id year month day) - :menu (admin-menu)) + :data (ticket-types-data calendar-slug entry-id year month day) + :content (~events-ticket-types-table + :list-container list-container + :rows (if has-types + (<> (map (fn (tt) + (~events-ticket-types-row + :tr-cls tr-cls :tt-href (get tt "tt-href") + :pill-cls pill-cls :hx-select hx-select + :tt-name (get tt "tt-name") :cost-str (get tt "cost-str") + :count (get tt "count") :action-btn action-btn + :del-url (get tt "del-url") + :csrf-hdr csrf-hdr)) + types-list)) + (~events-ticket-types-empty-row)) + :action-btn action-btn :add-url add-url) + :menu (~events-admin-placeholder-nav)) ;; Ticket type detail (defpage ticket-type-detail :path "///day////entries//ticket-types//" :auth :admin :layout :events-ticket-type - :content (ticket-type-content calendar-slug entry-id ticket-type-id year month day) - :menu (admin-menu)) + :data (ticket-type-data calendar-slug entry-id ticket-type-id year month day) + :content (~events-ticket-type-panel + :ticket-id ticket-id :list-container list-container + :c1 (~events-ticket-type-col :label "Name" :value tt-name) + :c2 (~events-ticket-type-col :label "Cost" :value cost-str) + :c3 (~events-ticket-type-col :label "Count" :value count-str) + :pre-action pre-action :edit-url edit-url) + :menu (~events-admin-placeholder-nav)) ;; My tickets (defpage my-tickets :path "/tickets/" :auth :public :layout :root - :content (tickets-content)) + :data (tickets-data) + :content (~events-tickets-panel + :list-container list-container + :has-tickets has-tickets + :cards (when has-tickets + (<> (map (fn (t) + (~events-ticket-card + :href (get t "href") :entry-name (get t "entry-name") + :type-name (get t "type-name") :time-str (get t "time-str") + :cal-name (get t "cal-name") + :badge (~badge :cls (get t "badge-cls") :label (get t "badge-label")) + :code-prefix (get t "code-prefix"))) + tickets-list))))) ;; Ticket detail (defpage ticket-detail :path "/tickets//" :auth :public :layout :root - :content (ticket-detail-content code)) + :data (ticket-detail-data code) + :content (~events-ticket-detail + :list-container list-container :back-href back-href + :header-bg header-bg :entry-name entry-name + :badge (span :class (str "inline-flex items-center rounded-full px-3 py-1 text-sm font-medium " badge-cls) + badge-label) + :type-name type-name :code ticket-code + :time-date time-date :time-range time-range + :cal-name cal-name :type-desc type-desc :checkin-str checkin-str + :qr-script qr-script)) ;; Ticket admin dashboard (defpage ticket-admin :path "/admin/tickets/" :auth :admin :layout :root - :content (ticket-admin-content)) + :data (ticket-admin-data) + :content (~events-ticket-admin-panel + :list-container list-container + :stats (<> (map (fn (s) + (~events-ticket-admin-stat + :border (get s "border") :bg (get s "bg") + :text-cls (get s "text-cls") :label-cls (get s "label-cls") + :value (get s "value") :label (get s "label"))) + admin-stats)) + :lookup-url lookup-url :has-tickets has-tickets + :rows (when has-tickets + (<> (map (fn (t) + (~events-ticket-admin-row + :code (get t "code") :code-short (get t "code-short") + :entry-name (get t "entry-name") + :date (when (get t "date-str") + (~events-ticket-admin-date :date-str (get t "date-str"))) + :type-name (get t "type-name") + :badge (~badge :cls (get t "badge-cls") :label (get t "badge-label")) + :action (if (get t "can-checkin") + (~events-ticket-admin-checkin-form + :checkin-url (get t "checkin-url") :code (get t "code") :csrf csrf) + (when (get t "is-checked-in") + (~events-ticket-admin-checked-in :time-str (get t "checkin-time")))))) + admin-tickets))))) ;; Markets (defpage events-markets :path "//markets/" :auth :public :layout :events-markets - :content (markets-content)) + :data (markets-data) + :content (~crud-panel + :list-id "markets-list" + :form (when can-create + (~crud-create-form :create-url create-url :csrf csrf + :errors-id "market-create-errors" :list-id "markets-list" + :placeholder "e.g. Farm Shop, Bakery" :btn-label "Add market")) + :list (if markets-list + (<> (map (fn (m) + (~crud-item :href (get m "href") :name (get m "name") + :slug (get m "slug") :del-url (get m "del-url") + :csrf-hdr (get m "csrf-hdr") + :list-id "markets-list" + :confirm-title "Delete market?" + :confirm-text "Products will be hidden (soft delete)")) + markets-list)) + (~empty-state :message "No markets yet. Create one above." + :cls "text-gray-500 mt-4")))) diff --git a/events/sxc/pages/helpers.py b/events/sxc/pages/helpers.py index 2925ca8..be5137d 100644 --- a/events/sxc/pages/helpers.py +++ b/events/sxc/pages/helpers.py @@ -1,26 +1,13 @@ -"""Layout registrations, page helpers, and shared hydration helpers.""" +"""Layout registrations, page helpers, and shared hydration helpers. + +All helpers return data dicts — no sx_call(). +Markup composition lives entirely in .sx defpage and .sx defcomp files. +""" from __future__ import annotations from typing import Any -from shared.sx.helpers import sx_call - -from .calendar import ( - _calendar_admin_main_panel_html, - _day_admin_main_panel_html, - _markets_main_panel_html, -) -from .entries import ( - _entry_main_panel_html, - _entry_nav_html, - _entry_admin_main_panel_html, -) -from .tickets import ( - _tickets_main_panel_html, _ticket_detail_panel_html, - _ticket_admin_main_panel_html, - render_ticket_type_main_panel, render_ticket_types_table, -) -from .slots import render_slot_main_panel, render_slots_table +from shared.sx.parser import SxExpr # --------------------------------------------------------------------------- @@ -261,6 +248,60 @@ def _register_events_layouts() -> None: "events-markets-layout-full", "events-markets-layout-oob") +# --------------------------------------------------------------------------- +# Badge data helpers +# --------------------------------------------------------------------------- + +_ENTRY_STATE_CLASSES = { + "confirmed": "bg-emerald-100 text-emerald-800", + "provisional": "bg-amber-100 text-amber-800", + "ordered": "bg-sky-100 text-sky-800", + "pending": "bg-stone-100 text-stone-700", + "declined": "bg-red-100 text-red-800", +} + +_TICKET_STATE_CLASSES = { + "confirmed": "bg-emerald-100 text-emerald-800", + "checked_in": "bg-blue-100 text-blue-800", + "reserved": "bg-amber-100 text-amber-800", + "cancelled": "bg-red-100 text-red-800", +} + + +def _entry_badge_data(state: str) -> dict: + cls = _ENTRY_STATE_CLASSES.get(state, "bg-stone-100 text-stone-700") + label = state.replace("_", " ").capitalize() + return {"cls": cls, "label": label} + + +def _ticket_badge_data(state: str) -> dict: + cls = _TICKET_STATE_CLASSES.get(state, "bg-stone-100 text-stone-700") + label = (state or "").replace("_", " ").capitalize() + return {"cls": cls, "label": label} + + +# --------------------------------------------------------------------------- +# Styles helper +# --------------------------------------------------------------------------- + +def _styles_data() -> dict: + """Extract common style classes from g.styles.""" + from quart import g + styles = getattr(g, "styles", None) or {} + + def _gs(attr): + return getattr(styles, attr, "") if hasattr(styles, attr) else styles.get(attr, "") + + return { + "list-container": _gs("list_container"), + "pre-action": _gs("pre_action_button"), + "action-btn": _gs("action_button"), + "tr-cls": _gs("tr"), + "pill-cls": _gs("pill"), + "nav-btn": _gs("nav_button"), + } + + # --------------------------------------------------------------------------- # Page helpers # --------------------------------------------------------------------------- @@ -269,141 +310,468 @@ def _register_events_helpers() -> None: from shared.sx.pages import register_page_helpers register_page_helpers("events", { - "calendar-admin-content": _h_calendar_admin_content, - "day-admin-content": _h_day_admin_content, - "slots-content": _h_slots_content, - "slot-content": _h_slot_content, - "entry-content": _h_entry_content, - "entry-menu": _h_entry_menu, - "entry-admin-content": _h_entry_admin_content, - "admin-menu": _h_admin_menu, - "ticket-types-content": _h_ticket_types_content, - "ticket-type-content": _h_ticket_type_content, - "tickets-content": _h_tickets_content, - "ticket-detail-content": _h_ticket_detail_content, - "ticket-admin-content": _h_ticket_admin_content, - "markets-content": _h_markets_content, + "calendar-admin-data": _h_calendar_admin_data, + "day-admin-data": _h_day_admin_data, + "slots-data": _h_slots_data, + "slot-data": _h_slot_data, + "entry-data": _h_entry_data, + "entry-admin-data": _h_entry_admin_data, + "ticket-types-data": _h_ticket_types_data, + "ticket-type-data": _h_ticket_type_data, + "tickets-data": _h_tickets_data, + "ticket-detail-data": _h_ticket_detail_data, + "ticket-admin-data": _h_ticket_admin_data, + "markets-data": _h_markets_data, }) -async def _h_calendar_admin_content(calendar_slug=None, **kw): +# --------------------------------------------------------------------------- +# Calendar admin +# --------------------------------------------------------------------------- + +async def _h_calendar_admin_data(calendar_slug=None, **kw) -> dict: + from quart import url_for + from shared.browser.app.csrf import generate_csrf_token + await _ensure_calendar(calendar_slug) await _ensure_container_nav_defpage_ctx() - from shared.sx.page import get_template_context - ctx = await get_template_context() - return _calendar_admin_main_panel_html(ctx) + + from quart import g + calendar = getattr(g, "calendar", None) + if not calendar: + return {} + + csrf = generate_csrf_token() + cal_slug = getattr(calendar, "slug", "") + desc = getattr(calendar, "description", "") or "" + desc_edit_url = url_for("calendar.admin.calendar_description_edit", + calendar_slug=cal_slug) + + return { + "cal-description": desc, + "csrf": csrf, + "desc-edit-url": desc_edit_url, + } -async def _h_day_admin_content(calendar_slug=None, year=None, month=None, day=None, **kw): +# --------------------------------------------------------------------------- +# Day admin +# --------------------------------------------------------------------------- + +async def _h_day_admin_data(calendar_slug=None, year=None, month=None, + day=None, **kw) -> dict: await _ensure_calendar(calendar_slug) await _ensure_container_nav_defpage_ctx() if year is not None: await _ensure_day_data(int(year), int(month), int(day)) - return _day_admin_main_panel_html({}) + return {} -async def _h_slots_content(calendar_slug=None, **kw): - from quart import g +# --------------------------------------------------------------------------- +# Slots listing +# --------------------------------------------------------------------------- + +async def _h_slots_data(calendar_slug=None, **kw) -> dict: + from quart import g, url_for + from shared.browser.app.csrf import generate_csrf_token + from bp.slots.services.slots import list_slots as svc_list_slots + await _ensure_calendar(calendar_slug) await _ensure_container_nav_defpage_ctx() + calendar = getattr(g, "calendar", None) - from bp.slots.services.slots import list_slots as svc_list_slots slots = await svc_list_slots(g.s, calendar.id) if calendar else [] _add_to_defpage_ctx(slots=slots) - return render_slots_table(slots, calendar) + + styles = _styles_data() + csrf = generate_csrf_token() + cal_slug = getattr(calendar, "slug", "") + hx_select = getattr(g, "hx_select_search", "#main-panel") + csrf_hdr = f'{{"X-CSRFToken": "{csrf}"}}' + add_url = url_for("calendar.slots.add_form", calendar_slug=cal_slug) + + slots_list = [] + for s in slots: + slot_href = url_for("defpage_slot_detail", + calendar_slug=cal_slug, slot_id=s.id) + del_url = url_for("calendar.slots.slot.slot_delete", + calendar_slug=cal_slug, slot_id=s.id) + desc = getattr(s, "description", "") or "" + days_display = getattr(s, "days_display", "\u2014") + day_list = days_display.split(", ") + has_days = bool(day_list and day_list[0] != "\u2014") + time_start = s.time_start.strftime("%H:%M") if s.time_start else "" + time_end = s.time_end.strftime("%H:%M") if s.time_end else "" + cost = getattr(s, "cost", None) + cost_str = f"{cost:.2f}" if cost is not None else "" + + slots_list.append({ + "name": s.name, + "description": desc, + "day-list": day_list if has_days else [], + "has-days": has_days, + "flexible": "yes" if s.flexible else "no", + "time-str": f"{time_start} - {time_end}", + "cost-str": cost_str, + "slot-href": slot_href, + "del-url": del_url, + }) + + return { + "has-slots": bool(slots), + "slots-list": slots_list, + "add-url": add_url, + "csrf-hdr": csrf_hdr, + "hx-select": hx_select, + **styles, + } -async def _h_slot_content(calendar_slug=None, slot_id=None, **kw): - from quart import g, abort +# --------------------------------------------------------------------------- +# Slot detail +# --------------------------------------------------------------------------- + +async def _h_slot_data(calendar_slug=None, slot_id=None, **kw) -> dict: + from quart import g, abort, url_for + await _ensure_calendar(calendar_slug) await _ensure_container_nav_defpage_ctx() + from bp.slot.services.slot import get_slot as svc_get_slot slot = await svc_get_slot(g.s, slot_id) if slot_id else None if not slot: abort(404) g.slot = slot _add_to_defpage_ctx(slot=slot) + calendar = getattr(g, "calendar", None) - return render_slot_main_panel(slot, calendar) + styles = _styles_data() + cal_slug = getattr(calendar, "slug", "") + + days_display = getattr(slot, "days_display", "\u2014") + day_list = days_display.split(", ") + has_days = bool(day_list and day_list[0] != "\u2014") + time_start = slot.time_start.strftime("%H:%M") if slot.time_start else "" + time_end = slot.time_end.strftime("%H:%M") if slot.time_end else "" + cost = getattr(slot, "cost", None) + cost_str = f"{cost:.2f}" if cost is not None else "" + edit_url = url_for("calendar.slots.slot.get_edit", + slot_id=slot.id, calendar_slug=cal_slug) + + return { + "slot-id-str": str(slot.id), + "day-list": day_list if has_days else [], + "has-days": has_days, + "flexible": "yes" if getattr(slot, "flexible", False) else "no", + "time-str": f"{time_start} \u2014 {time_end}", + "cost-str": cost_str, + "edit-url": edit_url, + **styles, + } -async def _h_entry_content(calendar_slug=None, entry_id=None, **kw): +# --------------------------------------------------------------------------- +# Entry detail (complex — sub-panels returned as SxExpr) +# --------------------------------------------------------------------------- + +async def _h_entry_data(calendar_slug=None, entry_id=None, **kw) -> dict: + from quart import url_for, g + from .entries import ( + _entry_nav_html, + _entry_options_html, + render_entry_tickets_config, + render_entry_posts_panel, + ) + from .tickets import render_buy_form + await _ensure_calendar(calendar_slug) await _ensure_entry_context(entry_id) + from shared.sx.page import get_template_context ctx = await get_template_context() - return _entry_main_panel_html(ctx) + + entry = ctx.get("entry") + if not entry: + return {} + + calendar = ctx.get("calendar") + cal_slug = getattr(calendar, "slug", "") if calendar else "" + day = ctx.get("day") + month = ctx.get("month") + year = ctx.get("year") + + styles = _styles_data() + eid = entry.id + state = getattr(entry, "state", "pending") or "pending" + + # Simple field data + slot = getattr(entry, "slot", None) + has_slot = slot is not None + slot_name = slot.name if slot else "" + flex_label = "(flexible)" if slot and getattr(slot, "flexible", False) else "(fixed)" + start_str = entry.start_at.strftime("%H:%M") if entry.start_at else "" + end_str = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else " \u2013 open-ended" + cost = getattr(entry, "cost", None) + cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00" + date_str = entry.start_at.strftime("%A, %B %d, %Y") if entry.start_at else "" + badge = _entry_badge_data(state) + + edit_url = url_for( + "calendar.day.calendar_entries.calendar_entry.get_edit", + entry_id=eid, calendar_slug=cal_slug, + day=day, month=month, year=year, + ) + + # Complex sub-panels (pre-composed as SxExpr) + ticket_remaining = ctx.get("ticket_remaining") + ticket_sold_count = ctx.get("ticket_sold_count", 0) + user_ticket_count = ctx.get("user_ticket_count", 0) + user_ticket_counts_by_type = ctx.get("user_ticket_counts_by_type") or {} + entry_posts = ctx.get("entry_posts") or [] + + tickets_config = render_entry_tickets_config(entry, calendar, day, month, year) + buy_form = render_buy_form( + entry, ticket_remaining, ticket_sold_count, + user_ticket_count, user_ticket_counts_by_type, + ) + posts_panel = render_entry_posts_panel( + entry_posts, entry, calendar, day, month, year, + ) + options_html = _entry_options_html(entry, calendar, day, month, year) + + # Entry menu (pre-composed for :menu slot) + entry_menu = _entry_nav_html(ctx) + + return { + "entry-id-str": str(eid), + "entry-name": entry.name, + "has-slot": has_slot, + "slot-name": slot_name, + "flex-label": flex_label, + "time-str": start_str + end_str, + "state-badge-cls": badge["cls"], + "state-badge-label": badge["label"], + "cost-str": cost_str, + "date-str": date_str, + "edit-url": edit_url, + "tickets-config": SxExpr(tickets_config), + "buy-form": SxExpr(buy_form) if buy_form else None, + "posts-panel": SxExpr(posts_panel), + "options-html": SxExpr(options_html), + "entry-menu": SxExpr(entry_menu) if entry_menu else None, + **styles, + } -async def _h_entry_menu(calendar_slug=None, entry_id=None, **kw): - await _ensure_calendar(calendar_slug) - await _ensure_entry_context(entry_id) - from shared.sx.page import get_template_context - ctx = await get_template_context() - return _entry_nav_html(ctx) +# --------------------------------------------------------------------------- +# Entry admin +# --------------------------------------------------------------------------- +async def _h_entry_admin_data(calendar_slug=None, entry_id=None, + year=None, month=None, day=None, **kw) -> dict: + from quart import url_for, g -async def _h_entry_admin_content(calendar_slug=None, entry_id=None, **kw): await _ensure_calendar(calendar_slug) await _ensure_container_nav_defpage_ctx() await _ensure_entry_context(entry_id) + + calendar = getattr(g, "calendar", None) + entry = getattr(g, "entry", None) + if not calendar or not entry: + return {} + + cal_slug = getattr(calendar, "slug", "") + styles = _styles_data() + from shared.sx.page import get_template_context ctx = await get_template_context() - return _entry_admin_main_panel_html(ctx) + select_colours = ctx.get("select_colours", "") + + ticket_types_href = url_for( + "calendar.day.calendar_entries.calendar_entry.ticket_types.get", + calendar_slug=cal_slug, entry_id=entry.id, + year=year, month=month, day=day, + ) + + return { + "ticket-types-href": ticket_types_href, + "select-colours": select_colours, + **styles, + } -def _h_admin_menu(): - return sx_call("events-admin-placeholder-nav") +# --------------------------------------------------------------------------- +# Ticket types listing +# --------------------------------------------------------------------------- +async def _h_ticket_types_data(calendar_slug=None, entry_id=None, + year=None, month=None, day=None, **kw) -> dict: + from quart import g, url_for + from shared.browser.app.csrf import generate_csrf_token -async def _h_ticket_types_content(calendar_slug=None, entry_id=None, - year=None, month=None, day=None, **kw): - from quart import g await _ensure_calendar(calendar_slug) await _ensure_entry(entry_id) + entry = getattr(g, "entry", None) calendar = getattr(g, "calendar", None) + from bp.ticket_types.services.tickets import list_ticket_types as svc_list_ticket_types ticket_types = await svc_list_ticket_types(g.s, entry.id) if entry else [] _add_to_defpage_ctx(ticket_types=ticket_types) - return render_ticket_types_table(ticket_types, entry, calendar, day, month, year) + + styles = _styles_data() + csrf = generate_csrf_token() + cal_slug = getattr(calendar, "slug", "") + hx_select = getattr(g, "hx_select_search", "#main-panel") + eid = entry.id if entry else 0 + csrf_hdr = f'{{"X-CSRFToken": "{csrf}"}}' + + types_list = [] + for tt in (ticket_types or []): + tt_href = url_for( + "calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get", + calendar_slug=cal_slug, year=year, month=month, day=day, + entry_id=eid, ticket_type_id=tt.id, + ) + del_url = url_for( + "calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.delete", + calendar_slug=cal_slug, year=year, month=month, day=day, + entry_id=eid, ticket_type_id=tt.id, + ) + cost = getattr(tt, "cost", None) + cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00" + + types_list.append({ + "tt-href": tt_href, + "tt-name": tt.name, + "cost-str": cost_str, + "count": str(tt.count), + "del-url": del_url, + }) + + add_url = url_for( + "calendar.day.calendar_entries.calendar_entry.ticket_types.add_form", + calendar_slug=cal_slug, entry_id=eid, year=year, month=month, day=day, + ) + + return { + "has-types": bool(ticket_types), + "types-list": types_list, + "add-url": add_url, + "csrf-hdr": csrf_hdr, + "hx-select": hx_select, + **styles, + } -async def _h_ticket_type_content(calendar_slug=None, entry_id=None, - ticket_type_id=None, year=None, month=None, day=None, **kw): - from quart import g, abort +# --------------------------------------------------------------------------- +# Ticket type detail +# --------------------------------------------------------------------------- + +async def _h_ticket_type_data(calendar_slug=None, entry_id=None, + ticket_type_id=None, + year=None, month=None, day=None, **kw) -> dict: + from quart import g, abort, url_for + await _ensure_calendar(calendar_slug) await _ensure_entry(entry_id) + from bp.ticket_type.services.ticket import get_ticket_type as svc_get_ticket_type ticket_type = await svc_get_ticket_type(g.s, ticket_type_id) if ticket_type_id else None if not ticket_type: abort(404) g.ticket_type = ticket_type _add_to_defpage_ctx(ticket_type=ticket_type) + entry = getattr(g, "entry", None) calendar = getattr(g, "calendar", None) - return render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year) + styles = _styles_data() + cal_slug = getattr(calendar, "slug", "") + cost = getattr(ticket_type, "cost", None) + cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00" + count = getattr(ticket_type, "count", 0) + + edit_url = url_for( + "calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get_edit", + ticket_type_id=ticket_type.id, calendar_slug=cal_slug, + year=year, month=month, day=day, + entry_id=entry.id if entry else 0, + ) + + return { + "ticket-id": str(ticket_type.id), + "tt-name": ticket_type.name, + "cost-str": cost_str, + "count-str": str(count), + "edit-url": edit_url, + **styles, + } -async def _h_tickets_content(**kw): - from quart import g +# --------------------------------------------------------------------------- +# My tickets +# --------------------------------------------------------------------------- + +async def _h_tickets_data(**kw) -> dict: + from quart import g, url_for from shared.infrastructure.cart_identity import current_cart_identity from bp.tickets.services.tickets import get_user_tickets + ident = current_cart_identity() tickets = await get_user_tickets( g.s, user_id=ident["user_id"], session_id=ident["session_id"], ) + from shared.sx.page import get_template_context ctx = await get_template_context() - return _tickets_main_panel_html(ctx, tickets) + styles = ctx.get("styles") or {} + list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "") + + tickets_list = [] + for ticket in (tickets or []): + href = url_for("defpage_ticket_detail", code=ticket.code) + entry = getattr(ticket, "entry", None) + entry_name = entry.name if entry else "Unknown event" + tt = getattr(ticket, "ticket_type", None) + state = getattr(ticket, "state", "") + cal = getattr(entry, "calendar", None) if entry else None + + time_str = "" + if entry and entry.start_at: + time_str = entry.start_at.strftime("%A, %B %d, %Y at %H:%M") + if entry.end_at: + time_str += f" \u2013 {entry.end_at.strftime('%H:%M')}" + + badge = _ticket_badge_data(state) + tickets_list.append({ + "href": href, + "entry-name": entry_name, + "type-name": tt.name if tt else None, + "time-str": time_str or None, + "cal-name": cal.name if cal else None, + "badge-cls": badge["cls"], + "badge-label": badge["label"], + "code-prefix": ticket.code[:8], + }) + + return { + "has-tickets": bool(tickets), + "tickets-list": tickets_list, + "list-container": list_container, + } -async def _h_ticket_detail_content(code=None, **kw): - from quart import g, abort +# --------------------------------------------------------------------------- +# Ticket detail +# --------------------------------------------------------------------------- + +async def _h_ticket_detail_data(code=None, **kw) -> dict: + from quart import g, abort, url_for from shared.infrastructure.cart_identity import current_cart_identity from bp.tickets.services.tickets import get_ticket_by_code + ticket = await get_ticket_by_code(g.s, code) if code else None if not ticket: abort(404) @@ -417,16 +785,71 @@ async def _h_ticket_detail_content(code=None, **kw): abort(404) else: abort(404) + from shared.sx.page import get_template_context ctx = await get_template_context() - return _ticket_detail_panel_html(ctx, ticket) + styles = ctx.get("styles") or {} + list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "") + + entry = getattr(ticket, "entry", None) + tt = getattr(ticket, "ticket_type", None) + state = getattr(ticket, "state", "") + ticket_code = ticket.code + cal = getattr(entry, "calendar", None) if entry else None + checked_in_at = getattr(ticket, "checked_in_at", None) + + bg_map = {"confirmed": "bg-emerald-50", "checked_in": "bg-blue-50", + "reserved": "bg-amber-50"} + header_bg = bg_map.get(state, "bg-stone-50") + entry_name = entry.name if entry else "Ticket" + back_href = url_for("defpage_my_tickets") + + badge = _ticket_badge_data(state) + + time_date = entry.start_at.strftime("%A, %B %d, %Y") if entry and entry.start_at else None + time_range = entry.start_at.strftime("%H:%M") if entry and entry.start_at else None + if time_range and entry.end_at: + time_range += f" \u2013 {entry.end_at.strftime('%H:%M')}" + + tt_desc = f"{tt.name} \u2014 \u00a3{tt.cost:.2f}" if tt and getattr(tt, "cost", None) else None + checkin_str = checked_in_at.strftime("Checked in: %B %d, %Y at %H:%M") if checked_in_at else None + + qr_script = ( + f"(function(){{var c=document.getElementById('ticket-qr-{ticket_code}');" + "if(c&&typeof QRCode!=='undefined'){" + "var cv=document.createElement('canvas');" + f"QRCode.toCanvas(cv,'{ticket_code}',{{width:200,margin:2,color:{{dark:'#1c1917',light:'#ffffff'}}}},function(e){{if(!e)c.appendChild(cv)}});" + "}})()" + ) + + return { + "list-container": list_container, + "back-href": back_href, + "header-bg": header_bg, + "entry-name": entry_name, + "badge-cls": badge["cls"], + "badge-label": badge["label"], + "type-name": tt.name if tt else None, + "ticket-code": ticket_code, + "time-date": time_date, + "time-range": time_range, + "cal-name": cal.name if cal else None, + "type-desc": tt_desc, + "checkin-str": checkin_str, + "qr-script": qr_script, + } -async def _h_ticket_admin_content(**kw): - from quart import g +# --------------------------------------------------------------------------- +# Ticket admin dashboard +# --------------------------------------------------------------------------- + +async def _h_ticket_admin_data(**kw) -> dict: + from quart import g, url_for from sqlalchemy import select, func from sqlalchemy.orm import selectinload from models.calendars import CalendarEntry, Ticket + from shared.browser.app.csrf import generate_csrf_token result = await g.s.execute( select(Ticket) @@ -449,20 +872,118 @@ async def _h_ticket_admin_content(**kw): reserved = await g.s.scalar( select(func.count(Ticket.id)).where(Ticket.state == "reserved") ) - stats = { - "total": total or 0, - "confirmed": confirmed or 0, - "checked_in": checked_in or 0, - "reserved": reserved or 0, + + csrf = generate_csrf_token() + lookup_url = url_for("ticket_admin.lookup") + + from shared.sx.page import get_template_context + ctx = await get_template_context() + styles = ctx.get("styles") or {} + list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "") + + # Stats cards data + admin_stats = [] + for label, key, border, bg, text_cls in [ + ("Total", "total", "border-stone-200", "", "text-stone-900"), + ("Confirmed", "confirmed", "border-emerald-200", "bg-emerald-50", "text-emerald-700"), + ("Checked In", "checked_in", "border-blue-200", "bg-blue-50", "text-blue-700"), + ("Reserved", "reserved", "border-amber-200", "bg-amber-50", "text-amber-700"), + ]: + val_map = {"total": total, "confirmed": confirmed, + "checked_in": checked_in, "reserved": reserved} + val = val_map.get(key, 0) or 0 + lbl_cls = text_cls.replace("700", "600").replace("900", "500") if "stone" not in text_cls else "text-stone-500" + admin_stats.append({ + "border": border, "bg": bg, "text-cls": text_cls, + "label-cls": lbl_cls, "value": str(val), "label": label, + }) + + # Ticket rows data + admin_tickets = [] + for ticket in tickets: + entry = getattr(ticket, "entry", None) + tt = getattr(ticket, "ticket_type", None) + state = getattr(ticket, "state", "") + tcode = ticket.code + checked_in_at = getattr(ticket, "checked_in_at", None) + + date_str = None + if entry and entry.start_at: + date_str = entry.start_at.strftime("%d %b %Y, %H:%M") + + badge = _ticket_badge_data(state) + can_checkin = state in ("confirmed", "reserved") + is_checked_in = state == "checked_in" + checkin_url = url_for("ticket_admin.do_checkin", code=tcode) if can_checkin else None + checkin_time = checked_in_at.strftime("%H:%M") if checked_in_at else "" + + admin_tickets.append({ + "code": tcode, + "code-short": tcode[:12] + "...", + "entry-name": entry.name if entry else "\u2014", + "date-str": date_str, + "type-name": tt.name if tt else "\u2014", + "badge-cls": badge["cls"], + "badge-label": badge["label"], + "can-checkin": can_checkin, + "is-checked-in": is_checked_in, + "checkin-url": checkin_url, + "checkin-time": checkin_time, + }) + + return { + "admin-stats": admin_stats, + "admin-tickets": admin_tickets, + "list-container": list_container, + "lookup-url": lookup_url, + "csrf": csrf, + "has-tickets": bool(tickets), } - from shared.sx.page import get_template_context - ctx = await get_template_context() - return _ticket_admin_main_panel_html(ctx, tickets, stats) +# --------------------------------------------------------------------------- +# Markets +# --------------------------------------------------------------------------- + +async def _h_markets_data(**kw) -> dict: + from quart import url_for + from shared.browser.app.csrf import generate_csrf_token + from shared.sx.helpers import call_url -async def _h_markets_content(**kw): _ensure_post_defpage_ctx() + from shared.sx.page import get_template_context ctx = await get_template_context() - return _markets_main_panel_html(ctx) + + rights = ctx.get("rights") or {} + is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False) + has_access = ctx.get("has_access") + can_create = has_access("markets.create_market") if callable(has_access) else is_admin + csrf = generate_csrf_token() + markets_raw = ctx.get("markets") or [] + + post = ctx.get("post") or {} + slug = post.get("slug", "") + csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}' + + markets_list = [] + for m in markets_raw: + m_slug = getattr(m, "slug", "") if hasattr(m, "slug") else m.get("slug", "") + m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "") + market_href = call_url(ctx, "market_url", f"/{slug}/{m_slug}/") + del_url = url_for("markets.delete_market", market_slug=m_slug) + + markets_list.append({ + "href": market_href, + "name": m_name, + "slug": m_slug, + "del-url": del_url, + "csrf-hdr": csrf_hdr, + }) + + return { + "can-create": can_create, + "create-url": url_for("markets.create_market") if can_create else None, + "csrf": csrf, + "markets-list": markets_list, + }